@effect-agent/platform-cloudflare 0.1.0-beta.114 → 0.1.0-beta.116

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/src/Alarm.ts CHANGED
@@ -9,7 +9,6 @@ import {
9
9
  Fiber,
10
10
  Layer,
11
11
  Option,
12
- PubSub,
13
12
  Random,
14
13
  Ref,
15
14
  Result,
@@ -286,116 +285,56 @@ export class ThreadPublication extends Context.Service<
286
285
  });
287
286
  }
288
287
 
289
- /** Event-local accounting shared by the existing maintenance pumps and native scheduler. */
290
- export class ThreadMaintenanceActivity extends Context.Service<
291
- ThreadMaintenanceActivity,
292
- {
293
- /**
294
- * Account for one finite selection/dispatch, through joined completion or interruption.
295
- * Register before reading/claiming work. After dispatch closes the body is not started.
296
- * Host waves retain their declared allowance from this call; message Claims keep the driver's
297
- * own deadline. Waiting for notifications belongs outside this bracket.
298
- */
299
- readonly run: <R>(
300
- wave: Effect.Effect<void, DurableAlarmError, R>,
301
- ) => Effect.Effect<void, DurableAlarmError, R>;
302
- /** Acknowledge all initial subscriptions/scans. Composite hosts use `all` below. */
303
- readonly ready: Effect.Effect<void>;
304
- /**
305
- * Acquire an independent scoped subscription before the initial scan and `ready`.
306
- * The returned wait observes coalesced native scan hints, including those published before
307
- * its first execution. Each hint requests a current local due-work check.
308
- */
309
- readonly subscribeChanges: Effect.Effect<Effect.Effect<void>, never, Scope.Scope>;
310
- }
311
- >()("@effect-agent/platform-cloudflare/ThreadMaintenanceActivity") {
312
- /** Join child pumps; acknowledge the parent only after every child is ready or has exited. */
313
- static readonly all = Effect.fnUntraced(function* <A, E, R>(
314
- lanes: ReadonlyArray<Effect.Effect<A, E, R>>,
315
- ): Effect.fn.Return<ReadonlyArray<A>, E, R | ThreadMaintenanceActivity> {
316
- const activity = yield* ThreadMaintenanceActivity;
317
- let remaining = lanes.length;
318
-
319
- if (remaining === 0) return yield* activity.ready.pipe(Effect.as([]));
320
-
321
- return yield* Effect.forEach(
322
- lanes,
323
- (lane) => {
324
- let ready = false;
325
-
326
- const child: ThreadMaintenanceActivity["Service"] = {
327
- ...activity,
328
- ready: Effect.suspend(() => {
329
- if (ready) return Effect.void;
330
- ready = true;
331
-
332
- return --remaining === 0 ? activity.ready : Effect.void;
333
- }).pipe(Effect.uninterruptible),
334
- };
335
-
336
- // A failed initial setup is accounted for without swallowing its Cause. Callers
337
- // may collect Exits when sibling pumps must keep their independent opportunities.
338
- return lane.pipe(
339
- Effect.provideService(ThreadMaintenanceActivity, child),
340
- Effect.onExit(() => child.ready),
341
- );
342
- },
343
- { concurrency: "unbounded" },
344
- );
345
- });
346
- }
347
-
348
288
  /**
349
- * Host-assembled native message recovery. The driver bounds each actual Claim and persists its
350
- * timeout/retry before this pump returns. Do not add a second timer starting at batch selection:
351
- * local Claim setup may take time, and expiration still owes the driver's local retry commit.
289
+ * Host-assembled native message recovery. Preparation is a bounded local selection; the
290
+ * driver bounds each actual Claim and persists its timeout/retry before `run` returns.
291
+ * The alarm must not add a timer starting at selection: Claim setup and retry commits belong
292
+ * to the driver. The prepared allowance decides only whether a wave fits this event.
352
293
  */
353
294
  export const ThreadMessageDelivery = Context.Reference<{
354
- readonly drainUntil: (
355
- dispatchClosed: Effect.Effect<void>,
356
- dispatchUntil: DateTime.Utc,
357
- ) => Effect.Effect<void, DurableAlarmError, Scope.Scope | ThreadMaintenanceActivity>;
295
+ readonly prepare: Effect.Effect<
296
+ {
297
+ readonly timeoutMillis: number;
298
+ readonly run: Effect.Effect<void, DurableAlarmError>;
299
+ },
300
+ DurableAlarmError
301
+ >;
358
302
  readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
359
303
  }>("@effect-agent/platform-cloudflare/ThreadMessageDelivery", {
360
304
  defaultValue: () => ({
361
- drainUntil: () => Effect.void,
305
+ prepare: Effect.succeed({ timeoutMillis: 1, run: Effect.void }),
362
306
  pendingDeadline: Effect.succeed(Option.none()),
363
307
  }),
364
308
  });
