@effect-agent/storage-memory 0.1.0-beta.49 → 0.1.0-beta.51

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.
@@ -3,6 +3,7 @@ import { compareScheduleNames } from "@effect-agent/thread/ScheduleTransition";
3
3
  import {
4
4
  AcceptedEvent,
5
5
  DeliveryChange,
6
+ SubscriptionChange,
6
7
  SourcePartition,
7
8
  SubscriptionDelivery,
8
9
  SubscriptionDeliveryKey,
@@ -10,6 +11,7 @@ import {
10
11
  SubscriptionFailpoint,
11
12
  SubscriptionKey,
12
13
  SubscriptionLimits,
14
+ SubscriptionRetentionPolicy,
13
15
  SubscriptionName,
14
16
  SubscriptionRecord,
15
17
  SubscriptionScanCursors,
@@ -19,6 +21,8 @@ import {
19
21
  } from "@effect-agent/thread/Subscription";
20
22
  import {
21
23
  applySubscriptionDeliveryChange,
24
+ applySubscriptionChange,
25
+ validateEventRetention,
22
26
  subscriptionCanSelect,
23
27
  subscriptionDeliveryCanSelect,
24
28
  } from "@effect-agent/thread/SubscriptionTransition";
@@ -26,6 +30,15 @@ import { Clock, Effect, Layer, Ref, Result, Schema } from "effect";
26
30
 
27
31
  interface MemorySubscriptionState {
28
32
  readonly sequence: number;
33
+ readonly retentionDeadline: number | null;
34
+ readonly tombstoneCount: number;
35
+ readonly eventKeys: ReadonlyArray<string>;
36
+ readonly deliveryKeys: ReadonlyArray<string>;
37
+ readonly maintenanceEvents: string;
38
+ readonly maintenanceDeliveries: string;
39
+ readonly recoveryCounts: ReadonlyMap<string, number>;
40
+ readonly eventDeliveryCounts: ReadonlyMap<string, number>;
41
+ readonly retentionHorizon: number | null;
29
42
  readonly registrations: ReadonlyMap<string, string>;
30
43
  readonly events: ReadonlyMap<string, string>;
31
44
  readonly deliveries: ReadonlyMap<string, string>;
@@ -42,6 +55,7 @@ interface RegistrationIndex {
42
55
  readonly key: SubscriptionKey;
43
56
  readonly ordinal: number;
44
57
  readonly state: SubscriptionRecord["state"];
58
+ readonly recoveryKey: string | null;
45
59
  readonly recoveryAt: number | null;
46
60
  }
47
61
  interface EventIndex {
@@ -51,6 +65,8 @@ interface EventIndex {
51
65
  interface DeliveryIndex {
52
66
  readonly key: SubscriptionDeliveryKey;
53
67
  readonly state: SubscriptionDelivery["state"];
68
+ readonly parked?: boolean;
69
+ readonly observeSettlement?: boolean;
54
70
  readonly nextAttemptAtMillis: number;
55
71
  }
56
72
 
@@ -117,7 +133,8 @@ const sameEventIdentity = (left: AcceptedEvent, right: AcceptedEvent): boolean =
117
133
  left.eventId === right.eventId &&
118
134
  sameSource(left.source, right.source) &&
119
135
  left.matchingKey === right.matchingKey &&
120
- left.payloadDigest === right.payloadDigest;
136
+ left.payloadDigest === right.payloadDigest &&
137
+ left.occurredAtMillis === right.occurredAtMillis;
121
138
 
122
139
  const deliveryBelongsTo = (
123
140
  delivery: SubscriptionDelivery,
@@ -128,6 +145,65 @@ const deliveryBelongsTo = (
128
145
  delivery.key.eventId === event.eventId &&
129
146
  sameSource(delivery.source, event.source);
130
147
 
148
+ // Ordered in-memory indexes allow bounded maintenance pages without scanning retained values.
149
+ const removeKeys = (
150
+ keys: ReadonlyArray<string>,
151
+ removed: ReadonlySet<string>,
152
+ ): ReadonlyArray<string> => {
153
+ if (removed.size === 0) return keys;
154
+ const result = [...keys];
155
+
156
+ for (const key of removed) {
157
+ const index = upperBound(result, key) - 1;
158
+
159
+ if (result[index] === key) result.splice(index, 1);
160
+ }
161
+
162
+ return result;
163
+ };
164
+
165
+ const upperBound = (keys: ReadonlyArray<string>, after: string): number => {
166
+ let low = 0;
167
+ let high = keys.length;
168
+
169
+ while (low < high) {
170
+ const middle = Math.floor((low + high) / 2);
171
+ const key = keys[middle];
172
+
173
+ if (key !== undefined && compareScheduleNames(key, after) <= 0) low = middle + 1;
174
+ else high = middle;
175
+ }
176
+
177
+ return low;
178
+ };
179
+
180
+ const insertKey = (keys: ReadonlyArray<string>, key: string): ReadonlyArray<string> => {
181
+ const at = upperBound(keys, key);
182
+
183
+ if (at > 0 && keys[at - 1] === key) return keys;
184
+
185
+ return [...keys.slice(0, at), key, ...keys.slice(at)];
186
+ };
187
+
188
+ const recoveryCountsAfter = (
189
+ current: MemorySubscriptionState,
190
+ next: ReadonlyMap<string, RegistrationIndex>,
191
+ keys: ReadonlyArray<string>,
192
+ ): ReadonlyMap<string, number> => {
193
+ const counts = new Map(current.recoveryCounts);
194
+
195
+ for (const key of keys) {
196
+ const before = current.registrationIndex.get(key)?.recoveryKey;
197
+ const after = next.get(key)?.recoveryKey;
198
+
199
+ if (before === after) continue;
200
+ if (before !== null && before !== undefined) counts.set(before, (counts.get(before) ?? 0) - 1);
201
+ if (after !== null && after !== undefined) counts.set(after, (counts.get(after) ?? 0) + 1);
202
+ }
203
+
204
+ return counts;
205
+ };
206
+
131
207
  const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(function* (
132
208
  ownedPartition: SourcePartition,
133
209
  ) {
@@ -135,6 +211,15 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
135
211
 
136
212
  const state = yield* Ref.make<MemorySubscriptionState>({
137
213
  sequence: 0,
214
+ retentionDeadline: null,
215
+ tombstoneCount: 0,
216
+ eventKeys: [],
217
+ deliveryKeys: [],
218
+ maintenanceEvents: "",
219
+ maintenanceDeliveries: "",
220
+ recoveryCounts: new Map(),
221
+ eventDeliveryCounts: new Map(),
222
+ retentionHorizon: null,
138
223
  registrations: new Map(),
139
224
  events: new Map(),
140
225
  deliveries: new Map(),
@@ -197,8 +282,8 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
197
282
  if (jsonBytes(record.configuration.parameters) > limits.maxPayloadBytes)
198
283
  return [Result.fail(error("capacity", "parameters-bytes")), current];
199
284
  if (
200
- record.configuration.expiresAtMillis - record.createdAtMillis >
201
- limits.maxLifetimeMillis
285
+ record.configuration.expiresAtMillis !== null &&
286
+ record.configuration.expiresAtMillis - record.createdAtMillis > limits.maxLifetimeMillis
202
287
  )
203
288
  return [Result.fail(error("capacity", "lifetime")), current];
204
289
  if (current.registrations.size >= limits.maxRegistrations)
@@ -220,6 +305,7 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
220
305
  key: assigned.key,
221
306
  ordinal: assigned.ordinal,
222
307
  state: assigned.state,
308
+ recoveryKey: assigned.recovery === null ? null : candidateIndexKey(assigned),
223
309
  recoveryAt: assigned.recovery?.nextAttemptAtMillis ?? null,
224
310
  });
225
311
  const candidateIndex = new Map(current.candidateIndex);
@@ -237,6 +323,7 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
237
323
  sequence: assigned.ordinal,
238
324
  registrations,
239
325
  registrationIndex,
326
+ recoveryCounts: recoveryCountsAfter(current, registrationIndex, [key]),
240
327
  candidateIndex,
241
328
  ownerRegistrationCounts,
242
329
  },
@@ -281,9 +368,92 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
281
368
  },
282
369
  );
283
370
 
371
+ const change: SubscriptionStore["Service"]["change"] = Effect.fn(
372
+ "MemorySubscriptionStore.change",
373
+ )(function* (input, expectedRevision, inputChange) {
374
+ const key = yield* requireKey(input, "change-key");
375
+ const change = yield* validate(SubscriptionChange, inputChange, "change");
376
+
377
+ yield* validate(Schema.Int.check(Schema.isGreaterThan(0)), expectedRevision, "revision");
378
+ yield* failpoint.hit("subscription:change:before");
379
+
380
+ const updated = yield* Effect.uninterruptible(
381
+ Ref.modify(
382
+ state,
383
+ (
384
+ current,
385
+ ): readonly [
386
+ Result.Result<SubscriptionRecord, SubscriptionError>,
387
+ MemorySubscriptionState,
388
+ ] => {
389
+ const storageKey = subscriptionKeyString(key);
390
+ const text = current.registrations.get(storageKey);
391
+
392
+ if (text === undefined) return [Result.fail(error("not-found", "subscription")), current];
393
+ const existing = decode(SubscriptionRecord, text, "change-record");
394
+
395
+ if (Result.isFailure(existing)) return [existing, current];
396
+ const updated = applySubscriptionChange(existing.success, expectedRevision, change);
397
+
398
+ if (Result.isFailure(updated)) return [updated, current];
399
+ const revised = { ...updated.success, ordinal: current.sequence + 1 };
400
+ const encoded = encode(SubscriptionRecord, revised, "change-encode");
401
+
402
+ if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
403
+ const registrations = new Map(current.registrations);
404
+
405
+ registrations.set(storageKey, encoded.success);
406
+ const registrationIndex = new Map(current.registrationIndex);
407
+
408
+ registrationIndex.set(storageKey, {
409
+ key,
410
+ ordinal: revised.ordinal,
411
+ state: revised.state,
412
+ recoveryKey: revised.recovery === null ? null : candidateIndexKey(revised),
413
+ recoveryAt: revised.recovery?.nextAttemptAtMillis ?? null,
414
+ });
415
+ const candidateIndex = new Map(current.candidateIndex);
416
+ const oldKey = candidateIndexKey(existing.success);
417
+ const nextKey = candidateIndexKey(revised);
418
+
419
+ {
420
+ candidateIndex.set(
421
+ oldKey,
422
+ (candidateIndex.get(oldKey) ?? []).filter((key) => key !== storageKey),
423
+ );
424
+ candidateIndex.set(
425
+ nextKey,
426
+ [...(candidateIndex.get(nextKey) ?? []), storageKey].sort(
427
+ (a, b) =>
428
+ (registrationIndex.get(a)?.ordinal ?? 0) -
429
+ (registrationIndex.get(b)?.ordinal ?? 0),
430
+ ),
431
+ );
432
+ }
433
+
434
+ return [
435
+ Result.succeed(revised),
436
+ {
437
+ ...current,
438
+ sequence: revised.ordinal,
439
+ registrations,
440
+ registrationIndex,
441
+ candidateIndex,
442
+ recoveryCounts: recoveryCountsAfter(current, registrationIndex, [storageKey]),
443
+ },
444
+ ];
445
+ },
446
+ ),
447
+ ).pipe(Effect.flatMap(Effect.fromResult));
448
+
449
+ yield* failpoint.hit("subscription:change:after");
450
+
451
+ return updated;
452
+ });
453
+
284
454
  const cancel: SubscriptionStore["Service"]["cancel"] = Effect.fn(
285
455
  "MemorySubscriptionStore.cancel",
286
- )(function* (input) {
456
+ )(function* (input, expectedRevision) {
287
457
  const key = yield* requireKey(input, "cancel-key");
288
458
 
289
459
  yield* failpoint.hit("subscription:cancel:before");
@@ -304,9 +474,35 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
304
474
  const decoded = decode(SubscriptionRecord, text, "cancel-record");
305
475
 
306
476
  if (Result.isFailure(decoded)) return [decoded, current];
477
+ if (
478
+ expectedRevision !== undefined &&
479
+ expectedRevision !== decoded.success.configurationRevision &&
480
+ !(
481
+ decoded.success.state === "cancelled" &&
482
+ expectedRevision + 1 === decoded.success.configurationRevision
483
+ )
484
+ )
485
+ return [
486
+ Result.fail(
487
+ SubscriptionError.make({
488
+ reason: "conflict",
489
+ code: "configuration-revision",
490
+ currentRevision: decoded.success.configurationRevision,
491
+ currentState: decoded.success.state,
492
+ }),
493
+ ),
494
+ current,
495
+ ];
307
496
  if (decoded.success.state === "cancelled")
308
497
  return [Result.succeed(decoded.success), current];
309
- const cancelled = { ...decoded.success, state: "cancelled" as const, recovery: null };
498
+
499
+ const cancelled = {
500
+ ...decoded.success,
501
+ configurationRevision: decoded.success.configurationRevision + 1,
502
+ state: "cancelled" as const,
503
+ recovery: null,
504
+ };
505
+
310
506
  const encoded = encode(SubscriptionRecord, cancelled, "cancel-encode");
311
507
 
312
508
  if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
@@ -318,9 +514,22 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
318
514
 
319
515
  if (indexed === undefined)
320
516
  return [Result.fail(error("corrupt", "cancel-index")), current];
321
- registrationIndex.set(storageKey, { ...indexed, state: "cancelled", recoveryAt: null });
517
+ registrationIndex.set(storageKey, {
518
+ ...indexed,
519
+ state: "cancelled",
520
+ recoveryAt: null,
521
+ recoveryKey: null,
522
+ });
322
523
 
323
- return [Result.succeed(cancelled), { ...current, registrations, registrationIndex }];
524
+ return [
525
+ Result.succeed(cancelled),
526
+ {
527
+ ...current,
528
+ registrations,
529
+ registrationIndex,
530
+ recoveryCounts: recoveryCountsAfter(current, registrationIndex, [storageKey]),
531
+ },
532
+ ];
324
533
  },
325
534
  ),
326
535
  ).pipe(Effect.flatMap(Effect.fromResult));
@@ -334,6 +543,7 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
334
543
  "MemorySubscriptionStore.accept",
335
544
  )(function* (input, inputLimits) {
336
545
  const event = yield* validate(AcceptedEvent, input, "accept-event");
546
+ const currentTimeMillis = yield* Clock.currentTimeMillis;
337
547
  const limits = yield* validate(SubscriptionLimits, inputLimits, "accept-limits");
338
548
 
339
549
  yield* requirePartition(event, "accept-partition");
@@ -356,9 +566,17 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
356
566
  ? [Result.succeed(existing.success), current]
357
567
  : [Result.fail(error("conflict", "event-identity")), current];
358
568
  }
569
+ if (
570
+ current.retentionHorizon !== null &&
571
+ current.retentionHorizon !== limits.retention?.replayHorizonMillis
572
+ )
573
+ return [Result.fail(error("conflict", "retention-horizon")), current];
574
+ const horizon = validateEventRetention(event, limits, currentTimeMillis);
575
+
576
+ if (Result.isFailure(horizon)) return [Result.fail(horizon.failure), current];
359
577
  if (jsonBytes(event.payload) > limits.maxPayloadBytes)
360
578
  return [Result.fail(error("capacity", "payload-bytes")), current];
361
- if (current.events.size >= limits.maxEvents)
579
+ if (current.events.size - current.tombstoneCount >= limits.maxEvents)
362
580
  return [Result.fail(error("capacity", "events")), current];
363
581
 
364
582
  const accepted: AcceptedEvent = {
@@ -384,7 +602,18 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
384
602
 
385
603
  return [
386
604
  Result.succeed(accepted),
387
- { ...current, sequence: accepted.cutoff, events, eventIndex },
605
+ {
606
+ ...current,
607
+ sequence: accepted.cutoff,
608
+ events,
609
+ eventIndex,
610
+ eventKeys: insertKey(current.eventKeys, accepted.eventId),
611
+ retentionHorizon: limits.retention?.replayHorizonMillis ?? current.retentionHorizon,
612
+ retentionDeadline:
613
+ limits.retention === undefined
614
+ ? current.retentionDeadline
615
+ : accepted.acceptedAtMillis,
616
+ },
388
617
  ];
389
618
  },
390
619
  ),
@@ -573,7 +802,7 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
573
802
  return [Result.fail(error("corrupt", "selection-index")), current];
574
803
  registrationUpdates.push([
575
804
  consumedKey,
576
- { ...indexed, state: "consumed", recoveryAt: null },
805
+ { ...indexed, state: "consumed", recoveryAt: null, recoveryKey: null },
577
806
  ]);
578
807
  }
579
808
  }
@@ -592,6 +821,13 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
592
821
 
593
822
  for (const [key, value] of registrationUpdates) registrationIndex.set(key, value);
594
823
 
824
+ const eventDeliveryCounts = new Map(current.eventDeliveryCounts);
825
+
826
+ eventDeliveryCounts.set(
827
+ accepted.eventId,
828
+ (eventDeliveryCounts.get(accepted.eventId) ?? 0) + additions.length,
829
+ );
830
+
595
831
  const nextEvent: AcceptedEvent = {
596
832
  ...accepted,
597
833
  cursor,
@@ -618,9 +854,19 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
618
854
  ...current,
619
855
  registrations,
620
856
  registrationIndex,
857
+ recoveryCounts: recoveryCountsAfter(
858
+ current,
859
+ registrationIndex,
860
+ registrationUpdates.map(([key]) => key),
861
+ ),
862
+ eventDeliveryCounts,
621
863
  events,
622
864
  eventIndex,
623
865
  deliveries: nextDeliveries,
866
+ deliveryKeys: additions.reduce(
867
+ (keys, [key]) => insertKey(keys, key),
868
+ current.deliveryKeys,
869
+ ),
624
870
  deliveryIndex,
625
871
  ownerDeliveryCounts: owners,
626
872
  },
@@ -720,7 +966,12 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
720
966
 
721
967
  if (indexed === undefined)
722
968
  return [Result.fail(error("corrupt", "catch-up-index")), current];
723
- registrationIndex.set(consumedKey, { ...indexed, state: "consumed", recoveryAt: null });
969
+ registrationIndex.set(consumedKey, {
970
+ ...indexed,
971
+ state: "consumed",
972
+ recoveryAt: null,
973
+ recoveryKey: null,
974
+ });
724
975
  const deliveryIndex = new Map(current.deliveryIndex);
725
976
 
726
977
  deliveryIndex.set(key, {
@@ -729,6 +980,12 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
729
980
  nextAttemptAtMillis: delivery.retry.nextAttemptAtMillis,
730
981
  });
731
982
  const ownerDeliveryCounts = new Map(current.ownerDeliveryCounts);
983
+ const eventDeliveryCounts = new Map(current.eventDeliveryCounts);
984
+
985
+ eventDeliveryCounts.set(
986
+ accepted.success.eventId,
987
+ (eventDeliveryCounts.get(accepted.success.eventId) ?? 0) + 1,
988
+ );
732
989
 
733
990
  ownerDeliveryCounts.set(consumed.key.ownerId, ownerCount + 1);
734
991
 
@@ -738,9 +995,12 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
738
995
  ...current,
739
996
  deliveries,
740
997
  deliveryIndex,
998
+ deliveryKeys: insertKey(current.deliveryKeys, key),
741
999
  ownerDeliveryCounts,
742
1000
  registrations,
743
1001
  registrationIndex,
1002
+ recoveryCounts: recoveryCountsAfter(current, registrationIndex, [consumedKey]),
1003
+ eventDeliveryCounts,
744
1004
  },
745
1005
  ];
746
1006
  },
