← 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
M .gitignoreD __pycache__/bulk-fivefield-exec.cpython-314.pycM bulk-fivefield-exec.pyM drain.sh
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 →