← back to Ticket System
TK-11506: cache the materialized ticket fold so /api/tickets stops blocking /healthz
b65f87aed953341db00b18367d78077c02da45d2 · 2026-09-11 13:31:26 -0700 · Steve Abrams
The board re-folded the whole 34MB / 193k-event append-only log on EVERY read:
tickets() re-parsed the log (~190ms), JSON.stringify re-serialized 15MB (~31ms),
and /api/agents + /api/messages each paid the same again on the same single main
thread. board.html fetches all four in parallel, so one poll cost ~4.5s of
blocking, and ONE in-flight /api/tickets was enough to stall the 3-line /healthz:
measured 4.705s at load 44.83, with only 2 established connections.
Cache is the same shape getRunning already uses after 661b3c5 - a TTL bounding
how often we stat, plus an events.jsonl size+mtime identity so any append
invalidates at once. Bodies are cached as pre-encoded Buffers and gzipped off
the main thread. withRanking mutates, so the ranked list shallow-copies and the
cached fold stays exactly what the event log says.
Also stops shipping 15MB unconditionally: gzip when the client asks and the
compressed copy has landed, plus opt-in ?fields=summary / ?status= /
?limit= &offset= (total in X-Total-Count). The bare /api/tickets path is
byte-identical to before, verified against the pre-patch build, so
ticket-export.js, reaper.js, ticket-autodone.js, board.html, office.html and
TicketBar.swift all keep working untouched.
Measured, healthz worst case while 6 /api/tickets are in flight:
4.705s live (pre-fix) -> 1.475s unpatched control -> 0.006s patched
/api/tickets warm: 1.052/1.400/0.619s -> 0.267/0.016/0.018s
Guards from 661b3c5 verified byte-identical: socket-less orphan-daemon guard,
TK-10970 serialized pm2 wrapper, basic-auth 401 + WWW-Authenticate, open /healthz.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files touched
Diff
commit b65f87aed953341db00b18367d78077c02da45d2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 11 13:31:26 2026 -0700
TK-11506: cache the materialized ticket fold so /api/tickets stops blocking /healthz
The board re-folded the whole 34MB / 193k-event append-only log on EVERY read:
tickets() re-parsed the log (~190ms), JSON.stringify re-serialized 15MB (~31ms),
and /api/agents + /api/messages each paid the same again on the same single main
thread. board.html fetches all four in parallel, so one poll cost ~4.5s of
blocking, and ONE in-flight /api/tickets was enough to stall the 3-line /healthz:
measured 4.705s at load 44.83, with only 2 established connections.
Cache is the same shape getRunning already uses after 661b3c5 - a TTL bounding
how often we stat, plus an events.jsonl size+mtime identity so any append
invalidates at once. Bodies are cached as pre-encoded Buffers and gzipped off
the main thread. withRanking mutates, so the ranked list shallow-copies and the
cached fold stays exactly what the event log says.
Also stops shipping 15MB unconditionally: gzip when the client asks and the
compressed copy has landed, plus opt-in ?fields=summary / ?status= /
?limit= &offset= (total in X-Total-Count). The bare /api/tickets path is
byte-identical to before, verified against the pre-patch build, so
ticket-export.js, reaper.js, ticket-autodone.js, board.html, office.html and
TicketBar.swift all keep working untouched.
Measured, healthz worst case while 6 /api/tickets are in flight:
4.705s live (pre-fix) -> 1.475s unpatched control -> 0.006s patched
/api/tickets warm: 1.052/1.400/0.619s -> 0.267/0.016/0.018s
Guards from 661b3c5 verified byte-identical: socket-less orphan-daemon guard,
TK-10970 serialized pm2 wrapper, basic-auth 401 + WWW-Authenticate, open /healthz.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
server.js | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 121 insertions(+), 13 deletions(-)
diff --git a/server.js b/server.js
index 4ab1e7be..0f85242d 100644
--- a/server.js
+++ b/server.js
@@ -4,7 +4,8 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const { exec, execFile, spawn } = require('child_process');
-const { tickets, STATUSES, messages, resolveId, append, withLock, IDRE, REFRE, resolveList } = require('./lib.js');
+const zlib = require('zlib');
+const { tickets, STATUSES, messages, resolveId, append, withLock, IDRE, REFRE, resolveList, EVENTS } = require('./lib.js');
// ── ticket-run + DTD wiring (TK-10527) ──
const DATA_DIR = path.join(os.homedir(), '.claude', 'tickets');
@@ -84,6 +85,89 @@ function getRunning(cb) {
cb(runCache.data);
}
+// ── TK-11506: materialized-view cache ──
+// Every read re-folded the whole append-only log on the single main thread: tickets()
+// re-parsed 193k events (~190ms), JSON.stringify re-serialized 15MB (~31ms), and
+// /api/agents + /api/messages each paid the same again — so one board poll cost ~4.5s of
+// blocking and one in-flight /api/tickets stalled the 3-line /healthz for 4.7s (measured).
+// Same shape as getRunning above: a TTL that bounds how often we stat, plus an
+// events.jsonl size+mtime identity so any append (tk, an agent, or this board) invalidates
+// at once. Payloads are cached as pre-encoded Buffers, and gzipped off-thread, so a repeat
+// request costs a socket write rather than a re-fold.
+const VIEW_TTL_MS = 1000;
+let viewStat = { at: 0, key: 'init' };
+function eventsKey() {
+ if (Date.now() - viewStat.at < VIEW_TTL_MS) return viewStat.key;
+ let key;
+ try { const st = fs.statSync(EVENTS); key = st.size + ':' + st.mtimeMs; } catch { key = 'missing'; }
+ viewStat = { at: Date.now(), key };
+ return key;
+}
+// The board's own writes must be visible to the very next read, so skip the stat TTL.
+function invalidateViews() { viewStat.at = 0; }
+const views = new Map();
+function cachedView(name, build) {
+ const key = eventsKey();
+ const hit = views.get(name);
+ if (hit && hit.key === key) return hit.value;
+ const value = build();
+ views.set(name, { key, value });
+ return value;
+}
+// A cached payload is the encoded body, not the object — stringify is ~31ms of the cost.
+function cachedPayload(name, build) {
+ return cachedView('payload:' + name, () => {
+ const entry = { raw: Buffer.from(JSON.stringify(build())) };
+ // zlib.gzip is threadpool work, so even the once-per-change compression never blocks
+ // the main thread; until it lands, requests are served uncompressed.
+ zlib.gzip(entry.raw, (err, gz) => { if (!err) entry.gz = gz; });
+ return entry;
+ });
+}
+function sendBuffer(req, res, entry, extraHeaders) {
+ const wantsGzip = /\bgzip\b/.test(req.headers['accept-encoding'] || '');
+ const body = (wantsGzip && entry.gz) ? entry.gz : entry.raw;
+ const head = Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store',
+ 'Content-Length': body.length, 'Vary': 'Accept-Encoding' }, extraHeaders || {});
+ if (body === entry.gz) head['Content-Encoding'] = 'gzip';
+ res.writeHead(200, head);
+ res.end(body);
+}
+const sendJsonBuffer = (req, res, obj, extraHeaders) =>
+ sendBuffer(req, res, { raw: Buffer.from(JSON.stringify(obj)) }, extraHeaders);
+
+const cachedTickets = () => cachedView('tickets', tickets);
+const cachedMessages = () => cachedView('messages', messages);
+
+// Field projection: the comments/actions threads are ~95% of the 15MB. A summary row keeps
+// every scalar the table sorts on, plus the counts and last-action text the UIs render, so a
+// caller that does not expand a ticket never pays for the full corpus.
+function summarize(t) {
+ const last = (arr) => arr && arr.length ? arr[arr.length - 1] : null;
+ const la = last(t.actions), lc = last(t.comments);
+ const out = {};
+ for (const k of Object.keys(t)) if (k !== 'comments' && k !== 'actions') out[k] = t[k];
+ out.ncom = (t.comments || []).length;
+ out.nact = (t.actions || []).length;
+ if (la) out.last_action = { ts: la.ts, agent: la.agent, text: la.text };
+ if (lc) out.last_comment = { ts: lc.ts, agent: lc.agent, kind: lc.kind, text: lc.text };
+ return out;
+}
+
+// ── /api/skills cache (sibling TK-11499) ──
+// Not keyed off events.jsonl — it walks the skill roots and reads every SKILL.md (measured
+// 1.255s here, 502ms worst elsewhere). It shares this one main thread, so leaving it
+// synchronous per-request would keep /healthz stallable and defeat the fix above.
+const SKILLS_TTL_MS = 60000;
+let skillsCache = { at: 0, entry: null };
+function cachedSkillsPayload() {
+ if (skillsCache.entry && Date.now() - skillsCache.at < SKILLS_TTL_MS) return skillsCache.entry;
+ const entry = { raw: Buffer.from(JSON.stringify(installedSkills())) };
+ zlib.gzip(entry.raw, (err, gz) => { if (!err) entry.gz = gz; });
+ skillsCache = { at: Date.now(), entry };
+ return entry;
+}
+
const OFFICE_HTML = path.join(__dirname, 'office.html');
const BOARD_HTML = path.join(__dirname, 'board.html');
const SKILL_ROOTS = [
@@ -123,7 +207,7 @@ function ticketAgents() {
if (ts && (!a.last_at || ts > a.last_at)) a.last_at = ts;
map.set(name, a);
};
- for (const t of tickets().values()) {
+ for (const t of cachedTickets().values()) {
touch(t.assignee, t.updated_at || t.created_at, 'assigned', t);
for (const a of (t.actions || [])) touch(a.agent, a.ts, 'actions', t);
for (const c of (t.comments || [])) touch(c.agent, c.ts, 'comments', t);
@@ -299,7 +383,7 @@ function withRanking(list) {
function page() {
const cols = { open: [], doing: [], blocked: [], done: [], stopped: [] };
- for (const t of tickets().values()) (cols[t.status] || (cols[t.status] = [])).push(t);
+ for (const t of cachedTickets().values()) (cols[t.status] || (cols[t.status] = [])).push(t);
for (const k of STATUSES) cols[k].sort((a, b) => a.updated_at < b.updated_at ? 1 : -1);
cols.done = cols.done.slice(0, 40);
cols.stopped = cols.stopped.slice(0, 40);
@@ -312,7 +396,7 @@ function page() {
${t.actions.map(a => `<div class="c k-action"><b>${esc(a.agent)}</b> <i>action</i> ${esc(a.text)}<span class="cts">${new Date(a.ts).toLocaleString()}</span></div>`).join('') || ''}
${!t.comments.length && !t.actions.length ? '<div class="c none">no comments yet</div>' : ''}</div></div>`;
// ── Direct-message conversations, grouped into threads ──
- const mm = messages();
+ const mm = cachedMessages();
const rootOf = mid => { let id = mid, c = mm.get(mid); const seen = new Set([id]); while (c && c.re && mm.get(c.re)) { if (seen.has(c.re)) break; seen.add(c.re); id = c.re; c = mm.get(id); } return id; };
const threads = new Map();
for (const m of mm.values()) { const r = rootOf(m.mid); (threads.get(r) || threads.set(r, []).get(r)).push(m); }
@@ -377,7 +461,7 @@ http.createServer((req, res) => {
if (!body) return json(res, 400, { error: 'bad json' });
const profile = effectiveRunProfile(body.profile);
if (!RUN_PROFILES.has(profile)) return json(res, 400, { error: 'invalid run profile' });
- const map = tickets(); const ids = resolveList(body.ids, map);
+ const map = cachedTickets(); const ids = resolveList(body.ids, map);
const launched = [], skipped = [];
ids.forEach((id, i) => {
const t = map.get(id);
@@ -393,6 +477,7 @@ http.createServer((req, res) => {
withLock(() => append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board',
text: err ? ('⚠ RUN NOW failed to launch iTerm2 session — ' + String(err.message || err).split('\n')[0])
: `▶ RUN NOW — launched iTerm2 session from the board · profile=${profile}` }));
+ invalidateViews();
}), i * 1300); // stagger ~1.3s
launched.push(id);
});
@@ -403,11 +488,12 @@ http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/api/stop') {
return readJson(req, body => {
if (!body) return json(res, 400, { error: 'bad json' });
- const map = tickets(); const ids = resolveList(body.ids, map);
+ const map = cachedTickets(); const ids = resolveList(body.ids, map);
withLock(() => { for (const id of ids) {
append({ ts: new Date().toISOString(), type: 'status', id, status: 'stopped', agent: 'board' });
append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board', text: '⛔ TicketStopped — stopped forever from the board' });
} });
+ invalidateViews();
json(res, 200, { stopped: ids });
});
}
@@ -415,11 +501,12 @@ http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/api/reopen') {
return readJson(req, body => {
if (!body) return json(res, 400, { error: 'bad json' });
- const map = tickets(); const ids = resolveList(body.ids, map);
+ const map = cachedTickets(); const ids = resolveList(body.ids, map);
withLock(() => { for (const id of ids) {
append({ ts: new Date().toISOString(), type: 'status', id, status: 'open', agent: 'board' });
append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board', text: '↩ reopened from the board' });
} });
+ invalidateViews();
json(res, 200, { reopened: ids });
});
}
@@ -429,7 +516,7 @@ http.createServer((req, res) => {
return readJson(req, body => {
if (fs.existsSync(DTD_RUNNING)) return json(res, 200, { started: false, already: true });
const args = [DTD_RUN];
- const explicit = body && Array.isArray(body.ids) && body.ids.length ? resolveList(body.ids, tickets()) : [];
+ const explicit = body && Array.isArray(body.ids) && body.ids.length ? resolveList(body.ids, cachedTickets()) : [];
if (explicit.length) args.push(...explicit); else args.push('--recent');
try { const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', cwd: __dirname }); child.unref(); }
catch (e) { return json(res, 500, { started: false, error: e.message }); }
@@ -459,11 +546,32 @@ http.createServer((req, res) => {
});
}
if (req.url === '/api/running') { return getRunning(d => { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(d)); }); }
- if (req.url === '/api/tickets') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify(withRanking([...tickets().values()]))); }
- if (req.url === '/api/agents') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify(ticketAgents())); }
- if (req.url === '/api/skills') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify(installedSkills())); }
- if (req.url === '/api/messages') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify([...messages().values()])); }
- if (req.url === '/kanban') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); return res.end(page()); }
+ // /api/tickets — the bare path stays byte-identical for every existing caller
+ // (ticket-export.js, reaper.js, ticket-autodone.js, board.html, office.html, TicketBar.swift).
+ // Opt-in ?fields=summary / ?status= / ?limit= &offset= let a new caller skip the corpus;
+ // the body is always an array, with the unpaginated total in X-Total-Count.
+ if (req.url === '/api/tickets' || req.url.startsWith('/api/tickets?')) {
+ const qs = new URLSearchParams(req.url.slice(req.url.indexOf('?') + 1));
+ const summary = qs.get('fields') === 'summary';
+ const statuses = (qs.get('status') || '').split(',').map(x => x.trim()).filter(Boolean);
+ const limit = Math.max(0, parseInt(qs.get('limit') || '0', 10) || 0);
+ const offset = Math.max(0, parseInt(qs.get('offset') || '0', 10) || 0);
+ const plain = !statuses.length && !limit && !offset;
+ // withRanking mutates (rank/tier/ratings), and the map is now shared across requests —
+ // rank shallow copies so the cached fold stays exactly what the event log says.
+ const ranked = () => withRanking([...cachedTickets().values()].map(t => ({ ...t })));
+ if (plain) return sendBuffer(req, res, cachedPayload(summary ? 'tickets:summary' : 'tickets:full',
+ () => summary ? ranked().map(summarize) : ranked()));
+ let list = cachedView('tickets:ranked', ranked);
+ if (statuses.length) list = list.filter(t => statuses.includes(t.status));
+ const total = list.length;
+ if (offset || limit) list = list.slice(offset, limit ? offset + limit : undefined);
+ return sendJsonBuffer(req, res, summary ? list.map(summarize) : list, { 'X-Total-Count': String(total) });
+ }
+ if (req.url === '/api/agents') return sendBuffer(req, res, cachedPayload('agents', ticketAgents));
+ if (req.url === '/api/skills') return sendBuffer(req, res, cachedSkillsPayload());
+ if (req.url === '/api/messages') return sendBuffer(req, res, cachedPayload('messages', () => [...cachedMessages().values()]));
+ if (req.url === '/kanban') { const buf = cachedView('kanban', () => Buffer.from(page())); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Length': buf.length }); return res.end(buf); }
// default (/) = the adjustable-columns TABLE view (Steve's list-builds rule, 2026-08-10)
return fs.readFile(BOARD_HTML, (e, buf) => {
if (e) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); return res.end(page()); } // fall back to kanban
← cf1f4bfb auto-data-snapshot: 2026-09-11T13:23:02 (1 data files) — dat
·
back to Ticket System
·
TK-11506: ship the head-of-line harness with its negative te 81f857a4 →