@@ -809,8 +1069,8 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
809
1069
 
810
1070
  for (const [storageKey, item] of (yield* Ref.get(state)).deliveryIndex) {
811
1071
  if (
812
- item.state !== "delivered" &&
813
- item.state !== "refused" &&
1072
+ ((item.state !== "delivered" && item.state !== "refused" && item.parked !== true) ||
1073
+ (item.state === "delivered" && item.observeSettlement === true)) &&
814
1074
  item.nextAttemptAtMillis <= nowMillis &&
815
1075
  compareScheduleNames(storageKey, after) > 0
816
1076
  )
@@ -921,6 +1181,8 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
921
1181
  deliveryIndex.set(storageKey, {
922
1182
  ...indexed,
923
1183
  state: updated.state,
1184
+ parked: updated.retry.parked ?? false,
1185
+ observeSettlement: updated.observeSettlement ?? false,
924
1186
  nextAttemptAtMillis: updated.retry.nextAttemptAtMillis,
925
1187
  });
926
1188
 
@@ -955,7 +1217,7 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
955
1217
 
956
1218
  const deferRecovery: SubscriptionStore["Service"]["deferRecovery"] = Effect.fn(
957
1219
  "MemorySubscriptionStore.deferRecovery",
958
- )(function* (input, recovery) {
1220
+ )(function* (input, expectedRevision, recovery) {
959
1221
  const key = yield* requireKey(input, "defer-recovery-key");
960
1222
 
961
1223
  yield* failpoint.hit("subscription:defer-recovery:before");
@@ -971,9 +1233,15 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
971
1233
 
972
1234
  if (Result.isFailure(record)) return [Result.fail(record.failure), current];
973
1235
 
1236
+ if (record.success.configurationRevision !== expectedRevision)
1237
+ return [Result.void, current];
1238
+
974
1239
  const updated = {
975
1240
  ...record.success,
976
- recovery: record.success.state === "active" ? recovery : null,
1241
+ recovery:
1242
+ record.success.state === "active" || record.success.state === "paused"
1243
+ ? recovery
1244
+ : null,
977
1245
  };
978
1246
 
979
1247
  const encoded = encode(SubscriptionRecord, updated, "defer-recovery-encode");
@@ -989,10 +1257,19 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
989
1257
  return [Result.fail(error("corrupt", "recovery-index")), current];
990
1258
  registrationIndex.set(storageKey, {
991
1259
  ...indexed,
1260
+ recoveryKey: updated.recovery === null ? null : candidateIndexKey(updated),
992
1261
  recoveryAt: updated.recovery?.nextAttemptAtMillis ?? null,
993
1262
  });
994
1263
 
995
- return [Result.void, { ...current, registrations, registrationIndex }];
1264
+ return [
1265
+ Result.void,
1266
+ {
1267
+ ...current,
1268
+ registrations,
1269
+ registrationIndex,
1270
+ recoveryCounts: recoveryCountsAfter(current, registrationIndex, [storageKey]),
1271
+ },
1272
+ ];
996
1273
  },
997
1274
  ),
998
1275
  ).pipe(Effect.flatMap(Effect.fromResult));
@@ -1030,10 +1307,14 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
1030
1307
  if (deadline === null || value < deadline) deadline = value;
1031
1308
  };
