@yielded/agent-storage-memory 0.1.0-beta.169 → 0.1.0-beta.171

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 "@yielded/agent/schedule-transition";
3
3
  import {
4
4
  AcceptedEvent,
5
5
  DeliveryChange,
6
+ defaultSubscriptionLimits,
6
7
  SubscriptionChange,
7
8
  SourcePartition,
8
9
  SubscriptionDelivery,
@@ -26,10 +27,17 @@ import {
26
27
  validateEventRetention,
27
28
  subscriptionCanSelect,
28
29
  subscriptionDeliveryCanSelect,
30
+ subscriptionUsesCapacity,
31
+ subscriptionDeliveryUsesCapacity,
29
32
  } from "@yielded/agent/subscription-transition";
30
33
  import { Clock, Effect, Layer, Ref, Result, Schema } from "effect";
31
34
 
32
- interface MemorySubscriptionState {
35
+ interface RegistrationCapacity {
36
+ readonly registrationExpiries: ReadonlyArray<number>;
37
+ readonly ownerRegistrationExpiries: ReadonlyMap<string, ReadonlyArray<number>>;
38
+ }
39
+
40
+ interface MemorySubscriptionState extends RegistrationCapacity {
33
41
  readonly sequence: number;
34
42
  readonly retentionDeadline: number | null;
35
43
  readonly tombstoneCount: number;
@@ -45,10 +53,11 @@ interface MemorySubscriptionState {
45
53
  readonly deliveries: ReadonlyMap<string, string>;
46
54
  readonly registrationIndex: ReadonlyMap<string, RegistrationIndex>;
47
55
  readonly candidateIndex: ReadonlyMap<string, ReadonlyArray<string>>;
48
- readonly ownerRegistrationCounts: ReadonlyMap<string, number>;
49
56
  readonly eventIndex: ReadonlyMap<string, EventIndex>;
50
57
  readonly deliveryIndex: ReadonlyMap<string, DeliveryIndex>;
51
58
  readonly ownerDeliveryCounts: ReadonlyMap<string, number>;
59
+ readonly liveDeliveryCount: number;
60
+ readonly pendingEventCount: number;
52
61
  readonly scanCursors: SubscriptionScanCursors;
53
62
  }
54
63
 
@@ -180,6 +189,71 @@ const insertKey = (keys: ReadonlyArray<string>, key: string): ReadonlyArray<stri
180
189
  return [...keys.slice(0, at), key, ...keys.slice(at)];
181
190
  };
182
191
 
192
+ // Count unexpired registrations without reading retained history. Keeping sorted expiry values
193
+ // also makes clock changes safe; expiry alone never deletes or rewrites the canonical record.
194
+ const expiryUpperBound = (expiries: ReadonlyArray<number>, nowMillis: number): number => {
195
+ let low = 0;
196
+ let high = expiries.length;
197
+
198
+ while (low < high) {
199
+ const middle = Math.floor((low + high) / 2);
200
+ const expiry = expiries[middle];
201
+
202
+ if (expiry !== undefined && expiry <= nowMillis) low = middle + 1;
203
+ else high = middle;
204
+ }
205
+
206
+ return low;
207
+ };
208
+
209
+ const registrationCount = (
210
+ current: MemorySubscriptionState,
211
+ nowMillis: number,
212
+ ownerId?: string,
213
+ ): number => {
214
+ const expiries =
215
+ ownerId === undefined
216
+ ? current.registrationExpiries
217
+ : (current.ownerRegistrationExpiries.get(ownerId) ?? []);
218
+
219
+ return expiries.length - expiryUpperBound(expiries, nowMillis);
220
+ };
221
+
222
+ const registrationExpiriesAfter = (
223
+ current: RegistrationCapacity,
224
+ before: SubscriptionRecord | undefined,
225
+ after: SubscriptionRecord,
226
+ ): RegistrationCapacity => {
227
+ const expiry = (record: SubscriptionRecord | undefined) =>
228
+ record !== undefined && (record.state === "active" || record.state === "paused")
229
+ ? (record.configuration.expiresAtMillis ?? Number.POSITIVE_INFINITY)
230
+ : null;
231
+
232
+ const previous = expiry(before);
233
+ const next = expiry(after);
234
+
235
+ const replace = (expiries: ReadonlyArray<number>): ReadonlyArray<number> => {
236
+ if (previous === next) return expiries;
237
+ const updated = [...expiries];
238
+
239
+ if (previous !== null) updated.splice(expiryUpperBound(updated, previous) - 1, 1);
240
+ if (next !== null) updated.splice(expiryUpperBound(updated, next), 0, next);
241
+
242
+ return updated;
243
+ };
244
+
245
+ const owners = new Map(current.ownerRegistrationExpiries);
246
+ const expiries = replace(owners.get(after.key.ownerId) ?? []);
247
+
248
+ if (expiries.length === 0) owners.delete(after.key.ownerId);
249
+ else owners.set(after.key.ownerId, expiries);
250
+
251
+ return {
252
+ registrationExpiries: replace(current.registrationExpiries),
253
+ ownerRegistrationExpiries: owners,
254
+ };
255
+ };
256
+
183
257
  const recoveryCountsAfter = (
184
258
  current: MemorySubscriptionState,
185
259
  next: ReadonlyMap<string, RegistrationIndex>,
@@ -218,10 +292,13 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
218
292
  deliveries: new Map(),
219
293
  registrationIndex: new Map(),
220
294
  candidateIndex: new Map(),
221
- ownerRegistrationCounts: new Map(),
295
+ registrationExpiries: [],
296
+ ownerRegistrationExpiries: new Map(),
222
297
  eventIndex: new Map(),
223
298
  deliveryIndex: new Map(),
224
299
  ownerDeliveryCounts: new Map(),
300
+ liveDeliveryCount: 0,
301
+ pendingEventCount: 0,
225
302
  scanCursors: { events: "", deliveries: "", recovery: 0 },
226
303
  });
227
304
 
@@ -247,6 +324,7 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
247
324
 
248
325
  yield* requirePartition(record.key, "register-partition");
249
326
  yield* failpoint.hit("subscription:register:before");
327
+ const nowMillis = yield* Clock.currentTimeMillis;
250
328
 
251
329
  const result = yield* Effect.uninterruptible(
252
330
  Ref.modify(
@@ -279,11 +357,16 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
279
357
  limits.maxLifetimeMillis
280
358
  )
281
359
  return [Result.fail(error("capacity", "lifetime")), current];
282
- if (current.registrations.size >= limits.maxRegistrations)
360
+ if (
361
+ subscriptionUsesCapacity(record, nowMillis) &&
362
+ registrationCount(current, nowMillis) >= limits.maxRegistrations
363
+ )
283
364
  return [Result.fail(error("capacity", "registrations")), current];
284
- const ownerCount = current.ownerRegistrationCounts.get(record.key.ownerId) ?? 0;
285
-
286
- if (ownerCount >= limits.maxRegistrationsPerOwner)
365
+ if (
366
+ subscriptionUsesCapacity(record, nowMillis) &&
367
+ registrationCount(current, nowMillis, record.key.ownerId) >=
368
+ limits.maxRegistrationsPerOwner
369
+ )
287
370
  return [Result.fail(error("capacity", "owner-registrations")), current];
288
371
  const assigned = { ...record, ordinal: current.sequence + 1 };
289
372
  const encoded = encode(SubscriptionRecord, assigned, "register-encode");
@@ -305,9 +388,6 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
305
388
  const candidateKey = candidateIndexKey(assigned);
306
389
 
307
390
  candidateIndex.set(candidateKey, [...(candidateIndex.get(candidateKey) ?? []), key]);
308
- const ownerRegistrationCounts = new Map(current.ownerRegistrationCounts);
309
-
310
- ownerRegistrationCounts.set(assigned.key.ownerId, ownerCount + 1);
311
391
 
312
392
  return [
313
393
  Result.succeed(assigned),
@@ -318,7 +398,7 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
318
398
  registrationIndex,
319
399
  recoveryCounts: recoveryCountsAfter(current, registrationIndex, [key]),
320
400
  candidateIndex,
321
- ownerRegistrationCounts,
401
+ ...registrationExpiriesAfter(current, undefined, assigned),
322
402
  },
323
403
  ];
324
404
  },
