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.
- package/CITATION.cff +15 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/dist/chunk-4TWPCPRP.cjs +1097 -0
- package/dist/chunk-4TWPCPRP.cjs.map +1 -0
- package/dist/chunk-7YCJSOK7.cjs +398 -0
- package/dist/chunk-7YCJSOK7.cjs.map +1 -0
- package/dist/chunk-CIXITJW6.cjs +249 -0
- package/dist/chunk-CIXITJW6.cjs.map +1 -0
- package/dist/chunk-EQHRUV2I.js +1466 -0
- package/dist/chunk-EQHRUV2I.js.map +1 -0
- package/dist/chunk-JTVNKSMO.js +1096 -0
- package/dist/chunk-JTVNKSMO.js.map +1 -0
- package/dist/chunk-LFKEV2YL.js +398 -0
- package/dist/chunk-LFKEV2YL.js.map +1 -0
- package/dist/chunk-NJTYVNP4.cjs +1467 -0
- package/dist/chunk-NJTYVNP4.cjs.map +1 -0
- package/dist/chunk-UECBSKTD.js +244 -0
- package/dist/chunk-UECBSKTD.js.map +1 -0
- package/dist/cli.cjs +409 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +408 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.cjs +42 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +93 -0
- package/dist/client.d.ts +93 -0
- package/dist/client.js +42 -0
- package/dist/client.js.map +1 -0
- package/dist/contracts/index.cjs +47 -0
- package/dist/contracts/index.cjs.map +1 -0
- package/dist/contracts/index.d.cts +14 -0
- package/dist/contracts/index.d.ts +14 -0
- package/dist/contracts/index.js +47 -0
- package/dist/contracts/index.js.map +1 -0
- package/dist/index.cjs +171 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +214 -0
- package/dist/index.d.ts +214 -0
- package/dist/index.js +171 -0
- package/dist/index.js.map +1 -0
- package/dist/logger-CCR9Mg1c.d.cts +319 -0
- package/dist/logger-QJU7SBDz.d.ts +319 -0
- package/dist/reports-4CejmOHf.d.cts +454 -0
- package/dist/reports-s2CTnGN8.d.ts +454 -0
- package/dist/tasks-CpaCJ6JE.d.cts +151 -0
- package/dist/tasks-CpaCJ6JE.d.ts +151 -0
- package/dist/tasks.cjs +22 -0
- package/dist/tasks.cjs.map +1 -0
- package/dist/tasks.d.cts +39 -0
- package/dist/tasks.d.ts +39 -0
- package/dist/tasks.js +22 -0
- package/dist/tasks.js.map +1 -0
- package/docs/README.md +25 -0
- package/docs/glossary.md +49 -0
- package/docs/methodology.md +58 -0
- package/docs/private-packs.md +74 -0
- package/docs/replay-elo.md +79 -0
- package/docs/task-authoring.md +80 -0
- package/package.json +137 -0
- package/tasks/hallucination/public/boundary-coverage-audit.yaml +274 -0
- package/tasks/hallucination/public/boundary-migration-audit.yaml +284 -0
- package/tasks/hallucination/public/conflict-dependency-versions.yaml +324 -0
- package/tasks/hallucination/public/conflict-runbook-versions.yaml +229 -0
- package/tasks/hallucination/public/fabrication-agent-tools.yaml +224 -0
- package/tasks/hallucination/public/fabrication-api-surface.yaml +239 -0
- package/tasks/hallucination/public/fidelity-commit-attribution.yaml +304 -0
- package/tasks/hallucination/public/fidelity-config-drift.yaml +307 -0
- package/tasks/hallucination/public/missing-deploy-window.yaml +204 -0
- package/tasks/hallucination/public/missing-latency-baseline.yaml +239 -0
- package/tasks/hallucination/public/premise-quota-breach.yaml +202 -0
- package/tasks/hallucination/public/premise-rollback-cause.yaml +235 -0
- package/tasks/reasoning/public/constraint-capacity-allocation.yaml +196 -0
- package/tasks/reasoning/public/constraint-deployment-policy.yaml +203 -0
- package/tasks/reasoning/public/counterexample-authorization-rule.yaml +278 -0
- package/tasks/reasoning/public/counterexample-scheduler-starvation.yaml +290 -0
- package/tasks/reasoning/public/root-cache-tenant-leak.yaml +225 -0
- package/tasks/reasoning/public/root-event-ordering.yaml +184 -0
- package/tasks/reasoning/public/stateful-lease-handoff.yaml +213 -0
- package/tasks/reasoning/public/stateful-retry-budget.yaml +222 -0
- package/tasks/reasoning/public/synthesis-api-contract.yaml +214 -0
- package/tasks/reasoning/public/synthesis-permission-migration.yaml +190 -0
- package/tasks/reasoning/public/uncertainty-conflicting-telemetry.yaml +242 -0
- 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]
|