deepline 0.1.290 → 0.1.292

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.
@@ -54,7 +54,7 @@ const DAYTONA_UPLOAD_ATTEMPT_DEADLINE_MS = 90_000;
54
54
  const RUNTIME_POSTGRES_CONNECT_RETRY_PATTERN =
55
55
  /\bRuntime Postgres\b.*\b(connection timed out|connect timeout|ETIMEDOUT|ECONNRESET|ECONNREFUSED|Connection terminated|Connection ended unexpectedly)\b/i;
56
56
  const RUNTIME_RECEIPT_GATEWAY_TRANSPORT_RETRY_PATTERN =
57
- /AppRuntimeApiTransportError: App runtime API transport exhausted action=(?:get|claim|mark|complete|fail|heartbeat|release|skip)_runtime_step_receipts?/i;
57
+ /AppRuntimeApiTransportError: (?:App runtime API|Runtime receipt gateway) transport exhausted action=(?:get|claim|mark|complete|fail|heartbeat|release|skip)_runtime_step_receipts?/i;
58
58
  const RUNTIME_API_TRANSPORT_RETRY_PATTERN =
59
59
  /\bRuntime API request to .+ failed before receiving a response:\s*(?:fetch failed|connection (?:terminated|timed out|closed|reset)|ECONNRESET|ETIMEDOUT|ECONNREFUSED)\b/i;
60
60
  const DAYTONA_INFRASTRUCTURE_RETRY_PATTERN =
@@ -78,6 +78,7 @@ import {
78
78
  sameOwnerTerminalAttemptEpochSql,
79
79
  } from './sheet-attempt-sql';
80
80
  import type { MapRowOutcome } from './durability-store';
