@effect-agent/platform-cloudflare 0.1.0-beta.94 → 0.1.0-beta.96

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
@@ -12,8 +12,10 @@ import {
12
12
  Random,
13
13
  Ref,
14
14
  Schema,
15
+ Scope,
15
16
  Semaphore,
16
17
  Stream,
18
+ Struct,
17
19
  } from "effect";
18
20
  import { type DurableBindingFailure } from "effect-agent/agent-registration";
19
21
  import {
@@ -21,7 +23,7 @@ import {
21
23
  type DurableWorkerFailure,
22
24
  type RecoveryReport,
23
25
  } from "effect-agent/durable-agent-runtime";
24
- import { ThreadId } from "effect-agent/identifiers";
26
+ import { ThreadId, SubmissionId } from "effect-agent/identifiers";
25
27
  import { SubmissionLedger, type SubmissionSnapshot } from "effect-agent/submission-ledger";
26
28
  import {
27
29
  ThreadProjectionMaintenance,
@@ -31,7 +33,7 @@ import {
31
33
  import { SqlClient } from "effect/unstable/sql/SqlClient";
32
34
 
33
35
  import { DurableObjectContext } from "./CloudflareBindings.ts";
34
- import { CloudflareDurableRuntimeConfig } from "./CloudflareConfig.ts";
36
+ import { AuxiliaryDispatchMillis, CloudflareDurableRuntimeConfig } from "./CloudflareConfig.ts";
35
37
  import { safeCauseMessage } from "./internal/boundary.ts";
36
38
 
37
39
  /**
@@ -206,6 +208,10 @@ export type ThreadMaintenanceFailpointLocation =
206
208
  | "maintenance:begin:after"
207
209
  | "maintenance:select:before"
208
210
  | "maintenance:select:after"
211
+ | "maintenance:binding-retry:before"
212
+ | "maintenance:binding-retry:after"
213
+ | "maintenance:retry:before"
214
+ | "maintenance:retry:after"
209
215
  | "maintenance:finish:before"
210
216
  | "maintenance:finish:after";
211
217
 
@@ -231,6 +237,8 @@ export class ThreadMaintenanceFailpoint extends Context.Service<
231
237
  * `invalidate`, `prepareGeneration` and `pendingDeadline` must be bounded local operations.
232
238
  * `prepareGeneration` durably invalidates a scan only when its generation changes; repeated
233
239
  * calls must preserve partial scan progress. It runs with no source mutation in flight.
240
+ * Use this gate only for publication required before dependent native execution. Independent
241
+ * UI relays and outboxes belong to ThreadHostMaintenance.
234
242
  * `drain` performs bounded delivery and persists retries before returning. A pending deadline
235
243
  * defers runtime recovery/Attempts, allowing committed host publications to drain first.
236
244
  * Unexpected hook failures leave the prearmed generation for retry. Hooks acquire per-call
@@ -258,38 +266,53 @@ export class ThreadPublication extends Context.Service<
258
266
  }
259
267
 
260
268
  /**
261
- * Host-assembled message recovery, supplied by ThreadObject.layer even when the application
262
- * rebuilds ThreadMaintenance. These obligations outlive source Runs and never defer a ready
263
- * source Attempt while a destination is processing an accepted message.
269
+ * Host-assembled native message recovery. The driver bounds each actual Claim and persists its
270
+ * timeout/retry before this pump returns. Do not add a second timer starting at batch selection:
271
+ * local Claim setup may take time, and expiration still owes the driver's local retry commit.
264
272
  */
265
273
  export const ThreadMessageDelivery = Context.Reference<{
266
- readonly drain: Effect.Effect<void, DurableAlarmError>;
267
- /** Drain inserts and due retries during source work, finishing the bounded wave on completion. */
268
- readonly drainUntil?: (
269
- finished: Deferred.Deferred<void>,
270
- ) => Effect.Effect<void, DurableAlarmError>;
274
+ readonly drainUntil: (
275
+ sourceFinished: Effect.Effect<void>,
276
+ dispatchUntil: DateTime.Utc,
277
+ ) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
271
278
  readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
272
279
  }>("@effect-agent/platform-cloudflare/ThreadMessageDelivery", {
273
- defaultValue: () => ({ drain: Effect.void, pendingDeadline: Effect.succeed(Option.none()) }),
280
+ defaultValue: () => ({
281
+ drainUntil: () => Effect.void,
282
+ pendingDeadline: Effect.succeed(Option.none()),
283
+ }),
274
284
  });
275
285
 
276
286
  /**
277
- * Application obligations sharing this Object's alarm. The deadline read is local and
278
- * read-only. Drain beside the native Attempt and always finish one initial bounded wave,
279
- * even when `finished` was already signalled. Then stop starting new waves on that signal
280
- * and finish the bounded current wave before returning. Native maintenance joins that work
281
- * before acknowledging a generation. Mutations use the same ThreadMutationGate; hooks never
282
- * write the raw alarm slot. Pending host work does not defer a ready model Attempt.
287
+ * Application obligations sharing this Object's alarm. Admit one initial external wave even on
288
+ * a caught-up pass, then respond to wakes while native work runs. sourceFinished closes only
289
+ * admission of NEW external waves. Keep local admission/hub subscriptions in the supplied event
290
+ * Scope until maintenance tears it down; do not scope them to sourceFinished or to this Effect.
291
+ * No deadline sleeps or automatic retry loops. Return after already-admitted waves finish.
292
+ *
293
+ * Declare a finite whole-wave allowance (1..300000ms): maximum for parallel lanes, sum for
294
+ * sequential operations. Admit a wave only if its allowance fits before dispatchUntil. Later
295
+ * arrivals cannot renew the retirement window. Maintenance bounds the join after sourceFinished,
296
+ * interrupts and joins event Scope, then reads local deadlines under the mutation gate.
297
+ *
298
+ * Setup and pendingDeadline are bounded local operations. Persist claims/envelopes before
299
+ * dispatch; interrupted waits leave exact retries/receipts recoverable. Cancellation is local,
300
+ * not remote rollback. Retry accounting is unchanged. Network waits/finalizers must be
301
+ * interruptible; short local atomic commits may be uninterruptible. Hooks never write the raw
302
+ * alarm slot. Required native publication gates belong to ThreadPublication.
283
303
  */
284
304
  export const ThreadHostMaintenance = Context.Reference<{
285
- readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
305
+ readonly dispatchTimeoutMillis: number;
286
306
  readonly drainUntil: (
287
- finished: Deferred.Deferred<void>,
288
- ) => Effect.Effect<void, DurableAlarmError>;
307
+ sourceFinished: Effect.Effect<void>,
308
+ dispatchUntil: DateTime.Utc,
309
+ ) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
310
+ readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
289
311
  }>("@effect-agent/platform-cloudflare/ThreadHostMaintenance", {
290
312
  defaultValue: () => ({
291
- pendingDeadline: Effect.succeed(Option.none()),
313
+ dispatchTimeoutMillis: 1,
292
314
  drainUntil: () => Effect.void,
315
+ pendingDeadline: Effect.succeed(Option.none()),
293
316
  }),
294
317
  });
295
318
 
@@ -320,6 +343,26 @@ const MaintenanceGeneration = Schema.BigIntFromString.check(
320
343
  Schema.isGreaterThanOrEqualToBigInt(0n),
321
344
  );
322
345
 
346
+ class BindingRetry extends Schema.Class<BindingRetry>("BindingRetry")({
347
+ threadId: ThreadId,
348
+ submissionId: SubmissionId,
349
+ attempts: Schema.Natural,
350
+ notBefore: Schema.Finite,
351
+ reportedAt: Schema.Finite,
352
+ }) {}
353
+
354
+ interface MaintenanceObservation {
355
+ generation?: bigint;
356
+ nativeOnly: boolean;
357
+ }
358
+
359
+ class MaintenanceRetry extends Schema.Class<MaintenanceRetry>("MaintenanceRetry")({
360
+ generation: MaintenanceGeneration,
361
+ notBefore: Schema.Finite,
362
+ nativeOnly: Schema.Boolean,
363
+ stalls: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(30)),
364
+ }) {}
365
+
323
366
  /** Versioned, platform-private maintenance state stored through Durable Object KV. */
324
367
  class ThreadMaintenanceState extends Schema.Class<ThreadMaintenanceState>(
325
368
  "@effect-agent/platform-cloudflare/ThreadMaintenanceState",
@@ -330,6 +373,9 @@ class ThreadMaintenanceState extends Schema.Class<ThreadMaintenanceState>(
330
373
  nonterminal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
331
374
  /** One physical-owner cursor; old single-lane records need no conversion. */
332
375
  lastServedThreadId: Schema.optionalKey(ThreadId),
376
+ bindingRetries: Schema.optionalKey(Schema.Array(BindingRetry)),
377
+ /** Absent on older records. A newer mutation makes this retry obsolete. */
378
+ retry: Schema.optionalKey(MaintenanceRetry),
333
379
  }) {}
334
380
 
335
381
  const MAINTENANCE_STATE_KEY = "effect-agent:thread-maintenance:v1";
@@ -403,7 +449,11 @@ export class ThreadMutationGate extends Context.Service<
403
449
  {
404
450
  readonly withMutation: <A, E, R>(
405
451
  body: Effect.Effect<A, E, R>,
406
- /** Indexed host work has its own durable deadline and does not invalidate ledger recovery. */
452
+ /**
453
+ * Native admission, approval, abort and unknown resolution keep the default true.
454
+ * Projection/relay/reply receipt-only bookkeeping must use false: its local durable
455
+ * pendingDeadline owns scheduling without creating native recovery debt.
456
+ */
407
457
  options?: { readonly invalidatesRecovery: boolean },
408
458
  ) => Effect.Effect<A, E | DurableAlarmError, R>;
409
459
  readonly withSnapshot: <A, E, R>(
@@ -491,15 +541,19 @@ export type MaintenancePassFailure =
491
541
  * alarm takes an O(1) path without recovery, ledger scans, or canonical-history reads.
492
542
  * 2. Recovery strictly precedes a new claim. One head Attempt advances the lane and requests
493
543
  * a safe yield after ten minutes. The whole event has a fourteen-minute cooperative timeout.
494
- * 3. The final transaction acknowledges only the generation observed at pass start. A racing
544
+ * 3. Auxiliary dispatch and listeners belong to the event Scope. After native completion,
545
+ * stop new external waves, join native delivery through its driver-owned Claim deadline,
546
+ * and bound host/backfill work by explicit allowances before closing and joining Scope.
547
+ * Ordinary auxiliary setup failures are reported after the one native opportunity.
548
+ * 4. The final transaction acknowledges only the generation observed at pass start. A racing
495
549
  * mutation therefore remains `dirty > processed` and retains its atomically-established alarm.
496
- * 4. Stable external waits acknowledge and clear. Autonomous retry, indeterminate, and lease
550
+ * 5. Stable external waits acknowledge and clear. Autonomous retry, indeterminate, and lease
497
551
  * recovery states leave their generation dirty and retain bounded backoff rearming.
498
552
  */
499
553
  export class ThreadMaintenance extends Context.Service<
500
554
  ThreadMaintenance,
501
555
  {
502
- /** One idempotent maintenance pass; failures propagate so workerd retries the alarm. */
556
+ /** One idempotent pass; failures propagate after durably scheduling bounded recovery. */
503
557
  readonly pass: Effect.Effect<MaintenancePassReport, MaintenancePassFailure>;
504
558
  /**
505
559
  * Constructor gate: initialize/inspect only the O(1) maintenance record and ensure a dirty
@@ -538,11 +592,6 @@ export class ThreadMaintenance extends Context.Service<
538
592
  const { ctx } = yield* DurableObjectContext;
539
593
  const failpoint = yield* ThreadMaintenanceFailpoint;
540
594
 
541
- /**
542
- * Consecutive no-progress passes — an in-memory CACHE, not state: a fresh incarnation
543
- * restarts at zero and merely re-arms sooner than a long-lived one would have.
544
- */
545
- const stalls = yield* Ref.make(0);
546
595
  const mutations = yield* ThreadMutationGate;
547
596
  const publication = yield* ThreadPublication;
548
597
  const projection = yield* ThreadProjectionMaintenance;
@@ -576,7 +625,7 @@ export class ThreadMaintenance extends Context.Service<
576
625
  yield* failpoint.hit("maintenance:ensure:before");
577
626
  const now = yield* Clock.currentTimeMillis;
578
627
 
579
- yield* runTransaction("ensure maintenance alarm", () =>
628
+ const retry = yield* runTransaction("ensure maintenance alarm", () =>
580
629
  ctx.storage.transaction(async (transaction) => {
581
630
  const { state, initialized } = await readMaintenanceState(transaction);
582
631
 
@@ -584,10 +633,18 @@ export class ThreadMaintenance extends Context.Service<
584
633
  await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(state));
585
634
  }
586
635
  if (state.dirty > state.processed) {
587
- await ensureTransactionAlarmBy(transaction, now + config.wakeScanInterval);
636
+ await ensureTransactionAlarmBy(
637
+ transaction,
638
+ state.retry?.generation === state.dirty
639
+ ? Math.max(now + minimumAlarmDelay, state.retry.notBefore)
640
+ : now + config.wakeScanInterval,
641
+ );
588
642
  }
643
+
644
+ return state.retry?.generation === state.dirty ? state.retry : undefined;
589
645
  }),
590
646
  );
647
+
591
648
  const deadline = yield* pendingDeadline;
592
649
 
593
650
  if (Option.isSome(deadline)) {
@@ -595,7 +652,12 @@ export class ThreadMaintenance extends Context.Service<
595
652
  ctx.storage.transaction((transaction) =>
596
653
  ensureTransactionAlarmBy(
597
654
  transaction,
598
- Math.max(now + minimumAlarmDelay, deadline.value),
655
+ Math.max(
656
+ now + minimumAlarmDelay,
657
+ deadline.value <= now && retry !== undefined && !retry.nativeOnly
658
+ ? Math.max(deadline.value, retry.notBefore)
659
+ : deadline.value,
660
+ ),
599
661
  ),
600
662
  ),
601
663
  );
@@ -603,7 +665,9 @@ export class ThreadMaintenance extends Context.Service<
603
665
  yield* failpoint.hit("maintenance:ensure:after");
604
666
  });
605
667
 
606
- const beginPass = Effect.fn("ThreadMaintenance.beginPass")(function* () {
668
+ const beginPass = Effect.fn("ThreadMaintenance.beginPass")(function* (observed: {
669
+ generation?: bigint;
670
+ }) {
607
671
  yield* failpoint.hit("maintenance:begin:before");
608
672
  const now = yield* Clock.currentTimeMillis;
609
673
 
@@ -611,23 +675,27 @@ export class ThreadMaintenance extends Context.Service<
611
675
  ctx.storage.transaction(async (transaction) => {
612
676
  const { state, initialized } = await readMaintenanceState(transaction);
613
677
 
678
+ observed.generation = state.dirty;
614
679
  if (!initialized) {
615
680
  await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(state));
616
681
  }
617
- if (state.processed >= state.dirty) {
618
- // Prearm even a publication-only pass before invoking any host hook.
619
- await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
682
+ const retryAt = state.retry?.generation === state.dirty ? state.retry.notBefore : 0;
620
683
 
621
- return { _tag: "CaughtUp" as const, nonterminal: state.nonterminal };
622
- }
623
- // Pre-arm the earliest retry before recovery. A successful finish may move this slot
624
- // LATER to its bounded backoff, which does not cancel the running handler.
684
+ // Prearm before recovery or any host hook, including publication-only passes.
685
+ // A completed pass may move this crash fallback later to its bounded retry.
625
686
  await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
687
+ if (state.processed >= state.dirty || retryAt > now) {
688
+ return {
689
+ _tag: "CaughtUp" as const,
690
+ nonterminal: state.nonterminal,
691
+ };
692
+ }
626
693
 
627
694
  return {
628
695
  _tag: "Actionable" as const,
629
696
  generation: state.dirty,
630
697
  nonterminal: state.nonterminal,
698
+ stalls: state.retry?.generation === state.dirty ? state.retry.stalls : 0,
631
699
  };
632
700
  }),
633
701
  );
@@ -637,25 +705,89 @@ export class ThreadMaintenance extends Context.Service<
637
705
  return result;
638
706
  });
639
707
 
640
- const rearmDelay = Effect.fn("ThreadMaintenance.rearmDelay")(function* (progressed: boolean) {
641
- const priorStalls = yield* Ref.getAndUpdate(stalls, (count) =>
642
- progressed ? 0 : count + 1,
708
+ const backoffDelay = (priorStalls: number, jitter: number) => {
709
+ const backoff = Math.min(
710
+ config.alarmBackoffCap,
711
+ config.alarmBackoffBase * 2 ** Math.min(priorStalls, 30),
643
712
  );
644
713
 
645
- if (progressed) return config.alarmBackoffBase;
646
- const exponent = Math.min(priorStalls, 30);
647
- const backoff = Math.min(config.alarmBackoffCap, config.alarmBackoffBase * 2 ** exponent);
648
- const jitter = yield* Random.next;
649
- // Full jitter over [backoff/2, backoff]: desynchronizes retry storms without ever
650
- // waiting longer than the deterministic bound.
651
- const jittered = Math.ceil(backoff / 2 + (backoff / 2) * jitter);
714
+ // Jitter over [backoff/2, backoff] spreads retries without exceeding the cap.
715
+ return Math.ceil(backoff / 2 + (backoff / 2) * jitter);
716
+ };
652
717
 
653
- return Math.min(jittered, config.wakeScanInterval);
718
+ const rearmDelay = Effect.fn("ThreadMaintenance.rearmDelay")(function* (
719
+ progressed: boolean,
720
+ priorStalls: number,
721
+ ) {
722
+ return progressed ? config.alarmBackoffBase : backoffDelay(priorStalls, yield* Random.next);
723
+ });
724
+
725
+ const rearmFailure = Effect.fn("ThreadMaintenance.rearmFailure")(function* (
726
+ observed: MaintenanceObservation,
727
+ ) {
728
+ const { generation, nativeOnly } = observed;
729
+
730
+ if (generation === undefined) return;
731
+ yield* failpoint.hit("maintenance:retry:before");
732
+ yield* mutations.withSnapshot((active) =>
733
+ Effect.gen(function* () {
734
+ // A failed deadline read must not prevent committing the native retry.
735
+ const deadline = yield* pendingDeadline.pipe(
736
+ Effect.catchCause(() => Effect.succeed(Option.none<number>())),
737
+ );
738
+
739
+ const now = yield* Clock.currentTimeMillis;
740
+
741
+ const jitter = yield* Random.next;
742
+
743
+ yield* runTransaction("back off failed maintenance", () =>
744
+ ctx.storage.transaction(async (transaction) => {
745
+ const { state } = await readMaintenanceState(transaction);
746
+
747
+ const previous = state.retry?.generation === generation ? state.retry : undefined;
748
+
749
+ const retry = MaintenanceRetry.make({
750
+ generation,
751
+ notBefore: Math.max(
752
+ previous?.notBefore ?? 0,
753
+ now + backoffDelay(previous?.stalls ?? 0, jitter),
754
+ ),
755
+ nativeOnly,
756
+ stalls: Math.min(30, (previous?.stalls ?? 0) + 1),
757
+ });
758
+
759
+ await transaction.put(
760
+ MAINTENANCE_STATE_KEY,
761
+ encodeMaintenanceState(ThreadMaintenanceState.make({ ...state, retry })),
762
+ );
763
+
764
+ // Never postpone a producer that raced the failed observation. Host work
765
+ // retains its own deadline; an early delivery skips native recovery below.
766
+ const nativeDeadline =
767
+ active > 0 || state.dirty !== generation
768
+ ? now + minimumAlarmDelay
769
+ : retry.notBefore;
770
+
771
+ await transaction.setAlarm(
772
+ Math.max(
773
+ now + minimumAlarmDelay,
774
+ Option.isSome(deadline) && (nativeOnly || deadline.value > now)
775
+ ? Math.min(nativeDeadline, deadline.value)
776
+ : nativeDeadline,
777
+ ),
778
+ );
779
+ }),
780
+ );
781
+ }),
782
+ );
783
+ yield* failpoint.hit("maintenance:retry:after");
654
784
  });
655
785
 
656
786
  const pass = Effect.fn("ThreadMaintenance.pass")(function* (
657
787
  yieldAfter: DateTime.Utc,
658
- ): Effect.fn.Return<MaintenancePassReport, MaintenancePassFailure> {
788
+ dispatchUntil: DateTime.Utc,
789
+ observed: MaintenanceObservation,
790
+ ): Effect.fn.Return<MaintenancePassReport, MaintenancePassFailure, Scope.Scope> {
659
791
  const annotate = (report: MaintenancePassReport) =>
660
792
  Effect.annotateCurrentSpan({
661
793
  phase: report.phase,
@@ -667,7 +799,7 @@ export class ThreadMaintenance extends Context.Service<
667
799
 
668
800
  const started = yield* mutations.withSnapshot((activeAtStart) =>
669
801
  Effect.gen(function* () {
670
- const generation = yield* beginPass();
802
+ const generation = yield* beginPass(observed);
671
803
 
672
804
  if (generation._tag === "Actionable" && activeAtStart === 0) {
673
805
  // The gate excludes a producer starting between the snapshot and certification.
@@ -678,32 +810,75 @@ export class ThreadMaintenance extends Context.Service<
678
810
  }),
679
811
  );
680
812
 
681
- // Deliver beside source work, including messages inserted by the running Attempt.
682
- // Slow destination RPCs never consume the source execution window. Stop starting
683
- // waves when source work ends, then join the bounded current wave before acknowledgement.
684
- const deliveryFinished = yield* Deferred.make<void>();
685
-
686
- const delivery = yield* Effect.forkChild(
687
- messages.drainUntil?.(deliveryFinished) ?? messages.drain,
813
+ // This scope owns auxiliary dispatch and listeners, independently of source completion.
814
+ // Close it before acknowledgement, including on failure or event interruption.
815
+ const auxiliaryScope = yield* Effect.acquireRelease(Scope.make("parallel"), (scope, exit) =>
816
+ Scope.close(scope, exit),
688
817
  );
689
818
 
690
- const hostWork = yield* Effect.forkChild(host.drainUntil(deliveryFinished));
819
+ const sourceFinished = yield* Deferred.make<void>();
820
+ const finished = Deferred.await(sourceFinished);
821
+
822
+ // Fork setup too: an ordinary auxiliary setup failure is reported after native work,
823
+ // rather than gating its opportunity. Event interruption still closes every fiber.
824
+ const deliveryFiber = yield* Effect.forkIn(
825
+ Scope.provide(auxiliaryScope)(messages.drainUntil(finished, dispatchUntil)),
826
+ auxiliaryScope,
827
+ );
691
828
 
692
- const finishDelivery = Deferred.succeed(deliveryFinished, undefined).pipe(
693
- Effect.andThen(Fiber.awaitAll([delivery, hostWork])),
694
- Effect.flatMap((outcomes) =>
695
- Effect.forEach(outcomes, (outcome) => outcome, { discard: true }),
829
+ const hostFiber = yield* Effect.forkIn(
830
+ Scope.provide(auxiliaryScope)(
831
+ Effect.gen(function* () {
832
+ yield* Schema.decodeEffect(AuxiliaryDispatchMillis)(host.dispatchTimeoutMillis).pipe(
833
+ Effect.mapError((cause) =>
834
+ DurableAlarmError.make({
835
+ operation: "host dispatch allowance",
836
+ message:
837
+ "Declare an integer whole-wave allowance between 1 and 300000 milliseconds",
838
+ cause,
839
+ }),
840
+ ),
841
+ );
842
+ yield* host.drainUntil(finished, dispatchUntil);
843
+ }),
696
844
  ),
845
+ auxiliaryScope,
697
846
  );
698
847
 
699
- // Capture derived-index failures until canonical work has had its turn. Interruption
700
- // still stops the event; ordinary failures and defects retain the prearmed generation.
701
- const projected = yield* Effect.exit(
702
- drainDue.pipe(Effect.provideService(ThreadProjectionMaintenance, projection)),
848
+ // Backfill is one disposable wave; its timer starts beside native execution.
849
+ const backfill = yield* Effect.forkIn(
850
+ drainDue.pipe(
851
+ Effect.provideService(ThreadProjectionMaintenance, projection),
852
+ Effect.timeoutOption(config.projectionDispatchTimeoutMillis),
853
+ ),
854
+ auxiliaryScope,
703
855
  );
704
856
 
705
- if (Exit.isFailure(projected) && Cause.hasInterrupts(projected.cause))
706
- return yield* Effect.failCause(projected.cause);
857
+ const finishAuxiliary = Effect.gen(function* () {
858
+ yield* Deferred.succeed(sourceFinished, undefined);
859
+
860
+ const remaining = Math.max(
861
+ 1,
862
+ DateTime.toEpochMillis(dispatchUntil) - (yield* Clock.currentTimeMillis),
863
+ );
864
+
865
+ const hostJoin = yield* Effect.forkIn(
866
+ Fiber.join(hostFiber).pipe(
867
+ Effect.timeoutOption(Math.min(host.dispatchTimeoutMillis, remaining)),
868
+ Effect.tap((result) =>
869
+ Effect.annotateCurrentSpan({ "host.timedOut": Option.isNone(result) }),
870
+ ),
871
+ ),
872
+ auxiliaryScope,
873
+ );
874
+
875
+ // Native transport uses the driver's actual Claim deadline, through Retry persistence.
876
+ // Only our host/backfill timers suppress their own interruption. Earlier failed exits,
877
+ // including pure self-interruption, remain failures while siblings are still blocked.
878
+ yield* Fiber.joinAll([deliveryFiber, hostJoin, backfill]);
879
+ yield* Scope.close(auxiliaryScope, Exit.void);
880
+ });
881
+
707
882
  const deadline = yield* publication.pendingDeadline;
708
883
 
709
884
  if (
@@ -715,8 +890,7 @@ export class ThreadMaintenance extends Context.Service<
715
890
  const pending = yield* publication.pendingDeadline;
716
891
 
717
892
  if (started._tag === "CaughtUp" || Option.isSome(pending)) {
718
- yield* finishDelivery;
719
- if (Exit.isFailure(projected)) return yield* Effect.failCause(projected.cause);
893
+ yield* finishAuxiliary;
720
894
  yield* failpoint.hit("maintenance:finish:before");
721
895
 
722
896
  const disposition = yield* mutations.withSnapshot((active) =>
@@ -732,7 +906,9 @@ export class ThreadMaintenance extends Context.Service<
732
906
 
733
907
  const nativeDeadline =
734
908
  active > 0 || state.dirty > state.processed
735
- ? now + config.wakeScanInterval
909
+ ? state.retry?.generation === state.dirty && active === 0
910
+ ? Math.max(now + minimumAlarmDelay, state.retry.notBefore)
911
+ : now + config.wakeScanInterval
736
912
  : Infinity;
737
913
 
738
914
  const next = Option.isSome(latest)
@@ -765,6 +941,7 @@ export class ThreadMaintenance extends Context.Service<
765
941
  );
766
942
  }
767
943
  // Step 2 — reconciliation strictly precedes new work in this pass (exit gate).
944
+ observed.nativeOnly = true;
768
945
  const recovered: ReadonlyArray<RecoveryReport> = yield* runtime.runRecovery;
769
946
  const reports = new Map(recovered.map((report) => [report.submissionId, report]));
770
947
  const current = yield* Stream.runCollect(ledger.scanNonterminal);
@@ -779,47 +956,122 @@ export class ThreadMaintenance extends Context.Service<
779
956
  .map((head) => head.threadId)
780
957
  .sort();
781
958
 
782
- let selected = eligible[0];
959
+ const selectionTime = yield* Clock.currentTimeMillis;
783
960
 
784
- if (heads.size > 1 && selected !== undefined) {
785
- yield* failpoint.hit("maintenance:select:before");
786
- selected = yield* runTransaction("select maintenance lane", () =>
787
- ctx.storage.transaction(async (transaction) => {
788
- const { state } = await readMaintenanceState(transaction);
961
+ yield* failpoint.hit("maintenance:select:before");
789
962
 
790
- const next =
791
- eligible.find(
792
- (threadId) =>
793
- state.lastServedThreadId === undefined || threadId > state.lastServedThreadId,
794
- ) ?? eligible[0];
963
+ const selection = yield* runTransaction("select maintenance lane", () =>
964
+ ctx.storage.transaction(async (transaction) => {
965
+ const { state } = await readMaintenanceState(transaction);
795
966
 
796
- if (next !== undefined) {
797
- // Persist before the Attempt so an eviction or repeated yield cannot
798
- // monopolize the first lane. The generation and prearmed alarm survive.
799
- await transaction.put(
800
- MAINTENANCE_STATE_KEY,
801
- encodeMaintenanceState(
802
- ThreadMaintenanceState.make({ ...state, lastServedThreadId: next }),
803
- ),
804
- );
805
- }
967
+ const retries = (state.bindingRetries ?? []).filter(
968
+ (retry) => heads.get(retry.threadId)?.submissionId === retry.submissionId,
969
+ );
806
970
 
807
- return next;
808
- }),
809
- );
810
- yield* failpoint.hit("maintenance:select:after");
811
- }
971
+ const runnable = eligible.filter(
972
+ (threadId) =>
973
+ !retries.some(
974
+ (retry) => retry.threadId === threadId && retry.notBefore > selectionTime,
975
+ ),
976
+ );
977
+
978
+ const next =
979
+ runnable.find(
980
+ (threadId) =>
981
+ state.lastServedThreadId === undefined || threadId > state.lastServedThreadId,
982
+ ) ?? runnable[0];
983
+
984
+ if (next !== undefined) {
985
+ await transaction.put(
986
+ MAINTENANCE_STATE_KEY,
987
+ encodeMaintenanceState(
988
+ ThreadMaintenanceState.make({ ...state, lastServedThreadId: next }),
989
+ ),
990
+ );
991
+ }
992
+
993
+ return { selected: next, retries };
994
+ }),
995
+ );
996
+
997
+ yield* failpoint.hit("maintenance:select:after");
998
+
999
+ const selected =
1000
+ selection.selected === undefined ? undefined : heads.get(selection.selected);
812
1001
 
813
- // One FIFO head per event, across all local lanes. The runtime keeps its normal
814
- // bounded Attempt and recovery contracts; followers belong to another alarm.
1002
+ let retries = selection.retries;
1003
+ let bindingFailure: DurableBindingFailure | undefined;
1004
+
1005
+ // One FIFO head per event. An unavailable contract is a durable wait for compatible code,
1006
+ // including for children; other local lanes and host deliveries remain independently due.
815
1007
  const settlement =
816
1008
  selected === undefined
817
1009
  ? Option.none()
818
- : yield* runtime.processThreadHead(selected, { yieldAfter });
1010
+ : yield* runtime.processThreadHead(selected.threadId, { yieldAfter }).pipe(
1011
+ Effect.catchTag(["BindingUnavailable", "BindingDigestMismatch"], (failure) => {
1012
+ bindingFailure = failure;
1013
+
1014
+ return Effect.succeed(Option.none());
1015
+ }),
1016
+ );
1017
+
1018
+ if (selected !== undefined) {
1019
+ const previous = retries.find((retry) => retry.submissionId === selected.submissionId);
1020
+ let retry: BindingRetry | undefined;
1021
+ let reportBindingFailure = false;
1022
+
1023
+ if (bindingFailure !== undefined) {
1024
+ const now = yield* Clock.currentTimeMillis;
1025
+ const attempts = Math.min(30, (previous?.attempts ?? 0) + 1);
1026
+
1027
+ reportBindingFailure =
1028
+ previous === undefined || now - previous.reportedAt >= 15 * 60_000;
1029
+ retry = BindingRetry.make({
1030
+ threadId: selected.threadId,
1031
+ submissionId: selected.submissionId,
1032
+ attempts,
1033
+ notBefore: now + Math.min(60_000, 5_000 * 2 ** (attempts - 1)),
1034
+ reportedAt: reportBindingFailure ? now : (previous?.reportedAt ?? now),
1035
+ });
1036
+ }
1037
+ if (retry !== undefined || previous !== undefined) {
1038
+ // The Attempt released its Claim. Commit its binding wait (or clear) once,
1039
+ // before joining fallible auxiliary work. This local fact neither acknowledges
1040
+ // a generation nor changes the shared alarm; those require event retirement.
1041
+ yield* failpoint.hit("maintenance:binding-retry:before");
1042
+ retries = yield* runTransaction("record submission binding retry", () =>
1043
+ ctx.storage.transaction(async (transaction) => {
1044
+ const { state } = await readMaintenanceState(transaction);
1045
+
1046
+ const bindingRetries = [
1047
+ ...(state.bindingRetries ?? []).filter(
1048
+ (entry) => entry.submissionId !== selected.submissionId,
1049
+ ),
1050
+ ...(retry === undefined ? [] : [retry]),
1051
+ ];
1052
+
1053
+ await transaction.put(
1054
+ MAINTENANCE_STATE_KEY,
1055
+ encodeMaintenanceState(ThreadMaintenanceState.make({ ...state, bindingRetries })),
1056
+ );
1057
+
1058
+ return bindingRetries;
1059
+ }),
1060
+ );
1061
+ yield* failpoint.hit("maintenance:binding-retry:after");
1062
+ }
1063
+ if (bindingFailure !== undefined) {
1064
+ yield* reportBindingFailure
1065
+ ? Effect.logError(
1066
+ "Thread awaits a compatible binding; original work remains pending",
1067
+ Cause.fail(bindingFailure),
1068
+ )
1069
+ : Effect.logDebug("Thread binding retry remains pending", Cause.fail(bindingFailure));
1070
+ }
1071
+ }
819
1072
 
820
- yield* finishDelivery;
821
- if (Exit.isFailure(projected)) return yield* Effect.failCause(projected.cause);
822
- // Observe residual state before acknowledging this exact pass-start generation.
1073
+ observed.nativeOnly = false;
1074
+ yield* finishAuxiliary;
823
1075
  const remaining = yield* Stream.runCollect(ledger.scanNonterminal);
824
1076
  const waitingHeads = new Map<ThreadId, boolean>();
825
1077
 
@@ -844,8 +1096,19 @@ export class ThreadMaintenance extends Context.Service<
844
1096
  Option.isSome(settlement) ||
845
1097
  recovered.some((report) => report.disposition === "repaired");
846
1098
 
847
- const delay = autonomous ? yield* rearmDelay(progressed) : 0;
848
1099
  const now = yield* Clock.currentTimeMillis;
1100
+ const ordinaryDelay = autonomous ? yield* rearmDelay(progressed, started.stalls) : 0;
1101
+
1102
+ const nextEligible = eligible.map(
1103
+ (threadId) =>
1104
+ retries.find((retry) => retry.submissionId === heads.get(threadId)?.submissionId)
1105
+ ?.notBefore ?? now,
1106
+ );
1107
+
1108
+ const bindingDelay =
1109
+ nextEligible.length === 0 ? 0 : Math.max(0, Math.min(...nextEligible) - now);
1110
+
1111
+ const delay = Math.max(ordinaryDelay, bindingDelay);
849
1112
 
850
1113
  yield* failpoint.hit("maintenance:finish:before");
851
1114
 
@@ -867,9 +1130,22 @@ export class ThreadMaintenance extends Context.Service<
867
1130
  : started.generation;
868
1131
 
869
1132
  const next = ThreadMaintenanceState.make({
870
- ...state,
1133
+ ...Struct.omit(state, ["retry"]),
871
1134
  processed,
872
1135
  nonterminal: remaining.length,
1136
+ bindingRetries: (state.bindingRetries ?? []).filter((retry) =>
1137
+ remaining.some((row) => row.submissionId === retry.submissionId),
1138
+ ),
1139
+ ...(autonomous && !progressed
1140
+ ? {
1141
+ retry: MaintenanceRetry.make({
1142
+ generation: started.generation,
1143
+ notBefore: now + delay,
1144
+ nativeOnly: true,
1145
+ stalls: Math.min(30, started.stalls + 1),
1146
+ }),
1147
+ }
1148
+ : {}),
873
1149
  });
874
1150
 
875
1151
  await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(next));
@@ -877,13 +1153,18 @@ export class ThreadMaintenance extends Context.Service<
877
1153
  // Replace the crash-fallback slot with this pass's bounded backoff. The target
878
1154
  // is never earlier than the begin-pass fallback, so workerd does not cancel
879
1155
  // this running alarm handler before its report/span can complete.
1156
+ const nativeDeadline =
1157
+ started.activeAtStart > 0 || active > 0 || state.dirty !== started.generation
1158
+ ? now + minimumAlarmDelay
1159
+ : now + delay;
1160
+
880
1161
  await transaction.setAlarm(
881
1162
  Option.isSome(publicationDeadline)
882
1163
  ? Math.max(
883
1164
  now + minimumAlarmDelay,
884
- Math.min(now + delay, publicationDeadline.value),
1165
+ Math.min(nativeDeadline, publicationDeadline.value),
885
1166
  )
886
- : now + delay,
1167
+ : nativeDeadline,
887
1168
  );
888
1169
 
889
1170
  return "rearmed" as const;
@@ -922,9 +1203,6 @@ export class ThreadMaintenance extends Context.Service<
922
1203
  );
923
1204
 
924
1205
  yield* failpoint.hit("maintenance:finish:after");
925
- if (alarmDisposition === "cleared") {
926
- yield* Ref.set(stalls, 0);
927
- }
928
1206
 
929
1207
  return yield* annotate(
930
1208
  MaintenancePassReport.make({
@@ -940,9 +1218,23 @@ export class ThreadMaintenance extends Context.Service<
940
1218
  return ThreadMaintenance.of({
941
1219
  // A mid-pass immediate hint is droppable; durable dirty state decides the final alarm.
942
1220
  pass: Effect.gen(function* () {
943
- const yieldAfter = DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 10 * 60_000);
944
-
945
- return yield* alarm.withWakesDeferred(maintenancePassGate.withPermit(pass(yieldAfter)));
1221
+ const now = yield* Clock.currentTimeMillis;
1222
+ const yieldAfter = DateTime.makeUnsafe(now + 10 * 60_000);
1223
+ const dispatchUntil = DateTime.makeUnsafe(now + 14 * 60_000);
1224
+ const observed: MaintenanceObservation = { nativeOnly: false };
1225
+
1226
+ return yield* alarm.withWakesDeferred(
1227
+ maintenancePassGate.withPermit(
1228
+ Effect.scoped(pass(yieldAfter, dispatchUntil, observed)).pipe(
1229
+ // Close event-owned auxiliary work and release Attempt ownership before
1230
+ // failure rearming, while still holding the pass permit.
1231
+ Effect.onErrorIf(
1232
+ () => true,
1233
+ () => rearmFailure(observed),
1234
+ ),
1235
+ ),
1236
+ ),
1237
+ );
946
1238
  }).pipe(
947
1239
  // Include permit waiting, recovery and acknowledgement in the event deadline.
948
1240
  // Interruption releases Attempt ownership, leaving the prearmed dirty generation