bridgebench 3.1.0-alpha.0 → 3.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -173
- package/dist/{chunk-UECBSKTD.js → chunk-AY45YLYL.js} +105 -5
- package/dist/{chunk-LFKEV2YL.js → chunk-CJGHBY54.js} +7 -7
- package/dist/{chunk-JTVNKSMO.js → chunk-DVMGL3L7.js} +80 -6
- package/dist/{chunk-EQHRUV2I.js → chunk-IUPFMGUL.js} +152 -16
- package/dist/{chunk-7YCJSOK7.cjs → chunk-KCXQ5SAU.cjs} +21 -21
- package/dist/{chunk-4TWPCPRP.cjs → chunk-QMOPRKWD.cjs} +89 -15
- package/dist/{chunk-NJTYVNP4.cjs → chunk-VAS6KNJA.cjs} +216 -80
- package/dist/{chunk-CIXITJW6.cjs → chunk-X3LPZGHS.cjs} +106 -6
- package/dist/cli.cjs +51 -49
- package/dist/cli.js +9 -7
- package/dist/client.cjs +5 -6
- package/dist/client.d.cts +3 -3
- package/dist/client.d.ts +3 -3
- package/dist/client.js +4 -5
- package/dist/contracts/index.cjs +6 -3
- package/dist/contracts/index.d.cts +2 -2
- package/dist/contracts/index.d.ts +2 -2
- package/dist/contracts/index.js +7 -4
- package/dist/index.cjs +8 -5
- package/dist/index.d.cts +9 -8
- package/dist/index.d.ts +9 -8
- package/dist/index.js +8 -5
- package/dist/{logger-CCR9Mg1c.d.cts → logger-BByta-7V.d.cts} +23 -23
- package/dist/{logger-QJU7SBDz.d.ts → logger-BQf29BLe.d.ts} +23 -23
- package/dist/{reports-s2CTnGN8.d.ts → reports-B8TCJtPr.d.ts} +57 -13
- package/dist/{reports-4CejmOHf.d.cts → reports-DPpOoOux.d.cts} +57 -13
- package/dist/{tasks-CpaCJ6JE.d.ts → tasks-BmhWuMBD.d.cts} +24 -22
- package/dist/{tasks-CpaCJ6JE.d.cts → tasks-BmhWuMBD.d.ts} +24 -22
- package/dist/tasks.cjs +3 -4
- package/dist/tasks.d.cts +1 -1
- package/dist/tasks.d.ts +1 -1
- package/dist/tasks.js +2 -3
- package/docs/README.md +32 -12
- package/docs/methodology.md +14 -0
- package/docs/operator-guide.md +178 -0
- package/docs/replay-elo.md +12 -2
- package/docs/reviewing-bridgebench.md +173 -0
- package/docs/task-authoring.md +79 -1
- package/package.json +5 -3
- package/tasks/bullshit/public/crossed-metric-properties.yaml +192 -0
- package/tasks/bullshit/public/crossed-release-checksums.yaml +199 -0
- package/tasks/bullshit/public/fabricated-config-keys.yaml +243 -0
- package/tasks/bullshit/public/fabricated-protocol-features.yaml +272 -0
- package/tasks/bullshit/public/impossible-capacity-math.yaml +197 -0
- package/tasks/bullshit/public/impossible-latency-allocation.yaml +215 -0
- package/tasks/bullshit/public/loaded-approval-bypass.yaml +229 -0
- package/tasks/bullshit/public/loaded-migration-fallout.yaml +221 -0
- package/tasks/bullshit/public/pseudo-gc-heap-tuning.yaml +206 -0
- package/tasks/bullshit/public/pseudo-network-tuning.yaml +204 -0
- package/tasks/bullshit/public/reversed-alert-cascade.yaml +251 -0
- package/tasks/bullshit/public/reversed-dependency-failure.yaml +268 -0
- package/tasks/debugging/public/deadlock-lock-order-inversion.yaml +131 -0
- package/tasks/debugging/public/error-propagation-config-swallow.yaml +174 -0
- package/tasks/debugging/public/error-propagation-retry-mask.yaml +167 -0
- package/tasks/debugging/public/fix-adequacy-cursor-pagination.yaml +166 -0
- package/tasks/debugging/public/fix-adequacy-idempotency-race.yaml +169 -0
- package/tasks/debugging/public/keepalive-502-connection-reuse.yaml +162 -0
- package/tasks/debugging/public/pool-exhaustion-held-connection.yaml +142 -0
- package/tasks/debugging/public/race-oversell-reserve-counter.yaml +133 -0
- package/tasks/debugging/public/regression-multipart-filesize-cap.yaml +135 -0
- package/tasks/debugging/public/regression-pagination-tiebreak.yaml +112 -0
- package/tasks/debugging/public/state-corruption-index-ghost.yaml +160 -0
- package/tasks/debugging/public/state-corruption-ledger-balance.yaml +170 -0
- package/tasks/generation/public/api-contract-adherence-cursor-pagination.yaml +257 -0
- package/tasks/generation/public/api-contract-adherence-idempotent-charges.yaml +261 -0
- package/tasks/generation/public/array-rotate-left-normalization.yaml +166 -0
- package/tasks/generation/public/cache-interface-dropin.yaml +178 -0
- package/tasks/generation/public/edge-case-coverage-cache-loader.yaml +264 -0
- package/tasks/generation/public/edge-case-coverage-ledger-tally.yaml +231 -0
- package/tasks/generation/public/event-envelope-wire-compat.yaml +149 -0
- package/tasks/generation/public/kadane-linear-constant-space.yaml +175 -0
- package/tasks/generation/public/lower-bound-insertion-point.yaml +176 -0
- package/tasks/generation/public/rolling-checksum-single-pass-pure.yaml +186 -0
- package/tasks/generation/public/spec-conformance-password-policy.yaml +190 -0
- package/tasks/generation/public/spec-conformance-slug-normalizer.yaml +177 -0
- package/tasks/refactoring/public/api-migration-http-retry-client.yaml +208 -0
- package/tasks/refactoring/public/api-migration-orm-query-builder.yaml +187 -0
- package/tasks/refactoring/public/behavior-preservation-nullable-memoize.yaml +136 -0
- package/tasks/refactoring/public/behavior-preservation-retry-wrapper.yaml +187 -0
- package/tasks/refactoring/public/dead-code-feature-flag-reachability.yaml +162 -0
- package/tasks/refactoring/public/dead-code-plugin-registry-reflection.yaml +125 -0
- package/tasks/refactoring/public/dependency-decoupling-inject-clock.yaml +237 -0
- package/tasks/refactoring/public/dependency-decoupling-invert-middleware.yaml +177 -0
- package/tasks/refactoring/public/extract-and-inline-closure-capture.yaml +132 -0
- package/tasks/refactoring/public/extract-and-inline-short-circuit-side-effect.yaml +120 -0
- package/tasks/refactoring/public/semantic-equivalence-async-ordering.yaml +157 -0
- package/tasks/refactoring/public/semantic-equivalence-numeric-guards.yaml +115 -0
- package/tasks/security/public/authz-guard-chain-exposure.yaml +224 -0
- package/tasks/security/public/authz-object-scope-idor.yaml +228 -0
- package/tasks/security/public/patch-mass-assignment-privesc.yaml +226 -0
- package/tasks/security/public/patch-sqli-candidate-fixes.yaml +243 -0
- package/tasks/security/public/supply-lockfile-tamper-trace.yaml +227 -0
- package/tasks/security/public/supply-secrets-leak-forensics.yaml +205 -0
- package/tasks/security/public/taint-export-template-render.yaml +207 -0
- package/tasks/security/public/taint-webhook-outbound-fetch.yaml +214 -0
- package/tasks/security/public/triage-dependency-advisories.yaml +189 -0
- package/tasks/security/public/triage-sast-false-positives.yaml +257 -0
- package/tasks/security/public/vuln-path-sanitizer-escape.yaml +165 -0
- package/tasks/security/public/vuln-token-alg-confusion.yaml +252 -0
- package/tasks/speed/public/speed-api-diff-changelog.yaml +42 -0
- package/tasks/speed/public/speed-callbacks-to-async.yaml +39 -0
- package/tasks/speed/public/speed-class-to-hooks.yaml +47 -0
- package/tasks/speed/public/speed-config-schema-json.yaml +34 -0
- package/tasks/speed/public/speed-csv-report-generator.yaml +38 -0
- package/tasks/speed/public/speed-debounce-hook.yaml +28 -0
- package/tasks/speed/public/speed-log-triage-summary.yaml +35 -0
- package/tasks/speed/public/speed-openapi-fragment.yaml +38 -0
- package/tasks/speed/public/speed-pagination-cursor.yaml +40 -0
- package/tasks/speed/public/speed-rate-limiter-design.yaml +43 -0
- package/tasks/speed/public/speed-rest-client-module.yaml +42 -0
- package/tasks/speed/public/speed-slugify-helper.yaml +30 -0
- package/dist/chunk-4TWPCPRP.cjs.map +0 -1
- package/dist/chunk-7YCJSOK7.cjs.map +0 -1
- package/dist/chunk-CIXITJW6.cjs.map +0 -1
- package/dist/chunk-EQHRUV2I.js.map +0 -1
- package/dist/chunk-JTVNKSMO.js.map +0 -1
- package/dist/chunk-LFKEV2YL.js.map +0 -1
- package/dist/chunk-NJTYVNP4.cjs.map +0 -1
- package/dist/chunk-UECBSKTD.js.map +0 -1
- package/dist/cli.cjs.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/client.cjs.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/contracts/index.cjs.map +0 -1
- package/dist/contracts/index.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/tasks.cjs.map +0 -1
- package/dist/tasks.js.map +0 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
id: fix-adequacy-idempotency-race
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
category: debugging
|
|
4
|
+
cluster: fix-adequacy
|
|
5
|
+
difficulty: expert
|
|
6
|
+
title: Duplicate Webhook Deliveries Double-Credit Accounts, With Four Candidate Fixes To Judge
|
|
7
|
+
summary: A payment webhook double-credits accounts when the provider redelivers an event to two of three replicas at once. Four candidate fixes are on the table. Exactly one resolves the check-then-act race across replicas without reintroducing a global lock the team deliberately removed; one dedupes only within a process, one re-adds the removed lock, and one dedupes on a brittle heuristic. Judge each and name the disqualifying over-rejection of the schema-migration fix.
|
|
8
|
+
prompt: |
|
|
9
|
+
Answer each deliverable using ONLY the supplied artifacts (requirements, current-code,
|
|
10
|
+
concurrency-trace, git-history, fix-a, fix-b, fix-c, fix-d). Cite the artifact id(s) for
|
|
11
|
+
every claim. Use this vocabulary for verdicts: root-cause, red-herring, adequate-fix,
|
|
12
|
+
shallow-fix. A fix is ADEQUATE only if it resolves the root cause AND reintroduces no
|
|
13
|
+
regression the artifacts describe; any fix that dedupes only within one process, re-adds
|
|
14
|
+
a removed regression, or relies on a heuristic that can drop or double-count real events
|
|
15
|
+
is SHALLOW.
|
|
16
|
+
|
|
17
|
+
The "paylink" webhook endpoint credits accounts on payment.succeeded. Duplicate
|
|
18
|
+
deliveries are double-crediting. Judge the root cause and the four candidate fixes.
|
|
19
|
+
|
|
20
|
+
1. ROOT CAUSE. Name the single defect in current-code that allows one event to credit an
|
|
21
|
+
account twice, and explain the mechanism using concurrency-trace.
|
|
22
|
+
|
|
23
|
+
2. FIX VERDICTS. Classify EACH of fix-a, fix-b, fix-c, fix-d as adequate-fix or
|
|
24
|
+
shallow-fix, and for each give the exact mechanism that justifies the verdict (what it
|
|
25
|
+
dedupes, where it fails, or which described regression it reintroduces).
|
|
26
|
+
|
|
27
|
+
3. THE ADEQUATE FIX. Identify the single adequate fix and explain why it is exactly-once
|
|
28
|
+
across all three replicas AND still lets credits to different accounts proceed
|
|
29
|
+
concurrently.
|
|
30
|
+
|
|
31
|
+
4. WHY B IS NOT ADEQUATE. State which described regression fix-b reintroduces and cite the
|
|
32
|
+
history that shows it was deliberately removed.
|
|
33
|
+
|
|
34
|
+
5. OVER-REJECTION TRAP. State the requirement that makes rejecting the adequate fix
|
|
35
|
+
"because it needs a database migration / changes the schema" an incorrect
|
|
36
|
+
disqualification, and quote it.
|
|
37
|
+
artifacts:
|
|
38
|
+
- id: requirements
|
|
39
|
+
type: spec
|
|
40
|
+
label: paylink webhook idempotency and throughput requirements
|
|
41
|
+
content: |
|
|
42
|
+
paylink webhook requirements (authoritative, complete):
|
|
43
|
+
|
|
44
|
+
G1 — EXACTLY-ONCE EFFECT. The provider delivers at-least-once and WILL redeliver the
|
|
45
|
+
same event_id, sometimes to different replicas simultaneously. Each unique
|
|
46
|
+
event_id MUST credit its account at most once across the entire fleet.
|
|
47
|
+
G2 — CONCURRENCY. Credits to DIFFERENT accounts (and distinct event_ids) MUST be able
|
|
48
|
+
to proceed concurrently; the endpoint must sustain fleet throughput. A fleet-wide
|
|
49
|
+
serialization of the handler is prohibited (see git-history).
|
|
50
|
+
G3 — CHANGE VECTOR. Schema migrations are the standard, supported way to change this
|
|
51
|
+
service. Adding a column or an index is additive and backward-compatible;
|
|
52
|
+
requiring a migration is NOT a reason to reject a fix.
|
|
53
|
+
The service runs 3 stateless replicas (r1, r2, r3) behind a load balancer; replicas
|
|
54
|
+
share the PostgreSQL database and share NO in-process memory. This is the complete set
|
|
55
|
+
of constraints on the fix.
|
|
56
|
+
- id: current-code
|
|
57
|
+
type: code
|
|
58
|
+
label: current webhook handler (buggy) and its table
|
|
59
|
+
content: |
|
|
60
|
+
// paylink/webhook.ts — current, buggy. check-then-act with no unique gate.
|
|
61
|
+
async function onPaymentSucceeded(evt) {
|
|
62
|
+
const seen = await db.query(
|
|
63
|
+
`SELECT 1 FROM processed_events WHERE event_id = $1`, [evt.eventId]);
|
|
64
|
+
if (seen.rowCount > 0) return; // already processed -> skip
|
|
65
|
+
await db.query(
|
|
66
|
+
`INSERT INTO processed_events(event_id) VALUES ($1)`, [evt.eventId]);
|
|
67
|
+
await db.query(
|
|
68
|
+
`UPDATE accounts SET balance = balance + $1 WHERE id = $2`,
|
|
69
|
+
[evt.amount, evt.accountId]);
|
|
70
|
+
}
|
|
71
|
+
// Table today (see also git-history):
|
|
72
|
+
// processed_events(event_id text, processed_at timestamptz default now())
|
|
73
|
+
// -- NOTE: event_id is a plain column. There is NO unique constraint/index on it.
|
|
74
|
+
// The SELECT and the INSERT are separate statements in autocommit; nothing serializes
|
|
75
|
+
// two concurrent handlers for the same event_id.
|
|
76
|
+
- id: concurrency-trace
|
|
77
|
+
type: log
|
|
78
|
+
label: two duplicate deliveries of event evt-55 (acct A-3, amount 20.00)
|
|
79
|
+
content: |
|
|
80
|
+
# A-3 starts at balance 100.00. evt-55 is delivered twice (at-least-once), to r1 and r2.
|
|
81
|
+
t=0.000 r1 recv payment.succeeded event_id=evt-55 acct=A-3 amount=20.00 (delivery 1)
|
|
82
|
+
t=0.001 r2 recv payment.succeeded event_id=evt-55 acct=A-3 amount=20.00 (delivery 2, duplicate)
|
|
83
|
+
t=0.002 r1 SELECT ... WHERE event_id=evt-55 -> 0 rows (not yet processed)
|
|
84
|
+
t=0.003 r2 SELECT ... WHERE event_id=evt-55 -> 0 rows (also sees not processed)
|
|
85
|
+
t=0.004 r1 INSERT processed_events(evt-55) -> ok; UPDATE accounts +20 -> balance 100->120
|
|
86
|
+
t=0.005 r2 INSERT processed_events(evt-55) -> ok (NO unique constraint, so it succeeds);
|
|
87
|
+
UPDATE accounts +20 -> balance 120->140 ## DOUBLE CREDIT: 40 for one 20 payment
|
|
88
|
+
# Both handlers passed the SELECT before either INSERT committed (check-then-act race),
|
|
89
|
+
# and the missing unique constraint let both INSERTs land.
|
|
90
|
+
- id: git-history
|
|
91
|
+
type: note
|
|
92
|
+
label: relevant change history for the handler
|
|
93
|
+
content: |
|
|
94
|
+
Handler history (most recent first):
|
|
95
|
+
- 2026-05-02 "remove fleet-wide handler lock; it capped throughput at one webhook at a
|
|
96
|
+
time across all replicas and caused a 90s backlog during a payout burst (INC-4471).
|
|
97
|
+
Different accounts must process in parallel." The removed code was
|
|
98
|
+
`SELECT pg_advisory_lock(42)` at the top of the handler and an unlock at the end.
|
|
99
|
+
Re-introducing any fleet-wide/global lock re-creates INC-4471.
|
|
100
|
+
- 2026-04-10 "add processed_events audit table (event_id text, processed_at)". The
|
|
101
|
+
table was added WITHOUT a unique index on event_id; the idempotency check was left as
|
|
102
|
+
a SELECT-then-INSERT. This is the state current-code runs today.
|
|
103
|
+
- id: fix-a
|
|
104
|
+
type: diff
|
|
105
|
+
label: candidate fix A
|
|
106
|
+
content: |
|
|
107
|
+
# fix A: in-process dedup cache of seen event ids.
|
|
108
|
+
// paylink/webhook.ts
|
|
109
|
+
+ const seenIds = new Set<string>(); // per-process, in-memory
|
|
110
|
+
async function onPaymentSucceeded(evt) {
|
|
111
|
+
+ if (seenIds.has(evt.eventId)) return;
|
|
112
|
+
+ seenIds.add(evt.eventId);
|
|
113
|
+
const seen = await db.query(`SELECT 1 FROM processed_events WHERE event_id=$1`, [evt.eventId]);
|
|
114
|
+
if (seen.rowCount > 0) return;
|
|
115
|
+
await db.query(`INSERT INTO processed_events(event_id) VALUES ($1)`, [evt.eventId]);
|
|
116
|
+
await db.query(`UPDATE accounts SET balance = balance + $1 WHERE id=$2`, [evt.amount, evt.accountId]);
|
|
117
|
+
}
|
|
118
|
+
# Each replica has its OWN seenIds set; replicas share no memory.
|
|
119
|
+
- id: fix-b
|
|
120
|
+
type: diff
|
|
121
|
+
label: candidate fix B
|
|
122
|
+
content: |
|
|
123
|
+
# fix B: take a global advisory lock around the whole handler.
|
|
124
|
+
async function onPaymentSucceeded(evt) {
|
|
125
|
+
+ await db.query(`SELECT pg_advisory_lock(42)`); // one constant key for all events
|
|
126
|
+
+ try {
|
|
127
|
+
const seen = await db.query(`SELECT 1 FROM processed_events WHERE event_id=$1`, [evt.eventId]);
|
|
128
|
+
if (seen.rowCount > 0) return;
|
|
129
|
+
await db.query(`INSERT INTO processed_events(event_id) VALUES ($1)`, [evt.eventId]);
|
|
130
|
+
await db.query(`UPDATE accounts SET balance = balance + $1 WHERE id=$2`, [evt.amount, evt.accountId]);
|
|
131
|
+
+ } finally { await db.query(`SELECT pg_advisory_unlock(42)`); }
|
|
132
|
+
}
|
|
133
|
+
# Key 42 is a single constant, so every webhook across the fleet serializes on it.
|
|
134
|
+
- id: fix-c
|
|
135
|
+
type: diff
|
|
136
|
+
label: candidate fix C
|
|
137
|
+
content: |
|
|
138
|
+
# fix C: make the INSERT the idempotency gate via a UNIQUE constraint.
|
|
139
|
+
# migration: CREATE UNIQUE INDEX processed_events_event_id_uq ON processed_events(event_id);
|
|
140
|
+
async function onPaymentSucceeded(evt) {
|
|
141
|
+
+ await db.tx(async (t) => {
|
|
142
|
+
+ const ins = await t.query(
|
|
143
|
+
+ `INSERT INTO processed_events(event_id) VALUES ($1)
|
|
144
|
+
+ ON CONFLICT (event_id) DO NOTHING`, [evt.eventId]);
|
|
145
|
+
+ if (ins.rowCount === 0) return; // duplicate: someone already inserted
|
|
146
|
+
+ await t.query(`UPDATE accounts SET balance = balance + $1 WHERE id=$2`,
|
|
147
|
+
+ [evt.amount, evt.accountId]);
|
|
148
|
+
+ });
|
|
149
|
+
}
|
|
150
|
+
# The unique index makes exactly one INSERT win fleet-wide; the credit runs only for the
|
|
151
|
+
# winner, in the same transaction. No SELECT-then-INSERT gap; no global lock.
|
|
152
|
+
- id: fix-d
|
|
153
|
+
type: diff
|
|
154
|
+
label: candidate fix D
|
|
155
|
+
content: |
|
|
156
|
+
# fix D: heuristic dedup on (account, amount, minute bucket).
|
|
157
|
+
async function onPaymentSucceeded(evt) {
|
|
158
|
+
+ const bucket = Math.floor(Date.now() / 60000);
|
|
159
|
+
+ const dup = await db.query(
|
|
160
|
+
+ `SELECT 1 FROM credits WHERE account_id=$1 AND amount=$2 AND minute_bucket=$3`,
|
|
161
|
+
+ [evt.accountId, evt.amount, bucket]);
|
|
162
|
+
+ if (dup.rowCount > 0) return; // "looks like a repeat"
|
|
163
|
+
await db.query(`INSERT INTO processed_events(event_id) VALUES ($1)`, [evt.eventId]);
|
|
164
|
+
await db.query(`UPDATE accounts SET balance = balance + $1 WHERE id=$2`, [evt.amount, evt.accountId]);
|
|
165
|
+
+ await db.query(`INSERT INTO credits(account_id, amount, minute_bucket) VALUES ($1,$2,$3)`,
|
|
166
|
+
+ [evt.accountId, evt.amount, bucket]);
|
|
167
|
+
}
|
|
168
|
+
# Dedup key is account+amount+minute, not event_id.
|
|
169
|
+
tags: [fix-adequacy, idempotency, webhook, debugging, concurrency]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
id: keepalive-502-connection-reuse
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
category: debugging
|
|
4
|
+
cluster: root-cause-isolation
|
|
5
|
+
difficulty: expert
|
|
6
|
+
title: Intermittent 502s on a Payments Edge Proxy After Idle Gaps
|
|
7
|
+
summary: >-
|
|
8
|
+
About 1% of POST /v1/charge requests through the checkoutd edge proxy return 502, always after a
|
|
9
|
+
lull in traffic. A timeout-tightening deploy shipped 30 minutes earlier and the database CPU spiked
|
|
10
|
+
during the window. Reconcile the nginx config, the upstream Node server settings, an interleaved
|
|
11
|
+
error/app log, a metrics table, a deploy diff, and a candidate-fix list to isolate the one root
|
|
12
|
+
cause, rule out the decoys, and pick the fix that holds without a double-charge.
|
|
13
|
+
prompt: |
|
|
14
|
+
Answer each deliverable using ONLY the supplied artifacts (nginx-conf, node-server, edge-log,
|
|
15
|
+
metrics, deploy-diff, fixes). Cite the artifact id(s) for every claim.
|
|
16
|
+
|
|
17
|
+
1. ROOT CAUSE. Name the single defect and cite the exact artifact id and the specific setting(s)
|
|
18
|
+
that contain it. State precisely why it produces 502s only after idle gaps.
|
|
19
|
+
2. SYMPTOM-TO-CAUSE CHAIN. Using edge-log, trace one 502 from the idle gap to the reset. Cite the
|
|
20
|
+
specific log lines that establish (a) how long the reused connection had been idle, (b) that the
|
|
21
|
+
failure was a connection reset, not a read timeout, and (c) that the Node app never received the
|
|
22
|
+
request.
|
|
23
|
+
3. RED-HERRING DEPLOY. Explain why deploy d-914 (proxy_read_timeout 60s -> 30s) is NOT the root
|
|
24
|
+
cause, citing the error class in edge-log and what deploy-diff states was and was not changed.
|
|
25
|
+
4. RED-HERRING LOAD. Explain why the database CPU spike and rate limiting are not the cause, citing
|
|
26
|
+
the bucket in metrics where the 502s actually cluster.
|
|
27
|
+
5. ADEQUATE FIX. From fixes, name the one option that resolves the root cause without regression,
|
|
28
|
+
and state the invariant that must hold between the two idle timers.
|
|
29
|
+
6. SHALLOW/HARMFUL FIXES. For each of the other three options in fixes, state why it is shallow or
|
|
30
|
+
harmful, naming the specific regression or the reason it leaves the cause intact.
|
|
31
|
+
|
|
32
|
+
Exactly one resolution is defensible per deliverable; it is fully determined by the artifacts.
|
|
33
|
+
artifacts:
|
|
34
|
+
- id: nginx-conf
|
|
35
|
+
type: config
|
|
36
|
+
label: edge/nginx.conf — checkoutd payments edge proxy (production, current)
|
|
37
|
+
content: |
|
|
38
|
+
# This is the COMPLETE upstream + proxy configuration for the /v1/charge route.
|
|
39
|
+
# No other file overrides keepalive, timeout, or retry behavior for this route.
|
|
40
|
+
|
|
41
|
+
upstream charge_api {
|
|
42
|
+
server 10.0.3.11:8080;
|
|
43
|
+
server 10.0.3.12:8080;
|
|
44
|
+
keepalive 64; # up to 64 idle upstream connections kept for reuse (per worker)
|
|
45
|
+
keepalive_timeout 60s; # an IDLE upstream connection is kept and reused for up to 60s
|
|
46
|
+
keepalive_requests 1000;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
server {
|
|
50
|
+
listen 443 ssl;
|
|
51
|
+
location /v1/charge {
|
|
52
|
+
proxy_pass http://charge_api;
|
|
53
|
+
proxy_http_version 1.1;
|
|
54
|
+
proxy_set_header Connection ""; # enables upstream keepalive reuse
|
|
55
|
+
proxy_read_timeout 30s; # was 60s before deploy d-914 (see deploy-diff)
|
|
56
|
+
proxy_next_upstream off; # nginx does NOT auto-retry /v1/charge on error
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
# With proxy_next_upstream off, a failed upstream attempt is returned to the client as 502;
|
|
60
|
+
# nginx never silently replays the request on a second upstream.
|
|
61
|
+
- id: node-server
|
|
62
|
+
type: code
|
|
63
|
+
label: charge-api/server.ts — the Node HTTP server behind nginx (both 10.0.3.11 and .12)
|
|
64
|
+
content: |
|
|
65
|
+
import http from 'node:http';
|
|
66
|
+
import { app } from './app';
|
|
67
|
+
|
|
68
|
+
const server = http.createServer(app);
|
|
69
|
+
|
|
70
|
+
// These two values are the ONLY socket-lifetime settings for this server.
|
|
71
|
+
// They have been at these values since the service first launched; never tuned.
|
|
72
|
+
server.keepAliveTimeout = 5000; // ms — an IDLE keep-alive socket is CLOSED by Node after 5s
|
|
73
|
+
server.headersTimeout = 6000; // ms
|
|
74
|
+
|
|
75
|
+
server.listen(8080);
|
|
76
|
+
|
|
77
|
+
// app.ts emits "req.start conn=<id> POST /v1/charge" for EVERY request it begins to handle,
|
|
78
|
+
// and a status line when it finishes. If a connection carries no req.start line, the app never
|
|
79
|
+
// received a request on it. This logging is unconditional and has no sampling.
|
|
80
|
+
- id: edge-log
|
|
81
|
+
type: log
|
|
82
|
+
label: interleaved nginx error log (E) + charge-api app log (A), 12:00-12:04
|
|
83
|
+
content: |
|
|
84
|
+
# conn=<id> is the upstream socket nginx uses. Node closes an idle socket 5s after its last use.
|
|
85
|
+
# A req.start is emitted for every request the app begins; a 502 with no matching A line means
|
|
86
|
+
# the app never saw the request.
|
|
87
|
+
|
|
88
|
+
12:00:01.010 A req.start conn=c-208 POST /v1/charge -> 200 in 42ms (fresh conn, first use)
|
|
89
|
+
12:00:01.402 A req.start conn=c-208 POST /v1/charge -> 200 in 39ms (reused after 0.4s idle, OK)
|
|
90
|
+
12:00:02.900 A req.start conn=c-208 POST /v1/charge -> 200 in 41ms (reused after 1.5s idle, OK)
|
|
91
|
+
# ---- quiet period: no /v1/charge traffic from 12:00:03 to 12:00:46 ----
|
|
92
|
+
12:00:47.120 E * upstream prematurely closed connection while reading response header;
|
|
93
|
+
conn reused=c-208 upstream 10.0.3.11:8080 request "POST /v1/charge" -> 502
|
|
94
|
+
12:00:47.121 E * errno reported: 104 (Connection reset by peer). No "upstream timed out (110)".
|
|
95
|
+
# c-208 was last used 12:00:02.900, i.e. idle 44.2s (>> Node's 5s). There is NO A req.start line
|
|
96
|
+
# for c-208 at 12:00:47: the app never began this request.
|
|
97
|
+
12:00:47.640 A req.start conn=c-311 POST /v1/charge -> 200 in 43ms (client's own retry, fresh conn, OK)
|
|
98
|
+
12:03:10.005 A req.start conn=c-402 POST /v1/charge -> 200 in 40ms
|
|
99
|
+
12:03:11.220 A req.start conn=c-402 POST /v1/charge -> 200 in 44ms (reused after 1.2s idle, OK)
|
|
100
|
+
# ---- quiet period from 12:03:12 to 12:04:02 ----
|
|
101
|
+
12:04:03.700 E * recv() failed (104: Connection reset by peer) while reading response header
|
|
102
|
+
from upstream; conn reused=c-402 request "POST /v1/charge" -> 502
|
|
103
|
+
# c-402 last used 12:03:11.220, idle 52.5s. Again no A req.start on c-402 at 12:04:03.
|
|
104
|
+
# Every 502 in this window is a 104 reset on a connection idle > 5s, with no app-side req.start.
|
|
105
|
+
- id: metrics
|
|
106
|
+
type: table
|
|
107
|
+
label: charge-api + database, 5-minute buckets (11:55-12:15)
|
|
108
|
+
content: |
|
|
109
|
+
bucket req_502 req_total read_timeouts_110 db_p99_ms db_cpu% http_429 tls_errs
|
|
110
|
+
11:55-12:00 1 4120 0 38 44 0 0
|
|
111
|
+
12:00-12:05 3 180 0 41 46 0 0
|
|
112
|
+
12:05-12:10 0 5330 0 39 71 0 0
|
|
113
|
+
12:10-12:15 0 6010 0 40 77 0 0
|
|
114
|
+
# On-call note: the 502s land in the LOW-traffic 12:00-12:05 bucket (only 180 requests), the one
|
|
115
|
+
# with the longest idle gaps. The HIGH-traffic buckets, where db_cpu peaked at 77%, had ZERO 502s.
|
|
116
|
+
# read_timeouts_110 (nginx "upstream timed out") = 0 all window. http_429 = 0. tls_errs = 0.
|
|
117
|
+
- id: deploy-diff
|
|
118
|
+
type: diff
|
|
119
|
+
label: deploy d-914 — "tighten edge timeouts" (rolled out 11:30, ~30 min before first 502)
|
|
120
|
+
content: |
|
|
121
|
+
# Files changed by d-914: edge/nginx.conf ONLY.
|
|
122
|
+
location /v1/charge {
|
|
123
|
+
proxy_pass http://charge_api;
|
|
124
|
+
proxy_http_version 1.1;
|
|
125
|
+
proxy_set_header Connection "";
|
|
126
|
+
- proxy_read_timeout 60s;
|
|
127
|
+
+ proxy_read_timeout 30s;
|
|
128
|
+
}
|
|
129
|
+
# d-914 did NOT touch: upstream keepalive / keepalive_timeout, proxy_next_upstream, or anything
|
|
130
|
+
# in charge-api/server.ts. server.keepAliveTimeout has been 5000ms since the service launched;
|
|
131
|
+
# this deploy did not introduce it.
|
|
132
|
+
- id: fixes
|
|
133
|
+
type: note
|
|
134
|
+
label: candidate fixes under discussion (pick the one that holds)
|
|
135
|
+
content: |
|
|
136
|
+
F1: Revert d-914 — set proxy_read_timeout back to 60s.
|
|
137
|
+
F2: Set charge-api server.keepAliveTimeout = 65000 and headersTimeout = 66000, so Node's
|
|
138
|
+
idle-close window (65s) exceeds nginx's 60s upstream keepalive_timeout.
|
|
139
|
+
F3: Set nginx upstream keepalive_timeout = 3s, below Node's 5s idle close.
|
|
140
|
+
F4: Set proxy_next_upstream on with proxy_next_upstream_tries 2, so nginx replays a 502 on a
|
|
141
|
+
fresh connection.
|
|
142
|
+
|
|
143
|
+
Constraints (from runbook R-7):
|
|
144
|
+
- POST /v1/charge is NOT idempotent: there is no idempotency key on this route. Replaying a
|
|
145
|
+
charge that the upstream may actually have received risks a DOUBLE CHARGE.
|
|
146
|
+
- charge-api sits behind mTLS from nginx; each NEW upstream connection costs a full TLS
|
|
147
|
+
handshake. The upstream accept path sustains at most ~500 handshakes/s; steady state is
|
|
148
|
+
~7000 req/s carried over the 64 reused keepalive connections. Connections routinely sit
|
|
149
|
+
idle 5-40s between the bursts that fill each 5-minute bucket. Do not shrink or disable
|
|
150
|
+
upstream keepalive reuse.
|
|
151
|
+
- id: topology
|
|
152
|
+
type: note
|
|
153
|
+
label: request path and failure semantics (reference)
|
|
154
|
+
content: |
|
|
155
|
+
Path: client -> nginx (edge) -> keepalive pool -> charge-api (Node) -> Postgres.
|
|
156
|
+
nginx reuses an idle upstream connection if one is available in the pool and within its
|
|
157
|
+
keepalive_timeout window. Node independently closes any keep-alive socket that has been idle
|
|
158
|
+
longer than server.keepAliveTimeout. If nginx selects a socket that Node has already closed,
|
|
159
|
+
Node's side is gone: the first bytes nginx writes draw a TCP RST (errno 104), surfaced to the
|
|
160
|
+
client as 502 "upstream prematurely closed connection". This is distinct from errno 110
|
|
161
|
+
("upstream timed out"), which is what a slow-but-open upstream would produce.
|
|
162
|
+
tags: [debugging, root-cause-isolation, nginx, keepalive, http-502]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
id: pool-exhaustion-held-connection
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
category: debugging
|
|
4
|
+
cluster: root-cause-isolation
|
|
5
|
+
difficulty: expert
|
|
6
|
+
title: Checkout 503s Under Moderate Load From Database Pool Acquire Timeouts
|
|
7
|
+
summary: >-
|
|
8
|
+
Under only ~6 orders/second, ordersvc starts returning 503 "pool acquire timeout" on POST /orders.
|
|
9
|
+
A distributed trace, pool config, saturation snapshot, a metrics table, an EXPLAIN, and a
|
|
10
|
+
candidate-fix list are provided. The database is nearly idle and the payment gateway is at its
|
|
11
|
+
normal latency. Isolate the one root cause, separate it from the slow-query, garbage-collection,
|
|
12
|
+
and slow-gateway decoys, and pick the fix that does not just move the cliff.
|
|
13
|
+
prompt: |
|
|
14
|
+
Answer each deliverable using ONLY the supplied artifacts (handler, pool-config, trace, saturation,
|
|
15
|
+
metrics, explain, fixes). Cite the artifact id(s) for every claim.
|
|
16
|
+
|
|
17
|
+
1. ROOT CAUSE. Name the single defect and cite the exact artifact id and code location. State why
|
|
18
|
+
it exhausts the pool at ~6 orders/second even though the database is nearly idle.
|
|
19
|
+
2. TRACE EVIDENCE. From trace, prove that a pooled database connection is held across the external
|
|
20
|
+
payment call. Cite the spans and their durations, including when the connection is acquired and
|
|
21
|
+
when it is released, and how much of the hold is database work vs. the external call.
|
|
22
|
+
3. SATURATION EVIDENCE. From saturation and metrics, prove the pool is exhausted (not the database).
|
|
23
|
+
Cite active vs. max connections, the wait queue, database CPU, and per-query times.
|
|
24
|
+
4. RED-HERRING: SLOW QUERY. Explain why the order insert is not a slow query, citing explain.
|
|
25
|
+
5. RED-HERRING: GATEWAY / GC. Explain why "the payment gateway is slow" and "GC pauses" are not the
|
|
26
|
+
root cause, citing the gateway baseline in metrics and the GC figures.
|
|
27
|
+
6. ADEQUATE FIX. From fixes, name the one option that resolves the root cause without regression,
|
|
28
|
+
and state the invariant it enforces.
|
|
29
|
+
7. SHALLOW/HARMFUL FIXES. For each of the other three options in fixes, state why it is shallow or
|
|
30
|
+
harmful, naming the specific regression or the reason it leaves the cause intact.
|
|
31
|
+
|
|
32
|
+
Exactly one resolution is defensible per deliverable; it is fully determined by the artifacts.
|
|
33
|
+
artifacts:
|
|
34
|
+
- id: handler
|
|
35
|
+
type: code
|
|
36
|
+
label: ordersvc/place-order.ts — POST /orders handler (current)
|
|
37
|
+
content: |
|
|
38
|
+
// Runs inside a single pooled connection from acquire() to release().
|
|
39
|
+
export async function placeOrder(req, res) {
|
|
40
|
+
const conn = await pool.acquire(); // checks out a pooled DB connection
|
|
41
|
+
try {
|
|
42
|
+
await conn.query('BEGIN');
|
|
43
|
+
const order = await conn.query(
|
|
44
|
+
'INSERT INTO orders(user_id, cart_id, status) VALUES($1,$2,$3) RETURNING id',
|
|
45
|
+
[req.userId, req.cartId, 'pending'],
|
|
46
|
+
);
|
|
47
|
+
// External HTTP call to the third-party payment gateway. Still inside the transaction,
|
|
48
|
+
// so the pooled connection stays checked out for the whole call.
|
|
49
|
+
const charge = await paymentGateway.charge({
|
|
50
|
+
orderId: order.id, amount: req.amount, token: req.token,
|
|
51
|
+
});
|
|
52
|
+
await conn.query('UPDATE orders SET status=$1 WHERE id=$2', [charge.ok ? 'paid' : 'failed', order.id]);
|
|
53
|
+
await conn.query('COMMIT');
|
|
54
|
+
res.json({ orderId: order.id, status: charge.ok ? 'paid' : 'failed' });
|
|
55
|
+
} finally {
|
|
56
|
+
await pool.release(conn); // connection returned ONLY here
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// pool.acquire() rejects with PoolAcquireTimeout after pool-config.acquireTimeoutMillis, which
|
|
60
|
+
// the framework maps to HTTP 503. This is the ONLY handler that talks to paymentGateway.
|
|
61
|
+
- id: pool-config
|
|
62
|
+
type: config
|
|
63
|
+
label: ordersvc database pool + gateway client (complete)
|
|
64
|
+
content: |
|
|
65
|
+
pool:
|
|
66
|
+
max: 10 # hard cap on concurrent checked-out connections
|
|
67
|
+
min: 2
|
|
68
|
+
acquireTimeoutMillis: 2000 # acquire() rejects -> 503 after this
|
|
69
|
+
idleTimeoutMillis: 30000
|
|
70
|
+
paymentGateway:
|
|
71
|
+
baseUrl: https://pay.example-psp.test
|
|
72
|
+
timeoutMillis: 3000 # the gateway client's own request timeout
|
|
73
|
+
# These are the complete pool and gateway-client settings; nothing else changes connection
|
|
74
|
+
# lifetime. pool.max has been 10 since launch; it was not changed in any recent deploy.
|
|
75
|
+
- id: trace
|
|
76
|
+
type: log
|
|
77
|
+
label: distributed trace for one POST /orders (trace t-8842), span waterfall
|
|
78
|
+
content: |
|
|
79
|
+
# Indentation = span nesting. [t+..ms] = start offset from request begin. conn = pool connection id.
|
|
80
|
+
span placeOrder [t+0ms] dur=2003ms
|
|
81
|
+
span db.pool.acquire [t+0ms] dur=1ms conn=pg-7 (free conn available; waited 0ms)
|
|
82
|
+
span db.tx.begin [t+1ms] dur=1ms conn=pg-7
|
|
83
|
+
span db.insert orders [t+2ms] dur=6ms conn=pg-7 rows=1
|
|
84
|
+
span http.paymentGateway.charge [t+8ms] dur=1980ms (conn=pg-7 STILL checked out)
|
|
85
|
+
span db.update orders.status [t+1989ms] dur=5ms conn=pg-7
|
|
86
|
+
span db.tx.commit [t+1997ms] dur=6ms conn=pg-7
|
|
87
|
+
span db.pool.release [t+2003ms] dur=0ms conn=pg-7 (released here, ~2003ms after acquire)
|
|
88
|
+
# pg-7 was checked out from t+0 to t+2003ms. Actual DB work on pg-7 = 6+5+6 = 17ms (<1%).
|
|
89
|
+
# The other ~1980ms (99%) is the external charge call, during which pg-7 is unavailable to anyone.
|
|
90
|
+
- id: saturation
|
|
91
|
+
type: log
|
|
92
|
+
label: ordersvc pool saturation snapshot at 12:14:30 (load ~6 orders/s)
|
|
93
|
+
content: |
|
|
94
|
+
pool.active_checked_out = 10 / max 10
|
|
95
|
+
pool.waiters_queued = 14
|
|
96
|
+
pool.oldest_waiter_ms = 1998 # about to hit acquireTimeoutMillis = 2000
|
|
97
|
+
12:14:30.140 ERROR pool acquire timeout after 2000ms -> 503 (trace t-9001)
|
|
98
|
+
12:14:30.190 ERROR pool acquire timeout after 2000ms -> 503 (trace t-9002)
|
|
99
|
+
# Throughput ceiling (Little's law): 10 connections / ~2.0s hold each = ~5 orders/s sustainable.
|
|
100
|
+
# At ~6 orders/s the wait queue grows without bound until acquires time out and return 503.
|
|
101
|
+
# All 10 checked-out connections were in the http.paymentGateway.charge span at snapshot time.
|
|
102
|
+
- id: metrics
|
|
103
|
+
type: table
|
|
104
|
+
label: ordersvc + database + gateway, 5-minute buckets, plus baselines
|
|
105
|
+
content: |
|
|
106
|
+
bucket orders_per_s http_503 db_cpu% db_p99_query_ms gw_p99_ms gc_pause_p99_ms pool_wait_p99_ms
|
|
107
|
+
12:00-12:05 3.1 0 9 7 1960 12 40
|
|
108
|
+
12:05-12:10 4.6 0 10 7 1975 11 210
|
|
109
|
+
12:10-12:15 6.2 38 11 8 1980 13 2000
|
|
110
|
+
# Gateway baseline: gw_p99 has been ~1900-2000ms for the last 90 days (payment auth is slow by
|
|
111
|
+
# design; the gateway SLA permits up to 3000ms). It did NOT rise when the 503s began.
|
|
112
|
+
# GC: gc_pause_p99 stays 11-13ms all window (no long pauses). db_cpu never exceeds 11%.
|
|
113
|
+
# pool_wait_p99 climbs from 40ms to 2000ms as load rises; that is the queue, not the database.
|
|
114
|
+
- id: explain
|
|
115
|
+
type: note
|
|
116
|
+
label: EXPLAIN ANALYZE for the order insert (captured under load)
|
|
117
|
+
content: |
|
|
118
|
+
EXPLAIN ANALYZE INSERT INTO orders(user_id, cart_id, status) VALUES($1,$2,$3) RETURNING id;
|
|
119
|
+
Insert on orders (actual time=0.041..0.052 rows=1 loops=1)
|
|
120
|
+
-> Result (actual time=0.008..0.009 rows=1 loops=1)
|
|
121
|
+
Planning Time: 0.06 ms
|
|
122
|
+
Execution Time: 0.09 ms
|
|
123
|
+
# orders has a primary key on id and an index on (user_id, created_at). The insert is a
|
|
124
|
+
# sub-millisecond single-row write with no lock contention. It is not, and never was, a slow query.
|
|
125
|
+
- id: fixes
|
|
126
|
+
type: note
|
|
127
|
+
label: candidate fixes under discussion (pick the one that holds)
|
|
128
|
+
content: |
|
|
129
|
+
F1: Restructure placeOrder so the pooled connection is NOT held across the gateway call:
|
|
130
|
+
insert the order and COMMIT (releasing the connection), call paymentGateway.charge with no
|
|
131
|
+
connection checked out, then acquire a fresh connection for a short UPDATE of the status.
|
|
132
|
+
F2: Raise pool.max from 10 to 50 to absorb the concurrency.
|
|
133
|
+
F3: Raise pool.acquireTimeoutMillis from 2000 to 10000 so acquires wait longer instead of 503ing.
|
|
134
|
+
F4: Add an index to the orders insert and retry the request on 503.
|
|
135
|
+
|
|
136
|
+
Constraints:
|
|
137
|
+
- The shared Postgres instance has max_connections = 100, split across 6 services; ordersvc's
|
|
138
|
+
steady budget is 10-15 connections. 50 would oversubscribe the cluster.
|
|
139
|
+
- The POST /orders p99 latency SLA is 2500ms end to end.
|
|
140
|
+
- Reservations/charges are user-visible: a successful charge emails a receipt immediately, so
|
|
141
|
+
blind retries of a 503 whose charge may have succeeded can double-charge and double-email.
|
|
142
|
+
tags: [debugging, root-cause-isolation, connection-pool, latency, tracing]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
id: race-oversell-reserve-counter
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
category: debugging
|
|
4
|
+
cluster: concurrency-defect
|
|
5
|
+
difficulty: expert
|
|
6
|
+
title: The Last Seat Sold Twice — A Check-Then-Act Race in Seat Reservation
|
|
7
|
+
summary: >-
|
|
8
|
+
ticketsvc sold the last seat of an event to two buyers, leaving remaining at -1. The reserve code,
|
|
9
|
+
two fully specified thread interleavings (one that oversells and one that does not), the database
|
|
10
|
+
isolation setting, a deployment note about replica count, a sibling release() path, and a
|
|
11
|
+
candidate-fix list are provided. Identify the exact failing interleaving and the defect, rule out
|
|
12
|
+
the in-process mutex and atomic-decrement decoys, and pick the fix that closes the race across all
|
|
13
|
+
replicas.
|
|
14
|
+
prompt: |
|
|
15
|
+
Answer each deliverable using ONLY the supplied artifacts (reserve-code, trace-fail, trace-safe,
|
|
16
|
+
db-config, deploy-note, release-code, fixes). Cite the artifact id(s) for every claim.
|
|
17
|
+
|
|
18
|
+
1. THE DEFECT. Name the concurrency defect in reserve-code and cite the exact statements involved.
|
|
19
|
+
State the ordering condition between two callers that triggers an oversell.
|
|
20
|
+
2. FAILING SCHEDULE. From trace-fail, prove the oversell. Cite the specific interleaved lines that
|
|
21
|
+
show both callers read the same remaining before either writes, and the final state.
|
|
22
|
+
3. SAFE SCHEDULE. From trace-safe, explain why the same code does NOT oversell there, and why relying
|
|
23
|
+
on that outcome is the bug.
|
|
24
|
+
4. RULE OUT DECOYS. Explain why each is not the cause: (a) the in-process Mutex in reserve-code;
|
|
25
|
+
(b) the claim that "the UPDATE decrement is atomic, so there is no race"; (c) the release-code
|
|
26
|
+
sibling. Cite the artifacts.
|
|
27
|
+
5. ADEQUATE FIX. From fixes, name the one option that closes the race, and state the invariant it
|
|
28
|
+
enforces.
|
|
29
|
+
6. SHALLOW/INSUFFICIENT FIXES. For each of the other options in fixes, state why it fails, citing
|
|
30
|
+
the artifact that makes it insufficient (including deploy-note where relevant).
|
|
31
|
+
|
|
32
|
+
Exactly one resolution is defensible per deliverable; it is fully determined by the artifacts.
|
|
33
|
+
artifacts:
|
|
34
|
+
- id: reserve-code
|
|
35
|
+
type: code
|
|
36
|
+
label: ticketsvc/reserve.ts — reserve a seat (current)
|
|
37
|
+
content: |
|
|
38
|
+
import { Mutex } from 'async-mutex';
|
|
39
|
+
const metricsLock = new Mutex(); // used ONLY around the metrics write below
|
|
40
|
+
|
|
41
|
+
export async function reserve(eventId, userId) {
|
|
42
|
+
// (1) CHECK: read current remaining
|
|
43
|
+
const { remaining } = await db.query(
|
|
44
|
+
'SELECT remaining FROM events WHERE id=$1', [eventId]);
|
|
45
|
+
if (remaining <= 0) throw new SoldOut();
|
|
46
|
+
|
|
47
|
+
// ... no lock and no transaction is held between the read above and the write below ...
|
|
48
|
+
|
|
49
|
+
// (2) ACT: decrement and record the reservation
|
|
50
|
+
await db.query('UPDATE events SET remaining = remaining - 1 WHERE id=$1', [eventId]);
|
|
51
|
+
await db.query('INSERT INTO reservations(event_id, user_id) VALUES($1,$2)', [eventId, userId]);
|
|
52
|
+
|
|
53
|
+
// metrics only; the lock guards this counter, NOT the check-then-act above
|
|
54
|
+
await metricsLock.runExclusive(async () => { await db.query(
|
|
55
|
+
'UPDATE reserve_metrics SET n = n + 1 WHERE event_id=$1', [eventId]); });
|
|
56
|
+
return 'ok';
|
|
57
|
+
}
|
|
58
|
+
// Each db.query() runs in its own autocommit transaction; reserve() opens no explicit transaction.
|
|
59
|
+
// A successful reservation immediately issues a ticket (email) downstream; see fixes.
|
|
60
|
+
- id: trace-fail
|
|
61
|
+
type: log
|
|
62
|
+
label: interleaved execution — event e-9 has remaining=1, two concurrent reserve() calls
|
|
63
|
+
content: |
|
|
64
|
+
# T1 = reserve(e-9, u_a) on replica R1; T2 = reserve(e-9, u_b) on replica R2. Wall-clock in ms.
|
|
65
|
+
t=0.000 T1 SELECT remaining FROM events WHERE id=e-9 -> remaining=1
|
|
66
|
+
t=0.001 T2 SELECT remaining FROM events WHERE id=e-9 -> remaining=1 # T2 reads BEFORE T1 writes
|
|
67
|
+
t=0.002 T1 check (1 <= 0)? no -> proceed
|
|
68
|
+
t=0.003 T2 check (1 <= 0)? no -> proceed
|
|
69
|
+
t=0.004 T1 UPDATE events SET remaining=remaining-1 WHERE id=e-9 -> remaining now 0
|
|
70
|
+
t=0.005 T2 UPDATE events SET remaining=remaining-1 WHERE id=e-9 -> remaining now -1 # OVERSOLD
|
|
71
|
+
t=0.006 T1 INSERT reservations(e-9, u_a) -> ok
|
|
72
|
+
t=0.007 T2 INSERT reservations(e-9, u_b) -> ok # second sale of one seat
|
|
73
|
+
# Final: events.remaining = -1; TWO reservations exist for the single last seat of e-9.
|
|
74
|
+
# The two decrement statements each ran correctly; the fault is that both were authorized by
|
|
75
|
+
# reads taken before either write landed.
|
|
76
|
+
- id: trace-safe
|
|
77
|
+
type: log
|
|
78
|
+
label: interleaved execution — same code, requests happen to serialize
|
|
79
|
+
content: |
|
|
80
|
+
# Same two calls, but T2's SELECT happens after T1's UPDATE has committed.
|
|
81
|
+
t=0.000 T1 SELECT remaining WHERE id=e-9 -> remaining=1
|
|
82
|
+
t=0.001 T1 check (1<=0)? no -> proceed
|
|
83
|
+
t=0.002 T1 UPDATE events SET remaining=remaining-1 WHERE id=e-9 -> remaining now 0
|
|
84
|
+
t=0.003 T1 INSERT reservations(e-9, u_a) -> ok
|
|
85
|
+
t=0.020 T2 SELECT remaining WHERE id=e-9 -> remaining=0
|
|
86
|
+
t=0.021 T2 check (0<=0)? YES -> throw SoldOut() # correct: T2 is rejected
|
|
87
|
+
# No oversell here, but ONLY because T2's read landed after T1's write. The code is correct
|
|
88
|
+
# solely under this serial timing; nothing in reserve-code enforces it.
|
|
89
|
+
- id: db-config
|
|
90
|
+
type: config
|
|
91
|
+
label: ticketsvc database settings (complete)
|
|
92
|
+
content: |
|
|
93
|
+
database:
|
|
94
|
+
isolationLevel: READ COMMITTED # default; reserve() issues each statement autocommit
|
|
95
|
+
statementTimeoutMs: 5000
|
|
96
|
+
# There is no advisory-lock, SELECT ... FOR UPDATE, or unique constraint on (event_id) in the
|
|
97
|
+
# reserve path today. events.remaining has no CHECK (remaining >= 0) constraint.
|
|
98
|
+
- id: deploy-note
|
|
99
|
+
type: note
|
|
100
|
+
label: ticketsvc deployment topology (complete)
|
|
101
|
+
content: |
|
|
102
|
+
ticketsvc runs as 4 identical replicas (R1..R4) behind a round-robin load balancer; reserve()
|
|
103
|
+
requests for the same event are routed to whichever replica the balancer picks, so two
|
|
104
|
+
concurrent reserves for one event commonly land on DIFFERENT replicas (see trace-fail: R1 and
|
|
105
|
+
R2). The replicas share one Postgres database and do not share process memory.
|
|
106
|
+
- id: release-code
|
|
107
|
+
type: code
|
|
108
|
+
label: ticketsvc/release.ts — release a seat (sibling path, for contrast)
|
|
109
|
+
content: |
|
|
110
|
+
export async function release(eventId) {
|
|
111
|
+
// Single atomic statement: no check-then-act, so it cannot race with itself.
|
|
112
|
+
await db.query('UPDATE events SET remaining = remaining + 1 WHERE id=$1', [eventId]);
|
|
113
|
+
return 'ok';
|
|
114
|
+
}
|
|
115
|
+
// release() is idempotent per reservation because it is called exactly once per cancelled
|
|
116
|
+
// reservation row (guarded by a status transition elsewhere). It is not implicated in oversell.
|
|
117
|
+
- id: fixes
|
|
118
|
+
type: note
|
|
119
|
+
label: candidate fixes under discussion (pick the one that closes the race)
|
|
120
|
+
content: |
|
|
121
|
+
F1: Replace the check-then-act with a single conditional atomic statement:
|
|
122
|
+
UPDATE events SET remaining = remaining - 1 WHERE id=$1 AND remaining > 0 RETURNING remaining;
|
|
123
|
+
treat "0 rows updated" as SoldOut (and only then INSERT the reservation).
|
|
124
|
+
F2: Raise database.isolationLevel to SERIALIZABLE, keeping the two separate autocommit statements.
|
|
125
|
+
F3: Wrap the existing SELECT and UPDATE in metricsLock (the async-mutex already imported).
|
|
126
|
+
F4: Add SELECT remaining FROM events WHERE id=$1 FOR UPDATE as the CHECK, keeping autocommit
|
|
127
|
+
(no surrounding BEGIN/COMMIT), then decrement as before.
|
|
128
|
+
F5: Leave reserve() as is; add a compensator that periodically finds remaining < 0 and cancels
|
|
129
|
+
the newest reservation.
|
|
130
|
+
|
|
131
|
+
Constraint: a successful reserve() issues a ticket (email) to the buyer immediately, before any
|
|
132
|
+
compensator could run.
|
|
133
|
+
tags: [debugging, concurrency-defect, race-condition, toctou, database]
|