@effect-agent/platform-cloudflare 0.1.0-beta.111 → 0.1.0-beta.112
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 +34 -8
- package/dist/Alarm.mjs +231 -41
- package/dist/Alarm.mjs.map +1 -1
- package/dist/CloudflareThreadClient.d.mts +35 -28
- package/dist/CloudflareThreadClient.mjs +1 -1
- package/dist/{ThreadObject-BGla_Obk.mjs → ThreadObject-0k-SVpY_.mjs} +2 -2
- package/dist/ThreadObject-0k-SVpY_.mjs.map +1 -0
- package/dist/{ThreadObject-Cmn8pAQ3.d.mts → ThreadObject-DVxgrU7c.d.mts} +16 -9
- package/dist/ThreadObject.d.mts +1 -1
- package/dist/ThreadObject.mjs +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
- package/src/Alarm.ts +432 -72
- package/src/ThreadObject.ts +2 -2
- package/dist/ThreadObject-BGla_Obk.mjs.map +0 -1
package/src/Alarm.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
Option,
|
|
12
12
|
Random,
|
|
13
13
|
Ref,
|
|
14
|
+
Result,
|
|
14
15
|
Schema,
|
|
15
16
|
Scope,
|
|
16
17
|
Semaphore,
|
|
@@ -20,11 +21,25 @@ import {
|
|
|
20
21
|
import { type DurableBindingFailure } from "effect-agent/agent-registration";
|
|
21
22
|
import {
|
|
22
23
|
DurableAgentRuntime,
|
|
24
|
+
DurableRuntimeConfig,
|
|
25
|
+
isolateRecovery,
|
|
26
|
+
RecoveryBlocked,
|
|
27
|
+
RecoveryFailure,
|
|
23
28
|
type DurableWorkerFailure,
|
|
24
29
|
type RecoveryReport,
|
|
30
|
+
RecoverySweepResult,
|
|
25
31
|
} from "effect-agent/durable-agent-runtime";
|
|
26
32
|
import { ThreadId, SubmissionId } from "effect-agent/identifiers";
|
|
27
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
OperationAuthorizationRequest,
|
|
35
|
+
OperationAuthorizer,
|
|
36
|
+
type OperationDenied,
|
|
37
|
+
} from "effect-agent/operation-authorizer";
|
|
38
|
+
import {
|
|
39
|
+
AbortIntentRequest,
|
|
40
|
+
SubmissionLedger,
|
|
41
|
+
type SubmissionWorkItem,
|
|
42
|
+
} from "effect-agent/submission-ledger";
|
|
28
43
|
import {
|
|
29
44
|
ThreadProjectionMaintenance,
|
|
30
45
|
drainDue,
|
|
@@ -187,7 +202,7 @@ export class MaintenancePassReport extends Schema.Class<MaintenancePassReport>(
|
|
|
187
202
|
)({
|
|
188
203
|
/** `caught-up` ran no runtime work (publication may be pending); `actionable` ran recovery. */
|
|
189
204
|
phase: Schema.Literals(["caught-up", "actionable"]),
|
|
190
|
-
/** Recovery decisions
|
|
205
|
+
/** Recovery decisions and Thread faults observed during this event. */
|
|
191
206
|
recovered: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
192
207
|
/** Head Attempts settled during the event. Joined input may settle with each head. */
|
|
193
208
|
settled: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
@@ -211,6 +226,8 @@ export type ThreadMaintenanceFailpointLocation =
|
|
|
211
226
|
| "maintenance:select:after"
|
|
212
227
|
| "maintenance:binding-retry:before"
|
|
213
228
|
| "maintenance:binding-retry:after"
|
|
229
|
+
| "maintenance:recovery-status:before"
|
|
230
|
+
| "maintenance:recovery-status:after"
|
|
214
231
|
| "maintenance:retry:before"
|
|
215
232
|
| "maintenance:retry:after"
|
|
216
233
|
| "maintenance:checkpoint:before"
|
|
@@ -354,14 +371,64 @@ class BindingRetry extends Schema.Class<BindingRetry>("BindingRetry")({
|
|
|
354
371
|
reportedAt: Schema.Finite,
|
|
355
372
|
}) {}
|
|
356
373
|
|
|
374
|
+
/**
|
|
375
|
+
* A durable observation of blocked recovery, independent of the execution journal. This is
|
|
376
|
+
* neither a Settlement nor proof that external effects did not happen. A successful recovery
|
|
377
|
+
* sweep clears it; repair must preserve canonical history and the original admission identity.
|
|
378
|
+
*/
|
|
379
|
+
export class ThreadRecoveryFault extends Schema.Class<ThreadRecoveryFault>(
|
|
380
|
+
"@effect-agent/platform-cloudflare/ThreadRecoveryFault",
|
|
381
|
+
)({
|
|
382
|
+
schemaVersion: Schema.Literal(1),
|
|
383
|
+
threadId: ThreadId,
|
|
384
|
+
firstFailedAt: Schema.Finite,
|
|
385
|
+
lastFailedAt: Schema.Finite,
|
|
386
|
+
/** Earliest automatic recovery retry; new admissions do not erase this deadline. */
|
|
387
|
+
retryAt: Schema.Finite,
|
|
388
|
+
/** Saturates at 2^31 - 1; one observation per Thread per recovery sweep. */
|
|
389
|
+
attempts: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(2_147_483_647)),
|
|
390
|
+
failure: RecoveryFailure,
|
|
391
|
+
}) {}
|
|
392
|
+
|
|
393
|
+
const recoveryFaultKey = (threadId: ThreadId) =>
|
|
394
|
+
`effect-agent:thread-recovery-fault:v1:${threadId}`;
|
|
395
|
+
|
|
396
|
+
const decodeRecoveryFaultValue = Schema.decodeUnknownSync(ThreadRecoveryFault);
|
|
397
|
+
|
|
398
|
+
const decodeRecoveryFault = (threadId: ThreadId, encoded: unknown) => {
|
|
399
|
+
const fault = decodeRecoveryFaultValue(encoded);
|
|
400
|
+
|
|
401
|
+
if (fault.threadId !== threadId) throw new Error("Recovery status does not match its Thread key");
|
|
402
|
+
|
|
403
|
+
return fault;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const encodeRecoveryFault = Schema.encodeSync(ThreadRecoveryFault);
|
|
407
|
+
|
|
357
408
|
interface NativePassResult {
|
|
358
409
|
readonly phase: "caught-up" | "actionable";
|
|
359
|
-
readonly recovered: number;
|
|
360
410
|
readonly settled: number;
|
|
361
411
|
readonly nonterminal: number;
|
|
362
412
|
readonly nextAttemptAt: number | undefined;
|
|
363
413
|
}
|
|
364
414
|
|
|
415
|
+
/** Event-local observations only; durable ingress keeps racing mutations dirty. */
|
|
416
|
+
interface NativeRecovery {
|
|
417
|
+
readonly queue: Deferred.Deferred<ReadonlyArray<ThreadId>>;
|
|
418
|
+
readonly pending: Set<ThreadId>;
|
|
419
|
+
readonly loaded: Set<ThreadId>;
|
|
420
|
+
readonly reports: Map<SubmissionId, RecoveryReport>;
|
|
421
|
+
readonly faults: Map<ThreadId, ThreadRecoveryFault>;
|
|
422
|
+
observation?: {
|
|
423
|
+
readonly generation: bigint;
|
|
424
|
+
readonly activeAtStart: number;
|
|
425
|
+
};
|
|
426
|
+
started: boolean;
|
|
427
|
+
needsCheckpoint: boolean;
|
|
428
|
+
recovered: number;
|
|
429
|
+
repaired: boolean;
|
|
430
|
+
}
|
|
431
|
+
|
|
365
432
|
interface MaintenanceObservation {
|
|
366
433
|
generation?: bigint;
|
|
367
434
|
nativeOnly: boolean;
|
|
@@ -384,6 +451,8 @@ class ThreadMaintenanceState extends Schema.Class<ThreadMaintenanceState>(
|
|
|
384
451
|
nonterminal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
385
452
|
/** One physical-owner cursor; old single-lane records need no conversion. */
|
|
386
453
|
lastServedThreadId: Schema.optionalKey(ThreadId),
|
|
454
|
+
/** Rotate old recovery independently of dispatch, including after eviction or timeout. */
|
|
455
|
+
lastRecoveredThreadId: Schema.optionalKey(ThreadId),
|
|
387
456
|
bindingRetries: Schema.optionalKey(Schema.Array(BindingRetry)),
|
|
388
457
|
/** Absent on older records. A newer mutation makes this retry obsolete. */
|
|
389
458
|
retry: Schema.optionalKey(MaintenanceRetry),
|
|
@@ -425,7 +494,7 @@ const ensureTransactionAlarmBy = async (
|
|
|
425
494
|
};
|
|
426
495
|
|
|
427
496
|
const stableExternalWait = (
|
|
428
|
-
snapshot:
|
|
497
|
+
snapshot: SubmissionWorkItem,
|
|
429
498
|
reports: ReadonlyMap<string, RecoveryReport>,
|
|
430
499
|
): boolean => {
|
|
431
500
|
const report = reports.get(snapshot.submissionId);
|
|
@@ -553,9 +622,9 @@ export type MaintenancePassFailure =
|
|
|
553
622
|
* recovery, ledger scans or canonical-history reads.
|
|
554
623
|
* 2. Reconcile before each head Attempt, then checkpoint only the observed generation. A racing
|
|
555
624
|
* producer keeps its newer generation dirty. Native retries retain their durable backoff.
|
|
556
|
-
* 3.
|
|
557
|
-
*
|
|
558
|
-
*
|
|
625
|
+
* 3. Keep delivery admission open while old recovery is pending, so fresh native replies can
|
|
626
|
+
* publish in this event. Close once, at latest at the original ten-minute yield deadline;
|
|
627
|
+
* native wakes can still advance heads while admitted deliveries retire.
|
|
559
628
|
* 4. Native message delivery retains its driver-owned Claim deadline. Host/backfill joins are
|
|
560
629
|
* bounded independently; incoming native work never restarts or cancels their attempts.
|
|
561
630
|
* Auxiliary failures are reported after the current native opportunity.
|
|
@@ -572,6 +641,15 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
572
641
|
* generation has an alarm. It never scans the ledger or canonical history.
|
|
573
642
|
*/
|
|
574
643
|
readonly ensureAlarm: Effect.Effect<void, MaintenancePassFailure>;
|
|
644
|
+
/**
|
|
645
|
+
* Authorize `explain`, then read one bounded local record without reading execution history.
|
|
646
|
+
* None means no recorded fault, not proof of health or settlement. The host authenticates
|
|
647
|
+
* callers and verifies local Thread membership before exposing this service across RPC.
|
|
648
|
+
* Provide OperationAuthorizer when constructing this Layer, as for DurableAgentRuntime.
|
|
649
|
+
*/
|
|
650
|
+
readonly recoveryStatus: (
|
|
651
|
+
threadId: ThreadId,
|
|
652
|
+
) => Effect.Effect<Option.Option<ThreadRecoveryFault>, DurableAlarmError | OperationDenied>;
|
|
575
653
|
/**
|
|
576
654
|
* Serialize the pre-arm boundary with pass acknowledgement, advance the durable dirty
|
|
577
655
|
* generation and arm the alarm in one transaction BEFORE running the caller's mutation.
|
|
@@ -589,6 +667,7 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
589
667
|
| ThreadPublication
|
|
590
668
|
| ThreadProjectionMaintenance
|
|
591
669
|
| DurableAgentRuntime
|
|
670
|
+
| DurableRuntimeConfig
|
|
592
671
|
| SubmissionLedger
|
|
593
672
|
| WakeScheduler
|
|
594
673
|
| DurableAlarmService
|
|
@@ -599,6 +678,7 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
599
678
|
> = Layer.effect(ThreadMaintenance)(
|
|
600
679
|
Effect.gen(function* () {
|
|
601
680
|
const runtime = yield* DurableAgentRuntime;
|
|
681
|
+
const recoveryConfig = yield* DurableRuntimeConfig;
|
|
602
682
|
const ledger = yield* SubmissionLedger;
|
|
603
683
|
const wakes = yield* WakeScheduler;
|
|
604
684
|
const alarm = yield* DurableAlarmService;
|
|
@@ -611,6 +691,7 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
611
691
|
const projection = yield* ThreadProjectionMaintenance;
|
|
612
692
|
const messages = yield* ThreadMessageDelivery;
|
|
613
693
|
const host = yield* ThreadHostMaintenance;
|
|
694
|
+
const authorizer = yield* OperationAuthorizer;
|
|
614
695
|
|
|
615
696
|
// A broken disposable index still needs a retry alarm and must not prevent startup.
|
|
616
697
|
const projectionDeadline = projection.pendingDeadline.pipe(
|
|
@@ -635,6 +716,97 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
635
716
|
|
|
636
717
|
const runTransaction = yield* makeStorageOperation;
|
|
637
718
|
|
|
719
|
+
const recoveryStatus = Effect.fn("ThreadMaintenance.recoveryStatus")(function* (
|
|
720
|
+
threadId: ThreadId,
|
|
721
|
+
) {
|
|
722
|
+
yield* authorizer.authorize(
|
|
723
|
+
OperationAuthorizationRequest.make({ operation: "explain", threadId }),
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
return yield* runTransaction("read Thread recovery status", async () => {
|
|
727
|
+
const encoded = await ctx.storage.get(recoveryFaultKey(threadId));
|
|
728
|
+
|
|
729
|
+
return encoded === undefined
|
|
730
|
+
? Option.none()
|
|
731
|
+
: Option.some(decodeRecoveryFault(threadId, encoded));
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
const recordRecoveryStatus = Effect.fn("ThreadMaintenance.recordRecoveryStatus")(function* (
|
|
736
|
+
result: RecoverySweepResult,
|
|
737
|
+
) {
|
|
738
|
+
const threads = new Map<ThreadId, RecoveryFailure | undefined>();
|
|
739
|
+
|
|
740
|
+
for (const report of result.reports) threads.set(report.threadId, undefined);
|
|
741
|
+
for (const blocked of result.blocked) threads.set(blocked.threadId, blocked.failure);
|
|
742
|
+
if (threads.size === 0) return new Map<ThreadId, ThreadRecoveryFault>();
|
|
743
|
+
const now = yield* Clock.currentTimeMillis;
|
|
744
|
+
|
|
745
|
+
yield* failpoint.hit("maintenance:recovery-status:before");
|
|
746
|
+
|
|
747
|
+
const retained = yield* runTransaction("record Thread recovery status", () =>
|
|
748
|
+
ctx.storage.transaction(async (transaction) => {
|
|
749
|
+
const newlyBlocked: Array<ThreadRecoveryFault> = [];
|
|
750
|
+
const faults = new Map<ThreadId, ThreadRecoveryFault>();
|
|
751
|
+
|
|
752
|
+
for (const [threadId, failure] of threads) {
|
|
753
|
+
const key = recoveryFaultKey(threadId);
|
|
754
|
+
const encoded = await transaction.get(key);
|
|
755
|
+
|
|
756
|
+
const previous =
|
|
757
|
+
encoded === undefined ? undefined : decodeRecoveryFault(threadId, encoded);
|
|
758
|
+
|
|
759
|
+
if (failure === undefined) {
|
|
760
|
+
if (previous !== undefined) await transaction.delete(key);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const fault = ThreadRecoveryFault.make({
|
|
765
|
+
schemaVersion: 1,
|
|
766
|
+
threadId,
|
|
767
|
+
firstFailedAt: previous?.firstFailedAt ?? now,
|
|
768
|
+
lastFailedAt: now,
|
|
769
|
+
attempts: Math.min(2_147_483_647, (previous?.attempts ?? 0) + 1),
|
|
770
|
+
retryAt: now + Math.min(60_000, 5_000 * 2 ** Math.min(30, previous?.attempts ?? 0)),
|
|
771
|
+
failure,
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
await transaction.put(key, encodeRecoveryFault(fault));
|
|
775
|
+
faults.set(threadId, fault);
|
|
776
|
+
if (previous === undefined) newlyBlocked.push(fault);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return { newlyBlocked, faults };
|
|
780
|
+
}),
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
yield* failpoint.hit("maintenance:recovery-status:after");
|
|
784
|
+
for (const fault of retained.newlyBlocked)
|
|
785
|
+
yield* Effect.logError(
|
|
786
|
+
"Native Thread recovery blocked; accepted work remains pending",
|
|
787
|
+
fault.failure.reason === "defect"
|
|
788
|
+
? Cause.die(fault.failure)
|
|
789
|
+
: Cause.fail(fault.failure),
|
|
790
|
+
).pipe(Effect.annotateLogs({ threadId: fault.threadId }));
|
|
791
|
+
|
|
792
|
+
return retained.faults;
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const recoverThread = Effect.fn("ThreadMaintenance.recoverThread")(function* (
|
|
796
|
+
threadId: ThreadId,
|
|
797
|
+
recovery: NativeRecovery,
|
|
798
|
+
) {
|
|
799
|
+
const result = yield* runtime.runRecovery({ threadId });
|
|
800
|
+
// Visibility is committed before a claim or any fallible auxiliary join.
|
|
801
|
+
const faults = yield* recordRecoveryStatus(result);
|
|
802
|
+
|
|
803
|
+
for (const report of result.reports) recovery.reports.set(report.submissionId, report);
|
|
804
|
+
recovery.faults.delete(threadId);
|
|
805
|
+
for (const [id, fault] of faults) recovery.faults.set(id, fault);
|
|
806
|
+
recovery.recovered += result.reports.length + result.blocked.length;
|
|
807
|
+
recovery.repaired ||= result.reports.some((report) => report.disposition === "repaired");
|
|
808
|
+
});
|
|
809
|
+
|
|
638
810
|
const ensureAlarm = Effect.fn("ThreadMaintenance.ensureAlarm")(function* () {
|
|
639
811
|
yield* failpoint.hit("maintenance:ensure:before");
|
|
640
812
|
const now = yield* Clock.currentTimeMillis;
|
|
@@ -821,6 +993,8 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
821
993
|
started: Effect.Success<ReturnType<typeof beginNative>>,
|
|
822
994
|
yieldAfter: DateTime.Utc,
|
|
823
995
|
observed: MaintenanceObservation,
|
|
996
|
+
recovery: NativeRecovery,
|
|
997
|
+
dispatch = true,
|
|
824
998
|
): Effect.fn.Return<NativePassResult, MaintenancePassFailure> {
|
|
825
999
|
const deadline = yield* publication.pendingDeadline;
|
|
826
1000
|
|
|
@@ -856,33 +1030,99 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
856
1030
|
|
|
857
1031
|
return {
|
|
858
1032
|
phase: "caught-up",
|
|
859
|
-
recovered: 0,
|
|
860
1033
|
settled: 0,
|
|
861
1034
|
nonterminal: started.nonterminal,
|
|
862
1035
|
nextAttemptAt,
|
|
863
1036
|
};
|
|
864
1037
|
}
|
|
865
|
-
//
|
|
1038
|
+
// Select from control state before reading execution history. A recovering or faulted
|
|
1039
|
+
// Thread cannot enter dispatch; old cleanup has its own scoped opportunity below.
|
|
866
1040
|
observed.nativeOnly = true;
|
|
867
|
-
|
|
868
|
-
|
|
1041
|
+
|
|
1042
|
+
// Every checkpoint keeps the producer overlap that belongs to this recovery wave.
|
|
1043
|
+
const observation = (recovery.observation ??= {
|
|
1044
|
+
generation: started.generation,
|
|
1045
|
+
activeAtStart: started.activeAtStart,
|
|
1046
|
+
});
|
|
1047
|
+
|
|
869
1048
|
const current = yield* Stream.runCollect(ledger.scanNonterminal);
|
|
870
|
-
const
|
|
1049
|
+
const selectionTime = yield* Clock.currentTimeMillis;
|
|
1050
|
+
|
|
1051
|
+
yield* runTransaction("read Thread recovery deadlines", async () => {
|
|
1052
|
+
for (const threadId of new Set(current.map((row) => row.threadId))) {
|
|
1053
|
+
if (recovery.loaded.has(threadId)) continue;
|
|
1054
|
+
const encoded = await ctx.storage.get(recoveryFaultKey(threadId));
|
|
1055
|
+
|
|
1056
|
+
if (encoded !== undefined)
|
|
1057
|
+
recovery.faults.set(threadId, decodeRecoveryFault(threadId, encoded));
|
|
1058
|
+
recovery.loaded.add(threadId);
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
const reports = recovery.reports;
|
|
1062
|
+
const recoveryFaults = recovery.faults;
|
|
1063
|
+
|
|
1064
|
+
const waiting = (row: SubmissionWorkItem) =>
|
|
1065
|
+
!recovery.pending.has(row.threadId) &&
|
|
1066
|
+
!recoveryFaults.has(row.threadId) &&
|
|
1067
|
+
stableExternalWait(row, reports);
|
|
1068
|
+
|
|
1069
|
+
const heads = new Map<ThreadId, SubmissionWorkItem>();
|
|
871
1070
|
|
|
872
1071
|
for (const row of current) {
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
if (row.state === "unknown" && stableExternalWait(row, reports)) continue;
|
|
1072
|
+
// Only recovered uncertainty may release later input; accepted aborts retain FIFO.
|
|
1073
|
+
if (row.state === "unknown" && waiting(row)) continue;
|
|
876
1074
|
if (!heads.has(row.threadId)) heads.set(row.threadId, row);
|
|
877
1075
|
}
|
|
1076
|
+
const stopping = new Set<ThreadId>();
|
|
1077
|
+
|
|
1078
|
+
for (const head of heads.values()) {
|
|
1079
|
+
if (
|
|
1080
|
+
head.state !== "ready" ||
|
|
1081
|
+
recovery.pending.has(head.threadId) ||
|
|
1082
|
+
recoveryFaults.has(head.threadId)
|
|
1083
|
+
)
|
|
1084
|
+
continue;
|
|
1085
|
+
|
|
1086
|
+
// An accepted abort is cleanup even when its input was never claimed. This
|
|
1087
|
+
// control-only read must not decode the execution journal or a recovery snapshot.
|
|
1088
|
+
const intent = yield* isolateRecovery(
|
|
1089
|
+
ledger.readAbortIntent(AbortIntentRequest.make({ submissionId: head.submissionId })),
|
|
1090
|
+
{
|
|
1091
|
+
timeout: recoveryConfig.recoveryTimeout,
|
|
1092
|
+
phase: () => "recovery",
|
|
1093
|
+
operation: "read abort intent",
|
|
1094
|
+
},
|
|
1095
|
+
);
|
|
1096
|
+
|
|
1097
|
+
if (Result.isSuccess(intent)) {
|
|
1098
|
+
if (intent.success !== undefined) stopping.add(head.threadId);
|
|
1099
|
+
} else {
|
|
1100
|
+
const faults = yield* recordRecoveryStatus(
|
|
1101
|
+
RecoverySweepResult.make({
|
|
1102
|
+
reports: [],
|
|
1103
|
+
blocked: [
|
|
1104
|
+
RecoveryBlocked.make({ threadId: head.threadId, failure: intent.failure }),
|
|
1105
|
+
],
|
|
1106
|
+
}),
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
for (const [threadId, fault] of faults) recoveryFaults.set(threadId, fault);
|
|
1110
|
+
recovery.recovered++;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
878
1113
|
|
|
879
1114
|
const eligible = [...heads.values()]
|
|
880
|
-
.filter(
|
|
1115
|
+
.filter(
|
|
1116
|
+
(head) =>
|
|
1117
|
+
!recovery.pending.has(head.threadId) &&
|
|
1118
|
+
!stopping.has(head.threadId) &&
|
|
1119
|
+
!recoveryFaults.has(head.threadId) &&
|
|
1120
|
+
!waiting(head) &&
|
|
1121
|
+
(head.state === "ready" || reports.has(head.submissionId)),
|
|
1122
|
+
)
|
|
881
1123
|
.map((head) => head.threadId)
|
|
882
1124
|
.sort();
|
|
883
1125
|
|
|
884
|
-
const selectionTime = yield* Clock.currentTimeMillis;
|
|
885
|
-
|
|
886
1126
|
yield* failpoint.hit("maintenance:select:before");
|
|
887
1127
|
|
|
888
1128
|
const selection = yield* runTransaction("select maintenance lane", () =>
|
|
@@ -900,11 +1140,12 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
900
1140
|
),
|
|
901
1141
|
);
|
|
902
1142
|
|
|
903
|
-
const next =
|
|
904
|
-
runnable.find(
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1143
|
+
const next = dispatch
|
|
1144
|
+
? (runnable.find(
|
|
1145
|
+
(threadId) =>
|
|
1146
|
+
state.lastServedThreadId === undefined || threadId > state.lastServedThreadId,
|
|
1147
|
+
) ?? runnable[0])
|
|
1148
|
+
: undefined;
|
|
908
1149
|
|
|
909
1150
|
if (next !== undefined) {
|
|
910
1151
|
await transaction.put(
|
|
@@ -915,22 +1156,54 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
915
1156
|
);
|
|
916
1157
|
}
|
|
917
1158
|
|
|
918
|
-
|
|
1159
|
+
const backlog = recovery.started
|
|
1160
|
+
? []
|
|
1161
|
+
: [...heads.keys()].filter((threadId) => {
|
|
1162
|
+
const fault = recoveryFaults.get(threadId);
|
|
1163
|
+
|
|
1164
|
+
return (
|
|
1165
|
+
!eligible.includes(threadId) &&
|
|
1166
|
+
(fault === undefined || fault.retryAt <= selectionTime)
|
|
1167
|
+
);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
// One finite wave, one Thread at a time, with the runtime's per-Thread deadline.
|
|
1171
|
+
// This cursor ensures an event deadline/eviction cannot always restart at the front.
|
|
1172
|
+
const after = backlog.filter(
|
|
1173
|
+
(threadId) =>
|
|
1174
|
+
state.lastRecoveredThreadId === undefined || threadId > state.lastRecoveredThreadId,
|
|
1175
|
+
);
|
|
1176
|
+
|
|
1177
|
+
const before = backlog.filter(
|
|
1178
|
+
(threadId) =>
|
|
1179
|
+
state.lastRecoveredThreadId !== undefined &&
|
|
1180
|
+
threadId <= state.lastRecoveredThreadId,
|
|
1181
|
+
);
|
|
1182
|
+
|
|
1183
|
+
return { selected: next, retries, backlog: [...after, ...before] };
|
|
919
1184
|
}),
|
|
920
1185
|
);
|
|
921
1186
|
|
|
922
1187
|
yield* failpoint.hit("maintenance:select:after");
|
|
1188
|
+
if (!recovery.started) {
|
|
1189
|
+
recovery.started = true;
|
|
1190
|
+
recovery.needsCheckpoint = selection.backlog.length > 0;
|
|
1191
|
+
for (const threadId of selection.backlog) recovery.pending.add(threadId);
|
|
1192
|
+
yield* Deferred.succeed(recovery.queue, selection.backlog);
|
|
1193
|
+
}
|
|
923
1194
|
|
|
924
1195
|
const selected =
|
|
925
1196
|
selection.selected === undefined ? undefined : heads.get(selection.selected);
|
|
926
1197
|
|
|
1198
|
+
if (selected !== undefined) yield* recoverThread(selected.threadId, recovery);
|
|
1199
|
+
|
|
927
1200
|
let retries = selection.retries;
|
|
928
1201
|
let bindingFailure: DurableBindingFailure | undefined;
|
|
929
1202
|
|
|
930
1203
|
// One runnable FIFO head per native opportunity. An absent agent waits for a deployment,
|
|
931
1204
|
// including for children; other local lanes and host deliveries remain independently due.
|
|
932
1205
|
const settlement =
|
|
933
|
-
selected === undefined
|
|
1206
|
+
selected === undefined || recoveryFaults.has(selected.threadId)
|
|
934
1207
|
? Option.none()
|
|
935
1208
|
: yield* runtime.processThreadHead(selected.threadId, { yieldAfter }).pipe(
|
|
936
1209
|
Effect.catchTag("BindingUnavailable", (failure) => {
|
|
@@ -999,11 +1272,10 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
999
1272
|
const waitingHeads = new Map<ThreadId, boolean>();
|
|
1000
1273
|
|
|
1001
1274
|
const autonomous = remaining.some((snapshot) => {
|
|
1002
|
-
if (snapshot.state === "unknown" &&
|
|
1275
|
+
if (snapshot.state === "unknown" && waiting(snapshot)) return false;
|
|
1003
1276
|
const headWaiting = waitingHeads.get(snapshot.threadId);
|
|
1004
1277
|
|
|
1005
|
-
if (headWaiting === undefined)
|
|
1006
|
-
waitingHeads.set(snapshot.threadId, stableExternalWait(snapshot, reports));
|
|
1278
|
+
if (headWaiting === undefined) waitingHeads.set(snapshot.threadId, waiting(snapshot));
|
|
1007
1279
|
// FIFO followers cannot execute through a stable external wait. Only plain queued
|
|
1008
1280
|
// input is dormant here; admission repairs and accepted aborts still need a pass.
|
|
1009
1281
|
if (
|
|
@@ -1013,26 +1285,28 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1013
1285
|
)
|
|
1014
1286
|
return false;
|
|
1015
1287
|
|
|
1016
|
-
return !
|
|
1288
|
+
return !waiting(snapshot);
|
|
1017
1289
|
});
|
|
1018
1290
|
|
|
1019
|
-
const progressed =
|
|
1020
|
-
Option.isSome(settlement) ||
|
|
1021
|
-
recovered.some((report) => report.disposition === "repaired");
|
|
1291
|
+
const progressed = Option.isSome(settlement) || recovery.repaired;
|
|
1022
1292
|
|
|
1023
1293
|
const now = yield* Clock.currentTimeMillis;
|
|
1024
1294
|
const ordinaryDelay = autonomous ? yield* rearmDelay(progressed, started.stalls) : 0;
|
|
1025
1295
|
|
|
1026
|
-
const nextEligible =
|
|
1027
|
-
(
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1296
|
+
const nextEligible = [...recoveryFaults.values()]
|
|
1297
|
+
.map((fault) => fault.retryAt)
|
|
1298
|
+
.concat(
|
|
1299
|
+
eligible.map(
|
|
1300
|
+
(threadId) =>
|
|
1301
|
+
retries.find((retry) => retry.submissionId === heads.get(threadId)?.submissionId)
|
|
1302
|
+
?.notBefore ?? now,
|
|
1303
|
+
),
|
|
1304
|
+
);
|
|
1031
1305
|
|
|
1032
|
-
const
|
|
1306
|
+
const retryDelay =
|
|
1033
1307
|
nextEligible.length === 0 ? 0 : Math.max(0, Math.min(...nextEligible) - now);
|
|
1034
1308
|
|
|
1035
|
-
const delay = Math.max(ordinaryDelay,
|
|
1309
|
+
const delay = Math.max(ordinaryDelay, retryDelay);
|
|
1036
1310
|
|
|
1037
1311
|
// Checkpoint native progress without changing the physical alarm. Auxiliary
|
|
1038
1312
|
// delivery remains live; later mutations still advance the shared generation.
|
|
@@ -1044,11 +1318,14 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1044
1318
|
const { state } = await readMaintenanceState(transaction);
|
|
1045
1319
|
|
|
1046
1320
|
const processed =
|
|
1047
|
-
autonomous ||
|
|
1321
|
+
autonomous ||
|
|
1322
|
+
observation.activeAtStart > 0 ||
|
|
1323
|
+
started.activeAtStart > 0 ||
|
|
1324
|
+
active > 0
|
|
1048
1325
|
? state.processed
|
|
1049
|
-
: state.processed >
|
|
1326
|
+
: state.processed > observation.generation
|
|
1050
1327
|
? state.processed
|
|
1051
|
-
:
|
|
1328
|
+
: observation.generation;
|
|
1052
1329
|
|
|
1053
1330
|
const next = ThreadMaintenanceState.make({
|
|
1054
1331
|
...Struct.omit(state, ["retry"]),
|
|
@@ -1087,7 +1364,6 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1087
1364
|
|
|
1088
1365
|
return {
|
|
1089
1366
|
phase: "actionable",
|
|
1090
|
-
recovered: recovered.length,
|
|
1091
1367
|
settled: Option.isSome(settlement) ? 1 : 0,
|
|
1092
1368
|
nonterminal: remaining.length,
|
|
1093
1369
|
nextAttemptAt,
|
|
@@ -1106,7 +1382,9 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1106
1382
|
Effect.catch(() => Effect.never),
|
|
1107
1383
|
);
|
|
1108
1384
|
|
|
1109
|
-
|
|
1385
|
+
// A wake may observe temporary backoff while this event's recovery is still running.
|
|
1386
|
+
// Its completion must retain the original actionable observation for acknowledgement.
|
|
1387
|
+
const started = yield* beginNative(observed);
|
|
1110
1388
|
|
|
1111
1389
|
// This scope owns auxiliary dispatch and listeners, independently of native progress.
|
|
1112
1390
|
// Close it before final alarm rearming, including on failure or event interruption.
|
|
@@ -1114,6 +1392,43 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1114
1392
|
Scope.close(scope, exit),
|
|
1115
1393
|
);
|
|
1116
1394
|
|
|
1395
|
+
const recovery: NativeRecovery = {
|
|
1396
|
+
queue: yield* Deferred.make<ReadonlyArray<ThreadId>>(),
|
|
1397
|
+
pending: new Set(),
|
|
1398
|
+
loaded: new Set(),
|
|
1399
|
+
reports: new Map(),
|
|
1400
|
+
faults: new Map(),
|
|
1401
|
+
started: false,
|
|
1402
|
+
needsCheckpoint: false,
|
|
1403
|
+
recovered: 0,
|
|
1404
|
+
repaired: false,
|
|
1405
|
+
};
|
|
1406
|
+
|
|
1407
|
+
const recoveryFiber = yield* Effect.forkIn(
|
|
1408
|
+
Effect.gen(function* () {
|
|
1409
|
+
for (const threadId of yield* Deferred.await(recovery.queue)) {
|
|
1410
|
+
yield* failpoint.hit("maintenance:select:before");
|
|
1411
|
+
yield* runTransaction("select old recovery lane", () =>
|
|
1412
|
+
ctx.storage.transaction(async (transaction) => {
|
|
1413
|
+
const { state } = await readMaintenanceState(transaction);
|
|
1414
|
+
|
|
1415
|
+
await transaction.put(
|
|
1416
|
+
MAINTENANCE_STATE_KEY,
|
|
1417
|
+
encodeMaintenanceState(
|
|
1418
|
+
ThreadMaintenanceState.make({ ...state, lastRecoveredThreadId: threadId }),
|
|
1419
|
+
),
|
|
1420
|
+
);
|
|
1421
|
+
}),
|
|
1422
|
+
);
|
|
1423
|
+
yield* failpoint.hit("maintenance:select:after");
|
|
1424
|
+
yield* recoverThread(threadId, recovery);
|
|
1425
|
+
recovery.pending.delete(threadId);
|
|
1426
|
+
yield* wakes.notify(threadId);
|
|
1427
|
+
}
|
|
1428
|
+
}),
|
|
1429
|
+
auxiliaryScope,
|
|
1430
|
+
);
|
|
1431
|
+
|
|
1117
1432
|
const dispatchClosed = yield* Deferred.make<void>();
|
|
1118
1433
|
const stopDispatch = Deferred.await(dispatchClosed);
|
|
1119
1434
|
|
|
@@ -1152,41 +1467,63 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1152
1467
|
auxiliaryScope,
|
|
1153
1468
|
);
|
|
1154
1469
|
|
|
1155
|
-
let result = yield* advance(started, yieldAfter, observed);
|
|
1470
|
+
let result = yield* advance(started, yieldAfter, observed, recovery);
|
|
1471
|
+
|
|
1472
|
+
// This event owns one finite old-recovery wave, including an empty caught-up wave.
|
|
1473
|
+
recovery.started = true;
|
|
1474
|
+
yield* Deferred.succeed(recovery.queue, []);
|
|
1156
1475
|
let phase = result.phase;
|
|
1157
|
-
let recovered = result.recovered;
|
|
1158
1476
|
let settled = result.settled;
|
|
1159
1477
|
|
|
1160
1478
|
observed.nativeOnly = false;
|
|
1161
1479
|
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
yield*
|
|
1480
|
+
// Old recovery may still admit fresh native dispatch. Keep its replies/deliveries
|
|
1481
|
+
// eligible in this same window. Retirement starts only after that window closes;
|
|
1482
|
+
// waiting for host retirement to close it would create a circular join.
|
|
1483
|
+
const hostJoin = yield* Effect.forkIn(
|
|
1484
|
+
Effect.gen(function* () {
|
|
1485
|
+
yield* stopDispatch;
|
|
1166
1486
|
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1487
|
+
const remaining = Math.max(
|
|
1488
|
+
1,
|
|
1489
|
+
DateTime.toEpochMillis(dispatchUntil) - (yield* Clock.currentTimeMillis),
|
|
1490
|
+
);
|
|
1171
1491
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
),
|
|
1492
|
+
const outcome = yield* Fiber.join(hostFiber).pipe(
|
|
1493
|
+
Effect.timeoutOption(Math.min(host.dispatchTimeoutMillis, remaining)),
|
|
1494
|
+
);
|
|
1495
|
+
|
|
1496
|
+
yield* Effect.annotateCurrentSpan({ "host.timedOut": Option.isNone(outcome) });
|
|
1497
|
+
}),
|
|
1179
1498
|
auxiliaryScope,
|
|
1180
1499
|
);
|
|
1181
1500
|
|
|
1182
|
-
const retired = yield* Effect.forkChild(
|
|
1501
|
+
const retired = yield* Effect.forkChild(
|
|
1502
|
+
Fiber.joinAll([deliveryFiber, hostJoin, backfill, recoveryFiber]),
|
|
1503
|
+
);
|
|
1183
1504
|
|
|
1184
1505
|
const auxiliaryPending = () =>
|
|
1185
1506
|
deliveryFiber.pollUnsafe() === undefined ||
|
|
1186
1507
|
hostFiber.pollUnsafe() === undefined ||
|
|
1187
|
-
backfill.pollUnsafe() === undefined
|
|
1188
|
-
|
|
1189
|
-
|
|
1508
|
+
backfill.pollUnsafe() === undefined ||
|
|
1509
|
+
recoveryFiber.pollUnsafe() === undefined;
|
|
1510
|
+
|
|
1511
|
+
while (true) {
|
|
1512
|
+
const recoveryFinished = recoveryFiber.pollUnsafe() !== undefined;
|
|
1513
|
+
|
|
1514
|
+
if (recoveryFinished) {
|
|
1515
|
+
if (recovery.needsCheckpoint) {
|
|
1516
|
+
yield* Fiber.join(recoveryFiber);
|
|
1517
|
+
// A head released by old recovery gets its native opportunity before the
|
|
1518
|
+
// delivery window closes, never after all reply listeners have retired.
|
|
1519
|
+
result = yield* advance(started, yieldAfter, observed, recovery, settled === 0);
|
|
1520
|
+
if (result.phase === "actionable") phase = "actionable";
|
|
1521
|
+
settled += result.settled;
|
|
1522
|
+
recovery.needsCheckpoint = false;
|
|
1523
|
+
}
|
|
1524
|
+
yield* Deferred.succeed(dispatchClosed, undefined);
|
|
1525
|
+
}
|
|
1526
|
+
if (retired.pollUnsafe() !== undefined || !auxiliaryPending()) break;
|
|
1190
1527
|
const now = yield* Clock.currentTimeMillis;
|
|
1191
1528
|
const until = DateTime.toEpochMillis(yieldAfter);
|
|
1192
1529
|
|
|
@@ -1198,24 +1535,46 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1198
1535
|
until,
|
|
1199
1536
|
);
|
|
1200
1537
|
|
|
1538
|
+
// A completion racing this iteration stays armed until the loop handles it.
|
|
1539
|
+
const recoveryDone = recoveryFinished ? Effect.never : Fiber.await(recoveryFiber);
|
|
1540
|
+
|
|
1201
1541
|
const ready = yield* Effect.raceFirst(
|
|
1202
|
-
Effect.raceFirst(notified, Effect.sleep(Math.max(0, next - now))).pipe(
|
|
1203
|
-
|
|
1542
|
+
Effect.raceFirst(notified, Effect.sleep(Math.max(0, next - now))).pipe(
|
|
1543
|
+
Effect.as("native" as const),
|
|
1544
|
+
),
|
|
1545
|
+
Effect.raceFirst(
|
|
1546
|
+
recoveryDone.pipe(Effect.as("recovery" as const)),
|
|
1547
|
+
Fiber.await(retired).pipe(Effect.as("retired" as const)),
|
|
1548
|
+
),
|
|
1204
1549
|
);
|
|
1205
1550
|
|
|
1206
|
-
|
|
1551
|
+
// Recovery completion closes admission before joining host retirement. It must
|
|
1552
|
+
// wake this loop directly: the public wake hint is allowed to be dropped.
|
|
1553
|
+
if (ready === "recovery") continue;
|
|
1554
|
+
if (ready === "retired" || retired.pollUnsafe() !== undefined) break;
|
|
1555
|
+
if (!auxiliaryPending()) continue;
|
|
1207
1556
|
if ((yield* Clock.currentTimeMillis) >= until) break;
|
|
1208
1557
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1558
|
+
const awakened = yield* beginNative(observed);
|
|
1559
|
+
|
|
1560
|
+
result = yield* advance(awakened, yieldAfter, observed, recovery);
|
|
1211
1561
|
if (result.phase === "actionable") phase = "actionable";
|
|
1212
|
-
recovered += result.recovered;
|
|
1213
1562
|
settled += result.settled;
|
|
1214
1563
|
observed.nativeOnly = false;
|
|
1215
1564
|
}
|
|
1565
|
+
// The original native yield deadline closes all new waves even if old recovery
|
|
1566
|
+
// is still pending. No recovery or delivery receives a renewed event budget.
|
|
1567
|
+
yield* Deferred.succeed(dispatchClosed, undefined);
|
|
1216
1568
|
// Preserve driver-owned Claim deadlines and failures, then close every
|
|
1217
1569
|
// listener before the one final alarm decision.
|
|
1218
1570
|
yield* Fiber.join(retired);
|
|
1571
|
+
if (recovery.needsCheckpoint) {
|
|
1572
|
+
// The native yield deadline ended dispatch before old recovery finished. Fold its
|
|
1573
|
+
// control state into acknowledgement without starting an Attempt after retirement.
|
|
1574
|
+
result = yield* advance(started, yieldAfter, observed, recovery, false);
|
|
1575
|
+
if (result.phase === "actionable") phase = "actionable";
|
|
1576
|
+
settled += result.settled;
|
|
1577
|
+
}
|
|
1219
1578
|
yield* Scope.close(auxiliaryScope, Exit.void);
|
|
1220
1579
|
yield* failpoint.hit("maintenance:finish:before");
|
|
1221
1580
|
|
|
@@ -1256,7 +1615,7 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1256
1615
|
|
|
1257
1616
|
const report = MaintenancePassReport.make({
|
|
1258
1617
|
phase,
|
|
1259
|
-
recovered,
|
|
1618
|
+
recovered: recovery.recovered,
|
|
1260
1619
|
settled,
|
|
1261
1620
|
nonterminal: result.nonterminal,
|
|
1262
1621
|
alarm: disposition,
|
|
@@ -1309,6 +1668,7 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1309
1668
|
}),
|
|
1310
1669
|
),
|
|
1311
1670
|
ensureAlarm: mutations.withSnapshot(() => ensureAlarm()),
|
|
1671
|
+
recoveryStatus,
|
|
1312
1672
|
withMutation: (body) =>
|
|
1313
1673
|
mutations.withMutation(
|
|
1314
1674
|
body.pipe(
|