@effect-agent/storage-memory 0.1.0-beta.9 → 0.1.0-beta.90

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 (34) hide show
  1. package/dist/MemoryMessageDeliveryStore.d.mts +15 -0
  2. package/dist/MemoryMessageDeliveryStore.mjs +147 -0
  3. package/dist/MemoryMessageDeliveryStore.mjs.map +1 -0
  4. package/dist/MemoryScheduleStore.d.mts +10 -0
  5. package/dist/MemoryScheduleStore.mjs +169 -0
  6. package/dist/MemoryScheduleStore.mjs.map +1 -0
  7. package/dist/MemorySemanticIndex.d.mts +21 -0
  8. package/dist/MemorySemanticIndex.mjs +222 -0
  9. package/dist/MemorySemanticIndex.mjs.map +1 -0
  10. package/dist/MemorySubmissionLedger.d.mts +24 -0
  11. package/dist/MemorySubmissionLedger.mjs +1058 -0
  12. package/dist/MemorySubmissionLedger.mjs.map +1 -0
  13. package/dist/MemorySubscriptionStore.d.mts +9 -0
  14. package/dist/MemorySubscriptionStore.mjs +849 -0
  15. package/dist/MemorySubscriptionStore.mjs.map +1 -0
  16. package/dist/MemoryThreadStore.d.mts +17 -0
  17. package/dist/MemoryThreadStore.mjs +377 -0
  18. package/dist/MemoryThreadStore.mjs.map +1 -0
  19. package/dist/index.d.mts +7 -30
  20. package/dist/index.mjs +7 -1298
  21. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  22. package/package.json +1 -45
  23. package/src/MemoryMessageDeliveryStore.ts +293 -0
  24. package/src/MemoryScheduleStore.ts +328 -0
  25. package/src/MemorySemanticIndex.ts +357 -0
  26. package/src/{memory-ledger.ts → MemorySubmissionLedger.ts} +478 -86
  27. package/src/MemorySubscriptionStore.ts +1549 -0
  28. package/src/MemoryThreadStore.ts +766 -0
  29. package/src/index.ts +6 -2
  30. package/dist/index.mjs.map +0 -1
  31. package/dist/testing.d.mts +0 -2
  32. package/dist/testing.mjs +0 -2
  33. package/src/memory-storage.ts +0 -614
  34. package/src/testing.ts +0 -10
