bridgebench 3.1.0-alpha.0

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 (86) hide show
  1. package/CITATION.cff +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +249 -0
  4. package/dist/chunk-4TWPCPRP.cjs +1097 -0
  5. package/dist/chunk-4TWPCPRP.cjs.map +1 -0
  6. package/dist/chunk-7YCJSOK7.cjs +398 -0
  7. package/dist/chunk-7YCJSOK7.cjs.map +1 -0
  8. package/dist/chunk-CIXITJW6.cjs +249 -0
  9. package/dist/chunk-CIXITJW6.cjs.map +1 -0
  10. package/dist/chunk-EQHRUV2I.js +1466 -0
  11. package/dist/chunk-EQHRUV2I.js.map +1 -0
  12. package/dist/chunk-JTVNKSMO.js +1096 -0
  13. package/dist/chunk-JTVNKSMO.js.map +1 -0
  14. package/dist/chunk-LFKEV2YL.js +398 -0
  15. package/dist/chunk-LFKEV2YL.js.map +1 -0
  16. package/dist/chunk-NJTYVNP4.cjs +1467 -0
  17. package/dist/chunk-NJTYVNP4.cjs.map +1 -0
  18. package/dist/chunk-UECBSKTD.js +244 -0
  19. package/dist/chunk-UECBSKTD.js.map +1 -0
  20. package/dist/cli.cjs +409 -0
  21. package/dist/cli.cjs.map +1 -0
  22. package/dist/cli.d.cts +1 -0
  23. package/dist/cli.d.ts +1 -0
  24. package/dist/cli.js +408 -0
  25. package/dist/cli.js.map +1 -0
  26. package/dist/client.cjs +42 -0
  27. package/dist/client.cjs.map +1 -0
  28. package/dist/client.d.cts +93 -0
  29. package/dist/client.d.ts +93 -0
  30. package/dist/client.js +42 -0
  31. package/dist/client.js.map +1 -0
  32. package/dist/contracts/index.cjs +47 -0
  33. package/dist/contracts/index.cjs.map +1 -0
  34. package/dist/contracts/index.d.cts +14 -0
  35. package/dist/contracts/index.d.ts +14 -0
  36. package/dist/contracts/index.js +47 -0
  37. package/dist/contracts/index.js.map +1 -0
  38. package/dist/index.cjs +171 -0
  39. package/dist/index.cjs.map +1 -0
  40. package/dist/index.d.cts +214 -0
  41. package/dist/index.d.ts +214 -0
  42. package/dist/index.js +171 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/logger-CCR9Mg1c.d.cts +319 -0
  45. package/dist/logger-QJU7SBDz.d.ts +319 -0
  46. package/dist/reports-4CejmOHf.d.cts +454 -0
  47. package/dist/reports-s2CTnGN8.d.ts +454 -0
  48. package/dist/tasks-CpaCJ6JE.d.cts +151 -0
  49. package/dist/tasks-CpaCJ6JE.d.ts +151 -0
  50. package/dist/tasks.cjs +22 -0
  51. package/dist/tasks.cjs.map +1 -0
  52. package/dist/tasks.d.cts +39 -0
  53. package/dist/tasks.d.ts +39 -0
  54. package/dist/tasks.js +22 -0
  55. package/dist/tasks.js.map +1 -0
  56. package/docs/README.md +25 -0
  57. package/docs/glossary.md +49 -0
  58. package/docs/methodology.md +58 -0
  59. package/docs/private-packs.md +74 -0
  60. package/docs/replay-elo.md +79 -0
  61. package/docs/task-authoring.md +80 -0
  62. package/package.json +137 -0
  63. package/tasks/hallucination/public/boundary-coverage-audit.yaml +274 -0
  64. package/tasks/hallucination/public/boundary-migration-audit.yaml +284 -0
  65. package/tasks/hallucination/public/conflict-dependency-versions.yaml +324 -0
  66. package/tasks/hallucination/public/conflict-runbook-versions.yaml +229 -0
  67. package/tasks/hallucination/public/fabrication-agent-tools.yaml +224 -0
  68. package/tasks/hallucination/public/fabrication-api-surface.yaml +239 -0
  69. package/tasks/hallucination/public/fidelity-commit-attribution.yaml +304 -0
  70. package/tasks/hallucination/public/fidelity-config-drift.yaml +307 -0
  71. package/tasks/hallucination/public/missing-deploy-window.yaml +204 -0
  72. package/tasks/hallucination/public/missing-latency-baseline.yaml +239 -0
  73. package/tasks/hallucination/public/premise-quota-breach.yaml +202 -0
  74. package/tasks/hallucination/public/premise-rollback-cause.yaml +235 -0
  75. package/tasks/reasoning/public/constraint-capacity-allocation.yaml +196 -0
  76. package/tasks/reasoning/public/constraint-deployment-policy.yaml +203 -0
  77. package/tasks/reasoning/public/counterexample-authorization-rule.yaml +278 -0
  78. package/tasks/reasoning/public/counterexample-scheduler-starvation.yaml +290 -0
  79. package/tasks/reasoning/public/root-cache-tenant-leak.yaml +225 -0
  80. package/tasks/reasoning/public/root-event-ordering.yaml +184 -0
  81. package/tasks/reasoning/public/stateful-lease-handoff.yaml +213 -0
  82. package/tasks/reasoning/public/stateful-retry-budget.yaml +222 -0
  83. package/tasks/reasoning/public/synthesis-api-contract.yaml +214 -0
  84. package/tasks/reasoning/public/synthesis-permission-migration.yaml +190 -0
  85. package/tasks/reasoning/public/uncertainty-conflicting-telemetry.yaml +242 -0
  86. package/tasks/reasoning/public/uncertainty-incomplete-incident.yaml +223 -0
