← back to Dw Signup Fulfillment
retail-webhook: close double-fire race — in-process in-flight claim before send
1944da5c6930f4bcb3f6052cc5c4e9e11cd0af68 · 2026-09-03 13:36:31 -0700 · steve
Two near-simultaneous customers/create deliveries (Shopify at-least-once) both read
sample_verify_sent=false and both send a verify letter. Added an in-memory Set claimed
synchronously (no await between has() and add(), atomic on the single pm2 fork) and
released in finally, so exactly one letter goes out per burst. Durable metafield still
owns cross-restart/retry-after-minutes idempotency; failed sends stay un-poisoned.
Proven by a 5-way concurrent + mixed-id + fail-retry test (all pass); codex-reviewed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PT22KZe3HDFcym6fzZRwD
Files touched
Diff
commit 1944da5c6930f4bcb3f6052cc5c4e9e11cd0af68
Author: steve <steve@designerwallcoverings.com>
Date: Thu Sep 3 13:36:31 2026 -0700
retail-webhook: close double-fire race — in-process in-flight claim before send
Two near-simultaneous customers/create deliveries (Shopify at-least-once) both read
sample_verify_sent=false and both send a verify letter. Added an in-memory Set claimed
synchronously (no await between has() and add(), atomic on the single pm2 fork) and
released in finally, so exactly one letter goes out per burst. Durable metafield still
owns cross-restart/retry-after-minutes idempotency; failed sends stay un-poisoned.
Proven by a 5-way concurrent + mixed-id + fail-retry test (all pass); codex-reviewed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PT22KZe3HDFcym6fzZRwD
---
lib/retail-webhook.js | 92 ++++++++++++++++++++++++++++++++++-----------------
1 file changed, 61 insertions(+), 31 deletions(-)
diff --git a/lib/retail-webhook.js b/lib/retail-webhook.js
index 9e2f40e..45f19da 100644
--- a/lib/retail-webhook.js
+++ b/lib/retail-webhook.js
@@ -21,46 +21,76 @@ const verify = require('./verify');
const SENT_FLAG = { namespace: 'custom', key: 'sample_verify_sent' };
+// IN-PROCESS CONCURRENCY GUARD (closes the double-fire race).
+// The durable idempotency (SENT_FLAG metafield, step 3/5) is a check-then-act with a
+// long window: two near-simultaneous customers/create deliveries — Shopify delivers
+// at-least-once and retries — both read the flag as false, both send, then both set it,
+// so the customer gets TWO verify letters. The metafield alone can't stop that: there's
+// no compare-and-swap on the REST read/write, and the send round-trip sits inside the gap.
+// Because the app is a single pm2 fork (ecosystem.config.js: instances:1, exec_mode:'fork')
+// every concurrent delivery shares one heap and one event loop, so an in-memory claim is
+// sufficient AND atomic: there is NO `await` between the has()-check and the add(), so Node's
+// single thread cannot interleave a second delivery into that window. Released in `finally`
+// so a failed/throwing send never permanently poisons an id (a later retry/backfill re-sends).
+// Division of labor: this Set owns the tight concurrent burst; the metafield still owns
+// cross-restart / retry-after-minutes idempotency. Keyed on String(id) so a number-vs-string
+// id shape can never defeat the guard.
+const inFlight = new Set();
+
async function handleCustomerCreate(payload) {
const id = payload && payload.id;
if (!id) return { ok: false, reason: 'no_customer_id' };
- // 1) AUTH — re-fetch the customer from Shopify. Forged/unknown id ⇒ reject.
- const r = await shopify.getCustomer(id);
- const real = r && r.json && r.json.customer ? r.json.customer : null;
- if (!real || !real.id || !real.email) {
- return { ok: false, reason: 'customer_not_found_or_no_email', id };
- }
+ // 0) CONCURRENCY CLAIM — atomic in Node (no await between check and add). A second
+ // delivery for the same id while the first is still in flight is a no-op, guaranteeing
+ // exactly one verify letter per burst.
+ const claimKey = String(id);
+ if (inFlight.has(claimKey)) return { ok: true, skipped: 'in_flight', id };
+ inFlight.add(claimKey);
+ try {
+ // 1) AUTH — re-fetch the customer from Shopify. Forged/unknown id ⇒ reject.
+ const r = await shopify.getCustomer(id);
+ const real = r && r.json && r.json.customer ? r.json.customer : null;
+ if (!real || !real.id || !real.email) {
+ return { ok: false, reason: 'customer_not_found_or_no_email', id };
+ }
- // 2) FRESHNESS — only a freshly-created customer gets the letter. (Missing created_at
- // ⇒ not real Shopify data ⇒ treated as stale/rejected.)
- const createdMs = real.created_at ? Date.parse(real.created_at) : NaN;
- const ageMin = Number.isNaN(createdMs) ? Infinity : (Date.now() - createdMs) / 60000;
- if (ageMin > config.WEBHOOK_FRESHNESS_MIN) {
- return { ok: false, reason: 'stale_customer', id, ageMin: Number.isFinite(ageMin) ? Math.round(ageMin) : null };
- }
+ // 2) FRESHNESS — only a freshly-created customer gets the letter. (Missing created_at
+ // ⇒ not real Shopify data ⇒ treated as stale/rejected.)
+ const createdMs = real.created_at ? Date.parse(real.created_at) : NaN;
+ const ageMin = Number.isNaN(createdMs) ? Infinity : (Date.now() - createdMs) / 60000;
+ if (ageMin > config.WEBHOOK_FRESHNESS_MIN) {
+ return { ok: false, reason: 'stale_customer', id, ageMin: Number.isFinite(ageMin) ? Math.round(ageMin) : null };
+ }
- // 3) IDEMPOTENCY — send the verify letter at most once per customer.
- const flag = await shopify.getCustomerMetafield(id, SENT_FLAG.namespace, SENT_FLAG.key);
- if (flag && String(flag).toLowerCase() === 'true') {
- return { ok: true, skipped: 'already_sent', id };
- }
+ // 3) IDEMPOTENCY (durable) — send the verify letter at most once per customer, across
+ // restarts and retries-after-minutes.
+ const flag = await shopify.getCustomerMetafield(id, SENT_FLAG.namespace, SENT_FLAG.key);
+ if (flag && String(flag).toLowerCase() === 'true') {
+ return { ok: true, skipped: 'already_sent', id };
+ }
- // 4) SEND — the branded "confirm your email" letter to the REAL on-file email, with
- // the real customer id baked into the token so the tag lands on the right account.
- const started = await verify.startVerification({ email: real.email, customerId: real.id, firstName: real.first_name });
+ // 4) SEND — the branded "confirm your email" letter to the REAL on-file email, with
+ // the real customer id baked into the token so the tag lands on the right account.
+ const started = await verify.startVerification({ email: real.email, customerId: real.id, firstName: real.first_name });
- // 5) FLAG in Shopify so it never double-sends (survives restarts) — but ONLY when the
- // letter actually went out. A failed send (missing VERIFY_SECRET, George down, etc.)
- // must NOT poison the idempotency flag, or the customer is marked "handled" while
- // having received nothing — permanently skipped, never gets their welcome email.
- if (started.ok) {
- await shopify.setCustomerMetafield(id, { ...SENT_FLAG, value: 'true', type: 'boolean' });
- } else {
- console.warn(`[retail-webhook] verify letter NOT sent for id=${id} (${started.reason || 'unknown'}) — flag left unset so a retry/backfill can re-send.`);
- }
+ // 5) FLAG in Shopify so it never double-sends (survives restarts) — but ONLY when the
+ // letter actually went out. A failed send (missing VERIFY_SECRET, George down, etc.)
+ // must NOT poison the idempotency flag, or the customer is marked "handled" while
+ // having received nothing — permanently skipped, never gets their welcome email.
+ if (started.ok) {
+ await shopify.setCustomerMetafield(id, { ...SENT_FLAG, value: 'true', type: 'boolean' });
+ } else {
+ console.warn(`[retail-webhook] verify letter NOT sent for id=${id} (${started.reason || 'unknown'}) — flag left unset so a retry/backfill can re-send.`);
+ }
- return { ok: started.ok, id, email: real.email, started };
+ return { ok: started.ok, id, email: real.email, started };
+ } finally {
+ // Release the claim regardless of outcome — the durable metafield (set on success)
+ // carries idempotency forward; a failure leaves both flag and claim clear so a retry
+ // can re-send rather than being permanently skipped.
+ inFlight.delete(claimKey);
+ }
}
module.exports = { handleCustomerCreate, SENT_FLAG };
← 90c2cb8 TK-11190: harden recover-stuck-apps — fail-loud --apply DRY_
·
back to Dw Signup Fulfillment
·
Guide designers from full application to account setup and e 01593e3 →