@@ -358,89 +438,105 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
358
438
  },
359
439
  );
360
440
 
361
- const change: SubscriptionStore["Service"]["change"] = Effect.fnUntraced(
362
- function* (input, expectedRevision, inputChange) {
363
- const key = yield* requireKey(input, "change-key");
364
- const change = yield* validate(SubscriptionChange, inputChange, "change");
365
-
366
- yield* validate(Schema.Int.check(Schema.isGreaterThan(0)), expectedRevision, "revision");
367
- yield* failpoint.hit("subscription:change:before");
368
-
369
- const updated = yield* Effect.uninterruptible(
370
- Ref.modify(
371
- state,
372
- (
373
- current,
374
- ): readonly [
375
- Result.Result<SubscriptionRecord, SubscriptionError>,
376
- MemorySubscriptionState,
377
- ] => {
378
- const storageKey = subscriptionKeyString(key);
379
- const text = current.registrations.get(storageKey);
380
-
381
- if (text === undefined)
382
- return [Result.fail(error("not-found", "subscription")), current];
383
- const existing = decode(SubscriptionRecord, text, "change-record");
384
-
385
- if (Result.isFailure(existing)) return [existing, current];
386
- const updated = applySubscriptionChange(existing.success, expectedRevision, change);
387
-
388
- if (Result.isFailure(updated)) return [updated, current];
389
- const revised = { ...updated.success, ordinal: current.sequence + 1 };
390
- const encoded = encode(SubscriptionRecord, revised, "change-encode");
391
-
392
- if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
393
- const registrations = new Map(current.registrations);
394
-
395
- registrations.set(storageKey, encoded.success);
396
- const registrationIndex = new Map(current.registrationIndex);
397
-
398
- registrationIndex.set(storageKey, {
399
- key,
400
- ordinal: revised.ordinal,
401
- state: revised.state,
402
- recoveryKey: revised.recovery === null ? null : candidateIndexKey(revised),
403
- recoveryAt: revised.recovery?.nextAttemptAtMillis ?? null,
404
- });
405
- const candidateIndex = new Map(current.candidateIndex);
406
- const oldKey = candidateIndexKey(existing.success);
407
- const nextKey = candidateIndexKey(revised);
441
+ const change: SubscriptionStore["Service"]["change"] = Effect.fnUntraced(function* (
442
+ input,
443
+ expectedRevision,
444
+ inputChange,
445
+ inputLimits = defaultSubscriptionLimits,
446
+ ) {
447
+ const key = yield* requireKey(input, "change-key");
448
+ const change = yield* validate(SubscriptionChange, inputChange, "change");
449
+ const limits = yield* validate(SubscriptionLimits, inputLimits, "change-limits");
450
+
451
+ yield* validate(Schema.Int.check(Schema.isGreaterThan(0)), expectedRevision, "revision");
452
+ yield* failpoint.hit("subscription:change:before");
453
+ const nowMillis = yield* Clock.currentTimeMillis;
454
+
455
+ const updated = yield* Effect.uninterruptible(
456
+ Ref.modify(
457
+ state,
458
+ (
459
+ current,
460
+ ): readonly [
461
+ Result.Result<SubscriptionRecord, SubscriptionError>,
462
+ MemorySubscriptionState,
463
+ ] => {
464
+ const storageKey = subscriptionKeyString(key);
465
+ const text = current.registrations.get(storageKey);
466
+
467
+ if (text === undefined) return [Result.fail(error("not-found", "subscription")), current];
468
+ const existing = decode(SubscriptionRecord, text, "change-record");
469
+
470
+ if (Result.isFailure(existing)) return [existing, current];
471
+ const updated = applySubscriptionChange(existing.success, expectedRevision, change);
472
+
473
+ if (Result.isFailure(updated)) return [updated, current];
474
+ if (
475
+ !subscriptionUsesCapacity(existing.success, nowMillis) &&
476
+ subscriptionUsesCapacity(updated.success, nowMillis)
477
+ ) {
478
+ if (registrationCount(current, nowMillis) >= limits.maxRegistrations)
479
+ return [Result.fail(error("capacity", "registrations")), current];
480
+ if (
481
+ registrationCount(current, nowMillis, key.ownerId) >= limits.maxRegistrationsPerOwner
482
+ )
483
+ return [Result.fail(error("capacity", "owner-registrations")), current];
484
+ }
485
+ const revised = { ...updated.success, ordinal: current.sequence + 1 };
486
+ const encoded = encode(SubscriptionRecord, revised, "change-encode");
487
+
488
+ if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
489
+ const registrations = new Map(current.registrations);
490
+
491
+ registrations.set(storageKey, encoded.success);
492
+ const registrationIndex = new Map(current.registrationIndex);
493
+
494
+ registrationIndex.set(storageKey, {
495
+ key,
496
+ ordinal: revised.ordinal,
497
+ state: revised.state,
498
+ recoveryKey: revised.recovery === null ? null : candidateIndexKey(revised),
499
+ recoveryAt: revised.recovery?.nextAttemptAtMillis ?? null,
500
+ });
501
+ const candidateIndex = new Map(current.candidateIndex);
502
+ const oldKey = candidateIndexKey(existing.success);
503
+ const nextKey = candidateIndexKey(revised);
504
+
505
+ {
506
+ candidateIndex.set(
507
+ oldKey,
508
+ (candidateIndex.get(oldKey) ?? []).filter((key) => key !== storageKey),
509
+ );
510
+ candidateIndex.set(
511
+ nextKey,
512
+ [...(candidateIndex.get(nextKey) ?? []), storageKey].sort(
513
+ (a, b) =>
514
+ (registrationIndex.get(a)?.ordinal ?? 0) -
515
+ (registrationIndex.get(b)?.ordinal ?? 0),
516
+ ),
517
+ );
518
+ }
408
519
 
520
+ return [
521
+ Result.succeed(revised),
409
522
  {
410
- candidateIndex.set(
411
- oldKey,
412
- (candidateIndex.get(oldKey) ?? []).filter((key) => key !== storageKey),
413
- );
414
- candidateIndex.set(
415
- nextKey,
416
- [...(candidateIndex.get(nextKey) ?? []), storageKey].sort(
417
- (a, b) =>
418
- (registrationIndex.get(a)?.ordinal ?? 0) -
419
- (registrationIndex.get(b)?.ordinal ?? 0),
420
- ),
421
- );
422
- }
423
-
424
- return [
425
- Result.succeed(revised),
426
- {
427
- ...current,
428
- sequence: revised.ordinal,
429
- registrations,
430
- registrationIndex,
431
- candidateIndex,
432
- recoveryCounts: recoveryCountsAfter(current, registrationIndex, [storageKey]),
433
- },
434
- ];
435
- },
436
- ),
437
- ).pipe(Effect.flatMap(Effect.fromResult));
438
-
439
- yield* failpoint.hit("subscription:change:after");
440
-
441
- return updated;
442
- },
443
- );
523
+ ...current,
524
+ sequence: revised.ordinal,
525
+ registrations,
526
+ registrationIndex,
527
+ candidateIndex,
528
+ ...registrationExpiriesAfter(current, existing.success, revised),
529
+ recoveryCounts: recoveryCountsAfter(current, registrationIndex, [storageKey]),
530
+ },
531
+ ];
532
+ },
533
+ ),
534
+ ).pipe(Effect.flatMap(Effect.fromResult));
535
+
536
+ yield* failpoint.hit("subscription:change:after");
537
+
538
+ return updated;
539
+ });
444
540
 
