@effect-agent/platform-cloudflare 0.1.0-beta.93 → 0.1.0-beta.95
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 +37 -18
- package/dist/Alarm.mjs +62 -29
- package/dist/Alarm.mjs.map +1 -1
- package/dist/{CloudflareConfig-f3CqTel1.d.mts → CloudflareConfig-D472JOQA.d.mts} +8 -3
- package/dist/CloudflareConfig.d.mts +2 -2
- package/dist/CloudflareConfig.mjs +7 -1
- package/dist/CloudflareConfig.mjs.map +1 -1
- package/dist/CloudflareThreadClient.d.mts +48 -34
- package/dist/{ThreadObject-BVMSQgqx.d.mts → ThreadObject-CZxuDWOb.d.mts} +9 -7
- package/dist/{ThreadObject-DvRG0dDz.mjs → ThreadObject-DXavwaU-.mjs} +34 -16
- package/dist/ThreadObject-DXavwaU-.mjs.map +1 -0
- 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 +124 -50
- package/src/CloudflareConfig.ts +6 -0
- package/src/internal/layers.ts +7 -2
- package/src/internal/message-delivery.ts +69 -40
- package/dist/ThreadObject-DvRG0dDz.mjs.map +0 -1
package/src/Alarm.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
Random,
|
|
13
13
|
Ref,
|
|
14
14
|
Schema,
|
|
15
|
+
Scope,
|
|
15
16
|
Semaphore,
|
|
16
17
|
Stream,
|
|
17
18
|
} from "effect";
|
|
@@ -31,7 +32,7 @@ import {
|
|
|
31
32
|
import { SqlClient } from "effect/unstable/sql/SqlClient";
|
|
32
33
|
|
|
33
34
|
import { DurableObjectContext } from "./CloudflareBindings.ts";
|
|
34
|
-
import { CloudflareDurableRuntimeConfig } from "./CloudflareConfig.ts";
|
|
35
|
+
import { AuxiliaryDispatchMillis, CloudflareDurableRuntimeConfig } from "./CloudflareConfig.ts";
|
|
35
36
|
import { safeCauseMessage } from "./internal/boundary.ts";
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -231,6 +232,8 @@ export class ThreadMaintenanceFailpoint extends Context.Service<
|
|
|
231
232
|
* `invalidate`, `prepareGeneration` and `pendingDeadline` must be bounded local operations.
|
|
232
233
|
* `prepareGeneration` durably invalidates a scan only when its generation changes; repeated
|
|
233
234
|
* calls must preserve partial scan progress. It runs with no source mutation in flight.
|
|
235
|
+
* Use this gate only for publication required before dependent native execution. Independent
|
|
236
|
+
* UI relays and outboxes belong to ThreadHostMaintenance.
|
|
234
237
|
* `drain` performs bounded delivery and persists retries before returning. A pending deadline
|
|
235
238
|
* defers runtime recovery/Attempts, allowing committed host publications to drain first.
|
|
236
239
|
* Unexpected hook failures leave the prearmed generation for retry. Hooks acquire per-call
|
|
@@ -258,38 +261,53 @@ export class ThreadPublication extends Context.Service<
|
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
/**
|
|
261
|
-
* Host-assembled message recovery
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
+
* Host-assembled native message recovery. The driver bounds each actual Claim and persists its
|
|
265
|
+
* timeout/retry before this pump returns. Do not add a second timer starting at batch selection:
|
|
266
|
+
* local Claim setup may take time, and expiration still owes the driver's local retry commit.
|
|
264
267
|
*/
|
|
265
268
|
export const ThreadMessageDelivery = Context.Reference<{
|
|
266
|
-
readonly
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
) => Effect.Effect<void, DurableAlarmError>;
|
|
269
|
+
readonly drainUntil: (
|
|
270
|
+
sourceFinished: Effect.Effect<void>,
|
|
271
|
+
dispatchUntil: DateTime.Utc,
|
|
272
|
+
) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
|
|
271
273
|
readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
|
|
272
274
|
}>("@effect-agent/platform-cloudflare/ThreadMessageDelivery", {
|
|
273
|
-
defaultValue: () => ({
|
|
275
|
+
defaultValue: () => ({
|
|
276
|
+
drainUntil: () => Effect.void,
|
|
277
|
+
pendingDeadline: Effect.succeed(Option.none()),
|
|
278
|
+
}),
|
|
274
279
|
});
|
|
275
280
|
|
|
276
281
|
/**
|
|
277
|
-
* Application obligations sharing this Object's alarm.
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
282
|
+
* Application obligations sharing this Object's alarm. Admit one initial external wave even on
|
|
283
|
+
* a caught-up pass, then respond to wakes while native work runs. sourceFinished closes only
|
|
284
|
+
* admission of NEW external waves. Keep local admission/hub subscriptions in the supplied event
|
|
285
|
+
* Scope until maintenance tears it down; do not scope them to sourceFinished or to this Effect.
|
|
286
|
+
* No deadline sleeps or automatic retry loops. Return after already-admitted waves finish.
|
|
287
|
+
*
|
|
288
|
+
* Declare a finite whole-wave allowance (1..300000ms): maximum for parallel lanes, sum for
|
|
289
|
+
* sequential operations. Admit a wave only if its allowance fits before dispatchUntil. Later
|
|
290
|
+
* arrivals cannot renew the retirement window. Maintenance bounds the join after sourceFinished,
|
|
291
|
+
* interrupts and joins event Scope, then reads local deadlines under the mutation gate.
|
|
292
|
+
*
|
|
293
|
+
* Setup and pendingDeadline are bounded local operations. Persist claims/envelopes before
|
|
294
|
+
* dispatch; interrupted waits leave exact retries/receipts recoverable. Cancellation is local,
|
|
295
|
+
* not remote rollback. Retry accounting is unchanged. Network waits/finalizers must be
|
|
296
|
+
* interruptible; short local atomic commits may be uninterruptible. Hooks never write the raw
|
|
297
|
+
* alarm slot. Required native publication gates belong to ThreadPublication.
|
|
283
298
|
*/
|
|
284
299
|
export const ThreadHostMaintenance = Context.Reference<{
|
|
285
|
-
readonly
|
|
300
|
+
readonly dispatchTimeoutMillis: number;
|
|
286
301
|
readonly drainUntil: (
|
|
287
|
-
|
|
288
|
-
|
|
302
|
+
sourceFinished: Effect.Effect<void>,
|
|
303
|
+
dispatchUntil: DateTime.Utc,
|
|
304
|
+
) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
|
|
305
|
+
readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
|
|
289
306
|
}>("@effect-agent/platform-cloudflare/ThreadHostMaintenance", {
|
|
290
307
|
defaultValue: () => ({
|
|
291
|
-
|
|
308
|
+
dispatchTimeoutMillis: 1,
|
|
292
309
|
drainUntil: () => Effect.void,
|
|
310
|
+
pendingDeadline: Effect.succeed(Option.none()),
|
|
293
311
|
}),
|
|
294
312
|
});
|
|
295
313
|
|
|
@@ -403,7 +421,11 @@ export class ThreadMutationGate extends Context.Service<
|
|
|
403
421
|
{
|
|
404
422
|
readonly withMutation: <A, E, R>(
|
|
405
423
|
body: Effect.Effect<A, E, R>,
|
|
406
|
-
/**
|
|
424
|
+
/**
|
|
425
|
+
* Native admission, approval, abort and unknown resolution keep the default true.
|
|
426
|
+
* Projection/relay/reply receipt-only bookkeeping must use false: its local durable
|
|
427
|
+
* pendingDeadline owns scheduling without creating native recovery debt.
|
|
428
|
+
*/
|
|
407
429
|
options?: { readonly invalidatesRecovery: boolean },
|
|
408
430
|
) => Effect.Effect<A, E | DurableAlarmError, R>;
|
|
409
431
|
readonly withSnapshot: <A, E, R>(
|
|
@@ -491,9 +513,13 @@ export type MaintenancePassFailure =
|
|
|
491
513
|
* alarm takes an O(1) path without recovery, ledger scans, or canonical-history reads.
|
|
492
514
|
* 2. Recovery strictly precedes a new claim. One head Attempt advances the lane and requests
|
|
493
515
|
* a safe yield after ten minutes. The whole event has a fourteen-minute cooperative timeout.
|
|
494
|
-
* 3.
|
|
516
|
+
* 3. Auxiliary dispatch and listeners belong to the event Scope. After native completion,
|
|
517
|
+
* stop new external waves, join native delivery through its driver-owned Claim deadline,
|
|
518
|
+
* and bound host/backfill work by explicit allowances before closing and joining Scope.
|
|
519
|
+
* Ordinary auxiliary setup failures are reported after the one native opportunity.
|
|
520
|
+
* 4. The final transaction acknowledges only the generation observed at pass start. A racing
|
|
495
521
|
* mutation therefore remains `dirty > processed` and retains its atomically-established alarm.
|
|
496
|
-
*
|
|
522
|
+
* 5. Stable external waits acknowledge and clear. Autonomous retry, indeterminate, and lease
|
|
497
523
|
* recovery states leave their generation dirty and retain bounded backoff rearming.
|
|
498
524
|
*/
|
|
499
525
|
export class ThreadMaintenance extends Context.Service<
|
|
@@ -618,7 +644,10 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
618
644
|
// Prearm even a publication-only pass before invoking any host hook.
|
|
619
645
|
await ensureTransactionAlarmBy(transaction, now + minimumAlarmDelay);
|
|
620
646
|
|
|
621
|
-
return {
|
|
647
|
+
return {
|
|
648
|
+
_tag: "CaughtUp" as const,
|
|
649
|
+
nonterminal: state.nonterminal,
|
|
650
|
+
};
|
|
622
651
|
}
|
|
623
652
|
// Pre-arm the earliest retry before recovery. A successful finish may move this slot
|
|
624
653
|
// LATER to its bounded backoff, which does not cancel the running handler.
|
|
@@ -655,7 +684,8 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
655
684
|
|
|
656
685
|
const pass = Effect.fn("ThreadMaintenance.pass")(function* (
|
|
657
686
|
yieldAfter: DateTime.Utc,
|
|
658
|
-
|
|
687
|
+
dispatchUntil: DateTime.Utc,
|
|
688
|
+
): Effect.fn.Return<MaintenancePassReport, MaintenancePassFailure, Scope.Scope> {
|
|
659
689
|
const annotate = (report: MaintenancePassReport) =>
|
|
660
690
|
Effect.annotateCurrentSpan({
|
|
661
691
|
phase: report.phase,
|
|
@@ -678,32 +708,75 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
678
708
|
}),
|
|
679
709
|
);
|
|
680
710
|
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const delivery = yield* Effect.forkChild(
|
|
687
|
-
messages.drainUntil?.(deliveryFinished) ?? messages.drain,
|
|
711
|
+
// This scope owns auxiliary dispatch and listeners, independently of source completion.
|
|
712
|
+
// Close it before acknowledgement, including on failure or event interruption.
|
|
713
|
+
const auxiliaryScope = yield* Effect.acquireRelease(Scope.make("parallel"), (scope, exit) =>
|
|
714
|
+
Scope.close(scope, exit),
|
|
688
715
|
);
|
|
689
716
|
|
|
690
|
-
const
|
|
717
|
+
const sourceFinished = yield* Deferred.make<void>();
|
|
718
|
+
const finished = Deferred.await(sourceFinished);
|
|
691
719
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
720
|
+
// Fork setup too: an ordinary auxiliary setup failure is reported after native work,
|
|
721
|
+
// rather than gating its opportunity. Event interruption still closes every fiber.
|
|
722
|
+
const deliveryFiber = yield* Effect.forkIn(
|
|
723
|
+
Scope.provide(auxiliaryScope)(messages.drainUntil(finished, dispatchUntil)),
|
|
724
|
+
auxiliaryScope,
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
const hostFiber = yield* Effect.forkIn(
|
|
728
|
+
Scope.provide(auxiliaryScope)(
|
|
729
|
+
Effect.gen(function* () {
|
|
730
|
+
yield* Schema.decodeEffect(AuxiliaryDispatchMillis)(host.dispatchTimeoutMillis).pipe(
|
|
731
|
+
Effect.mapError((cause) =>
|
|
732
|
+
DurableAlarmError.make({
|
|
733
|
+
operation: "host dispatch allowance",
|
|
734
|
+
message:
|
|
735
|
+
"Declare an integer whole-wave allowance between 1 and 300000 milliseconds",
|
|
736
|
+
cause,
|
|
737
|
+
}),
|
|
738
|
+
),
|
|
739
|
+
);
|
|
740
|
+
yield* host.drainUntil(finished, dispatchUntil);
|
|
741
|
+
}),
|
|
696
742
|
),
|
|
743
|
+
auxiliaryScope,
|
|
697
744
|
);
|
|
698
745
|
|
|
699
|
-
//
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
746
|
+
// Backfill is one disposable wave; its timer starts beside native execution.
|
|
747
|
+
const backfill = yield* Effect.forkIn(
|
|
748
|
+
drainDue.pipe(
|
|
749
|
+
Effect.provideService(ThreadProjectionMaintenance, projection),
|
|
750
|
+
Effect.timeoutOption(config.projectionDispatchTimeoutMillis),
|
|
751
|
+
),
|
|
752
|
+
auxiliaryScope,
|
|
703
753
|
);
|
|
704
754
|
|
|
705
|
-
|
|
706
|
-
|
|
755
|
+
const finishAuxiliary = Effect.gen(function* () {
|
|
756
|
+
yield* Deferred.succeed(sourceFinished, undefined);
|
|
757
|
+
|
|
758
|
+
const remaining = Math.max(
|
|
759
|
+
1,
|
|
760
|
+
DateTime.toEpochMillis(dispatchUntil) - (yield* Clock.currentTimeMillis),
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
const hostJoin = yield* Effect.forkIn(
|
|
764
|
+
Fiber.join(hostFiber).pipe(
|
|
765
|
+
Effect.timeoutOption(Math.min(host.dispatchTimeoutMillis, remaining)),
|
|
766
|
+
Effect.tap((result) =>
|
|
767
|
+
Effect.annotateCurrentSpan({ "host.timedOut": Option.isNone(result) }),
|
|
768
|
+
),
|
|
769
|
+
),
|
|
770
|
+
auxiliaryScope,
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
// Native transport uses the driver's actual Claim deadline, through Retry persistence.
|
|
774
|
+
// Only our host/backfill timers suppress their own interruption. Earlier failed exits,
|
|
775
|
+
// including pure self-interruption, remain failures while siblings are still blocked.
|
|
776
|
+
yield* Fiber.joinAll([deliveryFiber, hostJoin, backfill]);
|
|
777
|
+
yield* Scope.close(auxiliaryScope, Exit.void);
|
|
778
|
+
});
|
|
779
|
+
|
|
707
780
|
const deadline = yield* publication.pendingDeadline;
|
|
708
781
|
|
|
709
782
|
if (
|
|
@@ -715,8 +788,7 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
715
788
|
const pending = yield* publication.pendingDeadline;
|
|
716
789
|
|
|
717
790
|
if (started._tag === "CaughtUp" || Option.isSome(pending)) {
|
|
718
|
-
yield*
|
|
719
|
-
if (Exit.isFailure(projected)) return yield* Effect.failCause(projected.cause);
|
|
791
|
+
yield* finishAuxiliary;
|
|
720
792
|
yield* failpoint.hit("maintenance:finish:before");
|
|
721
793
|
|
|
722
794
|
const disposition = yield* mutations.withSnapshot((active) =>
|
|
@@ -817,9 +889,7 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
817
889
|
? Option.none()
|
|
818
890
|
: yield* runtime.processThreadHead(selected, { yieldAfter });
|
|
819
891
|
|
|
820
|
-
yield*
|
|
821
|
-
if (Exit.isFailure(projected)) return yield* Effect.failCause(projected.cause);
|
|
822
|
-
// Observe residual state before acknowledging this exact pass-start generation.
|
|
892
|
+
yield* finishAuxiliary;
|
|
823
893
|
const remaining = yield* Stream.runCollect(ledger.scanNonterminal);
|
|
824
894
|
const waitingHeads = new Map<ThreadId, boolean>();
|
|
825
895
|
|
|
@@ -940,9 +1010,13 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
940
1010
|
return ThreadMaintenance.of({
|
|
941
1011
|
// A mid-pass immediate hint is droppable; durable dirty state decides the final alarm.
|
|
942
1012
|
pass: Effect.gen(function* () {
|
|
943
|
-
const
|
|
1013
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1014
|
+
const yieldAfter = DateTime.makeUnsafe(now + 10 * 60_000);
|
|
1015
|
+
const dispatchUntil = DateTime.makeUnsafe(now + 14 * 60_000);
|
|
944
1016
|
|
|
945
|
-
return yield* alarm.withWakesDeferred(
|
|
1017
|
+
return yield* alarm.withWakesDeferred(
|
|
1018
|
+
maintenancePassGate.withPermit(Effect.scoped(pass(yieldAfter, dispatchUntil))),
|
|
1019
|
+
);
|
|
946
1020
|
}).pipe(
|
|
947
1021
|
// Include permit waiting, recovery and acknowledgement in the event deadline.
|
|
948
1022
|
// Interruption releases Attempt ownership, leaving the prearmed dirty generation
|
package/src/CloudflareConfig.ts
CHANGED
|
@@ -10,6 +10,9 @@ import { DEFAULT_OWNERSHIP_LEASE_DURATION } from "effect-agent/submission-ledger
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const PositiveMillis = Schema.Int.check(Schema.isGreaterThan(0));
|
|
13
|
+
|
|
14
|
+
/** Maximum supported auxiliary wave equals the native message policy's five-minute ceiling. */
|
|
15
|
+
export const AuxiliaryDispatchMillis = PositiveMillis.check(Schema.isLessThanOrEqualTo(300_000));
|
|
13
16
|
const NonNegativeMillis = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
|
|
14
17
|
|
|
15
18
|
/** The supplied Cloudflare durable runtime configuration failed validation (DEPLOY-003). */
|
|
@@ -104,6 +107,8 @@ export class CloudflareDurableRuntimeConfigValue extends Schema.Class<Cloudflare
|
|
|
104
107
|
abortPollInterval: PositiveMillis,
|
|
105
108
|
/** Canonical observation poll cadence of the Durable Object store. */
|
|
106
109
|
observationPollInterval: NonNegativeMillis,
|
|
110
|
+
/** One disposable projection backfill wave; independent of required live applyCommitted. */
|
|
111
|
+
projectionDispatchTimeoutMillis: AuxiliaryDispatchMillis,
|
|
107
112
|
/** Per-value byte bound; must stay under the platform's 2 MB SQLite value limit. */
|
|
108
113
|
maxStoredValueBytes: Schema.Int.check(
|
|
109
114
|
Schema.isGreaterThan(0),
|
|
@@ -130,6 +135,7 @@ export const CLOUDFLARE_RUNTIME_DEFAULTS = {
|
|
|
130
135
|
leaseRenewalInterval: 10_000,
|
|
131
136
|
abortPollInterval: 500,
|
|
132
137
|
observationPollInterval: 25,
|
|
138
|
+
projectionDispatchTimeoutMillis: 30_000,
|
|
133
139
|
maxStoredValueBytes: DEFAULT_MAX_STORED_VALUE_BYTES,
|
|
134
140
|
verifyOnOpen: false,
|
|
135
141
|
maxQueueDepthPerLane: 256,
|
package/src/internal/layers.ts
CHANGED
|
@@ -140,6 +140,8 @@ export interface CloudflareDurableRuntimeOptions {
|
|
|
140
140
|
readonly toolFailureObserver?: RunToolFailureObserver | undefined;
|
|
141
141
|
/** Milliseconds; default 25. */
|
|
142
142
|
readonly observationPollInterval?: number | undefined;
|
|
143
|
+
/** Whole disposable projection wave in milliseconds, 1..300000; default 30000. */
|
|
144
|
+
readonly projectionDispatchTimeoutMillis?: number | undefined;
|
|
143
145
|
/** Bytes; default just under the 2 MB platform value limit. */
|
|
144
146
|
readonly maxStoredValueBytes?: number | undefined;
|
|
145
147
|
/** Default false. */
|
|
@@ -254,6 +256,9 @@ const configFromOptions = (
|
|
|
254
256
|
abortPollInterval: options.abortPollInterval ?? CLOUDFLARE_RUNTIME_DEFAULTS.abortPollInterval,
|
|
255
257
|
observationPollInterval:
|
|
256
258
|
options.observationPollInterval ?? CLOUDFLARE_RUNTIME_DEFAULTS.observationPollInterval,
|
|
259
|
+
projectionDispatchTimeoutMillis:
|
|
260
|
+
options.projectionDispatchTimeoutMillis ??
|
|
261
|
+
CLOUDFLARE_RUNTIME_DEFAULTS.projectionDispatchTimeoutMillis,
|
|
257
262
|
maxStoredValueBytes:
|
|
258
263
|
options.maxStoredValueBytes ?? CLOUDFLARE_RUNTIME_DEFAULTS.maxStoredValueBytes,
|
|
259
264
|
verifyOnOpen: options.verifyOnOpen ?? CLOUDFLARE_RUNTIME_DEFAULTS.verifyOnOpen,
|
|
@@ -609,8 +614,8 @@ const sharedLayer = <A, E, R, PE = never, PR = never>(
|
|
|
609
614
|
);
|
|
610
615
|
|
|
611
616
|
const messageRecovery = threadMessageDeliveryLayer.pipe(
|
|
612
|
-
//
|
|
613
|
-
//
|
|
617
|
+
// Four parallel attempts per wave. Native completion stops new wake-driven waves;
|
|
618
|
+
// retained retry deadlines schedule future alarms.
|
|
614
619
|
Layer.provide(MessageDeliveryDriver.layer({ batchSize: 4, concurrency: 4 })),
|
|
615
620
|
Layer.provide(cloudflarePreparedInputAdmissionLayer),
|
|
616
621
|
Layer.provide(CloudflareThreadClient.layer),
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Clock,
|
|
3
|
+
Context,
|
|
4
|
+
DateTime,
|
|
5
|
+
Effect,
|
|
6
|
+
Fiber,
|
|
7
|
+
Layer,
|
|
8
|
+
Option,
|
|
9
|
+
Ref,
|
|
10
|
+
Semaphore,
|
|
11
|
+
Stream,
|
|
12
|
+
} from "effect";
|
|
2
13
|
import { type ThreadId } from "effect-agent/identifiers";
|
|
3
14
|
import {
|
|
4
15
|
MessageDeliveryDriver,
|
|
@@ -9,7 +20,6 @@ import { WakeScheduler } from "effect-agent/wake-scheduler";
|
|
|
9
20
|
|
|
10
21
|
import { DurableAlarmError, ThreadMessageDelivery, ThreadMutationGate } from "../Alarm.ts";
|
|
11
22
|
import { ThreadObjectPlacement } from "../CloudflareBindings.ts";
|
|
12
|
-
import { CloudflareDurableRuntimeConfig } from "../CloudflareConfig.ts";
|
|
13
23
|
|
|
14
24
|
/** Every write prearms its owner; the delivery due index owns its recovery deadline. */
|
|
15
25
|
export const guardedMessageDeliveryStoreLayer = Layer.effect(
|
|
@@ -94,8 +104,6 @@ export const threadMessageDeliveryLayer = Layer.effectContext(
|
|
|
94
104
|
Effect.gen(function* () {
|
|
95
105
|
const driver = yield* MessageDeliveryDriver;
|
|
96
106
|
const store = yield* MessageDeliveryStore;
|
|
97
|
-
const wakes = yield* WakeScheduler;
|
|
98
|
-
const config = yield* CloudflareDurableRuntimeConfig;
|
|
99
107
|
|
|
100
108
|
const failure = (operation: string) => () =>
|
|
101
109
|
DurableAlarmError.make({
|
|
@@ -103,47 +111,68 @@ export const threadMessageDeliveryLayer = Layer.effectContext(
|
|
|
103
111
|
message: "Durable message recovery remains pending",
|
|
104
112
|
});
|
|
105
113
|
|
|
106
|
-
const
|
|
114
|
+
const wakes = yield* WakeScheduler;
|
|
115
|
+
|
|
116
|
+
const prepare = Effect.gen(function* () {
|
|
107
117
|
const deadline = yield* store.nextDeadline();
|
|
108
118
|
|
|
109
|
-
if (deadline
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
119
|
+
if (deadline === null || deadline > (yield* Clock.currentTimeMillis))
|
|
120
|
+
return { timeoutMillis: 1, run: Effect.void };
|
|
121
|
+
// The assembled driver has four permits: a wave is one parallel attempt window.
|
|
122
|
+
const keys = yield* store.due(yield* Clock.currentTimeMillis, 4);
|
|
123
|
+
const records = yield* Effect.forEach(keys, (key) => store.get(key));
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
timeoutMillis: Math.max(
|
|
127
|
+
1,
|
|
128
|
+
...records.map((record) => record?.policy.attemptTimeoutMillis ?? 1),
|
|
129
|
+
),
|
|
130
|
+
run: Effect.forEach(keys, (key) => driver.process(key), {
|
|
131
|
+
concurrency: 4,
|
|
132
|
+
discard: true,
|
|
133
|
+
}).pipe(Effect.mapError(failure("dispatch message delivery"))),
|
|
134
|
+
};
|
|
135
|
+
}).pipe(Effect.mapError(failure("prepare message delivery")));
|
|
113
136
|
|
|
114
137
|
return Context.make(ThreadMessageDelivery, {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const notified = (yield* Stream.toPull(wakes.wakes)).pipe(Effect.catch(() => Effect.never));
|
|
122
|
-
|
|
123
|
-
// Always finish one wave; source completion prevents starting subsequent waves.
|
|
124
|
-
yield* Effect.gen(function* () {
|
|
125
|
-
yield* drain;
|
|
126
|
-
|
|
127
|
-
const deadline = yield* store
|
|
128
|
-
.nextDeadline()
|
|
129
|
-
.pipe(Effect.mapError(failure("read message deadline")));
|
|
130
|
-
|
|
131
|
-
// The index includes unfinished waves, lease expiry, retry and settlement polls.
|
|
132
|
-
// Yield at least one millisecond for an already-due deadline instead of spinning.
|
|
133
|
-
const delay =
|
|
134
|
-
deadline === null
|
|
135
|
-
? config.wakeScanInterval
|
|
136
|
-
: Math.min(
|
|
137
|
-
config.wakeScanInterval,
|
|
138
|
-
Math.max(1, deadline - (yield* Clock.currentTimeMillis)),
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
yield* Effect.raceFirst(
|
|
142
|
-
Deferred.await(finished),
|
|
143
|
-
Effect.raceFirst(notified, Effect.sleep(delay)),
|
|
138
|
+
drainUntil: (sourceFinished, dispatchUntil) =>
|
|
139
|
+
Effect.gen(function* () {
|
|
140
|
+
// Subscribe before the initial scan. Only external dispatch stops with the source;
|
|
141
|
+
// the enclosing event owns these resources until its actual teardown.
|
|
142
|
+
const notified = (yield* Stream.toPull(wakes.wakes)).pipe(
|
|
143
|
+
Effect.catch(() => Effect.never),
|
|
144
144
|
);
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
|
|
146
|
+
const done = yield* Effect.forkScoped(sourceFinished);
|
|
147
|
+
|
|
148
|
+
const select = (initial = false) =>
|
|
149
|
+
Effect.gen(function* () {
|
|
150
|
+
const wave = yield* prepare;
|
|
151
|
+
const now = yield* Clock.currentTimeMillis;
|
|
152
|
+
|
|
153
|
+
// Recheck after local preparation: a late selection cannot start another wave once
|
|
154
|
+
// the source ends. Always grant the initial opportunity to a caught-up alarm.
|
|
155
|
+
if (
|
|
156
|
+
(!initial && done.pollUnsafe() !== undefined) ||
|
|
157
|
+
now + wave.timeoutMillis > DateTime.toEpochMillis(dispatchUntil)
|
|
158
|
+
)
|
|
159
|
+
return;
|
|
160
|
+
yield* wave.run;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
yield* select(true);
|
|
164
|
+
while (done.pollUnsafe() === undefined) {
|
|
165
|
+
const wake = yield* Effect.raceFirst(
|
|
166
|
+
notified.pipe(Effect.map(Option.some)),
|
|
167
|
+
Fiber.join(done).pipe(Effect.as(Option.none())),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (Option.isNone(wake)) return;
|
|
171
|
+
yield* Effect.forEach(wake.value, () => select(), { discard: true });
|
|
172
|
+
}
|
|
173
|
+
// No deadline sleeps: the driver finishes its one active parallel wave, including
|
|
174
|
+
// timeout/backoff commits; retained retries belong to a future physical alarm.
|
|
175
|
+
}),
|
|
147
176
|
pendingDeadline: store
|
|
148
177
|
.nextDeadline()
|
|
149
178
|
.pipe(Effect.map(Option.fromNullishOr), Effect.mapError(failure("read message deadline"))),
|