@nylorun/runtime 0.22.0-beta → 0.23.0-beta

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 (49) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +17 -3
  3. package/dist/adapters/execution/restate.d.ts +15 -3
  4. package/dist/adapters/execution/restate.js +77 -83
  5. package/dist/adapters/streams/s2.js +1 -13
  6. package/dist/core/flow-host.d.ts +1 -19
  7. package/dist/core/flow-host.js +8 -37
  8. package/dist/execution/types.d.ts +10 -8
  9. package/dist/execution/types.js +5 -7
  10. package/dist/host/execution.d.ts +8 -6
  11. package/dist/host/main.js +10 -16
  12. package/dist/host/stack-config.d.ts +6 -0
  13. package/dist/host/stack-config.js +6 -0
  14. package/dist/infra/execution.d.ts +18 -1
  15. package/dist/infra/execution.js +28 -4
  16. package/dist/infra/index.d.ts +1 -1
  17. package/dist/management-openapi.json +1 -1
  18. package/dist/openapi.json +1 -1
  19. package/dist/retry.d.ts +21 -0
  20. package/dist/retry.js +34 -0
  21. package/dist/sandbox/join.js +6 -7
  22. package/dist/sandbox/pods/reconcile.js +3 -3
  23. package/dist/store/postgres/control.js +8 -1
  24. package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
  25. package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
  26. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  27. package/dist/store/postgres/schema.d.ts +263 -2
  28. package/dist/store/postgres/schema.js +34 -3
  29. package/dist/store/postgres/store.js +165 -52
  30. package/dist/store/types.d.ts +96 -39
  31. package/dist/streams/relay/core.js +1 -13
  32. package/dist/tenant/advance.js +6 -7
  33. package/dist/tenant/commands.js +1 -2
  34. package/dist/tenant/context.d.ts +19 -11
  35. package/dist/tenant/control.d.ts +8 -1
  36. package/dist/tenant/control.js +2 -0
  37. package/dist/tenant/runtime.js +23 -3
  38. package/dist/tenant/sandboxes.d.ts +2 -1
  39. package/dist/tenant/sandboxes.js +23 -19
  40. package/dist/tenant/scheduler.d.ts +7 -0
  41. package/dist/tenant/scheduler.js +38 -7
  42. package/dist/tenant/session-streams.d.ts +0 -1
  43. package/dist/tenant/session-streams.js +1 -13
  44. package/dist/tenant/streams.js +14 -29
  45. package/dist/tenant/sweep.d.ts +39 -12
  46. package/dist/tenant/sweep.js +157 -58
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +2 -1
@@ -4,26 +4,34 @@
4
4
  * and startup scan the Tenant runtime used to keep. It reconciles the Session Store with what
5
5
  * Durable Session Execution was asked to do; it never decides when a session advances:
6
6
  *