445
541
  const cancel: SubscriptionStore["Service"]["cancel"] = Effect.fnUntraced(
446
542
  function* (input, expectedRevision) {
@@ -518,6 +614,7 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
518
614
  ...current,
519
615
  registrations,
520
616
  registrationIndex,
617
+ ...registrationExpiriesAfter(current, decoded.success, cancelled),
521
618
  recoveryCounts: recoveryCountsAfter(current, registrationIndex, [storageKey]),
522
619
  },
523
620
  ];
@@ -570,7 +667,7 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
570
667
  if (Result.isFailure(horizon)) return [Result.fail(horizon.failure), current];
571
668
  if (jsonBytes(event.payload) > limits.maxPayloadBytes)
572
669
  return [Result.fail(error("capacity", "payload-bytes")), current];
573
- if (current.events.size - current.tombstoneCount >= limits.maxEvents)
670
+ if (current.pendingEventCount >= limits.maxEvents)
574
671
  return [Result.fail(error("capacity", "events")), current];
575
672
 
576
673
  const accepted: AcceptedEvent = {
@@ -601,6 +698,7 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
601
698
  sequence: accepted.cutoff,
602
699
  events,
603
700
  eventIndex,
701
+ pendingEventCount: current.pendingEventCount + 1,
604
702
  eventKeys: insertKey(current.eventKeys, accepted.eventId),
605
703
  retentionHorizon: limits.retention?.replayHorizonMillis ?? current.retentionHorizon,
606
704
  retentionDeadline:
@@ -681,6 +779,11 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
681
779
  "select-deliveries",
682
780
  );
