@adonis-agora/durable 0.56.2 → 0.56.3

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/assets/spa/assets/{index-9kO8tWjg.js → index-BULnCUuC.js} +4 -4
  3. package/dist/assets/spa/assets/index-BULnCUuC.js.map +1 -0
  4. package/dist/assets/spa/index.html +1 -1
  5. package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -1
  6. package/dist/src/admission-redis/redis-admission-backend.js +6 -3
  7. package/dist/src/admission-redis/redis-admission-backend.js.map +1 -1
  8. package/dist/src/dashboard/compat.d.ts.map +1 -1
  9. package/dist/src/dashboard/compat.js +16 -7
  10. package/dist/src/dashboard/compat.js.map +1 -1
  11. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  12. package/dist/src/dashboard/handlers.js +5 -1
  13. package/dist/src/dashboard/handlers.js.map +1 -1
  14. package/dist/src/dashboard/index.d.ts +1 -1
  15. package/dist/src/dashboard/index.js +1 -1
  16. package/dist/src/engine.d.ts +19 -4
  17. package/dist/src/engine.d.ts.map +1 -1
  18. package/dist/src/engine.js +99 -18
  19. package/dist/src/engine.js.map +1 -1
  20. package/dist/src/index.d.ts +1 -1
  21. package/dist/src/index.js +1 -1
  22. package/dist/src/interfaces.d.ts +3 -0
  23. package/dist/src/interfaces.d.ts.map +1 -1
  24. package/dist/src/run-detail-summary.d.ts.map +1 -1
  25. package/dist/src/run-detail-summary.js +23 -14
  26. package/dist/src/run-detail-summary.js.map +1 -1
  27. package/dist/src/singleton-gate.d.ts +1 -1
  28. package/dist/src/singleton-gate.d.ts.map +1 -1
  29. package/dist/src/singleton-gate.js +7 -6
  30. package/dist/src/singleton-gate.js.map +1 -1
  31. package/dist/src/stores/lucid-schema.d.ts +1 -1
  32. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  33. package/dist/src/stores/lucid-schema.js +26 -1
  34. package/dist/src/stores/lucid-schema.js.map +1 -1
  35. package/dist/src/stores/lucid.d.ts.map +1 -1
  36. package/dist/src/stores/lucid.js +45 -8
  37. package/dist/src/stores/lucid.js.map +1 -1
  38. package/dist/src/telescope/data-providers.d.ts.map +1 -1
  39. package/dist/src/telescope/data-providers.js +27 -3
  40. package/dist/src/telescope/data-providers.js.map +1 -1
  41. package/package.json +3 -3
  42. package/dist/assets/spa/assets/index-9kO8tWjg.js.map +0 -1
@@ -122,6 +122,9 @@ const STEP_FAN_CHUNK = 200;
122
122
  * waiting for every checkpoint of a wide fan to commit.
123
123
  */
124
124
  const STEP_FAN_SEND_ROUND = 2000;
125
+ /** Most step events one control-plane message carries (see {@link WorkflowEngine.broadcastEvents}). */
126
+ const STEP_EVENTS_PER_BROADCAST = 500;
127
+ const isStepEvent = (event) => event.type.startsWith('step.');
125
128
  /** The default {@link WorkflowEngineDeps.timerBatchSize}. */
126
129
  const DEFAULT_TIMER_BATCH = 500;
127
130
  /** {@link WorkflowEngine.sweepBufferedSignals}: rows read per page. */
@@ -161,6 +164,24 @@ const LEASE_RECHECK_SLACK_MS = 100;
161
164
  * turn; the calls already in flight are not re-dispatched (their pending checkpoints dedupe them).
162
165
  */
163
166
  const REMOTE_TURN_REDRIVE_MS = 1_000;
167
+ /**
168
+ * The most entries a per-step in-memory throttle/fallback map keeps (heartbeat writes, lease
169
+ * re-arms, admitted queues). Each entry is a few dozen bytes; the cap is there so a long-lived pod
170
+ * that hears the whole fleet's beats cannot grow without bound, while staying well above the number
171
+ * of steps a fleet keeps in flight, so the throttles keep working at scale.
172
+ */
173
+ const THROTTLE_MAP_CAP = 16_384;
174
+ /** `map.set(key, value)` keeping insertion order as recency, evicting the oldest past {@link THROTTLE_MAP_CAP}. */
175
+ function setBounded(map, key, value) {
176
+ map.delete(key);
177
+ map.set(key, value);
178
+ while (map.size > THROTTLE_MAP_CAP) {
179
+ const oldest = map.keys().next();
180
+ if (oldest.done)
181
+ break;
182
+ map.delete(oldest.value);
183
+ }
184
+ }
164
185
  /** Fisher–Yates, in place. */
