[object Object]

← 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 →