683
781
 
782
+ if (
783
+ new Set(deliveries.map((delivery) => subscriptionDeliveryKeyString(delivery.key))).size !==
784
+ deliveries.length
785
+ )
786
+ return yield* error("validation", "duplicate-delivery");
684
787
  const limits = yield* validate(SubscriptionLimits, inputLimits, "select-limits");
685
788
 
686
789
  yield* requirePartition(suppliedEvent, "select-partition");
@@ -724,6 +827,11 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
724
827
  const registrationUpdates: Array<readonly [string, RegistrationIndex]> = [];
725
828
  const owners = new Map(current.ownerDeliveryCounts);
726
829
 
830
+ let registrationCapacity: RegistrationCapacity = {
831
+ registrationExpiries: current.registrationExpiries,
832
+ ownerRegistrationExpiries: current.ownerRegistrationExpiries,
833
+ };
834
+
727
835
  for (const delivery of deliveries) {
728
836
  const recordText = current.registrations.get(
729
837
  subscriptionKeyString(delivery.key.subscription),
@@ -800,9 +908,14 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
800
908
  consumedKey,
801
909
  { ...indexed, state: "consumed", recoveryAt: null, recoveryKey: null },
802
910
  ]);
911
+ registrationCapacity = registrationExpiriesAfter(
912
+ registrationCapacity,
913
+ record.success,
914
+ consumed,
915
+ );
803
916
  }
