[object Object]

← back to Dw Five Field Step0

Fold flag-gated display_variant tag backfill into dw-fivefield-drain (Option A, DTD 3/3)

2a39ffec951d063b70a505edbbbce8284d67b313 · 2026-06-24 10:31:02 -0700 · Steve Abrams

- Add gql() Admin GraphQL helper with 429/5xx back-off
- backfill_display_variant(): idempotent tagsAdd on active products missing the
  tag, paged via status:active AND -tag:display_variant query (self-correcting)
- Own per-run cap DISPLAY_VARIANT_SLOT_MAX (default 30) + same 0.4s inter-call
  sleep as the variant drain; runs AFTER variant work each slot
- NO variant-budget debit (tagsAdd != variant create, no daily variant cap)
- GATED behind env DISPLAY_VARIANT_BACKFILL=1, defaults OFF: scheduled runs do
  not auto-tag until Steve enables it
- --display-variant-only flag for standalone runs; --dry-run supported
- drain.sh logs the gate state each slot

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 2a39ffec951d063b70a505edbbbce8284d67b313
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 24 10:31:02 2026 -0700

    Fold flag-gated display_variant tag backfill into dw-fivefield-drain (Option A, DTD 3/3)
    
    - Add gql() Admin GraphQL helper with 429/5xx back-off
    - backfill_display_variant(): idempotent tagsAdd on active products missing the
      tag, paged via status:active AND -tag:display_variant query (self-correcting)
    - Own per-run cap DISPLAY_VARIANT_SLOT_MAX (default 30) + same 0.4s inter-call
      sleep as the variant drain; runs AFTER variant work each slot
    - NO variant-budget debit (tagsAdd != variant create, no daily variant cap)
    - GATED behind env DISPLAY_VARIANT_BACKFILL=1, defaults OFF: scheduled runs do
      not auto-tag until Steve enables it
    - --display-variant-only flag for standalone runs; --dry-run supported
    - drain.sh logs the gate state each slot
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore                                      |   1 +
 __pycache__/bulk-fivefield-exec.cpython-314.pyc | Bin 24626 -> 0 bytes
 bulk-fivefield-exec.py                          | 156 ++++++++++++++++++++++++
 drain.sh                                        |  10 ++
 4 files changed, 167 insertions(+)

diff --git a/.gitignore b/.gitignore
index 1924158..87910ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ tmp/
 dist/
 build/
 .next/
+__pycache__/
diff --git a/__pycache__/bulk-fivefield-exec.cpython-314.pyc b/__pycache__/bulk-fivefield-exec.cpython-314.pyc
deleted file mode 100644
index c84f9b2..0000000
Binary files a/__pycache__/bulk-fivefield-exec.cpython-314.pyc and /dev/null differ
diff --git a/bulk-fivefield-exec.py b/bulk-fivefield-exec.py
index 2fb47f3..c8f8bd7 100644
--- a/bulk-fivefield-exec.py
+++ b/bulk-fivefield-exec.py
@@ -45,6 +45,14 @@ API = "2024-10"
 OUTDIR = "/Users/stevestudio2/Projects/dw-five-field-step0/out"
 RESULT = os.path.join(OUTDIR, "bulk-fivefield-result.json")
 SAMPLE_PRICE = "4.25"
+# --- display_variant legacy tag backfill (Option A, DTD 3/3 2026-06-24) -------
+# Flag-gated, default OFF: the next scheduled drain run will NOT auto-tag unless
+# DISPLAY_VARIANT_BACKFILL=1 is exported. tagsAdd is NOT a variant create -> it
+# does NOT debit the variant budget.cjs ledger and does NOT hit the daily 1k
+# variant cap. Paced with its OWN per-run cap + the SAME inter-call sleep as the
+# variant drain, and runs AFTER the variant work each hourly slot.
+DV_TAG = "display_variant"
+DV_RESULT = os.path.join(OUTDIR, "display-variant-backfill-result.json")
 BUDGET = os.path.expanduser("~/Projects/designerwallcoverings/scripts/variant-budget/budget.cjs")
 BUDGET_CAT = "backlog"  # 2026-06-21 EVEN 50/50: own reserved 500/day category (was 'upload', which the cadence starved to ~140/day)
 NODE = "/opt/homebrew/bin/node" if os.path.exists("/opt/homebrew/bin/node") else "node"
@@ -69,6 +77,28 @@ def shop(method, path, body=None):
             return e.code, {"error": "non-json"}
 
 
