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,174 @@
|
|
|
1
|
+
id: error-propagation-config-swallow
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
category: debugging
|
|
4
|
+
cluster: error-propagation
|
|
5
|
+
difficulty: expert
|
|
6
|
+
title: Shipgate Throws Undefined-Multiplier Deep in Rate Calculation, Far From Its Origin
|
|
7
|
+
summary: A shipping-rate service throws a TypeError reading a multiplier deep inside rate calculation, and a retry layer relabels it as ServiceUnavailable. A recent unrelated caching change and a suspected downstream outage are in play. Trace past the throw site and the relabelers to the true origin, distinguish the masking mechanism that hides it, and pick the fix that addresses the origin without masking bad config.
|
|
8
|
+
prompt: |
|
|
9
|
+
Answer each deliverable using ONLY the supplied artifacts (stacktrace, rate-code,
|
|
10
|
+
config-loader, zones-diff, retry-mw, error-policy, oncall-note). Cite the artifact id(s)
|
|
11
|
+
for every claim. Use this vocabulary for verdicts: root-cause, red-herring, adequate-fix,
|
|
12
|
+
shallow-fix.
|
|
13
|
+
|
|
14
|
+
Production alerts show "ServiceUnavailable: rate service degraded" but the underlying
|
|
15
|
+
crash is "TypeError: Cannot read properties of undefined (reading 'multiplier')" in
|
|
16
|
+
RateCalculator.apply. Do not stop at the throw site; trace to the true origin.
|
|
17
|
+
|
|
18
|
+
1. THROW SITE vs ORIGIN. Identify the exact line that throws and the value that is
|
|
19
|
+
undefined there, then trace up the call chain to the TRUE origin of the failure —
|
|
20
|
+
the earliest defect without which the throw could not occur. Name the origin and cite
|
|
21
|
+
it.
|
|
22
|
+
|
|
23
|
+
2. MASKING MECHANISM. Explain precisely why an operator sees a generic error far from the
|
|
24
|
+
origin rather than a clear one at the origin. Identify every layer that transforms or
|
|
25
|
+
hides the real error and what each does to it.
|
|
26
|
+
|
|
27
|
+
3. RED HERRINGS. Classify (a) the PricingCache change in zones-diff's neighboring commit,
|
|
28
|
+
(b) the suspected downstream geo-service outage, and (c) the RetryMiddleware. For each,
|
|
29
|
+
explain why it is or is not the root cause, citing evidence.
|
|
30
|
+
|
|
31
|
+
4. POLICY VIOLATION. State which artifact defines how configuration-load failures must be
|
|
32
|
+
handled, and which component violates that policy. Quote the rule.
|
|
33
|
+
|
|
34
|
+
5. FIX ADEQUACY. Three fixes are proposed: (a) add `zoneTable[zone]?.multiplier ?? 1`
|
|
35
|
+
at the throw site; (b) raise the RetryMiddleware attempt count and the alert threshold;
|
|
36
|
+
(c) make ConfigLoader stop swallowing parse errors so a malformed config aborts startup,
|
|
37
|
+
and fix the malformed zones.yaml. Classify each as adequate-fix or shallow-fix and
|
|
38
|
+
justify which resolves the origin without masking bad configuration.
|
|
39
|
+
artifacts:
|
|
40
|
+
- id: stacktrace
|
|
41
|
+
type: log
|
|
42
|
+
label: production error with surrounding request log
|
|
43
|
+
content: |
|
|
44
|
+
12:04:01 INFO boot: ConfigLoader.load('zones.yaml') ...
|
|
45
|
+
12:04:01 WARN ConfigLoader: parse failed for zones.yaml, using defaults ({}) <-- only warning emitted
|
|
46
|
+
12:04:01 INFO boot: PricingContext built (zones loaded: 0)
|
|
47
|
+
12:04:09 INFO POST /v2/rates zone=EU-WEST weight=2.4kg
|
|
48
|
+
12:04:09 ERROR RateCalculator.apply: TypeError: Cannot read properties of undefined (reading 'multiplier')
|
|
49
|
+
at RateCalculator.apply (rate.ts:41) zoneTable['EU-WEST'] is undefined
|
|
50
|
+
at PricingContext.quote (pricing-context.ts:88)
|
|
51
|
+
at RetryMiddleware.run (retry.ts:33)
|
|
52
|
+
12:04:09 WARN RetryMiddleware: attempt 1 failed, retrying
|
|
53
|
+
12:04:39 WARN RetryMiddleware: attempt 2 failed, retrying
|
|
54
|
+
12:05:09 ERROR RetryMiddleware: exhausted 3 attempts -> throw ServiceUnavailable('rate service degraded')
|
|
55
|
+
12:05:09 ERROR POST /v2/rates -> 503 ServiceUnavailable
|
|
56
|
+
# every retry re-runs the SAME calculation against the SAME empty zoneTable and fails identically.
|
|
57
|
+
- id: rate-code
|
|
58
|
+
type: code
|
|
59
|
+
label: RateCalculator.apply (throw site)
|
|
60
|
+
content: |
|
|
61
|
+
// shipgate/rate.ts
|
|
62
|
+
export class RateCalculator {
|
|
63
|
+
// zoneTable is injected by PricingContext; keyed by zone code.
|
|
64
|
+
constructor(private zoneTable: Record<string, { multiplier: number }>) {}
|
|
65
|
+
|
|
66
|
+
apply(zone: string, weightKg: number): number {
|
|
67
|
+
const base = weightKg * BASE_PER_KG;
|
|
68
|
+
const mult = this.zoneTable[zone].multiplier; // rate.ts:41 — throws when zoneTable[zone] is undefined
|
|
69
|
+
return base * mult;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// NOTE: apply assumes every zone it is asked about exists in zoneTable. It does not
|
|
73
|
+
// validate zoneTable; it is only ever as correct as the table PricingContext gives it.
|
|
74
|
+
- id: config-loader
|
|
75
|
+
type: code
|
|
76
|
+
label: ConfigLoader.load
|
|
77
|
+
content: |
|
|
78
|
+
// shipgate/config-loader.ts
|
|
79
|
+
export class ConfigLoader {
|
|
80
|
+
static load(file: string): Record<string, unknown> {
|
|
81
|
+
try {
|
|
82
|
+
const text = readFileSync(configPath(file), 'utf8');
|
|
83
|
+
return parseYaml(text); // throws on malformed YAML
|
|
84
|
+
} catch (e) {
|
|
85
|
+
// Swallow: log a warning and hand back an empty object so boot can continue.
|
|
86
|
+
logger.warn(`ConfigLoader: parse failed for ${file}, using defaults ({})`);
|
|
87
|
+
return {}; // <-- real parse error is discarded here
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
- id: zones-diff
|
|
92
|
+
type: diff
|
|
93
|
+
label: recent commits to config and pricing
|
|
94
|
+
content: |
|
|
95
|
+
# commit A "add priority tier to EU zones" — shipgate/config/zones.yaml
|
|
96
|
+
zones:
|
|
97
|
+
EU-WEST:
|
|
98
|
+
multiplier: 1.35
|
|
99
|
+
- priority: false
|
|
100
|
+
+ priority: false
|
|
101
|
+
+ express: true # <-- over-indented by one space; sibling key becomes a
|
|
102
|
+
# mapping value, making zones.yaml malformed YAML
|
|
103
|
+
US-EAST:
|
|
104
|
+
multiplier: 1.10
|
|
105
|
+
|
|
106
|
+
# commit B (unrelated, same day) "cache PricingContext for 60s" — pricing-context.ts
|
|
107
|
+
export class PricingContext {
|
|
108
|
+
+ private static cache?: { ctx: PricingContext; at: number };
|
|
109
|
+
static build() {
|
|
110
|
+
+ if (this.cache && Date.now() - this.cache.at < 60_000) return this.cache.ctx;
|
|
111
|
+
const zones = ConfigLoader.load('zones.yaml') as ZoneTable;
|
|
112
|
+
- return new PricingContext(zones);
|
|
113
|
+
+ const ctx = new PricingContext(zones);
|
|
114
|
+
+ this.cache = { ctx, at: Date.now() };
|
|
115
|
+
+ return ctx;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
# commit B changes only caching; it passes whatever ConfigLoader.load returns straight through.
|
|
119
|
+
- id: retry-mw
|
|
120
|
+
type: code
|
|
121
|
+
label: RetryMiddleware.run
|
|
122
|
+
content: |
|
|
123
|
+
// shipgate/retry.ts
|
|
124
|
+
export class RetryMiddleware {
|
|
125
|
+
async run<T>(fn: () => T): Promise<T> {
|
|
126
|
+
let attempt = 0;
|
|
127
|
+
for (;;) {
|
|
128
|
+
try { return fn(); }
|
|
129
|
+
catch (e) {
|
|
130
|
+
attempt++;
|
|
131
|
+
if (attempt >= 3) {
|
|
132
|
+
// relabels ANY exhausted failure as a generic 503, discarding the cause.
|
|
133
|
+
throw new ServiceUnavailable('rate service degraded');
|
|
134
|
+
}
|
|
135
|
+
await sleep(30_000);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Retries a pure recomputation; it does not reload config between attempts.
|
|
141
|
+
- id: error-policy
|
|
142
|
+
type: spec
|
|
143
|
+
label: shipgate configuration error-handling policy
|
|
144
|
+
content: |
|
|
145
|
+
shipgate error-handling policy (authoritative, applies to all services):
|
|
146
|
+
|
|
147
|
+
P1 — Configuration load failures MUST abort process startup with the underlying
|
|
148
|
+
parse error surfaced. A service MUST NOT boot with partial or empty configuration
|
|
149
|
+
and MUST NOT substitute silent defaults for a config file that failed to parse.
|
|
150
|
+
P2 — Retry layers MAY retry transient I/O failures. They MUST NOT retry deterministic
|
|
151
|
+
errors (a recomputation over unchanged inputs), and MUST preserve the original
|
|
152
|
+
error cause when surfacing a failure.
|
|
153
|
+
P3 — geo-service degradation is reported via the geo-health probe and a
|
|
154
|
+
GeoUnavailable error; it never manifests as an undefined-multiplier TypeError.
|
|
155
|
+
This policy is the complete contract for config and error handling.
|
|
156
|
+
- id: oncall-note
|
|
157
|
+
type: note
|
|
158
|
+
label: on-call triage thread
|
|
159
|
+
content: |
|
|
160
|
+
Triage guesses during the incident (record only; not all correct):
|
|
161
|
+
- "The 60s PricingContext cache (commit B) is serving a corrupt object." — commit B
|
|
162
|
+
only adds caching and passes ConfigLoader.load's result through unchanged
|
|
163
|
+
(zones-diff); with an empty table it caches an empty table, but it did not create
|
|
164
|
+
the emptiness.
|
|
165
|
+
- "geo-service is down and that is cascading into rate errors." — per error-policy P3,
|
|
166
|
+
geo degradation surfaces as GeoUnavailable via the geo-health probe, never as an
|
|
167
|
+
undefined-multiplier TypeError; the geo-health probe was green all window.
|
|
168
|
+
- "RetryMiddleware is the problem — it keeps failing." — RetryMiddleware only relabels
|
|
169
|
+
and re-runs the same deterministic computation; it neither caused nor could fix the
|
|
170
|
+
empty table (retry-mw).
|
|
171
|
+
- "Just null-check the multiplier and move on." — that would return a wrong price for
|
|
172
|
+
every EU-WEST quote silently instead of surfacing that zones.yaml did not load.
|
|
173
|
+
Reminder: boot logged exactly one WARN — ConfigLoader parse failed, zones loaded: 0.
|
|
174
|
+
tags: [error-propagation, config, swallowed-error, debugging, shipping]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
id: error-propagation-retry-mask
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
category: debugging
|
|
4
|
+
cluster: error-propagation
|
|
5
|
+
difficulty: expert
|
|
6
|
+
title: Batchmill Uploads Fail As Timeouts While The Real Error Is A Stale-Signature 403
|
|
7
|
+
summary: An upload worker alerts on UploadTimeout for a third of jobs and on-call blames slow storage. But the retry layer relabels every exhausted failure as a timeout, hiding a 403 SignatureExpired on each attempt, and a recent change pre-signs URLs at enqueue so queue wait eats the signature's 60s validity. Storage is healthy and the client timeout is generous. Trace past the relabeled symptom to the true origin and pick the fix that addresses it.
|
|
8
|
+
prompt: |
|
|
9
|
+
Answer each deliverable using ONLY the supplied artifacts (alert-log, upload-code,
|
|
10
|
+
signer-code, signer-diff, storage-metrics, queue-metrics, http-config, error-taxonomy).
|
|
11
|
+
Cite the artifact id(s) for every claim. Use this vocabulary for verdicts: root-cause,
|
|
12
|
+
red-herring, adequate-fix, shallow-fix.
|
|
13
|
+
|
|
14
|
+
The "batchmill" worker uploads files to object storage. About 30% of jobs alert as
|
|
15
|
+
"UploadTimeout: upstream did not respond in 30s". Do not stop at the alert's label;
|
|
16
|
+
trace to the true origin.
|
|
17
|
+
|
|
18
|
+
1. RELABELED SYMPTOM vs ORIGIN. State what the alert claims and what actually failed on
|
|
19
|
+
each attempt. Identify the TRUE origin — the earliest defect without which the failure
|
|
20
|
+
could not occur — and cite it.
|
|
21
|
+
|
|
22
|
+
2. MASKING MECHANISM. Explain precisely how a 403 SignatureExpired becomes an
|
|
23
|
+
"UploadTimeout" alert. Identify the layer that performs the relabel and why the retries
|
|
24
|
+
do not help.
|
|
25
|
+
|
|
26
|
+
3. WHICH JOBS FAIL. Using the numbers in the artifacts, state the precise condition that
|
|
27
|
+
separates the ~30% that fail from the ~70% that succeed, and show the arithmetic for
|
|
28
|
+
job u-9931.
|
|
29
|
+
|
|
30
|
+
4. RED HERRINGS. Classify (a) the storage backend latency, (b) the 30s HTTP client
|
|
31
|
+
timeout, and (c) the retry wrapper. For each, explain with evidence why it is or is not
|
|
32
|
+
the root cause.
|
|
33
|
+
|
|
34
|
+
5. FIX ADEQUACY. Three fixes are proposed: (a) raise HTTP client timeout to 120s and
|
|
35
|
+
retries to 10; (b) provision a larger storage instance to cut latency; (c) mint the
|
|
36
|
+
signature at the worker just before the put (or re-sign per attempt) so no put uses a
|
|
37
|
+
signature older than its 60s validity. Classify each as adequate-fix or shallow-fix and
|
|
38
|
+
justify which resolves the origin.
|
|
39
|
+
artifacts:
|
|
40
|
+
- id: alert-log
|
|
41
|
+
type: log
|
|
42
|
+
label: worker log for job u-9931 (representative failing job)
|
|
43
|
+
content: |
|
|
44
|
+
# u-9931 was pre-signed at enqueue, then waited in the queue before a worker ran it.
|
|
45
|
+
09:58:40 INFO enqueue u-9931: signRequest minted url, signature valid 09:58:40..09:59:40 (60s)
|
|
46
|
+
10:00:00 INFO worker dequeued u-9931 (queue wait 80s); using the url minted at enqueue
|
|
47
|
+
10:00:00 WARN storage.put attempt 1 -> HTTP 403 SignatureExpired (signature age 80s > 60s) [12ms]
|
|
48
|
+
10:00:30 WARN storage.put attempt 2 -> HTTP 403 SignatureExpired (age 110s; url NOT re-signed) [11ms]
|
|
49
|
+
10:01:00 WARN storage.put attempt 3 -> HTTP 403 SignatureExpired (age 140s) [13ms]
|
|
50
|
+
10:01:00 ERROR withRetry exhausted 3 attempts -> throw UploadTimeout('upstream did not respond in 30s')
|
|
51
|
+
10:01:00 ERROR job u-9931 failed: UploadTimeout
|
|
52
|
+
# Every attempt returns 403 in ~12ms; none times out. The final error type is fixed by code, not measured.
|
|
53
|
+
# Contrast u-9930: enqueued 10:00:10, dequeued 10:00:25 (wait 15s), put at age 15s -> 200 OK.
|
|
54
|
+
- id: upload-code
|
|
55
|
+
type: code
|
|
56
|
+
label: enqueue and worker paths with retry wrapper
|
|
57
|
+
content: |
|
|
58
|
+
// batchmill/upload.ts
|
|
59
|
+
// PRODUCER (enqueue): pre-signs the URL now and stores it on the job.
|
|
60
|
+
function enqueue(file) {
|
|
61
|
+
const url = signRequest(bucketPathFor(file)); // signed at ENQUEUE (see signer-diff)
|
|
62
|
+
queue.push({ id: file.id, url, body: file.bytes });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// WORKER (dequeue): uses the URL minted at enqueue; does not sign.
|
|
66
|
+
async function uploadJob(job) {
|
|
67
|
+
return withRetry(3, () => storageClient.put(job.url, job.body));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// withRetry: retries on ANY thrown error; after N attempts throws a FIXED UploadTimeout.
|
|
71
|
+
async function withRetry(n, fn) {
|
|
72
|
+
let attempt = 0;
|
|
73
|
+
for (;;) {
|
|
74
|
+
try { return await fn(); }
|
|
75
|
+
catch (e) {
|
|
76
|
+
attempt++;
|
|
77
|
+
if (attempt >= n) {
|
|
78
|
+
// relabels the last failure as a timeout regardless of what actually happened.
|
|
79
|
+
throw new UploadTimeout('upstream did not respond in 30s');
|
|
80
|
+
}
|
|
81
|
+
await sleep(30_000); // wait between attempts; job.url is NOT re-signed
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
- id: signer-code
|
|
86
|
+
type: code
|
|
87
|
+
label: signRequest (current)
|
|
88
|
+
content: |
|
|
89
|
+
// batchmill/signer.ts
|
|
90
|
+
export function signRequest(bucketPath: string): string {
|
|
91
|
+
// Mints a presigned URL whose signature the backend accepts for 60 seconds.
|
|
92
|
+
return presign(bucketPath, { expiresInSec: 60 });
|
|
93
|
+
}
|
|
94
|
+
// The backend returns HTTP 403 SignatureExpired for any put received more than
|
|
95
|
+
// expiresInSec seconds after the signature was minted. Signing is cheap (~0.3ms) and
|
|
96
|
+
// has no per-call side effects; a fresh signature can be minted at any point.
|
|
97
|
+
- id: signer-diff
|
|
98
|
+
type: diff
|
|
99
|
+
label: signing move shipped 2026-07-05
|
|
100
|
+
content: |
|
|
101
|
+
# commit "pre-sign at enqueue to shave worker dequeue latency" — batchmill/upload.ts
|
|
102
|
+
function enqueue(file) {
|
|
103
|
+
+ const url = signRequest(bucketPathFor(file)); // NEW: sign now, at enqueue
|
|
104
|
+
+ queue.push({ id: file.id, url, body: file.bytes });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function uploadJob(job) {
|
|
108
|
+
- const url = signRequest(job.bucketPath); // BEFORE: signed at dequeue, right before put
|
|
109
|
+
- return withRetry(3, () => storageClient.put(url, job.body));
|
|
110
|
+
+ return withRetry(3, () => storageClient.put(job.url, job.body)); // AFTER: reuse enqueue-time url
|
|
111
|
+
}
|
|
112
|
+
# BEFORE: the worker minted a fresh 60s signature immediately before the put, so queue
|
|
113
|
+
# wait did not count against it. AFTER: the signature is minted at enqueue, so the full
|
|
114
|
+
# queue wait (plus retry backoff) is charged against the 60s validity. The 60s expiry
|
|
115
|
+
# itself was not changed.
|
|
116
|
+
- id: storage-metrics
|
|
117
|
+
type: table
|
|
118
|
+
label: object-storage backend metrics (incident window)
|
|
119
|
+
content: |
|
|
120
|
+
metric value
|
|
121
|
+
----------------------------- -----------
|
|
122
|
+
put p50 latency 12 ms
|
|
123
|
+
put p99 latency 41 ms
|
|
124
|
+
5xx rate 0.00%
|
|
125
|
+
403 SignatureExpired rate 31.2% <-- matches the ~30% failing jobs
|
|
126
|
+
throttling (503 SlowDown) 0.00%
|
|
127
|
+
# The backend is healthy and fast; it REJECTS ~31% of puts for expired signatures.
|
|
128
|
+
# It is not timing out, throttling, or erroring on the server side.
|
|
129
|
+
- id: queue-metrics
|
|
130
|
+
type: table
|
|
131
|
+
label: worker queue wait distribution (incident window)
|
|
132
|
+
content: |
|
|
133
|
+
percentile queue_wait
|
|
134
|
+
---------- ----------
|
|
135
|
+
p50 22 s
|
|
136
|
+
p70 60 s <-- 70% of jobs wait <= 60s; ~30% wait longer
|
|
137
|
+
p90 145 s
|
|
138
|
+
p99 410 s
|
|
139
|
+
# queue wait is the time from enqueue (when the URL is now signed) to dequeue.
|
|
140
|
+
# Share of jobs with queue_wait > 60s ~= 30%, matching the 403 rate in storage-metrics.
|
|
141
|
+
- id: http-config
|
|
142
|
+
type: config
|
|
143
|
+
label: storage HTTP client configuration
|
|
144
|
+
content: |
|
|
145
|
+
# batchmill/http.yaml
|
|
146
|
+
storageClient:
|
|
147
|
+
connectTimeoutMs: 5000
|
|
148
|
+
requestTimeoutMs: 30000 # 30s; the "30s" quoted in the UploadTimeout string
|
|
149
|
+
maxSockets: 64
|
|
150
|
+
# The 30s in the alert text is copied from this setting; it is NOT a measured elapsed
|
|
151
|
+
# time. Observed put responses complete in ~12ms (storage-metrics, alert-log).
|
|
152
|
+
- id: error-taxonomy
|
|
153
|
+
type: spec
|
|
154
|
+
label: batchmill upload error taxonomy
|
|
155
|
+
content: |
|
|
156
|
+
batchmill error taxonomy (authoritative, complete for the upload path):
|
|
157
|
+
|
|
158
|
+
- UploadTimeout: the storage endpoint accepted the connection but sent no response
|
|
159
|
+
within requestTimeoutMs. Emitted ONLY on a genuine socket/read timeout.
|
|
160
|
+
- SignatureExpired (HTTP 403): the presigned URL's signature was older than its
|
|
161
|
+
expiresInSec when the backend received it. A retry with the SAME url cannot succeed;
|
|
162
|
+
the url must be re-signed to recover.
|
|
163
|
+
- Queue wait is unbounded and independent of signing; a job may sit in the queue for an
|
|
164
|
+
arbitrary time before a worker dequeues it.
|
|
165
|
+
There are no other upload error classes. withRetry's thrown type is a label chosen by
|
|
166
|
+
code, not a classification of the underlying HTTP status.
|
|
167
|
+
tags: [error-propagation, retry, presigned-url, debugging, storage]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
id: fix-adequacy-cursor-pagination
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
category: debugging
|
|
4
|
+
cluster: fix-adequacy
|
|
5
|
+
difficulty: expert
|
|
6
|
+
title: Feed Pagination Duplicates And Skips Items, With Four Candidate Fixes To Judge
|
|
7
|
+
summary: A feed paginated by OFFSET over created_at DESC returns duplicate and skipped items when rows are inserted or deleted between page requests. Four candidate fix diffs are on the table. Exactly one resolves the root cause without reintroducing a regression the contract describes; one masks, one is per-request only, and one switches to keyset on a non-unique column and drops rows at a tie boundary. Judge each and name the disqualifying over-rejection.
|
|
8
|
+
prompt: |
|
|
9
|
+
Answer each deliverable using ONLY the supplied artifacts (contract, current-code,
|
|
10
|
+
bug-report, boundary-data, fix-a, fix-b, fix-c, fix-d). Cite the artifact id(s) for every
|
|
11
|
+
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 merely masks the symptom, fixes only one
|
|
14
|
+
request in isolation, or reintroduces a described regression is SHALLOW.
|
|
15
|
+
|
|
16
|
+
The "streamly" feed at GET /v2/feed returns some items twice and skips others while a
|
|
17
|
+
user scrolls. Judge the root cause and the four candidate fixes.
|
|
18
|
+
|
|
19
|
+
1. ROOT CAUSE. Name the single defect in current-code that lets items be duplicated or
|
|
20
|
+
skipped across page requests, and explain the mechanism using bug-report's scenario.
|
|
21
|
+
|
|
22
|
+
2. FIX VERDICTS. Classify EACH of fix-a, fix-b, fix-c, fix-d as adequate-fix or
|
|
23
|
+
shallow-fix, and for each give the exact mechanism that justifies the verdict (what it
|
|
24
|
+
masks, what it leaves unfixed, or what described regression it reintroduces).
|
|
25
|
+
|
|
26
|
+
3. THE ADEQUATE FIX. Identify the single adequate fix and show, using boundary-data, that
|
|
27
|
+
it produces no duplicate and no skip across the page boundary, including at the tie in
|
|
28
|
+
created_at.
|
|
29
|
+
|
|
30
|
+
4. WHY C IS NOT ADEQUATE. Using boundary-data, show concretely which item(s) fix-c drops
|
|
31
|
+
or repeats at the created_at tie boundary, and which contract rule it violates.
|
|
32
|
+
|
|
33
|
+
5. OVER-REJECTION TRAP. State the contract rule that makes rejecting the adequate fix
|
|
34
|
+
"because it changes the cursor format" an incorrect disqualification, and quote it.
|
|
35
|
+
artifacts:
|
|
36
|
+
- id: contract
|
|
37
|
+
type: spec
|
|
38
|
+
label: streamly feed pagination contract
|
|
39
|
+
content: |
|
|
40
|
+
streamly GET /v2/feed pagination contract (authoritative, complete):
|
|
41
|
+
|
|
42
|
+
R1 — CURSOR IS OPAQUE. The `cursor` query parameter is an opaque token. Clients MUST
|
|
43
|
+
NOT parse it. Its internal encoding is an implementation detail and MAY change
|
|
44
|
+
between releases without a version bump. Changing what the cursor encodes is
|
|
45
|
+
explicitly permitted.
|
|
46
|
+
R2 — NO DUPLICATES OR SKIPS. Within a single scroll session, an item MUST NOT be
|
|
47
|
+
returned in more than one page, and no item that existed for the whole session
|
|
48
|
+
and matches the filter MUST be skipped — even when other rows are inserted or
|
|
49
|
+
deleted concurrently between page requests.
|
|
50
|
+
R3 — TOTAL ORDER. Results are ordered created_at DESC. created_at is NOT unique (many
|
|
51
|
+
rows share the same millisecond). Ties on created_at MUST be broken by id DESC to
|
|
52
|
+
give a deterministic total order. A pagination scheme that cannot distinguish two
|
|
53
|
+
rows with equal created_at is non-conforming.
|
|
54
|
+
This contract is the complete set of pagination requirements.
|
|
55
|
+
- id: current-code
|
|
56
|
+
type: code
|
|
57
|
+
label: current pagination query (buggy)
|
|
58
|
+
content: |
|
|
59
|
+
// streamly/feed.ts — current, buggy.
|
|
60
|
+
// cursor encodes a numeric OFFSET into the created_at DESC ordering.
|
|
61
|
+
async function page(cursor?: string) {
|
|
62
|
+
const offset = cursor ? decodeOffset(cursor) : 0; // e.g. "o:3" -> 3
|
|
63
|
+
const rows = await db.query(`
|
|
64
|
+
SELECT id, created_at, body FROM posts
|
|
65
|
+
WHERE deleted = false
|
|
66
|
+
ORDER BY created_at DESC
|
|
67
|
+
LIMIT 3 OFFSET ${offset}
|
|
68
|
+
`);
|
|
69
|
+
return { rows, cursor: encodeOffset(offset + rows.length) };
|
|
70
|
+
}
|
|
71
|
+
// The next page is "the same ORDER BY, skip N rows". N is a position, not an anchor to
|
|
72
|
+
// a specific row.
|
|
73
|
+
- id: bug-report
|
|
74
|
+
type: note
|
|
75
|
+
label: reproduction of the duplicate and the skip
|
|
76
|
+
content: |
|
|
77
|
+
Repro (page size 3), newest first.
|
|
78
|
+
Initial feed: [I10, I9, I8, I7, I6, I5, ...]
|
|
79
|
+
Page 1 (offset 0): returns I10, I9, I8 -> next cursor offset=3
|
|
80
|
+
|
|
81
|
+
DUPLICATE case: before page 2, two new posts I11, I12 are inserted at the head.
|
|
82
|
+
Feed now: [I12, I11, I10, I9, I8, I7, I6, ...]
|
|
83
|
+
Page 2 (offset 3): returns I9, I8, I7 -> I9 and I8 were ALREADY shown on page 1
|
|
84
|
+
(duplicates), because inserting 2 rows above shifted every
|
|
85
|
+
row's offset by +2.
|
|
86
|
+
|
|
87
|
+
SKIP case: instead, before page 2, two head posts I10, I9 are deleted.
|
|
88
|
+
Feed now: [I8, I7, I6, I5, ...]
|
|
89
|
+
Page 2 (offset 3): returns I5, I4, I3 -> I8, I7, I6 were never shown (skipped),
|
|
90
|
+
because removing 2 rows above pulled every row's offset by -2.
|
|
91
|
+
Both come from the same cause: OFFSET counts positions in a list that changes between
|
|
92
|
+
requests.
|
|
93
|
+
- id: boundary-data
|
|
94
|
+
type: table
|
|
95
|
+
label: rows around the page-1/page-2 boundary (note the created_at tie)
|
|
96
|
+
content: |
|
|
97
|
+
id created_at (UTC) note
|
|
98
|
+
--- -------------------------- -------------------------------
|
|
99
|
+
I10 2026-07-10T10:00:00.002Z page 1, row 1
|
|
100
|
+
I9 2026-07-10T10:00:00.001Z page 1, row 2
|
|
101
|
+
I8 2026-07-10T10:00:00.000Z page 1, row 3 <-- LAST row of page 1 (the anchor)
|
|
102
|
+
I7 2026-07-10T10:00:00.000Z page 2, row 1 <-- SAME created_at as I8 (tie)
|
|
103
|
+
I6 2026-07-10T10:00:00.000Z page 2, row 2 <-- SAME created_at as I8 (tie)
|
|
104
|
+
I5 2026-07-09T09:59:59.998Z page 2, row 3
|
|
105
|
+
# Under the R3 total order (created_at DESC, id DESC) the correct sequence is
|
|
106
|
+
# I8, I7, I6 (10:00:00.000, ids 8 > 7 > 6), so page 2 must start at I7.
|
|
107
|
+
- id: fix-a
|
|
108
|
+
type: diff
|
|
109
|
+
label: candidate fix A
|
|
110
|
+
content: |
|
|
111
|
+
# fix A: keep OFFSET; dedup by id in the client before rendering.
|
|
112
|
+
// streamly-web/feed-list.ts
|
|
113
|
+
+ const seen = new Set(renderedIds);
|
|
114
|
+
+ const deduped = page.rows.filter(r => !seen.has(r.id)); // drop ids already shown
|
|
115
|
+
render(deduped);
|
|
116
|
+
# Server query is unchanged (still OFFSET). Only the client hides repeats.
|
|
117
|
+
- id: fix-b
|
|
118
|
+
type: diff
|
|
119
|
+
label: candidate fix B
|
|
120
|
+
content: |
|
|
121
|
+
# fix B: keep OFFSET; wrap the single page query in a REPEATABLE READ transaction.
|
|
122
|
+
async function page(cursor?) {
|
|
123
|
+
const offset = cursor ? decodeOffset(cursor) : 0;
|
|
124
|
+
+ return db.tx({ isolation: 'repeatable read' }, async (t) => {
|
|
125
|
+
const rows = await t.query(`SELECT ... ORDER BY created_at DESC LIMIT 3 OFFSET ${offset}`);
|
|
126
|
+
return { rows, cursor: encodeOffset(offset + rows.length) };
|
|
127
|
+
+ });
|
|
128
|
+
}
|
|
129
|
+
# Each page request is its own transaction/snapshot; there is no shared snapshot across
|
|
130
|
+
# the separate page-1 and page-2 requests.
|
|
131
|
+
- id: fix-c
|
|
132
|
+
type: diff
|
|
133
|
+
label: candidate fix C
|
|
134
|
+
content: |
|
|
135
|
+
# fix C: keyset pagination anchored on created_at only.
|
|
136
|
+
async function page(cursor?) {
|
|
137
|
+
- const offset = cursor ? decodeOffset(cursor) : 0;
|
|
138
|
+
- const rows = await db.query(`... ORDER BY created_at DESC LIMIT 3 OFFSET ${offset}`);
|
|
139
|
+
- return { rows, cursor: encodeOffset(offset + rows.length) };
|
|
140
|
+
+ const ts = cursor ? decodeTs(cursor) : MAX_TS; // last row's created_at
|
|
141
|
+
+ const rows = await db.query(`
|
|
142
|
+
+ SELECT id, created_at, body FROM posts
|
|
143
|
+
+ WHERE deleted = false AND created_at < '${ts}'
|
|
144
|
+
+ ORDER BY created_at DESC LIMIT 3`);
|
|
145
|
+
+ return { rows, cursor: encodeTs(rows.at(-1)?.created_at) };
|
|
146
|
+
}
|
|
147
|
+
# cursor now encodes only created_at.
|
|
148
|
+
- id: fix-d
|
|
149
|
+
type: diff
|
|
150
|
+
label: candidate fix D
|
|
151
|
+
content: |
|
|
152
|
+
# fix D: keyset pagination anchored on the (created_at, id) tuple.
|
|
153
|
+
async function page(cursor?) {
|
|
154
|
+
- const offset = cursor ? decodeOffset(cursor) : 0;
|
|
155
|
+
- const rows = await db.query(`... ORDER BY created_at DESC LIMIT 3 OFFSET ${offset}`);
|
|
156
|
+
- return { rows, cursor: encodeOffset(offset + rows.length) };
|
|
157
|
+
+ const { ts, id } = cursor ? decodeKey(cursor) : { ts: MAX_TS, id: MAX_ID };
|
|
158
|
+
+ const rows = await db.query(`
|
|
159
|
+
+ SELECT id, created_at, body FROM posts
|
|
160
|
+
+ WHERE deleted = false AND (created_at, id) < ('${ts}', ${id})
|
|
161
|
+
+ ORDER BY created_at DESC, id DESC LIMIT 3`);
|
|
162
|
+
+ const last = rows.at(-1);
|
|
163
|
+
+ return { rows, cursor: encodeKey({ ts: last?.created_at, id: last?.id }) };
|
|
164
|
+
}
|
|
165
|
+
# cursor now encodes the (created_at, id) of the last returned row.
|
|
166
|
+
tags: [fix-adequacy, pagination, keyset, debugging, cursor]
|