@@ -0,0 +1,225 @@
1
+ id: root-cache-tenant-leak
2
+ version: 2.0.0
3
+ category: reasoning
4
+ cluster: root-cause-reasoning
5
+ difficulty: expert
6
+ title: Cross-Tenant Report Cache Leak Across Gateway, Cache, and Database
7
+ summary: >-
8
+ A multi-tenant reporting platform served tenant beta a report containing tenant alpha's
9
+ figures, with no 5xx errors and green dashboards. A report-service release shipped minutes
10
+ earlier. Reconcile handler code, the cache-key builder, gateway auth, a deploy diff, cache
11
+ config, a long interleaved incident log, the table schema, and a metrics panel to isolate
12
+ the single root cause, separate it from three decoys, and specify the minimal safe fix.
13
+ prompt: |
14
+ A tenant beta user opened report "q4-forecast" and saw tenant alpha's numbers. The platform
15
+ returned HTTP 200 throughout, no alert fired, and a report-service release (v3.4.1) had rolled
16
+ out 15 minutes before. Using only the artifacts, produce:
17
+
18
+ 1. ROOT CAUSE. Name the single defect and cite the exact artifact id and the specific line or
19
+ expression that contains it. State precisely why it produces cross-tenant reads.
20
+ 2. CAUSAL CHAIN. In timestamp order, trace how tenant beta received tenant alpha's q4-forecast
21
+ row. Cite the specific incident-log lines (by timestamp and req id) that establish each step,
22
+ including the write that poisoned the cache and the read that leaked it.
23
+ 3. INNOCENT DEPLOY. Explain why the v3.4.1 release in deploy-diff is NOT the root cause of the
24
+ observed leak, and state what that release actually changed and what separate exposure it
25
+ closed.
26
+ 4. RED-HERRING CONFIG. Explain why `tenantIsolation: strict` in cache-config does not prevent
27
+ this leak, citing the artifact that shows what actually builds the cache key.
28
+ 5. BLAST RADIUS. State exactly which (tenant, slug, locale) combinations can be served the wrong
29
+ tenant's data and in which direction; explain why tenant gamma's request and the burn-rate
30
+ request were unaffected; and explain why no 5xx alarm fired.
31
+ 6. MINIMAL FIX. Give the smallest change that closes the leak without disabling caching and
32
+ without weakening the database predicate, and state what must be done to already-cached
33
+ entries.
34
+
35
+ Exactly one resolution is defensible per deliverable; it is fully determined by the artifacts.
36
+ artifacts:
37
+ - id: report-handler
38
+ type: code
39
+ label: report-service request handler (v3.4.1, current)
40
+ content: |
41
+ // report-service/src/reports.controller.ts (running release v3.4.1)
42
+ import { reportCacheKey } from './cache-key-util';
43
+ import { cacheConfig } from './cache.config';
44
+ import { and, eq } from './db';
45
+
46
+ interface RequestCtx {
47
+ tenantId: string; // set by the gateway from the verified JWT (see gateway-auth)
48
+ locale: string; // 'en' unless the client requests otherwise
49
+ userId: string;
50
+ }
51
+
52
+ export async function getReport(ctx: RequestCtx, slug: string) {
53
+ const key = reportCacheKey({ slug, locale: ctx.locale });
54
+ const cached = await redis.get(key);
55
+ if (cached) {
56
+ return JSON.parse(cached); // returns whatever payload was cached first
57
+ }
58
+ const row = await db.reports.findFirst({
59
+ where: and(eq(reports.slug, slug), eq(reports.tenantId, ctx.tenantId)),
60
+ });
61
+ await redis.set(key, JSON.stringify(row), { EX: cacheConfig.ttlSeconds });
62
+ return row;
63
+ }
64
+
65
+ export async function listReports(ctx: RequestCtx) {
66
+ // Listing is not cached and is out of scope for this incident.
67
+ return db.reports.findMany({ where: eq(reports.tenantId, ctx.tenantId) });
68
+ }
69
+
70
+ export async function reportsController(req) {
71
+ const ctx: RequestCtx = {
72
+ tenantId: req.headers['x-tenant-id'], // set by the gateway from the verified JWT
73
+ locale: req.headers['x-locale'] ?? 'en',
74
+ userId: req.headers['x-user-id'],
75
+ };
76
+ return getReport(ctx, req.params.slug);
77
+ }
78
+ - id: cache-key-util
79
+ type: code
80
+ label: cache-key builder (identical in v3.4.0 and v3.4.1)
81
+ content: |
82
+ // report-service/src/cache-key-util.ts
83
+ // NOTE: byte-identical between v3.4.0 and v3.4.1; the release did not touch this file.
84
+
85
+ export function userCacheKey(input: { tenantId: string; userId: string }): string {
86
+ // User objects are keyed by tenant + user. Tenant is part of the key.
87
+ return `user:${input.tenantId}:${input.userId}`;
88
+ }
89
+
90
+ export function reportCacheKey(input: { slug: string; locale: string }): string {
91
+ // Reports are keyed by slug + locale. Slugs are human-friendly and stable.
92
+ return `report:${input.slug}:${input.locale}`;
93
+ }
94
+ - id: gateway-auth
95
+ type: code
96
+ label: gateway tenant resolution middleware
97
+ content: |
98
+ // gateway/src/auth.middleware.ts
99
+ // Verifies the JWT, derives the tenant, and forwards it to report-service as ctx.tenantId.
100
+
101
+ export function resolveTenant(req, res, next) {
102
+ const claim = req.jwt?.tenant; // signature already verified upstream
103
+ if (!claim || claim.trim() === '') {
104
+ logger.warn('jwt.tenant claim missing/blank', { user: req.jwt?.sub });
105
+ return res.status(401).end(); // rejected; NOT forwarded to report-service
106
+ }
107
+ req.headers['x-tenant-id'] = claim; // becomes ctx.tenantId downstream
108
+ req.headers['x-locale'] = req.query.locale ?? 'en';
109
+ return next();
110
+ }
111
+ - id: deploy-diff
112
+ type: diff
113
+ label: report-service v3.4.0 to v3.4.1 (deployed 14:05)
114
+ content: |
115
+ # release v3.4.1 — "harden report query with tenant predicate; bump pg driver"
116
+ # rolled out at 14:05 (see incident-log). Files changed: reports.controller.ts, package.json.
117
+
118
+ export async function getReport(ctx, slug) {
119
+ const key = reportCacheKey({ slug, locale: ctx.locale });
120
+ const cached = await redis.get(key);
121
+ if (cached) return JSON.parse(cached);
122
+ const row = await db.reports.findFirst({
123
+ - where: eq(reports.slug, slug),
124
+ + where: and(eq(reports.slug, slug), eq(reports.tenantId, ctx.tenantId)),
125
+ });
126
+ await redis.set(key, JSON.stringify(row), { EX: cacheConfig.ttlSeconds });
127
+ return row;
128
+ }
129
+
130
+ # package.json
131
+ - "pg": "8.11.3"
132
+ + "pg": "8.12.0"
133
+ - id: cache-config
134
+ type: config
135
+ label: report-service cache configuration
136
+ content: |
137
+ # report-service/cache.config.yaml
138
+ cache:
139
+ backend: redis
140
+ ttlSeconds: 3600
141
+ namespace: reports
142
+ compression: false
143
+ # tenantIsolation is consumed ONLY by the legacy per-tenant quota module.
144
+ # reportCacheKey() and userCacheKey() do not read this flag.
145
+ tenantIsolation: strict
146
+ - id: incident-log
147
+ type: log
148
+ label: interleaved gateway / report-service / redis log (14:05-14:23)
149
+ content: |
150
+ 14:05:00 deploy report-svc release v3.4.0 -> v3.4.1 rolled out (nodes 6/6 healthy)
151
+ 14:19:58 gateway req=r7731 GET /v2/reports/hiring-plan tenant=t_alpha user=u_a04 jwt.tenant=t_alpha authz=ok locale=en
152
+ 14:19:58 report-svc req=r7731 slug=hiring-plan tenant=t_alpha cacheKey=report:hiring-plan:en
153
+ 14:19:58 redis GET report:hiring-plan:en -> (nil) MISS
154
+ 14:19:58 report-svc req=r7731 db SELECT reports WHERE slug='hiring-plan' AND tenant_id='t_alpha' -> id=rep_10 rows=1
155
+ 14:19:58 redis SET report:hiring-plan:en ttl=3600 bytes=5120 -> OK
156
+ 14:19:58 report-svc req=r7731 200 source=db rowTenant=t_alpha
157
+ 14:20:12 gateway req=r7740 GET /v2/reports/q4-forecast tenant=t_alpha user=u_a04 jwt.tenant=t_alpha authz=ok locale=en
158
+ 14:20:12 report-svc req=r7740 slug=q4-forecast tenant=t_alpha cacheKey=report:q4-forecast:en
159
+ 14:20:12 redis GET report:q4-forecast:en -> (nil) MISS
160
+ 14:20:12 report-svc req=r7740 db SELECT reports WHERE slug='q4-forecast' AND tenant_id='t_alpha' -> id=rep_88 rows=1
161
+ 14:20:12 redis SET report:q4-forecast:en ttl=3600 bytes=4096 -> OK
162
+ 14:20:12 report-svc req=r7740 200 source=db rowTenant=t_alpha
163
+ 14:20:41 gateway req=r7751 GET /v2/reports/q4-forecast tenant=t_gamma user=u_g19 jwt.tenant=t_gamma authz=ok locale=fr
164
+ 14:20:41 report-svc req=r7751 slug=q4-forecast tenant=t_gamma cacheKey=report:q4-forecast:fr
165
+ 14:20:41 redis GET report:q4-forecast:fr -> (nil) MISS
166
+ 14:20:41 report-svc req=r7751 db SELECT reports WHERE slug='q4-forecast' AND tenant_id='t_gamma' -> id=rep_57 rows=1
167
+ 14:20:41 redis SET report:q4-forecast:fr ttl=3600 bytes=4210 -> OK
168
+ 14:20:41 report-svc req=r7751 200 source=db rowTenant=t_gamma
169
+ 14:20:55 gateway req=r7756 GET /v2/reports/q4-forecast tenant=t_epsilon user=u_e05 jwt.tenant=t_epsilon authz=ok locale=en
170
+ 14:20:55 report-svc req=r7756 slug=q4-forecast tenant=t_epsilon cacheKey=report:q4-forecast:en
171
+ 14:20:55 redis GET report:q4-forecast:en -> HIT bytes=4096
172
+ 14:20:55 report-svc req=r7756 200 source=cache rowTenant=t_alpha # epsilon also leaked alpha's rep_88
173
+ 14:21:03 gateway req=r7760 GET /v2/reports/q4-forecast tenant=t_delta user=u_d02 jwt.tenant= authz=deny
174
+ 14:21:03 gateway req=r7760 warn jwt.tenant claim missing/blank user=u_d02 -> 401 (request not forwarded)
175
+ 14:21:20 gateway req=r7765 GET /v2/reports/q4-forecast tenant=t_alpha user=u_a04 jwt.tenant=t_alpha authz=ok locale=en
176
+ 14:21:20 report-svc req=r7765 slug=q4-forecast tenant=t_alpha cacheKey=report:q4-forecast:en
177
+ 14:21:20 redis GET report:q4-forecast:en -> HIT bytes=4096
178
+ 14:21:20 report-svc req=r7765 200 source=cache rowTenant=t_alpha # alpha served alpha's own data (correct only because alpha filled the key)
179
+ 14:21:40 gateway req=r7772 GET /v2/reports/q4-forecast tenant=t_beta user=u_b07 jwt.tenant=t_beta authz=ok locale=en
180
+ 14:21:40 report-svc req=r7772 slug=q4-forecast tenant=t_beta cacheKey=report:q4-forecast:en
181
+ 14:21:40 redis GET report:q4-forecast:en -> HIT bytes=4096
182
+ 14:21:40 report-svc req=r7772 200 source=cache rowTenant=t_alpha # payload was built for t_alpha at 14:20:12
183
+ 14:22:05 gateway req=r7781 GET /v2/reports/q4-forecast tenant=t_beta user=u_b11 jwt.tenant=t_beta authz=ok locale=en
184
+ 14:22:05 report-svc req=r7781 slug=q4-forecast tenant=t_beta cacheKey=report:q4-forecast:en
185
+ 14:22:05 redis GET report:q4-forecast:en -> HIT bytes=4096
186
+ 14:22:05 report-svc req=r7781 200 source=cache rowTenant=t_alpha
187
+ 14:22:33 gateway req=r7790 GET /v2/reports/burn-rate tenant=t_beta user=u_b07 jwt.tenant=t_beta authz=ok locale=en
188
+ 14:22:33 report-svc req=r7790 slug=burn-rate tenant=t_beta cacheKey=report:burn-rate:en
189
+ 14:22:33 redis GET report:burn-rate:en -> (nil) MISS
190
+ 14:22:33 report-svc req=r7790 db SELECT reports WHERE slug='burn-rate' AND tenant_id='t_beta' -> id=rep_63 rows=1
191
+ 14:22:33 redis SET report:burn-rate:en ttl=3600 bytes=3990 -> OK
192
+ 14:22:33 report-svc req=r7790 200 source=db rowTenant=t_beta
193
+ 14:22:50 gateway req=r7799 GET /v2/reports/hiring-plan tenant=t_zeta user=u_z09 jwt.tenant=t_zeta authz=ok locale=en
194
+ 14:22:50 report-svc req=r7799 slug=hiring-plan tenant=t_zeta cacheKey=report:hiring-plan:en
195
+ 14:22:50 redis GET report:hiring-plan:en -> HIT bytes=5120
196
+ 14:22:50 report-svc req=r7799 200 source=cache rowTenant=t_alpha # zeta leaked alpha's hiring-plan (rep_10, cached at 14:19:58)
197
+ 14:23:10 report-svc incident opened: t_beta user u_b07 reports q4-forecast shows t_alpha figures
198
+ - id: schema-note
199
+ type: note
200
+ label: reports table schema and verified rows
201
+ content: |
202
+ reports table constraints:
203
+ PRIMARY KEY (id)
204
+ UNIQUE (tenant_id, slug) -- slugs are unique PER TENANT, not globally
205
+
206
+ Distinct tenants routinely reuse the same slug. Verified live rows for slug 'q4-forecast':
207
+ (tenant_id=t_alpha, slug=q4-forecast) -> id=rep_88
208
+ (tenant_id=t_beta, slug=q4-forecast) -> id=rep_41
209
+ (tenant_id=t_gamma, slug=q4-forecast) -> id=rep_57
210
+ All three are distinct, legitimate rows owned by their respective tenants. There is no
211
+ duplicate-data bug: t_beta's own q4-forecast is rep_41 and was never returned to it.
212
+ - id: metrics-table
213
+ type: table
214
+ label: platform dashboard, 14:00-14:30 (5-minute buckets)
215
+ content: |
216
+ bucket gateway_cpu% redis_hit_rate% db_p99_ms err_5xx authz_denies
217
+ 14:00-14:05 61 38 240 0 2
218
+ 14:05-14:10 74 41 255 0 1
219
+ 14:10-14:15 79 52 260 0 0
220
+ 14:15-14:20 86 68 248 0 0
221
+ 14:20-14:25 88 79 251 0 1
222
+ 14:25-14:30 87 81 244 0 0
223
+ # On-call note: gateway CPU climbed to ~88% after the 14:05 deploy and db_p99 stayed flat.
224
+ # No 5xx and only sporadic authz denies. Nothing here paged.
225
+ tags: [cache, tenant-isolation, multi-service, root-cause, redis]
@@ -0,0 +1,184 @@
1
+ id: root-event-ordering
2
+ version: 2.0.0
3
+ category: reasoning
4
+ cluster: root-cause-reasoning
5
+ difficulty: expert
6
+ title: Order-Status Projection Regresses From Shipped to Processing After a Concurrency Rollout
7
+ summary: A projection service began stranding orders at "processing" after they had already shipped. A consumer concurrency increase rolled out the same hour, one producer's clock runs fast and another's slow, and the broker only promises at-least-once delivery. Reconcile all seven artifacts to find the one true defect, separate it from the concurrency, clock-skew, and broker decoys, and state the durable invariant plus fix.
8
+ prompt: |
9
+ Order O-5000 is shown as "processing" in the UI even though the carrier confirms it shipped.
10
+ A consumer concurrency change rolled out at 14:00, producer clocks are known to differ, and the
11
+ broker rebalanced once during the window. Using only the artifacts, produce:
12
+
13
+ 1. ROOT CAUSE. Name the single defect and cite the exact artifact id and the specific expression
14
+ that contains it. Explain why it lets a later state be overwritten by an earlier one.
15
+ 2. CAUSAL CHAIN. In commit order, trace O-5000 from created to its final wrong state, citing the
16
+ specific pipeline-log lines (timestamp, worker, seq). Explain precisely why the authoritative
17
+ latest event (seq=4 shipped) can never win under the current logic.
18
+ 3. CONCURRENCY IS NOT THE ROOT CAUSE. Using the contrast between O-5000 and O-5100 in the log,
19
+ explain why the maxConcurrentPerPartition increase is a trigger and not the defect, and why
20
+ reverting it would not durably fix the bug.
21
+ 4. SKEW AND DELIVERY DECOYS. Explain why "sync the producer clocks (NTP)" is not the correct fix,
22
+ and why `exactlyOnce: true` in consumer-config does nothing here. Cite the event contract.
23
+ 5. INVARIANT AND FIX. State the ordering invariant the projector must enforce and give the minimal
24
+ change that enforces it correctly under concurrent, duplicated, and reordered delivery.
25
+ 6. BLAST RADIUS. State exactly which orders can regress and which are safe, tie it to the producer
26
+ clock topology, and state what "stranded forever" means for an affected order.
27
+
28
+ Exactly one resolution is defensible per deliverable; it is fully determined by the artifacts.
29
+ artifacts:
30
+ - id: projector-current
31
+ type: code
32
+ label: projection-service apply() (release v2.2, current)
33
+ content: |
34
+ // projection-service/src/apply.ts (release v2.2, currently deployed)
35
+ // Applies an order event to the read model. Meant to ignore "older" events.
36
+
37
+ export async function apply(event) {
38
+ // v2.2 decides which event is newer by comparing event time (occurredAt).
39
+ await db.orders.update({
40
+ where: and(
41
+ eq(orders.orderId, event.orderId),
42
+ lt(orders.occurredAt, event.occurredAt), // apply only if stored time is earlier
43
+ ),
44
+ set: {
45
+ status: event.status,
46
+ occurredAt: event.occurredAt,
47
+ lastSeq: event.seq, // stored, but not used by the predicate
48
+ },
49
+ });
50
+ }
51
+ - id: fence-deploy-diff
52
+ type: diff
53
+ label: projection-service v2.1 to v2.2 fence change
54
+ content: |
55
+ # projection-service v2.1 -> v2.2
56
+ # commit: "rank order events by event time so late corrections win"
57
+
58
+ await db.orders.update({
59
+ where: and(
60
+ eq(orders.orderId, event.orderId),
61
+ - lt(orders.lastSeq, event.seq), // v2.1: fence on authoritative per-order seq
62
+ + lt(orders.occurredAt, event.occurredAt), // v2.2: fence on producer event time
63
+ ),
64
+ set: { status: event.status, occurredAt: event.occurredAt, lastSeq: event.seq },
65
+ });
66
+ - id: consumer-config
67
+ type: config
68
+ label: projection consumer configuration
69
+ content: |
70
+ # projection-service/consumer.yaml
71
+ consumer:
72
+ group: proj
73
+ partitionKey: orderId
74
+ maxConcurrentPerPartition: 8 # raised from 1 at 14:00 to clear a backlog
75
+ deliveryGuarantee: at-least-once
76
+ dedupeWindowMs: 0 # no client-side dedupe; duplicates reach apply()
77
+ maxPollRecords: 500
78
+ autoCommit: true
79
+ exactlyOnce: true # NOTE: this broker does not support exactly-once; ignored
80
+ # The 14:00 rollout changed ONLY maxConcurrentPerPartition (1 -> 8). No other consumer setting
81
+ # and no broker setting changed in the window.
82
+ - id: event-contract
83
+ type: spec
84
+ label: event ordering contract
85
+ content: |
86
+ Event ordering contract (stable across all releases):
87
+ - seq: a strictly increasing integer per orderId, assigned centrally by the order aggregate.
88
+ It is the ONLY authoritative ordering signal. A higher seq is always a later transition,
89
+ regardless of which service emitted it or what its clock reads.
90
+ - occurredAt: the emitting service's LOCAL wall-clock time at emit. Clocks are NOT
91
+ synchronized across services; occurredAt may be non-monotonic with respect to seq, so a
92
+ later event (higher seq) can carry an EARLIER occurredAt than an earlier event.
93
+ - Delivery guarantee: AT-LEAST-ONCE. Duplicates and redeliveries occur, especially around
94
+ consumer-group rebalances. A projection may see the same (orderId, seq) more than once.
95
+ - Ordering guarantee: per-partition FIFO; partitioning is by orderId. A consumer MAY process
96
+ one partition concurrently (see consumer-config), which can interleave the apply order of
97
+ two events for the same order. Projections MUST remain correct under reordering and duplicates.
98
+ - Exactly-once delivery is not offered by this broker; any exactlyOnce setting is ignored.
99
+ - id: producer-topology
100
+ type: note
101
+ label: producer clock topology
102
+ content: |
103
+ Producers and measured NTP offsets at incident time:
104
+ order-svc offset +0 ms emits: created, payment_authorized. Hosts the order
105
+ aggregate that assigns the authoritative per-order seq.
106
+ fulfillment-a offset +2000 ms emits: processing
107
+ fulfillment-b offset -1000 ms emits: shipped
108
+ Every producer stamps occurredAt from its OWN local clock at emit. seq is assigned centrally.
109
+ For O-5000, processing (seq=3, fulfillment-a) is stamped occurredAt=14:00:08 while the later
110
+ shipped (seq=4, fulfillment-b) is stamped occurredAt=14:00:07 — occurredAt is inverted vs seq.
111
+ For O-5100, both processing and shipped were emitted by fulfillment-a, so their occurredAt
112
+ values are consistent with seq.
113
+
114
+ Why the offsets matter for O-5000: the true (real-time) order is created < payment < processing
115
+ < shipped. But fulfillment-a runs +2000ms fast, so its processing event is stamped 14:00:08,
116
+ while fulfillment-b runs -1000ms slow, so its later shipped event is stamped 14:00:07. The later
117
+ transition therefore carries the smaller timestamp. seq (3 then 4) still reflects the true order.
118
+ An order is only vulnerable when two of its transitions come from producers whose offsets order
119
+ the timestamps opposite to seq; orders whose transitions all come from one clock (or from clocks
120
+ that happen not to invert) keep occurredAt aligned with seq.
121
+ - id: pipeline-log
122
+ type: log
123
+ label: interleaved producer / broker / consumer log (14:00-14:05)
124
+ content: |
125
+ 14:00:00 order-svc emit O-5000 seq=1 status=created occurredAt=14:00:00.000 -> partition p3
126
+ 14:00:03 order-svc emit O-5000 seq=2 status=payment_authorized occurredAt=14:00:03.000 -> partition p3
127
+ 14:00:06 fulfill-a emit O-5000 seq=3 status=processing occurredAt=14:00:08.000 (clock +2000ms) -> partition p3
128
+ 14:00:08 fulfill-b emit O-5000 seq=4 status=shipped occurredAt=14:00:07.000 (clock -1000ms) -> partition p3
129
+ 14:00:05 broker deliver p3 offset=1 (O-5000 seq=1) -> worker w2
130
+ 14:00:05 consumer w2 apply O-5000 seq=1 fence occurred_at(NULL)<14:00:00 -> APPLY status=created stored.occurred_at=14:00:00
131
+ 14:00:06 broker deliver p3 offset=2 (O-5000 seq=2) -> worker w2
132
+ 14:00:06 consumer w2 apply O-5000 seq=2 fence 14:00:00<14:00:03 -> APPLY status=payment_authorized stored.occurred_at=14:00:03
133
+ 14:00:10 broker deliver p3 offset=3,4 (O-5000 seq=3,4) maxConcurrentPerPartition=8 -> seq=4 to w5, seq=3 to w2
134
+ 14:00:11 consumer w5 apply O-5000 seq=4 status=shipped fence 14:00:03<14:00:07 -> APPLY stored.occurred_at=14:00:07
135
+ 14:00:12 consumer w2 apply O-5000 seq=3 status=processing fence 14:00:07<14:00:08 -> APPLY stored.occurred_at=14:00:08 ## REGRESSION shipped->processing
136
+ 14:00:12 consumer w2 note O-5000 status=processing lastSeq=3 (won by occurred_at, not seq)
137
+ 14:00:20 consumer group=proj lag p3=0 p8=0 (caught up)
138
+ 14:00:45 order-svc emit O-5077 seq=1 status=created occurredAt=14:00:45.000 -> partition p5
139
+ 14:00:45 consumer w3 apply O-5077 seq=1 fence occurred_at(NULL)<14:00:45 -> APPLY status=created stored.occurred_at=14:00:45
140
+ 14:01:00 order-svc emit O-5100 seq=1 status=created occurredAt=14:01:00.000 -> partition p8
141
+ 14:01:05 fulfill-a emit O-5100 seq=2 status=processing occurredAt=14:01:05.000 (clock +2000ms) -> partition p8
142
+ 14:01:10 fulfill-a emit O-5100 seq=3 status=shipped occurredAt=14:01:10.000 (clock +2000ms) -> partition p8
143
+ 14:01:12 broker deliver p8 offset=1,2,3 (O-5100 seq=1,2,3) maxConcurrentPerPartition=8 -> seq=3 to w1, seq=2 to w4, seq=1 to w7
144
+ 14:01:12 consumer w7 apply O-5100 seq=1 status=created fence occurred_at(NULL)<14:01:00 -> APPLY stored.occurred_at=14:01:00
145
+ 14:01:12 consumer w1 apply O-5100 seq=3 status=shipped fence 14:01:00<14:01:10 -> APPLY stored.occurred_at=14:01:10
146
+ 14:01:13 consumer w4 apply O-5100 seq=2 status=processing fence 14:01:10<14:01:05 -> REJECT (no-op) ## reordered but NO regression: occurred_at aligned with seq
147
+ 14:02:00 broker group=proj rebalance: partition p3 reassigned w2 -> w9
148
+ 14:02:01 broker redeliver p3 offset=3,4 (O-5000 seq=3,4) at-least-once, in order -> w9
149
+ 14:02:01 consumer w9 apply O-5000 seq=3 status=processing fence 14:00:08<14:00:08 -> REJECT (no-op, duplicate)
150
+ 14:02:01 consumer w9 apply O-5000 seq=4 status=shipped fence 14:00:08<14:00:07 -> REJECT (no-op) ## seq=4 can NEVER win: 08<07 is always false
151
+ 14:02:02 consumer w9 note O-5000 remains status=processing (stranded; authoritative latest seq=4 shipped never applied)
152
+ 14:03:30 order-svc emit O-5077 seq=2 status=payment_authorized occurredAt=14:03:30.000 -> partition p5
153
+ 14:03:30 consumer w3 apply O-5077 seq=2 fence 14:00:45<14:03:30 -> APPLY status=payment_authorized stored.occurred_at=14:03:30
154
+ 14:03:45 fulfill-a emit O-5077 seq=3 status=processing occurredAt=14:03:47.000 (clock +2000ms) -> partition p5
155
+ 14:03:50 fulfill-a emit O-5077 seq=4 status=shipped occurredAt=14:03:52.000 (clock +2000ms) -> partition p5
156
+ 14:03:52 broker deliver p5 offset=3,4 (O-5077 seq=3,4) maxConcurrentPerPartition=8 -> seq=4 to w3, seq=3 to w8
157
+ 14:03:52 consumer w3 apply O-5077 seq=4 status=shipped fence 14:03:30<14:03:52 -> APPLY stored.occurred_at=14:03:52
158
+ 14:03:53 consumer w8 apply O-5077 seq=3 status=processing fence 14:03:52<14:03:47 -> REJECT (no-op) ## reordered but occurred_at aligned with seq -> safe
159
+ 14:03:54 consumer w8 note O-5077 status=shipped (correct final)
160
+ 14:05:00 support ticket: O-5000 shows "processing" in UI though carrier confirms shipped
161
+ - id: order-audit-table
162
+ type: table
163
+ label: projection audit and consumer metrics
164
+ content: |
165
+ projection audit -- order O-5000
166
+ appliedAt seq status stored.occurred_at result
167
+ 14:00:05 1 created 14:00:00 applied
168
+ 14:00:06 2 payment_authorized 14:00:03 applied
169
+ 14:00:11 4 shipped 14:00:07 applied
170
+ 14:00:12 3 processing 14:00:08 applied (regression)
171
+ 14:02:01 3 processing 14:00:08 rejected (duplicate; 08<08 false)
172
+ 14:02:01 4 shipped 14:00:08 rejected (07<08 false)
173
+
174
+ projection audit -- order O-5100
175
+ 14:01:12 1 created 14:01:00 applied
176
+ 14:01:12 3 shipped 14:01:10 applied
177
+ 14:01:13 2 processing 14:01:10 rejected (correct)
178
+
179
+ consumer metrics (14:00-14:05)
180
+ consumer_lag_max rebalances redeliveries worker_restarts
181
+ 0 1 2 0
182
+ # On-call note: one rebalance and a couple redeliveries in-window; lag stayed at 0.
183
+ # Broker health looks nominal; no partition was stuck.
184
+ tags: [events, projections, ordering, at-least-once, root-cause]
@@ -0,0 +1,213 @@
1
+ id: stateful-lease-handoff
2
+ version: 2.0.0
3
+ category: reasoning
4
+ cluster: stateful-execution
5
+ difficulty: expert
6
+ title: Distributed Lease Handoff With Generation Fencing Across Two Jobs
7
+ summary: >-
8
+ Reconstruct the terminal ownership, commit validity, and fencing-store state of two jobs run by
9
+ three workers through a partition, an expiry, delayed renewals, a stale monitoring cache, and a
10
+ proposed lease-timeout change, using an authoritative database clock.
11
+ prompt: |
12
+ A lease coordinator hands job ownership between workers. Two jobs (J7 and J3) run across workers
13
+ worker-a, worker-b, and worker-c. All acquire, renew, and expiry decisions are judged by the
14
+ authoritative database clock in [lease-protocol]; worker local clocks in [lease-config] matter only
15
+ for the client-side self-fencing rule. The exact commit path is [commit-routine]. Commits are
16
+ validated against the fencing object store in [fencing-store]. The full database-ordered history is
17
+ [cluster-events]; every store write attempt and its outcome is in [store-ledger]. A monitoring cache
18
+ appears in [ownership-snapshot]. A proposed configuration change appears in [proposed-change].
19
+
20
+ Answer every deliverable. Each requires cross-referencing multiple artifacts; a single-artifact
21
+ reading will be wrong.
22
+
23
+ 1. Terminal ownership. Give the owner and generation of J7 and of J3 at the end of [cluster-events],
24
+ and name the event that established each.
25
+ 2. Commit validity for J7. State which worker's J7 commit is valid, and for every worker that
26
+ attempted to commit J7 but did not succeed, cite the exact protocol step ([commit-routine]) that
27
+ stopped it (lease fence vs. store fence) and why.
28
+ 3. Fencing-store accounting. For J7 alone, give the number of store write attempts that reached the
29
+ store, how many were accepted, how many rejected, and the final high-water-mark. Then do the same
30
+ for J3. Reconcile your counts against [store-ledger].
31
+ 4. Monitoring reconciliation. [ownership-snapshot] reports ownership for J7 and J3. Is it correct as
32
+ of the end of [cluster-events]? For each job, state what the snapshot claims, what is actually
33
+ true, and the reason for any discrepancy.
34
+ 5. Proposed change. Under [proposed-change], explain precisely what fails and why, grounding the
35
+ failure in concrete evidence from [cluster-events]. Give the minimal configuration fix that
36
+ preserves the intended renewal cadence, and state one plausible-but-wrong diagnosis the change
37
+ invites.
38
+ artifacts:
39
+ - id: lease-protocol
40
+ type: spec
41
+ label: Lease and commit protocol
42
+ content: |
43
+ CLOCK
44
+ - The database clock is authoritative for acquire, renew, and expiry. A lease row is
45
+ {job, owner, generation, expires_at}. generation is a monotonically increasing integer that
46
+ never resets and never decreases for a given job.
47
+
48
+ ACQUIRE(job, worker)
49
+ - Permitted only if there is no lease row for job, OR now_db >= expires_at (the lease has
50
+ expired by the database clock).
51
+ - On success: generation := previous_generation + 1 (or 1 if none), owner := worker,
52
+ expires_at := now_db + lease_ttl_ms. The new generation value is the fencing token for
53
+ that ownership. Acquire is an atomic compare-and-swap; only one worker can win. A worker
54
+ that loses the compare-and-swap receives null and must not proceed as owner.
55
+
56
+ RENEW(job, worker, generation)
57
+ - Succeeds only if ALL hold at the moment the request reaches the database: the lease's owner
58
+ equals worker, the lease's generation equals generation, and now_db < expires_at.
59
+ - On success: expires_at := now_db + lease_ttl_ms. generation is UNCHANGED by renewal.
60
+ - Otherwise the renewal is rejected and changes nothing. The time a renewal was *sent* is
61
+ irrelevant; only its arrival time at the database and the current row at that instant matter.
62
+ A renewal that was sent while the sender still (locally) believed it held the lease can still
63
+ arrive after expiry and after another worker has acquired a new generation.
64
+
65
+ COMMIT(job, worker, generation) — two steps, in order (see [commit-routine]):
66
+ - Step L (lease fence): re-read the lease row. Proceed only if its owner == worker AND its
67
+ generation == generation AND now_db < expires_at. If not, ABORT the commit (no store write).
68
+ - Step S (store fence): write to [fencing-store] with fencing_token = generation. The store
69
+ decides acceptance. A commit "succeeds" only if Step L passes AND Step S is accepted.
70
+
71
+ SELF-FENCING (client-side guard)
72
+ - A correct worker must not begin Step S once its own local clock reads
73
+ >= (expires_at - safety_margin_ms) for the generation it believes it holds. This only
74
+ prevents wasted writes; Step L and the store fence are the real safety net. A worker that
75
+ skips or is late on this guard is still caught by Step L or the store fence.
76
+ - id: fencing-store
77
+ type: spec
78
+ label: Fencing object store rule
79
+ content: |
80
+ The store keeps one high-water-mark (HWM) fencing token per job, initialized to 0. It has no
81
+ knowledge of leases, owners, or generations beyond the integer token it is handed; it enforces
82
+ only that tokens presented for a job never move backwards.
83
+ On a write with token t for a job:
84
+ - if t >= HWM(job): ACCEPT, then HWM(job) := t (equal tokens are accepted; an idempotent retry
85
+ of the same generation does not move the HWM).
86
+ - if t < HWM(job): REJECT, HWM(job) unchanged.
87
+ Worked example for a job starting at HWM 0: write token 1 -> accept, HWM 1; write token 1 again
88
+ -> accept (equal), HWM stays 1; write token 3 -> accept, HWM 3; write token 2 -> reject (2 < 3),
89
+ HWM stays 3. Because the store cannot tell a valid owner from a stale one, a stale writer that
90
+ manages to present the current-or-higher token would be accepted — which is why Step L in
91
+ [commit-routine] must run first and why generation only ever increases.
92
+ - id: commit-routine
93
+ type: code
94
+ label: Worker acquire/renew/commit pseudocode
95
+ content: |
96
+ // Run by every worker. db is the authoritative primary; store is the fencing object store.
97
+ // ttl = lease_ttl_ms; safetyMargin = safety_margin_ms (see [lease-config]).
98
+
99
+ function acquire(job, self):
100
+ row = db.readLease(job)
101
+ if row == null or db.now() >= row.expires_at:
102
+ nextGen = (row == null) ? 1 : row.generation + 1
103
+ ok = db.cas(job, expected = row,
104
+ next = { owner: self, generation: nextGen, expires_at: db.now() + ttl })
105
+ if ok: return Lease{ job, owner: self, generation: nextGen } // fencing token = nextGen
106
+ return null // not expired, or lost CAS
107
+
108
+ function renew(lease):
109
+ // db applies: row.owner == lease.owner AND row.generation == lease.generation
110
+ // AND db.now() < row.expires_at ; else rejected, no change.
111
+ return db.renewCAS(lease.job, lease.owner, lease.generation, newExpiry = db.now() + ttl)
112
+
113
+ function commit(lease, payload):
114
+ row = db.readLease(lease.job) // Step L: lease fence
115
+ if row.owner != lease.owner
116
+ or row.generation != lease.generation
117
+ or db.now() >= row.expires_at:
118
+ return ABORT_STALE_LEASE // never reaches the store
119
+ if localClock() >= row.expires_at - safetyMargin: // client-side self-fence
120
+ return ABORT_SELF_FENCED
121
+ return store.write(lease.job, token = lease.generation, payload) // Step S: store fence
122
+ - id: lease-config
123
+ type: config
124
+ label: Coordinator configuration
125
+ content: |
126
+ lease_ttl_ms: 10000
127
+ renewal_interval_ms: 4000 # a worker attempts renewal every 4s while it holds a lease
128
+ safety_margin_ms: 1000 # self-fencing guard; see [lease-protocol] / [commit-routine]
129
+ heartbeat_interval_ms: 3000 # liveness only; heartbeats never renew a lease
130
+ dashboard_replica_lag_ms: 1600 # the ownership dashboard reads a lagging read-replica
131
+ worker_clock_offsets_ms: # worker_local_time = db_time + offset
132
+ worker-a: -200 # 200ms behind the database clock
133
+ worker-b: +100 # 100ms ahead
134
+ worker-c: +600 # 600ms ahead
135
+ notes: |
136
+ Offsets describe skew only. Acquire/renew/expiry are evaluated by the database clock, so an
137
+ offset never changes whether a renewal is accepted. Self-fencing uses the local clock: e.g. a
138
+ worker holding a lease with expires_at 12:00:18.000 and offset -200 stops issuing writes once
139
+ its local clock reads 12:00:17.000, i.e. at db_time 12:00:17.200. The dashboard's lagging
140
+ replica means [ownership-snapshot] reflects primary state from ~1.6s before its poll time.
141
+ - id: cluster-events
142
+ type: table
143
+ label: Database-ordered event history
144
+ content: |
145
+ db_time | job | actor | event | result / effect
146
+ 11:59:58.000 | -- | coordinator | bootstrap: lease table empty | no active leases for J3 or J7
147
+ 12:00:00.000 | J7 | worker-a | ACQUIRE J7 | success: gen 1, owner a, expires_at 12:00:10.000, token 1
148
+ 12:00:00.500 | -- | worker-b | heartbeat: idle, holds no lease | informational
149
+ 12:00:01.000 | J3 | worker-c | ACQUIRE J3 | success: gen 1, owner c, expires_at 12:00:11.000, token 1
150
+ 12:00:04.000 | J7 | worker-a | RENEW J7 (a, gen 1) | success: expires_at -> 12:00:14.000
151
+ 12:00:05.000 | J3 | worker-c | RENEW J3 (c, gen 1) | success: expires_at -> 12:00:15.000
152
+ 12:00:06.000 | J3 | worker-c | COMMIT J3 Step L (c, gen 1) | pass: row matches, now < expiry
153
+ 12:00:06.100 | J3 | worker-c | COMMIT J3 Step S (token 1) | store accept: HWM 0 -> 1
154
+ 12:00:07.000 | -- | worker-a | heartbeat: holds J7 gen 1 | informational
155
+ 12:00:08.000 | J7 | worker-a | RENEW J7 (a, gen 1) | success: expires_at -> 12:00:18.000
156
+ 12:00:09.000 | J3 | worker-c | RENEW J3 (c, gen 1) | success: expires_at -> 12:00:19.000
157
+ 12:00:10.200 | -- | coordinator | read-replica lag measured ~1.6s behind | informational
158
+ 12:00:11.500 | -- | dashboard | ownership poll against the read-replica | feeds [ownership-snapshot]
159
+ 12:00:12.000 | J7 | worker-a | RENEW J7 (a, gen 1) SENT | enters network queue, delivery delayed
160
+ 12:00:12.050 | J3 | worker-c | store write (delayed dup of 12:00:06) | store accept: token 1 >= HWM 1, HWM stays 1
161
+ 12:00:13.000 | J3 | worker-c | worker-c enters long GC pause | no further renewals or heartbeats from c
162
+ 12:00:16.000 | J7 | worker-a | RENEW J7 (a, gen 1) SENT | enters network queue, delivery delayed
163
+ 12:00:17.000 | J7 | worker-b | worker-b begins watching J7 | informational; lease near expiry
164
+ 12:00:18.000 | J7 | -- | J7 lease reaches expires_at 12:00:18.000| acquirable; last success was the 12:00:08 renew
165
+ 12:00:18.100 | J7 | worker-b | ACQUIRE J7 | success: now_db >= expiry, gen 2, owner b, expires_at 12:00:28.100, token 2
166
+ 12:00:18.250 | J7 | worker-a | RENEW J7 (a, gen 1) ARRIVES (from 12:12) | rejected: row is gen 2 / owner b; owner and generation mismatch
167
+ 12:00:18.400 | J7 | worker-a | RENEW J7 (a, gen 1) ARRIVES (from 12:16) | rejected: row is gen 2 / owner b; owner and generation mismatch
168
+ 12:00:19.000 | J3 | -- | J3 lease reaches expires_at 12:00:19.000| acquirable; c is still paused
169
+ 12:00:19.020 | J7 | worker-b | COMMIT J7 Step L (b, gen 2) | pass: row matches gen 2 / owner b, now < expiry
170
+ 12:00:19.060 | J7 | worker-b | COMMIT J7 Step S (token 2) | store accept: HWM 0 -> 2
171
+ 12:00:19.500 | J7 | worker-a | COMMIT J7 Step L (a, gen 1) | ABORT: row is gen 2 / owner b; a holds gen 1, mismatch. No store write issued.
172
+ 12:00:20.000 | J3 | worker-a | ACQUIRE J3 | success: now_db >= expiry, gen 2, owner a, expires_at 12:00:30.000, token 2
173
+ 12:00:20.500 | J3 | worker-a | COMMIT J3 Step L (a, gen 2) | pass: row matches gen 2 / owner a, now < expiry
174
+ 12:00:20.600 | J3 | worker-a | COMMIT J3 Step S (token 2) | store accept: HWM 1 -> 2
175
+ 12:00:21.000 | J3 | worker-c | worker-c wakes, stale store write | issues store write token 1 for J3 (see [store-ledger])
176
+ 12:00:21.200 | J3 | worker-c | worker-c RENEW J3 (c, gen 1) ARRIVES | rejected: row is gen 2 / owner a; owner and generation mismatch
177
+ - id: store-ledger
178
+ type: table
179
+ label: Fencing-store write ledger
180
+ content: |
181
+ db_time | job | worker | token | hwm_before | decision | hwm_after
182
+ 12:00:06.100 | J3 | worker-c | 1 | 0 | accept | 1
183
+ 12:00:12.050 | J3 | worker-c | 1 | 1 | accept | 1
184
+ 12:00:19.060 | J7 | worker-b | 2 | 0 | accept | 2
185
+ 12:00:20.600 | J3 | worker-a | 2 | 1 | accept | 2
186
+ 12:00:21.000 | J3 | worker-c | 1 | 2 | reject | 2
187
+ note: worker-a never issued a J7 store write; its J7 commit aborted at Step L (12:00:19.500), so
188
+ it is absent from this ledger. Every row here is a write that actually reached the store.
189
+ - id: ownership-snapshot
190
+ type: note
191
+ label: Monitoring cache
192
+ content: |
193
+ Ownership dashboard — rendered from a read-replica, polled at db_time 12:00:11.500, cache TTL 30s.
194
+ Because the replica lags primary by ~1.6s (see [lease-config]) and is not re-polled until the TTL
195
+ elapses, this card is the newest data the dashboard will show for the whole window.
196
+ job | owner | generation | last_renew_at (replica) | state
197
+ J7 | worker-a | 1 | 12:00:08.000 | healthy
198
+ J3 | worker-c | 1 | 12:00:09.000 | healthy
199
+ poll_source: read-replica (lagging) next_refresh: 12:00:41.500
200
+ - id: proposed-change
201
+ type: diff
202
+ label: Proposed configuration change
203
+ content: |
204
+ --- lease-config (current)
205
+ +++ lease-config (proposed)
206
+ - lease_ttl_ms: 10000
207
+ + lease_ttl_ms: 4000 # "tighten failover: reclaim dead leases faster"
208
+ renewal_interval_ms: 4000 # unchanged
209
+ safety_margin_ms: 1000 # unchanged
210
+ Rationale attached to the change request: "Shorter TTL means we detect a dead worker sooner, so
211
+ failover is quicker." An alternative the author rejected was lowering renewal_interval_ms,
212
+ "because renewing more often just adds database load." Evaluate the change as written.
213
+ tags: [distributed-systems, leases, fencing, generation, cas, clock-skew]