+def gql(query, variables=None, retries=6):
+    """Admin GraphQL POST with simple 429/5xx back-off. Used only by the
+    display_variant tag backfill (Option A, DTD 3/3 2026-06-24) — tagsAdd is a
+    tag mutation, NOT a variant create, so it never touches the variant cap or
+    the budget.cjs ledger."""
+    url = f"https://{DOMAIN}/admin/api/{API}/graphql.json"
+    body = json.dumps({"query": query, "variables": variables or {}}).encode()
+    for attempt in range(retries):
+        r = urllib.request.Request(url, data=body, headers=HDR, method="POST")
+        try:
+            with urllib.request.urlopen(r, timeout=120) as resp:
+                return json.loads(resp.read().decode())
+        except urllib.error.HTTPError as e:
+            if e.code in (429, 502, 503):
+                time.sleep(2 * (attempt + 1)); continue
+            try:
+                return {"errors": [{"message": f"http_{e.code}", "body": e.read().decode()[:300]}]}
+            except Exception:
+                return {"errors": [{"message": f"http_{e.code}"}]}
+    return {"errors": [{"message": "gql_retries_exhausted"}]}
+
+
 # ── budget ledger helpers (fail-open like budget.cjs itself) ──────────────────
 def budget_remaining():
     try:
@@ -279,15 +309,133 @@ def process(item, results, dry_run=False):
         return 0
 
 
+def backfill_display_variant(slot_max, dry_run=False):
+    """Option A (DTD 3/3 2026-06-24): idempotent backfill of the legacy
+    'display_variant' tag on ACTIVE products that lack it.
+
+    - Pages products(query:"status:active AND -tag:display_variant") so the
+      population is self-correcting — anything already tagged drops out of the
+      query, so a re-run never re-tags. Belt-and-braces: also skips if the live
+      tag list already contains DV_TAG.
+    - Caps this run at `slot_max` tag-adds (own lane; see DISPLAY_VARIANT_SLOT_MAX),
+      mirroring the variant drain's per-slot batching so the daily total spreads
+      across the 24 hourly slots instead of bursting.
+    - tagsAdd is a tag mutation, NOT a variant create: NO variant-budget debit,
+      no daily variant-cap exposure.
+    - Appends an audit ledger to DV_RESULT. DELETEs nothing. Resumable (the
+      query naturally excludes already-tagged products on the next run).
+
+    Returns the number of products tagged this run.
+    """
+    results = []
+    if os.path.exists(DV_RESULT):
+        try:
+            results = json.load(open(DV_RESULT))
+        except Exception:
+            results = []
+
+    Q = ('query($c:String){ products(first:50, after:$c, '
+         'query:"status:active AND -tag:display_variant"){ '
+         'pageInfo{hasNextPage endCursor} '
+         'edges{ node{ id handle tags } } } }')
+    M = ('mutation($id:ID!, $tags:[String!]!){ tagsAdd(id:$id, tags:$tags){ '
+         'node{ id } userErrors{ field message } } }')
+
+    print(f"\n=== display_variant backfill === cap_this_run={slot_max} dry_run={dry_run}")
+    tagged = 0
+    cursor = None
+    while tagged < slot_max:
+        d = gql(Q, {"c": cursor})
+        if "errors" in d:
+            print(f"[dv] GQL error: {json.dumps(d['errors'])[:300]} — stopping run")
+            break
+        conn = d["data"]["products"]
+        edges = conn["edges"]
+        if not edges:
+            print("[dv] no more untagged active products — backfill complete for now")
+            break
+        for e in edges:
+            if tagged >= slot_max:
+                break
+            node = e["node"]
+            pid = node["id"]
+            handle = node.get("handle")
+            live_tags = node.get("tags") or []
+            rec = {"product_id": pid, "handle": handle, "status": None, "reason": None}
+            # belt-and-braces idempotency (query already filters, but double-check)
+            if DV_TAG in live_tags:
+                rec["status"] = "skipped"; rec["reason"] = "already_tagged"
+                results.append(rec)
+                continue
+            if dry_run:
+                rec["status"] = "dryrun"; rec["reason"] = "would_tag"
+                results.append(rec)
+                print(f"[dv] DRYRUN would tag {handle} ({pid})")
+                tagged += 1
+                continue
+            md = gql(M, {"id": pid, "tags": [DV_TAG]})
+            if "errors" in md:
+                rec["status"] = "errored"; rec["reason"] = json.dumps(md["errors"])[:200]
+                results.append(rec); print(f"[dv] ERR {handle}: {rec['reason']}")
+                continue
+            ue = md.get("data", {}).get("tagsAdd", {}).get("userErrors", [])
+            if ue:
+                rec["status"] = "errored"; rec["reason"] = json.dumps(ue)[:200]
+                results.append(rec); print(f"[dv] ERR {handle}: {rec['reason']}")
+                continue
+            rec["status"] = "tagged"; rec["reason"] = "ok"
+            results.append(rec); tagged += 1
+            print(f"[dv] TAGGED {handle} ({pid})  [{tagged}/{slot_max}]")
+            json.dump(results, open(DV_RESULT, "w"), indent=1)
+            time.sleep(0.4)  # SAME inter-call sleep as the variant drain (shared pacing)
+        # advance cursor only if we tagged some but didn't exit; since tagged rows
+        # leave the query, re-query from the top (cursor=None) to pick up the next
+        # untagged batch deterministically.
+        if dry_run:
+            pi = conn["pageInfo"]
+            if not pi["hasNextPage"]:
+                break
+            cursor = pi["endCursor"]
+        else:
+            cursor = None  # tagged products fall out of the query -> fresh top page
+        time.sleep(0.25)
+
+    if not dry_run:
+        json.dump(results, open(DV_RESULT, "w"), indent=1)
+    total_tagged = sum(1 for r in results if r["status"] == "tagged")
+    print(f"[dv] run done: this_run={tagged}  all_time_tagged={total_tagged}  audit={DV_RESULT}")
+    return tagged
+
+
 def main():
     ap = argparse.ArgumentParser()
     ap.add_argument("--max", type=int, default=None,
                     help="max variant-CREATES this run (default = live budget.cjs remaining upload)")
     ap.add_argument("--dry-run", action="store_true",
                     help="print the day-1 batch (first --max creatable SKUs) WITHOUT writing or debiting budget")