165
186
  function shuffle(items) {
166
187
  for (let i = items.length - 1; i > 0; i -= 1) {
@@ -327,7 +348,8 @@ export class WorkflowEngine {
327
348
  admission;
328
349
  /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
329
350
  queueWaiters = new Map();
330
- /** Which queue a dispatched step took a slot from, by stepId — so the result can release it. */
351
+ /** Which queue a dispatched step took a slot from, by stepId — so the result can release it.
352
+ * Bounded (see {@link setBounded}): the checkpoint's `queue` is the durable source. */
331
353
  stepQueue = new Map();
332
354
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
333
355
  inflight = new Map();
@@ -621,6 +643,11 @@ export class WorkflowEngine {
621
643
  // `at` may be a string after JSON transit (Redis) — normalize back to a Date.
622
644
  this.deliver(markRemoteEvent({ ...msg.event, at: new Date(msg.event.at) }));
623
645
  }
646
+ else if (msg.kind === 'events') {
647
+ for (const event of msg.events) {
648
+ this.deliver(markRemoteEvent({ ...event, at: new Date(event.at) }));
649
+ }
650
+ }
624
651
  else if (msg.kind === 'cancel') {
625
652
  this.notifyCancelled(msg.runId);
626
653
  }
@@ -1156,8 +1183,11 @@ export class WorkflowEngine {
1156
1183
  * One exemption: a beat CARRYING progress punches through when the last persisted one had none —
1157
1184
  * the automatic pickup beat lands first (no payload) and must not shadow the handler's first real
1158
1185
  * progress for a whole window. Optional store capability: no `recordStepHeartbeat` → no-op.
1159
- * Failures are swallowed (a lost liveness update never harms the run). The throttle map is cleared
1160
- * wholesale past 1024 entries — a rare extra write, never a leak.
1186
+ * Failures are swallowed (a lost liveness update never harms the run). The throttle map stays in
1187
+ * write order: entries whose window has passed are dropped from its front (they no longer throttle
1188
+ * anything), and past {@link THROTTLE_MAP_CAP} the oldest goes — a rare extra write, never a leak.
1189
+ * It used to be cleared wholesale past 1024 entries, which a fleet with more steps beating than that
1190
+ * (every engine pod hears every beat) hit every few seconds, turning the throttle off.
1161
1191
  */
1162
1192
  persistHeartbeat(beat) {
1163
1193
  const id = beat.stepId;
@@ -1170,9 +1200,15 @@ export class WorkflowEngine {
1170
1200
  now - last.at < this.heartbeatPersistThrottleMs &&
1171
1201
  !upgradesToProgress)
1172
1202
  return;
1173
- if (this.heartbeatPersists.size > 1024)
1174
- this.heartbeatPersists.clear();
1203
+ // Re-insert at the back so the map stays ordered by write time, then drop the expired front.
1204
+ this.heartbeatPersists.delete(id);
1175
1205
  this.heartbeatPersists.set(id, { at: now, hadProgress: beat.progress !== undefined });
1206
+ for (const [oldId, entry] of this.heartbeatPersists) {
1207
+ if (now - entry.at < this.heartbeatPersistThrottleMs &&
1208
+ this.heartbeatPersists.size <= THROTTLE_MAP_CAP)
1209
+ break;
1210
+ this.heartbeatPersists.delete(oldId);
1211
+ }
1176
1212
  this.heartbeatWrites.set(id, {
1177
1213
  runId: beat.runId,
1178
1214
  seq: beat.seq,
@@ -1335,12 +1371,51 @@ export class WorkflowEngine {
1335
1371
  emit(event) {
1336
1372
  const full = { ...event, at: new Date() };
1337
1373
  this.deliver(full);
1338
- if (this.controlPlane) {
1339
- void this.controlPlane
1340
- .publishControl({ kind: 'event', event: full, from: this.instanceId })
1341
- .catch(() => {
1342
- // control-plane delivery is best-effort observability; never break execution
1343
- });
1374
+ if (!this.controlPlane)
1375
+ return;
1376
+ this.eventsToBroadcast.push(full);
1377
+ if (!this.broadcastingEvents)
1378
+ void this.broadcastEvents();
1379
+ }
1380
+ /** Events waiting for the control-plane publish in flight, in emit order (see {@link broadcastEvents}). */
1381
+ eventsToBroadcast = [];
1382
+ broadcastingEvents = false;
1383
+ /**
1384
+ * Publish the queued events in the order they were emitted. A run's own events go out one per
1385
+ * message, as every instance expects them. Step events (a wide fan emits two per step) that queued
1386
+ * up while a publish was in flight go out together in one `events` message, so a fan of 40,000
1387
+ * steps costs the control plane a few hundred messages to every instance instead of 80,000.
1388
+ */
1389
+ async broadcastEvents() {
1390
+ this.broadcastingEvents = true;
1391
+ try {
1392
+ while (this.eventsToBroadcast.length > 0 && this.controlPlane) {
1393
+ const first = this.eventsToBroadcast[0];
1394
+ let msg;
1395
+ if (isStepEvent(first)) {
1396
+ let n = 1;
1397
+ while (n < STEP_EVENTS_PER_BROADCAST &&
1398
+ n < this.eventsToBroadcast.length &&
1399
+ isStepEvent(this.eventsToBroadcast[n])) {
1400
+ n += 1;
1401
+ }
1402
+ const events = this.eventsToBroadcast.splice(0, n);
1403
+ msg =
1404
+ events.length === 1
1405
+ ? { kind: 'event', event: first, from: this.instanceId }
1406
+ : { kind: 'events', events, from: this.instanceId };
1407
+ }
1408
+ else {
1409
+ this.eventsToBroadcast.shift();
1410
+ msg = { kind: 'event', event: first, from: this.instanceId };
1411
+ }
1412
+ await this.controlPlane.publishControl(msg).catch(() => {
1413
+ // control-plane delivery is best-effort observability; never break execution
1414
+ });
1415
+ }
1416
+ }
1417
+ finally {
1418
+ this.broadcastingEvents = false;
1344
1419
  }
1345
1420
  }
1346
1421
  /** Deliver an event to local subscribers only (no re-broadcast) — used for both locally-produced
@@ -1726,7 +1801,12 @@ export class WorkflowEngine {
1726
1801
  // (b) an old pending REMOTE step whose worker heartbeat is silent — dispatched and lost.
1727
1802
  // A long `ctx.sleep` (wakeAt in the future) and a beating long step are NOT stalled.
1728
1803
  let stalePending = null;
1729
- for (const cp of await this.store.listCheckpoints(run.id)) {
1804
+ // Only pending checkpoints can be stale: read just those where the store can filter, not the
1805
+ // run's whole history (a wide fan's thousands of settled rows, payloads included).
1806
+ const pendingCheckpoints = this.store.queryCheckpoints
1807
+ ? await this.store.queryCheckpoints(run.id, { statuses: ['pending'] })
1808
+ : await this.store.listCheckpoints(run.id);
1809
+ for (const cp of pendingCheckpoints) {
1730
1810
  if (cp.kind !== 'remote' || cp.status !== 'pending')
1731
1811
  continue;
1732
1812
  const ageMs = now - cp.enqueuedAt.getTime();
@@ -5066,7 +5146,8 @@ export class WorkflowEngine {
5066
5146
  * (with a 30-minute window: two writes an hour), independent of beat frequency. The in-memory mark
5067
5147
  * is a pure optimisation — cold on a fresh instance, or on the one that didn't dispatch the step —
5068
5148
  * and the durable half-spent check re-derives the same answer from the checkpoint, so nothing
5069
- * depends on it surviving. Cleared wholesale past 1024 entries: a rare extra read, never a leak.
5149
+ * depends on it surviving. Bounded at {@link THROTTLE_MAP_CAP} entries, oldest first: a rare extra
5150
+ * read, never a leak.
5070
5151
  *
5071
5152
  * NEVER REJECTS. This runs on the transport's SERIAL heartbeat handler, so a store hiccup thrown out
5072
5153
  * of here would take the whole fleet's beat loop with it — and every lease would then lapse at once,
@@ -5104,15 +5185,13 @@ export class WorkflowEngine {
5104
5185
  const halfWindow = Math.ceil(this.remoteRedispatchMs / 2);
5105
5186
  // More than half the window left: nothing to buy, and nothing to look at until it is half spent.
5106
5187
  if (cp.wakeAt != null && cp.wakeAt > now + halfWindow) {
5107
- this.stepLeaseRearms.set(id, cp.wakeAt - halfWindow);
5188
+ setBounded(this.stepLeaseRearms, id, cp.wakeAt - halfWindow);
5108
5189
  return;
5109
5190
  }
5110
5191
  const renewed = now + this.remoteRedispatchMs;
5111
5192
  const previous = cp.wakeAt;
5112
5193
  await this.store.saveCheckpoint({ ...cp, wakeAt: renewed });
5113
- if (this.stepLeaseRearms.size > 1024)
5114
- this.stepLeaseRearms.clear();
5115
- this.stepLeaseRearms.set(id, renewed - halfWindow);
5194
+ setBounded(this.stepLeaseRearms, id, renewed - halfWindow);
5116
5195
  // If the run is parked EXACTLY on this step's old lease, carry its wake forward too — otherwise a
5117
5196
  // beating worker costs one wasted turn per renewal window. Only on an exact match: any other
5118
5197
  // `wakeAt` belongs to some other timer (a sleep, a sibling's shorter lease) and moving it would
@@ -6265,7 +6344,9 @@ export class WorkflowEngine {
6265
6344
  throw new WorkflowSuspended(decision.retryAt);
6266
6345
  }
6267
6346
  this.queueWaiters.get(queue)?.delete(runId);
6268
- this.stepQueue.set(id, queue);
6347
+ // Bounded: the result (and so the delete) may land on another pod, which releases the slot
6348
+ // from the checkpoint's durable `queue` — this map is only the legacy fallback.
6349
+ setBounded(this.stepQueue, id, queue);
6269
6350
  admittedQueue = queue;
6270
6351
  }
6271
6352
  // Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name