804
917
  }
805
- if (current.deliveries.size + additions.length > limits.maxDeliveries)
918
+ if (current.liveDeliveryCount + additions.length > limits.maxDeliveries)
806
919
  return [Result.fail(error("capacity", "deliveries")), current];
807
920
  const registrations = new Map(current.registrations);
808
921
 
@@ -859,12 +972,15 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
859
972
  events,
860
973
  eventIndex,
861
974
  deliveries: nextDeliveries,
975
+ liveDeliveryCount: current.liveDeliveryCount + additions.length,
976
+ pendingEventCount: current.pendingEventCount - Number(complete),
862
977
  deliveryKeys: additions.reduce(
863
978
  (keys, [key]) => insertKey(keys, key),
864
979
  current.deliveryKeys,
865
980
  ),
866
981
  deliveryIndex,
867
982
  ownerDeliveryCounts: owners,
983
+ ...registrationCapacity,
868
984
  },
869
985
  ];
870
986
  },
@@ -926,7 +1042,7 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
926
1042
  }
927
1043
  if (!subscriptionCanSelect(record.success, accepted.success, effectiveNowMillis, true))
928
1044
  return [Result.fail(error("conflict", "catch-up-eligibility")), current];
929
- if (current.deliveries.size >= limits.maxDeliveries)
1045
+ if (current.liveDeliveryCount >= limits.maxDeliveries)
930
1046
  return [Result.fail(error("capacity", "deliveries")), current];
931
1047
  const ownerCount = current.ownerDeliveryCounts.get(record.success.key.ownerId) ?? 0;
932
1048
 
@@ -994,6 +1110,8 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
994
1110
  deliveryIndex,
995
1111
  deliveryKeys: insertKey(current.deliveryKeys, key),
996
1112
  ownerDeliveryCounts,
1113
+ ...registrationExpiriesAfter(current, record.success, consumed),
1114
+ liveDeliveryCount: current.liveDeliveryCount + 1,
997
1115
  registrations,
998
1116
  registrationIndex,
999
1117
  recoveryCounts: recoveryCountsAfter(current, registrationIndex, [consumedKey]),
@@ -1106,10 +1224,11 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
1106
1224
  );
1107
1225
 