7
- * 1. **Wakes.** Wake outbox rows (`Tx.wake`) are delivered again and deleted: a delivery
8
- * after commit that failed, or that a crash between the commit and the send cut off. The
9
- * first pass on this process takes every row, so a previous process's are not left
10
- * waiting; later passes take the rows older than `WAKE_GRACE_MS`. Each row carries the
11
- * idempotency key it was first sent with, so a wake the execution did accept before the
12
- * crash, or one still being sent after its commit, causes no second advance.
7
+ * 1. **Outbox.** Outbox rows (`Tx.wake`, `Tx.signalSandbox`) are delivered again and deleted:
8
+ * a delivery after commit that failed, or that a crash between the commit and the send cut
9
+ * off. The first pass on this process takes every row, so a previous process's are not
10
+ * left waiting; later passes take those whose delivery after commit failed and those older
11
+ * than `OUTBOX_GRACE_MS` (the database's clock), batch after batch within
12
+ * `DRAIN_BUDGET_MS`. Each row carries the idempotency key it was first sent with, so a
13
+ * request the execution did accept before the crash, or one still being sent after its
14
+ * commit, runs once. Rows are delivered one by one: a row that fails is retried with
15
+ * backoff and parked after `MAX_OUTBOX_ATTEMPTS` (`deliverOutbox`), and no delivery fails
16
+ * the step.
13
17
  * 2. **Linked agents.** Pending workflow `agent` effects whose linked turn already settled
14
- * are completed and the workflow is woken (a crash between the two, or data written by an
15
- * older Runtime).
18
+ * are completed and the workflow is woken. A linked turn's settle does both in its own
19
+ * transaction; this is the backstop for one that settle left pending, such as an effect
20
+ * an older Runtime wrote.
16
21
  * 3. **Orphaned sessions.** On this process's first pass for the Tenant, then at most every
17
22
  * `ORPHAN_SCAN_MS`: `running` or `runnable` sessions with no owner, or an owner whose lease
18
- * expired, are woken with reason `recover`. Neither a lost wake (step 1) nor a Worker that
19
- * died mid-advance (the execution retries that advance, which takes over, §11.4) needs it:
20
- * it is the backstop for what the execution itself lost or ended without an advance, such
21
- * as an in-process execution's queue when its process stopped, an advance for a Tenant not
22
- * open on its Worker, an invocation an operator killed, or Restate's state wiped (§17.11).
23
+ * expired, are woken with reason `recover`, page by page within `DRAIN_BUDGET_MS`; a scan
24
+ * the budget cut short goes on at the next pass. Neither a lost wake (step 1) nor a Worker
25
+ * that died mid-advance (the execution retries that advance, which takes over, §11.4)
26
+ * needs it: it is the backstop for what the execution itself lost or ended without an
27
+ * advance, such as an in-process execution's queue when its process stopped, an advance
28
+ * for a Tenant not open on its Worker, an invocation an operator killed, Restate's state
29
+ * wiped (§17.11), or a parked outbox row.
23
30
  * 4. **Sandboxes.** Idle sandboxes are stopped, records of compute no longer held are marked
24
31
  * stopped, and sandboxes whose session or sandbox resource is gone are removed, by the
25
32
  * workspace capability (`harness-api/workspace.ts`: here, or in the harness that serves
26
- * workspaces). Idle MCP connections of the in-process harness are closed.
33
+ * workspaces). Idle MCP connections of the in-process harness are closed. As often as the
34
+ * orphan scan, pod sandboxes whose change stalled get a reconcile (`reconcileStalePods`).
27
35
  * 5. **Control signals.** Signals on the control bus older than `SIGNAL_RETENTION_MS` are
28
36
  * deleted: followers read back only the last two minutes (`store/postgres/control.ts`).
29
37
  * 6. **Hooks.** Callbacks registered with `ctx.onSweep`.
@@ -38,14 +46,28 @@ const BATCH = 100;
38
46
  /** How long a control signal is kept after it was written. */
39
47
  const SIGNAL_RETENTION_MS = 60 * 60 * 1000;
40
48
  /**
41
- * How old a wake outbox row is before the sweep delivers it: longer than a delivery after
42
- * commit takes, so the sweep seldom sends a wake its commit is still sending. Sending one
43
- * twice is harmless either way (the idempotency key).
49
+ * How old an outbox row is before the sweep delivers it, unless its delivery after commit
50
+ * already failed (then it is due at once): longer than that delivery can take with its
51
+ * retries, so the sweep does not send again, and double the load on, an execution that is
52
+ * only slow. Sending one twice is harmless either way (the idempotency key).
53
+ */
54
+ export const OUTBOX_GRACE_MS = 30_000;
55
+ /**
56
+ * How often the orphan scan, and the stale pod sandbox check, run after the first pass of a
57
+ * Tenant opened on this process.
44
58
  */
45
- export const WAKE_GRACE_MS = 2000;
46
- /** How often the orphan scan runs after the first pass of a Tenant opened on this process. */
47
59
  export const ORPHAN_SCAN_MS = 60_000;
