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.
Files changed (130) hide show
  1. package/README.md +141 -173
  2. package/dist/{chunk-UECBSKTD.js → chunk-AY45YLYL.js} +105 -5
  3. package/dist/{chunk-LFKEV2YL.js → chunk-CJGHBY54.js} +7 -7
  4. package/dist/{chunk-JTVNKSMO.js → chunk-DVMGL3L7.js} +80 -6
  5. package/dist/{chunk-EQHRUV2I.js → chunk-IUPFMGUL.js} +152 -16
  6. package/dist/{chunk-7YCJSOK7.cjs → chunk-KCXQ5SAU.cjs} +21 -21
  7. package/dist/{chunk-4TWPCPRP.cjs → chunk-QMOPRKWD.cjs} +89 -15
  8. package/dist/{chunk-NJTYVNP4.cjs → chunk-VAS6KNJA.cjs} +216 -80
  9. package/dist/{chunk-CIXITJW6.cjs → chunk-X3LPZGHS.cjs} +106 -6
  10. package/dist/cli.cjs +51 -49
  11. package/dist/cli.js +9 -7
  12. package/dist/client.cjs +5 -6
  13. package/dist/client.d.cts +3 -3
  14. package/dist/client.d.ts +3 -3
  15. package/dist/client.js +4 -5
  16. package/dist/contracts/index.cjs +6 -3
  17. package/dist/contracts/index.d.cts +2 -2
  18. package/dist/contracts/index.d.ts +2 -2
  19. package/dist/contracts/index.js +7 -4
  20. package/dist/index.cjs +8 -5
  21. package/dist/index.d.cts +9 -8
  22. package/dist/index.d.ts +9 -8
  23. package/dist/index.js +8 -5
  24. package/dist/{logger-CCR9Mg1c.d.cts → logger-BByta-7V.d.cts} +23 -23
  25. package/dist/{logger-QJU7SBDz.d.ts → logger-BQf29BLe.d.ts} +23 -23
  26. package/dist/{reports-s2CTnGN8.d.ts → reports-B8TCJtPr.d.ts} +57 -13
  27. package/dist/{reports-4CejmOHf.d.cts → reports-DPpOoOux.d.cts} +57 -13
  28. package/dist/{tasks-CpaCJ6JE.d.ts → tasks-BmhWuMBD.d.cts} +24 -22
  29. package/dist/{tasks-CpaCJ6JE.d.cts → tasks-BmhWuMBD.d.ts} +24 -22
  30. package/dist/tasks.cjs +3 -4
  31. package/dist/tasks.d.cts +1 -1
  32. package/dist/tasks.d.ts +1 -1
  33. package/dist/tasks.js +2 -3
  34. package/docs/README.md +32 -12
  35. package/docs/methodology.md +14 -0
  36. package/docs/operator-guide.md +178 -0
  37. package/docs/replay-elo.md +12 -2
  38. package/docs/reviewing-bridgebench.md +173 -0
  39. package/docs/task-authoring.md +79 -1
  40. package/package.json +5 -3
  41. package/tasks/bullshit/public/crossed-metric-properties.yaml +192 -0
  42. package/tasks/bullshit/public/crossed-release-checksums.yaml +199 -0
  43. package/tasks/bullshit/public/fabricated-config-keys.yaml +243 -0
  44. package/tasks/bullshit/public/fabricated-protocol-features.yaml +272 -0
  45. package/tasks/bullshit/public/impossible-capacity-math.yaml +197 -0
  46. package/tasks/bullshit/public/impossible-latency-allocation.yaml +215 -0
  47. package/tasks/bullshit/public/loaded-approval-bypass.yaml +229 -0
  48. package/tasks/bullshit/public/loaded-migration-fallout.yaml +221 -0
  49. package/tasks/bullshit/public/pseudo-gc-heap-tuning.yaml +206 -0
  50. package/tasks/bullshit/public/pseudo-network-tuning.yaml +204 -0
  51. package/tasks/bullshit/public/reversed-alert-cascade.yaml +251 -0
  52. package/tasks/bullshit/public/reversed-dependency-failure.yaml +268 -0
  53. package/tasks/debugging/public/deadlock-lock-order-inversion.yaml +131 -0
  54. package/tasks/debugging/public/error-propagation-config-swallow.yaml +174 -0
  55. package/tasks/debugging/public/error-propagation-retry-mask.yaml +167 -0
  56. package/tasks/debugging/public/fix-adequacy-cursor-pagination.yaml +166 -0
  57. package/tasks/debugging/public/fix-adequacy-idempotency-race.yaml +169 -0
  58. package/tasks/debugging/public/keepalive-502-connection-reuse.yaml +162 -0
  59. package/tasks/debugging/public/pool-exhaustion-held-connection.yaml +142 -0
  60. package/tasks/debugging/public/race-oversell-reserve-counter.yaml +133 -0
  61. package/tasks/debugging/public/regression-multipart-filesize-cap.yaml +135 -0
  62. package/tasks/debugging/public/regression-pagination-tiebreak.yaml +112 -0
  63. package/tasks/debugging/public/state-corruption-index-ghost.yaml +160 -0
  64. package/tasks/debugging/public/state-corruption-ledger-balance.yaml +170 -0
  65. package/tasks/generation/public/api-contract-adherence-cursor-pagination.yaml +257 -0
  66. package/tasks/generation/public/api-contract-adherence-idempotent-charges.yaml +261 -0
  67. package/tasks/generation/public/array-rotate-left-normalization.yaml +166 -0
  68. package/tasks/generation/public/cache-interface-dropin.yaml +178 -0
  69. package/tasks/generation/public/edge-case-coverage-cache-loader.yaml +264 -0
  70. package/tasks/generation/public/edge-case-coverage-ledger-tally.yaml +231 -0
  71. package/tasks/generation/public/event-envelope-wire-compat.yaml +149 -0
  72. package/tasks/generation/public/kadane-linear-constant-space.yaml +175 -0
  73. package/tasks/generation/public/lower-bound-insertion-point.yaml +176 -0
  74. package/tasks/generation/public/rolling-checksum-single-pass-pure.yaml +186 -0
  75. package/tasks/generation/public/spec-conformance-password-policy.yaml +190 -0
  76. package/tasks/generation/public/spec-conformance-slug-normalizer.yaml +177 -0
  77. package/tasks/refactoring/public/api-migration-http-retry-client.yaml +208 -0
  78. package/tasks/refactoring/public/api-migration-orm-query-builder.yaml +187 -0
  79. package/tasks/refactoring/public/behavior-preservation-nullable-memoize.yaml +136 -0
  80. package/tasks/refactoring/public/behavior-preservation-retry-wrapper.yaml +187 -0
  81. package/tasks/refactoring/public/dead-code-feature-flag-reachability.yaml +162 -0
  82. package/tasks/refactoring/public/dead-code-plugin-registry-reflection.yaml +125 -0
  83. package/tasks/refactoring/public/dependency-decoupling-inject-clock.yaml +237 -0
  84. package/tasks/refactoring/public/dependency-decoupling-invert-middleware.yaml +177 -0
  85. package/tasks/refactoring/public/extract-and-inline-closure-capture.yaml +132 -0
  86. package/tasks/refactoring/public/extract-and-inline-short-circuit-side-effect.yaml +120 -0
  87. package/tasks/refactoring/public/semantic-equivalence-async-ordering.yaml +157 -0
  88. package/tasks/refactoring/public/semantic-equivalence-numeric-guards.yaml +115 -0
  89. package/tasks/security/public/authz-guard-chain-exposure.yaml +224 -0
  90. package/tasks/security/public/authz-object-scope-idor.yaml +228 -0
  91. package/tasks/security/public/patch-mass-assignment-privesc.yaml +226 -0
  92. package/tasks/security/public/patch-sqli-candidate-fixes.yaml +243 -0
  93. package/tasks/security/public/supply-lockfile-tamper-trace.yaml +227 -0
  94. package/tasks/security/public/supply-secrets-leak-forensics.yaml +205 -0
  95. package/tasks/security/public/taint-export-template-render.yaml +207 -0
  96. package/tasks/security/public/taint-webhook-outbound-fetch.yaml +214 -0
  97. package/tasks/security/public/triage-dependency-advisories.yaml +189 -0
  98. package/tasks/security/public/triage-sast-false-positives.yaml +257 -0
  99. package/tasks/security/public/vuln-path-sanitizer-escape.yaml +165 -0
  100. package/tasks/security/public/vuln-token-alg-confusion.yaml +252 -0
  101. package/tasks/speed/public/speed-api-diff-changelog.yaml +42 -0
  102. package/tasks/speed/public/speed-callbacks-to-async.yaml +39 -0
  103. package/tasks/speed/public/speed-class-to-hooks.yaml +47 -0
  104. package/tasks/speed/public/speed-config-schema-json.yaml +34 -0
  105. package/tasks/speed/public/speed-csv-report-generator.yaml +38 -0
  106. package/tasks/speed/public/speed-debounce-hook.yaml +28 -0
  107. package/tasks/speed/public/speed-log-triage-summary.yaml +35 -0
  108. package/tasks/speed/public/speed-openapi-fragment.yaml +38 -0
  109. package/tasks/speed/public/speed-pagination-cursor.yaml +40 -0
  110. package/tasks/speed/public/speed-rate-limiter-design.yaml +43 -0
  111. package/tasks/speed/public/speed-rest-client-module.yaml +42 -0
  112. package/tasks/speed/public/speed-slugify-helper.yaml +30 -0
  113. package/dist/chunk-4TWPCPRP.cjs.map +0 -1
  114. package/dist/chunk-7YCJSOK7.cjs.map +0 -1
  115. package/dist/chunk-CIXITJW6.cjs.map +0 -1
  116. package/dist/chunk-EQHRUV2I.js.map +0 -1
  117. package/dist/chunk-JTVNKSMO.js.map +0 -1
  118. package/dist/chunk-LFKEV2YL.js.map +0 -1
  119. package/dist/chunk-NJTYVNP4.cjs.map +0 -1
  120. package/dist/chunk-UECBSKTD.js.map +0 -1
  121. package/dist/cli.cjs.map +0 -1
  122. package/dist/cli.js.map +0 -1
  123. package/dist/client.cjs.map +0 -1
  124. package/dist/client.js.map +0 -1
  125. package/dist/contracts/index.cjs.map +0 -1
  126. package/dist/contracts/index.js.map +0 -1
  127. package/dist/index.cjs.map +0 -1
  128. package/dist/index.js.map +0 -1
  129. package/dist/tasks.cjs.map +0 -1
  130. 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]