+    ap.add_argument("--display-variant-only", action="store_true",
+                    help="run ONLY the display_variant tag backfill (skip the variant drain). "
+                         "Still gated by env DISPLAY_VARIANT_BACKFILL=1.")
     args = ap.parse_args()
 
     os.makedirs(OUTDIR, exist_ok=True)
+
+    # --- display_variant tag backfill (Option A, DTD 3/3 2026-06-24) ----------
+    # GATED OFF by default: only runs when env DISPLAY_VARIANT_BACKFILL=1. Own
+    # per-run cap (DISPLAY_VARIANT_SLOT_MAX, default 30) + same inter-call sleep;
+    # NO variant-budget debit. Runs AFTER the variant drain unless
+    # --display-variant-only is passed.
+    dv_enabled = os.environ.get("DISPLAY_VARIANT_BACKFILL", "0") == "1"
+    dv_slot_max = int(os.environ.get("DISPLAY_VARIANT_SLOT_MAX", "30"))
+
+    if args.display_variant_only:
+        if not dv_enabled:
+            print("[dv] --display-variant-only requested but DISPLAY_VARIANT_BACKFILL!=1 — "
+                  "gated OFF, nothing to do. Export DISPLAY_VARIANT_BACKFILL=1 to enable.")
+            return
+        backfill_display_variant(dv_slot_max, dry_run=args.dry_run)
+        return
+
     work = load_worklist()
     total_work = len(work)
     print(f"Loaded {total_work} pending SKUs from dw_unified.bulk_fivefield_worklist")
@@ -374,6 +522,14 @@ def main():
     if not args.dry_run:
         json.dump(results, open(RESULT, "w"), indent=1)
 
+    # --- display_variant tag backfill (runs AFTER the variant drain) ----------
+    # Option A (DTD 3/3 2026-06-24). GATED OFF by default: only fires when env
+    # DISPLAY_VARIANT_BACKFILL=1. Own cap + same sleep; NO variant-budget debit.
+    if dv_enabled:
+        backfill_display_variant(dv_slot_max, dry_run=args.dry_run)
+    else:
+        print("[dv] display_variant backfill GATED OFF (set DISPLAY_VARIANT_BACKFILL=1 to enable).")
+
 
 if __name__ == "__main__":
     main()
diff --git a/drain.sh b/drain.sh
index 3771022..ac93ec7 100644
--- a/drain.sh
+++ b/drain.sh
@@ -38,6 +38,16 @@ fi
 # across the 24 hourly slots (~21/slot) instead of bursting at once. The shared
 # budget.cjs 'backlog' category still hard-caps the daily total at 500.
 SLOT_MAX="${DW_BACKLOG_SLOT_MAX:-30}"
+
+# display_variant legacy tag backfill (Option A, DTD 3/3 2026-06-24).
+# GATED OFF by default: the executor only runs the tag pass when env
+# DISPLAY_VARIANT_BACKFILL=1 is present. tagsAdd is NOT a variant create -> it
+# does NOT debit the variant budget. Own per-slot cap (DISPLAY_VARIANT_SLOT_MAX,
+# default 30) + same inter-call sleep; runs AFTER the variant drain each slot.
+# To GO LIVE: add DISPLAY_VARIANT_BACKFILL=1 to this job's environment (the
+# launchd plist EnvironmentVariables block) and reload the plist. Until then the
+# scheduled run logs "backfill GATED OFF" and changes no tags.
+echo "[$(ts)] display_variant backfill = ${DISPLAY_VARIANT_BACKFILL:-0} (slot_max=${DISPLAY_VARIANT_SLOT_MAX:-30})" >>"$LOG"
 python3 "$EXE" --max "$SLOT_MAX" >>"$LOG" 2>&1
 RC=$?
 DONE2=$(node -e 'try{const r=require(process.argv[1]);console.log(r.filter(x=>x.status==="fixed"||x.status==="skipped").length)}catch(e){console.log(0)}' "$RESULT" 2>/dev/null || echo 0)

← 91fb026 auto-save: 2026-06-24T09:26:00 (1 files) — out/bulk-fivefiel  ·  back to Dw Five Field Step0  ·  auto-save: 2026-06-25T00:30:33 (2 files) — out/bulk-fivefiel 6a324b2 →