Skip to content

Commit eac5686

Browse files
authored
Merge pull request #9 from O-Labz/memory-improvements
Memory improvements
2 parents 5269413 + 5e3f480 commit eac5686

32 files changed

Lines changed: 3282 additions & 199 deletions

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,13 @@ ENV NODE_ENV=production
7070
ENV DATA_DIR=/data
7171
ENV WORKSPACE_ROOT=/workspace
7272
ENV MOUNT_ROOT=/host
73-
ENV NODE_OPTIONS=--max-old-space-size=8192
73+
ENV NODE_OPTIONS="--max-old-space-size=2560 --expose-gc"
7474

7575
# Expose port
7676
EXPOSE 3001
7777

7878
# Health check
79-
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
79+
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
8080
CMD curl -f http://localhost:3001/api/health || exit 1
8181

8282
# Volume mounts

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ Want no AI in the loop at all? Start it with `-e LLM_PROVIDER=none`. You lose se
109109

110110
The engineering memory layer is on by default. Turn it off with `-e EML_ENABLED=false`.
111111

112+
## Tuning
113+
114+
Control resource usage with:
115+
116+
```bash
117+
-e PARSE_WORKER_POOL_SIZE=2 # Parse workers (set 0 to disable)
118+
-e INDEX_MAX_CONCURRENT_JOBS=1 # Concurrent indexing jobs
119+
-e GRAPH_HOT_CACHE_MB=256 # Graph cache size
120+
```
121+
122+
For container limits, use `docker run --memory=4g --cpus=4`.
123+
112124
## Good to know
113125

114126
- Mounting your home directory at `/host` lets you switch between projects from the dashboard without restarting the container.

docker-compose.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ services:
33
image: context-simplo:latest
44
build: .
55
container_name: context-simplo
6+
mem_limit: ${SIMPLO_MEM_LIMIT:-4g}
7+
cpus: ${SIMPLO_CPUS:-4}
68
ports:
79
- "3001:3001" # Web dashboard + API + MCP Streamable HTTP endpoint
810
volumes:
@@ -19,13 +21,28 @@ services:
1921
- CONTEXT_SIMPLO_WATCH=true
2022
- CONTEXT_SIMPLO_LOG_LEVEL=info
2123
- CONTEXT_SIMPLO_RESPONSE_MODE=${CONTEXT_SIMPLO_RESPONSE_MODE:-compact}
22-
- NODE_OPTIONS=--max-old-space-size=8192
24+
- NODE_OPTIONS=--max-old-space-size=${NODE_HEAP_MB:-2560} --expose-gc
25+
- PARSE_WORKER_POOL_SIZE=${PARSE_WORKER_POOL_SIZE:-2}
26+
- PARSE_WORKER_RECYCLE_AFTER=${PARSE_WORKER_RECYCLE_AFTER:-200}
27+
- WORKER_HEAP_MB=${WORKER_HEAP_MB:-512}
28+
- INDEX_MAX_CONCURRENT_JOBS=${INDEX_MAX_CONCURRENT_JOBS:-1}
29+
- INDEX_QUEUE_MAX_DEPTH=${INDEX_QUEUE_MAX_DEPTH:-16}
30+
- GRAPH_HOT_CACHE_MB=${GRAPH_HOT_CACHE_MB:-256}
31+
- GRAPH_MEMORY_SOFT_PCT=${GRAPH_MEMORY_SOFT_PCT:-75}
32+
- GRAPH_MEMORY_HARD_PCT=${GRAPH_MEMORY_HARD_PCT:-90}
33+
- GRAPH_MAX_NODES=${GRAPH_MAX_NODES:-2000000}
2334
# Graph engine in-memory budget. Code hard-caps this at 4096MB (see src/core/graph.ts).
2435
# - GRAPH_MEMORY_LIMIT_MB=${GRAPH_MEMORY_LIMIT_MB:-4096}
2536
# Mount root for browsing (defaults to /host for dynamic workspace switching)
2637
- MOUNT_ROOT=${MOUNT_ROOT:-/host}
2738
# Initial workspace within the mount (defaults to /workspace for backward compat)
2839
- INITIAL_WORKSPACE=${INITIAL_WORKSPACE:-/workspace}
40+
healthcheck:
41+
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
42+
interval: 30s
43+
timeout: 10s
44+
retries: 3
45+
start_period: 120s
2946
extra_hosts:
3047
- "host.docker.internal:host-gateway" # Required for Linux, harmless on macOS/Windows
3148
stdin_open: true