1108
1226
  const changeDelivery: SubscriptionStore["Service"]["changeDelivery"] = Effect.fnUntraced(
1109
- function* (inputKey, inputDeliveryId, inputChange) {
1227
+ function* (inputKey, inputDeliveryId, inputChange, inputLimits = defaultSubscriptionLimits) {
1110
1228
  const key = yield* validate(SubscriptionDeliveryKey, inputKey, "change-delivery-key");
1111
1229
  const deliveryId = yield* validate(Digest, inputDeliveryId, "change-delivery-id");
1112
1230
  const change = yield* validate(DeliveryChange, inputChange, "change-delivery-change");
1231
+ const limits = yield* validate(SubscriptionLimits, inputLimits, "change-delivery-limits");
1113
1232
 
1114
1233
  yield* requirePartition(key.subscription, "change-delivery-partition");
1115
1234
  yield* failpoint.hit(`subscription:delivery-${change._tag.toLowerCase()}:before`);
@@ -1162,6 +1281,20 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
1162
1281
  if (transition.success === decoded.success)
1163
1282
  return [Result.succeed(decoded.success), current];
1164
1283
  const updated = transition.success;
1284
+
1285
+ const capacityDelta =
1286
+ Number(subscriptionDeliveryUsesCapacity(updated)) -
1287
+ Number(subscriptionDeliveryUsesCapacity(decoded.success));
1288
+
1289
+ const ownerId = key.subscription.ownerId;
1290
+ const ownerCount = current.ownerDeliveryCounts.get(ownerId) ?? 0;
1291
+
1292
+ if (capacityDelta > 0) {
1293
+ if (current.liveDeliveryCount >= limits.maxDeliveries)
1294
+ return [Result.fail(error("capacity", "deliveries")), current];
1295
+ if (ownerCount >= limits.maxDeliveriesPerOwner)
1296
+ return [Result.fail(error("capacity", "owner-deliveries")), current];
1297
+ }
1165
1298
  const encoded = encode(SubscriptionDelivery, updated, "change-delivery-encode");
1166
1299
 
1167
1300
  if (Result.isFailure(encoded)) return [Result.fail(encoded.failure), current];
@@ -1180,8 +1313,21 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
1180
1313
  observeSettlement: updated.observeSettlement ?? false,
1181
1314
  nextAttemptAtMillis: updated.retry.nextAttemptAtMillis,
1182
1315
  });
1316
+ const ownerDeliveryCounts = new Map(current.ownerDeliveryCounts);
1317
+
1318
+ if (ownerCount + capacityDelta === 0) ownerDeliveryCounts.delete(ownerId);
1319
+ else ownerDeliveryCounts.set(ownerId, ownerCount + capacityDelta);
1183
1320
 
1184
- return [Result.succeed(updated), { ...current, deliveries, deliveryIndex }];
1321
+ return [
1322
+ Result.succeed(updated),
1323
+ {
1324
+ ...current,
1325
+ deliveries,
1326
+ deliveryIndex,
1327
+ ownerDeliveryCounts,
1328
+ liveDeliveryCount: current.liveDeliveryCount + capacityDelta,
1329
+ },
1330
+ ];
1185
1331
  },
1186
1332
  ),
1187
1333
  ).pipe(Effect.flatMap(Effect.fromResult));
@@ -1350,6 +1496,7 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
1350
1496
  const deliveryIndex = new Map(current.deliveryIndex);
1351
1497
  const eventDeliveryCounts = new Map(current.eventDeliveryCounts);
1352
1498
  const ownerDeliveryCounts = new Map(current.ownerDeliveryCounts);
1499
+ let liveDeliveryCount = current.liveDeliveryCount;
1353
1500
 
1354
1501
  // Page indexes before decoding. Each pass reads at most 2 * limit event values and
1355
1502
  // limit delivery values; relationship checks use maintained reference counts.
@@ -1432,9 +1579,14 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
1432
1579
  event.eventId,
1433
1580
  (eventDeliveryCounts.get(event.eventId) ?? 1) - 1,
1434
1581
  );
1435
- const owner = delivery.key.subscription.ownerId;
1582
+ if (subscriptionDeliveryUsesCapacity(delivery)) {
1583
+ liveDeliveryCount--;
1584
+ const owner = delivery.key.subscription.ownerId;
1585
+ const count = (ownerDeliveryCounts.get(owner) ?? 1) - 1;
1436
1586
 
1437
- ownerDeliveryCounts.set(owner, (ownerDeliveryCounts.get(owner) ?? 1) - 1);
1587
+ if (count === 0) ownerDeliveryCounts.delete(owner);
1588
+ else ownerDeliveryCounts.set(owner, count);
1589
+ }
1438
1590
  }
1439
1591
  let tombstones = current.tombstoneCount;
1440
1592
 