48
- /** Each open Tenant's sweep state on this process: when its orphan scan last ran. */
60
+ /**
61
+ * How long one pass may spend draining the outbox, and paging through orphaned sessions.
62
+ * A backlog is worked off over several passes rather than holding one (and with it the
63
+ * Tenant's sweep object in the execution) for long.
64
+ */
65
+ export const DRAIN_BUDGET_MS = 2000;
66
+ /**
67
+ * Each open Tenant's sweep state on this process: when its last orphan scan finished, the
68
+ * last session id of one that ran out of budget, which the next pass goes on from, and when
69
+ * the stale pod sandbox check last ran.
70
+ */
49
71
  const passes = new WeakMap();
50
72
  export async function sweep(ctx) {
51
73
  if (ctx.closing || ctx.closed)
@@ -54,20 +76,22 @@ export async function sweep(ctx) {
54
76
  const first = !passes.has(ctx);
55
77
  const pass = passes.get(ctx) ?? {};
56
78
  passes.set(ctx, pass);
57
- const scanOrphans = pass.orphansAt === undefined || now.getTime() - pass.orphansAt >= ORPHAN_SCAN_MS;
79
+ const scanOrphans = pass.orphansAt === undefined ||
80
+ pass.orphansAfter !== undefined ||
81
+ now.getTime() - pass.orphansAt >= ORPHAN_SCAN_MS;
82
+ const checkPods = pass.podsAt === undefined || now.getTime() - pass.podsAt >= ORPHAN_SCAN_MS;
58
83
  const steps = [
59
- [
60
- "wakes",
61
- () => deliverPendingWakes(ctx, first ? now : new Date(now.getTime() - WAKE_GRACE_MS)),
62
- ],
84
+ ["outbox", () => deliverOutbox(ctx, first ? 0 : OUTBOX_GRACE_MS)],
63
85
  ["linked", () => reconcileLinkedAgents(ctx)],
64
86
  ...(scanOrphans
65
87
  ? [
66
88
  [
67
89
  "orphans",
68
90
  async () => {
69
- await wakeOrphanedSessions(ctx, now);
70
- pass.orphansAt = now.getTime();
91
+ const scan = await wakeOrphanedSessions(ctx, now, pass.orphansAfter);
92
+ pass.orphansAfter = scan.next;
93
+ if (scan.next === undefined)
94
+ pass.orphansAt = now.getTime();
71
95
  },
72
96
  ],
73
97
  ]
@@ -80,7 +104,17 @@ export async function sweep(ctx) {
80
104
  sandboxExists: async (id) => (await ctx.store.tx((t) => t.sandboxResource(id))) !== undefined,
81
105
  }),
82
106
  ],
83
- ["pods", () => reconcileStalePods(ctx, now)],
107
+ ...(checkPods
108
+ ? [
109
+ [
110
+ "pods",
111
+ async () => {
112
+ await reconcileStalePods(ctx, now);
113
+ pass.podsAt = now.getTime();
114
+ },
115
+ ],
116
+ ]
117
+ : []),
84
118
  // A harness elsewhere closes its own idle MCP connections.
85
119
  ["mcp", async () => ctx.mcp?.sweep()],
86
120
  [
@@ -107,53 +141,118 @@ export async function sweep(ctx) {
107
141
  if (failure)
108
142
  throw failure.error;
109
143
  }
144
+ /** Failed deliveries after which the sweep parks an outbox row (`Tx.failOutbox`). */
145
+ export const MAX_OUTBOX_ATTEMPTS = 10;
146
+ /** How long after its first failed delivery the sweep tries a row again; doubled each time. */
147
+ const RETRY_MS = 5000;
148
+ const RETRY_MAX_MS = 10 * 60_000;
110
149
  /**
111
- * Deliver the wake outbox rows written before `before` (oldest first, one batch) and delete
112
- * the delivered ones. A row the Tenant declines (it is closing) stays. Returns the ids of the
113
- * rows delivered.
150
+ * Deliver the outbox rows that are due (`Tx.pendingOutbox`: older than `graceMs`, or whose
151
+ * delivery failed), oldest first, batch after batch until none is left or `budgetMs` is
152
+ * spent, and delete the delivered ones. Each row is delivered on its own: one that fails
153
+ * (Restate refuses it, or a Runtime that does not know a wake's reason) is tried again later,
154
+ * with backoff, so it never holds back the rows behind it, and after `MAX_OUTBOX_ATTEMPTS`
155
+ * failures it is parked: logged once, at error, and kept in the outbox for an operator. A
156
+ * parked wake's session is still re-woken by the orphan scan while it is runnable, and a
157
+ * parked sandbox signal's pod by the stale pod check. A row the Tenant declines (it is
158
+ * closing) stays as it is, and ends the drain. Never throws for a delivery; returns the rows
159
+ * delivered.
114
160
  */
115
- export async function deliverPendingWakes(ctx, before = new Date()) {
116
- const pending = await ctx.store.tx((t) => t.pendingWakes(before, BATCH));
161
+ export async function deliverOutbox(ctx, graceMs = 0, budgetMs = DRAIN_BUDGET_MS) {
162
+ const deadline = Date.now() + budgetMs;
117
163
  const delivered = [];
118
- try {
119
- for (const row of pending)
120
- if ((await ctx.wake(row.sessionId, row.wake)) !== false)
121
- delivered.push(row.id);
122
- }
123
- finally {
124
- // Delivered ones go even when a later one fails, so they are not sent again.
125
- if (delivered.length > 0)
126
- await ctx.store.tx((t) => t.deleteWakes(delivered));
164
+ for (;;) {
165
+ const pending = await ctx.store.tx((t) => t.pendingOutbox(graceMs, BATCH));
166
+ const batch = [];
167
+ let done = pending.length < BATCH;
168
+ try {
169
+ for (const row of pending) {
170
+ if (Date.now() >= deadline) {
171
+ done = true;
172
+ break;
173
+ }
174
+ let accepted;
175
+ try {
176
+ accepted = await ctx.send(row.request);
177
+ }
178
+ catch (error) {
179
+ await failed(ctx, row, error);
180
+ continue;
181
+ }
182
+ if (accepted)
183
+ batch.push(row);
184
+ else
185
+ done = true;
186
+ }
187
+ }
188
+ finally {
189
+ // Delivered ones go even when a later step fails, so they are not sent again.
190
+ if (batch.length > 0)
191
+ await ctx.store.tx((t) => t.deleteOutbox(batch));
192
+ }
193
+ delivered.push(...batch);
194
+ if (done)
195
+ return delivered;
127
196
  }
128
- return delivered;
197
+ }
198
+ /** Records a failed delivery of `row`: retried with backoff, parked after the last attempt. */
199
+ async function failed(ctx, row, error) {
200
+ const attempts = row.attempts + 1;
201
+ const park = attempts >= MAX_OUTBOX_ATTEMPTS;
202
+ const retryInMs = Math.min(RETRY_MS * 2 ** (attempts - 1), RETRY_MAX_MS);
203
+ await ctx.store.tx((t) => t.failOutbox(row, { retryInMs, park }));
204
+ const { request } = row;
205
+ const fields = {
206
+ rowId: row.id,
207
+ ...(request.kind === "wake"
208
+ ? { sessionId: request.sessionId, reason: request.wake.reason }
209
+ : { sandboxId: request.sandboxId, signal: request.signal.kind }),
210
+ attempts,
211
+ message: error instanceof Error ? error.message : String(error),
212
+ };
213
+ if (park)
214
+ ctx.config.logger.error("outbox row parked: its delivery keeps failing", fields);
215
+ else
216
+ ctx.config.logger.warn("outbox delivery failed; the sweep tries it again", fields);
129
217
  }
130
218
  /** Settle pending workflow `agent` effects whose linked turn already finished. */
131
219
  export async function reconcileLinkedAgents(ctx) {
132
220
  const { store } = ctx;
133
221
  const effects = await store.tx((t) => pendingAgentEffects(t));
134
222
  for (const effect of effects)
135
- await store.tx((t) => reconcilePendingAgentEffect({
136
- t,
137
- effectId: effect.request.effectId,
138
- schedule: ctx.wake,
139
- }));
223
+ await store.tx((t) => reconcilePendingAgentEffect({ t, effectId: effect.request.effectId }));
140
224
  }
141
- /** Wake `running`/`runnable` sessions without a live owner. Returns the ids woken. */
142
- export async function wakeOrphanedSessions(ctx, now = new Date()) {
143
- const orphans = await ctx.store.tx((t) => t.orphanedSessions(now, BATCH));
144
- const ids = orphans.map((session) => session.id);
145
- for (const id of ids)
146
- await ctx.wake(id, { reason: "recover" });
147
- return ids;
225
+ /**
226
+ * Wake `running`/`runnable` sessions without a live owner, page after page from the first id
227
+ * after `after`, until none is left or `budgetMs` is spent. Returns the ids woken, and the last
228
+ * id when the budget ran out first (`next`, where the next scan goes on).
229
+ */
230
+ export async function wakeOrphanedSessions(ctx, now = new Date(), after, budgetMs = DRAIN_BUDGET_MS) {
231
+ const deadline = Date.now() + budgetMs;
232
+ const woken = [];
233
+ let cursor = after;
234
+ for (;;) {
235
+ const page = await ctx.store.tx((t) => t.orphanedSessions(now, BATCH, cursor));
236
+ for (const session of page) {
237
+ await ctx.wake(session.id, { reason: "recover" });
238
+ woken.push(session.id);
239
+ cursor = session.id;
240
+ }
241
+ if (page.length < BATCH)
242
+ return { woken };
243
+ if (Date.now() >= deadline)
244
+ return { woken, next: cursor };
245
+ }
148
246
  }
149
247
  /** A pod sandbox in a change this long without a reconcile gets one from the sweep. */
150
248
  const STALE_POD_MS = 60_000;
151
249
  /**
152
250
  * Pod sandboxes (F7.2) whose change has not finished (creating, deleting, an old volume still
153
251
  * to delete, an expiry not yet recorded), or that should run but whose engine is not connected,
154
- * and whose row has not moved for a minute: a reconcile the `Sandbox` object lost (a send
155
- * dropped while the Tenant closed) is sent again, and a pod or volume deleted outside the
156
- * Runtime is found.
252
+ * and whose row has not moved for a minute, get a reconcile: a pod or volume deleted outside
253
+ * the Runtime is found, and a change goes on whose reconcile the execution itself lost (its
254
+ * retry timer, an invocation an operator killed, Restate's state wiped) or whose outbox row
255
+ * was parked. A reconcile a commit asked for is never lost: it is in the outbox.
157
256
  */
158
257
  async function reconcileStalePods(ctx, now) {
159
258
  if (!ctx.pods)
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.22.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.23.0-beta";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in step with package.json by runtime/scripts/check-package.mjs, which runs in npm run check.
2
2
  // release:prepare also rewrites this when it bumps @nylorun/runtime.
3
- export const RUNTIME_VERSION = "0.22.0-beta";
3
+ export const RUNTIME_VERSION = "0.23.0-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.22.0-beta",
3
+ "version": "0.23.0-beta",
4
4
  "description": "Nylorun Runtime: the agent execution Host on Postgres, Restate and S2, and model providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -68,6 +68,7 @@
68
68
  "@nylorun/core": "0.19.0-beta",
69
69
  "@nylorun/harness": "0.25.1-beta",
70
70
  "@restatedev/restate-sdk": "1.17.2",
71
+ "@restatedev/restate-sdk-clients": "1.17.2",
71
72
  "@s2-dev/streamstore": "0.28.0",
72
73
  "aws4fetch": "1.0.20",
73
74
  "drizzle-orm": "^0.45.3",