deepline 0.1.291 → 0.1.293

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.
@@ -1,16 +1,25 @@
1
1
  /**
2
- * One fixed capacity policy for the Absurd runtime.
2
+ * One capacity policy for the Absurd runtime.
3
3
  *
4
- * Postgres owns backlog. Workers drain it at a predictable rate; queue depth
5
- * never creates more runtime or receipt traffic by itself. Keep these values
6
- * together so the worker, queue policy, scaler, gateway, and tests cannot
7
- * independently invent concurrency or timeout budgets.
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
8
  */
9
9
  export const RUNTIME_CAPACITY_POLICY = {
10
10
  absurd: {
11
+ /** Warm floor for availability and deploy rollovers. */
11
12
  activeLaneMachines: 2,
13
+ /** 32 Machines x 8 claim slots permits 256 concurrent launch/resume legs. */
14
+ maxActiveLaneMachines: 32,
12
15
  workerSlotsPerMachine: 8,
13
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,
14
23
  },
15
24
  receiptGateway: {
16
25
  admissionTimeoutMs: 10_000,
@@ -19,22 +28,70 @@ export const RUNTIME_CAPACITY_POLICY = {
19
28
  } as const;
20
29
 
21
30
  export const ABSURD_GLOBAL_RUN_CONCURRENCY =
22
- RUNTIME_CAPACITY_POLICY.absurd.activeLaneMachines *
31
+ RUNTIME_CAPACITY_POLICY.absurd.maxActiveLaneMachines *
23
32
  RUNTIME_CAPACITY_POLICY.absurd.workerSlotsPerMachine;
24
33
 
25
34
  /**
26
- * Active releases retain fixed capacity. Historical releases stay cold until
27
- * they have immediately runnable or currently claimed work.
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.
28
42
  */
29
43
  export function desiredAbsurdLaneMachines(input: {
30
44
  active: boolean;
31
45
  claimableRuns: number;
32
46
  runningRuns?: number;
47
+ currentMachines?: number;
48
+ oldestClaimableAgeMs?: number;
49
+ idleForMs?: number;
33
50
  }): number {
34
- if (input.active) {
35
- return RUNTIME_CAPACITY_POLICY.absurd.activeLaneMachines;
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);
36
85
  }
37
- return input.claimableRuns > 0 || (input.runningRuns ?? 0) > 0 ? 1 : 0;
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;
38
95
  }
39
96
 
40
97
  export function assertRuntimeCapacityPolicy(): void {
@@ -54,6 +111,14 @@ export function assertRuntimeCapacityPolicy(): void {
54
111
  'Per-org runtime concurrency cannot exceed global runtime concurrency.',
55
112
  );
56
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
+ }
57
122
  }
58
123
 
59
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
+ }