deepline 0.3.113 → 0.3.115
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/dist/bundling-sources/sdk/src/client.ts +3 -1
- package/dist/bundling-sources/sdk/src/compat.ts +167 -11
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/sdk/src/skills-version.ts +27 -1
- package/dist/bundling-sources/shared_libs/monitors/inbound-queue.ts +6 -2
- package/dist/bundling-sources/shared_libs/observability/dlq.ts +942 -0
- package/dist/bundling-sources/shared_libs/observability/queue-health.ts +361 -6
- package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +24 -24
- package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +2 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-work.ts +29 -1
- package/dist/bundling-sources/shared_libs/play-runtime/admission-transport-policy.ts +91 -0
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +2 -0
- package/dist/bundling-sources/shared_libs/play-runtime/direct-admission-deadline.ts +34 -0
- package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +1 -0
- package/dist/bundling-sources/shared_libs/play-runtime/internal-bootstrap-token.ts +325 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-admission-outcome.ts +134 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/contracts.ts +13 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/index.ts +4 -1
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/postgres-adapter.ts +13 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-admission-probe.ts +32 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-identity.ts +28 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-cron-fire-task.ts +100 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +313 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +111 -3
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd.ts +16 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +67 -8
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +1734 -147
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +74 -13
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-queue-health.ts +245 -81
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/submit-admission.ts +79 -15
- package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +21 -21
- package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +4 -1
- package/dist/bundling-sources/shared_libs/product-notifications/catalog.ts +64 -0
- package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +1 -1
- package/dist/bundling-sources/shared_libs/product-notifications/events.ts +20 -1
- package/dist/bundling-sources/shared_libs/security/receipt-admission-token.ts +224 -0
- package/dist/cli/index.js +574 -114
- package/dist/cli/index.mjs +592 -133
- package/dist/index.d.mts +54 -1
- package/dist/index.d.ts +54 -1
- package/dist/index.js +8 -3
- package/dist/index.mjs +8 -3
- package/dist/install-integrity.json +8 -0
- package/dist/plays/bundle-play-file.mjs +1 -1
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- package/package.json +1 -1
package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-cron-fire-task.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { RetryStrategy } from '@deepline/absurd/sdk';
|
|
3
|
+
import { RUNTIME_RELIABILITY_POLICY } from '../runtime-reliability-policy';
|
|
4
|
+
import {
|
|
5
|
+
buildPlayRunIdentity,
|
|
6
|
+
type PlayRunIdentity,
|
|
7
|
+
} from '../run-identity';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Durable control task for one scheduled Play occurrence.
|
|
11
|
+
*
|
|
12
|
+
* This is deliberately distinct from `play-run`: it owns the timer and the
|
|
13
|
+
* successor cursor, but never executes customer code. Its handler first parks
|
|
14
|
+
* with `sleepUntil`, then atomically admits the occurrence and its successor.
|
|
15
|
+
*/
|
|
16
|
+
export const ABSURD_CRON_FIRE_TASK = 'cron-fire';
|
|
17
|
+
|
|
18
|
+
export const ABSURD_CRON_FIRE_RETRY_STRATEGY: RetryStrategy = {
|
|
19
|
+
kind: 'exponential',
|
|
20
|
+
baseSeconds: RUNTIME_RELIABILITY_POLICY.engine.retryBaseSeconds,
|
|
21
|
+
factor: 2,
|
|
22
|
+
maxSeconds: RUNTIME_RELIABILITY_POLICY.engine.retryMaxSeconds,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A timer must not exhaust quietly: unlike a customer-code task, it is the
|
|
27
|
+
* only owner of the next occurrence. The finite retry ladder remains a guard
|
|
28
|
+
* against malformed task payloads; expected scheduler-store failures are
|
|
29
|
+
* deferred by the handler before they consume this ladder.
|
|
30
|
+
*/
|
|
31
|
+
export const ABSURD_CRON_FIRE_MAX_ATTEMPTS =
|
|
32
|
+
RUNTIME_RELIABILITY_POLICY.engine.maxAttempts;
|
|
33
|
+
|
|
34
|
+
export type AbsurdCronFireTaskParams = {
|
|
35
|
+
scheduleId: string;
|
|
36
|
+
generation: number;
|
|
37
|
+
/** Fences a replacement timer for the same scheduled occurrence. */
|
|
38
|
+
fireGeneration?: number;
|
|
39
|
+
dueAtMs: number;
|
|
40
|
+
schedulerSchema?: string | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function cronFireIdempotencyKey(input: AbsurdCronFireTaskParams): string {
|
|
44
|
+
return `cron-fire:${input.scheduleId}:${input.generation}:${input.dueAtMs}:${input.fireGeneration ?? 1}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildAbsurdCronFireTask(
|
|
48
|
+
input: AbsurdCronFireTaskParams & { orgId?: string | null },
|
|
49
|
+
) {
|
|
50
|
+
const params: AbsurdCronFireTaskParams = {
|
|
51
|
+
scheduleId: input.scheduleId,
|
|
52
|
+
generation: input.generation,
|
|
53
|
+
fireGeneration: input.fireGeneration ?? 1,
|
|
54
|
+
dueAtMs: input.dueAtMs,
|
|
55
|
+
schedulerSchema: input.schedulerSchema ?? null,
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
taskName: ABSURD_CRON_FIRE_TASK,
|
|
59
|
+
params,
|
|
60
|
+
options: {
|
|
61
|
+
idempotency_key: cronFireIdempotencyKey(params),
|
|
62
|
+
...(input.orgId?.trim() ? { org_key: input.orgId.trim() } : {}),
|
|
63
|
+
// Control tasks must not inherit the cron execution task's 30-minute
|
|
64
|
+
// queue deadline. They may sleep for days and are the successor owner.
|
|
65
|
+
max_attempts: ABSURD_CRON_FIRE_MAX_ATTEMPTS,
|
|
66
|
+
retry_strategy: {
|
|
67
|
+
kind: ABSURD_CRON_FIRE_RETRY_STRATEGY.kind,
|
|
68
|
+
base_seconds: ABSURD_CRON_FIRE_RETRY_STRATEGY.baseSeconds,
|
|
69
|
+
factor: ABSURD_CRON_FIRE_RETRY_STRATEGY.factor,
|
|
70
|
+
max_seconds: ABSURD_CRON_FIRE_RETRY_STRATEGY.maxSeconds,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One schedule occurrence has a deterministic Run identity. Different cron
|
|
78
|
+
* bindings for the same Play and instant receive distinct identities, while a
|
|
79
|
+
* handler replay after the database commit converges to this exact Run.
|
|
80
|
+
*/
|
|
81
|
+
export function buildCronFireRunIdentity(input: {
|
|
82
|
+
scheduleId: string;
|
|
83
|
+
generation: number;
|
|
84
|
+
dueAtMs: number;
|
|
85
|
+
playName: string;
|
|
86
|
+
}): PlayRunIdentity {
|
|
87
|
+
const base = buildPlayRunIdentity(input.playName, new Date(input.dueAtMs), {
|
|
88
|
+
unique: false,
|
|
89
|
+
});
|
|
90
|
+
const suffix = createHash('sha256')
|
|
91
|
+
.update(`${input.scheduleId}\0${input.generation}\0${input.dueAtMs}`)
|
|
92
|
+
.digest('hex')
|
|
93
|
+
.slice(0, 20);
|
|
94
|
+
const workflowId = `${base.workflowId}-cron-${suffix}`;
|
|
95
|
+
return {
|
|
96
|
+
workflowId,
|
|
97
|
+
runId: workflowId,
|
|
98
|
+
workflowFamilyKey: base.workflowFamilyKey,
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts
CHANGED
|
@@ -164,6 +164,8 @@ import {
|
|
|
164
164
|
readPostgresSchedulerRunnerLiveness,
|
|
165
165
|
readPostgresSchedulerRuntimeResourcesForRun,
|
|
166
166
|
requestComputeBillingDispatchToken,
|
|
167
|
+
blockPostgresSchedulerCronSchedule,
|
|
168
|
+
PostgresSchedulerCronScheduleConfigurationError,
|
|
167
169
|
releasePostgresSchedulerReceiptsForAttempt,
|
|
168
170
|
runtimeTerminalReasonFromError,
|
|
169
171
|
settlePostgresSchedulerRuntimeResources,
|
|
@@ -196,6 +198,8 @@ import { runtimeSandboxProviderCapabilities } from './absurd-worker-required-env
|
|
|
196
198
|
import {
|
|
197
199
|
acquirePgNotifyHub,
|
|
198
200
|
isRuntimeSchedulerCapacityError,
|
|
201
|
+
isRuntimeSchedulerRetryableTransactionError,
|
|
202
|
+
isConnectionCategoryError,
|
|
199
203
|
parseRuntimeSchedulerCapacityError,
|
|
200
204
|
RuntimeSchedulerCapacityError,
|
|
201
205
|
runtimeSchedulerCapacityRetryAfterMs,
|
|
@@ -211,6 +215,7 @@ import {
|
|
|
211
215
|
import {
|
|
212
216
|
ABSURD_PLAY_RUN_MAX_ATTEMPTS,
|
|
213
217
|
ABSURD_PLAY_RUN_RETRY_STRATEGY,
|
|
218
|
+
ABSURD_CRON_FIRE_TASK,
|
|
214
219
|
ABSURD_DAYTONA_SANDBOX_CLEANUP_TASK,
|
|
215
220
|
ABSURD_PLAY_RUN_TASK,
|
|
216
221
|
ABSURD_RUNS_QUEUE,
|
|
@@ -234,6 +239,7 @@ import {
|
|
|
234
239
|
normalizeAbsurdSchedulerOptions,
|
|
235
240
|
resolveAbsurdPoolOptions,
|
|
236
241
|
type AbsurdPlayRunTaskParams,
|
|
242
|
+
type AbsurdCronFireTaskParams,
|
|
237
243
|
type AbsurdDaytonaSandboxCleanupTaskParams,
|
|
238
244
|
type AbsurdRuntimeResult,
|
|
239
245
|
type AbsurdSchedulerOptions,
|
|
@@ -659,6 +665,26 @@ export type AbsurdRuntimeWorkerDeps = {
|
|
|
659
665
|
* suspenders lane-mismatch guard without mutating process env. Defaults to
|
|
660
666
|
* `currentAbsurdReleaseId()`. */
|
|
661
667
|
currentReleaseId: () => string;
|
|
668
|
+
/** Raw release registry identity stamped onto new child Run envelopes. */
|
|
669
|
+
currentLaunchReleaseId: () => string;
|
|
670
|
+
/**
|
|
671
|
+
* Cron has a different preparation boundary than ordinary worker execution:
|
|
672
|
+
* the sleeping task asks the current Vercel deployment to build a fresh
|
|
673
|
+
* authority, then that deployment commits the Run, task, outbox fact, and
|
|
674
|
+
* successor cursor in one Runtime Postgres transaction. The task itself
|
|
675
|
+
* never reuses the seed-time launch envelope.
|
|
676
|
+
*/
|
|
677
|
+
admitCronFire: (input: {
|
|
678
|
+
scheduleId: string;
|
|
679
|
+
generation: number;
|
|
680
|
+
fireGeneration: number;
|
|
681
|
+
dueAtMs: number;
|
|
682
|
+
schedulerSchema?: string | null;
|
|
683
|
+
}) => Promise<
|
|
684
|
+
| { status: 'admitted'; runId: string; successorDueAtMs: number }
|
|
685
|
+
| { status: 'superseded' }
|
|
686
|
+
| { status: 'blocked' }
|
|
687
|
+
>;
|
|
662
688
|
/** Best-effort deletion of an orphaned runtime sandbox by id (push-execution
|
|
663
689
|
* B4 GC on reclaim/terminal-failure). Injectable so tests assert the GC call
|
|
664
690
|
* without touching the Daytona SDK. Defaults to `deleteDaytonaSandboxById`. */
|
|
@@ -757,6 +783,8 @@ function buildDefaultDeps(absurd: Absurd): AbsurdRuntimeWorkerDeps {
|
|
|
757
783
|
expectedOrganizationId: routingDomain,
|
|
758
784
|
}),
|
|
759
785
|
currentReleaseId: () => currentAbsurdReleaseId(),
|
|
786
|
+
currentLaunchReleaseId: () => currentAbsurdLaunchReleaseId(),
|
|
787
|
+
admitCronFire: requestCronFireAdmissionViaApp,
|
|
760
788
|
};
|
|
761
789
|
}
|
|
762
790
|
|
|
@@ -930,6 +958,149 @@ export async function requestAttemptExecutorToken(input: {
|
|
|
930
958
|
return body.executorToken;
|
|
931
959
|
}
|
|
932
960
|
|
|
961
|
+
type CronFireAdmissionResult =
|
|
962
|
+
| { status: 'admitted'; runId: string; successorDueAtMs: number }
|
|
963
|
+
| { status: 'superseded' }
|
|
964
|
+
| { status: 'blocked' };
|
|
965
|
+
|
|
966
|
+
/** A Vercel-side admission failure that tells the durable timer whether to defer. */
|
|
967
|
+
class CronFireAdmissionGatewayError extends Error {
|
|
968
|
+
constructor(
|
|
969
|
+
message: string,
|
|
970
|
+
readonly retryable: boolean,
|
|
971
|
+
readonly retryAfterMs: number | null = null,
|
|
972
|
+
) {
|
|
973
|
+
super(message);
|
|
974
|
+
this.name = 'CronFireAdmissionGatewayError';
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function cronFireAdmissionGatewayUrl(): string {
|
|
979
|
+
const raw = process.env.DEEPLINE_RUNTIME_ADMISSION_GATEWAY_URL?.trim();
|
|
980
|
+
if (!raw) {
|
|
981
|
+
throw new CronFireAdmissionGatewayError(
|
|
982
|
+
'DEEPLINE_RUNTIME_ADMISSION_GATEWAY_URL is required for cron-fire fresh admission.',
|
|
983
|
+
true,
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
try {
|
|
987
|
+
const url = new URL(raw);
|
|
988
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
989
|
+
throw new Error('non-HTTP protocol');
|
|
990
|
+
}
|
|
991
|
+
return url.origin;
|
|
992
|
+
} catch {
|
|
993
|
+
throw new CronFireAdmissionGatewayError(
|
|
994
|
+
'DEEPLINE_RUNTIME_ADMISSION_GATEWAY_URL must be an absolute HTTP(S) URL.',
|
|
995
|
+
true,
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function cronFireRetryAfterMs(headers: Headers): number | null {
|
|
1001
|
+
const value = headers.get('retry-after')?.trim();
|
|
1002
|
+
if (!value) return null;
|
|
1003
|
+
const seconds = Number(value);
|
|
1004
|
+
return Number.isFinite(seconds) && seconds >= 0
|
|
1005
|
+
? Math.round(seconds * 1_000)
|
|
1006
|
+
: null;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Make the timer's only external call after wake. The target is a stable
|
|
1011
|
+
* Vercel alias, not the seed-time runtimeApiBaseUrl, so it always mints fresh
|
|
1012
|
+
* run authority under the currently serving application release.
|
|
1013
|
+
*/
|
|
1014
|
+
export async function requestCronFireAdmissionViaApp(input: {
|
|
1015
|
+
scheduleId: string;
|
|
1016
|
+
generation: number;
|
|
1017
|
+
fireGeneration: number;
|
|
1018
|
+
dueAtMs: number;
|
|
1019
|
+
schedulerSchema?: string | null;
|
|
1020
|
+
}): Promise<CronFireAdmissionResult> {
|
|
1021
|
+
const internalToken = process.env.DEEPLINE_INTERNAL_TOKEN?.trim();
|
|
1022
|
+
if (!internalToken) {
|
|
1023
|
+
throw new CronFireAdmissionGatewayError(
|
|
1024
|
+
'DEEPLINE_INTERNAL_TOKEN is required for cron-fire fresh admission.',
|
|
1025
|
+
true,
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
const admissionGatewayUrl = cronFireAdmissionGatewayUrl();
|
|
1029
|
+
const protectionHeaders = await vercelProtectionBypassHeaders({
|
|
1030
|
+
baseUrl: admissionGatewayUrl,
|
|
1031
|
+
token: process.env.VERCEL_PROTECTION_BYPASS_TOKEN ?? null,
|
|
1032
|
+
});
|
|
1033
|
+
let response: Response;
|
|
1034
|
+
try {
|
|
1035
|
+
response = await fetch(
|
|
1036
|
+
`${admissionGatewayUrl}/api/v2/internal/runtime/cron-fire`,
|
|
1037
|
+
{
|
|
1038
|
+
method: 'POST',
|
|
1039
|
+
signal: AbortSignal.timeout(30_000),
|
|
1040
|
+
headers: {
|
|
1041
|
+
'content-type': 'application/json',
|
|
1042
|
+
'x-deepline-internal-token': internalToken,
|
|
1043
|
+
'x-deepline-runtime-trace-id': `cron-fire:${input.scheduleId}:${input.generation}:${input.fireGeneration}:${input.dueAtMs}`,
|
|
1044
|
+
...protectionHeaders,
|
|
1045
|
+
},
|
|
1046
|
+
body: JSON.stringify(input),
|
|
1047
|
+
},
|
|
1048
|
+
);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
throw new CronFireAdmissionGatewayError(
|
|
1051
|
+
`Cron-fire admission gateway transport failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1052
|
+
true,
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
const raw = await response.text();
|
|
1056
|
+
const body = (() => {
|
|
1057
|
+
try {
|
|
1058
|
+
return JSON.parse(raw) as {
|
|
1059
|
+
status?: unknown;
|
|
1060
|
+
runId?: unknown;
|
|
1061
|
+
successorDueAtMs?: unknown;
|
|
1062
|
+
error?: unknown;
|
|
1063
|
+
retryable?: unknown;
|
|
1064
|
+
};
|
|
1065
|
+
} catch {
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
})();
|
|
1069
|
+
if (
|
|
1070
|
+
response.ok &&
|
|
1071
|
+
body?.status === 'admitted' &&
|
|
1072
|
+
typeof body.runId === 'string' &&
|
|
1073
|
+
typeof body.successorDueAtMs === 'number'
|
|
1074
|
+
) {
|
|
1075
|
+
return {
|
|
1076
|
+
status: 'admitted',
|
|
1077
|
+
runId: body.runId,
|
|
1078
|
+
successorDueAtMs: body.successorDueAtMs,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
if (
|
|
1082
|
+
response.ok &&
|
|
1083
|
+
(body?.status === 'superseded' || body?.status === 'blocked')
|
|
1084
|
+
) {
|
|
1085
|
+
return { status: body.status };
|
|
1086
|
+
}
|
|
1087
|
+
const retryable =
|
|
1088
|
+
typeof body?.retryable === 'boolean'
|
|
1089
|
+
? body.retryable
|
|
1090
|
+
: response.status === 401 ||
|
|
1091
|
+
response.status === 403 ||
|
|
1092
|
+
response.status === 408 ||
|
|
1093
|
+
response.status === 429 ||
|
|
1094
|
+
response.status >= 500;
|
|
1095
|
+
throw new CronFireAdmissionGatewayError(
|
|
1096
|
+
typeof body?.error === 'string'
|
|
1097
|
+
? body.error
|
|
1098
|
+
: `Cron-fire admission gateway returned HTTP ${response.status}.`,
|
|
1099
|
+
retryable,
|
|
1100
|
+
cronFireRetryAfterMs(response.headers),
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
933
1104
|
function signalData(
|
|
934
1105
|
record: PostgresSchedulerSignalRecord | null,
|
|
935
1106
|
): Record<string, unknown> | null {
|
|
@@ -1268,6 +1439,142 @@ export async function executeAbsurdRuntimeLaunch(
|
|
|
1268
1439
|
}
|
|
1269
1440
|
}
|
|
1270
1441
|
|
|
1442
|
+
/**
|
|
1443
|
+
* Advance one Runtime-owned cron cursor. The durable sleep is intentionally in
|
|
1444
|
+
* the task rather than in Vercel: it releases the worker claim until due and a
|
|
1445
|
+
* database outage after wake defers this same task without acknowledging the
|
|
1446
|
+
* occurrence or consuming customer-code attempts.
|
|
1447
|
+
*/
|
|
1448
|
+
export async function executeAbsurdCronFire(
|
|
1449
|
+
params: AbsurdCronFireTaskParams,
|
|
1450
|
+
ctx: TaskContext,
|
|
1451
|
+
deps: Pick<AbsurdRuntimeWorkerDeps, 'runWithClient' | 'admitCronFire'>,
|
|
1452
|
+
): Promise<
|
|
1453
|
+
| { status: 'admitted'; runId: string; successorDueAtMs: number }
|
|
1454
|
+
| { status: 'superseded' }
|
|
1455
|
+
| { status: 'blocked' }
|
|
1456
|
+
> {
|
|
1457
|
+
// Reader-first ABI compatibility: a timer written by the immediately
|
|
1458
|
+
// preceding reader did not carry the repair generation. Its only safe
|
|
1459
|
+
// interpretation is the original generation one.
|
|
1460
|
+
const fireGeneration = params.fireGeneration ?? 1;
|
|
1461
|
+
if (
|
|
1462
|
+
!params.scheduleId?.trim() ||
|
|
1463
|
+
!Number.isSafeInteger(params.generation) ||
|
|
1464
|
+
params.generation <= 0 ||
|
|
1465
|
+
!Number.isSafeInteger(fireGeneration) ||
|
|
1466
|
+
fireGeneration <= 0 ||
|
|
1467
|
+
!Number.isSafeInteger(params.dueAtMs) ||
|
|
1468
|
+
params.dueAtMs <= 0
|
|
1469
|
+
) {
|
|
1470
|
+
throw new Error(
|
|
1471
|
+
'cron-fire requires a schedule id, generation, and due time.',
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
try {
|
|
1475
|
+
// `sleepUntil` persists a checkpoint and a wake through the scheduler
|
|
1476
|
+
// store before it throws SuspendTask. A transient failure there is no less
|
|
1477
|
+
// retryable than the later atomic fire CTE, so keep this boundary inside
|
|
1478
|
+
// the same durable deferral policy.
|
|
1479
|
+
await ctx.sleepUntil('due', new Date(params.dueAtMs));
|
|
1480
|
+
const result = await deps.admitCronFire({
|
|
1481
|
+
scheduleId: params.scheduleId,
|
|
1482
|
+
generation: params.generation,
|
|
1483
|
+
fireGeneration,
|
|
1484
|
+
dueAtMs: params.dueAtMs,
|
|
1485
|
+
schedulerSchema: params.schedulerSchema ?? null,
|
|
1486
|
+
});
|
|
1487
|
+
console.info('[play.cron_fire]', {
|
|
1488
|
+
scheduleId: params.scheduleId,
|
|
1489
|
+
generation: params.generation,
|
|
1490
|
+
dueAtMs: params.dueAtMs,
|
|
1491
|
+
status: result.status,
|
|
1492
|
+
...(result.status === 'admitted'
|
|
1493
|
+
? { runId: result.runId, successorDueAtMs: result.successorDueAtMs }
|
|
1494
|
+
: {}),
|
|
1495
|
+
});
|
|
1496
|
+
return result;
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
if (
|
|
1499
|
+
error instanceof SuspendTask ||
|
|
1500
|
+
error instanceof CancelledTask ||
|
|
1501
|
+
error instanceof FailedTask
|
|
1502
|
+
) {
|
|
1503
|
+
throw error;
|
|
1504
|
+
}
|
|
1505
|
+
if (error instanceof PostgresSchedulerCronScheduleConfigurationError) {
|
|
1506
|
+
const reason = error.message;
|
|
1507
|
+
let blocked: boolean;
|
|
1508
|
+
try {
|
|
1509
|
+
blocked = await deps.runWithClient((client) =>
|
|
1510
|
+
blockPostgresSchedulerCronSchedule(
|
|
1511
|
+
client,
|
|
1512
|
+
{
|
|
1513
|
+
scheduleId: params.scheduleId,
|
|
1514
|
+
generation: params.generation,
|
|
1515
|
+
fireGeneration,
|
|
1516
|
+
dueAtMs: params.dueAtMs,
|
|
1517
|
+
reason,
|
|
1518
|
+
},
|
|
1519
|
+
{ schema: params.schedulerSchema ?? undefined },
|
|
1520
|
+
),
|
|
1521
|
+
);
|
|
1522
|
+
} catch (blockError) {
|
|
1523
|
+
if (isRetryableCronFireStoreError(blockError)) {
|
|
1524
|
+
throw deferCronFireStoreError(blockError);
|
|
1525
|
+
}
|
|
1526
|
+
throw blockError;
|
|
1527
|
+
}
|
|
1528
|
+
console.error('[play.cron_fire.blocked]', {
|
|
1529
|
+
scheduleId: params.scheduleId,
|
|
1530
|
+
generation: params.generation,
|
|
1531
|
+
dueAtMs: params.dueAtMs,
|
|
1532
|
+
blocked,
|
|
1533
|
+
reason,
|
|
1534
|
+
});
|
|
1535
|
+
return { status: 'blocked' };
|
|
1536
|
+
}
|
|
1537
|
+
// A failed transaction has created neither Run nor successor. Parking the
|
|
1538
|
+
// same durable fire preserves the cron retry owner (Absurd) through a
|
|
1539
|
+
// PgBouncer or control-plane outage; DeferTask does not spend an engine
|
|
1540
|
+
// attempt or create a second task.
|
|
1541
|
+
if (isRetryableCronFireStoreError(error)) {
|
|
1542
|
+
throw deferCronFireStoreError(error);
|
|
1543
|
+
}
|
|
1544
|
+
throw error;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function isRetryableCronFireStoreError(error: unknown): boolean {
|
|
1549
|
+
if (error instanceof CronFireAdmissionGatewayError) return error.retryable;
|
|
1550
|
+
if (
|
|
1551
|
+
isRuntimeSchedulerCapacityError(error) ||
|
|
1552
|
+
isConnectionCategoryError(error) ||
|
|
1553
|
+
isRuntimeSchedulerRetryableTransactionError(error)
|
|
1554
|
+
) {
|
|
1555
|
+
return true;
|
|
1556
|
+
}
|
|
1557
|
+
const code =
|
|
1558
|
+
error && typeof error === 'object'
|
|
1559
|
+
? (error as { code?: unknown }).code
|
|
1560
|
+
: null;
|
|
1561
|
+
if (code === '57014') return true; // query cancelled / statement timeout
|
|
1562
|
+
return (
|
|
1563
|
+
error instanceof Error &&
|
|
1564
|
+
/no longer accepts new launches/i.test(error.message)
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function deferCronFireStoreError(error: unknown): DeferTask {
|
|
1569
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1570
|
+
const retryAfterMs =
|
|
1571
|
+
error instanceof CronFireAdmissionGatewayError ? error.retryAfterMs : null;
|
|
1572
|
+
return new DeferTask(
|
|
1573
|
+
`cron-fire atomic admission deferred: ${reason}`,
|
|
1574
|
+
retryAfterMs === null ? 30 : Math.max(1, Math.ceil(retryAfterMs / 1_000)),
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1271
1578
|
/**
|
|
1272
1579
|
* ADR 0016 rule 3: load the run's durable terminal truth and fold it into the
|
|
1273
1580
|
* progress reporter BEFORE its terminal finalize flush.
|
|
@@ -4248,6 +4555,12 @@ export function createAbsurdRuntimeTasks(
|
|
|
4248
4555
|
resolved,
|
|
4249
4556
|
),
|
|
4250
4557
|
);
|
|
4558
|
+
// Register on both the current lane and every adopted continuation lane.
|
|
4559
|
+
// The task may sleep for days, but its handler schedules children on the
|
|
4560
|
+
// current release through `resolved.currentReleaseId` after waking.
|
|
4561
|
+
absurd.registerTask({ name: ABSURD_CRON_FIRE_TASK, queue }, (params, ctx) =>
|
|
4562
|
+
executeAbsurdCronFire(params as AbsurdCronFireTaskParams, ctx, resolved),
|
|
4563
|
+
);
|
|
4251
4564
|
absurd.registerTask(
|
|
4252
4565
|
{ name: ABSURD_DAYTONA_SANDBOX_CLEANUP_TASK, queue },
|
|
4253
4566
|
async (params) => {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* absurd-specific wiring (queue, event names, checkpoint boundary shaping).
|
|
11
11
|
*/
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
14
|
import type {
|
|
14
15
|
PlayCheckpoint,
|
|
15
16
|
PlayExecutionEvent,
|
|
@@ -20,6 +21,11 @@ import type { PlayRunLedgerEvent } from '../run-ledger';
|
|
|
20
21
|
import type { PlaySchedulerSubmitInput } from '../scheduler-backend';
|
|
21
22
|
import type { WorkerRuntimeApiContext } from '../app-runtime-api';
|
|
22
23
|
import { RUNTIME_CAPACITY_POLICY } from '../runtime-capacity-policy';
|
|
24
|
+
import {
|
|
25
|
+
PLAY_ADMISSION_MAX_ACQUIRE_QUEUE_WAIT_MS,
|
|
26
|
+
PLAY_ADMISSION_MAX_ACTIVE_LIFECYCLES,
|
|
27
|
+
PLAY_ADMISSION_MAX_PARALLELISM,
|
|
28
|
+
} from '../admission-transport-policy';
|
|
23
29
|
import { assertKnownResolvedBoundary } from '../checkpoint-boundaries';
|
|
24
30
|
import type { Absurd, Log, RetryStrategy } from '@deepline/absurd/sdk';
|
|
25
31
|
import { isIsolatedRuntimeSchedulerSchema } from '../runtime-scheduler-topology';
|
|
@@ -503,6 +509,15 @@ export {
|
|
|
503
509
|
ABSURD_PLAY_RUN_TASK,
|
|
504
510
|
type AbsurdPlayRunTaskParams,
|
|
505
511
|
} from './absurd-play-run-task';
|
|
512
|
+
export {
|
|
513
|
+
ABSURD_CRON_FIRE_MAX_ATTEMPTS,
|
|
514
|
+
ABSURD_CRON_FIRE_RETRY_STRATEGY,
|
|
515
|
+
ABSURD_CRON_FIRE_TASK,
|
|
516
|
+
buildAbsurdCronFireTask,
|
|
517
|
+
buildCronFireRunIdentity,
|
|
518
|
+
cronFireIdempotencyKey,
|
|
519
|
+
type AbsurdCronFireTaskParams,
|
|
520
|
+
} from './absurd-cron-fire-task';
|
|
506
521
|
/** Worker-owned cleanup for a Daytona sandbox in this release lane. */
|
|
507
522
|
export const ABSURD_DAYTONA_SANDBOX_CLEANUP_TASK = 'daytona-sandbox-cleanup';
|
|
508
523
|
|
|
@@ -548,6 +563,87 @@ export const ABSURD_RUNTIME_POOL_MAX_CLIENTS =
|
|
|
548
563
|
*/
|
|
549
564
|
export const ABSURD_SERVERLESS_POOL_MAX_CLIENTS = 1;
|
|
550
565
|
|
|
566
|
+
/**
|
|
567
|
+
* A Vercel isolate may survive long after its one webhook has returned. Keep
|
|
568
|
+
* its single scheduler client only long enough to reuse it for an immediate
|
|
569
|
+
* follow-up, then return that scarce PgBouncer client before the source's
|
|
570
|
+
* delayed Workpool recovery is due. The worker fleet deliberately keeps the
|
|
571
|
+
* ordinary 30-second idle lifetime.
|
|
572
|
+
*/
|
|
573
|
+
export const ABSURD_SERVERLESS_POOL_IDLE_TIMEOUT_MS = 1_000;
|
|
574
|
+
|
|
575
|
+
// A normal Vercel request submits exactly one Run and must retain the one-client
|
|
576
|
+
// ceiling. The private batch adapter is different: Workpool elects one global
|
|
577
|
+
// up-to-1,024-source collection before entering this context, so one batch
|
|
578
|
+
// request may use the bounded scheduler capacity without multiplying it per
|
|
579
|
+
// source request.
|
|
580
|
+
// Other one-client Workpool adapters can still overlap during rollout.
|
|
581
|
+
type ServerlessAdmissionPoolContext = {
|
|
582
|
+
maxClients: number;
|
|
583
|
+
maxAcquireQueueDepth: number;
|
|
584
|
+
maxAcquireQueueWaitMs: number;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const serverlessAdmissionPoolContext =
|
|
588
|
+
new AsyncLocalStorage<ServerlessAdmissionPoolContext>();
|
|
589
|
+
|
|
590
|
+
export async function withServerlessAdmissionPoolMaxClients<T>(input: {
|
|
591
|
+
maxClients: number;
|
|
592
|
+
maxAcquireQueueDepth?: number;
|
|
593
|
+
maxAcquireQueueWaitMs?: number;
|
|
594
|
+
fn: () => Promise<T>;
|
|
595
|
+
}): Promise<T> {
|
|
596
|
+
if (
|
|
597
|
+
!Number.isInteger(input.maxClients) ||
|
|
598
|
+
input.maxClients < 1 ||
|
|
599
|
+
input.maxClients > PLAY_ADMISSION_MAX_PARALLELISM
|
|
600
|
+
) {
|
|
601
|
+
throw new Error(
|
|
602
|
+
'Serverless admission pool capacity is outside the supported range.',
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
const maxAcquireQueueDepth =
|
|
606
|
+
input.maxAcquireQueueDepth ?? PLAY_ADMISSION_MAX_ACTIVE_LIFECYCLES;
|
|
607
|
+
const maxAcquireQueueWaitMs =
|
|
608
|
+
input.maxAcquireQueueWaitMs ?? PLAY_ADMISSION_MAX_ACQUIRE_QUEUE_WAIT_MS;
|
|
609
|
+
if (
|
|
610
|
+
!Number.isSafeInteger(maxAcquireQueueDepth) ||
|
|
611
|
+
maxAcquireQueueDepth < 1 ||
|
|
612
|
+
maxAcquireQueueDepth > PLAY_ADMISSION_MAX_ACTIVE_LIFECYCLES
|
|
613
|
+
) {
|
|
614
|
+
throw new Error(
|
|
615
|
+
'Serverless admission acquire queue capacity is outside the supported range.',
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
if (
|
|
619
|
+
!Number.isSafeInteger(maxAcquireQueueWaitMs) ||
|
|
620
|
+
maxAcquireQueueWaitMs < 5_000 ||
|
|
621
|
+
maxAcquireQueueWaitMs > PLAY_ADMISSION_MAX_ACQUIRE_QUEUE_WAIT_MS
|
|
622
|
+
) {
|
|
623
|
+
throw new Error(
|
|
624
|
+
'Serverless admission acquire queue wait is outside the supported range.',
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
return await serverlessAdmissionPoolContext.run(
|
|
628
|
+
{
|
|
629
|
+
maxClients: input.maxClients,
|
|
630
|
+
maxAcquireQueueDepth,
|
|
631
|
+
maxAcquireQueueWaitMs,
|
|
632
|
+
},
|
|
633
|
+
input.fn,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The private batch adapter owns this request-local scheduler pool. Reserve
|
|
639
|
+
* one client for executor-token and other critical control-plane traffic; the
|
|
640
|
+
* rest may submit the independently idempotent batch sources in parallel.
|
|
641
|
+
*/
|
|
642
|
+
export function serverlessAdmissionSubmitMaxActive(): number | null {
|
|
643
|
+
const context = serverlessAdmissionPoolContext.getStore();
|
|
644
|
+
return context === undefined ? null : Math.max(1, context.maxClients - 1);
|
|
645
|
+
}
|
|
646
|
+
|
|
551
647
|
/**
|
|
552
648
|
* Resolve the runtime scheduler Postgres pool options for the absurd control
|
|
553
649
|
* plane. Loud failure over silent fallback: the absurd worker and scheduler
|
|
@@ -563,12 +659,24 @@ export function resolveAbsurdPoolOptions(
|
|
|
563
659
|
'scheduler Postgres URL). Refusing to fall back to local execution.',
|
|
564
660
|
);
|
|
565
661
|
}
|
|
662
|
+
const serverless = process.env.VERCEL?.trim();
|
|
663
|
+
const context = serverlessAdmissionPoolContext.getStore();
|
|
566
664
|
return {
|
|
567
665
|
url,
|
|
568
666
|
applicationName: 'deepline-absurd-runtime',
|
|
569
|
-
maxClients:
|
|
570
|
-
|
|
571
|
-
|
|
667
|
+
maxClients:
|
|
668
|
+
serverless || context !== undefined
|
|
669
|
+
? (context?.maxClients ?? ABSURD_SERVERLESS_POOL_MAX_CLIENTS)
|
|
670
|
+
: ABSURD_RUNTIME_POOL_MAX_CLIENTS,
|
|
671
|
+
...(serverless || context !== undefined
|
|
672
|
+
? { idleTimeoutMs: ABSURD_SERVERLESS_POOL_IDLE_TIMEOUT_MS }
|
|
673
|
+
: {}),
|
|
674
|
+
...(context
|
|
675
|
+
? {
|
|
676
|
+
maxAcquireQueueDepth: context.maxAcquireQueueDepth,
|
|
677
|
+
maxAcquireQueueWaitMs: context.maxAcquireQueueWaitMs,
|
|
678
|
+
}
|
|
679
|
+
: {}),
|
|
572
680
|
};
|
|
573
681
|
}
|
|
574
682
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { recordPerfTrace } from '../perf-trace';
|
|
22
22
|
import { CTX_RUN_PLAY_INLINE_ONLY } from '../child-execution-strategy';
|
|
23
|
+
import { PLAY_ADMISSION_MAX_ACTIVE_LIFECYCLES } from '../admission-transport-policy';
|
|
23
24
|
import {
|
|
24
25
|
defaultRuntimeSandboxReconciliation,
|
|
25
26
|
runtimeSandboxDeleteSucceeded,
|
|
@@ -58,6 +59,7 @@ import {
|
|
|
58
59
|
ensureAbsurdRuntimeReady,
|
|
59
60
|
normalizeAbsurdSchedulerOptions,
|
|
60
61
|
resolveAbsurdPoolOptions,
|
|
62
|
+
serverlessAdmissionSubmitMaxActive,
|
|
61
63
|
type AbsurdDaytonaSandboxCleanupTaskParams,
|
|
62
64
|
type AbsurdSchedulerOptions,
|
|
63
65
|
} from './absurd-shared';
|
|
@@ -325,9 +327,23 @@ export function createAbsurdSchedulerBackend(
|
|
|
325
327
|
// Returning from submit therefore means the durable work is runnable;
|
|
326
328
|
// there is no outbox, dispatcher, or second admission boundary.
|
|
327
329
|
let submissionQueue = queue;
|
|
330
|
+
const serverlessSubmitMaxActive = serverlessAdmissionSubmitMaxActive();
|
|
328
331
|
const admissionParams = {
|
|
329
332
|
schedulerUrl: poolOptions.url,
|
|
330
333
|
schedulerSchema: normalizedSchedulerOptions.schema,
|
|
334
|
+
...(serverlessSubmitMaxActive === null
|
|
335
|
+
? {}
|
|
336
|
+
: {
|
|
337
|
+
maxActive: serverlessSubmitMaxActive,
|
|
338
|
+
// The private batch may prepare 256 sources while 31 atomic
|
|
339
|
+
// Runtime submissions are active. Its process-local wait room
|
|
340
|
+
// must cover that already-bounded difference; otherwise the
|
|
341
|
+
// generic 128-launch buffer rejects healthy burst members
|
|
342
|
+
// before PgBouncer has a chance to drain them.
|
|
343
|
+
maxQueued:
|
|
344
|
+
PLAY_ADMISSION_MAX_ACTIVE_LIFECYCLES -
|
|
345
|
+
serverlessSubmitMaxActive,
|
|
346
|
+
}),
|
|
331
347
|
};
|
|
332
348
|
let spawn = await withRuntimeSchedulerSubmitAdmission(
|
|
333
349
|
admissionParams,
|