[object Object]

← back to Big Red

feat(big-red/tick2): Shopify lookup 60s TTL cache (next: SKU resolver fallback)

83791703b325f0b5829a1033c20d3af68bc53750 · 2026-05-12 01:27:36 -0700 · Steve Abrams

In-memory Map keyed by SKU, 60s TTL. Wraps lookupSkuLive() so repeat
questions about the same SKU within a minute hit local memory instead
of Shopify Admin GraphQL.

- Cache age surfaced in response as _cache_age_ms when serving from
  cache; absent on fresh fetches (so consumers can detect freshness).
- Null results cached too — repeated 'no-such-SKU' queries don't
  hammer Shopify any more than valid SKUs do.
- Background sweep every 5 min drops entries older than TTL*4 so the
  Map doesn't grow unbounded across long uptime. setInterval().unref()
  so it doesn't block shutdown.

Live timing (DWKK-150018 against running big-red):
  call 1 (fresh):  ~600ms — Shopify roundtrip
  call 2 (cache):    52ms — _cache_age_ms: 52
  calls 3-6: 34-41ms each — pure cache hits

YOLO tick 2 — next tick (T+30m): expand SKU resolver to fall back into
sister catalogs (scalamandre, sisterparish, etc.) when shopify_products
misses, so retired/unsynced SKUs still resolve.

Files touched

Diff

commit 83791703b325f0b5829a1033c20d3af68bc53750
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 01:27:36 2026 -0700

    feat(big-red/tick2): Shopify lookup 60s TTL cache (next: SKU resolver fallback)
    
    In-memory Map keyed by SKU, 60s TTL. Wraps lookupSkuLive() so repeat
    questions about the same SKU within a minute hit local memory instead
    of Shopify Admin GraphQL.
    
    - Cache age surfaced in response as _cache_age_ms when serving from
      cache; absent on fresh fetches (so consumers can detect freshness).
    - Null results cached too — repeated 'no-such-SKU' queries don't
      hammer Shopify any more than valid SKUs do.
    - Background sweep every 5 min drops entries older than TTL*4 so the
      Map doesn't grow unbounded across long uptime. setInterval().unref()
      so it doesn't block shutdown.
    
    Live timing (DWKK-150018 against running big-red):
      call 1 (fresh):  ~600ms — Shopify roundtrip
      call 2 (cache):    52ms — _cache_age_ms: 52
      calls 3-6: 34-41ms each — pure cache hits
    
    YOLO tick 2 — next tick (T+30m): expand SKU resolver to fall back into
    sister catalogs (scalamandre, sisterparish, etc.) when shopify_products
    misses, so retired/unsynced SKUs still resolve.
---
 server.js | 31 +++++++++++++++++++++++++++++--
 1 file changed, 29 insertions(+), 2 deletions(-)

diff --git a/server.js b/server.js
index d31db91..03c7fa8 100644
--- a/server.js
+++ b/server.js
@@ -79,8 +79,28 @@ async function lookupSkuInPg(sku) {
   }
 }
 
+// In-memory cache for live Shopify SKU lookups. 60s TTL — long enough to
+// absorb a chatty back-and-forth about the same SKU within a single turn
+// without re-hammering Shopify Admin GraphQL, short enough that a real
+// inventory or price change is reflected within a minute. Map grows boundedly
+// because we sweep entries older than TTL*4 every 5 min (cap insurance).
+const SHOPIFY_TTL_MS = 60_000;
+const SHOPIFY_CACHE = new Map(); // sku -> { result, fetched_at }
+setInterval(() => {
+  const cutoff = Date.now() - SHOPIFY_TTL_MS * 4;
+  let dropped = 0;
+  for (const [k, v] of SHOPIFY_CACHE.entries()) {
+    if (v.fetched_at < cutoff) { SHOPIFY_CACHE.delete(k); dropped++; }
+  }
+  if (dropped > 0) console.log(`[shopify-cache] swept ${dropped} stale entries`);
+}, 5 * 60 * 1000).unref();
+
 async function lookupSkuLive(sku) {
   if (!SHOPIFY_STORE || !SHOPIFY_TOKEN) return null;
+  const cached = SHOPIFY_CACHE.get(sku);
+  if (cached && (Date.now() - cached.fetched_at) < SHOPIFY_TTL_MS) {
+    return cached.result ? { ...cached.result, _cache_age_ms: Date.now() - cached.fetched_at } : null;
+  }
   const query = `{
     products(first: 1, query: "sku:${sku}") {
       edges { node {
@@ -101,9 +121,14 @@ async function lookupSkuLive(sku) {
     if (!r.ok) { console.error('[shopify]', r.status, await r.text().catch(()=>'')); return null; }
     const j = await r.json();
     const node = j?.data?.products?.edges?.[0]?.node;
-    if (!node) return null;
+    if (!node) {
+      // Cache a null too — repeated "no-such-SKU" queries within a minute
+      // shouldn't hammer Shopify any more than valid queries should.
+      SHOPIFY_CACHE.set(sku, { result: null, fetched_at: Date.now() });
+      return null;
+    }
     const v = node.variants?.edges?.[0]?.node || {};
-    return {
+    const result = {
       shopify_id: node.legacyResourceId || node.id,
       handle: node.handle,
       title: node.title,
@@ -116,6 +141,8 @@ async function lookupSkuLive(sku) {
       inventory: v.inventoryQuantity ?? null,
       available: v.availableForSale ?? null,
     };
+    SHOPIFY_CACHE.set(sku, { result, fetched_at: Date.now() });
+    return result;
   } catch (e) {
     console.error('[shopify lookup]', e.message);
     return null;

← 56b06d5 feat(big-red/tick1): per-mode launcher ring color (next: sho  ·  back to Big Red  ·  feat(big-red/tick3): SKU resolver fallback into sister catal 6688ef1 →