[object Object]

← back to Enrich Local Hybrid

enricher: dead-image drain guard + tag-on-skip (TK-10168)

11302b0d84d28ee74679d0be3864f474771d3972 · 2026-08-06 14:28:54 -0700 · steve

loadProducts() re-loaded dead-image rows every run (unfetchable URL → fetch fails →
self-skip → stays untagged → re-churned forever). Two additive changes:
1. drain guard: exclude image_rejected IS DISTINCT FROM TRUE from the enrichable queue.
2. tag-on-skip: a genuinely dead image (geminiAnalyze all-candidates-failed) now returns a
   DISTINCT {__deadImage} sentinel; the main loop flags image_rejected=TRUE once + continues.
Sentinel avoids the naive-fix bug of mis-tagging 429/no-data nulls as dead images.
Reversible; writes gate on the normal enricher run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 11302b0d84d28ee74679d0be3864f474771d3972
Author: steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 14:28:54 2026 -0700

    enricher: dead-image drain guard + tag-on-skip (TK-10168)
    
    loadProducts() re-loaded dead-image rows every run (unfetchable URL → fetch fails →
    self-skip → stays untagged → re-churned forever). Two additive changes:
    1. drain guard: exclude image_rejected IS DISTINCT FROM TRUE from the enrichable queue.
    2. tag-on-skip: a genuinely dead image (geminiAnalyze all-candidates-failed) now returns a
       DISTINCT {__deadImage} sentinel; the main loop flags image_rejected=TRUE once + continues.
    Sentinel avoids the naive-fix bug of mis-tagging 429/no-data nulls as dead images.
    Reversible; writes gate on the normal enricher run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 enrich-ai-tags.patched.js | 23 ++++++++++++++++++++++-
 1 file changed, 22 insertions(+), 1 deletion(-)

diff --git a/enrich-ai-tags.patched.js b/enrich-ai-tags.patched.js
index 248945a..16a8320 100644
--- a/enrich-ai-tags.patched.js
+++ b/enrich-ai-tags.patched.js
@@ -347,7 +347,9 @@ async function geminiAnalyze(imageUrl, mode, attempt = 0) {
   }
   if (!imageBuffer) {
     console.log(`    SKIP: Cannot fetch image — all ${candidates.length} candidate URL(s) failed`);
-    return null;
+    // TK-10168: DISTINCT dead-image sentinel (not a bare null). The caller tags image_rejected
+    // ONLY on this signal — never on a 429/no-data null, which also reads falsy downstream.
+    return { __deadImage: true };
   }
   imageUrl = resolvedUrl; // from here on, use the URL that actually fetched
 
@@ -601,6 +603,10 @@ async function loadProducts(vendorCode, tableName, force, limit) {
   if (!force) {
     // Handle both text and jsonb column types for ai_tags
     conditions.push(`(ai_tags IS NULL OR ai_tags::text = '' OR ai_tags::text = '[]' OR ai_tags::text = 'null')`);
+    // TK-10168 drain guard: rows already flagged image_rejected=TRUE (e.g. dead image URLs,
+    // tagged by the tag-on-skip below) drop out of the enrichable queue so they don't
+    // re-fetch/re-fail/re-churn every run. Paired with the tag-on-skip; a no-op without it.
+    conditions.push(`(image_rejected IS DISTINCT FROM TRUE)`);
   }
 
   let sql = `
@@ -748,6 +754,21 @@ async function main() {
       continue;
     }
 
+    // TK-10168 tag-on-skip: a genuinely dead image (all candidate URLs unfetchable) is flagged
+    // ONCE so the drain guard permanently excludes it — instead of re-churning forever. Keyed on
+    // the explicit __deadImage sentinel, NEVER on a plain 429/no-data null (which must not be tagged).
+    if (aiData && aiData.__deadImage) {
+      console.log('  → SKIP (dead image — all candidate URLs unfetchable) → tagging image_rejected [TK-10168]');
+      skipped++;
+      try {
+        await pool.query(
+          `UPDATE ${catalogTable} SET image_rejected = TRUE, image_rejection_reason = $2 WHERE id = $1`,
+          [row.id, 'dead_image_url:unfetchable [TK-10168]']
+        );
+      } catch (e) { console.log(`    (image_rejected tag write failed: ${e.message})`); }
+      continue;
+    }
+
     // circuit-breaker: a success clears the streak; too many quota-429s in a row => stop cleanly
     if (aiData) consecutive429 = 0;
     else if (consecutive429 >= QUOTA_BREAK_STREAK) {

← 20434f1 chore: macstudio3 migration — reconcile from mac2 + repoint  ·  back to Enrich Local Hybrid  ·  Add reversible Phase-3 image-less phantom-backlog reconcile 2b5fd92 →