1032
1309
 
1310
+ if (current.retentionDeadline !== null) consider(current.retentionDeadline);
1033
1311
  for (const accepted of current.eventIndex.values())
1034
1312
  if (!accepted.routingComplete) consider(accepted.nextAttemptAtMillis);
1035
1313
  for (const item of current.deliveryIndex.values())
1036
- if (item.state !== "delivered" && item.state !== "refused")
1314
+ if (
1315
+ (item.state !== "delivered" && item.state !== "refused" && item.parked !== true) ||
1316
+ (item.state === "delivered" && item.observeSettlement === true)
1317
+ )
1037
1318
  consider(item.nextAttemptAtMillis);
1038
1319
  for (const record of current.registrationIndex.values())
1039
1320
  if (record.state === "active" && record.recoveryAt !== null) consider(record.recoveryAt);
@@ -1041,12 +1322,213 @@ const makeMemorySubscriptionStore = Effect.fn("makeMemorySubscriptionStore")(fun
1041
1322
  return deadline;
1042
1323
  }).pipe(Effect.withSpan("MemorySubscriptionStore.nextDeadline"));
1043
1324
 
1325
+ const compact: SubscriptionStore["Service"]["compact"] = Effect.fn(
1326
+ "MemorySubscriptionStore.compact",
1327
+ )(function* (nowMillis, inputPolicy, requestedLimit) {
1328
+ nowMillis = Math.min(nowMillis, yield* Clock.currentTimeMillis);
1329
+ const policy = yield* validate(SubscriptionRetentionPolicy, inputPolicy, "retention-policy");
1330
+
1331
+ const limit = yield* validate(
1332
+ Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 })),
1333
+ requestedLimit,
1334
+ "maintenance-limit",
1335
+ );
1336
+
1337
+ yield* failpoint.hit("subscription:compact:before");
1338
+ let corruptCandidates = 0;
1339
+
1340
+ const result = yield* Effect.uninterruptible(
1341
+ Ref.modify(
1342
+ state,
1343
+ (current): readonly [Result.Result<number, SubscriptionError>, MemorySubscriptionState] => {
1344
+ if (
1345
+ current.retentionHorizon !== null &&
1346
+ current.retentionHorizon !== policy.replayHorizonMillis
1347
+ )
1348
+ return [Result.fail(error("conflict", "retention-horizon")), current];
1349
+ const events = new Map(current.events);
1350
+ const eventIndex = new Map(current.eventIndex);
1351
+ const deliveries = new Map(current.deliveries);
1352
+ const deliveryIndex = new Map(current.deliveryIndex);
1353
+ const eventDeliveryCounts = new Map(current.eventDeliveryCounts);
1354
+ const ownerDeliveryCounts = new Map(current.ownerDeliveryCounts);
1355
+
1356
+ // Page indexes before decoding. Each pass reads at most 2 * limit event values and
1357
+ // limit delivery values; relationship checks use maintained reference counts.
1358
+ const deliveryPage = current.deliveryKeys.slice(
1359
+ upperBound(current.deliveryKeys, current.maintenanceDeliveries),
1360
+ upperBound(current.deliveryKeys, current.maintenanceDeliveries) + limit,
1361
+ );
1362
+
1363
+ const eventPage = current.eventKeys.slice(
1364
+ upperBound(current.eventKeys, current.maintenanceEvents),
1365
+ upperBound(current.eventKeys, current.maintenanceEvents) + limit,
1366
+ );
1367
+
1368
+ const cutoff = nowMillis - policy.completedRetentionMillis;
1369
+ let removed = 0;
1370
+ const removedEvents = new Set<string>();
1371
+ const removedDeliveries = new Set<string>();
1372
+
1373
+ for (const key of deliveryPage) {
1374
+ const text = deliveries.get(key);
1375
+
1376
+ if (text === undefined) continue;
1377
+ const decoded = decode(SubscriptionDelivery, text, "compact-delivery");
1378
+
1379
+ if (Result.isFailure(decoded)) {
1380
+ corruptCandidates++;
1381
+ continue;
1382
+ }
1383
+ const delivery = decoded.success;
1384
+
1385
+ if (
1386
+ subscriptionDeliveryKeyString(delivery.key) !== key ||
1387
+ !samePartition(delivery.key.subscription.partition, partition)
1388
+ ) {
1389
+ corruptCandidates++;
1390
+ continue;
1391
+ }
1392
+
1393
+ if (
1394
+ delivery.state !== "refused" &&
1395
+ (delivery.state !== "delivered" || delivery.settledAtMillis === undefined)
1396
+ )
1397
+ continue;
1398
+ if (
1399
+ (delivery.settledAtMillis ??
1400
+ delivery.completedAtMillis ??
1401
+ delivery.selectedAtMillis) > cutoff
1402
+ )
1403
+ continue;
1404
+ const eventText = events.get(delivery.key.eventId);
1405
+
1406
+ if (eventText === undefined) continue;
1407
+ const accepted = decode(AcceptedEvent, eventText, "compact-delivery-event");
1408
+
1409
+ if (Result.isFailure(accepted)) {
1410
+ corruptCandidates++;
1411
+ continue;
1412
+ }
1413
+ const event = accepted.success;
1414
+
1415
+ if (
1416
+ event.eventId !== delivery.key.eventId ||
1417
+ !samePartition(event.partition, partition)
1418
+ ) {
1419
+ corruptCandidates++;
1420
+ continue;
1421
+ }
1422
+
1423
+ if (
1424
+ !event.routingComplete ||
1425
+ event.occurredAtMillis === undefined ||
1426
+ event.acceptedAtMillis > cutoff ||
1427
+ (current.recoveryCounts.get(eventCandidateIndexKey(event)) ?? 0) > 0
1428
+ )
1429
+ continue;
1430
+ deliveries.delete(key);
1431
+ removedDeliveries.add(key);
1432
+ deliveryIndex.delete(key);
1433
+ eventDeliveryCounts.set(
1434
+ event.eventId,
1435
+ (eventDeliveryCounts.get(event.eventId) ?? 1) - 1,
1436
+ );
1437
+ const owner = delivery.key.subscription.ownerId;
1438
+
1439
+ ownerDeliveryCounts.set(owner, (ownerDeliveryCounts.get(owner) ?? 1) - 1);
1440
+ }
1441
+ let tombstones = current.tombstoneCount;
1442
+
1443
+ for (const key of eventPage) {
1444
+ const text = events.get(key);
1445
+
1446
+ if (text === undefined) continue;
1447
+ const decoded = decode(AcceptedEvent, text, "compact-event");
1448
+
1449
+ if (Result.isFailure(decoded)) {
1450
+ corruptCandidates++;
1451
+ continue;
1452
+ }
1453
+ const event = decoded.success;
1454
+
1455
+ if (event.eventId !== key || !samePartition(event.partition, partition)) {
1456
+ corruptCandidates++;
1457
+ continue;
1458
+ }
1459
+
1460
+ if (!event.routingComplete || event.occurredAtMillis === undefined) continue;
1461
+ const expired = event.occurredAtMillis <= nowMillis - policy.replayHorizonMillis;
1462
+
1463
+ if (event.tombstone === true && !expired) continue;
1464
+ if (
1465
+ event.acceptedAtMillis > cutoff ||
1466
+ (eventDeliveryCounts.get(key) ?? 0) > 0 ||
1467
+ (current.recoveryCounts.get(eventCandidateIndexKey(event)) ?? 0) > 0
1468
+ )
1469
+ continue;
1470
+ if (expired) {
1471
+ events.delete(key);
1472
+ removedEvents.add(key);
1473
+ eventIndex.delete(key);
1474
+ eventDeliveryCounts.delete(key);
1475
+ if (event.tombstone === true) tombstones--;
1476
+ } else {
1477
+ if (tombstones >= policy.maxTombstones) continue;
1478
+
1479
+ const encoded = encode(
1480
+ AcceptedEvent,
1481
+ { ...event, payload: null, tombstone: true },
1482
+ "compact-tombstone",
1483
+ );
1484
+
1485
+ if (Result.isFailure(encoded)) continue;
1486
+ events.set(key, encoded.success);
1487
+ tombstones++;
1488
+ }
1489
+ removed++;
1490
+ }
1491
+
1492
+ return [
1493
+ Result.succeed(removed),
1494
+ {
1495
+ ...current,
1496
+ events,
1497
+ eventIndex,
1498
+ deliveries,
1499
+ deliveryIndex,
1500
+ eventDeliveryCounts,
1501
+ ownerDeliveryCounts,
1502
+ eventKeys: removeKeys(current.eventKeys, removedEvents),
1503
+ deliveryKeys: removeKeys(current.deliveryKeys, removedDeliveries),
1504
+ tombstoneCount: tombstones,
1505
+ maintenanceEvents: eventPage.length < limit ? "" : (eventPage.at(-1) ?? ""),
1506
+ maintenanceDeliveries: deliveryPage.length < limit ? "" : (deliveryPage.at(-1) ?? ""),
1507
+ retentionHorizon: policy.replayHorizonMillis,
1508
+ retentionDeadline: events.size === 0 ? null : nowMillis + 60_000,
1509
+ },
1510
+ ];
1511
+ },
1512
+ ),
1513
+ ).pipe(Effect.flatMap(Effect.fromResult));
1514
+
1515
+ if (corruptCandidates > 0)
1516
+ yield* Effect.logWarning("Subscription retention preserved corrupt candidates", {
1517
+ count: corruptCandidates,
1518
+ });
1519
+ yield* failpoint.hit("subscription:compact:after");
1520
+
1521
+ return result;
1522
+ });
1523
+
1044
1524
  return SubscriptionStore.of({
1045
1525
  partition,
1526
+ compact,
1046
1527
  register,
1047
1528
  get,
1048
1529
  list,
1049
1530
  cancel,
1531
+ change,
1050
1532
  accept,
1051
1533
  event,
1052
1534
  pendingEvents,