@effect-agent/platform-cloudflare 0.1.0-beta.112 → 0.1.0-beta.114
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 -12
- package/dist/Alarm.mjs +118 -29
- package/dist/Alarm.mjs.map +1 -1
- package/dist/CloudflareThreadClient.d.mts +8 -8
- package/dist/{ThreadObject-DVxgrU7c.d.mts → ThreadObject-B97Se7j5.d.mts} +33 -33
- package/dist/{ThreadObject-0k-SVpY_.mjs → ThreadObject-BPt5uByD.mjs} +17 -8
- package/dist/ThreadObject-BPt5uByD.mjs.map +1 -0
- 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 +247 -46
- package/src/internal/message-delivery.ts +33 -23
- package/dist/ThreadObject-0k-SVpY_.mjs.map +0 -1
package/src/Alarm.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
Fiber,
|
|
10
10
|
Layer,
|
|
11
11
|
Option,
|
|
12
|
+
PubSub,
|
|
12
13
|
Random,
|
|
13
14
|
Ref,
|
|
14
15
|
Result,
|
|
@@ -285,6 +286,65 @@ export class ThreadPublication extends Context.Service<
|
|
|
285
286
|
});
|
|
286
287
|
}
|
|
287
288
|
|
|
289
|
+
/** Event-local accounting shared by the existing maintenance pumps and native scheduler. */
|
|
290
|
+
export class ThreadMaintenanceActivity extends Context.Service<
|
|
291
|
+
ThreadMaintenanceActivity,
|
|
292
|
+
{
|
|
293
|
+
/**
|
|
294
|
+
* Account for one finite selection/dispatch, through joined completion or interruption.
|
|
295
|
+
* Register before reading/claiming work. After dispatch closes the body is not started.
|
|
296
|
+
* Host waves retain their declared allowance from this call; message Claims keep the driver's
|
|
297
|
+
* own deadline. Waiting for notifications belongs outside this bracket.
|
|
298
|
+
*/
|
|
299
|
+
readonly run: <R>(
|
|
300
|
+
wave: Effect.Effect<void, DurableAlarmError, R>,
|
|
301
|
+
) => Effect.Effect<void, DurableAlarmError, R>;
|
|
302
|
+
/** Acknowledge all initial subscriptions/scans. Composite hosts use `all` below. */
|
|
303
|
+
readonly ready: Effect.Effect<void>;
|
|
304
|
+
/**
|
|
305
|
+
* Acquire an independent scoped subscription before the initial scan and `ready`.
|
|
306
|
+
* The returned wait observes coalesced native scan hints, including those published before
|
|
307
|
+
* its first execution. Each hint requests a current local due-work check.
|
|
308
|
+
*/
|
|
309
|
+
readonly subscribeChanges: Effect.Effect<Effect.Effect<void>, never, Scope.Scope>;
|
|
310
|
+
}
|
|
311
|
+
>()("@effect-agent/platform-cloudflare/ThreadMaintenanceActivity") {
|
|
312
|
+
/** Join child pumps; acknowledge the parent only after every child is ready or has exited. */
|
|
313
|
+
static readonly all = Effect.fnUntraced(function* <A, E, R>(
|
|
314
|
+
lanes: ReadonlyArray<Effect.Effect<A, E, R>>,
|
|
315
|
+
): Effect.fn.Return<ReadonlyArray<A>, E, R | ThreadMaintenanceActivity> {
|
|
316
|
+
const activity = yield* ThreadMaintenanceActivity;
|
|
317
|
+
let remaining = lanes.length;
|
|
318
|
+
|
|
319
|
+
if (remaining === 0) return yield* activity.ready.pipe(Effect.as([]));
|
|
320
|
+
|
|
321
|
+
return yield* Effect.forEach(
|
|
322
|
+
lanes,
|
|
323
|
+
(lane) => {
|
|
324
|
+
let ready = false;
|
|
325
|
+
|
|
326
|
+
const child: ThreadMaintenanceActivity["Service"] = {
|
|
327
|
+
...activity,
|
|
328
|
+
ready: Effect.suspend(() => {
|
|
329
|
+
if (ready) return Effect.void;
|
|
330
|
+
ready = true;
|
|
331
|
+
|
|
332
|
+
return --remaining === 0 ? activity.ready : Effect.void;
|
|
333
|
+
}).pipe(Effect.uninterruptible),
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// A failed initial setup is accounted for without swallowing its Cause. Callers
|
|
337
|
+
// may collect Exits when sibling pumps must keep their independent opportunities.
|
|
338
|
+
return lane.pipe(
|
|
339
|
+
Effect.provideService(ThreadMaintenanceActivity, child),
|
|
340
|
+
Effect.onExit(() => child.ready),
|
|
341
|
+
);
|
|
342
|
+
},
|
|
343
|
+
{ concurrency: "unbounded" },
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
288
348
|
/**
|
|
289
349
|
* Host-assembled native message recovery. The driver bounds each actual Claim and persists its
|
|
290
350
|
* timeout/retry before this pump returns. Do not add a second timer starting at batch selection:
|
|
@@ -294,7 +354,7 @@ export const ThreadMessageDelivery = Context.Reference<{
|
|
|
294
354
|
readonly drainUntil: (
|
|
295
355
|
dispatchClosed: Effect.Effect<void>,
|
|
296
356
|
dispatchUntil: DateTime.Utc,
|
|
297
|
-
) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
|
|
357
|
+
) => Effect.Effect<void, DurableAlarmError, Scope.Scope | ThreadMaintenanceActivity>;
|
|
298
358
|
readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
|
|
299
359
|
}>("@effect-agent/platform-cloudflare/ThreadMessageDelivery", {
|
|
300
360
|
defaultValue: () => ({
|
|
@@ -305,14 +365,16 @@ export const ThreadMessageDelivery = Context.Reference<{
|
|
|
305
365
|
|
|
306
366
|
/**
|
|
307
367
|
* Application obligations sharing this Object's alarm. Admit one initial external wave even on
|
|
308
|
-
* a caught-up pass, then respond to wakes
|
|
309
|
-
*
|
|
310
|
-
*
|
|
368
|
+
* a caught-up pass, then respond to wakes and ThreadMaintenanceActivity.subscribeChanges until
|
|
369
|
+
* dispatchClosed. Yield the event's ThreadMaintenanceActivity to bracket each finite lane with
|
|
370
|
+
* run and acknowledge initial setup with ready.
|
|
371
|
+
* Passive subscriptions do not count as active work. Closure stops both new waves and native
|
|
372
|
+
* Attempts; local admission/control subscriptions belong to the event Scope until teardown.
|
|
311
373
|
* No deadline sleeps or automatic retry loops. Return after already-admitted waves finish.
|
|
312
374
|
*
|
|
313
375
|
* Declare a finite whole-wave allowance (1..300000ms): maximum for parallel lanes, sum for
|
|
314
376
|
* sequential operations. Admit a wave only if its allowance fits before dispatchUntil. Later
|
|
315
|
-
* arrivals cannot renew
|
|
377
|
+
* arrivals cannot renew an admitted wave's allowance. Maintenance bounds each registered wave,
|
|
316
378
|
* interrupts and joins event Scope, then reads local deadlines under the mutation gate.
|
|
317
379
|
*
|
|
318
380
|
* Setup and pendingDeadline are bounded local operations. Persist claims/envelopes before
|
|
@@ -326,7 +388,7 @@ export const ThreadHostMaintenance = Context.Reference<{
|
|
|
326
388
|
readonly drainUntil: (
|
|
327
389
|
dispatchClosed: Effect.Effect<void>,
|
|
328
390
|
dispatchUntil: DateTime.Utc,
|
|
329
|
-
) => Effect.Effect<void, DurableAlarmError, Scope.Scope>;
|
|
391
|
+
) => Effect.Effect<void, DurableAlarmError, Scope.Scope | ThreadMaintenanceActivity>;
|
|
330
392
|
readonly pendingDeadline: Effect.Effect<Option.Option<number>, DurableAlarmError>;
|
|
331
393
|
}>("@effect-agent/platform-cloudflare/ThreadHostMaintenance", {
|
|
332
394
|
defaultValue: () => ({
|
|
@@ -621,11 +683,12 @@ export type MaintenancePassFailure =
|
|
|
621
683
|
* 1. Prearm before any work. A caught-up native step uses the O(1) generation record without
|
|
622
684
|
* recovery, ledger scans or canonical-history reads.
|
|
623
685
|
* 2. Reconcile before each head Attempt, then checkpoint only the observed generation. A racing
|
|
624
|
-
* producer keeps its
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
686
|
+
* producer keeps its generation dirty and immediately eligible. Quiescent native retries
|
|
687
|
+
* retain their durable backoff.
|
|
688
|
+
* 3. Keep native and delivery admission open together while finite waves remain active, so
|
|
689
|
+
* fresh replies and abort controls can progress during unrelated cleanup. Close atomically
|
|
690
|
+
* at quiescence, or at the original ten-minute yield deadline, before retiring listeners.
|
|
691
|
+
* 4. Native message delivery retains its driver-owned Claim deadline. Host/backfill waves are
|
|
629
692
|
* bounded independently; incoming native work never restarts or cancels their attempts.
|
|
630
693
|
* Auxiliary failures are reported after the current native opportunity.
|
|
631
694
|
* 5. Close every event resource before the final gated deadline snapshot and alarm decision.
|
|
@@ -1317,16 +1380,20 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1317
1380
|
ctx.storage.transaction(async (transaction) => {
|
|
1318
1381
|
const { state } = await readMaintenanceState(transaction);
|
|
1319
1382
|
|
|
1383
|
+
const mutationOverlap =
|
|
1384
|
+
observation.activeAtStart > 0 || started.activeAtStart > 0 || active > 0;
|
|
1385
|
+
|
|
1320
1386
|
const processed =
|
|
1321
|
-
autonomous ||
|
|
1322
|
-
observation.activeAtStart > 0 ||
|
|
1323
|
-
started.activeAtStart > 0 ||
|
|
1324
|
-
active > 0
|
|
1387
|
+
autonomous || mutationOverlap
|
|
1325
1388
|
? state.processed
|
|
1326
1389
|
: state.processed > observation.generation
|
|
1327
1390
|
? state.processed
|
|
1328
1391
|
: observation.generation;
|
|
1329
1392
|
|
|
1393
|
+
// Enrollment precedes the mutation body. A retry from an overlapping snapshot
|
|
1394
|
+
// must not defer work that becomes ready later in that same generation.
|
|
1395
|
+
const canBackoff = !mutationOverlap && state.dirty === started.generation;
|
|
1396
|
+
|
|
1330
1397
|
const next = ThreadMaintenanceState.make({
|
|
1331
1398
|
...Struct.omit(state, ["retry"]),
|
|
1332
1399
|
processed,
|
|
@@ -1334,7 +1401,7 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1334
1401
|
bindingRetries: (state.bindingRetries ?? []).filter((retry) =>
|
|
1335
1402
|
remaining.some((row) => row.submissionId === retry.submissionId),
|
|
1336
1403
|
),
|
|
1337
|
-
...(autonomous && !progressed
|
|
1404
|
+
...(autonomous && !progressed && canBackoff
|
|
1338
1405
|
? {
|
|
1339
1406
|
retry: MaintenanceRetry.make({
|
|
1340
1407
|
generation: started.generation,
|
|
@@ -1348,12 +1415,10 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1348
1415
|
|
|
1349
1416
|
await transaction.put(MAINTENANCE_STATE_KEY, encodeMaintenanceState(next));
|
|
1350
1417
|
if (autonomous) {
|
|
1351
|
-
return
|
|
1352
|
-
? now + minimumAlarmDelay
|
|
1353
|
-
: now + delay;
|
|
1418
|
+
return now + (canBackoff ? delay : minimumAlarmDelay);
|
|
1354
1419
|
}
|
|
1355
1420
|
|
|
1356
|
-
return
|
|
1421
|
+
return mutationOverlap || next.dirty > next.processed
|
|
1357
1422
|
? now + minimumAlarmDelay
|
|
1358
1423
|
: undefined;
|
|
1359
1424
|
}),
|
|
@@ -1392,6 +1457,83 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1392
1457
|
Scope.close(scope, exit),
|
|
1393
1458
|
);
|
|
1394
1459
|
|
|
1460
|
+
const dispatchClosed = yield* Deferred.make<void>();
|
|
1461
|
+
const stopDispatch = Deferred.await(dispatchClosed);
|
|
1462
|
+
// Coalesce local scan hints while a lane is busy; one hint requests a current read.
|
|
1463
|
+
const checks = yield* PubSub.sliding<void>(1);
|
|
1464
|
+
|
|
1465
|
+
yield* Effect.addFinalizer(() => PubSub.shutdown(checks));
|
|
1466
|
+
let dispatchOpen = true;
|
|
1467
|
+
let activityChanged = yield* Deferred.make<void>();
|
|
1468
|
+
const signalActivity = Effect.suspend(() => Deferred.succeed(activityChanged, undefined));
|
|
1469
|
+
|
|
1470
|
+
const closeDispatch = Effect.sync(() => {
|
|
1471
|
+
dispatchOpen = false;
|
|
1472
|
+
}).pipe(Effect.andThen(Deferred.succeed(dispatchClosed, undefined)));
|
|
1473
|
+
|
|
1474
|
+
const makeActivity = Effect.fnUntraced(function* (allowance?: number) {
|
|
1475
|
+
const initialized = yield* Deferred.make<void>();
|
|
1476
|
+
let active = 0;
|
|
1477
|
+
let failed = false;
|
|
1478
|
+
|
|
1479
|
+
const ready = Deferred.succeed(initialized, undefined).pipe(
|
|
1480
|
+
Effect.andThen(signalActivity),
|
|
1481
|
+
Effect.asVoid,
|
|
1482
|
+
Effect.uninterruptible,
|
|
1483
|
+
);
|
|
1484
|
+
|
|
1485
|
+
const activity: ThreadMaintenanceActivity["Service"] = {
|
|
1486
|
+
ready,
|
|
1487
|
+
subscribeChanges: PubSub.subscribe(checks).pipe(Effect.map(PubSub.take)),
|
|
1488
|
+
run: (wave) =>
|
|
1489
|
+
Effect.acquireUseRelease(
|
|
1490
|
+
Effect.sync(() => {
|
|
1491
|
+
if (!dispatchOpen) return false;
|
|
1492
|
+
active++;
|
|
1493
|
+
|
|
1494
|
+
return true;
|
|
1495
|
+
}),
|
|
1496
|
+
(admitted) =>
|
|
1497
|
+
!admitted
|
|
1498
|
+
? Effect.void
|
|
1499
|
+
: (allowance === undefined
|
|
1500
|
+
? wave
|
|
1501
|
+
: wave.pipe(
|
|
1502
|
+
Effect.timeoutOrElse({
|
|
1503
|
+
duration: allowance,
|
|
1504
|
+
orElse: () =>
|
|
1505
|
+
DurableAlarmError.make({
|
|
1506
|
+
operation: "host dispatch allowance",
|
|
1507
|
+
message:
|
|
1508
|
+
"The admitted host wave exceeded its allowance; durable work remains pending",
|
|
1509
|
+
}),
|
|
1510
|
+
}),
|
|
1511
|
+
)
|
|
1512
|
+
).pipe(
|
|
1513
|
+
Effect.onExit((exit) =>
|
|
1514
|
+
Effect.sync(() => {
|
|
1515
|
+
if (Exit.isFailure(exit)) failed = true;
|
|
1516
|
+
}),
|
|
1517
|
+
),
|
|
1518
|
+
),
|
|
1519
|
+
(admitted) =>
|
|
1520
|
+
Effect.sync(() => {
|
|
1521
|
+
if (admitted) active--;
|
|
1522
|
+
}).pipe(Effect.andThen(signalActivity)),
|
|
1523
|
+
),
|
|
1524
|
+
};
|
|
1525
|
+
|
|
1526
|
+
return {
|
|
1527
|
+
activity,
|
|
1528
|
+
initialized: Deferred.await(initialized),
|
|
1529
|
+
quiet: () => active === 0 && Deferred.isDoneUnsafe(initialized),
|
|
1530
|
+
failed: () => failed,
|
|
1531
|
+
};
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
const messageActivity = yield* makeActivity();
|
|
1535
|
+
const hostActivity = yield* makeActivity(host.dispatchTimeoutMillis);
|
|
1536
|
+
|
|
1395
1537
|
const recovery: NativeRecovery = {
|
|
1396
1538
|
queue: yield* Deferred.make<ReadonlyArray<ThreadId>>(),
|
|
1397
1539
|
pending: new Set(),
|
|
@@ -1429,13 +1571,15 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1429
1571
|
auxiliaryScope,
|
|
1430
1572
|
);
|
|
1431
1573
|
|
|
1432
|
-
const dispatchClosed = yield* Deferred.make<void>();
|
|
1433
|
-
const stopDispatch = Deferred.await(dispatchClosed);
|
|
1434
|
-
|
|
1435
1574
|
// Fork setup too: an ordinary auxiliary setup failure is reported after native work,
|
|
1436
1575
|
// rather than gating its opportunity. Event interruption still closes every fiber.
|
|
1437
1576
|
const deliveryFiber = yield* Effect.forkIn(
|
|
1438
|
-
Scope.provide(auxiliaryScope)(
|
|
1577
|
+
Scope.provide(auxiliaryScope)(
|
|
1578
|
+
messages.drainUntil(stopDispatch, dispatchUntil).pipe(
|
|
1579
|
+
Effect.provideService(ThreadMaintenanceActivity, messageActivity.activity),
|
|
1580
|
+
Effect.onExit(() => messageActivity.activity.ready),
|
|
1581
|
+
),
|
|
1582
|
+
),
|
|
1439
1583
|
auxiliaryScope,
|
|
1440
1584
|
);
|
|
1441
1585
|
|
|
@@ -1452,8 +1596,29 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1452
1596
|
}),
|
|
1453
1597
|
),
|
|
1454
1598
|
);
|
|
1455
|
-
|
|
1456
|
-
|
|
1599
|
+
|
|
1600
|
+
// Initial setup must also be finite. Once it is accounted for, each actual
|
|
1601
|
+
// wave has its own unchanged allowance; passive listeners have no timer.
|
|
1602
|
+
const initialization = hostActivity.initialized.pipe(
|
|
1603
|
+
Effect.timeoutOrElse({
|
|
1604
|
+
duration: host.dispatchTimeoutMillis,
|
|
1605
|
+
orElse: () =>
|
|
1606
|
+
DurableAlarmError.make({
|
|
1607
|
+
operation: "host initialization",
|
|
1608
|
+
message:
|
|
1609
|
+
"The host did not account for initial maintenance before its allowance; durable work remains pending",
|
|
1610
|
+
}),
|
|
1611
|
+
}),
|
|
1612
|
+
Effect.andThen(Effect.never),
|
|
1613
|
+
);
|
|
1614
|
+
|
|
1615
|
+
yield* Effect.raceFirst(
|
|
1616
|
+
host
|
|
1617
|
+
.drainUntil(stopDispatch, dispatchUntil)
|
|
1618
|
+
.pipe(Effect.provideService(ThreadMaintenanceActivity, hostActivity.activity)),
|
|
1619
|
+
initialization,
|
|
1620
|
+
);
|
|
1621
|
+
}).pipe(Effect.onExit(() => hostActivity.activity.ready)),
|
|
1457
1622
|
),
|
|
1458
1623
|
auxiliaryScope,
|
|
1459
1624
|
);
|
|
@@ -1463,6 +1628,7 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1463
1628
|
drainDue.pipe(
|
|
1464
1629
|
Effect.provideService(ThreadProjectionMaintenance, projection),
|
|
1465
1630
|
Effect.timeoutOption(config.projectionDispatchTimeoutMillis),
|
|
1631
|
+
Effect.onExit(() => signalActivity),
|
|
1466
1632
|
),
|
|
1467
1633
|
auxiliaryScope,
|
|
1468
1634
|
);
|
|
@@ -1477,9 +1643,8 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1477
1643
|
|
|
1478
1644
|
observed.nativeOnly = false;
|
|
1479
1645
|
|
|
1480
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
// waiting for host retirement to close it would create a circular join.
|
|
1646
|
+
// Pump termination and quiescence are distinct: listeners await closure, while their
|
|
1647
|
+
// finite waves report activity. A held sibling never retires another lane's controls.
|
|
1483
1648
|
const hostJoin = yield* Effect.forkIn(
|
|
1484
1649
|
Effect.gen(function* () {
|
|
1485
1650
|
yield* stopDispatch;
|
|
@@ -1502,28 +1667,60 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1502
1667
|
Fiber.joinAll([deliveryFiber, hostJoin, backfill, recoveryFiber]),
|
|
1503
1668
|
);
|
|
1504
1669
|
|
|
1505
|
-
const auxiliaryPending = () =>
|
|
1506
|
-
deliveryFiber.pollUnsafe() === undefined ||
|
|
1507
|
-
hostFiber.pollUnsafe() === undefined ||
|
|
1508
|
-
backfill.pollUnsafe() === undefined ||
|
|
1509
|
-
recoveryFiber.pollUnsafe() === undefined;
|
|
1510
|
-
|
|
1511
1670
|
while (true) {
|
|
1512
1671
|
const recoveryFinished = recoveryFiber.pollUnsafe() !== undefined;
|
|
1672
|
+
const retiredExit = retired.pollUnsafe();
|
|
1513
1673
|
|
|
1514
1674
|
if (recoveryFinished) {
|
|
1515
1675
|
if (recovery.needsCheckpoint) {
|
|
1516
1676
|
yield* Fiber.join(recoveryFiber);
|
|
1517
|
-
//
|
|
1518
|
-
// delivery window closes, never after all reply listeners have retired.
|
|
1677
|
+
// Fold the original recovery observation into its checkpoint before closing.
|
|
1519
1678
|
result = yield* advance(started, yieldAfter, observed, recovery, settled === 0);
|
|
1520
1679
|
if (result.phase === "actionable") phase = "actionable";
|
|
1521
1680
|
settled += result.settled;
|
|
1522
1681
|
recovery.needsCheckpoint = false;
|
|
1682
|
+
yield* PubSub.publish(checks, undefined);
|
|
1523
1683
|
}
|
|
1524
|
-
yield* Deferred.succeed(dispatchClosed, undefined);
|
|
1525
1684
|
}
|
|
1526
|
-
if (
|
|
1685
|
+
if (retiredExit !== undefined && Exit.isFailure(retiredExit)) break;
|
|
1686
|
+
|
|
1687
|
+
if (
|
|
1688
|
+
recoveryFinished &&
|
|
1689
|
+
backfill.pollUnsafe() !== undefined &&
|
|
1690
|
+
messageActivity.quiet() &&
|
|
1691
|
+
hostActivity.quiet()
|
|
1692
|
+
) {
|
|
1693
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1694
|
+
const messageDeadline = yield* messages.pendingDeadline;
|
|
1695
|
+
const hostDeadline = yield* host.pendingDeadline;
|
|
1696
|
+
|
|
1697
|
+
const messageDue =
|
|
1698
|
+
deliveryFiber.pollUnsafe() === undefined &&
|
|
1699
|
+
!messageActivity.failed() &&
|
|
1700
|
+
Option.isSome(messageDeadline) &&
|
|
1701
|
+
messageDeadline.value <= now;
|
|
1702
|
+
|
|
1703
|
+
const hostDue =
|
|
1704
|
+
hostFiber.pollUnsafe() === undefined &&
|
|
1705
|
+
!hostActivity.failed() &&
|
|
1706
|
+
now + host.dispatchTimeoutMillis <= DateTime.toEpochMillis(dispatchUntil) &&
|
|
1707
|
+
Option.isSome(hostDeadline) &&
|
|
1708
|
+
hostDeadline.value <= now;
|
|
1709
|
+
|
|
1710
|
+
// No asynchronous operation separates the activity recheck from closure. A
|
|
1711
|
+
// racing registration either keeps this window open or cannot start its body.
|
|
1712
|
+
const closed = yield* Effect.sync(() => {
|
|
1713
|
+
if (messageDue || hostDue || !messageActivity.quiet() || !hostActivity.quiet())
|
|
1714
|
+
return false;
|
|
1715
|
+
|
|
1716
|
+
dispatchOpen = false;
|
|
1717
|
+
|
|
1718
|
+
return true;
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
if (closed) break;
|
|
1722
|
+
yield* PubSub.publish(checks, undefined);
|
|
1723
|
+
}
|
|
1527
1724
|
const now = yield* Clock.currentTimeMillis;
|
|
1528
1725
|
const until = DateTime.toEpochMillis(yieldAfter);
|
|
1529
1726
|
|
|
@@ -1537,6 +1734,8 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1537
1734
|
|
|
1538
1735
|
// A completion racing this iteration stays armed until the loop handles it.
|
|
1539
1736
|
const recoveryDone = recoveryFinished ? Effect.never : Fiber.await(recoveryFiber);
|
|
1737
|
+
const retirementDone = retiredExit === undefined ? Fiber.await(retired) : Effect.never;
|
|
1738
|
+
const changed = activityChanged;
|
|
1540
1739
|
|
|
1541
1740
|
const ready = yield* Effect.raceFirst(
|
|
1542
1741
|
Effect.raceFirst(notified, Effect.sleep(Math.max(0, next - now))).pipe(
|
|
@@ -1544,16 +1743,17 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1544
1743
|
),
|
|
1545
1744
|
Effect.raceFirst(
|
|
1546
1745
|
recoveryDone.pipe(Effect.as("recovery" as const)),
|
|
1547
|
-
|
|
1746
|
+
Effect.raceFirst(
|
|
1747
|
+
retirementDone.pipe(Effect.as("retired" as const)),
|
|
1748
|
+
Deferred.await(changed).pipe(Effect.as("activity" as const)),
|
|
1749
|
+
),
|
|
1548
1750
|
),
|
|
1549
1751
|
);
|
|
1550
1752
|
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
if (ready === "recovery") continue;
|
|
1554
|
-
if (ready === "retired" || retired.pollUnsafe() !== undefined) break;
|
|
1555
|
-
if (!auxiliaryPending()) continue;
|
|
1753
|
+
if (ready === "recovery" || ready === "retired") continue;
|
|
1754
|
+
if (ready === "activity") activityChanged = yield* Deferred.make<void>();
|
|
1556
1755
|
if ((yield* Clock.currentTimeMillis) >= until) break;
|
|
1756
|
+
if (ready === "native") yield* PubSub.publish(checks, undefined);
|
|
1557
1757
|
|
|
1558
1758
|
const awakened = yield* beginNative(observed);
|
|
1559
1759
|
|
|
@@ -1561,10 +1761,11 @@ export class ThreadMaintenance extends Context.Service<
|
|
|
1561
1761
|
if (result.phase === "actionable") phase = "actionable";
|
|
1562
1762
|
settled += result.settled;
|
|
1563
1763
|
observed.nativeOnly = false;
|
|
1764
|
+
if (result.settled > 0) yield* PubSub.publish(checks, undefined);
|
|
1564
1765
|
}
|
|
1565
1766
|
// The original native yield deadline closes all new waves even if old recovery
|
|
1566
1767
|
// is still pending. No recovery or delivery receives a renewed event budget.
|
|
1567
|
-
yield*
|
|
1768
|
+
yield* closeDispatch;
|
|
1568
1769
|
// Preserve driver-owned Claim deadlines and failures, then close every
|
|
1569
1770
|
// listener before the one final alarm decision.
|
|
1570
1771
|
yield* Fiber.join(retired);
|
|
@@ -18,7 +18,12 @@ import {
|
|
|
18
18
|
} from "effect-agent/message-delivery";
|
|
19
19
|
import { WakeScheduler } from "effect-agent/wake-scheduler";
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
DurableAlarmError,
|
|
23
|
+
ThreadMaintenanceActivity,
|
|
24
|
+
ThreadMessageDelivery,
|
|
25
|
+
ThreadMutationGate,
|
|
26
|
+
} from "../Alarm.ts";
|
|
22
27
|
import { ThreadObjectPlacement } from "../CloudflareBindings.ts";
|
|
23
28
|
|
|
24
29
|
/** Every write prearms its owner; the delivery due index owns its recovery deadline. */
|
|
@@ -137,38 +142,43 @@ export const threadMessageDeliveryLayer = Layer.effectContext(
|
|
|
137
142
|
return Context.make(ThreadMessageDelivery, {
|
|
138
143
|
drainUntil: (dispatchClosed, dispatchUntil) =>
|
|
139
144
|
Effect.gen(function* () {
|
|
145
|
+
const activity = yield* ThreadMaintenanceActivity;
|
|
140
146
|
// Subscribe before the initial scan. Only admission of new waves stops;
|
|
141
147
|
// the enclosing event owns these resources until its actual teardown.
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
);
|
|
148
|
+
const hinted = yield* Stream.toPull(wakes.wakes);
|
|
149
|
+
const checked = (yield* activity.subscribeChanges).pipe(Effect.as([undefined]));
|
|
150
|
+
const notified = Effect.raceFirst(hinted, checked).pipe(Effect.catch(() => Effect.never));
|
|
145
151
|
|
|
146
152
|
const done = yield* Effect.forkScoped(dispatchClosed);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
153
|
+
let exhausted = false;
|
|
154
|
+
|
|
155
|
+
const select = Effect.fnUntraced(function* (initial = false) {
|
|
156
|
+
if (exhausted) return;
|
|
157
|
+
const wave = yield* prepare;
|
|
158
|
+
const now = yield* Clock.currentTimeMillis;
|
|
159
|
+
|
|
160
|
+
// Recheck after local preparation: a late selection cannot start another wave once
|
|
161
|
+
// dispatch closes. Always grant the initial opportunity to a caught-up alarm.
|
|
162
|
+
if (!initial && done.pollUnsafe() !== undefined) return;
|
|
163
|
+
if (now + wave.timeoutMillis > DateTime.toEpochMillis(dispatchUntil)) {
|
|
164
|
+
// This pump has used its event opportunity. Keep the durable deadline for the
|
|
165
|
+
// next alarm without repeatedly selecting a wave that cannot fit this event.
|
|
166
|
+
exhausted = true;
|
|
167
|
+
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
yield* wave.run;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
yield* activity.run(activity.ready.pipe(Effect.andThen(select(true))));
|
|
174
|
+
while (!exhausted && done.pollUnsafe() === undefined) {
|
|
165
175
|
const wake = yield* Effect.raceFirst(
|
|
166
176
|
notified.pipe(Effect.map(Option.some)),
|
|
167
177
|
Fiber.join(done).pipe(Effect.as(Option.none())),
|
|
168
178
|
);
|
|
169
179
|
|
|
170
180
|
if (Option.isNone(wake)) return;
|
|
171
|
-
yield* Effect.forEach(wake.value, () => select(), { discard: true });
|
|
181
|
+
yield* Effect.forEach(wake.value, () => activity.run(select()), { discard: true });
|
|
172
182
|
}
|
|
173
183
|
// No deadline sleeps: the driver finishes its one active parallel wave, including
|
|
174
184
|
// timeout/backoff commits; retained retries belong to a future physical alarm.
|