@@ -1497,6 +1649,7 @@ const makeMemorySubscriptionStore = Effect.fnUntraced(function* (ownedPartition:
1497
1649
  deliveryIndex,
1498
1650
  eventDeliveryCounts,
1499
1651
  ownerDeliveryCounts,
1652
+ liveDeliveryCount,
1500
1653
  eventKeys: removeKeys(current.eventKeys, removedEvents),
1501
1654
  deliveryKeys: removeKeys(current.deliveryKeys, removedDeliveries),
1502
1655
  tombstoneCount: tombstones,
@@ -185,7 +185,6 @@ interface StoredThread {
185
185
  readonly applicationInputs: Array<CanonicalRecordEnvelope>;
186
186
  readonly durableOwners: Array<CanonicalRecordEnvelope>;
187
187
  readonly windowBoundaries: Map<string, Array<CanonicalRecordEnvelope>>;
188
- readonly peerCount: number;
189
188
  readonly workerRecords: Map<string, Array<CanonicalRecordEnvelope>>;
190
189
  readonly byId: Map<string, CanonicalRecordEnvelope>;
191
190
  readonly runInputs: Map<string, CanonicalRecordEnvelope | null>;
@@ -274,7 +273,6 @@ type NativeIndexes = Pick<
274
273
  | "applicationInputs"
275
274
  | "durableOwners"
276
275
  | "windowBoundaries"
277
- | "peerCount"
278
276
  | "workerRecords"
279
277
  | "byId"
280
278
  | "runInputs"
@@ -292,7 +290,6 @@ const emptyIndexes = (): NativeIndexes => ({
292
290
  applicationInputs: [],
293
291
  durableOwners: [],
294
292
  windowBoundaries: new Map(),
295
- peerCount: 0,
296
293
  workerRecords: new Map(),
297
294
  byId: new Map(),
298
295
  runInputs: new Map(),
@@ -516,7 +513,6 @@ const prepareIndexes = (
516
513
  previous: NativeIndexes,
517
514
  records: ReadonlyArray<CanonicalRecordEnvelope>,
518
515
  ) => {
519
- let peerCount = previous.peerCount;
520
516
  // Latest execution entries are replaced in this private map; owner histories only append.
521
517
  // Rebuild staging is private: reuse it only in the synchronous cursor publication step.
522
518
  const workerRecords = new Map<string, Array<CanonicalRecordEnvelope>>();
@@ -624,7 +620,6 @@ const prepareIndexes = (
624
620
  );
625
621
  }
626
622
 
627
- if (payload._tag === "PeerMessagePrepared") peerCount++;
628
623
  if (
629
624
  isWorker &&
630
625
  (payload._tag === "UserInputRecorded" || payload._tag === "SubmissionSettled") &&
@@ -691,7 +686,7 @@ const prepareIndexes = (
691
686
  }
692
687
 
693
688
  return {
694
- indexes: { ...previous, peerCount },
689
+ indexes: previous,
695
690
  commit: () => {
696
691
  commitIndex(previous.runRecords, runRecords);
697
692
  commitIndex(previous.operationRecords, operationAppends);
@@ -1154,7 +1149,6 @@ const makeThreadStore = Effect.gen(function* () {
1154
1149
  tailSequence: ZERO_CANONICAL_SEQUENCE,
1155
1150
  byId: new Map(),
1156
1151
  workerRecords: new Map(),
1157
- peerCount: 0,
1158
1152
  runInputs: new Map(),
1159
1153
  runRecords: new Map(),
1160
1154
  operationRecords: new Map(),
@@ -1527,7 +1521,7 @@ const makeThreadStore = Effect.gen(function* () {
1527
1521
 
1528
1522
  return Math.min(
1529
1523
  index.foreign.peer +
1530
- (deliveryTransfer?.pendingPeerCount(request.threadId, request.limit) ?? 0),
1524
+ (deliveryTransfer?.peerCount(request.threadId, request.nowMillis, request.limit) ?? 0),
1531
1525
  request.limit,
1532
1526
  );
1533
1527
  });
@@ -95,7 +95,7 @@ export interface MemoryDeliveryTransfer {
95
95
  readonly startImport: (
96
96
  threadId: ThreadId,
97
97
  ) => Effect.Effect<MemoryDeliveryImport, ThreadImportRejected | ThreadStoreError>;
98
- readonly pendingPeerCount: (threadId: ThreadId, limit: number) => number;
98
+ readonly peerCount: (threadId: ThreadId, nowMillis: number, limit: number) => number;
99
99
  }
100
100
 
101
101
  /** Native control metadata only; callers already hold the journal's shared gate. */