@effect-agent/platform-cloudflare 0.1.0-beta.79 → 0.1.0-beta.80
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 +17 -5
- package/dist/Alarm.mjs +48 -12
- package/dist/Alarm.mjs.map +1 -1
- package/dist/CloudflareBindings.d.mts +14 -7
- package/dist/CloudflareBindings.mjs +10 -7
- package/dist/CloudflareBindings.mjs.map +1 -1
- package/dist/CloudflareScheduling.mjs +2 -2
- package/dist/CloudflareSubscriptions.mjs +1 -1
- package/dist/CloudflareThreadClient.d.mts +41 -41
- package/dist/CloudflareThreadClient.mjs +3 -3
- package/dist/CloudflareThreadClient.mjs.map +1 -1
- package/dist/{ThreadObject-MuU9xAyn.d.mts → ThreadObject-3uDpKfB3.d.mts} +82 -44
- package/dist/{ThreadObject-KJf_5Isr.mjs → ThreadObject-DGkXv41C.mjs} +213 -68
- package/dist/ThreadObject-DGkXv41C.mjs.map +1 -0
- package/dist/ThreadObject.d.mts +2 -2
- package/dist/ThreadObject.mjs +2 -2
- package/dist/WakeScheduler.d.mts +2 -2
- package/dist/WakeScheduler.mjs +6 -6
- package/dist/WakeScheduler.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{prepared-admission-G7N4DDqS.mjs → prepared-admission-DSi55W-7.mjs} +2 -2
- package/dist/{prepared-admission-G7N4DDqS.mjs.map → prepared-admission-DSi55W-7.mjs.map} +1 -1
- package/package.json +1 -1
- package/src/Alarm.ts +91 -14
- package/src/CloudflareBindings.ts +15 -6
- package/src/CloudflareThreadClient.ts +3 -3
- package/src/ThreadObject.ts +284 -59
- package/src/WakeScheduler.ts +6 -6
- package/src/internal/layers.ts +150 -22
- package/src/internal/message-delivery.ts +37 -21
- package/src/internal/transport.ts +2 -2
- package/dist/ThreadObject-KJf_5Isr.mjs.map +0 -1
package/src/Alarm.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ThreadId } from "@effect-agent/core/Identifiers";
|
|
1
2
|
import { type DurableBindingFailure } from "@effect-agent/thread/AgentRegistration";
|
|
2
3
|
import {
|
|
3
4
|
DurableAgentRuntime,
|
|
@@ -28,7 +29,7 @@ import {
|
|
|
28
29
|
Stream,
|
|
29
30
|
} from "effect";
|
|
30
31
|
|
|
31
|
-
import {
|
|
32
|
+
import { DurableObjectContext } from "./CloudflareBindings.ts";
|
|
32
33
|
import { CloudflareDurableRuntimeConfig } from "./CloudflareConfig.ts";
|
|
33
34
|
import { safeCauseMessage } from "./internal/boundary.ts";
|
|
34
35
|
|
|
@@ -188,6 +189,8 @@ export type ThreadMaintenanceFailpointLocation =
|
|
|
188
189
|
| "maintenance:ensure:after"
|
|
189
190
|
| "maintenance:begin:before"
|
|
190
191
|
| "maintenance:begin:after"
|
|
192
|
+
| "maintenance:select:before"
|
|
193
|
+
| "maintenance:select:after"
|
|
191
194
|
| "maintenance:finish:before"
|
|
192
195
|
| "maintenance:finish:after";
|
|
193
196
|
|
|
@@ -255,6 +258,26 @@ export const ThreadMessageDelivery = Context.Reference<{
|
|
|
255
258
|
defaultValue: () => ({ drain: Effect.void, pendingDeadline: Effect.succeed(Option.none()) }),
|
|
256
259
|
});
|
|
257
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Application obligations sharing this Object's alarm. The deadline read is local and
|
|
263
|
+
* read-only. Drain beside the native Attempt and always finish one initial bounded wave,
|
|
264
|
+
* even when `finished` was already signalled. Then stop starting new waves on that signal
|
|
265
|
+
* and finish the bounded current wave before returning. Native maintenance joins that work
|
|
266
|
+
* before acknowledging a generation. Mutations use the same ThreadMutationGate; hooks never
|
|
267
|
+
* write the raw alarm slot. Pending host work does not defer a ready model Attempt.
|
|
268
|
+
*/
|
|
269
|
+
export const ThreadHostMaintenance = Context.Reference<{
|
|
270
|
+
readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
|
|
271
|
+
readonly drainUntil: (
|
|
272
|
+
finished: Deferred.Deferred<void>,
|
|
273
|
+
) => Effect.Effect<void, DurableAlarmError>;
|
|
274
|
+
}>("@effect-agent/platform-cloudflare/ThreadHostMaintenance", {
|
|
275
|
+
defaultValue: () => ({
|
|
276
|
+
pendingDeadline: Effect.succeed(Option.none()),
|
|
277
|
+
drainUntil: () => Effect.void,
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
|
|
258
281
|
const earliestDeadline = (
|
|
259
282
|
left: Option.Option<number>,
|
|
260
283
|
right: Option.Option<number>,
|
|
@@ -290,6 +313,8 @@ class ThreadMaintenanceState extends Schema.Class<ThreadMaintenanceState>(
|
|
|
290
313
|
dirty: MaintenanceGeneration,
|
|
291
314
|
processed: MaintenanceGeneration,
|
|
292
315
|
nonterminal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
316
|
+
/** One physical-owner cursor; old single-lane records need no conversion. */
|
|
317
|
+
lastServedThreadId: Schema.optionalKey(ThreadId),
|
|
293
318
|
}) {}
|
|
294
319
|
|
|
295
320
|
const MAINTENANCE_STATE_KEY = "effect-agent:thread-maintenance:v1";
|
|
@@ -482,7 +507,6 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
482
507
|
| DurableAlarmService
|
|
483
508
|
| ThreadMaintenanceFailpoint
|
|
484
509
|
| CloudflareDurableRuntimeConfig
|
|
485
|
-
| ThreadObjectIdentity
|
|
486
510
|
| DurableObjectContext
|
|
487
511
|
> = Layer.effect(ThreadMaintenance)(
|
|
488
512
|
Effect.gen(function* () {
|
|
@@ -490,7 +514,6 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
490
514
|
const ledger = yield* SubmissionLedger;
|
|
491
515
|
const alarm = yield* DurableAlarmService;
|
|
492
516
|
const config = yield* CloudflareDurableRuntimeConfig;
|
|
493
|
-
const identity = yield* ThreadObjectIdentity;
|
|
494
517
|
const { ctx } = yield* DurableObjectContext;
|
|
495
518
|
const failpoint = yield* ThreadMaintenanceFailpoint;
|
|
496
519
|
|
|
@@ -503,6 +526,7 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
503
526
|
const publication = yield* ThreadPublication;
|
|
504
527
|
const projection = yield* ThreadProjectionMaintenance;
|
|
505
528
|
const messages = yield* ThreadMessageDelivery;
|
|
529
|
+
const host = yield* ThreadHostMaintenance;
|
|
506
530
|
|
|
507
531
|
// A broken disposable index still needs a retry alarm and must not prevent startup.
|
|
508
532
|
const projectionDeadline = projection.pendingDeadline.pipe(
|
|
@@ -518,7 +542,7 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
518
542
|
const pendingDeadline = Effect.gen(function* () {
|
|
519
543
|
return earliestDeadline(
|
|
520
544
|
earliestDeadline(yield* publication.pendingDeadline, yield* messages.pendingDeadline),
|
|
521
|
-
yield* projectionDeadline,
|
|
545
|
+
earliestDeadline(yield* projectionDeadline, yield* host.pendingDeadline),
|
|
522
546
|
);
|
|
523
547
|
});
|
|
524
548
|
|
|
@@ -643,8 +667,13 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
643
667
|
messages.drainUntil?.(deliveryFinished) ?? messages.drain,
|
|
644
668
|
);
|
|
645
669
|
|
|
670
|
+
const hostWork = yield* Effect.forkChild(host.drainUntil(deliveryFinished));
|
|
671
|
+
|
|
646
672
|
const finishDelivery = Deferred.succeed(deliveryFinished, undefined).pipe(
|
|
647
|
-
Effect.andThen(Fiber.
|
|
673
|
+
Effect.andThen(Fiber.awaitAll([delivery, hostWork])),
|
|
674
|
+
Effect.flatMap((outcomes) =>
|
|
675
|
+
Effect.forEach(outcomes, (outcome) => outcome, { discard: true }),
|
|
676
|
+
),
|
|
648
677
|
);
|
|
649
678
|
|
|
650
679
|
// Capture derived-index failures until canonical work has had its turn. Interruption
|
|
@@ -717,24 +746,72 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
717
746
|
}
|
|
718
747
|
// Step 2 — reconciliation strictly precedes new work in this pass (exit gate).
|
|
719
748
|
const recovered: ReadonlyArray<RecoveryReport> = yield* runtime.runRecovery;
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const
|
|
749
|
+
const reports = new Map(recovered.map((report) => [report.submissionId, report]));
|
|
750
|
+
const current = yield* Stream.runCollect(ledger.scanNonterminal);
|
|
751
|
+
const heads = new Map<ThreadId, SubmissionSnapshot>();
|
|
752
|
+
|
|
753
|
+
for (const row of current) {
|
|
754
|
+
if (!heads.has(row.threadId)) heads.set(row.threadId, row);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const eligible = [...heads.values()]
|
|
758
|
+
.filter((head) => !stableExternalWait(head, reports))
|
|
759
|
+
.map((head) => head.threadId)
|
|
760
|
+
.sort();
|
|
761
|
+
|
|
762
|
+
let selected = eligible[0];
|
|
763
|
+
|
|
764
|
+
if (heads.size > 1 && selected !== undefined) {
|
|
765
|
+
yield* failpoint.hit("maintenance:select:before");
|
|
766
|
+
selected = yield* runTransaction("select maintenance lane", () =>
|
|
767
|
+
ctx.storage.transaction(async (transaction) => {
|
|
768
|
+
const { state } = await readMaintenanceState(transaction);
|
|
769
|
+
|
|
770
|
+
const next =
|
|
771
|
+
eligible.find(
|
|
772
|
+
(threadId) =>
|
|
773
|
+
state.lastServedThreadId === undefined || threadId > state.lastServedThreadId,
|
|
774
|
+
) ?? eligible[0];
|
|
775
|
+
|
|
776
|
+
if (next !== undefined) {
|
|
777
|
+
// Persist before the Attempt so an eviction or repeated yield cannot
|
|
778
|
+
// monopolize the first lane. The generation and prearmed alarm survive.
|
|
779
|
+
await transaction.put(
|
|
780
|
+
MAINTENANCE_STATE_KEY,
|
|
781
|
+
encodeMaintenanceState(
|
|
782
|
+
ThreadMaintenanceState.make({ ...state, lastServedThreadId: next }),
|
|
783
|
+
),
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return next;
|
|
788
|
+
}),
|
|
789
|
+
);
|
|
790
|
+
yield* failpoint.hit("maintenance:select:after");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// One FIFO head per event, across all local lanes. The runtime keeps its normal
|
|
794
|
+
// bounded Attempt and recovery contracts; followers belong to another alarm.
|
|
795
|
+
const settlement =
|
|
796
|
+
selected === undefined
|
|
797
|
+
? Option.none()
|
|
798
|
+
: yield* runtime.processThreadHead(selected, { yieldAfter });
|
|
723
799
|
|
|
724
800
|
yield* finishDelivery;
|
|
725
801
|
if (Exit.isFailure(projected)) return yield* Effect.failCause(projected.cause);
|
|
726
802
|
// Observe residual state before acknowledging this exact pass-start generation.
|
|
727
803
|
const remaining = yield* Stream.runCollect(ledger.scanNonterminal);
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
const
|
|
804
|
+
const waitingHeads = new Map<ThreadId, boolean>();
|
|
805
|
+
|
|
806
|
+
const autonomous = remaining.some((snapshot) => {
|
|
807
|
+
const headWaiting = waitingHeads.get(snapshot.threadId);
|
|
731
808
|
|
|
732
|
-
|
|
809
|
+
if (headWaiting === undefined)
|
|
810
|
+
waitingHeads.set(snapshot.threadId, stableExternalWait(snapshot, reports));
|
|
733
811
|
// FIFO followers cannot execute through a stable external wait. Only plain queued
|
|
734
812
|
// input is dormant here; admission repairs and accepted aborts still need a pass.
|
|
735
813
|
if (
|
|
736
|
-
|
|
737
|
-
headWaiting &&
|
|
814
|
+
headWaiting === true &&
|
|
738
815
|
snapshot.state === "ready" &&
|
|
739
816
|
reports.get(snapshot.submissionId)?.decision._tag === "ApplyInput"
|
|
740
817
|
)
|
|
@@ -52,15 +52,18 @@ export interface ThreadObjectRpc extends Rpc.DurableObjectBranded {
|
|
|
52
52
|
wake(): Promise<void>;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** A logical Thread endpoint may be bound to an application Object without forging its brand. */
|
|
56
|
+
export type ThreadObjectClient = Omit<ThreadObjectRpc, keyof Rpc.DurableObjectBranded>;
|
|
57
|
+
|
|
55
58
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
+
* Deterministic logical Thread placement. The native adapter uses `idFromName(threadId)`;
|
|
60
|
+
* a shared application owner can bind that logical identity into its RPC adapter instead.
|
|
61
|
+
* Lookup performs no I/O and grants no authority. Every call resolves its target afresh.
|
|
59
62
|
*/
|
|
60
63
|
export class ThreadObjectNamespace extends Context.Service<
|
|
61
64
|
ThreadObjectNamespace,
|
|
62
65
|
{
|
|
63
|
-
readonly
|
|
66
|
+
readonly get: (threadId: ThreadId) => ThreadObjectClient;
|
|
64
67
|
/** Stable binding name for opted-in native RPC tracing; absent by default. */
|
|
65
68
|
readonly rpcTracing?: string;
|
|
66
69
|
}
|
|
@@ -70,7 +73,7 @@ export class ThreadObjectNamespace extends Context.Service<
|
|
|
70
73
|
options: { readonly rpcTracing?: string } = {},
|
|
71
74
|
): Layer.Layer<ThreadObjectNamespace> {
|
|
72
75
|
return Layer.succeed(ThreadObjectNamespace)({
|
|
73
|
-
namespace,
|
|
76
|
+
get: (threadId) => namespace.get(namespace.idFromName(threadId)),
|
|
74
77
|
...(options.rpcTracing === undefined ? {} : { rpcTracing: options.rpcTracing }),
|
|
75
78
|
});
|
|
76
79
|
}
|
|
@@ -135,7 +138,7 @@ export const threadNamespaceLayer = (
|
|
|
135
138
|
): Layer.Layer<ThreadObjectNamespace, CloudflareBindingError> =>
|
|
136
139
|
Layer.effect(ThreadObjectNamespace)(
|
|
137
140
|
Effect.map(threadNamespaceFromEnv(env, binding), (namespace) => ({
|
|
138
|
-
namespace,
|
|
141
|
+
get: (threadId) => namespace.get(namespace.idFromName(threadId)),
|
|
139
142
|
...(options.rpcTracing === true ? { rpcTracing: binding } : {}),
|
|
140
143
|
})),
|
|
141
144
|
);
|
|
@@ -169,3 +172,9 @@ export class ThreadObjectIdentity extends Context.Service<
|
|
|
169
172
|
readonly producerId: ProducerId;
|
|
170
173
|
}
|
|
171
174
|
>()("@effect-agent/platform-cloudflare/ThreadObjectIdentity") {}
|
|
175
|
+
|
|
176
|
+
/** Logical Threads whose canonical stores and admission ledger live in this physical Object. */
|
|
177
|
+
export class ThreadObjectPlacement extends Context.Service<
|
|
178
|
+
ThreadObjectPlacement,
|
|
179
|
+
{ readonly ownsThread: (threadId: ThreadId) => boolean }
|
|
180
|
+
>()("@effect-agent/platform-cloudflare/ThreadObjectPlacement") {}
|
|
@@ -445,12 +445,12 @@ export class CloudflareThreadClient extends Context.Service<
|
|
|
445
445
|
ThreadObjectNamespace | Crypto.Crypto
|
|
446
446
|
> = Layer.effect(CloudflareThreadClient)(
|
|
447
447
|
Effect.gen(function* () {
|
|
448
|
-
const {
|
|
448
|
+
const { get, rpcTracing } = yield* ThreadObjectNamespace;
|
|
449
449
|
const crypto = yield* Crypto.Crypto;
|
|
450
450
|
|
|
451
451
|
const call = Effect.fn(
|
|
452
452
|
function* (
|
|
453
|
-
threadId:
|
|
453
|
+
threadId: ThreadId,
|
|
454
454
|
operation: keyof typeof hostRpcMethods,
|
|
455
455
|
encoded: unknown,
|
|
456
456
|
): Effect.fn.Return<HostResponse, ThreadClientError | HostProtocolError> {
|
|
@@ -460,7 +460,7 @@ export class CloudflareThreadClient extends Context.Service<
|
|
|
460
460
|
|
|
461
461
|
const raw = yield* Effect.tryPromise({
|
|
462
462
|
try: () => {
|
|
463
|
-
const stub =
|
|
463
|
+
const stub = get(threadId);
|
|
464
464
|
|
|
465
465
|
return stub[hostRpcMethods[operation]](encoded, ...traceArgs);
|
|
466
466
|
},
|