81
+ import { RUNTIME_CAPACITY_POLICY } from './runtime-capacity-policy';
81
82
  import {
82
83
  normalizeRuntimeMapInputIndex,
83
84
  prepareRuntimeSheetRowsForJsonTransport,
@@ -349,7 +350,8 @@ const RUNTIME_POSTGRES_RECEIPT_ADMISSION_MAX_QUEUED = 2_000;
349
350
  // memory independently from lightweight receipt requests; overflow is a
350
351
  // retryable 503 and waits outside this long-lived gateway process.
351
352
  const RUNTIME_POSTGRES_SHEET_ADMISSION_MAX_QUEUED = 4;
352
- const RUNTIME_POSTGRES_RECEIPT_ADMISSION_TIMEOUT_MS = 10_000;
353
+ const RUNTIME_POSTGRES_RECEIPT_ADMISSION_TIMEOUT_MS =
354
+ RUNTIME_CAPACITY_POLICY.receiptGateway.admissionTimeoutMs;
353
355
  const RECEIPT_STATUS_QUEUED = RECEIPT_STATUS_CODE.queued;
354
356
  const RECEIPT_STATUS_PENDING = RECEIPT_STATUS_CODE.pending;
355
357
  const RECEIPT_STATUS_RUNNING = RECEIPT_STATUS_CODE.running;
@@ -4173,6 +4175,9 @@ export async function claimRuntimeWorkReceipt(
4173
4175
  input.leaseTtlMs,
4174
4176
  PLAY_RUNTIME_WORK_RECEIPT_LEASE_TTL_MS,
4175
4177
  );
4178
+ // A successful lease-bearing claim is the execution transition. Keep the
4179
+ // stored queued status for mixed-version readers; the runner does not add
4180
+ // a second queued -> running write on the provider hot path.
4176
4181
  const claimStatus =
4177
4182
  input.leaseAware === true
4178
4183
  ? RECEIPT_STATUS_QUEUED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * One capacity policy for the Absurd runtime.
3
+ *
4
+ * Postgres owns backlog. The active lane adds workers from durable claimable
5
+ * demand and oldest-claimable age; historical lanes stay cold unless they own
6
+ * runnable work. Keep these values together so the worker, scaler, gateway,
7
+ * and tests cannot independently invent concurrency or timeout budgets.
8
+ */
9
+ export const RUNTIME_CAPACITY_POLICY = {
10
+ absurd: {
11
+ /** Warm floor for availability and deploy rollovers. */
12
+ activeLaneMachines: 2,
13
+ /** 32 Machines x 8 claim slots permits 256 concurrent launch/resume legs. */
14
+ maxActiveLaneMachines: 32,
15
+ workerSlotsPerMachine: 8,
16
+ perOrgConcurrency: 4,
17
+ /** Queue age is an escape hatch for small-but-stuck backlogs. */
18
+ scaleUpQueueAgeMs: 10_000,
19
+ /** Bound one Fly reconciliation without turning backlog into a stampede. */
20
+ maxScaleUpMachinesPerReconcile: 4,
21
+ /** Do not tear down burst capacity on the first empty observation. */
22
+ scaleDownQuietMs: 120_000,
23
+ },
24
+ receiptGateway: {
25
+ admissionTimeoutMs: 10_000,
26
+ requestTimeoutMs: 30_000,
27
+ },
28
+ } as const;
29
+
30
+ export const ABSURD_GLOBAL_RUN_CONCURRENCY =
31
+ RUNTIME_CAPACITY_POLICY.absurd.maxActiveLaneMachines *
32
+ RUNTIME_CAPACITY_POLICY.absurd.workerSlotsPerMachine;
33
+
34
+ /**
35
+ * Active releases use pg-boss-style burst semantics: claimable count sets the
36
+ * target, an aged claimable queue forces continued growth, and growth per
37
+ * observation is bounded. A short/empty observation ends burst growth; the
38
+ * caller supplies idleForMs to hold excess capacity through a quiet window.
39
+ *
40
+ * Historical releases stay cold until they have immediately runnable or
41
+ * currently claimed work.
42
+ */
43
+ export function desiredAbsurdLaneMachines(input: {
44
+ active: boolean;
45
+ claimableRuns: number;
46
+ runningRuns?: number;
47
+ currentMachines?: number;
48
+ oldestClaimableAgeMs?: number;
49
+ idleForMs?: number;
50
+ }): number {
51
+ const claimableRuns = nonNegativeInteger(input.claimableRuns);
52
+ const runningRuns = nonNegativeInteger(input.runningRuns ?? 0);
53
+ if (!input.active) return claimableRuns > 0 || runningRuns > 0 ? 1 : 0;
54
+
55
+ const policy = RUNTIME_CAPACITY_POLICY.absurd;
56
+ const currentMachines = Math.max(
57
+ policy.activeLaneMachines,
58
+ nonNegativeInteger(input.currentMachines ?? policy.activeLaneMachines),
59
+ );
60
+ const demandedMachines = Math.ceil(
61
+ (claimableRuns + runningRuns) / policy.workerSlotsPerMachine,
62
+ );
63
+ let desired = Math.max(policy.activeLaneMachines, demandedMachines);
64
+
65
+ if (
66
+ claimableRuns > 0 &&
67
+ nonNegativeNumber(input.oldestClaimableAgeMs ?? 0) >=
68
+ policy.scaleUpQueueAgeMs
69
+ ) {
70
+ desired = Math.max(desired, currentMachines + 1);
71
+ }
72
+
73
+ desired = Math.min(
74
+ desired,
75
+ currentMachines + policy.maxScaleUpMachinesPerReconcile,
76
+ policy.maxActiveLaneMachines,
77
+ );
78
+
79
+ if (
80
+ claimableRuns === 0 &&
81
+ runningRuns === 0 &&
82
+ nonNegativeNumber(input.idleForMs ?? 0) < policy.scaleDownQuietMs
83
+ ) {
84
+ desired = Math.max(desired, currentMachines);
85
+ }
86
+ return desired;
87
+ }
88
+
89
+ function nonNegativeInteger(value: number): number {
90
+ return Math.max(0, Math.floor(nonNegativeNumber(value)));
91
+ }
92
+
93
+ function nonNegativeNumber(value: number): number {
94
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
95
+ }
96
+
97
+ export function assertRuntimeCapacityPolicy(): void {
98
+ if (
99
+ RUNTIME_CAPACITY_POLICY.receiptGateway.requestTimeoutMs <=
100
+ RUNTIME_CAPACITY_POLICY.receiptGateway.admissionTimeoutMs
101
+ ) {
102
+ throw new Error(
103
+ 'Receipt gateway request timeout must exceed its admission timeout.',
104
+ );
105
+ }
106
+ if (
107
+ RUNTIME_CAPACITY_POLICY.absurd.perOrgConcurrency >
108
+ ABSURD_GLOBAL_RUN_CONCURRENCY
109
+ ) {
110
+ throw new Error(
111
+ 'Per-org runtime concurrency cannot exceed global runtime concurrency.',
112
+ );
113
+ }
114
+ if (
115
+ RUNTIME_CAPACITY_POLICY.absurd.activeLaneMachines >
116
+ RUNTIME_CAPACITY_POLICY.absurd.maxActiveLaneMachines
117
+ ) {
118
+ throw new Error(
119
+ 'Active-lane minimum Machines cannot exceed the active-lane maximum.',
120
+ );
121
+ }
122
+ }
123
+
124
+ assertRuntimeCapacityPolicy();
@@ -0,0 +1,410 @@
1
+ import type {
2
+ AcquireRuntimeReceiptExecutionLockInput,
3
+ ClaimRuntimeStepReceiptInput,
4
+ CompleteRuntimeStepReceiptInput,
5
+ ContextOptions,
6
+ FailRuntimeStepReceiptInput,
7
+ ReleaseRuntimeReceiptExecutionLockInput,
8
+ ReleaseRuntimeStepReceiptInput,
9
+ RuntimeStepReceipt,
10
+ SkipRuntimeStepReceiptInput,
11
+ } from './ctx-types';
12
+ import {
13
+ acquireRuntimeReceiptExecutionLockViaAppRuntime,
14
+ AppRuntimeApiResponseError,
15
+ AppRuntimeApiTransportError,
16
+ claimRuntimeStepReceiptsViaAppRuntime,
17
+ completeRuntimeStepReceiptsViaAppRuntime,
18
+ failRuntimeStepReceiptsViaAppRuntime,
19
+ getRuntimeStepReceiptsViaAppRuntime,
20
+ heartbeatRuntimeStepReceiptsViaAppRuntime,
21
+ isAppRuntimeApiCapacityError,
22
+ releaseRuntimeReceiptExecutionLockViaAppRuntime,
23
+ releaseRuntimeStepReceiptViaAppRuntime,
24
+ skipRuntimeStepReceiptViaAppRuntime,
25
+ type WorkerRuntimeApiContext,
26
+ } from './app-runtime-api';
27
+ import { RuntimeReceiptWriter } from './runtime-receipt-writer';
28
+
29
+ type ReceiptOutput =
30
+ | RuntimeStepReceipt
31
+ | null
32
+ | boolean
33
+ | { ownerExecutionId: string; expiresAt: string };
34
+
35
+ type ReceiptCommand =
36
+ | {
37
+ kind: 'get';
38
+ input: { key: string; runId: string };
39
+ }
40
+ | {
41
+ kind: 'claim';
42
+ input: ClaimRuntimeStepReceiptInput;
43
+ }
44
+ | {
45
+ kind: 'settle';
46
+ outcome: 'complete';
47
+ input: CompleteRuntimeStepReceiptInput;
48
+ }
49
+ | {
50
+ kind: 'settle';
51
+ outcome: 'fail';
52
+ input: FailRuntimeStepReceiptInput;
53
+ }
54
+ | {
55
+ kind: 'heartbeat';
56
+ input: {
57
+ key: string;
58
+ runId: string;
59
+ runAttempt?: number | null;
60
+ leaseId: string;
61
+ };
62
+ }
63
+ | {
64
+ kind: 'settle';
65
+ outcome: 'release';
66
+ input: ReleaseRuntimeStepReceiptInput;
67
+ serial: number;
68
+ }
69
+ | {
70
+ kind: 'settle';
71
+ outcome: 'skip';
72
+ input: SkipRuntimeStepReceiptInput;
73
+ serial: number;
74
+ }
75
+ | {
76
+ kind: 'acquire_execution_lock';
77
+ input: AcquireRuntimeReceiptExecutionLockInput;
78
+ serial: number;
79
+ }
80
+ | {
81
+ kind: 'release_execution_lock';
82
+ input: ReleaseRuntimeReceiptExecutionLockInput;
83
+ serial: number;
84
+ };
85
+
86
+ type ReceiptHandlerName =
87
+ | 'acquireRuntimeReceiptExecutionLock'
88
+ | 'releaseRuntimeReceiptExecutionLock'
89
+ | 'getRuntimeStepReceipt'
90
+ | 'getRuntimeStepReceipts'
91
+ | 'claimRuntimeStepReceipt'
92
+ | 'claimRuntimeStepReceipts'
93
+ | 'completeRuntimeStepReceipt'
94
+ | 'completeRuntimeStepReceipts'
95
+ | 'releaseRuntimeStepReceipt'
96
+ | 'failRuntimeStepReceipt'
97
+ | 'failRuntimeStepReceipts'
98
+ | 'heartbeatRuntimeStepReceipts'
99
+ | 'skipRuntimeStepReceipt';
100
+
101
+ export type RuntimeReceiptStoreHandlers = Required<
102
+ Pick<ContextOptions, ReceiptHandlerName>
103
+ >;
104
+
105
+ /**
106
+ * The runner's single Work Receipt Adapter.
107
+ *
108
+ * Receipt-domain batching and gateway transport stay behind this seam. Every
109
+ * command shares one writer, so heterogeneous operations cannot produce
110
+ * concurrent gateway requests. The low-level runtime client is explicitly
111
+ * one-attempt; this Adapter is the sole retry owner.
112
+ */
113
+ export class RuntimeReceiptStoreAdapter {
114
+ readonly handlers: RuntimeReceiptStoreHandlers;
115
+ readonly #writer: RuntimeReceiptWriter<ReceiptCommand, ReceiptOutput>;
116
+ #serial = 0;
117
+
118
+ constructor(options: {
119
+ context: WorkerRuntimeApiContext;
120
+ playName: string;
121
+ runId: string;
122
+ maxBatchSize?: number;
123
+ maxFlushMs?: number;
124
+ maxBufferedBytes?: number;
125
+ }) {
126
+ const context: WorkerRuntimeApiContext = {
127
+ ...options.context,
128
+ retryPolicy: 'none',
129
+ };
130
+ this.#writer = new RuntimeReceiptWriter({
131
+ batchKey: receiptCommandBatchKey,
132
+ maxBatchSize: options.maxBatchSize,
133
+ maxFlushMs: options.maxFlushMs,
134
+ maxBufferedBytes: options.maxBufferedBytes,
135
+ classifyRetryableError: classifyReceiptRetry,
136
+ send: async (commands, { signal }) =>
137
+ await sendReceiptCommands({
138
+ context: { ...context, signal },
139
+ playName: options.playName,
140
+ commands,
141
+ }),
142
+ });
143
+
144
+ const write = <Output extends ReceiptOutput>(
145
+ command: ReceiptCommand,
146
+ ): Promise<Output> =>
147
+ this.#writer.write(command, {
148
+ signal: options.context.signal,
149
+ }) as Promise<Output>;
150
+ const writeMany = <Output extends ReceiptOutput>(
151
+ commands: readonly ReceiptCommand[],
152
+ ): Promise<Output[]> =>
153
+ Promise.all(commands.map((command) => write<Output>(command)));
154
+
155
+ this.handlers = {
156
+ acquireRuntimeReceiptExecutionLock: (input) =>
157
+ write({
158
+ kind: 'acquire_execution_lock',
159
+ input,
160
+ serial: this.#nextSerial(),
161
+ }),
162
+ releaseRuntimeReceiptExecutionLock: (input) =>
163
+ write({
164
+ kind: 'release_execution_lock',
165
+ input,
166
+ serial: this.#nextSerial(),
167
+ }),
168
+ getRuntimeStepReceipt: ({ key }) =>
169
+ write({
170
+ kind: 'get',
171
+ input: { key, runId: options.runId },
172
+ }),
173
+ getRuntimeStepReceipts: ({ keys }) =>
174
+ writeMany(
175
+ keys.map((key) => ({
176
+ kind: 'get' as const,
177
+ input: { key, runId: options.runId },
178
+ })),
179
+ ),
180
+ claimRuntimeStepReceipt: (input) => write({ kind: 'claim', input }),
181
+ claimRuntimeStepReceipts: (input) =>
182
+ writeMany(
183
+ input.keys.map((key, index) => ({
184
+ kind: 'claim' as const,
185
+ input: {
186
+ key,
187
+ runId: input.runId,
188
+ runAttempt: input.runAttempt,
189
+ leaseAware: input.leaseAware,
190
+ reclaimRunning: input.reclaimRunning,
191
+ forceRefresh: input.forceRefresh,
192
+ forceFailedRefresh: input.forceFailedRefresh,
193
+ ...(input.leaseIds ? { leaseId: input.leaseIds[index] } : {}),
194
+ },
195
+ })),
196
+ ),
197
+ completeRuntimeStepReceipt: (input) =>
198
+ write({ kind: 'settle', outcome: 'complete', input }),
199
+ completeRuntimeStepReceipts: ({ receipts }) =>
200
+ writeMany(
201
+ receipts.map((input) => ({
202
+ kind: 'settle' as const,
203
+ outcome: 'complete' as const,
204
+ input,
205
+ })),
206
+ ),
207
+ releaseRuntimeStepReceipt: (input) =>
208
+ write({
209
+ kind: 'settle',
210
+ outcome: 'release',
211
+ input,
212
+ serial: this.#nextSerial(),
213
+ }),
214
+ failRuntimeStepReceipt: (input) =>
215
+ write({ kind: 'settle', outcome: 'fail', input }),
216
+ failRuntimeStepReceipts: ({ receipts }) =>
217
+ writeMany(
218
+ receipts.map((input) => ({
219
+ kind: 'settle' as const,
220
+ outcome: 'fail' as const,
221
+ input,
222
+ })),
223
+ ),
224
+ heartbeatRuntimeStepReceipts: (input) =>
225
+ writeMany(
226
+ input.keys.map((key) => ({
227
+ kind: 'heartbeat' as const,
228
+ input: {
229
+ key,
230
+ runId: input.runId,
231
+ runAttempt: input.runAttempt,
232
+ leaseId: input.leaseId,
233
+ },
234
+ })),
235
+ ),
236
+ skipRuntimeStepReceipt: (input) =>
237
+ write({
238
+ kind: 'settle',
239
+ outcome: 'skip',
240
+ input,
241
+ serial: this.#nextSerial(),
242
+ }),
243
+ };
244
+ }
245
+
246
+ flush(): Promise<void> {
247
+ return this.#writer.flush();
248
+ }
249
+
250
+ close(reason?: unknown): Promise<void> {
251
+ return this.#writer.close(reason);
252
+ }
253
+
254
+ #nextSerial(): number {
255
+ return ++this.#serial;
256
+ }
257
+ }
258
+
259
+ function receiptCommandBatchKey(command: ReceiptCommand): string {
260
+ switch (command.kind) {
261
+ case 'get':
262
+ return JSON.stringify([command.kind, command.input.runId]);
263
+ case 'claim':
264
+ return JSON.stringify([
265
+ command.kind,
266
+ command.input.runId,
267
+ command.input.runAttempt ?? null,
268
+ command.input.leaseAware === true,
269
+ command.input.reclaimRunning === true,
270
+ command.input.forceRefresh === true,
271
+ command.input.forceFailedRefresh === true,
272
+ command.input.leaseId !== undefined,
273
+ ]);
274
+ case 'settle':
275
+ return command.outcome === 'complete' || command.outcome === 'fail'
276
+ ? JSON.stringify([command.kind, command.outcome, command.input.runId])
277
+ : `${command.kind}:${command.outcome}:${command.serial}`;
278
+ case 'heartbeat':
279
+ return JSON.stringify([
280
+ command.kind,
281
+ command.input.runId,
282
+ command.input.runAttempt ?? null,
283
+ command.input.leaseId,
284
+ ]);
285
+ case 'acquire_execution_lock':
286
+ case 'release_execution_lock':
287
+ return `${command.kind}:${command.serial}`;
288
+ }
289
+ }
290
+
291
+ function classifyReceiptRetry(error: unknown): { retryAfterMs: number } | null {
292
+ if (isAppRuntimeApiCapacityError(error)) {
293
+ return { retryAfterMs: error.retryAfterMs };
294
+ }
295
+ if (error instanceof AppRuntimeApiTransportError) {
296
+ return { retryAfterMs: 250 };
297
+ }
298
+ if (error instanceof AppRuntimeApiResponseError && error.retryable) {
299
+ return { retryAfterMs: 250 };
300
+ }
301
+ return null;
302
+ }
303
+
304
+ async function sendReceiptCommands(input: {
305
+ context: WorkerRuntimeApiContext;
306
+ playName: string;
307
+ commands: readonly ReceiptCommand[];
308
+ }): Promise<readonly ReceiptOutput[]> {
309
+ const first = input.commands[0];
310
+ if (!first) return [];
311
+ switch (first.kind) {
312
+ case 'get': {
313
+ const commands = input.commands as readonly Extract<
314
+ ReceiptCommand,
315
+ { kind: 'get' }
316
+ >[];
317
+ return await getRuntimeStepReceiptsViaAppRuntime(input.context, {
318
+ playName: input.playName,
319
+ runId: first.input.runId,
320
+ keys: commands.map((command) => command.input.key),
321
+ });
322
+ }
323
+ case 'claim': {
324
+ const commands = input.commands as readonly Extract<
325
+ ReceiptCommand,
326
+ { kind: 'claim' }
327
+ >[];
328
+ return await claimRuntimeStepReceiptsViaAppRuntime(input.context, {
329
+ playName: input.playName,
330
+ runId: first.input.runId,
331
+ runAttempt: first.input.runAttempt,
332
+ keys: commands.map((command) => command.input.key),
333
+ leaseIds:
334
+ first.input.leaseId === undefined
335
+ ? undefined
336
+ : commands.map((command) => command.input.leaseId!),
337
+ leaseAware: first.input.leaseAware,
338
+ reclaimRunning: first.input.reclaimRunning,
339
+ forceRefresh: first.input.forceRefresh,
340
+ forceFailedRefresh: first.input.forceFailedRefresh,
341
+ });
342
+ }
343
+ case 'settle':
344
+ switch (first.outcome) {
345
+ case 'complete': {
346
+ const commands = input.commands as readonly Extract<
347
+ ReceiptCommand,
348
+ { kind: 'settle'; outcome: 'complete' }
349
+ >[];
350
+ return await completeRuntimeStepReceiptsViaAppRuntime(input.context, {
351
+ playName: input.playName,
352
+ runId: first.input.runId,
353
+ receipts: commands.map((command) => command.input),
354
+ });
355
+ }
356
+ case 'fail': {
357
+ const commands = input.commands as readonly Extract<
358
+ ReceiptCommand,
359
+ { kind: 'settle'; outcome: 'fail' }
360
+ >[];
361
+ return await failRuntimeStepReceiptsViaAppRuntime(input.context, {
362
+ playName: input.playName,
363
+ runId: first.input.runId,
364
+ receipts: commands.map((command) => command.input),
365
+ });
366
+ }
367
+ case 'release':
368
+ return [
369
+ await releaseRuntimeStepReceiptViaAppRuntime(input.context, {
370
+ playName: input.playName,
371
+ ...first.input,
372
+ }),
373
+ ];
374
+ case 'skip':
375
+ return [
376
+ await skipRuntimeStepReceiptViaAppRuntime(input.context, {
377
+ playName: input.playName,
378
+ ...first.input,
379
+ }),
380
+ ];
381
+ }
382
+ case 'heartbeat': {
383
+ const commands = input.commands as readonly Extract<
384
+ ReceiptCommand,
385
+ { kind: 'heartbeat' }
386
+ >[];
387
+ return await heartbeatRuntimeStepReceiptsViaAppRuntime(input.context, {
388
+ playName: input.playName,
389
+ runId: first.input.runId,
390
+ runAttempt: first.input.runAttempt,
391
+ leaseId: first.input.leaseId,
392
+ keys: commands.map((command) => command.input.key),
393
+ });
394
+ }
395
+ case 'acquire_execution_lock':
396
+ return [
397
+ await acquireRuntimeReceiptExecutionLockViaAppRuntime(input.context, {
398
+ playName: input.playName,
399
+ ...first.input,
400
+ }),
401
+ ];
402
+ case 'release_execution_lock':
403
+ return [
404
+ await releaseRuntimeReceiptExecutionLockViaAppRuntime(input.context, {
405
+ playName: input.playName,
406
+ ...first.input,
407
+ }),
408
+ ];
409
+ }
410
+ }