@effect-agent/storage-memory 0.1.0-beta.50 → 0.1.0-beta.52
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/dist/MemorySubmissionLedger.mjs +30 -5
- package/dist/MemorySubmissionLedger.mjs.map +1 -1
- package/dist/MemorySubscriptionStore.mjs +266 -17
- package/dist/MemorySubscriptionStore.mjs.map +1 -1
- package/package.json +1 -1
- package/src/MemorySubmissionLedger.ts +76 -3
- package/src/MemorySubscriptionStore.ts +499 -17
|
@@ -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
|
|
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
|
-
|
|
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, {
|
|
517
|
+
registrationIndex.set(storageKey, {
|
|
518
|
+
...indexed,
|
|
519
|
+
state: "cancelled",
|
|
520
|
+
recoveryAt: null,
|
|
521
|
+
recoveryKey: null,
|
|
522
|
+
});
|
|
322
523
|
|
|
323
|
-
return [
|
|
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
|
-
{
|
|
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, {
|
|
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
|
-
|
|
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:
|
|
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 [
|
|
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 (
|
|
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,
|