Skip to content

Commit 0515002

Browse files
fix(playground): server-side memory cleanup on session expiry + startup purge (DAK-6975) (#231)
BUG 1: sweep() only removed sessions from the in-memory Map — engine memories persisted forever in RocksDB. Now tracks namespaces per session and calls DELETE /v1/namespaces/{ns} on expiry (both sweep timer and resolve-time expiry). BUG 6: Historical playground-demo-* namespaces accumulated from all past sessions. Added startup purge that lists all namespaces and deletes playground-demo-* ones. 4 new tests: onExpire callback, resolve-time cleanup, trackNamespace dedup, no-op when no namespaces tracked. All 64 tests pass. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d3ba964 commit 0515002

4 files changed

Lines changed: 146 additions & 4 deletions

File tree

docker/playground/proxy/index.js

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,78 @@
11
'use strict';
22

3+
const http = require('http');
34
const { config } = require('./config');
45
const { createServer } = require('./server');
56
const { SessionStore } = require('./sessions');
7+
const { NS_PREFIX } = require('./namespace');
68

79
// =============================================================================
810
// Entrypoint — wires config + session store + HTTP server and starts listening.
911
// =============================================================================
1012

13+
function deleteNamespace(ns) {
14+
const url = new URL(config.upstreamUrl + '/v1/namespaces/' + encodeURIComponent(ns));
15+
const headers = { 'Content-Type': 'application/json' };
16+
if (config.rootApiKey) headers['authorization'] = `Bearer ${config.rootApiKey}`;
17+
18+
const req = http.request({
19+
protocol: url.protocol,
20+
hostname: url.hostname,
21+
port: url.port || 80,
22+
method: 'DELETE',
23+
path: url.pathname,
24+
headers,
25+
timeout: 10000,
26+
});
27+
req.on('error', () => {});
28+
req.on('timeout', () => req.destroy());
29+
req.end();
30+
}
31+
32+
function purgeStaleNamespaces() {
33+
const url = new URL(config.upstreamUrl + '/v1/namespaces');
34+
const headers = { 'Content-Type': 'application/json' };
35+
if (config.rootApiKey) headers['authorization'] = `Bearer ${config.rootApiKey}`;
36+
37+
const req = http.request({
38+
protocol: url.protocol,
39+
hostname: url.hostname,
40+
port: url.port || 80,
41+
method: 'GET',
42+
path: url.pathname,
43+
headers,
44+
timeout: 15000,
45+
}, (res) => {
46+
const chunks = [];
47+
res.on('data', (c) => chunks.push(c));
48+
res.on('end', () => {
49+
try {
50+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
51+
const nsList = Array.isArray(body) ? body : (body.namespaces || []);
52+
const prefix = '_dakera_agent_' + NS_PREFIX + '-';
53+
let purged = 0;
54+
for (const entry of nsList) {
55+
const name = typeof entry === 'string' ? entry : (entry.name || entry.id || '');
56+
if (name.startsWith(prefix)) {
57+
deleteNamespace(name);
58+
purged++;
59+
}
60+
}
61+
if (purged > 0) {
62+
console.log(`[sandbox-proxy] startup purge: deleting ${purged} stale playground namespace(s)`);
63+
}
64+
} catch (_) {}
65+
});
66+
});
67+
req.on('error', (e) => {
68+
console.warn(`[sandbox-proxy] startup purge failed: ${e.message}`);
69+
});
70+
req.on('timeout', () => req.destroy());
71+
req.end();
72+
}
73+
1174
function main() {
1275
if (!config.rootApiKey) {
13-
// Fail loud rather than silently forwarding unauthenticated requests to an
14-
// auth-enabled engine (would produce confusing upstream 401s).
1576
console.warn(
1677
'[sandbox-proxy] WARNING: DAKERA_ROOT_API_KEY is not set — forwarded requests will be unauthenticated.',
1778
);
@@ -23,6 +84,13 @@ function main() {
2384
ttlMs: config.sessionTtlMs,
2485
maxSessionsPerIp: config.maxSessionsPerIp,
2586
llmRateLimit: config.llmRateLimitPer10Min,
87+
onExpire: (_sessionId, session) => {
88+
if (!session.namespaces) return;
89+
for (const ns of session.namespaces) {
90+
deleteNamespace('_dakera_agent_' + ns);
91+
console.log(`[sandbox-proxy] expired session — deleting namespace ${ns}`);
92+
}
93+
},
2694
});
2795

2896
const sweep = setInterval(() => {
@@ -37,6 +105,8 @@ function main() {
37105
`[sandbox-proxy] v${config.version} listening on ${config.host}:${config.port} -> ${config.upstreamUrl} ` +
38106
`| rate=${config.rateLimitPerMin}/min cap=${config.memoryCapPerSession} ttl=${config.sessionTtlMs / 1000}s`,
39107
);
108+
// BUG 6: Purge historical playground namespaces on startup
109+
setTimeout(() => purgeStaleNamespaces(), 2000);
40110
});
41111

42112
const shutdown = (sig) => {

docker/playground/proxy/proxy.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,64 @@ test('sweep removes expired sessions', () => {
227227
assert.equal(store.size, 0);
228228
});
229229

230+
test('sweep calls onExpire with tracked namespaces (DAK-6975)', () => {
231+
let now = 0;
232+
const expired = [];
233+
const store = new SessionStore({
234+
rateLimitPerMin: 10, memoryCap: 50, ttlMs: 100, maxSessionsPerIp: 0, now: () => now,
235+
onExpire: (id, session) => { expired.push({ id, namespaces: [...session.namespaces] }); },
236+
});
237+
const res = store.resolve('pg_testexpire', '5.5.5.5');
238+
assert.equal(res.ok, true);
239+
store.trackNamespace(res.session, 'playground-demo-abc123');
240+
store.trackNamespace(res.session, 'playground-demo-def456');
241+
now = 200;
242+
assert.equal(store.sweep(), 1);
243+
assert.equal(expired.length, 1);
244+
assert.equal(expired[0].id, 'pg_testexpire');
245+
assert.deepStrictEqual(expired[0].namespaces.sort(), ['playground-demo-abc123', 'playground-demo-def456']);
246+
});
247+
248+
test('resolve calls onExpire when session is expired on re-resolve (DAK-6975)', () => {
249+
let now = 0;
250+
const expired = [];
251+
const store = new SessionStore({
252+
rateLimitPerMin: 10, memoryCap: 50, ttlMs: 100, maxSessionsPerIp: 0, now: () => now,
253+
onExpire: (id, session) => { expired.push(id); },
254+
});
255+
const res = store.resolve('pg_resolveexp', '6.6.6.6');
256+
store.trackNamespace(res.session, 'playground-demo-aaa');
257+
now = 200;
258+
const res2 = store.resolve('pg_resolveexp', '6.6.6.6');
259+
assert.equal(res2.ok, true);
260+
assert.equal(expired.length, 1);
261+
assert.equal(expired[0], 'pg_resolveexp');
262+
});
263+
264+
test('trackNamespace adds to session.namespaces set (DAK-6975)', () => {
265+
let now = 0;
266+
const store = new SessionStore({ rateLimitPerMin: 10, memoryCap: 50, ttlMs: 100, maxSessionsPerIp: 0, now: () => now });
267+
const res = store.resolve('pg_tracktest1', '7.7.7.7');
268+
assert.equal(res.session.namespaces, undefined);
269+
store.trackNamespace(res.session, 'ns-a');
270+
store.trackNamespace(res.session, 'ns-b');
271+
store.trackNamespace(res.session, 'ns-a');
272+
assert.equal(res.session.namespaces.size, 2);
273+
});
274+
275+
test('sweep does not call onExpire when no namespaces tracked (DAK-6975)', () => {
276+
let now = 0;
277+
let called = false;
278+
const store = new SessionStore({
279+
rateLimitPerMin: 10, memoryCap: 50, ttlMs: 100, maxSessionsPerIp: 0, now: () => now,
280+
onExpire: () => { called = true; },
281+
});
282+
store.resolve('pg_nonamespace', '8.8.8.8');
283+
now = 200;
284+
store.sweep();
285+
assert.equal(called, false);
286+
});
287+
230288
// ---------------------------------------------------------------------------
231289
// unit: CORS (req #5)
232290
// ---------------------------------------------------------------------------

docker/playground/proxy/server.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,7 @@ function createServer(config, store) {
408408
if (rewritten.clientAgentId !== null) {
409409
bodyBuf = rewritten.body;
410410
rewrite = { namespace: rewritten.namespace, restoreTo: rewritten.clientAgentId };
411+
store.trackNamespace(resolved.session, rewritten.namespace);
411412
}
412413
}
413414

docker/playground/proxy/sessions.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ class SessionStore {
3939
this.maxSessionsPerIp = opts.maxSessionsPerIp;
4040
this.llmRateLimit = opts.llmRateLimit !== undefined ? opts.llmRateLimit : 5;
4141
this.now = opts.now || Date.now;
42-
/** @type {Map<string, {createdAt:number, calls:number[], memoryCount:number, ip:string, generated:boolean, llmCalls?:number[], llmSeeded?:boolean}>} */
42+
this.onExpire = opts.onExpire || null;
43+
/** @type {Map<string, {createdAt:number, calls:number[], memoryCount:number, ip:string, generated:boolean, llmCalls?:number[], llmSeeded?:boolean, namespaces?:Set<string>}>} */
4344
this.sessions = new Map();
4445
}
4546

@@ -63,6 +64,9 @@ class SessionStore {
6364
let s = this.sessions.get(key);
6465
if (s && this._expired(s)) {
6566
// req #3: 30-min auto-expiry — drop stale state, start fresh.
67+
if (this.onExpire && s.namespaces && s.namespaces.size > 0) {
68+
try { this.onExpire(key, s); } catch (_) {}
69+
}
6670
this.sessions.delete(key);
6771
s = undefined;
6872
}
@@ -151,11 +155,20 @@ class SessionStore {
151155
return { ok: true, remaining: this.llmRateLimit - session.llmCalls.length };
152156
}
153157

154-
/** Evict expired sessions; returns the number removed. */
158+
/** Track a namespace used by this session (for cleanup on expiry). */
159+
trackNamespace(session, namespace) {
160+
if (!session.namespaces) session.namespaces = new Set();
161+
session.namespaces.add(namespace);
162+
}
163+
164+
/** Evict expired sessions; calls onExpire for engine cleanup. Returns count removed. */
155165
sweep() {
156166
let removed = 0;
157167
for (const [k, s] of this.sessions) {
158168
if (this._expired(s)) {
169+
if (this.onExpire && s.namespaces && s.namespaces.size > 0) {
170+
try { this.onExpire(k, s); } catch (_) {}
171+
}
159172
this.sessions.delete(k);
160173
removed += 1;
161174
}

0 commit comments

Comments
 (0)