src/api/routes/graph.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { FastifyInstance } from 'fastify';
1414
import { z } from 'zod';
1515
import DirectedGraph from 'graphology';
1616
import forceAtlas2 from 'graphology-layout-forceatlas2';
17-
import type { CodeGraph } from '../../core/graph.js';
17+
import type { CodeGraphApi } from '../../core/graph.js';
1818

1919
const GraphQuerySchema = z.object({
2020
maxNodes: z.number().int().min(1).max(10000).default(1000),
@@ -23,7 +23,7 @@ const GraphQuerySchema = z.object({
2323
});
2424

2525
export interface GraphRouteOptions {
26-
graph: CodeGraph;
26+
graph: CodeGraphApi;
2727
}
2828

2929
/**

src/api/routes/metrics.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@
1717

1818
import type { FastifyInstance } from 'fastify';
1919
import type { StorageProvider } from '../../store/provider.js';
20-
import type { CodeGraph } from '../../core/graph.js';
20+
import type { CodeGraphApi } from '../../core/graph.js';
2121

2222
export interface MetricsRouteOptions {
2323
storage: StorageProvider;
24-
graph: CodeGraph;
24+
graph: CodeGraphApi;
2525
watcher?: any;
2626
embeddingQueue?: any;
2727
vectorStore?: any;

src/api/routes/repositories.ts

Lines changed: 109 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import type { FastifyInstance } from 'fastify';
1818
import { z } from 'zod';
1919
import path from 'node:path';
2020
import type { StorageProvider } from '../../store/provider.js';
21-
import type { CodeGraph } from '../../core/graph.js';
21+
import { IndexQueueFullError, MemoryPressureError } from '../../core/errors.js';
22+
import type { CodeGraphApi } from '../../core/graph.js';
2223
import type { WebSocketBroadcaster } from '../websocket.js';
2324
import { WebSocketEvents } from '../websocket.js';
2425
import { isSubpath } from '../../core/path-utils.js';
@@ -30,12 +31,13 @@ const IndexRepositorySchema = z.object({
3031

3132
export interface RepositoryRouteOptions {
3233
storage: StorageProvider;
33-
graph: CodeGraph;
34+
graph: CodeGraphApi;
3435
broadcaster: WebSocketBroadcaster;
3536
workspaceRoot: string;
3637
indexer?: any;
3738
watcher?: any;
3839
vectorStore?: any;
40+
indexQueue?: any;
3941
}
4042

4143
/**
@@ -115,34 +117,69 @@ export async function registerRepositoryRoutes(
115117
status: 'starting',
116118
});
117119

118-
// Run indexing in background
119-
setImmediate(async () => {
120-
try {
121-
const job = await options.indexer.indexRepository(absolutePath, {
120+
// Run indexing in background, but handle queue admission errors immediately
121+
try {
122+
const indexingPromise = options.indexQueue?.run(() =>
123+
options.indexer.indexRepository(absolutePath, {
122124
incremental: input.incremental,
123125
respectIgnore: true,
124-
});
126+
})
127+
) ?? options.indexer.indexRepository(absolutePath, {
128+
incremental: input.incremental,
129+
respectIgnore: true,
130+
});
125131

126-
options.broadcaster.broadcast(WebSocketEvents.INDEX_COMPLETE, {
127-
repositoryId: job.repositoryId,
128-
path: input.path,
129-
filesProcessed: job.filesProcessed,
130-
nodesCreated: job.nodesCreated,
131-
edgesCreated: job.edgesCreated,
132-
});
133-
} catch (error) {
134-
options.broadcaster.broadcast(WebSocketEvents.INDEX_ERROR, {
135-
path: input.path,
136-
error: error instanceof Error ? error.message : 'Unknown error',
137-
});
138-
}
139-
});
132+
// Handle completion in background
133+
setImmediate(async () => {
134+
try {
135+
const job = await indexingPromise;
136+
options.broadcaster.broadcast(WebSocketEvents.INDEX_COMPLETE, {
137+
repositoryId: job.repositoryId,
138+
path: input.path,
139+
filesProcessed: job.filesProcessed,
140+
nodesCreated: job.nodesCreated,
141+
edgesCreated: job.edgesCreated,
142+
});
143+
} catch (error) {
144+
options.broadcaster.broadcast(WebSocketEvents.INDEX_ERROR, {
145+
path: input.path,
146+
error: error instanceof Error ? error.message : 'Unknown error',
147+
});
148+
}
149+
});
140150

141-
return {
142-
success: true,
143-
path: input.path,
144-
message: 'Indexing started',
145-
};
151+
return {
152+
success: true,
153+
path: input.path,
154+
message: 'Indexing started',
155+
};
156+
} catch (error) {
157+
if (error instanceof IndexQueueFullError) {
158+
return reply
159+
.status(429)
160+
.header('Retry-After', String(error.retryAfterSeconds))
161+
.send({
162+
error: {
163+
code: error.code,
164+
message: 'indexing busy, retry later',
165+
retryAfterSeconds: error.retryAfterSeconds,
166+
},
167+
});
168+
}
169+
if (error instanceof MemoryPressureError) {
170+
return reply
171+
.status(503)
172+
.header('Retry-After', String(error.retryAfterSeconds))
173+
.send({
174+
error: {
175+
code: error.code,
176+
message: 'server under memory pressure, retry later',
177+
retryAfterSeconds: error.retryAfterSeconds,
178+
},
179+
});
180+
}
181+
throw error; // Re-throw other errors to be handled by outer catch
182+
}
146183
} catch (error) {
147184
return reply.status(500).send({
148185
error: 'Indexing failed',
@@ -247,13 +284,22 @@ export async function registerRepositoryRoutes(
247284
status: 'reindexing',
248285
});
249286

250-
// Run re-indexing in background
251-
setImmediate(async () => {
252-
try {
253-
const job = await options.indexer.indexRepository(repo.path, {
287+
// Run re-indexing in background, but handle queue admission errors immediately
288+
try {
289+
const indexingPromise = options.indexQueue?.run(() =>
290+
options.indexer.indexRepository(repo.path, {
254291
incremental: false,
255292
respectIgnore: true,
256-
});
293+
})
294+
) ?? options.indexer.indexRepository(repo.path, {
295+
incremental: false,
296+
respectIgnore: true,
297+
});
298+
299+
// Handle completion in background
300+
setImmediate(async () => {
301+
try {
302+
const job = await indexingPromise;
257303

258304
options.broadcaster.broadcast(WebSocketEvents.INDEX_COMPLETE, {
259305
repositoryId: job.repositoryId,
@@ -270,11 +316,38 @@ export async function registerRepositoryRoutes(
270316
}
271317
});
272318

273-
return {
274-
success: true,
275-
repositoryId: id,
276-
message: 'Re-indexing started',
277-
};
319+
return {
320+
success: true,
321+
repositoryId: id,
322+
message: 'Re-indexing started',
323+
};
324+
} catch (error) {
325+
if (error instanceof IndexQueueFullError) {
326+
return reply
327+
.status(429)
328+
.header('Retry-After', String(error.retryAfterSeconds))
329+
.send({
330+
error: {
331+
code: error.code,
332+
message: 'indexing busy, retry later',
333+
retryAfterSeconds: error.retryAfterSeconds,
334+
},
335+
});
336+
}
337+
if (error instanceof MemoryPressureError) {
338+
return reply
339+
.status(503)
340+
.header('Retry-After', String(error.retryAfterSeconds))
341+
.send({
342+
error: {
343+
code: error.code,
344+
message: 'server under memory pressure, retry later',
345+
retryAfterSeconds: error.retryAfterSeconds,
346+
},
347+
});
348+
}
349+
throw error; // Re-throw other errors to be handled by outer catch
350+
}
278351
} catch (error) {
279352
return reply.status(500).send({
280353
error: 'Re-indexing failed',

src/api/server.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import Fastify from 'fastify';
88
import fastifyStatic from '@fastify/static';
99
import fastifyWebsocket from '@fastify/websocket';
1010
import { basename } from 'path';
11-
import type { CodeGraph } from '../core/graph.js';
11+
import type { CodeGraphApi } from '../core/graph.js';
1212
import type { StorageProvider } from '../store/provider.js';
1313
import {
1414
WebSocketBroadcaster,
@@ -29,7 +29,7 @@ import type { EmlServices } from '../eml/mcp/handlers.js';
2929

3030
export interface APIServerOptions {
3131
storage: StorageProvider;
32-
graph: CodeGraph;
32+
graph: CodeGraphApi;
3333
dashboardPath: string;
3434
workspaceRoot: string;
3535
mountRoot?: string;
@@ -49,6 +49,7 @@ export interface APIServerOptions {
4949
mcpServer?: any;
5050
configManager?: any;
5151
eml?: EmlServices;
52+
indexQueue?: any;
5253
}
5354

5455
export interface APIServer {
@@ -200,6 +201,7 @@ export async function createAPIServer(
200201
indexer: options.indexer,
201202
watcher: options.watcher,
202203
vectorStore: options.vectorStore,
204+
indexQueue: options.indexQueue,
203205
});
204206

205207
await registerSearchRoutes(fastify, {

0 commit comments

Comments
 (0)