365
309
 
366
310
  /**
367
- * Application obligations sharing this Object's alarm. Admit one initial external wave even on
368
- * a caught-up pass, then respond to wakes and ThreadMaintenanceActivity.subscribeChanges until
369
- * dispatchClosed. Yield the event's ThreadMaintenanceActivity to bracket each finite lane with
370
- * run and acknowledge initial setup with ready.
371
- * Passive subscriptions do not count as active work. Closure stops both new waves and native
372
- * Attempts; local admission/control subscriptions belong to the event Scope until teardown.
373
- * No deadline sleeps or automatic retry loops. Return after already-admitted waves finish.
311
+ * One independently schedulable application obligation. `run` selects and joins one finite
312
+ * wave, including its retry/receipt commits. The alarm gives every lane an initial opportunity,
313
+ * then checks its local pending deadline on completion, native progress, wakes and bounded scans.
314
+ * No subscriptions, readiness acknowledgements, deadline sleeps or dispatch loops are needed.
374
315
  *
375
- * Declare a finite whole-wave allowance (1..300000ms): maximum for parallel lanes, sum for
376
- * sequential operations. Admit a wave only if its allowance fits before dispatchUntil. Later
377
- * arrivals cannot renew an admitted wave's allowance. Maintenance bounds each registered wave,
378
- * interrupts and joins event Scope, then reads local deadlines under the mutation gate.
316
+ * Declare a whole-wave allowance (1..300000ms), including selection, local commits and cleanup. A wave
317
+ * starts only if that allowance fits the event and later arrivals cannot renew it. A failed
318
+ * lane is not retried within the event. The alarm joins admitted work and closes its Scope
319
+ * before reading durable deadlines under the shared ThreadMutationGate.
379
320
  *
380
- * Setup and pendingDeadline are bounded local operations. Persist claims/envelopes before
381
- * dispatch; interrupted waits leave exact retries/receipts recoverable. Cancellation is local,
382
- * not remote rollback. Retry accounting is unchanged. Network waits/finalizers must be
383
- * interruptible; short local atomic commits may be uninterruptible. Hooks never write the raw
384
- * alarm slot. Required native publication gates belong to ThreadPublication.
321
+ * `pendingDeadline` is bounded local control state, never a retained-history scan. Persist
322
+ * envelopes/claims before dispatch; cancellation is local, not remote rollback. Network waits
323
+ * and finalizers must be interruptible; short atomic commits may be uninterruptible. Mutations
324
+ * use the shared gate and producers notify the existing WakeScheduler after commit. Hooks never
325
+ * write the raw alarm slot. Required native publication gates belong to ThreadPublication.
385
326
  */
