[object Object]

← back to Dw Photo Capture

fix(perf): guard redundant disk-cache reparse in buildCatalog (TK-11962)

39cda0efbee278ca995a3e39d4aac462dd9156ec · 2026-09-21 17:53:04 -0700 · Steve Abrams

Root cause of the local dwphoto event-loop wedge (reproduced 5x): when the
all-dw feed (:9958) is down, buildCatalog() falls back to synchronously
readFileSync+JSON.parse+map of data/all-catalog.json (332MB / 252,187 rows,
of which 137,433 are real STAGED "catalog-only" products, NOT junk) on the
MAIN THREAD — at boot AND every 15min via setInterval. That spikes the heap
~2.3GB and GC-thrashes the loop for tens of seconds; healthz + all requests
time out (scanner offline). It recurs every 15min, turning a healthy process
into a wedged one.

Fix: size+mtime guard — skip the reparse when CATALOG already reflects the
unchanged cache file. Eliminates the RECURRING 15-min reparse stall (the
common feed-down case). Verified: unchanged cache -> /api/reindex is a ~2ms
no-op (1 index, rss flat 2290MB); changed mtime -> correctly reloads.

PARTIAL by design: the one-time boot load and a genuine cache-change reload
still parse synchronously (a changed-cache reindex still stalls, negative
test confirmed). The dep-free deeper fix (worker_threads offload of the
parse) is a follow-up — rows can't be dropped (real staged products) and a
streaming parser would add a runtime dep (repo is zero-runtime-dep).

Deploy of this = external publish -> GATED (memo in pending-approval).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vBEHxUrUejKV7JCQ3uYdg

Files touched

Diff

commit 39cda0efbee278ca995a3e39d4aac462dd9156ec
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Sep 21 17:53:04 2026 -0700

    fix(perf): guard redundant disk-cache reparse in buildCatalog (TK-11962)
    
    Root cause of the local dwphoto event-loop wedge (reproduced 5x): when the
    all-dw feed (:9958) is down, buildCatalog() falls back to synchronously
    readFileSync+JSON.parse+map of data/all-catalog.json (332MB / 252,187 rows,
    of which 137,433 are real STAGED "catalog-only" products, NOT junk) on the
    MAIN THREAD — at boot AND every 15min via setInterval. That spikes the heap
    ~2.3GB and GC-thrashes the loop for tens of seconds; healthz + all requests
    time out (scanner offline). It recurs every 15min, turning a healthy process
    into a wedged one.
    
    Fix: size+mtime guard — skip the reparse when CATALOG already reflects the
    unchanged cache file. Eliminates the RECURRING 15-min reparse stall (the
    common feed-down case). Verified: unchanged cache -> /api/reindex is a ~2ms
    no-op (1 index, rss flat 2290MB); changed mtime -> correctly reloads.
    
    PARTIAL by design: the one-time boot load and a genuine cache-change reload
    still parse synchronously (a changed-cache reindex still stalls, negative
    test confirmed). The dep-free deeper fix (worker_threads offload of the
    parse) is a follow-up — rows can't be dropped (real staged products) and a
    streaming parser would add a runtime dep (repo is zero-runtime-dep).
    
    Deploy of this = external publish -> GATED (memo in pending-approval).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_011vBEHxUrUejKV7JCQ3uYdg
---
 server.js | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/server.js b/server.js
index 8e133a1..6593d12 100644
--- a/server.js
+++ b/server.js
@@ -2710,6 +2710,7 @@ async function createNewItem(p, b64, dryRun) {
 
 // In-memory index of ALL non-archived Fentucci products (for "find any SKU" lookup).
 let CATALOG = [], INDEX = [];
+let _diskCacheSig = null;   // TK-11962: size+mtime of the disk cache last loaded into CATALOG (skip redundant reparse)
 const nmfr = s => (s || '').trim().toUpperCase().replace(/T$/, ''); // normalize mfr (WOS3467T == wos3467)
 function rebuildIndex() {
   const haveSku = new Set(CATALOG.map(x => (x.dw_sku || '').toUpperCase()));
@@ -2781,9 +2782,17 @@ async function buildCatalog() {
   }
   // 2) last-good disk cache
   try {
+    // TK-11962: the feed is down, so this branch runs on EVERY 15-min interval. The cache is a
+    // multi-hundred-MB / ~250k-row JSON; re-doing readFileSync+JSON.parse+map synchronously each
+    // time spikes the heap ~2.3GB and GC-thrashes the event loop for tens of seconds (scanner goes
+    // unresponsive). Skip the reparse when CATALOG already reflects this exact cache file (size+mtime).
+    const st = fs.statSync(CATALOG_CACHE);
+    const sig = st.size + ':' + st.mtimeMs;
+    if (CATALOG.length && _diskCacheSig === sig) return;   // unchanged since last load → nothing to do
     const feed = JSON.parse(fs.readFileSync(CATALOG_CACHE, 'utf8'));
     if (feed && Array.isArray(feed.rows) && feed.rows.length) {
       CATALOG = feed.rows.map(feedRowToItem);
+      _diskCacheSig = sig;
       console.log(`catalog indexed: ${CATALOG.length} products from disk cache (feed down)`);
       return rebuildIndex();
     }

← b272ac4 auto-data-snapshot: 2026-09-21T17:27:28 (2 data files) — 5x/  ·  back to Dw Photo Capture  ·  harden(TK-11962): catalog-cache guard survives corrupt/unrea 77ae6ab →