@cosmicdrift/kumiko-framework 0.200.1 → 0.202.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/package.json +7 -3
- package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
- package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
- package/src/api/__tests__/api.test.ts +116 -1
- package/src/api/__tests__/batch.integration.test.ts +53 -0
- package/src/api/__tests__/body-limit.test.ts +90 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
- package/src/api/api-constants.ts +44 -7
- package/src/api/auth-middleware.ts +19 -3
- package/src/api/index.ts +1 -0
- package/src/api/route-registrars.ts +19 -21
- package/src/api/routes.ts +47 -1
- package/src/api/server.ts +1 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
- package/src/db/tenant-db.ts +46 -2
- package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
- package/src/engine/__tests__/build-app-schema.test.ts +25 -0
- package/src/engine/boot-validator/detail-screens.ts +35 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/entity-handlers.ts +8 -1
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +156 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
- package/src/engine/feature-ast/extractors/events.ts +5 -3
- package/src/engine/feature-ast/extractors/round3.ts +5 -3
- package/src/engine/feature-ast/extractors/round5.ts +5 -4
- package/src/engine/feature-ast/extractors/shared.ts +29 -4
- package/src/engine/feature-ast/patch.ts +48 -21
- package/src/engine/feature-ast/patterns.ts +18 -0
- package/src/engine/feature-ast/render.ts +19 -6
- package/src/engine/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +97 -1
- package/src/files/file-routes.ts +10 -2
- package/src/files/types.ts +72 -0
- package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
- package/src/http/__tests__/egress.test.ts +440 -0
- package/src/http/__tests__/policy.test.ts +125 -0
- package/src/http/egress.ts +158 -0
- package/src/http/index.ts +2 -0
- package/src/http/policy.ts +193 -0
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
- package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
- package/src/pipeline/dispatch-batch.ts +11 -5
- package/src/pipeline/dispatch-shared.ts +42 -16
- package/src/pipeline/idempotency.ts +91 -30
|
@@ -22,37 +22,44 @@ describe("idempotency guard", () => {
|
|
|
22
22
|
const tenantA = "00000000-0000-4000-8000-00000000000a";
|
|
23
23
|
const userA = "00000000-0000-4000-8000-0000000000a1";
|
|
24
24
|
|
|
25
|
-
test("returns
|
|
25
|
+
test("returns acquired for new request", async () => {
|
|
26
26
|
const guard = createIdempotencyGuard(testRedis.redis);
|
|
27
27
|
const result = await guard.check(tenantA, userA, "req-new-123");
|
|
28
|
-
expect(result).
|
|
28
|
+
expect(result.status).toBe("acquired");
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
test("returns cached result for duplicate request", async () => {
|
|
32
32
|
const guard = createIdempotencyGuard(testRedis.redis);
|
|
33
33
|
const requestId = "req-dup-456";
|
|
34
34
|
|
|
35
|
-
await guard.
|
|
35
|
+
const acquired = await guard.check(tenantA, userA, requestId);
|
|
36
|
+
if (acquired.status !== "acquired") throw new Error("expected to acquire the lock");
|
|
37
|
+
await guard.store(tenantA, userA, requestId, acquired.token, {
|
|
38
|
+
isSuccess: true,
|
|
39
|
+
data: { id: 1 },
|
|
40
|
+
});
|
|
36
41
|
const cached = await guard.check(tenantA, userA, requestId);
|
|
37
42
|
|
|
38
|
-
expect(cached).
|
|
39
|
-
if (
|
|
40
|
-
expect(JSON.parse(cached)).toEqual({ isSuccess: true, data: { id: 1 } });
|
|
43
|
+
expect(cached.status).toBe("cached");
|
|
44
|
+
if (cached.status !== "cached") throw new Error("expected cached value");
|
|
45
|
+
expect(JSON.parse(cached.result)).toEqual({ isSuccess: true, data: { id: 1 } });
|
|
41
46
|
});
|
|
42
47
|
|
|
43
48
|
test("expires after TTL", async () => {
|
|
44
49
|
const guard = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 1 });
|
|
45
50
|
const requestId = "req-ttl-789";
|
|
46
51
|
|
|
47
|
-
await guard.
|
|
52
|
+
const acquired = await guard.check(tenantA, userA, requestId);
|
|
53
|
+
if (acquired.status !== "acquired") throw new Error("expected to acquire the lock");
|
|
54
|
+
await guard.store(tenantA, userA, requestId, acquired.token, { done: true });
|
|
48
55
|
|
|
49
56
|
// Should exist immediately
|
|
50
|
-
expect(await guard.check(tenantA, userA, requestId)).
|
|
57
|
+
expect((await guard.check(tenantA, userA, requestId)).status).toBe("cached");
|
|
51
58
|
|
|
52
59
|
// Wait for expiry
|
|
53
60
|
await new Promise((r) => setTimeout(r, 1100));
|
|
54
61
|
|
|
55
|
-
expect(await guard.check(tenantA, userA, requestId)).
|
|
62
|
+
expect((await guard.check(tenantA, userA, requestId)).status).toBe("acquired");
|
|
56
63
|
});
|
|
57
64
|
|
|
58
65
|
test("parallel check(): second caller waits for the first's store() instead of racing", async () => {
|
|
@@ -65,7 +72,8 @@ describe("idempotency guard", () => {
|
|
|
65
72
|
|
|
66
73
|
// Request #1 starts — claims the in-progress lock.
|
|
67
74
|
const first = await guard.check(tenantA, userA, requestId);
|
|
68
|
-
expect(first).
|
|
75
|
+
expect(first.status).toBe("acquired");
|
|
76
|
+
if (first.status !== "acquired") throw new Error("expected to acquire the lock");
|
|
69
77
|
|
|
70
78
|
// Request #2 runs concurrently — must block until #1 stores a result.
|
|
71
79
|
const secondPromise = guard.check(tenantA, userA, requestId);
|
|
@@ -80,12 +88,17 @@ describe("idempotency guard", () => {
|
|
|
80
88
|
expect(quickResult.done).toBe(false);
|
|
81
89
|
|
|
82
90
|
// Request #1 finishes.
|
|
83
|
-
await guard.store(tenantA, userA, requestId,
|
|
91
|
+
await guard.store(tenantA, userA, requestId, first.token, {
|
|
92
|
+
isSuccess: true,
|
|
93
|
+
data: { id: 99 },
|
|
94
|
+
});
|
|
84
95
|
|
|
85
|
-
// Request #2 should now see the stored result, not
|
|
96
|
+
// Request #2 should now see the stored result, not a fresh acquisition —
|
|
97
|
+
// no duplicate work.
|
|
86
98
|
const second = await secondPromise;
|
|
87
|
-
expect(second).
|
|
88
|
-
|
|
99
|
+
expect(second.status).toBe("cached");
|
|
100
|
+
if (second.status !== "cached") throw new Error("expected cached value");
|
|
101
|
+
expect(JSON.parse(second.result)).toEqual({ isSuccess: true, data: { id: 99 } });
|
|
89
102
|
});
|
|
90
103
|
|
|
91
104
|
test("crashed handler: pending marker expires, next caller reclaims the lock", async () => {
|
|
@@ -97,11 +110,11 @@ describe("idempotency guard", () => {
|
|
|
97
110
|
const requestId = "req-crashed";
|
|
98
111
|
|
|
99
112
|
const first = await guard.check(tenantA, userA, requestId);
|
|
100
|
-
expect(first).
|
|
113
|
+
expect(first.status).toBe("acquired"); // we acquired the lock, then "crash" — never call store()
|
|
101
114
|
|
|
102
115
|
// After the pending-TTL lapses, a retry should be allowed to take over.
|
|
103
116
|
const second = await guard.check(tenantA, userA, requestId);
|
|
104
|
-
expect(second).
|
|
117
|
+
expect(second.status).toBe("acquired"); // reclaimed
|
|
105
118
|
});
|
|
106
119
|
|
|
107
120
|
test("same requestId from different tenant/user does not hit the same cache entry", async () => {
|
|
@@ -112,22 +125,103 @@ describe("idempotency guard", () => {
|
|
|
112
125
|
|
|
113
126
|
// Tenant A / user A owns the request and stores its result.
|
|
114
127
|
const firstCheck = await guard.check(tenantA, userA, requestId);
|
|
115
|
-
expect(firstCheck).
|
|
116
|
-
|
|
128
|
+
expect(firstCheck.status).toBe("acquired");
|
|
129
|
+
if (firstCheck.status !== "acquired") throw new Error("expected to acquire the lock");
|
|
130
|
+
await guard.store(tenantA, userA, requestId, firstCheck.token, {
|
|
131
|
+
isSuccess: true,
|
|
132
|
+
data: { tenant: "A" },
|
|
133
|
+
});
|
|
117
134
|
|
|
118
135
|
// Same requestId, different tenant+user: must be treated as a fresh
|
|
119
136
|
// request, not see tenant A's cached/pending state.
|
|
120
137
|
const otherTenantCheck = await guard.check(tenantB, userB, requestId);
|
|
121
|
-
expect(otherTenantCheck).
|
|
138
|
+
expect(otherTenantCheck.status).toBe("acquired");
|
|
122
139
|
|
|
123
140
|
// Different user, same tenant: also isolated.
|
|
124
141
|
const otherUserCheck = await guard.check(tenantA, userB, requestId);
|
|
125
|
-
expect(otherUserCheck).
|
|
142
|
+
expect(otherUserCheck.status).toBe("acquired");
|
|
126
143
|
|
|
127
144
|
// Tenant A's own result is still retrievable and unaffected.
|
|
128
145
|
const ownResult = await guard.check(tenantA, userA, requestId);
|
|
129
|
-
expect(ownResult).
|
|
130
|
-
|
|
146
|
+
expect(ownResult.status).toBe("cached");
|
|
147
|
+
if (ownResult.status !== "cached") throw new Error("expected cached value");
|
|
148
|
+
expect(JSON.parse(ownResult.result)).toEqual({ isSuccess: true, data: { tenant: "A" } });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("bug 1 — wait shorter than the pending lock no longer forces a duplicate re-run", async () => {
|
|
152
|
+
// Same inverted ratio as the pre-fix defaults (waitTimeoutMs < pendingTtl),
|
|
153
|
+
// scaled to sub-second so the test stays fast. Pre-fix, the internal
|
|
154
|
+
// waitTimeoutMs was trusted as-is: the waiter gives up at 100ms and
|
|
155
|
+
// reports "acquired" even though request #1 is still legitimately
|
|
156
|
+
// running and stores its result 200ms later — the double-execute bug.
|
|
157
|
+
// Post-fix, waitTimeoutMs is clamped to stay above pendingTtl, so the
|
|
158
|
+
// waiter keeps polling and observes the real result instead.
|
|
159
|
+
const guard = createIdempotencyGuard(testRedis.redis, {
|
|
160
|
+
pendingTtlSeconds: 1,
|
|
161
|
+
waitTimeoutMs: 100,
|
|
162
|
+
pollIntervalMs: 20,
|
|
163
|
+
});
|
|
164
|
+
const requestId = "req-bug1-inverted-timeout";
|
|
165
|
+
|
|
166
|
+
const first = await guard.check(tenantA, userA, requestId);
|
|
167
|
+
expect(first.status).toBe("acquired");
|
|
168
|
+
if (first.status !== "acquired") throw new Error("expected to acquire the lock");
|
|
169
|
+
|
|
170
|
+
// Request #1 is "slow" — stores well after the old 100ms wait window,
|
|
171
|
+
// but well within pendingTtl (1s).
|
|
172
|
+
const storeAfterDelay = (async () => {
|
|
173
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
174
|
+
await guard.store(tenantA, userA, requestId, first.token, {
|
|
175
|
+
isSuccess: true,
|
|
176
|
+
data: { id: "slow-handler" },
|
|
177
|
+
});
|
|
178
|
+
})();
|
|
179
|
+
|
|
180
|
+
const second = await guard.check(tenantA, userA, requestId);
|
|
181
|
+
await storeAfterDelay;
|
|
182
|
+
|
|
183
|
+
// Must observe request #1's real result — never a second "acquired".
|
|
184
|
+
expect(second.status).toBe("cached");
|
|
185
|
+
if (second.status !== "cached") throw new Error("expected cached value, not a re-run");
|
|
186
|
+
expect(JSON.parse(second.result)).toEqual({ isSuccess: true, data: { id: "slow-handler" } });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("bug 2 (window B) — a reclaimed lock's fresh result survives the original owner's stale store()", async () => {
|
|
190
|
+
const guard = createIdempotencyGuard(testRedis.redis, {
|
|
191
|
+
pendingTtlSeconds: 1, // expire fast so we can force a reclaim quickly
|
|
192
|
+
waitTimeoutMs: 6_000,
|
|
193
|
+
pollIntervalMs: 20,
|
|
194
|
+
});
|
|
195
|
+
const requestId = "req-bug2-window-b";
|
|
196
|
+
|
|
197
|
+
// Original owner acquires, then "hangs" (never stores) past pendingTtl.
|
|
198
|
+
const original = await guard.check(tenantA, userA, requestId);
|
|
199
|
+
expect(original.status).toBe("acquired");
|
|
200
|
+
if (original.status !== "acquired") throw new Error("expected to acquire the lock");
|
|
201
|
+
|
|
202
|
+
// Let the lock expire, then a second run reclaims it and finishes fast.
|
|
203
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
204
|
+
const reclaimer = await guard.check(tenantA, userA, requestId);
|
|
205
|
+
expect(reclaimer.status).toBe("acquired");
|
|
206
|
+
if (reclaimer.status !== "acquired") throw new Error("expected to reclaim the lock");
|
|
207
|
+
await guard.store(tenantA, userA, requestId, reclaimer.token, {
|
|
208
|
+
isSuccess: true,
|
|
209
|
+
data: { owner: "reclaimer" },
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// The original (now-stale) run finally "finishes" and tries to store its
|
|
213
|
+
// own, outdated result using its original token.
|
|
214
|
+
await guard.store(tenantA, userA, requestId, original.token, {
|
|
215
|
+
isSuccess: true,
|
|
216
|
+
data: { owner: "original-stale" },
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// The reclaimer's fresh result must survive — the stale store() must be
|
|
220
|
+
// a no-op, not a silent overwrite.
|
|
221
|
+
const final = await guard.check(tenantA, userA, requestId);
|
|
222
|
+
expect(final.status).toBe("cached");
|
|
223
|
+
if (final.status !== "cached") throw new Error("expected cached value");
|
|
224
|
+
expect(JSON.parse(final.result)).toEqual({ isSuccess: true, data: { owner: "reclaimer" } });
|
|
131
225
|
});
|
|
132
226
|
});
|
|
133
227
|
|
|
@@ -29,20 +29,26 @@ export async function runBatch(
|
|
|
29
29
|
|
|
30
30
|
// Idempotency: if the same requestId has already been processed, return the
|
|
31
31
|
// cached result without re-executing. The cache holds the full BatchResult.
|
|
32
|
+
// idempotencyToken is only set when we actually acquired the lock — the
|
|
33
|
+
// corrupted-cache fallthrough below leaves it unset, so finalize() skips
|
|
34
|
+
// store() rather than writing over an entry it never owned.
|
|
35
|
+
let idempotencyToken: string | undefined;
|
|
32
36
|
if (requestId && idempotency) {
|
|
33
|
-
const
|
|
34
|
-
if (cached) {
|
|
35
|
-
const parsed = parseJsonSafe<BatchResult | null>(
|
|
37
|
+
const checked = await idempotency.check(user.tenantId, user.id, requestId);
|
|
38
|
+
if (checked.status === "cached") {
|
|
39
|
+
const parsed = parseJsonSafe<BatchResult | null>(checked.result, null);
|
|
36
40
|
if (parsed) return parsed;
|
|
37
41
|
// corrupted cache entry — treat as miss, let the request re-run
|
|
42
|
+
} else {
|
|
43
|
+
idempotencyToken = checked.token;
|
|
38
44
|
}
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
// Wrap return paths: cache the final result under requestId so retries get
|
|
42
48
|
// the same answer (both success and failure results are cached).
|
|
43
49
|
const finalize = async (result: BatchResult): Promise<BatchResult> => {
|
|
44
|
-
if (requestId && idempotency) {
|
|
45
|
-
await idempotency.store(user.tenantId, user.id, requestId, result);
|
|
50
|
+
if (requestId && idempotency && idempotencyToken) {
|
|
51
|
+
await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, result);
|
|
46
52
|
}
|
|
47
53
|
return result;
|
|
48
54
|
};
|
|
@@ -157,19 +157,23 @@ async function appendDomainEvent(
|
|
|
157
157
|
|
|
158
158
|
// r.systemScope() handlers must go through ctx.systemDb's guarded methods
|
|
159
159
|
// (assertTenantMatch / acknowledgeCrossTenant) instead of plain ctx.db —
|
|
160
|
-
// "system" mode has no tenant filter at all, so silent ctx.db
|
|
161
|
-
// cross-tenant leak. HandlerContext
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
160
|
+
// "system" mode has no tenant filter at all, so silent ctx.db or
|
|
161
|
+
// ctx.dbOutsideTransaction use there is a cross-tenant leak. HandlerContext
|
|
162
|
+
// has no way to make either field optional per handler (isSystem is a
|
|
163
|
+
// runtime-only registry lookup, not a type-level discriminant), so instead
|
|
164
|
+
// of omitting the key we hand back a Proxy that throws on first touch,
|
|
165
|
+
// naming the handler and pointing at the matching ctx.systemDb escape hatch.
|
|
166
|
+
function createSystemScopedDbGuard(
|
|
167
|
+
handlerType: string,
|
|
168
|
+
fieldName: "db" | "dbOutsideTransaction",
|
|
169
|
+
hint: string,
|
|
170
|
+
): TenantDb {
|
|
166
171
|
return new Proxy({} as TenantDb, {
|
|
167
172
|
get(_target, prop) {
|
|
168
173
|
throw new InternalError({
|
|
169
174
|
message:
|
|
170
|
-
`Handler "${handlerType}" is r.systemScope()'d — ctx
|
|
171
|
-
`(it would be unfiltered across every tenant). Use ` +
|
|
172
|
-
`ctx.systemDb.assertTenantMatch(...) or ctx.systemDb.acknowledgeCrossTenant(...) ` +
|
|
175
|
+
`Handler "${handlerType}" is r.systemScope()'d — ctx.${fieldName} is unavailable ` +
|
|
176
|
+
`(it would be unfiltered across every tenant). Use ${hint} ` +
|
|
173
177
|
`instead (attempted to read "${String(prop)}").`,
|
|
174
178
|
});
|
|
175
179
|
},
|
|
@@ -204,19 +208,41 @@ export async function buildHandlerContext(
|
|
|
204
208
|
// the client has disconnected — handlers with many sequential queries skip
|
|
205
209
|
// the rest of the chain instead of burning DB-CPU for results no one reads.
|
|
206
210
|
const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
|
|
207
|
-
const systemDb = isSystem && db ? createUncheckedSystemDb(db) : undefined;
|
|
208
|
-
// Exposed as ctx.db below — the internal `db` above stays the real,
|
|
209
|
-
// working TenantDb for this function's own use (config/derivatives
|
|
210
|
-
// accessors, systemDb construction).
|
|
211
|
-
const exposedDb = isSystem && db ? createSystemScopedDbGuard(type) : db;
|
|
212
211
|
// Unbound pool, tenant-scoped like `db` but never tx-bound — writes
|
|
213
212
|
// through it survive a rollback of the handler's own transaction. No
|
|
214
213
|
// AbortSignal here: a client disconnect must not abort a durability write
|
|
215
|
-
// that is meant to outlive the request.
|
|
214
|
+
// that is meant to outlive the request. Computed before `systemDb` below
|
|
215
|
+
// so its guarded escape hatch (ctx.systemDb.outsideTransaction) can wrap
|
|
216
|
+
// this same TenantDb instead of a second, independently-built one.
|
|
216
217
|
const outsideTxSource = resolveDbSource(ctx, undefined);
|
|
217
|
-
const
|
|
218
|
+
const rawDbOutsideTransaction = outsideTxSource
|
|
218
219
|
? buildTenantScopedDb(outsideTxSource, undefined)
|
|
219
220
|
: undefined;
|
|
221
|
+
const systemDb =
|
|
222
|
+
isSystem && db ? createUncheckedSystemDb(db, rawDbOutsideTransaction) : undefined;
|
|
223
|
+
// Exposed as ctx.db below — the internal `db` above stays the real,
|
|
224
|
+
// working TenantDb for this function's own use (config/derivatives
|
|
225
|
+
// accessors, systemDb construction).
|
|
226
|
+
const exposedDb =
|
|
227
|
+
isSystem && db
|
|
228
|
+
? createSystemScopedDbGuard(
|
|
229
|
+
type,
|
|
230
|
+
"db",
|
|
231
|
+
"ctx.systemDb.assertTenantMatch(...) or ctx.systemDb.acknowledgeCrossTenant(...)",
|
|
232
|
+
)
|
|
233
|
+
: db;
|
|
234
|
+
// Same fail-closed treatment as `exposedDb` — a system handler reaching
|
|
235
|
+
// ctx.dbOutsideTransaction directly would be a second, unguarded door to
|
|
236
|
+
// the same cross-tenant access ctx.db closes off above.
|
|
237
|
+
const dbOutsideTransaction =
|
|
238
|
+
isSystem && rawDbOutsideTransaction
|
|
239
|
+
? createSystemScopedDbGuard(
|
|
240
|
+
type,
|
|
241
|
+
"dbOutsideTransaction",
|
|
242
|
+
"ctx.systemDb.outsideTransaction.assertTenantMatch(...) or " +
|
|
243
|
+
"ctx.systemDb.outsideTransaction.acknowledgeCrossTenant(...)",
|
|
244
|
+
)
|
|
245
|
+
: rawDbOutsideTransaction;
|
|
220
246
|
const log = context.log?.child({
|
|
221
247
|
handler: type,
|
|
222
248
|
tenantId: user.tenantId,
|
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import type Redis from "ioredis";
|
|
2
|
+
import { InternalError } from "../errors";
|
|
3
|
+
import { generateId } from "../utils";
|
|
2
4
|
import { RedisKeys } from "./redis-keys";
|
|
3
5
|
|
|
6
|
+
// Discriminated so a truthy "acquired" object can never be misread as a
|
|
7
|
+
// cache hit by a callsite doing `if (result)` — the caller must switch on
|
|
8
|
+
// `status`.
|
|
9
|
+
export type IdempotencyCheckResult =
|
|
10
|
+
| { readonly status: "cached"; readonly result: string }
|
|
11
|
+
| { readonly status: "acquired"; readonly token: string };
|
|
12
|
+
|
|
4
13
|
export type IdempotencyGuard = {
|
|
5
|
-
check(tenantId: string, userId: string, requestId: string): Promise<
|
|
6
|
-
store(
|
|
14
|
+
check(tenantId: string, userId: string, requestId: string): Promise<IdempotencyCheckResult>;
|
|
15
|
+
store(
|
|
16
|
+
tenantId: string,
|
|
17
|
+
userId: string,
|
|
18
|
+
requestId: string,
|
|
19
|
+
token: string,
|
|
20
|
+
result: unknown,
|
|
21
|
+
): Promise<void>;
|
|
7
22
|
};
|
|
8
23
|
|
|
9
|
-
// Sentinel stored under the key while the handler is running.
|
|
10
|
-
//
|
|
11
|
-
|
|
24
|
+
// Sentinel prefix stored under the key while the handler is running. Each
|
|
25
|
+
// acquisition appends a unique token so store() can later prove it still
|
|
26
|
+
// owns the lock it started with (see storeScript below) instead of blindly
|
|
27
|
+
// overwriting whatever is currently there.
|
|
28
|
+
const PENDING_PREFIX = "__pending__:";
|
|
12
29
|
|
|
13
30
|
export function createIdempotencyGuard(
|
|
14
31
|
redis: Redis,
|
|
@@ -25,15 +42,40 @@ export function createIdempotencyGuard(
|
|
|
25
42
|
// handler doesn't permanently block retries, long enough to cover normal
|
|
26
43
|
// batch durations.
|
|
27
44
|
const pendingTtl = options.pendingTtlSeconds ?? 30;
|
|
28
|
-
|
|
45
|
+
// Must stay comfortably above pendingTtl: a wait shorter than the lock's
|
|
46
|
+
// own TTL makes a retry give up and re-run the handler while the original
|
|
47
|
+
// call is still legitimately in flight — the exact case this lock exists
|
|
48
|
+
// to prevent. Clamped rather than trusted so a misconfigured explicit
|
|
49
|
+
// option can't reintroduce that inversion.
|
|
50
|
+
const waitTimeoutMs = Math.max(options.waitTimeoutMs ?? 35_000, pendingTtl * 1000 + 5_000);
|
|
29
51
|
const pollIntervalMs = options.pollIntervalMs ?? 50;
|
|
30
52
|
const prefix = RedisKeys.idempotency;
|
|
31
53
|
|
|
54
|
+
// Atomic compare-and-swap: only persist the result if the key still holds
|
|
55
|
+
// the exact pending token this run acquired. If a parallel retry reclaimed
|
|
56
|
+
// an expired lock in the meantime, this is a no-op — the reclaiming run
|
|
57
|
+
// owns the key now and will persist the authoritative result itself.
|
|
58
|
+
const storeScript = `
|
|
59
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
60
|
+
redis.call("set", KEYS[1], ARGV[2], "EX", ARGV[3])
|
|
61
|
+
return 1
|
|
62
|
+
else
|
|
63
|
+
return 0
|
|
64
|
+
end
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
async function tryAcquire(key: string): Promise<string | null> {
|
|
68
|
+
const token = `${PENDING_PREFIX}${generateId()}`;
|
|
69
|
+
const acquired = await redis.set(key, token, "EX", pendingTtl, "NX");
|
|
70
|
+
return acquired === "OK" ? token : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
32
73
|
return {
|
|
33
74
|
// Returns:
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
75
|
+
// { status: "acquired", token } — caller owns the in-progress lock,
|
|
76
|
+
// proceed to run the handler and then call store() with this token.
|
|
77
|
+
// { status: "cached", result } — serialized result from a concurrent
|
|
78
|
+
// or prior request; do not run the handler.
|
|
37
79
|
//
|
|
38
80
|
// The old behaviour (pure GET + SET-NX-store) let two parallel requests
|
|
39
81
|
// both see a cache miss, both execute side-effects, and only one persist
|
|
@@ -42,40 +84,59 @@ export function createIdempotencyGuard(
|
|
|
42
84
|
async check(tenantId, userId, requestId) {
|
|
43
85
|
const key = `${prefix}${tenantId}:${userId}:${requestId}`;
|
|
44
86
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (acquired === "OK") return null;
|
|
87
|
+
const token = await tryAcquire(key);
|
|
88
|
+
if (token) return { status: "acquired", token };
|
|
48
89
|
|
|
49
90
|
// Lost the race. Poll until the lock holder stores a result, or the
|
|
50
91
|
// lock expires (handler crashed) and we can try again.
|
|
51
92
|
const deadline = Date.now() + waitTimeoutMs;
|
|
52
93
|
while (Date.now() < deadline) {
|
|
53
94
|
const value = await redis.get(key);
|
|
54
|
-
if (value === null) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
95
|
+
if (value === null || value.startsWith(PENDING_PREFIX)) {
|
|
96
|
+
if (value === null) {
|
|
97
|
+
// Lock expired before a result was stored — try to claim it
|
|
98
|
+
// ourselves and proceed as the new owner.
|
|
99
|
+
const reclaimed = await tryAcquire(key);
|
|
100
|
+
if (reclaimed) return { status: "acquired", token: reclaimed };
|
|
101
|
+
}
|
|
102
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
59
103
|
continue;
|
|
60
104
|
}
|
|
61
|
-
|
|
62
|
-
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
105
|
+
return { status: "cached", result: value };
|
|
63
106
|
}
|
|
64
107
|
|
|
65
|
-
// Gave up waiting.
|
|
66
|
-
//
|
|
67
|
-
|
|
108
|
+
// Gave up waiting. By construction waitTimeoutMs > pendingTtl, so the
|
|
109
|
+
// lock must have expired already — make one last claim attempt rather
|
|
110
|
+
// than silently reporting ownership we never acquired (that would
|
|
111
|
+
// reintroduce the double-execute bug). If even that loses the race,
|
|
112
|
+
// fail loudly instead of running the handler a second time.
|
|
113
|
+
const finalValue = await redis.get(key);
|
|
114
|
+
if (finalValue !== null && !finalValue.startsWith(PENDING_PREFIX)) {
|
|
115
|
+
return { status: "cached", result: finalValue };
|
|
116
|
+
}
|
|
117
|
+
const lastResort = await tryAcquire(key);
|
|
118
|
+
if (lastResort) return { status: "acquired", token: lastResort };
|
|
119
|
+
throw new InternalError({
|
|
120
|
+
message: `idempotency lock contention: gave up waiting for requestId ${requestId}`,
|
|
121
|
+
});
|
|
68
122
|
},
|
|
69
123
|
|
|
70
|
-
async store(tenantId, userId, requestId, result) {
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
await redis.
|
|
74
|
-
|
|
124
|
+
async store(tenantId, userId, requestId, token, result) {
|
|
125
|
+
const key = `${prefix}${tenantId}:${userId}:${requestId}`;
|
|
126
|
+
// @cast-boundary db-operator — Lua EVAL return type is untyped in ioredis
|
|
127
|
+
const written = (await redis.eval(
|
|
128
|
+
storeScript,
|
|
129
|
+
1,
|
|
130
|
+
key,
|
|
131
|
+
token,
|
|
75
132
|
JSON.stringify(result),
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
133
|
+
String(ttl),
|
|
134
|
+
)) as number;
|
|
135
|
+
// written === 0 means another process reclaimed this key after our
|
|
136
|
+
// lock expired and is (or already did) persist its own result —
|
|
137
|
+
// silently skipping here is the fix: the old code did an unconditional
|
|
138
|
+
// SET and could stomp that fresher result with our stale one.
|
|
139
|
+
void written;
|
|
79
140
|
},
|
|
80
141
|
};
|
|
81
142
|
}
|