effect-cf 0.54.0 → 0.55.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # effect-cf
2
2
 
3
+ ## 0.55.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#211](https://github.com/danieljvdm/effect-cf/pull/211) [`2764143`](https://github.com/danieljvdm/effect-cf/commit/276414374c3fe71f619c26ef1c447ab0f52f9ab4) Thanks [@danieljvdm](https://github.com/danieljvdm)! - Keep in-flight Durable Object work recoverable through a configurable short alarm deadline while preserving hourly parked recovery. Coalesce native deliveries with active processing without starting another pass.
8
+
3
9
  ## 0.54.0
4
10
 
5
11
  ### Minor Changes
@@ -21,6 +21,8 @@ declare const DEFAULT_MIN_REPEAT_INTERVAL_MS = 60000;
21
21
  interface ScheduleConfiguration {
22
22
  /** Check for due alarms after RPC/fetch responses unwind. Native alarms remain recovery authority. */
23
23
  readonly dispatchAfterEvent?: boolean;
24
+ /** Positive recovery deadline while dispatch or deferWakes is active. Defaults to one second. */
25
+ readonly inFlightRecovery?: Duration.Input;
24
26
  /** Retry floor, at least one second. Defaults to one second. */
25
27
  readonly minimumRetryDelay?: Duration.Input;
26
28
  /** Positive safe integer. Defaults to eight unchanged attempts. */
@@ -184,7 +186,7 @@ type AlarmScheduler = {
184
186
  * object's concurrent and nested scopes. Source/checkpoint transactions still commit;
185
187
  * the last scope reconciles their current deadlines on success, failure or interruption.
186
188
  * Run outside transactions, around bounded maintenance or inline work. The first scope's
187
- * parkedRetryDelay bounds the shared guard; renew it if it expires and is consumed while
189
+ * inFlightRecovery bounds the shared guard; renew it if it expires and is consumed while
188
190
  * scopes remain active. A process loss before exit recovers at that retained alarm.
189
191
  */
190
192
  readonly deferWakes: <A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E | InvalidScheduleConfigurationError | StorageOperationError, R>;
@@ -68,6 +68,7 @@ const DEFAULT_PROCESS_DUE_ALARMS_LIMIT = 100;
68
68
  const MIN_RETRY_DELAY_MS = 1e3;
69
69
  /** Minimum and default parked recovery interval. */
70
70
  const PARKED_RETRY_DELAY_MS = 36e5;
71
+ const DEFAULT_IN_FLIGHT_RECOVERY_MS = 1e3;
71
72
  /** Absolute product repeat floor. */
72
73
  const MIN_REPEAT_INTERVAL_MS = 1e3;
73
74
  /** Default product schedule floor. Never use repeats to poll state. */
@@ -78,6 +79,7 @@ const safeIntegerAtLeast = (minimum) => S.Int.check(S.isGreaterThanOrEqualTo(min
78
79
  const AlarmLifecycleSchema = S.Literals(["automatic", "manual"]);
79
80
  const ScheduleConfigurationSchema = S.Struct({
80
81
  dispatchAfterEvent: S.Boolean,
82
+ inFlightRecovery: safeIntegerAtLeast(1),
81
83
  minimumRetryDelay: safeIntegerAtLeast(MIN_RETRY_DELAY_MS),
82
84
  unchangedAttemptBudget: safeIntegerAtLeast(1),
83
85
  parkedRetryDelay: safeIntegerAtLeast(PARKED_RETRY_DELAY_MS),
@@ -142,6 +144,7 @@ const getScheduleConfiguration = Effect.fnUntraced(function* (defaults) {
142
144
  return yield* Effect.try({
143
145
  try: () => S.decodeUnknownSync(ScheduleConfigurationSchema)({
144
146
  dispatchAfterEvent: input.dispatchAfterEvent ?? false,
147
+ inFlightRecovery: Math.ceil(Duration.toMillis(input.inFlightRecovery ?? DEFAULT_IN_FLIGHT_RECOVERY_MS)),
145
148
  minimumRetryDelay: Math.ceil(Duration.toMillis(input.minimumRetryDelay ?? 1e3)),
146
149
  unchangedAttemptBudget: input.unchangedAttemptBudget ?? 8,
147
150
  parkedRetryDelay: Math.ceil(Duration.toMillis(input.parkedRetryDelay ?? 36e5)),
@@ -533,7 +536,7 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
533
536
  const configuration = yield* getScheduleConfiguration(configurationDefaults);
534
537
  const now = yield* Clock.currentTimeMillis;
535
538
  if (wakeDeferrals === 0) {
536
- deferredRecoveryDelay = configuration.parkedRetryDelay;
539
+ deferredRecoveryDelay = configuration.inFlightRecovery;
537
540
  deferredRecoveryAt = now + deferredRecoveryDelay;
538
541
  wakesReady ??= Deferred.makeUnsafe();
539
542
  }
@@ -566,6 +569,16 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
566
569
  })).pipe(Effect.onExit((exit) => Effect.gen(function* () {
567
570
  const reconcile = dispatchNeedsReconcile || dispatchStarted || Exit.isFailure(exit);
568
571
  dispatchActive = Exit.isFailure(exit) && !dispatchStarted;
572
+ if (dispatchActive) {
573
+ const recoveryDelay = yield* getScheduleConfiguration(configurationDefaults).pipe(Effect.match({
574
+ onFailure: () => PARKED_RETRY_DELAY_MS,
575
+ onSuccess: (configuration) => configuration.parkedRetryDelay
576
+ }));
577
+ if (wakeDeferrals === 0) {
578
+ deferredRecoveryDelay = recoveryDelay;
579
+ deferredRecoveryAt = 0;
580
+ }
581
+ }
569
582
  const reconciled = yield* (reconcile ? transaction(() => Effect.void) : Effect.void).pipe(Effect.exit);
570
583
  dispatchActive = false;
571
584
  pendingAlarm = void 0;
@@ -607,7 +620,10 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
607
620
  const dueRows = yield* select("automatic", limit);
608
621
  const manualRows = yield* select("manual", DEFAULT_PROCESS_DUE_ALARMS_LIMIT);
609
622
  if (manualRows.length > 0) yield* transaction(() => Effect.gen(function* () {
610
- for (const row of manualRows) yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`, now + configuration.parkedRetryDelay, now + configuration.parkedRetryDelay, row.storage_id, row.storage_id, row.revision);
623
+ for (const row of manualRows) {
624
+ const recoveryAt = now + (options?.[ManualTags]?.has(row.tag) ? configuration.inFlightRecovery : configuration.parkedRetryDelay);
625
+ yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`, recoveryAt, recoveryAt, row.storage_id, row.storage_id, row.revision);
626
+ }
611
627
  }));
612
628
  const handled = [];
613
629
  const failed = [];
@@ -665,7 +681,10 @@ var DurableObjectAlarm = class DurableObjectAlarm extends Context.Service()("eff
665
681
  const permits = dispatch?.permits ?? (yield* Semaphore.make(4));
666
682
  const automatic = Effect.forEach(dueRows, handleRow, { discard: true }).pipe(Effect.exit);
667
683
  const exits = yield* manualRows.length === 0 ? automatic.pipe(Effect.map((exit) => [exit])) : Effect.all([automatic, Effect.forEach(manualRows, (row) => permits.withPermits(1)(handleRow(row)).pipe(Effect.exit), { concurrency: 4 }).pipe(Effect.flatMap(Exit.asVoidAll), Effect.exit)], { concurrency: 2 });
668
- yield* transaction(() => Effect.void);
684
+ yield* transaction(() => Effect.gen(function* () {
685
+ const recoveryAt = (yield* Clock.currentTimeMillis) + configuration.parkedRetryDelay;
686
+ for (const row of manualRows) yield* state.storage.sql.exec(`UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`, recoveryAt, recoveryAt, row.storage_id, row.storage_id, row.revision);
687
+ }));
669
688
  yield* Exit.asVoidAll(exits);
670
689
  return {
671
690
  failed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-cf",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "Effect-native primitives for Cloudflare Workers and bindings.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,6 +46,7 @@ export const MIN_RETRY_DELAY_MS = 1_000;
46
46
  export const UNCHANGED_ATTEMPT_BUDGET = 8;
47
47
  /** Minimum and default parked recovery interval. */
48
48
  export const PARKED_RETRY_DELAY_MS = 3_600_000;
49
+ const DEFAULT_IN_FLIGHT_RECOVERY_MS = 1_000;
49
50
 
50
51
  /** Absolute product repeat floor. */
51
52
  export const MIN_REPEAT_INTERVAL_MS = 1_000;
@@ -55,6 +56,8 @@ export const DEFAULT_MIN_REPEAT_INTERVAL_MS = 60_000;
55
56
  export interface ScheduleConfiguration {
56
57
  /** Check for due alarms after RPC/fetch responses unwind. Native alarms remain recovery authority. */
57
58
  readonly dispatchAfterEvent?: boolean;
59
+ /** Positive recovery deadline while dispatch or deferWakes is active. Defaults to one second. */
60
+ readonly inFlightRecovery?: Duration.Input;
58
61
  /** Retry floor, at least one second. Defaults to one second. */
59
62
  readonly minimumRetryDelay?: Duration.Input;
60
63
  /** Positive safe integer. Defaults to eight unchanged attempts. */
@@ -81,6 +84,7 @@ const AlarmLifecycleSchema = S.Literals(["automatic", "manual"]);
81
84
 
82
85
  const ScheduleConfigurationSchema = S.Struct({
83
86
  dispatchAfterEvent: S.Boolean,
87
+ inFlightRecovery: safeIntegerAtLeast(1),
84
88
  minimumRetryDelay: safeIntegerAtLeast(MIN_RETRY_DELAY_MS),
85
89
  unchangedAttemptBudget: safeIntegerAtLeast(1),
86
90
  parkedRetryDelay: safeIntegerAtLeast(PARKED_RETRY_DELAY_MS),
@@ -230,6 +234,9 @@ const getScheduleConfiguration = Effect.fnUntraced(function* (defaults: Schedule
230
234
  try: () =>
231
235
  S.decodeUnknownSync(ScheduleConfigurationSchema)({
232
236
  dispatchAfterEvent: input.dispatchAfterEvent ?? false,
237
+ inFlightRecovery: Math.ceil(
238
+ Duration.toMillis(input.inFlightRecovery ?? DEFAULT_IN_FLIGHT_RECOVERY_MS),
239
+ ),
233
240
  minimumRetryDelay: Math.ceil(
234
241
  Duration.toMillis(input.minimumRetryDelay ?? MIN_RETRY_DELAY_MS),
235
242
  ),
@@ -385,7 +392,7 @@ export type AlarmScheduler = {
385
392
  * object's concurrent and nested scopes. Source/checkpoint transactions still commit;
386
393
  * the last scope reconciles their current deadlines on success, failure or interruption.
387
394
  * Run outside transactions, around bounded maintenance or inline work. The first scope's
388
- * parkedRetryDelay bounds the shared guard; renew it if it expires and is consumed while
395
+ * inFlightRecovery bounds the shared guard; renew it if it expires and is consumed while
389
396
  * scopes remain active. A process loss before exit recovers at that retained alarm.
390
397
  */
391
398
  readonly deferWakes: <A, E, R>(
@@ -1369,7 +1376,7 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1369
1376
  const now = yield* Clock.currentTimeMillis;
1370
1377
 
1371
1378
  if (wakeDeferrals === 0) {
1372
- deferredRecoveryDelay = configuration.parkedRetryDelay;
1379
+ deferredRecoveryDelay = configuration.inFlightRecovery;
1373
1380
  deferredRecoveryAt = now + deferredRecoveryDelay;
1374
1381
  wakesReady ??= Deferred.makeUnsafe<void>();
1375
1382
  }
@@ -1448,6 +1455,22 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1448
1455
  dispatchNeedsReconcile || dispatchStarted || Exit.isFailure(exit);
1449
1456
 
1450
1457
  dispatchActive = Exit.isFailure(exit) && !dispatchStarted;
1458
+ if (dispatchActive) {
1459
+ const recoveryDelay = yield* getScheduleConfiguration(
1460
+ configurationDefaults,
1461
+ ).pipe(
1462
+ Effect.match({
1463
+ onFailure: () => PARKED_RETRY_DELAY_MS,
1464
+ onSuccess: (configuration) => configuration.parkedRetryDelay,
1465
+ }),
1466
+ );
1467
+
1468
+ // A concurrent deferred scope retains ownership of its short guard.
1469
+ if (wakeDeferrals === 0) {
1470
+ deferredRecoveryDelay = recoveryDelay;
1471
+ deferredRecoveryAt = 0;
1472
+ }
1473
+ }
1451
1474
  const reconciled = yield* (
1452
1475
  reconcile ? transaction(() => Effect.void) : Effect.void
1453
1476
  ).pipe(Effect.exit);
@@ -1570,11 +1593,17 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1570
1593
  yield* transaction(() =>
1571
1594
  Effect.gen(function* () {
1572
1595
  for (const row of manualRows) {
1573
- // Keep the selected revision: a racing replacement must survive this guard.
1596
+ // Keep work eligible after eviction; unknown registrations stay parked.
1597
+ const recoveryAt =
1598
+ now +
1599
+ (options?.[ManualTags]?.has(row.tag)
1600
+ ? configuration.inFlightRecovery
1601
+ : configuration.parkedRetryDelay);
1602
+
1574
1603
  yield* state.storage.sql.exec(
1575
1604
  `UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`,
1576
- now + configuration.parkedRetryDelay,
1577
- now + configuration.parkedRetryDelay,
1605
+ recoveryAt,
1606
+ recoveryAt,
1578
1607
  row.storage_id,
1579
1608
  row.storage_id,
1580
1609
  row.revision,
@@ -1686,7 +1715,23 @@ export class DurableObjectAlarm extends Context.Service<DurableObjectAlarm, Alar
1686
1715
  { concurrency: 2 },
1687
1716
  );
1688
1717
 
1689
- yield* transaction(() => Effect.void);
1718
+ yield* transaction(() =>
1719
+ Effect.gen(function* () {
1720
+ const recoveryAt = (yield* Clock.currentTimeMillis) + configuration.parkedRetryDelay;
1721
+
1722
+ for (const row of manualRows) {
1723
+ // Only a returned handler with no newer checkpoint falls back to parked recovery.
1724
+ yield* state.storage.sql.exec(
1725
+ `UPDATE effect_cf_scheduled_alarms SET run_at = ?, wake_at = ? WHERE ${sameRevisionSql}`,
1726
+ recoveryAt,
1727
+ recoveryAt,
1728
+ row.storage_id,
1729
+ row.storage_id,
1730
+ row.revision,
1731
+ );
1732
+ }
1733
+ }),
1734
+ );
1690
1735
  yield* Exit.asVoidAll(exits);
1691
1736
 
1692
1737
  return { failed, handled, parked };