@@ -0,0 +1,1549 @@
1
+ import { Clock, Effect, Layer, Ref, Result, Schema } from "effect";
2
+ import { Digest } from "effect-agent/records";
3
+ import { compareScheduleNames } from "effect-agent/schedule-transition";
4
+ import {
5
+ AcceptedEvent,
6
+ DeliveryChange,
7
+ SubscriptionChange,
8
+ SourcePartition,
9
+ SubscriptionDelivery,
10
+ SubscriptionDeliveryKey,
11
+ SubscriptionError,
12
+ SubscriptionFailpoint,
13
+ SubscriptionKey,
14
+ SubscriptionLimits,
15
+ SubscriptionRetentionPolicy,
16
+ SubscriptionName,
17
+ SubscriptionRecord,
18
+ SubscriptionScanCursors,
19
+ SubscriptionStore,
20
+ subscriptionDeliveryKeyString,
21
+ subscriptionKeyString,
22
+ } from "effect-agent/subscription";
23
+ import {
24
+ sameAcceptedEventIdentity,
25
+ applySubscriptionDeliveryChange,
26
+ applySubscriptionChange,
27
+ validateEventRetention,
28
+ subscriptionCanSelect,
29
+ subscriptionDeliveryCanSelect,
30
+ } from "effect-agent/subscription-transition";
31
+
32
+ interface MemorySubscriptionState {
33
+ readonly sequence: number;
34
+ readonly retentionDeadline: number | null;
35
+ readonly tombstoneCount: number;
36
+ readonly eventKeys: ReadonlyArray<string>;
37
+ readonly deliveryKeys: ReadonlyArray<string>;
38
+ readonly maintenanceEvents: string;
39
+ readonly maintenanceDeliveries: string;
40
+ readonly recoveryCounts: ReadonlyMap<string, number>;
41
+ readonly eventDeliveryCounts: ReadonlyMap<string, number>;
42
+ readonly retentionHorizon: number | null;
43
+ readonly registrations: ReadonlyMap<string, string>;
44
+ readonly events: ReadonlyMap<string, string>;
45
+ readonly deliveries: ReadonlyMap<string, string>;
46
+ readonly registrationIndex: ReadonlyMap<string, RegistrationIndex>;
47
+ readonly candidateIndex: ReadonlyMap<string, ReadonlyArray<string>>;
48
+ readonly ownerRegistrationCounts: ReadonlyMap<string, number>;
49
+ readonly eventIndex: ReadonlyMap<string, EventIndex>;
50
+ readonly deliveryIndex: ReadonlyMap<string, DeliveryIndex>;
51
+ readonly ownerDeliveryCounts: ReadonlyMap<string, number>;
52
+ readonly scanCursors: SubscriptionScanCursors;
53
+ }
54
+
55
+ interface RegistrationIndex {
56
+ readonly key: SubscriptionKey;
57
+ readonly ordinal: number;
58
+ readonly state: SubscriptionRecord["state"];
59
+ readonly recoveryKey: string | null;
60
+ readonly recoveryAt: number | null;
61
+ }
62
+ interface EventIndex {
63
+ readonly routingComplete: boolean;
64
+ readonly nextAttemptAtMillis: number;
65
+ }
66
+ interface DeliveryIndex {
67
+ readonly key: SubscriptionDeliveryKey;
68
+ readonly state: SubscriptionDelivery["state"];
69
+ readonly parked?: boolean;
70
+ readonly observeSettlement?: boolean;
71
+ readonly nextAttemptAtMillis: number;
72
+ }
73
+
74
+ const error = (reason: SubscriptionError["reason"], code: string) =>
75
+ SubscriptionError.make({ reason, code });
76
+
77
+ const samePartition = (left: SourcePartition, right: SourcePartition): boolean =>
78
+ left.tenantId === right.tenantId && left.address === right.address;
79
+
80
+ const sameSource = (left: AcceptedEvent["source"], right: AcceptedEvent["source"]): boolean =>
81
+ left.name === right.name && left.version === right.version;
82
+
83
+ const encode = <A, I>(
84
+ schema: Schema.Codec<A, I>,
85
+ value: A,
86
+ code: string,
87
+ ): Result.Result<string, SubscriptionError> =>
88
+ Result.try({
89
+ try: () => Schema.encodeSync(Schema.fromJsonString(schema))(value),
90
+ catch: () => error("corrupt", code),
91
+ });
92
+
93
+ const decode = <A, I>(
94
+ schema: Schema.Codec<A, I>,
95
+ value: string,
96
+ code: string,
97
+ ): Result.Result<A, SubscriptionError> =>
98
+ Result.try({
99
+ try: () => Schema.decodeSync(Schema.fromJsonString(schema))(value),
100
+ catch: () => error("corrupt", code),
101
+ });
102
+
103
+ const decodeEffect = <A, I>(schema: Schema.Codec<A, I>, value: string, code: string) =>
104
+ Effect.fromResult(decode(schema, value, code));
105
+
106
+ const validate = <A, I>(schema: Schema.Codec<A, I>, value: unknown, code: string) =>
107
+ Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => error("validation", code)));
108
+
109
+ const jsonBytes = (value: unknown): number =>
110
+ new TextEncoder().encode(JSON.stringify(value)).byteLength;
111
+
112
+ const sameDeliveryIdentity = (left: SubscriptionDelivery, right: SubscriptionDelivery): boolean =>
113
+ subscriptionDeliveryKeyString(left.key) === subscriptionDeliveryKeyString(right.key) &&
114
+ left.deliveryId === right.deliveryId &&
115
+ left.source.name === right.source.name &&
116
+ left.source.version === right.source.version &&
117
+ left.threadId === right.threadId &&
118
+ left.admissionKey === right.admissionKey &&
119
+ left.subscriptionFingerprint === right.subscriptionFingerprint &&
120
+ left.eventDigest === right.eventDigest;
121
+
122
+ const candidateIndexKey = (record: SubscriptionRecord): string =>
123
+ JSON.stringify([
124
+ record.configuration.source.name,
125
+ record.configuration.source.version,
126
+ record.configuration.matchingKey,
127
+ ]);
128
+
129
+ const eventCandidateIndexKey = (event: AcceptedEvent): string =>
130
+ JSON.stringify([event.source.name, event.source.version, event.matchingKey]);
131
+
132
+ const sameEventIdentity = sameAcceptedEventIdentity;
133
+
134
+ const deliveryBelongsTo = (
135
+ delivery: SubscriptionDelivery,
136
+ record: SubscriptionRecord,
137
+ event: AcceptedEvent,
138
+ ): boolean =>
139
+ subscriptionKeyString(delivery.key.subscription) === subscriptionKeyString(record.key) &&
140
+ delivery.key.eventId === event.eventId &&
141
+ sameSource(delivery.source, event.source);
142
+
143
+ // Ordered in-memory indexes allow bounded maintenance pages without scanning retained values.
144
+ const removeKeys = (
145
+ keys: ReadonlyArray<string>,
146
+ removed: ReadonlySet<string>,
147
+ ): ReadonlyArray<string> => {
148
+ if (removed.size === 0) return keys;
149
+ const result = [...keys];
150
+
151
+ for (const key of removed) {
152
+ const index = upperBound(result, key) - 1;
153
+
154
+ if (result[index] === key) result.splice(index, 1);
155
+ }
156
+
157
+ return result;
158
+ };
159
+
160
+ const upperBound = (keys: ReadonlyArray<string>, after: string): number => {
161
+ let low = 0;
162
+ let high = keys.length;
163
+
164
+ while (low < high) {
165
+ const middle = Math.floor((low + high) / 2);
166
+ const key = keys[middle];
167
+
168
+ if (key !== undefined && compareScheduleNames(key, after) <= 0) low = middle + 1;
169
+ else high = middle;
170
+ }
171
+
172
+ return low;
173
+ };
174
+
175
+ const insertKey = (keys: ReadonlyArray<string>, key: string): ReadonlyArray<string> => {
176
+ const at = upperBound(keys, key);
177
+
178
+ if (at > 0 && keys[at - 1] === key) return keys;
179
+
180
+ return [...keys.slice(0, at), key, ...keys.slice(at)];
181
+ };
182
+
183
+ const recoveryCountsAfter = (
184
+ current: MemorySubscriptionState,
185
+ next: ReadonlyMap<string, RegistrationIndex>,
186
+ keys: ReadonlyArray<string>,
187
+ ): ReadonlyMap<string, number> => {
188
+ const counts = new Map(current.recoveryCounts);
189
+
190
+ for (const key of keys) {
191
+ const before = current.registrationIndex.get(key)?.recoveryKey;
192
+ const after = next.get(key)?.recoveryKey;
193
+
194
+ if (before === after) continue;
195
+ if (before !== null && before !== undefined) counts.set(before, (counts.get(before) ?? 0) - 1);
196
+ if (after !== null && after !== undefined) counts.set(after, (counts.get(after) ?? 0) + 1);
197
+ }
198
+
199
+ return counts;
200
+ };
201
+
202
+ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(function* (
203
+ ownedPartition: SourcePartition,
204
+ ) {
205
+ const partition = yield* validate(SourcePartition, ownedPartition, "partition");
206
+
207
+ const state = yield* Ref.make<MemorySubscriptionState>({
208
+ sequence: 0,
209
+ retentionDeadline: null,
210
+ tombstoneCount: 0,
211
+ eventKeys: [],
212
+ deliveryKeys: [],
213
+ maintenanceEvents: "",
214
+ maintenanceDeliveries: "",
215
+ recoveryCounts: new Map(),
216
+ eventDeliveryCounts: new Map(),
217
+ retentionHorizon: null,
218
+ registrations: new Map(),
219
+ events: new Map(),
220
+ deliveries: new Map(),
221
+ registrationIndex: new Map(),
222
+ candidateIndex: new Map(),
223
+ ownerRegistrationCounts: new Map(),
224
+ eventIndex: new Map(),
225
+ deliveryIndex: new Map(),
226
+ ownerDeliveryCounts: new Map(),
227
+ scanCursors: { events: "", deliveries: "", recovery: 0 },
228
+ });
229
+
230
+ const failpoint = yield* SubscriptionFailpoint;
231
+
232
+ const requirePartition = <A extends { readonly partition: SourcePartition }>(
233
+ value: A,
234
+ code: string,
235
+ ) =>
236
+ samePartition(value.partition, partition)
237
+ ? Effect.succeed(value)
238
+ : Effect.fail(error("validation", code));
239
+
240
+ const requireKey = (key: SubscriptionKey, code: string) =>
241
+ validate(SubscriptionKey, key, code).pipe(
242
+ Effect.flatMap((decoded) => requirePartition(decoded, code)),
243
+ );
244
+
245
+ const register: SubscriptionStore["Service"]["register"] = Effect.fn(
246
+ "MemorySubscriptionStore.register",
247
+ )(function* (input, inputLimits) {
248
+ const record = yield* validate(SubscriptionRecord, input, "register-record");
249
+ const limits = yield* validate(SubscriptionLimits, inputLimits, "register-limits");
250
+
251
+ yield* requirePartition(record.key, "register-partition");
252
+ yield* failpoint.hit("subscription:register:before");
253
+
254
+ const result = yield* Effect.uninterruptible(
255
+ Ref.modify(
256
+ state,
257
+ (
258
+ current,
259
+ ): readonly [
260
+ Result.Result<SubscriptionRecord, SubscriptionError>,
261
+ MemorySubscriptionState,
262
+ ] => {
263
+ const key = subscriptionKeyString(record.key);
264
+ const existingText = current.registrations.get(key);
265
+
266
+ if (existingText !== undefined) {
267
+ const existing = decode(SubscriptionRecord, existingText, "register-existing");
268
+
269
+ if (Result.isFailure(existing)) return [Result.fail(existing.failure), current];
270
+
271
+ return existing.success.creationFingerprint === record.creationFingerprint
272
+ ? [Result.succeed(existing.success), current]
273
+ : [Result.fail(error("conflict", "registration-identity")), current];
274
+ }
275
+ if (jsonBytes(record.configuration.context) > limits.maxContextBytes)
276
+ return [Result.fail(error("capacity", "context-bytes")), current];
277
+ if (jsonBytes(record.configuration.parameters) > limits.maxPayloadBytes)
278
+ return [Result.fail(error("capacity", "parameters-bytes")), current];
279
+ if (
280
+ record.configuration.expiresAtMillis !== null &&
281
+ record.configuration.expiresAtMillis - record.createdAtMillis > limits.maxLifetimeMillis
282
+ )
283
+ return [Result.fail(error("capacity", "lifetime")), current];
284
+ if (current.registrations.size >= limits.maxRegistrations)
285
+ return [Result.fail(error("capacity", "registrations")), current];
286
+ const ownerCount = current.ownerRegistrationCounts.get(record.key.ownerId) ?? 0;
287
+
288
+ if (ownerCount >= limits.maxRegistrationsPerOwner)
289
+ return [Result.fail(error("capacity", "owner-registrations")), current];
290
+ const assigned = { ...record, ordinal: current.sequence + 1 };
291
+ const encoded = encode(SubscriptionRecord, assigned, "register-encode");
292
+
293
+ if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
294
+ const registrations = new Map(current.registrations);
295
+
296
+ registrations.set(key, encoded.success);
297
+ const registrationIndex = new Map(current.registrationIndex);
298
+
299
+ registrationIndex.set(key, {
300
+ key: assigned.key,
301
+ ordinal: assigned.ordinal,
302
+ state: assigned.state,
303
+ recoveryKey: assigned.recovery === null ? null : candidateIndexKey(assigned),
304
+ recoveryAt: assigned.recovery?.nextAttemptAtMillis ?? null,
305
+ });
306
+ const candidateIndex = new Map(current.candidateIndex);
307
+ const candidateKey = candidateIndexKey(assigned);
308
+
309
+ candidateIndex.set(candidateKey, [...(candidateIndex.get(candidateKey) ?? []), key]);
310
+ const ownerRegistrationCounts = new Map(current.ownerRegistrationCounts);
311
+
312
+ ownerRegistrationCounts.set(assigned.key.ownerId, ownerCount + 1);
313
+
314
+ return [
315
+ Result.succeed(assigned),
316
+ {
317
+ ...current,
318
+ sequence: assigned.ordinal,
319
+ registrations,
320
+ registrationIndex,
321
+ recoveryCounts: recoveryCountsAfter(current, registrationIndex, [key]),
322
+ candidateIndex,
323
+ ownerRegistrationCounts,
324
+ },
325
+ ];
326
+ },
327
+ ),
328
+ ).pipe(Effect.flatMap(Effect.fromResult));
329
+
330
+ yield* failpoint.hit("subscription:register:after");
331
+
332
+ return result;
333
+ });
334
+
335
+ const get: SubscriptionStore["Service"]["get"] = Effect.fn("MemorySubscriptionStore.get")(
336
+ function* (input) {
337
+ const key = yield* requireKey(input, "get-key");
338
+ const text = (yield* Ref.get(state)).registrations.get(subscriptionKeyString(key));
339
+
340
+ return text === undefined
341
+ ? null
342
+ : yield* decodeEffect(SubscriptionRecord, text, "get-record");
343
+ },
344
+ );
345
+
346
+ const list: SubscriptionStore["Service"]["list"] = Effect.fn("MemorySubscriptionStore.list")(
347
+ function* (ownerId, after, limit) {
348
+ if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit <= 0)
349
+ return yield* error("validation", "list-page");
350
+ const current = yield* Ref.get(state);
351
+ const records: Array<SubscriptionRecord> = [];
352
+
353
+ for (const [storageKey, indexed] of current.registrationIndex) {
354
+ if (indexed.key.ownerId !== ownerId || indexed.ordinal <= after) continue;
355
+ const text = current.registrations.get(storageKey);
356
+
357
+ if (text === undefined) return yield* error("corrupt", "list-index");
358
+ records.push(yield* decodeEffect(SubscriptionRecord, text, "list-record"));
359
+ }
360
+ records.sort((a, b) => a.ordinal - b.ordinal);
361
+
362
+ return records.slice(0, limit);
363
+ },
364
+ );
365
+
366
+ const change: SubscriptionStore["Service"]["change"] = Effect.fn(
367
+ "MemorySubscriptionStore.change",
368
+ )(function* (input, expectedRevision, inputChange) {
369
+ const key = yield* requireKey(input, "change-key");
370
+ const change = yield* validate(SubscriptionChange, inputChange, "change");
371
+
372
+ yield* validate(Schema.Int.check(Schema.isGreaterThan(0)), expectedRevision, "revision");
373
+ yield* failpoint.hit("subscription:change:before");
374
+
375
+ const updated = yield* Effect.uninterruptible(
376
+ Ref.modify(
377
+ state,
378
+ (
379
+ current,
380
+ ): readonly [
381
+ Result.Result<SubscriptionRecord, SubscriptionError>,
382
+ MemorySubscriptionState,
383
+ ] => {
384
+ const storageKey = subscriptionKeyString(key);
385
+ const text = current.registrations.get(storageKey);
386
+
387
+ if (text === undefined) return [Result.fail(error("not-found", "subscription")), current];
388
+ const existing = decode(SubscriptionRecord, text, "change-record");
389
+
390
+ if (Result.isFailure(existing)) return [existing, current];
391
+ const updated = applySubscriptionChange(existing.success, expectedRevision, change);
392
+
393
+ if (Result.isFailure(updated)) return [updated, current];
394
+ const revised = { ...updated.success, ordinal: current.sequence + 1 };
395
+ const encoded = encode(SubscriptionRecord, revised, "change-encode");
396
+
397
+ if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
398
+ const registrations = new Map(current.registrations);
399
+
400
+ registrations.set(storageKey, encoded.success);
401
+ const registrationIndex = new Map(current.registrationIndex);
402
+
403
+ registrationIndex.set(storageKey, {
404
+ key,
405
+ ordinal: revised.ordinal,
406
+ state: revised.state,
407
+ recoveryKey: revised.recovery === null ? null : candidateIndexKey(revised),
408
+ recoveryAt: revised.recovery?.nextAttemptAtMillis ?? null,
409
+ });
410
+ const candidateIndex = new Map(current.candidateIndex);
411
+ const oldKey = candidateIndexKey(existing.success);
412
+ const nextKey = candidateIndexKey(revised);
413
+
414
+ {
415
+ candidateIndex.set(
416
+ oldKey,
417
+ (candidateIndex.get(oldKey) ?? []).filter((key) => key !== storageKey),
418
+ );
419
+ candidateIndex.set(
420
+ nextKey,
421
+ [...(candidateIndex.get(nextKey) ?? []), storageKey].sort(
422
+ (a, b) =>
423
+ (registrationIndex.get(a)?.ordinal ?? 0) -
424
+ (registrationIndex.get(b)?.ordinal ?? 0),
425
+ ),
426
+ );
427
+ }
428
+
429
+ return [
430
+ Result.succeed(revised),
431
+ {
432
+ ...current,
433
+ sequence: revised.ordinal,
434
+ registrations,
435
+ registrationIndex,
436
+ candidateIndex,
437
+ recoveryCounts: recoveryCountsAfter(current, registrationIndex, [storageKey]),
438
+ },
439
+ ];
440
+ },
441
+ ),
442
+ ).pipe(Effect.flatMap(Effect.fromResult));
443
+
444
+ yield* failpoint.hit("subscription:change:after");
445
+
446
+ return updated;
447
+ });
448
+
449
+ const cancel: SubscriptionStore["Service"]["cancel"] = Effect.fn(
450
+ "MemorySubscriptionStore.cancel",
451
+ )(function* (input, expectedRevision) {
452
+ const key = yield* requireKey(input, "cancel-key");
453
+
454
+ yield* failpoint.hit("subscription:cancel:before");
455
+
456
+ const result = yield* Effect.uninterruptible(
457
+ Ref.modify(
458
+ state,
459
+ (
460
+ current,
461
+ ): readonly [
462
+ Result.Result<SubscriptionRecord, SubscriptionError>,
463
+ MemorySubscriptionState,
464
+ ] => {
465
+ const storageKey = subscriptionKeyString(key);
466
+ const text = current.registrations.get(storageKey);
467
+
468
+ if (text === undefined) return [Result.fail(error("not-found", "subscription")), current];
469
+ const decoded = decode(SubscriptionRecord, text, "cancel-record");
470
+
471
+ if (Result.isFailure(decoded)) return [decoded, current];
472
+ if (
473
+ expectedRevision !== undefined &&
474
+ expectedRevision !== decoded.success.configurationRevision &&
475
+ !(
476
+ decoded.success.state === "cancelled" &&
477
+ expectedRevision + 1 === decoded.success.configurationRevision
478
+ )
479
+ )
480
+ return [
481
+ Result.fail(
482
+ SubscriptionError.make({
483
+ reason: "conflict",
484
+ code: "configuration-revision",
485
+ currentRevision: decoded.success.configurationRevision,
486
+ currentState: decoded.success.state,
487
+ }),
488
+ ),
489
+ current,
490
+ ];
491
+ if (decoded.success.state === "cancelled")
492
+ return [Result.succeed(decoded.success), current];
493
+
494
+ const cancelled = {
495
+ ...decoded.success,
496
+ configurationRevision: decoded.success.configurationRevision + 1,
497
+ state: "cancelled" as const,
498
+ recovery: null,
499
+ };
500
+
501
+ const encoded = encode(SubscriptionRecord, cancelled, "cancel-encode");
502
+
503
+ if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
504
+ const registrations = new Map(current.registrations);
505
+
506
+ registrations.set(storageKey, encoded.success);
507
+ const registrationIndex = new Map(current.registrationIndex);
508
+ const indexed = registrationIndex.get(storageKey);
509
+
510
+ if (indexed === undefined)
511
+ return [Result.fail(error("corrupt", "cancel-index")), current];
512
+ registrationIndex.set(storageKey, {
513
+ ...indexed,
514
+ state: "cancelled",
515
+ recoveryAt: null,
516
+ recoveryKey: null,
517
+ });
518
+
519
+ return [
520
+ Result.succeed(cancelled),
521
+ {
522
+ ...current,
523
+ registrations,
524
+ registrationIndex,
525
+ recoveryCounts: recoveryCountsAfter(current, registrationIndex, [storageKey]),
526
+ },
527
+ ];
528
+ },
529
+ ),
530
+ ).pipe(Effect.flatMap(Effect.fromResult));
531
+
532
+ yield* failpoint.hit("subscription:cancel:after");
533
+
534
+ return result;
535
+ });
536
+
537
+ const accept: SubscriptionStore["Service"]["accept"] = Effect.fn(
538
+ "MemorySubscriptionStore.accept",
539
+ )(function* (input, inputLimits) {
540
+ const event = yield* validate(AcceptedEvent, input, "accept-event");
541
+ const currentTimeMillis = yield* Clock.currentTimeMillis;
542
+ const limits = yield* validate(SubscriptionLimits, inputLimits, "accept-limits");
543
+
544
+ yield* requirePartition(event, "accept-partition");
545
+ yield* failpoint.hit("subscription:accept:before");
546
+
547
+ const result = yield* Effect.uninterruptible(
548
+ Ref.modify(
549
+ state,
550
+ (
551
+ current,
552
+ ): readonly [Result.Result<AcceptedEvent, SubscriptionError>, MemorySubscriptionState] => {
553
+ const existingText = current.events.get(event.eventId);
554
+
555
+ if (existingText !== undefined) {
556
+ const existing = decode(AcceptedEvent, existingText, "accept-existing");
557
+
558
+ if (Result.isFailure(existing)) return [Result.fail(existing.failure), current];
559
+
560
+ return sameEventIdentity(existing.success, event)
561
+ ? [Result.succeed(existing.success), current]
562
+ : [Result.fail(error("conflict", "event-identity")), current];
563
+ }
564
+ if (
565
+ current.retentionHorizon !== null &&
566
+ current.retentionHorizon !== limits.retention?.replayHorizonMillis
567
+ )
568
+ return [Result.fail(error("conflict", "retention-horizon")), current];
569
+ const horizon = validateEventRetention(event, limits, currentTimeMillis);
570
+
571
+ if (Result.isFailure(horizon)) return [Result.fail(horizon.failure), current];
572
+ if (jsonBytes(event.payload) > limits.maxPayloadBytes)
573
+ return [Result.fail(error("capacity", "payload-bytes")), current];
574
+ if (current.events.size - current.tombstoneCount >= limits.maxEvents)
575
+ return [Result.fail(error("capacity", "events")), current];
576
+
577
+ const accepted: AcceptedEvent = {
578
+ ...event,
579
+ cutoff: current.sequence + 1,
580
+ cursor: 0,
581
+ routingComplete: false,
582
+ routingFailure: null,
583
+ };
584
+
585
+ const encoded = encode(AcceptedEvent, accepted, "accept-encode");
586
+
587
+ if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
588
+ const events = new Map(current.events);
589
+
590
+ events.set(accepted.eventId, encoded.success);
591
+ const eventIndex = new Map(current.eventIndex);
592
+
593
+ eventIndex.set(accepted.eventId, {
594
+ routingComplete: false,
595
+ nextAttemptAtMillis: accepted.nextAttemptAtMillis,
596
+ });
597
+
598
+ return [
599
+ Result.succeed(accepted),
600
+ {
601
+ ...current,
602
+ sequence: accepted.cutoff,
603
+ events,
604
+ eventIndex,
605
+ eventKeys: insertKey(current.eventKeys, accepted.eventId),
606
+ retentionHorizon: limits.retention?.replayHorizonMillis ?? current.retentionHorizon,
607
+ retentionDeadline:
608
+ limits.retention === undefined
609
+ ? current.retentionDeadline
610
+ : accepted.acceptedAtMillis,
611
+ },
612
+ ];
613
+ },
614
+ ),
615
+ ).pipe(Effect.flatMap(Effect.fromResult));
616
+
617
+ yield* failpoint.hit("subscription:accept:after");
618
+
619
+ return result;
620
+ });
621
+
622
+ const event: SubscriptionStore["Service"]["event"] = Effect.fn("MemorySubscriptionStore.event")(
623
+ function* (eventId) {
624
+ const text = (yield* Ref.get(state)).events.get(eventId);
625
+
626
+ return text === undefined ? null : yield* decodeEffect(AcceptedEvent, text, "event-record");
627
+ },
628
+ );
629
+
630
+ const pendingEvents: SubscriptionStore["Service"]["pendingEvents"] = Effect.fn(
631
+ "MemorySubscriptionStore.pendingEvents",
632
+ )(function* (nowMillis, after, limit) {
633
+ const events: Array<string> = [];
634
+
635
+ for (const [eventId, indexed] of (yield* Ref.get(state)).eventIndex) {
636
+ if (
637
+ !indexed.routingComplete &&
638
+ indexed.nextAttemptAtMillis <= nowMillis &&
639
+ compareScheduleNames(eventId, after) > 0
640
+ )
641
+ events.push(eventId);
642
+ }
643
+ events.sort(compareScheduleNames);
644
+
645
+ return events.slice(0, limit);
646
+ });
647
+
648
+ const candidates: SubscriptionStore["Service"]["candidates"] = Effect.fn(
649
+ "MemorySubscriptionStore.candidates",
650
+ )(function* (input, limit) {
651
+ const accepted = yield* validate(AcceptedEvent, input, "candidates-event");
652
+
653
+ yield* requirePartition(accepted, "candidates-partition");
654
+ const stored = yield* event(accepted.eventId);
655
+
656
+ if (stored === null || !sameEventIdentity(stored, accepted))
657
+ return yield* error(stored === null ? "not-found" : "conflict", "event");
658
+ const current = yield* Ref.get(state);
659
+ const records: Array<SubscriptionRecord> = [];
660
+
661
+ for (const storageKey of current.candidateIndex.get(eventCandidateIndexKey(stored)) ?? []) {
662
+ const indexed = current.registrationIndex.get(storageKey);
663
+
664
+ if (indexed === undefined) return yield* error("corrupt", "candidate-index");
665
+ if (indexed.ordinal <= stored.cursor || indexed.ordinal > stored.cutoff) continue;
666
+ const text = current.registrations.get(storageKey);
667
+
668
+ if (text === undefined) return yield* error("corrupt", "candidate-record");
669
+ records.push(yield* decodeEffect(SubscriptionRecord, text, "candidate-record"));
670
+ }
671
+ records.sort((a, b) => a.ordinal - b.ordinal);
672
+
673
+ return records.slice(0, limit);
674
+ });
675
+
676
+ const select: SubscriptionStore["Service"]["select"] = Effect.fn(
677
+ "MemorySubscriptionStore.select",
678
+ )(function* (inputEvent, inputDeliveries, cursor, complete, nowMillis, inputLimits) {
679
+ const suppliedEvent = yield* validate(AcceptedEvent, inputEvent, "select-event");
680
+
681
+ const deliveries = yield* validate(
682
+ Schema.Array(SubscriptionDelivery),
683
+ inputDeliveries,
684
+ "select-deliveries",
685
+ );
686
+
687
+ const limits = yield* validate(SubscriptionLimits, inputLimits, "select-limits");
688
+
689
+ yield* requirePartition(suppliedEvent, "select-partition");
690
+ yield* failpoint.hit("subscription:select:before");
691
+ const effectiveNowMillis = Math.max(nowMillis, yield* Clock.currentTimeMillis);
692
+
693
+ yield* Effect.uninterruptible(
694
+ Ref.modify(
695
+ state,
696
+ (current): readonly [Result.Result<void, SubscriptionError>, MemorySubscriptionState] => {
697
+ const eventText = current.events.get(suppliedEvent.eventId);
698
+
699
+ if (eventText === undefined) return [Result.fail(error("not-found", "event")), current];
700
+ const decodedEvent = decode(AcceptedEvent, eventText, "select-event-record");
701
+
702
+ if (Result.isFailure(decodedEvent)) return [Result.fail(decodedEvent.failure), current];
703
+ const accepted = decodedEvent.success;
704
+
705
+ if (
706
+ !sameEventIdentity(accepted, suppliedEvent) ||
707
+ suppliedEvent.cursor !== accepted.cursor
708
+ )
709
+ return [Result.fail(error("conflict", "event-cursor")), current];
710
+ if (accepted.routingComplete)
711
+ return [
712
+ complete && cursor === accepted.cursor
713
+ ? Result.void
714
+ : Result.fail(error("conflict", "routing-complete")),
715
+ current,
716
+ ];
717
+ if (!Number.isSafeInteger(cursor) || cursor < accepted.cursor || cursor > accepted.cutoff)
718
+ return [Result.fail(error("validation", "cursor")), current];
719
+
720
+ const additions: Array<readonly [string, string]> = [];
721
+ const updates: Array<readonly [string, string]> = [];
722
+ const additionIndex: Array<readonly [string, DeliveryIndex, string]> = [];
723
+ const registrationUpdates: Array<readonly [string, RegistrationIndex]> = [];
724
+ const owners = new Map(current.ownerDeliveryCounts);
725
+
726
+ for (const delivery of deliveries) {
727
+ const recordText = current.registrations.get(
728
+ subscriptionKeyString(delivery.key.subscription),
729
+ );
730
+
731
+ if (recordText === undefined)
732
+ return [Result.fail(error("not-found", "subscription")), current];
733
+ const record = decode(SubscriptionRecord, recordText, "select-registration");
734
+
735
+ if (Result.isFailure(record)) return [Result.fail(record.failure), current];
736
+ if (
737
+ !deliveryBelongsTo(delivery, record.success, accepted) ||
738
+ !subscriptionDeliveryCanSelect(delivery, record.success, accepted) ||
739
+ record.success.ordinal <= accepted.cursor ||
740
+ record.success.ordinal > cursor
741
+ )
742
+ return [Result.fail(error("conflict", "selection")), current];
743
+ if (!subscriptionCanSelect(record.success, accepted, effectiveNowMillis, false))
744
+ continue;
745
+ const deliveryKey = subscriptionDeliveryKeyString(delivery.key);
746
+ const existingText = current.deliveries.get(deliveryKey);
747
+
748
+ if (existingText !== undefined) {
749
+ const existing = decode(SubscriptionDelivery, existingText, "select-existing");
750
+
751
+ if (Result.isFailure(existing)) return [Result.fail(existing.failure), current];
752
+ if (!sameDeliveryIdentity(existing.success, delivery))
753
+ return [Result.fail(error("conflict", "delivery-identity")), current];
754
+ continue;
755
+ }
756
+
757
+ const encodedDelivery = encode(
758
+ SubscriptionDelivery,
759
+ delivery,
760
+ "select-delivery-encode",
761
+ );
762
+
763
+ if (Result.isFailure(encodedDelivery))
764
+ return [Result.fail(encodedDelivery.failure), current];
765
+ additions.push([deliveryKey, encodedDelivery.success]);
766
+ additionIndex.push([
767
+ deliveryKey,
768
+ {
769
+ key: delivery.key,
770
+ state: delivery.state,
771
+ nextAttemptAtMillis: delivery.retry.nextAttemptAtMillis,
772
+ },
773
+ record.success.key.ownerId,
774
+ ]);
775
+ const ownerId = record.success.key.ownerId;
776
+
777
+ owners.set(ownerId, (owners.get(ownerId) ?? 0) + 1);
778
+ if ((owners.get(ownerId) ?? 0) > limits.maxDeliveriesPerOwner)
779
+ return [Result.fail(error("capacity", "owner-deliveries")), current];
780
+ if (record.success.configuration.mode === "once") {
781
+ const consumed = { ...record.success, state: "consumed" as const, recovery: null };
782
+
783
+ const encodedRecord = encode(
784
+ SubscriptionRecord,
785
+ consumed,
786
+ "select-registration-encode",
787
+ );
788
+
789
+ if (Result.isFailure(encodedRecord))
790
+ return [Result.fail(encodedRecord.failure), current];
791
+ const consumedKey = subscriptionKeyString(consumed.key);
792
+
793
+ updates.push([consumedKey, encodedRecord.success]);
794
+ const indexed = current.registrationIndex.get(consumedKey);
795
+
796
+ if (indexed === undefined)
797
+ return [Result.fail(error("corrupt", "selection-index")), current];
798
+ registrationUpdates.push([
799
+ consumedKey,
800
+ { ...indexed, state: "consumed", recoveryAt: null, recoveryKey: null },
801
+ ]);
802
+ }
803
+ }
804
+ if (current.deliveries.size + additions.length > limits.maxDeliveries)
805
+ return [Result.fail(error("capacity", "deliveries")), current];
806
+ const registrations = new Map(current.registrations);
807
+
808
+ for (const [key, value] of updates) registrations.set(key, value);
809
+ const nextDeliveries = new Map(current.deliveries);
810
+
811
+ for (const [key, value] of additions) nextDeliveries.set(key, value);
812
+ const deliveryIndex = new Map(current.deliveryIndex);
813
+
814
+ for (const [key, value] of additionIndex) deliveryIndex.set(key, value);
815
+ const registrationIndex = new Map(current.registrationIndex);
816
+
817
+ for (const [key, value] of registrationUpdates) registrationIndex.set(key, value);
818
+
819
+ const eventDeliveryCounts = new Map(current.eventDeliveryCounts);
820
+
821
+ eventDeliveryCounts.set(
822
+ accepted.eventId,
823
+ (eventDeliveryCounts.get(accepted.eventId) ?? 0) + additions.length,
824
+ );
825
+
826
+ const nextEvent: AcceptedEvent = {
827
+ ...accepted,
828
+ cursor,
829
+ routingComplete: complete,
830
+ routingFailure: null,
831
+ };
832
+
833
+ const encodedEvent = encode(AcceptedEvent, nextEvent, "select-event-encode");
834
+
835
+ if (Result.isFailure(encodedEvent)) return [Result.fail(encodedEvent.failure), current];
836
+ const events = new Map(current.events);
837
+
838
+ events.set(nextEvent.eventId, encodedEvent.success);
839
+ const eventIndex = new Map(current.eventIndex);
840
+
841
+ eventIndex.set(nextEvent.eventId, {
842
+ routingComplete: nextEvent.routingComplete,
843
+ nextAttemptAtMillis: nextEvent.nextAttemptAtMillis,
844
+ });
845
+
846
+ return [
847
+ Result.void,
848
+ {
849
+ ...current,
850
+ registrations,
851
+ registrationIndex,
852
+ recoveryCounts: recoveryCountsAfter(
853
+ current,
854
+ registrationIndex,
855
+ registrationUpdates.map(([key]) => key),
856
+ ),
857
+ eventDeliveryCounts,
858
+ events,
859
+ eventIndex,
860
+ deliveries: nextDeliveries,
861
+ deliveryKeys: additions.reduce(
862
+ (keys, [key]) => insertKey(keys, key),
863
+ current.deliveryKeys,
864
+ ),
865
+ deliveryIndex,
866
+ ownerDeliveryCounts: owners,
867
+ },
868
+ ];
869
+ },
870
+ ),
871
+ ).pipe(Effect.flatMap(Effect.fromResult));
872
+ yield* failpoint.hit("subscription:select:after");
873
+ });
874
+
875
+ const catchUp: SubscriptionStore["Service"]["catchUp"] = Effect.fn(
876
+ "MemorySubscriptionStore.catchUp",
877
+ )(function* (inputEvent, inputDelivery, nowMillis, inputLimits) {
878
+ const suppliedEvent = yield* validate(AcceptedEvent, inputEvent, "catch-up-event");
879
+ const delivery = yield* validate(SubscriptionDelivery, inputDelivery, "catch-up-delivery");
880
+ const limits = yield* validate(SubscriptionLimits, inputLimits, "catch-up-limits");
881
+
882
+ yield* requirePartition(suppliedEvent, "catch-up-partition");
883
+ yield* failpoint.hit("subscription:catch-up:before");
884
+ const effectiveNowMillis = Math.max(nowMillis, yield* Clock.currentTimeMillis);
885
+
886
+ yield* Effect.uninterruptible(
887
+ Ref.modify(
888
+ state,
889
+ (current): readonly [Result.Result<void, SubscriptionError>, MemorySubscriptionState] => {
890
+ const eventText = current.events.get(suppliedEvent.eventId);
891
+
892
+ const recordText = current.registrations.get(
893
+ subscriptionKeyString(delivery.key.subscription),
894
+ );
895
+
896
+ if (eventText === undefined || recordText === undefined)
897
+ return [
898
+ Result.fail(error("not-found", eventText === undefined ? "event" : "subscription")),
899
+ current,
900
+ ];
901
+ const accepted = decode(AcceptedEvent, eventText, "catch-up-event-record");
902
+
903
+ if (Result.isFailure(accepted)) return [Result.fail(accepted.failure), current];
904
+ const record = decode(SubscriptionRecord, recordText, "catch-up-registration");
905
+
906
+ if (Result.isFailure(record)) return [Result.fail(record.failure), current];
907
+ if (
908
+ !sameEventIdentity(accepted.success, suppliedEvent) ||
909
+ !deliveryBelongsTo(delivery, record.success, accepted.success) ||
910
+ !subscriptionDeliveryCanSelect(delivery, record.success, accepted.success) ||
911
+ record.success.configuration.mode !== "once"
912
+ )
913
+ return [Result.fail(error("conflict", "catch-up-identity")), current];
914
+ const key = subscriptionDeliveryKeyString(delivery.key);
915
+ const existingText = current.deliveries.get(key);
916
+
917
+ if (existingText !== undefined) {
918
+ const existing = decode(SubscriptionDelivery, existingText, "catch-up-existing");
919
+
920
+ if (Result.isFailure(existing)) return [Result.fail(existing.failure), current];
921
+
922
+ return sameDeliveryIdentity(existing.success, delivery)
923
+ ? [Result.void, current]
924
+ : [Result.fail(error("conflict", "delivery-identity")), current];
925
+ }
926
+ if (!subscriptionCanSelect(record.success, accepted.success, effectiveNowMillis, true))
927
+ return [Result.fail(error("conflict", "catch-up-eligibility")), current];
928
+ if (current.deliveries.size >= limits.maxDeliveries)
929
+ return [Result.fail(error("capacity", "deliveries")), current];
930
+ const ownerCount = current.ownerDeliveryCounts.get(record.success.key.ownerId) ?? 0;
931
+
932
+ if (ownerCount >= limits.maxDeliveriesPerOwner)
933
+ return [Result.fail(error("capacity", "owner-deliveries")), current];
934
+
935
+ const encodedDelivery = encode(
936
+ SubscriptionDelivery,
937
+ delivery,
938
+ "catch-up-delivery-encode",
939
+ );
940
+
941
+ if (Result.isFailure(encodedDelivery))
942
+ return [Result.fail(encodedDelivery.failure), current];
943
+ const consumed = { ...record.success, state: "consumed" as const, recovery: null };
944
+
945
+ const encodedRecord = encode(
946
+ SubscriptionRecord,
947
+ consumed,
948
+ "catch-up-registration-encode",
949
+ );
950
+
951
+ if (Result.isFailure(encodedRecord)) return [Result.fail(encodedRecord.failure), current];
952
+ const deliveries = new Map(current.deliveries);
953
+
954
+ deliveries.set(key, encodedDelivery.success);
955
+ const registrations = new Map(current.registrations);
956
+ const consumedKey = subscriptionKeyString(consumed.key);
957
+
958
+ registrations.set(consumedKey, encodedRecord.success);
959
+ const registrationIndex = new Map(current.registrationIndex);
960
+ const indexed = registrationIndex.get(consumedKey);
961
+
962
+ if (indexed === undefined)
963
+ return [Result.fail(error("corrupt", "catch-up-index")), current];
964
+ registrationIndex.set(consumedKey, {
965
+ ...indexed,
966
+ state: "consumed",
967
+ recoveryAt: null,
968
+ recoveryKey: null,
969
+ });
970
+ const deliveryIndex = new Map(current.deliveryIndex);
971
+
972
+ deliveryIndex.set(key, {
973
+ key: delivery.key,
974
+ state: delivery.state,
975
+ nextAttemptAtMillis: delivery.retry.nextAttemptAtMillis,
976
+ });
977
+ const ownerDeliveryCounts = new Map(current.ownerDeliveryCounts);
978
+ const eventDeliveryCounts = new Map(current.eventDeliveryCounts);
979
+
980
+ eventDeliveryCounts.set(
981
+ accepted.success.eventId,
982
+ (eventDeliveryCounts.get(accepted.success.eventId) ?? 0) + 1,
983
+ );
984
+
985
+ ownerDeliveryCounts.set(consumed.key.ownerId, ownerCount + 1);
986
+
987
+ return [
988
+ Result.void,
989
+ {
990
+ ...current,
991
+ deliveries,
992
+ deliveryIndex,
993
+ deliveryKeys: insertKey(current.deliveryKeys, key),
994
+ ownerDeliveryCounts,
995
+ registrations,
996
+ registrationIndex,
997
+ recoveryCounts: recoveryCountsAfter(current, registrationIndex, [consumedKey]),
998
+ eventDeliveryCounts,
999
+ },
1000
+ ];
1001
+ },
1002
+ ),
1003
+ ).pipe(Effect.flatMap(Effect.fromResult));
1004
+ yield* failpoint.hit("subscription:catch-up:after");
1005
+ });
1006
+
1007
+ const deferEvent: SubscriptionStore["Service"]["deferEvent"] = Effect.fn(
1008
+ "MemorySubscriptionStore.deferEvent",
1009
+ )(function* (eventId, nextAttemptAtMillis, code) {
1010
+ const routingFailure =
1011
+ code === undefined
1012
+ ? "routing-failed"
1013
+ : yield* validate(SubscriptionName, code, "routing-failure");
1014
+
1015
+ yield* failpoint.hit("subscription:defer-event:before");
1016
+ yield* Effect.uninterruptible(
1017
+ Ref.modify(
1018
+ state,
1019
+ (current): readonly [Result.Result<void, SubscriptionError>, MemorySubscriptionState] => {
1020
+ const text = current.events.get(eventId);
1021
+
1022
+ if (text === undefined) return [Result.fail(error("not-found", "event")), current];
1023
+ const accepted = decode(AcceptedEvent, text, "defer-event-record");
1024
+
1025
+ if (Result.isFailure(accepted)) return [Result.fail(accepted.failure), current];
1026
+ const updated = { ...accepted.success, nextAttemptAtMillis, routingFailure };
1027
+ const encoded = encode(AcceptedEvent, updated, "defer-event-encode");
1028
+
1029
+ if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
1030
+ const events = new Map(current.events);
1031
+
1032
+ events.set(eventId, encoded.success);
1033
+ const eventIndex = new Map(current.eventIndex);
1034
+ const indexed = eventIndex.get(eventId);
1035
+
1036
+ if (indexed === undefined)
1037
+ return [Result.fail(error("corrupt", "defer-event-index")), current];
1038
+ eventIndex.set(eventId, { ...indexed, nextAttemptAtMillis });
1039
+
1040
+ return [Result.void, { ...current, events, eventIndex }];
1041
+ },
1042
+ ),
1043
+ ).pipe(Effect.flatMap(Effect.fromResult));
1044
+ yield* failpoint.hit("subscription:defer-event:after");
1045
+ });
1046
+
1047
+ const delivery: SubscriptionStore["Service"]["delivery"] = Effect.fn(
1048
+ "MemorySubscriptionStore.delivery",
1049
+ )(function* (input) {
1050
+ const key = yield* validate(SubscriptionDeliveryKey, input, "delivery-key");
1051
+
1052
+ yield* requirePartition(key.subscription, "delivery-partition");
1053
+ const text = (yield* Ref.get(state)).deliveries.get(subscriptionDeliveryKeyString(key));
1054
+
1055
+ return text === undefined
1056
+ ? null
1057
+ : yield* decodeEffect(SubscriptionDelivery, text, "delivery-record");
1058
+ });
1059
+
1060
+ const pendingDeliveries: SubscriptionStore["Service"]["pendingDeliveries"] = Effect.fn(
1061
+ "MemorySubscriptionStore.pendingDeliveries",
1062
+ )(function* (nowMillis, after, limit) {
1063
+ const items: Array<SubscriptionDeliveryKey> = [];
1064
+
1065
+ for (const [storageKey, item] of (yield* Ref.get(state)).deliveryIndex) {
1066
+ if (
1067
+ ((item.state !== "delivered" && item.state !== "refused" && item.parked !== true) ||
1068
+ (item.state === "delivered" && item.observeSettlement === true)) &&
1069
+ item.nextAttemptAtMillis <= nowMillis &&
1070
+ compareScheduleNames(storageKey, after) > 0
1071
+ )
1072
+ items.push(item.key);
1073
+ }
1074
+ items.sort((a, b) =>
1075
+ compareScheduleNames(subscriptionDeliveryKeyString(a), subscriptionDeliveryKeyString(b)),
1076
+ );
1077
+
1078
+ return items.slice(0, limit);
1079
+ });
1080
+
1081
+ const listDeliveries: SubscriptionStore["Service"]["listDeliveries"] = Effect.fn(
1082
+ "MemorySubscriptionStore.listDeliveries",
1083
+ )(function* (input, after, limit) {
1084
+ const key = yield* requireKey(input, "list-deliveries-key");
1085
+ const items: Array<SubscriptionDelivery> = [];
1086
+
1087
+ for (const text of (yield* Ref.get(state)).deliveries.values()) {
1088
+ const item = yield* decodeEffect(SubscriptionDelivery, text, "list-delivery");
1089
+ const itemKey = subscriptionDeliveryKeyString(item.key);
1090
+
1091
+ if (
1092
+ subscriptionKeyString(item.key.subscription) === subscriptionKeyString(key) &&
1093
+ compareScheduleNames(itemKey, after) > 0
1094
+ )
1095
+ items.push(item);
1096
+ }
1097
+ items.sort((a, b) =>
1098
+ compareScheduleNames(
1099
+ subscriptionDeliveryKeyString(a.key),
1100
+ subscriptionDeliveryKeyString(b.key),
1101
+ ),
1102
+ );
1103
+
1104
+ return items.slice(0, limit);
1105
+ });
1106
+
1107
+ const changeDelivery: SubscriptionStore["Service"]["changeDelivery"] = Effect.fn(
1108
+ "MemorySubscriptionStore.changeDelivery",
1109
+ )(function* (inputKey, inputDeliveryId, inputChange) {
1110
+ const key = yield* validate(SubscriptionDeliveryKey, inputKey, "change-delivery-key");
1111
+ const deliveryId = yield* validate(Digest, inputDeliveryId, "change-delivery-id");
1112
+ const change = yield* validate(DeliveryChange, inputChange, "change-delivery-change");
1113
+
1114
+ yield* requirePartition(key.subscription, "change-delivery-partition");
1115
+ yield* failpoint.hit(`subscription:delivery-${change._tag.toLowerCase()}:before`);
1116
+
1117
+ const effectiveChange =
1118
+ change._tag === "Prepare"
1119
+ ? { ...change, nowMillis: Math.max(change.nowMillis, yield* Clock.currentTimeMillis) }
1120
+ : change;
1121
+
1122
+ const result = yield* Effect.uninterruptible(
1123
+ Ref.modify(
1124
+ state,
1125
+ (
1126
+ current,
1127
+ ): readonly [
1128
+ Result.Result<SubscriptionDelivery, SubscriptionError>,
1129
+ MemorySubscriptionState,
1130
+ ] => {
1131
+ const storageKey = subscriptionDeliveryKeyString(key);
1132
+ const text = current.deliveries.get(storageKey);
1133
+
1134
+ if (text === undefined) return [Result.fail(error("not-found", "delivery")), current];
1135
+ const decoded = decode(SubscriptionDelivery, text, "change-delivery-record");
1136
+
1137
+ if (Result.isFailure(decoded)) return [decoded, current];
1138
+
1139
+ const registrationText = current.registrations.get(
1140
+ subscriptionKeyString(key.subscription),
1141
+ );
1142
+
1143
+ if (registrationText === undefined)
1144
+ return [Result.fail(error("corrupt", "delivery-registration")), current];
1145
+
1146
+ const registration = decode(
1147
+ SubscriptionRecord,
1148
+ registrationText,
1149
+ "delivery-registration",
1150
+ );
1151
+
1152
+ if (Result.isFailure(registration)) return [Result.fail(registration.failure), current];
1153
+
1154
+ const transition = applySubscriptionDeliveryChange(
1155
+ decoded.success,
1156
+ registration.success,
1157
+ deliveryId,
1158
+ effectiveChange,
1159
+ );
1160
+
1161
+ if (Result.isFailure(transition)) return [Result.fail(transition.failure), current];
1162
+ if (transition.success === decoded.success)
1163
+ return [Result.succeed(decoded.success), current];
1164
+ const updated = transition.success;
1165
+ const encoded = encode(SubscriptionDelivery, updated, "change-delivery-encode");
1166
+
1167
+ if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
1168
+ const deliveries = new Map(current.deliveries);
1169
+
1170
+ deliveries.set(storageKey, encoded.success);
1171
+ const deliveryIndex = new Map(current.deliveryIndex);
1172
+ const indexed = deliveryIndex.get(storageKey);
1173
+
1174
+ if (indexed === undefined)
1175
+ return [Result.fail(error("corrupt", "delivery-index")), current];
1176
+ deliveryIndex.set(storageKey, {
1177
+ ...indexed,
1178
+ state: updated.state,
1179
+ parked: updated.retry.parked ?? false,
1180
+ observeSettlement: updated.observeSettlement ?? false,
1181
+ nextAttemptAtMillis: updated.retry.nextAttemptAtMillis,
1182
+ });
1183
+
1184
+ return [Result.succeed(updated), { ...current, deliveries, deliveryIndex }];
1185
+ },
1186
+ ),
1187
+ ).pipe(Effect.flatMap(Effect.fromResult));
1188
+
1189
+ yield* failpoint.hit(`subscription:delivery-${change._tag.toLowerCase()}:after`);
1190
+
1191
+ return result;
1192
+ });
1193
+
1194
+ const recovering: SubscriptionStore["Service"]["recovering"] = Effect.fn(
1195
+ "MemorySubscriptionStore.recovering",
1196
+ )(function* (nowMillis, after, limit) {
1197
+ const records: Array<{ readonly key: SubscriptionKey; readonly ordinal: number }> = [];
1198
+
1199
+ for (const item of (yield* Ref.get(state)).registrationIndex.values()) {
1200
+ if (
1201
+ item.ordinal > after &&
1202
+ item.state === "active" &&
1203
+ item.recoveryAt !== null &&
1204
+ item.recoveryAt <= nowMillis
1205
+ )
1206
+ records.push({ key: item.key, ordinal: item.ordinal });
1207
+ }
1208
+ records.sort((a, b) => a.ordinal - b.ordinal);
1209
+
1210
+ return records.slice(0, limit);
1211
+ });
1212
+
1213
+ const deferRecovery: SubscriptionStore["Service"]["deferRecovery"] = Effect.fn(
1214
+ "MemorySubscriptionStore.deferRecovery",
1215
+ )(function* (input, expectedRevision, recovery) {
1216
+ const key = yield* requireKey(input, "defer-recovery-key");
1217
+
1218
+ yield* failpoint.hit("subscription:defer-recovery:before");
1219
+ yield* Effect.uninterruptible(
1220
+ Ref.modify(
1221
+ state,
1222
+ (current): readonly [Result.Result<void, SubscriptionError>, MemorySubscriptionState] => {
1223
+ const storageKey = subscriptionKeyString(key);
1224
+ const text = current.registrations.get(storageKey);
1225
+
1226
+ if (text === undefined) return [Result.fail(error("not-found", "subscription")), current];
1227
+ const record = decode(SubscriptionRecord, text, "defer-recovery-record");
1228
+
1229
+ if (Result.isFailure(record)) return [Result.fail(record.failure), current];
1230
+
1231
+ if (record.success.configurationRevision !== expectedRevision)
1232
+ return [Result.void, current];
1233
+
1234
+ const updated = {
1235
+ ...record.success,
1236
+ recovery:
1237
+ record.success.state === "active" || record.success.state === "paused"
1238
+ ? recovery
1239
+ : null,
1240
+ };
1241
+
1242
+ const encoded = encode(SubscriptionRecord, updated, "defer-recovery-encode");
1243
+
1244
+ if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
1245
+ const registrations = new Map(current.registrations);
1246
+
1247
+ registrations.set(storageKey, encoded.success);
1248
+ const registrationIndex = new Map(current.registrationIndex);
1249
+ const indexed = registrationIndex.get(storageKey);
1250
+
1251
+ if (indexed === undefined)
1252
+ return [Result.fail(error("corrupt", "recovery-index")), current];
1253
+ registrationIndex.set(storageKey, {
1254
+ ...indexed,
1255
+ recoveryKey: updated.recovery === null ? null : candidateIndexKey(updated),
1256
+ recoveryAt: updated.recovery?.nextAttemptAtMillis ?? null,
1257
+ });
1258
+
1259
+ return [
1260
+ Result.void,
1261
+ {
1262
+ ...current,
1263
+ registrations,
1264
+ registrationIndex,
1265
+ recoveryCounts: recoveryCountsAfter(current, registrationIndex, [storageKey]),
1266
+ },
1267
+ ];
1268
+ },
1269
+ ),
1270
+ ).pipe(Effect.flatMap(Effect.fromResult));
1271
+ yield* failpoint.hit("subscription:defer-recovery:after");
1272
+ });
1273
+
1274
+ const readScanCursors: SubscriptionStore["Service"]["readScanCursors"] = Ref.get(state).pipe(
1275
+ Effect.map((current) => current.scanCursors),
1276
+ );
1277
+
1278
+ const advanceScanCursors: SubscriptionStore["Service"]["advanceScanCursors"] = Effect.fn(
1279
+ "MemorySubscriptionStore.advanceScanCursors",
1280
+ )(function* (input) {
1281
+ const cursors = yield* validate(SubscriptionScanCursors, input, "scan-cursors");
1282
+
1283
+ yield* failpoint.hit("subscription:advance-scan-cursors:before");
1284
+ yield* Effect.uninterruptible(
1285
+ Ref.update(state, (current) => ({ ...current, scanCursors: cursors })),
1286
+ );
1287
+ yield* failpoint.hit("subscription:advance-scan-cursors:after");
1288
+ });
1289
+
1290
+ const nextDeadline = Effect.gen(function* () {
1291
+ let deadline: number | null = null;
1292
+ const current = yield* Ref.get(state);
1293
+
1294
+ if (
1295
+ current.scanCursors.events !== "" ||
1296
+ current.scanCursors.deliveries !== "" ||
1297
+ current.scanCursors.recovery !== 0
1298
+ )
1299
+ return 0;
1300
+
1301
+ const consider = (value: number) => {
1302
+ if (deadline === null || value < deadline) deadline = value;
1303
+ };
1304
+
1305
+ if (current.retentionDeadline !== null) consider(current.retentionDeadline);
1306
+ for (const accepted of current.eventIndex.values())
1307
+ if (!accepted.routingComplete) consider(accepted.nextAttemptAtMillis);
1308
+ for (const item of current.deliveryIndex.values())
1309
+ if (
1310
+ (item.state !== "delivered" && item.state !== "refused" && item.parked !== true) ||
1311
+ (item.state === "delivered" && item.observeSettlement === true)
1312
+ )
1313
+ consider(item.nextAttemptAtMillis);
1314
+ for (const record of current.registrationIndex.values())
1315
+ if (record.state === "active" && record.recoveryAt !== null) consider(record.recoveryAt);
1316
+
1317
+ return deadline;
1318
+ }).pipe(Effect.withSpan("MemorySubscriptionStore.nextDeadline"));
1319
+
1320
+ const compact: SubscriptionStore["Service"]["compact"] = Effect.fn(
1321
+ "MemorySubscriptionStore.compact",
1322
+ )(function* (nowMillis, inputPolicy, requestedLimit) {
1323
+ nowMillis = Math.min(nowMillis, yield* Clock.currentTimeMillis);
1324
+ const policy = yield* validate(SubscriptionRetentionPolicy, inputPolicy, "retention-policy");
1325
+
1326
+ const limit = yield* validate(
1327
+ Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 })),
1328
+ requestedLimit,
1329
+ "maintenance-limit",
1330
+ );
1331
+
1332
+ yield* failpoint.hit("subscription:compact:before");
1333
+ let corruptCandidates = 0;
1334
+
1335
+ const result = yield* Effect.uninterruptible(
1336
+ Ref.modify(
1337
+ state,
1338
+ (current): readonly [Result.Result<number, SubscriptionError>, MemorySubscriptionState] => {
1339
+ if (
1340
+ current.retentionHorizon !== null &&
1341
+ current.retentionHorizon !== policy.replayHorizonMillis
1342
+ )
1343
+ return [Result.fail(error("conflict", "retention-horizon")), current];
1344
+ const events = new Map(current.events);
1345
+ const eventIndex = new Map(current.eventIndex);
1346
+ const deliveries = new Map(current.deliveries);
1347
+ const deliveryIndex = new Map(current.deliveryIndex);
1348
+ const eventDeliveryCounts = new Map(current.eventDeliveryCounts);
1349
+ const ownerDeliveryCounts = new Map(current.ownerDeliveryCounts);
1350
+
1351
+ // Page indexes before decoding. Each pass reads at most 2 * limit event values and
1352
+ // limit delivery values; relationship checks use maintained reference counts.
1353
+ const deliveryPage = current.deliveryKeys.slice(
1354
+ upperBound(current.deliveryKeys, current.maintenanceDeliveries),
1355
+ upperBound(current.deliveryKeys, current.maintenanceDeliveries) + limit,
1356
+ );
1357
+
1358
+ const eventPage = current.eventKeys.slice(
1359
+ upperBound(current.eventKeys, current.maintenanceEvents),
1360
+ upperBound(current.eventKeys, current.maintenanceEvents) + limit,
1361
+ );
1362
+
1363
+ const cutoff = nowMillis - policy.completedRetentionMillis;
1364
+ let removed = 0;
1365
+ const removedEvents = new Set<string>();
1366
+ const removedDeliveries = new Set<string>();
1367
+
1368
+ for (const key of deliveryPage) {
1369
+ const text = deliveries.get(key);
1370
+
1371
+ if (text === undefined) continue;
1372
+ const decoded = decode(SubscriptionDelivery, text, "compact-delivery");
1373
+
1374
+ if (Result.isFailure(decoded)) {
1375
+ corruptCandidates++;
1376
+ continue;
1377
+ }
1378
+ const delivery = decoded.success;
1379
+
1380
+ if (
1381
+ subscriptionDeliveryKeyString(delivery.key) !== key ||
1382
+ !samePartition(delivery.key.subscription.partition, partition)
1383
+ ) {
1384
+ corruptCandidates++;
1385
+ continue;
1386
+ }
1387
+
1388
+ if (
1389
+ delivery.state !== "refused" &&
1390
+ (delivery.state !== "delivered" || delivery.settledAtMillis === undefined)
1391
+ )
1392
+ continue;
1393
+ if (
1394
+ (delivery.settledAtMillis ??
1395
+ delivery.completedAtMillis ??
1396
+ delivery.selectedAtMillis) > cutoff
1397
+ )
1398
+ continue;
1399
+ const eventText = events.get(delivery.key.eventId);
1400
+
1401
+ if (eventText === undefined) continue;
1402
+ const accepted = decode(AcceptedEvent, eventText, "compact-delivery-event");
1403
+
1404
+ if (Result.isFailure(accepted)) {
1405
+ corruptCandidates++;
1406
+ continue;
1407
+ }
1408
+ const event = accepted.success;
1409
+
1410
+ if (
1411
+ event.eventId !== delivery.key.eventId ||
1412
+ !samePartition(event.partition, partition)
1413
+ ) {
1414
+ corruptCandidates++;
1415
+ continue;
1416
+ }
1417
+
1418
+ if (
1419
+ !event.routingComplete ||
1420
+ event.occurredAtMillis === undefined ||
1421
+ event.acceptedAtMillis > cutoff ||
1422
+ (current.recoveryCounts.get(eventCandidateIndexKey(event)) ?? 0) > 0
1423
+ )
1424
+ continue;
1425
+ deliveries.delete(key);
1426
+ removedDeliveries.add(key);
1427
+ deliveryIndex.delete(key);
1428
+ eventDeliveryCounts.set(
1429
+ event.eventId,
1430
+ (eventDeliveryCounts.get(event.eventId) ?? 1) - 1,
1431
+ );
1432
+ const owner = delivery.key.subscription.ownerId;
1433
+
1434
+ ownerDeliveryCounts.set(owner, (ownerDeliveryCounts.get(owner) ?? 1) - 1);
1435
+ }
1436
+ let tombstones = current.tombstoneCount;
1437
+
1438
+ for (const key of eventPage) {
1439
+ const text = events.get(key);
1440
+
1441
+ if (text === undefined) continue;
1442
+ const decoded = decode(AcceptedEvent, text, "compact-event");
1443
+
1444
+ if (Result.isFailure(decoded)) {
1445
+ corruptCandidates++;
1446
+ continue;
1447
+ }
1448
+ const event = decoded.success;
1449
+
1450
+ if (event.eventId !== key || !samePartition(event.partition, partition)) {
1451
+ corruptCandidates++;
1452
+ continue;
1453
+ }
1454
+
1455
+ if (!event.routingComplete || event.occurredAtMillis === undefined) continue;
1456
+ const expired = event.occurredAtMillis <= nowMillis - policy.replayHorizonMillis;
1457
+
1458
+ if (event.tombstone === true && !expired) continue;
1459
+ if (
1460
+ event.acceptedAtMillis > cutoff ||
1461
+ (eventDeliveryCounts.get(key) ?? 0) > 0 ||
1462
+ (current.recoveryCounts.get(eventCandidateIndexKey(event)) ?? 0) > 0
1463
+ )
1464
+ continue;
1465
+ if (expired) {
1466
+ events.delete(key);
1467
+ removedEvents.add(key);
1468
+ eventIndex.delete(key);
1469
+ eventDeliveryCounts.delete(key);
1470
+ if (event.tombstone === true) tombstones--;
1471
+ } else {
1472
+ if (tombstones >= policy.maxTombstones) continue;
1473
+
1474
+ const encoded = encode(
1475
+ AcceptedEvent,
1476
+ { ...event, payload: null, tombstone: true },
1477
+ "compact-tombstone",
1478
+ );
1479
+
1480
+ if (Result.isFailure(encoded)) continue;
1481
+ events.set(key, encoded.success);
1482
+ tombstones++;
1483
+ }
1484
+ removed++;
1485
+ }
1486
+
1487
+ return [
1488
+ Result.succeed(removed),
1489
+ {
1490
+ ...current,
1491
+ events,
1492
+ eventIndex,
1493
+ deliveries,
1494
+ deliveryIndex,
1495
+ eventDeliveryCounts,
1496
+ ownerDeliveryCounts,
1497
+ eventKeys: removeKeys(current.eventKeys, removedEvents),
1498
+ deliveryKeys: removeKeys(current.deliveryKeys, removedDeliveries),
1499
+ tombstoneCount: tombstones,
1500
+ maintenanceEvents: eventPage.length < limit ? "" : (eventPage.at(-1) ?? ""),
1501
+ maintenanceDeliveries: deliveryPage.length < limit ? "" : (deliveryPage.at(-1) ?? ""),
1502
+ retentionHorizon: policy.replayHorizonMillis,
1503
+ retentionDeadline: events.size === 0 ? null : nowMillis + 60_000,
1504
+ },
1505
+ ];
1506
+ },
1507
+ ),
1508
+ ).pipe(Effect.flatMap(Effect.fromResult));
1509
+
1510
+ if (corruptCandidates > 0)
1511
+ yield* Effect.logWarning("Subscription retention preserved corrupt candidates", {
1512
+ count: corruptCandidates,
1513
+ });
1514
+ yield* failpoint.hit("subscription:compact:after");
1515
+
1516
+ return result;
1517
+ });
1518
+
1519
+ return SubscriptionStore.of({
1520
+ partition,
1521
+ compact,
1522
+ register,
1523
+ get,
1524
+ list,
1525
+ cancel,
1526
+ change,
1527
+ accept,
1528
+ event,
1529
+ pendingEvents,
1530
+ candidates,
1531
+ select,
1532
+ catchUp,
1533
+ deferEvent,
1534
+ delivery,
1535
+ pendingDeliveries,
1536
+ listDeliveries,
1537
+ changeDelivery,
1538
+ recovering,
1539
+ deferRecovery,
1540
+ readScanCursors,
1541
+ advanceScanCursors,
1542
+ nextDeadline,
1543
+ });
1544
+ });
1545
+
1546
+ export const memorySubscriptionStoreLayer = (
1547
+ partition: SourcePartition,
1548
+ ): Layer.Layer<SubscriptionStore, SubscriptionError> =>
1549
+ Layer.effect(SubscriptionStore, makeMemorySubscriptionStore(partition));