386
- export const ThreadHostMaintenance = Context.Reference<{
327
+ export interface ThreadHostMaintenanceLane {
387
328
  readonly dispatchTimeoutMillis: number;
388
- readonly drainUntil: (
389
- dispatchClosed: Effect.Effect<void>,
390
- dispatchUntil: DateTime.Utc,
391
- ) => Effect.Effect<void, DurableAlarmError, Scope.Scope | ThreadMaintenanceActivity>;
329
+ readonly run: Effect.Effect<void, DurableAlarmError, Scope.Scope>;
392
330
  readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
331
+ }
332
+
333
+ /** Independent lanes share the existing alarm; compose hosts by concatenating their lanes. */
334
+ export const ThreadHostMaintenance = Context.Reference<{
335
+ readonly lanes: ReadonlyArray<ThreadHostMaintenanceLane>;
393
336
  }>("@effect-agent/platform-cloudflare/ThreadHostMaintenance", {
394
- defaultValue: () => ({
395
- dispatchTimeoutMillis: 1,
396
- drainUntil: () => Effect.void,
397
- pendingDeadline: Effect.succeed(Option.none()),
398
- }),
337
+ defaultValue: () => ({ lanes: [] }),
399
338
  });
400
339
 
401
340
  const earliestDeadline = (
@@ -687,7 +626,7 @@ export type MaintenancePassFailure =
687
626
  * retain their durable backoff.
688
627
  * 3. Keep native and delivery admission open together while finite waves remain active, so
689
628
  * fresh replies and abort controls can progress during unrelated cleanup. Close atomically
690
- * at quiescence, or at the original ten-minute yield deadline, before retiring listeners.
629
+ * at quiescence, or at the original ten-minute yield deadline, then join admitted waves.
691
630
  * 4. Native message delivery retains its driver-owned Claim deadline. Host/backfill waves are
692
631
  * bounded independently; incoming native work never restarts or cancels their attempts.
693
632
  * Auxiliary failures are reported after the current native opportunity.
@@ -770,7 +709,13 @@ export class ThreadMaintenance extends Context.Service<
770
709
  const pendingDeadline = Effect.gen(function* () {
771
710
  return earliestDeadline(
772
711
  earliestDeadline(yield* publication.pendingDeadline, yield* messages.pendingDeadline),
773
- earliestDeadline(yield* projectionDeadline, yield* host.pendingDeadline),
712
+ earliestDeadline(
713
+ yield* projectionDeadline,
714
+ (yield* Effect.forEach(host.lanes, (lane) => lane.pendingDeadline)).reduce(
715
+ earliestDeadline,
716
+ Option.none<number>(),
717
+ ),
718
+ ),
774
719
  );
775
720
  });
776
721
 
@@ -1451,88 +1396,27 @@ export class ThreadMaintenance extends Context.Service<
1451
1396
  // Its completion must retain the original actionable observation for acknowledgement.
1452
1397
  const started = yield* beginNative(observed);
1453
1398
 
1454
- // This scope owns auxiliary dispatch and listeners, independently of native progress.
1399
+ // This scope owns every admitted finite wave, including native advancement.
1455
1400
  // Close it before final alarm rearming, including on failure or event interruption.
1456
1401
  const auxiliaryScope = yield* Effect.acquireRelease(Scope.make("parallel"), (scope, exit) =>
1457
1402
  Scope.close(scope, exit),
1458
1403
  );
1459
1404
 
1460
- const dispatchClosed = yield* Deferred.make<void>();
1461
- const stopDispatch = Deferred.await(dispatchClosed);
1462
- // Coalesce local scan hints while a lane is busy; one hint requests a current read.
1463
- const checks = yield* PubSub.sliding<void>(1);
1464
-
1465
- yield* Effect.addFinalizer(() => PubSub.shutdown(checks));
1466
- let dispatchOpen = true;
1467
- let activityChanged = yield* Deferred.make<void>();
1468
- const signalActivity = Effect.suspend(() => Deferred.succeed(activityChanged, undefined));
1469
-
1470
- const closeDispatch = Effect.sync(() => {
1471
- dispatchOpen = false;
1472
- }).pipe(Effect.andThen(Deferred.succeed(dispatchClosed, undefined)));
1473
-
1474
- const makeActivity = Effect.fnUntraced(function* (allowance?: number) {
1475
- const initialized = yield* Deferred.make<void>();
1476
- let active = 0;
1477
- let failed = false;
1478
-
1479
- const ready = Deferred.succeed(initialized, undefined).pipe(
1480
- Effect.andThen(signalActivity),
1481
- Effect.asVoid,
1482
- Effect.uninterruptible,
1483
- );
1484
-
1485
- const activity: ThreadMaintenanceActivity["Service"] = {
1486
- ready,
1487
- subscribeChanges: PubSub.subscribe(checks).pipe(Effect.map(PubSub.take)),
1488
- run: (wave) =>
1489
- Effect.acquireUseRelease(
1490
- Effect.sync(() => {
1491
- if (!dispatchOpen) return false;
1492
- active++;
1493
-
1494
- return true;
1495
- }),
1496
- (admitted) =>
1497
- !admitted
1498
- ? Effect.void
1499
- : (allowance === undefined
1500
- ? wave
1501
- : wave.pipe(
1502
- Effect.timeoutOrElse({
1503
- duration: allowance,
1504
- orElse: () =>
1505
- DurableAlarmError.make({
1506
- operation: "host dispatch allowance",
1507
- message:
1508
- "The admitted host wave exceeded its allowance; durable work remains pending",
1509
- }),
1510
- }),
1511
- )
1512
- ).pipe(
1513
- Effect.onExit((exit) =>
1514
- Effect.sync(() => {
1515
- if (Exit.isFailure(exit)) failed = true;
1516
- }),
1517
- ),
1518
- ),
1519
- (admitted) =>
1520
- Effect.sync(() => {
1521
- if (admitted) active--;
1522
- }).pipe(Effect.andThen(signalActivity)),
1523
- ),
1524
- };
1405
+ const fork = <A, E>(work: Effect.Effect<A, E>) => Effect.forkIn(work, auxiliaryScope);
1525
1406
 
1526
- return {
1527
- activity,
1528
- initialized: Deferred.await(initialized),
1529
- quiet: () => active === 0 && Deferred.isDoneUnsafe(initialized),
1530
- failed: () => failed,
1531
- };
1532
- });
1407
+ // Each lane has at most one finite wave. A completion is retained until the single
1408
+ // scheduling loop observes it; a busy sibling cannot consume another lane's hint.
1409
+ const lanes = host.lanes.map((lane) => ({
1410
+ ...lane,
1411
+ initial: true,
1412
+ check: true,
1413
+ exhausted: false,
1414
+ fiber: undefined as Fiber.Fiber<boolean, DurableAlarmError> | undefined,
1415
+ }));
1533
1416
 
1534
- const messageActivity = yield* makeActivity();
1535
- const hostActivity = yield* makeActivity(host.dispatchTimeoutMillis);
1417
+ let messageExhausted = false;
1418
+ let delivery: Fiber.Fiber<void, DurableAlarmError> | undefined;
1419
+ let failure: Cause.Cause<DurableAlarmError | ThreadProjectionError> | undefined;
1536
1420
 
1537
1421
  const recovery: NativeRecovery = {
1538
1422
  queue: yield* Deferred.make<ReadonlyArray<ThreadId>>(),
@@ -1546,7 +1430,7 @@ export class ThreadMaintenance extends Context.Service<
1546
1430
  repaired: false,
1547
1431
  };
1548
1432
 
1549
- const recoveryFiber = yield* Effect.forkIn(
1433
+ const recoveryFiber = yield* fork(
1550
1434
  Effect.gen(function* () {
1551
1435
  for (const threadId of yield* Deferred.await(recovery.queue)) {
1552
1436
  yield* failpoint.hit("maintenance:select:before");
@@ -1568,210 +1452,264 @@ export class ThreadMaintenance extends Context.Service<
1568
1452
  yield* wakes.notify(threadId);
1569
1453
  }
1570
1454
  }),
1571
- auxiliaryScope,
1572
- );
1573
-
1574
- // Fork setup too: an ordinary auxiliary setup failure is reported after native work,
1575
- // rather than gating its opportunity. Event interruption still closes every fiber.
1576
- const deliveryFiber = yield* Effect.forkIn(
1577
- Scope.provide(auxiliaryScope)(
1578
- messages.drainUntil(stopDispatch, dispatchUntil).pipe(
1579
- Effect.provideService(ThreadMaintenanceActivity, messageActivity.activity),
1580
- Effect.onExit(() => messageActivity.activity.ready),
1581
- ),
1582
- ),
1583
- auxiliaryScope,
1584
- );
1585
-
1586
- const hostFiber = yield* Effect.forkIn(
1587
- Scope.provide(auxiliaryScope)(
1588
- Effect.gen(function* () {
1589
- yield* Schema.decodeEffect(AuxiliaryDispatchMillis)(host.dispatchTimeoutMillis).pipe(
1590
- Effect.mapError((cause) =>
1591
- DurableAlarmError.make({
1592
- operation: "host dispatch allowance",
1593
- message:
1594
- "Declare an integer whole-wave allowance between 1 and 300000 milliseconds",
1595
- cause,
1596
- }),
1597
- ),
1598
- );
1599
-
1600
- // Initial setup must also be finite. Once it is accounted for, each actual
1601
- // wave has its own unchanged allowance; passive listeners have no timer.
1602
- const initialization = hostActivity.initialized.pipe(
1603
- Effect.timeoutOrElse({
1604
- duration: host.dispatchTimeoutMillis,
1605
- orElse: () =>
1606
- DurableAlarmError.make({
1607
- operation: "host initialization",
1608
- message:
1609
- "The host did not account for initial maintenance before its allowance; durable work remains pending",
1610
- }),
1611
- }),
1612
- Effect.andThen(Effect.never),
1613
- );
1614
-
1615
- yield* Effect.raceFirst(
1616
- host
1617
- .drainUntil(stopDispatch, dispatchUntil)
1618
- .pipe(Effect.provideService(ThreadMaintenanceActivity, hostActivity.activity)),
1619
- initialization,
1620
- );
1621
- }).pipe(Effect.onExit(() => hostActivity.activity.ready)),
1622
- ),
1623
- auxiliaryScope,
1624
1455
  );
1625
1456
 
1626
- // Backfill is one disposable wave; its timer starts beside native execution.
1627
- const backfill = yield* Effect.forkIn(
1457
+ // Backfill is one disposable wave; native and host work keep their own opportunities.
1458
+ const backfill = yield* fork(
1628
1459
  drainDue.pipe(
1629
1460
  Effect.provideService(ThreadProjectionMaintenance, projection),
1630
1461
  Effect.timeoutOption(config.projectionDispatchTimeoutMillis),
1631
- Effect.onExit(() => signalActivity),
1632
1462
  ),
1633
- auxiliaryScope,
1634
1463
  );
1635
1464
 
1636
- let result = yield* advance(started, yieldAfter, observed, recovery);
1465
+ let backfillObserved = false;
1466
+ let recoveryObserved = false;
1467
+
1468
+ let native: Fiber.Fiber<NativePassResult, MaintenancePassFailure> | undefined = yield* fork(
1469
+ advance(started, yieldAfter, observed, recovery),
1470
+ );
1471
+
1472
+ let result: NativePassResult = {
1473
+ phase: "caught-up",
1474
+ settled: 0,
1475
+ nonterminal: started.nonterminal,
1476
+ nextAttemptAt: undefined,
1477
+ };
1637
1478
 
1638
- // This event owns one finite old-recovery wave, including an empty caught-up wave.
1639
- recovery.started = true;
1640
- yield* Deferred.succeed(recovery.queue, []);
1641
1479
  let phase = result.phase;
1642
- let settled = result.settled;
1480
+ let settled = 0;
1481
+ let nativeCheck = false;
1482
+ const until = DateTime.toEpochMillis(yieldAfter);
1483
+ const dispatchEnd = DateTime.toEpochMillis(dispatchUntil);
1484
+
1485
+ const checkLanes = () => {
1486
+ for (const lane of lanes) lane.check = true;
1487
+ };
1643
1488
 
1644
- observed.nativeOnly = false;
1489
+ while (true) {
1490
+ if (native?.pollUnsafe() !== undefined) {
1491
+ result = yield* Fiber.join(native);
1492
+ native = undefined;
1493
+ if (result.phase === "actionable") phase = "actionable";
1494
+ settled += result.settled;
1495
+ observed.nativeOnly = false;
1496
+ // An empty initial scan still opens exactly one old-recovery opportunity.
1497
+ recovery.started = true;
1498
+ yield* Deferred.succeed(recovery.queue, []);
1499
+ checkLanes();
1500
+ }
1501
+ if (!recoveryObserved && recoveryFiber.pollUnsafe() !== undefined) {
1502
+ yield* Fiber.join(recoveryFiber);
1503
+ recoveryObserved = true;
1504
+ nativeCheck = recovery.needsCheckpoint;
1505
+ }
1506
+ if (!backfillObserved) {
1507
+ const exit = backfill.pollUnsafe();
1645
1508
 
1646
- // Pump termination and quiescence are distinct: listeners await closure, while their
1647
- // finite waves report activity. A held sibling never retires another lane's controls.
1648
- const hostJoin = yield* Effect.forkIn(
1649
- Effect.gen(function* () {
1650
- yield* stopDispatch;
1509
+ if (exit !== undefined) {
1510
+ backfillObserved = true;
1511
+ if (Exit.isFailure(exit)) failure ??= exit.cause;
1512
+ }
1513
+ }
1514
+ for (const lane of lanes) {
1515
+ const exit = lane.fiber?.pollUnsafe();
1516
+
1517
+ if (exit === undefined) continue;
1518
+ lane.fiber = undefined;
1519
+ if (Exit.isFailure(exit)) {
1520
+ failure ??= exit.cause;
1521
+ lane.exhausted = true;
1522
+ } else {
1523
+ lane.check ||= exit.value;
1524
+ }
1525
+ }
1526
+ const deliveryExit = delivery?.pollUnsafe();
1651
1527
 
1652
- const remaining = Math.max(
1653
- 1,
1654
- DateTime.toEpochMillis(dispatchUntil) - (yield* Clock.currentTimeMillis),
1655
- );
1528
+ if (deliveryExit !== undefined) {
1529
+ delivery = undefined;
1530
+ if (Exit.isFailure(deliveryExit)) {
1531
+ failure ??= deliveryExit.cause;
1532
+ messageExhausted = true;
1533
+ }
1534
+ }
1656
1535
 
1657
- const outcome = yield* Fiber.join(hostFiber).pipe(
1658
- Effect.timeoutOption(Math.min(host.dispatchTimeoutMillis, remaining)),
1659
- );
1536
+ // Preserve the initial native opportunity even when auxiliary setup fails.
1537
+ if (failure !== undefined && native === undefined)
1538
+ return yield* Effect.failCause(failure);
1539
+ const now = yield* Clock.currentTimeMillis;
1660
1540
 
1661
- yield* Effect.annotateCurrentSpan({ "host.timedOut": Option.isNone(outcome) });
1662
- }),
1663
- auxiliaryScope,
1664
- );
1541
+ if (now >= until) break;
1665
1542
 
1666
- const retired = yield* Effect.forkChild(
1667
- Fiber.joinAll([deliveryFiber, hostJoin, backfill, recoveryFiber]),
1668
- );
1543
+ for (const lane of lanes) {
1544
+ if (lane.fiber !== undefined || lane.exhausted || !lane.check) continue;
1545
+ lane.check = false;
1546
+ lane.fiber = yield* fork(
1547
+ Effect.gen(function* () {
1548
+ yield* Schema.decodeEffect(AuxiliaryDispatchMillis)(
1549
+ lane.dispatchTimeoutMillis,
1550
+ ).pipe(
1551
+ Effect.mapError((cause) =>
1552
+ DurableAlarmError.make({
1553
+ operation: "host dispatch allowance",
1554
+ message:
1555
+ "Declare an integer whole-wave allowance between 1 and 300000 milliseconds",
1556
+ cause,
1557
+ }),
1558
+ ),
1559
+ );
1560
+ if ((yield* Clock.currentTimeMillis) + lane.dispatchTimeoutMillis > dispatchEnd) {
1561
+ lane.exhausted = true;
1669
1562
 
1670
- while (true) {
1671
- const recoveryFinished = recoveryFiber.pollUnsafe() !== undefined;
1672
- const retiredExit = retired.pollUnsafe();
1673
-
1674
- if (recoveryFinished) {
1675
- if (recovery.needsCheckpoint) {
1676
- yield* Fiber.join(recoveryFiber);
1677
- // Fold the original recovery observation into its checkpoint before closing.
1678
- result = yield* advance(started, yieldAfter, observed, recovery, settled === 0);
1679
- if (result.phase === "actionable") phase = "actionable";
1680
- settled += result.settled;
1681
- recovery.needsCheckpoint = false;
1682
- yield* PubSub.publish(checks, undefined);
1563
+ return false;
1564
+ }
1565
+
1566
+ return yield* Effect.gen(function* () {
1567
+ if (!lane.initial) {
1568
+ const deadline = yield* lane.pendingDeadline;
1569
+
1570
+ if (
1571
+ Option.isNone(deadline) ||
1572
+ deadline.value > (yield* Clock.currentTimeMillis)
1573
+ )
1574
+ return false;
1575
+ }
1576
+ const selectedAt = yield* Clock.currentTimeMillis;
1577
+
1578
+ if (
1579
+ selectedAt >= until ||
1580
+ selectedAt + lane.dispatchTimeoutMillis > dispatchEnd
1581
+ ) {
1582
+ lane.exhausted = true;
1583
+
1584
+ return false;
1585
+ }
1586
+ lane.initial = false;
1587
+ yield* lane.run;
1588
+
1589
+ return true;
1590
+ }).pipe(
1591
+ // Cleanup shares this wave's original allowance.
1592
+ Effect.scoped,
1593
+ Effect.timeoutOrElse({
1594
+ duration: lane.dispatchTimeoutMillis,
1595
+ orElse: () =>
1596
+ DurableAlarmError.make({
1597
+ operation: "host dispatch allowance",
1598
+ message:
1599
+ "The admitted host wave exceeded its allowance; durable work remains pending",
1600
+ }),
1601
+ }),
1602
+ );
1603
+ }),
1604
+ );
1605
+ }
1606
+ if (delivery === undefined && !messageExhausted) {
1607
+ // Selection is bounded local work. Fork only an actual due wave, so an empty
1608
+ // deadline check cannot extend retirement or consume another scheduling turn.
1609
+ const selected = yield* Effect.gen(function* () {
1610
+ const deadline = yield* messages.pendingDeadline;
1611
+
1612
+ return Option.isSome(deadline) && deadline.value <= (yield* Clock.currentTimeMillis)
1613
+ ? Option.some(yield* messages.prepare)
1614
+ : Option.none();
1615
+ }).pipe(Effect.exit);
1616
+
1617
+ if (Exit.isFailure(selected)) {
1618
+ failure ??= selected.cause;
1619
+ messageExhausted = true;
1620
+ } else if (Option.isSome(selected.value)) {
1621
+ const wave = selected.value.value;
1622
+ const selectedAt = yield* Clock.currentTimeMillis;
1623
+
1624
+ if (selectedAt >= until || selectedAt + wave.timeoutMillis > dispatchEnd) {
1625
+ messageExhausted = true;
1626
+ } else {
1627
+ // No second timeout: the driver owes the Claim's timeout/retry commit.
1628
+ delivery = yield* fork(wave.run);
1629
+ }
1683
1630
  }
1684
1631
  }
1685
- if (retiredExit !== undefined && Exit.isFailure(retiredExit)) break;
1686
1632
 
1633
+ // Retire a quiet event before consuming hints queued during its native Attempt.
1634
+ // Recovery still gets its checkpoint and, if needed, initial dispatch opportunity.
1687
1635
  if (
1688
- recoveryFinished &&
1689
- backfill.pollUnsafe() !== undefined &&
1690
- messageActivity.quiet() &&
1691
- hostActivity.quiet()
1692
- ) {
1693
- const now = yield* Clock.currentTimeMillis;
1694
- const messageDeadline = yield* messages.pendingDeadline;
1695
- const hostDeadline = yield* host.pendingDeadline;
1696
-
1697
- const messageDue =
1698
- deliveryFiber.pollUnsafe() === undefined &&
1699
- !messageActivity.failed() &&
1700
- Option.isSome(messageDeadline) &&
1701
- messageDeadline.value <= now;
1702
-
1703
- const hostDue =
1704
- hostFiber.pollUnsafe() === undefined &&
1705
- !hostActivity.failed() &&
1706
- now + host.dispatchTimeoutMillis <= DateTime.toEpochMillis(dispatchUntil) &&
1707
- Option.isSome(hostDeadline) &&
1708
- hostDeadline.value <= now;
1709
-
1710
- // No asynchronous operation separates the activity recheck from closure. A
1711
- // racing registration either keeps this window open or cannot start its body.
1712
- const closed = yield* Effect.sync(() => {
1713
- if (messageDue || hostDue || !messageActivity.quiet() || !hostActivity.quiet())
1714
- return false;
1715
-
1716
- dispatchOpen = false;
1717
-
1718
- return true;
1719
- });
1720
-
1721
- if (closed) break;
1722
- yield* PubSub.publish(checks, undefined);
1636
+ native === undefined &&
1637
+ recoveryObserved &&
1638
+ !recovery.needsCheckpoint &&
1639
+ backfillObserved &&
1640
+ delivery === undefined &&
1641
+ lanes.every((lane) => lane.fiber === undefined)
1642
+ )
1643
+ break;
1644
+
1645
+ if (native === undefined && nativeCheck) {
1646
+ nativeCheck = false;
1647
+ const checkpoint = recoveryObserved && recovery.needsCheckpoint;
1648
+
1649
+ if (checkpoint) recovery.needsCheckpoint = false;
1650
+ native = yield* fork(
1651
+ Effect.gen(function* () {
1652
+ const awakened = checkpoint ? started : yield* beginNative(observed);
1653
+
1654
+ return yield* advance(
1655
+ awakened,
1656
+ yieldAfter,
1657
+ observed,
1658
+ recovery,
1659
+ !checkpoint || settled === 0,
1660
+ );
1661
+ }),
1662
+ );
1723
1663
  }
1724
- const now = yield* Clock.currentTimeMillis;
1725
- const until = DateTime.toEpochMillis(yieldAfter);
1726
-
1727
- if (now >= until) break;
1728
1664
 
1729
1665
  const next = Math.min(
1730
- result.nextAttemptAt ?? Infinity,
1666
+ native === undefined ? (result.nextAttemptAt ?? Infinity) : Infinity,
1731
1667
  now + config.wakeScanInterval,
1732
1668
  until,
1733
1669
  );
1734
1670
 
1735
- // A completion racing this iteration stays armed until the loop handles it.
1736
- const recoveryDone = recoveryFinished ? Effect.never : Fiber.await(recoveryFiber);
1737
- const retirementDone = retiredExit === undefined ? Fiber.await(retired) : Effect.never;
1738
- const changed = activityChanged;
1739
-
1740
- const ready = yield* Effect.raceFirst(
1741
- Effect.raceFirst(notified, Effect.sleep(Math.max(0, next - now))).pipe(
1742
- Effect.as("native" as const),
1743
- ),
1744
- Effect.raceFirst(
1745
- recoveryDone.pipe(Effect.as("recovery" as const)),
1746
- Effect.raceFirst(
1747
- retirementDone.pipe(Effect.as("retired" as const)),
1748
- Deferred.await(changed).pipe(Effect.as("activity" as const)),
1671
+ const completing = [
1672
+ ...(native === undefined ? [] : [native]),
1673
+ ...(recoveryObserved ? [] : [recoveryFiber]),
1674
+ ...(backfillObserved ? [] : [backfill]),
1675
+ ...(delivery === undefined ? [] : [delivery]),
1676
+ ...lanes.flatMap((lane) => (lane.fiber === undefined ? [] : [lane.fiber])),
1677
+ ];
1678
+
1679
+ const ready = yield* Effect.raceAllFirst([
1680
+ ...completing.map((fiber) =>
1681
+ Fiber.await<unknown, MaintenancePassFailure>(fiber).pipe(
1682
+ Effect.as("completed" as const),
1749
1683
  ),
1750
1684
  ),
1751
- );
1752
-
1753
- if (ready === "recovery" || ready === "retired") continue;
1754
- if (ready === "activity") activityChanged = yield* Deferred.make<void>();
1755
- if ((yield* Clock.currentTimeMillis) >= until) break;
1756
- if (ready === "native") yield* PubSub.publish(checks, undefined);
1757
-
1758
- const awakened = yield* beginNative(observed);
1685
+ Effect.raceFirst(notified, Effect.sleep(Math.max(0, next - now))).pipe(
1686
+ Effect.as("scan" as const),
1687
+ ),
1688
+ ]);
1759
1689
 
1760
- result = yield* advance(awakened, yieldAfter, observed, recovery);
1690
+ if (ready === "scan") {
1691
+ nativeCheck = true;
1692
+ checkLanes();
1693
+ }
1694
+ }
1695
+ // Dispatch is closed. Join each admitted wave without renewing its allowance.
1696
+ // Message Claims retain their driver's own timer and local retry commit.
1697
+ if (native !== undefined) {
1698
+ result = yield* Fiber.join(native);
1761
1699
  if (result.phase === "actionable") phase = "actionable";
1762
1700
  settled += result.settled;
1763
1701
  observed.nativeOnly = false;
1764
- if (result.settled > 0) yield* PubSub.publish(checks, undefined);
1702
+ recovery.started = true;
1703
+ yield* Deferred.succeed(recovery.queue, []);
1765
1704
  }
1766
- // The original native yield deadline closes all new waves even if old recovery
1767
- // is still pending. No recovery or delivery receives a renewed event budget.
1768
- yield* closeDispatch;
1769
- // Preserve driver-owned Claim deadlines and failures, then close every
1770
- // listener before the one final alarm decision.
1771
- yield* Fiber.join(retired);
1705
+ yield* Fiber.joinAll([
1706
+ recoveryFiber,
1707
+ backfill,
1708
+ ...(delivery === undefined ? [] : [delivery]),
1709
+ ...lanes.flatMap((lane) => (lane.fiber === undefined ? [] : [lane.fiber])),
1710
+ ]);
1711
+ if (failure !== undefined) return yield* Effect.failCause(failure);
1772
1712
  if (recovery.needsCheckpoint) {
1773
- // The native yield deadline ended dispatch before old recovery finished. Fold its
1774
- // control state into acknowledgement without starting an Attempt after retirement.
1775
1713
  result = yield* advance(started, yieldAfter, observed, recovery, false);
1776
1714
  if (result.phase === "actionable") phase = "actionable";
1777
1715
  settled += result.settled;