@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/dist/Alarm.d.mts +39 -20
- package/dist/Alarm.mjs +200 -77
- package/dist/Alarm.mjs.map +1 -1
- package/dist/{CloudflareConfig-f3CqTel1.d.mts → CloudflareConfig-DKXGo8L3.d.mts} +12 -7
- package/dist/CloudflareConfig.d.mts +2 -2
- package/dist/CloudflareConfig.mjs +11 -5
- package/dist/CloudflareConfig.mjs.map +1 -1
- package/dist/CloudflareThreadClient.d.mts +99 -30
- package/dist/{ThreadObject-DvRG0dDz.mjs → ThreadObject-DXavwaU-.mjs} +34 -16
- package/dist/ThreadObject-DXavwaU-.mjs.map +1 -0
- package/dist/{ThreadObject-8LfGzzz4.d.mts → ThreadObject-DiqVDdJE.d.mts} +39 -37
- package/dist/ThreadObject.d.mts +1 -1
- package/dist/ThreadObject.mjs +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
- package/src/Alarm.ts +409 -117
- package/src/CloudflareConfig.ts +10 -4
- package/src/internal/layers.ts +9 -4
- package/src/internal/message-delivery.ts +69 -40
- package/dist/ThreadObject-DvRG0dDz.mjs.map +0 -1
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
|
|
262
|
-
*
|
|
263
|
-
*
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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: () => ({
|
|
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.
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
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
|
|
305
|
+
readonly dispatchTimeoutMillis: number;
|
|
286
306
|
readonly drainUntil: (
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
641
|
-
const
|
|
642
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
682
|
-
//
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
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
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
//
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
706
|
-
|
|
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*
|
|
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
|
-
?
|
|
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
|
-
|
|
959
|
+
const selectionTime = yield* Clock.currentTimeMillis;
|
|
783
960
|
|
|
784
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
814
|
-
|
|
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
|
-
|
|
821
|
-
|
|
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(
|
|
1165
|
+
Math.min(nativeDeadline, publicationDeadline.value),
|
|
885
1166
|
)
|
|
886
|
-
:
|
|
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
|
|
944
|
-
|
|
945
|
-
|
|
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
|