feat(ql3): reconcile local lost run retries

This commit is contained in:
whyour
2026-08-12 05:11:46 +08:00
parent 5d3b40ce3b
commit 42e8992818
17 changed files with 809 additions and 43 deletions
@@ -88,6 +88,7 @@ export type LocalAdoptedProfileBootstrapResult =
readonly executionControl: ReadyLocalStorage['executionControl'];
readonly completionReceipts: ReadyLocalStorage['completionReceipts'];
readonly runAttemptLogRetention: ReadyLocalStorage['runAttemptLogRetention'];
readonly runLostRetry: ReadyLocalStorage['runLostRetry'];
readonly localSecrets: LocalSecretEnvelopeRepository;
readonly localSecretAdministration: LocalSecretAdministrationRepository;
readonly projectPolicy: ProjectPolicyRepository;
@@ -233,6 +234,7 @@ export async function bootstrapLocalAdoptedProfileStorage(
executionControl: readyStorage.executionControl,
completionReceipts: readyStorage.completionReceipts,
runAttemptLogRetention: readyStorage.runAttemptLogRetention,
runLostRetry: readyStorage.runLostRetry,
localSecrets: readyStorage.localSecrets,
localSecretAdministration: readyStorage.localSecretAdministration,
projectPolicy: readyStorage.projectPolicy,
@@ -73,6 +73,7 @@ const EXECUTION_CONTROL_POLICIES = Object.freeze({
cleanupPageSize: 8,
controlIntervalMs: 5_000,
controlPageSize: 4,
lostRetryPageSize: 2,
maxDrainPages: 2,
retentionMs: 24 * 60 * 60_000,
artifactNormalRetentionMs: 7 * 24 * 60 * 60_000,
@@ -87,6 +88,7 @@ const EXECUTION_CONTROL_POLICIES = Object.freeze({
cleanupPageSize: 32,
controlIntervalMs: 1_000,
controlPageSize: 32,
lostRetryPageSize: 16,
maxDrainPages: 8,
retentionMs: 60 * 60_000,
artifactNormalRetentionMs: 30 * 24 * 60 * 60_000,
@@ -345,6 +347,8 @@ export async function bootstrapLocalApplication(
stopTimeoutMs: executionPolicy.stopTimeoutMs,
maxDrainPages: executionPolicy.maxDrainPages,
artifactRetention,
lostRetry: storage.runLostRetry,
lostRetryPageSize: executionPolicy.lostRetryPageSize,
onDiagnostic: async (error) => {
if (error === undefined) return;
await bestEffortAudit(options, {
@@ -87,6 +87,8 @@ const RECEIPT_ATTEMPT_ID = '019f70c0-0000-7000-8000-000000000002';
const RECEIPT_TOKEN = 'A'.repeat(32);
const CLEANUP_RUN_ID = '019f70c0-0000-7000-8000-000000000011';
const CLEANUP_ATTEMPT_ID = '019f70c0-0000-7000-8000-000000000012';
const LOST_RETRY_RUN_ID = '019f70c0-0000-7000-8000-000000000021';
const LOST_RETRY_ATTEMPT_ID = '019f70c0-0000-7000-8000-000000000022';
const WORKFLOW_CANCELLATION_CREDENTIAL_ID = 'application-workflow-owner';
const WORKFLOW_CANCELLATION_PEPPER_KEY_ID = 'application-workflow-owner-v1';
const WORKFLOW_CANCELLATION_PEPPER_BYTES = Buffer.alloc(32, 141);
@@ -478,6 +480,58 @@ function insertActiveTargetRun(value, id, status = 'running') {
target.close();
}
function insertSafeLostTargetRun(value) {
const now = Date.now();
const target = new DatabaseSync(value.targetPath);
target.exec('BEGIN IMMEDIATE');
try {
target
.prepare(
`INSERT INTO "Runs" (
id, project_id, task_id, task_revision, trigger_type,
execution_origin, execution_owner, status, version, event_sequence,
priority, created_at_ms, queued_at_ms, started_at_ms,
error_code, error_summary
) VALUES (?, 'default', 'task-lost-retry', 'revision-lost-retry',
'manual', 'manual', 'runtime', 'lost', 1, 0, 0, ?, ?, ?,
'LOCAL_RECOVERY_EXECUTION_NOT_RUNNING', 'lost')`,
)
.run(LOST_RETRY_RUN_ID, now - 300, now - 250, now - 200);
target
.prepare(
`INSERT INTO "RunAttempts" (
id, run_id, attempt, status, executor_type, callback_sequence,
created_at_ms, started_at_ms, finished_at_ms,
error_code, error_summary
) VALUES (?, ?, 1, 'lost', 'local_process', 0, ?, ?, ?,
'LOCAL_RECOVERY_EXECUTION_NOT_RUNNING', 'lost')`,
)
.run(
LOST_RETRY_ATTEMPT_ID,
LOST_RETRY_RUN_ID,
now - 250,
now - 200,
now - 100,
);
target
.prepare(
`INSERT INTO "RunRetryPolicies" (
run_id, max_attempts, retry_on_lost, safety,
backoff_base_ms, backoff_max_ms, next_attempt_at_ms,
version, created_at_ms, updated_at_ms
) VALUES (?, 3, 1, 'idempotent', 86400000, 86400000,
NULL, 0, ?, ?)`,
)
.run(LOST_RETRY_RUN_ID, now - 300, now - 300);
target.exec('COMMIT');
} catch (error) {
if (target.isTransaction) target.exec('ROLLBACK');
throw error;
} finally {
target.close();
}
}
function insertManyActiveTargetRuns(value, count) {
const target = new DatabaseSync(value.targetPath);
const statement = target.prepare(
@@ -2179,6 +2233,39 @@ test('durable Run candidates block lifecycle activation', async (t) => {
assertSourceWritable(value, 2);
});
test('reconciles a safe lost Run before the first Local scheduler pass', async (t) => {
const value = await prepare(t, 'edge');
insertSafeLostTargetRun(value);
const audits = [];
const result = await bootstrapLocalApplication(
options(value, {
applicationAudit: (record) => audits.push(record),
}),
);
assert.equal(result.status, 'active');
assert.equal(
(await result.runs.findRunById(LOST_RETRY_RUN_ID)).status,
'retry_wait',
);
const reconciled = audits.find(
(record) => record.state === 'receipts_reconciled',
);
assert.deepEqual(reconciled.executionControl.lostRetry, {
scanned: 1,
scheduled: 1,
requeued: 0,
failed: 0,
raced: 0,
hasMore: false,
});
assert.equal(
(await result.runs.findLatestAttemptByRunId(LOST_RETRY_RUN_ID)).attempt,
1,
);
assert.equal(await result.stop(), 'stopped');
});
test('startup recovery candidate overflow fails closed at the hard page bound', async (t) => {
const value = await prepare(t, 'edge');
insertManyActiveTargetRuns(value, MAX_LOCAL_RUN_RECOVERY_ITEMS + 1);
@@ -1,6 +1,11 @@
import type { LocalCompletionReceiptJournalCursor } from '@qinglong/runtime-core/local-completion-receipt-journal';
import { assertLocalExecutionControlLimit } from '@qinglong/runtime-core/local-execution-control';
import type { RunAttemptLogRetentionSweepSummary } from '@qinglong/runtime-core/run-attempt-log-retention';
import {
RunLostRetryCoordinator,
type RunLostRetryPageResult,
type RunLostRetryRepository,
} from '@qinglong/runtime-core/run-lost-retry';
import type {
LocalCompletionReceiptCleanupScanner,
LocalCompletionReceiptCleanupSummary,
@@ -25,6 +30,8 @@ export interface LocalExecutionControlLifecycleOptions {
readonly artifactRetention?: Readonly<{
sweep(): Promise<RunAttemptLogRetentionSweepSummary>;
}>;
readonly lostRetry?: RunLostRetryRepository;
readonly lostRetryPageSize?: number;
readonly clock?: { now(): number };
readonly onDiagnostic?: (
error: unknown,
@@ -38,6 +45,7 @@ export interface LocalExecutionControlCycleSummary {
readonly control: LocalExecutionControlScanSummary;
readonly cleanup?: LocalCompletionReceiptCleanupSummary;
readonly artifactRetention?: RunAttemptLogRetentionSweepSummary;
readonly lostRetry?: Readonly<RunLostRetryPageResult>;
}
export interface LocalExecutionControlStopSummary {
@@ -49,6 +57,7 @@ export interface LocalExecutionControlStopSummary {
export class LocalExecutionControlLifecycle {
private readonly clock: { now(): number };
private readonly maxNotifications: number;
private readonly lostRetry: RunLostRetryCoordinator | undefined;
private timer: NodeJS.Timeout | undefined;
private inFlight: Promise<LocalExecutionControlCycleSummary> | undefined;
private stopPromise: Promise<LocalExecutionControlStopSummary> | undefined;
@@ -122,6 +131,18 @@ export class LocalExecutionControlLifecycle {
) {
throw new TypeError('Local Artifact retention lifecycle is invalid');
}
if (
(options.lostRetry === undefined) !==
(options.lostRetryPageSize === undefined)
) {
throw new TypeError('Local Run lost retry lifecycle is invalid');
}
this.lostRetry =
options.lostRetry && options.lostRetryPageSize !== undefined
? new RunLostRetryCoordinator(options.lostRetry, {
pageSize: options.lostRetryPageSize,
})
: undefined;
this.clock = options.clock ?? { now: Date.now };
}
@@ -238,6 +259,7 @@ export class LocalExecutionControlLifecycle {
: { cursor: this.controlCursor }),
});
this.controlCursor = control.truncated ? control.nextCursor : undefined;
const lostRetry = await this.lostRetry?.reconcile();
const now = this.clock.now();
if (!Number.isSafeInteger(now) || now < 0) {
throw new RangeError(
@@ -266,6 +288,7 @@ export class LocalExecutionControlLifecycle {
completions: completion.processed,
completionFailures: completion.failed,
control,
...(lostRetry === undefined ? {} : { lostRetry }),
...(cleanup === undefined ? {} : { cleanup }),
...(artifactRetention === undefined ? {} : { artifactRetention }),
});
@@ -0,0 +1,92 @@
'use strict';
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { LocalExecutionControlLifecycle } = require('../dist/control');
test('runs lost retry inside the existing control cadence before cleanup', async () => {
const calls = [];
const lifecycle = new LocalExecutionControlLifecycle(
{
async process() {
throw new Error('no completion notifications are expected');
},
},
{
async scan() {
calls.push('control');
return {
observedAtMs: 100,
scanned: 0,
terminal: 0,
deferred: 0,
failed: 0,
truncated: false,
};
},
async drain() {
return {
observedAtMs: 100,
scanned: 0,
terminal: 0,
deferred: 0,
failed: 0,
remaining: 0,
pages: 0,
truncated: false,
};
},
},
{
async scan() {
calls.push('cleanup');
return {
scanned: 0,
removed: 0,
missing: 0,
deferred: 0,
failed: 0,
truncated: false,
};
},
},
{
intervalMs: 5_000,
pageSize: 4,
cleanupIntervalMs: 60_000,
cleanupPageSize: 4,
stopTimeoutMs: 1_000,
maxDrainPages: 1,
lostRetry: {
async reconcilePage(command) {
calls.push('lost_retry');
assert.deepEqual(command, { limit: 2 });
return {
scanned: 1,
scheduled: 1,
requeued: 0,
failed: 0,
raced: 0,
hasMore: false,
};
},
},
lostRetryPageSize: 2,
clock: { now: () => 100 },
},
);
const first = lifecycle.runOnce(true);
const second = lifecycle.runOnce(true);
assert.equal(first, second);
assert.deepEqual((await first).lostRetry, {
scanned: 1,
scheduled: 1,
requeued: 0,
failed: 0,
raced: 0,
hasMore: false,
});
assert.deepEqual(calls, ['control', 'lost_retry', 'cleanup']);
});
+5
View File
@@ -250,6 +250,11 @@
"require": "./dist/run/runAttemptLogRetentionRepository.js",
"default": "./dist/run/runAttemptLogRetentionRepository.js"
},
"./run-lost-retry": {
"types": "./dist/run/runLostRetryRepository.d.ts",
"require": "./dist/run/runLostRetryRepository.js",
"default": "./dist/run/runLostRetryRepository.js"
},
"./trigger-administration": {
"types": "./dist/scheduling/triggerAdministration.d.ts",
"require": "./dist/scheduling/triggerAdministration.js",
@@ -47,6 +47,7 @@ export type LocalProfileStorageBootstrapResult =
readonly executionControl: LocalSqliteRuntimeDatabase['executionControl'];
readonly completionReceipts: LocalSqliteRuntimeDatabase['completionReceipts'];
readonly runAttemptLogRetention: LocalSqliteRuntimeDatabase['runAttemptLogRetention'];
readonly runLostRetry: LocalSqliteRuntimeDatabase['runLostRetry'];
readonly localSecrets: LocalSqliteRuntimeDatabase['localSecrets'];
readonly localSecretAdministration: LocalSqliteRuntimeDatabase['localSecretAdministration'];
readonly projectPolicy: LocalSqliteRuntimeDatabase['projectPolicy'];
@@ -140,6 +141,7 @@ export async function bootstrapLocalProfileStorage(
executionControl: database.executionControl,
completionReceipts: database.completionReceipts,
runAttemptLogRetention: database.runAttemptLogRetention,
runLostRetry: database.runLostRetry,
localSecrets: database.localSecrets,
localSecretAdministration: database.localSecretAdministration,
projectPolicy: database.projectPolicy,
@@ -0,0 +1,251 @@
import { randomUUID } from 'node:crypto';
import {
RunLostRetryUnavailableError,
buildRunLostRetryTransition,
normalizeRunLostRetryPageCommand,
normalizeRunLostRetryPageResult,
type RunLostRetryDisposition,
type RunLostRetryPageCommand,
type RunLostRetryPageResult,
type RunLostRetryRepository,
type RunLostRetryTransition,
} from '@qinglong/runtime-core/run-lost-retry';
import type {
RunRecord,
RunRepositoryTransaction,
RunRetryPolicyRecord,
} from '@qinglong/runtime-core/run-repository';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
import { LocalSqliteRunRepository } from './runRepository';
type Row = Record<string, unknown>;
interface Candidate {
readonly runId: string;
readonly attemptId: string;
}
function identifier(row: Row, key: string): string {
const value = row[key];
if (
typeof value !== 'string' ||
value.length < 1 ||
value.length > 128 ||
/[\u0000-\u001f\u007f]/.test(value)
) {
throw new TypeError(`Local SQLite lost retry ${key} is invalid`);
}
return value;
}
function generatedId(factory: () => string): string {
const value = factory();
if (
typeof value !== 'string' ||
value.length < 1 ||
value.length > 36 ||
/[\u0000-\u001f\u007f]/.test(value)
) {
throw new TypeError('Local SQLite lost retry generated ID is invalid');
}
return value;
}
/**
* Local Profiles share one SQLite authority. Candidate discovery and each
* BEGIN IMMEDIATE aggregate mutation are bounded, serialized operations; this
* repository owns no connection, timer, cursor, or background task.
*/
export class LocalSqliteRunLostRetryRepository
implements RunLostRetryRepository
{
constructor(
private readonly authority: LocalSqliteOperationAuthority,
private readonly runs: LocalSqliteRunRepository,
private readonly createId: () => string = randomUUID,
private readonly clock: { now(): number } = { now: Date.now },
) {
if (
!(authority instanceof LocalSqliteOperationAuthority) ||
!(runs instanceof LocalSqliteRunRepository) ||
typeof createId !== 'function' ||
typeof clock?.now !== 'function'
) {
throw new TypeError('Local SQLite lost retry repository is invalid');
}
}
async reconcilePage(
input: Readonly<RunLostRetryPageCommand>,
): Promise<Readonly<RunLostRetryPageResult>> {
const command = normalizeRunLostRetryPageCommand(input);
try {
const candidates = await this.listCandidates(command.limit + 1);
const page = candidates.slice(0, command.limit);
const counts: Record<RunLostRetryDisposition | 'raced', number> = {
scheduled: 0,
requeued: 0,
failed_disabled: 0,
failed_unsafe: 0,
failed_exhausted: 0,
raced: 0,
};
for (const candidate of page) {
counts[await this.reconcileCandidate(candidate)] += 1;
}
return normalizeRunLostRetryPageResult(
{
scanned: page.length,
scheduled: counts.scheduled,
requeued: counts.requeued,
failed:
counts.failed_disabled +
counts.failed_unsafe +
counts.failed_exhausted,
raced: counts.raced,
hasMore: candidates.length > command.limit,
},
command.limit,
);
} catch (error) {
if (error instanceof RunLostRetryUnavailableError) throw error;
throw new RunLostRetryUnavailableError({ cause: error });
}
}
private listCandidates(limit: number): Promise<readonly Candidate[]> {
return this.authority.enqueue(
async () => {
const observedAtMs = this.observedAtMs();
const rows = this.authority.client
.prepare(
`SELECT run."id" AS "runId", attempt."id" AS "attemptId"
FROM "Runs" AS run
JOIN "RunAttempts" AS attempt
ON attempt."run_id" = run."id"
AND attempt."attempt" = (
SELECT MAX(latest."attempt")
FROM "RunAttempts" AS latest
WHERE latest."run_id" = run."id"
)
LEFT JOIN "RunRetryPolicies" AS policy
ON policy."run_id" = run."id"
WHERE run."execution_owner" = 'runtime'
AND run."trigger_type" <> 'plugin_package_workflow'
AND run."cancel_requested_at_ms" IS NULL
AND attempt."status" = 'lost'
AND (
run."status" = 'lost'
OR (
run."status" = 'retry_wait'
AND policy."next_attempt_at_ms" IS NOT NULL
AND policy."next_attempt_at_ms" <= ?
)
)
ORDER BY
CASE WHEN run."status" = 'lost' THEN 0
ELSE policy."next_attempt_at_ms" END,
run."id"
LIMIT ?`,
)
.all(observedAtMs, limit) as Row[];
if (rows.length > limit) {
throw new TypeError('Local SQLite lost retry exceeded its page size');
}
return Object.freeze(
rows.map((row) =>
Object.freeze({
runId: identifier(row, 'runId'),
attemptId: identifier(row, 'attemptId'),
}),
),
);
},
() => new RunLostRetryUnavailableError(),
);
}
private async reconcileCandidate(
candidate: Readonly<Candidate>,
): Promise<RunLostRetryDisposition | 'raced'> {
return this.runs.transaction(async (transaction) => {
const run = await transaction.findRunById(candidate.runId);
const attempt = await transaction.findLatestAttemptByRunId(
candidate.runId,
);
const policy = await transaction.findRetryPolicyByRunId(candidate.runId);
if (
!run ||
!attempt ||
attempt.id !== candidate.attemptId ||
attempt.status !== 'lost' ||
run.executionOwner !== 'runtime' ||
run.triggerType === 'plugin_package_workflow' ||
run.cancelRequestedAtMs !== undefined ||
(run.status !== 'lost' && run.status !== 'retry_wait') ||
(run.status === 'retry_wait' &&
(policy?.nextAttemptAtMs === undefined ||
policy.nextAttemptAtMs > this.observedAtMs()))
) {
return 'raced';
}
const transition = buildRunLostRetryTransition({
run,
attempt,
policy,
observedAtMs: this.observedAtMs(),
runEventId: generatedId(this.createId),
...(run.status === 'retry_wait'
? {
attemptId: generatedId(this.createId),
attemptEventId: generatedId(this.createId),
}
: {}),
});
await this.persistTransition(transaction, run, policy, transition);
return transition.disposition;
});
}
private observedAtMs(): number {
const value = this.clock.now();
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError('Local SQLite lost retry clock is invalid');
}
return value;
}
private async persistTransition(
transaction: RunRepositoryTransaction,
currentRun: Readonly<RunRecord>,
currentPolicy: Readonly<RunRetryPolicyRecord> | null,
transition: Readonly<RunLostRetryTransition>,
): Promise<void> {
let version = currentRun.version;
for (const run of transition.runTransitions) {
if (!(await transaction.compareAndSetRun(run, version))) {
throw new TypeError('Local SQLite lost retry Run fence changed');
}
version = run.version;
}
if (transition.policy && transition.policy !== currentPolicy) {
if (
!currentPolicy ||
!(await transaction.compareAndSetRetryPolicy(
transition.policy,
currentPolicy.version,
))
) {
throw new TypeError('Local SQLite lost retry policy fence changed');
}
}
if (transition.attempt) {
await transaction.insertAttempt(transition.attempt);
}
for (const event of transition.events) {
await transaction.appendEvent(event);
}
}
}
@@ -63,6 +63,7 @@ import type { LocalSqlitePluginPackageWorkflowFrontierRepository } from '../plug
import type { LocalSqlitePluginPackageWorkflowTaskAttemptAdmissionRepository } from '../plugin-package/workflow/pluginPackageWorkflowTaskAttemptAdmissionRepository';
import type { LocalSqlitePluginPackageWorkflowCancellationConvergenceRepository } from '../plugin-package/workflow/pluginPackageWorkflowCancellationConvergenceRepository';
import { LocalSqliteRunAttemptLogRetentionRepository } from '../run/runAttemptLogRetentionRepository';
import { LocalSqliteRunLostRetryRepository } from '../run/runLostRetryRepository';
export interface LocalSqliteRuntimeDependencies {
readonly taskSpecSemanticRegistry?: TaskSpecSemanticRegistry;
@@ -99,6 +100,7 @@ export interface LocalSqliteRuntimeDatabase {
readonly executionControl: LocalExecutionControlSource;
readonly completionReceipts: LocalCompletionReceiptJournal;
readonly runAttemptLogRetention: LocalSqliteRunAttemptLogRetentionRepository;
readonly runLostRetry: LocalSqliteRunLostRetryRepository;
readonly localSecrets: LocalSecretEnvelopeRepository;
readonly localSecretAdministration: LocalSecretAdministrationRepository;
readonly projectPolicy: ProjectPolicyRepository;
@@ -183,6 +185,10 @@ export async function openLocalSqliteRuntimeDatabase(
const ownerPepper = new LocalSqliteOwnerPepperRepository(authority);
const runAttemptLogRetention =
new LocalSqliteRunAttemptLogRetentionRepository(authority);
const runLostRetry = new LocalSqliteRunLostRetryRepository(
authority,
runRepository,
);
let pluginPackageInstallsPromise:
| Promise<PluginPackageInstallRepository>
| undefined;
@@ -237,6 +243,7 @@ export async function openLocalSqliteRuntimeDatabase(
executionControl: runRuntimeCapabilities.executionControl,
completionReceipts: runRuntimeCapabilities.completionReceipts,
runAttemptLogRetention,
runLostRetry,
localSecrets: securityAuthority,
localSecretAdministration: securityAuthority,
projectPolicy,
@@ -0,0 +1,149 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const {
migrateLocalSqlitePath,
openLocalSqliteRuntimeDatabase,
} = require('../dist');
async function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-lost-retry-'));
const databasePath = path.join(directory, 'qinglong3.sqlite');
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
const runtime = await openLocalSqliteRuntimeDatabase({
databasePath,
profile: 'edge',
});
t.after(async () => {
await runtime.close();
fs.rmSync(directory, { recursive: true, force: true });
});
return runtime;
}
async function insertLostRun(runtime, suffix, retryPolicy) {
const runId = `run-lost-${suffix}`;
const attemptId = `attempt-lost-${suffix}`;
await runtime.runRepository.transaction(async (transaction) => {
await transaction.insertRun({
id: runId,
projectId: 'default',
taskId: `task-${suffix}`,
taskRevision: 'revision-1',
triggerType: 'manual',
executionOrigin: 'manual',
executionOwner: 'runtime',
status: 'lost',
version: 1,
eventSequence: 1,
priority: 0,
createdAtMs: 100,
queuedAtMs: 110,
startedAtMs: 120,
errorCode: 'LOCAL_RECOVERY_EXECUTION_NOT_RUNNING',
errorSummary: 'lost',
});
await transaction.insertAttempt({
id: attemptId,
runId,
attempt: 1,
status: 'lost',
executorType: 'local_process',
callbackSequence: 0,
createdAtMs: 110,
startedAtMs: 120,
finishedAtMs: 130,
errorCode: 'LOCAL_RECOVERY_EXECUTION_NOT_RUNNING',
errorSummary: 'lost',
});
if (retryPolicy) {
await transaction.insertRetryPolicy({
runId,
maxAttempts: 3,
retryOnLost: true,
safety: 'idempotent',
backoffBaseMs: 0,
backoffMaxMs: 0,
version: 0,
createdAtMs: 100,
updatedAtMs: 100,
});
}
});
return { runId, attemptId };
}
test('atomically schedules and requeues one safe Local lost Run exactly once', async (t) => {
const runtime = await fixture(t);
const ids = await insertLostRun(runtime, 'safe', true);
assert.deepEqual(await runtime.runLostRetry.reconcilePage({ limit: 1 }), {
scanned: 1,
scheduled: 1,
requeued: 0,
failed: 0,
raced: 0,
hasMore: false,
});
assert.equal(
(await runtime.runRepository.findRunById(ids.runId)).status,
'retry_wait',
);
assert.deepEqual(await runtime.runLostRetry.reconcilePage({ limit: 1 }), {
scanned: 1,
scheduled: 0,
requeued: 1,
failed: 0,
raced: 0,
hasMore: false,
});
const run = await runtime.runRepository.findRunById(ids.runId);
const attempt = await runtime.runRepository.findLatestAttemptByRunId(
ids.runId,
);
assert.equal(run.status, 'queued');
assert.equal(run.version, 4);
assert.equal(attempt.attempt, 2);
assert.equal(attempt.status, 'claimed');
assert.equal(attempt.executorType, 'local_process');
assert.deepEqual(
(await runtime.runRepository.listEvents(ids.runId)).map(
(event) => event.type,
),
['run.retry_wait', 'run.queued', 'attempt.claimed'],
);
assert.equal(
(await runtime.runLostRetry.reconcilePage({ limit: 1 })).scanned,
0,
);
});
test('fails closed without an admitted retry policy and preserves a bounded page', async (t) => {
const runtime = await fixture(t);
const first = await insertLostRun(runtime, 'disabled-a', false);
await insertLostRun(runtime, 'disabled-b', false);
const result = await runtime.runLostRetry.reconcilePage({ limit: 1 });
assert.deepEqual(result, {
scanned: 1,
scheduled: 0,
requeued: 0,
failed: 1,
raced: 0,
hasMore: true,
});
assert.equal(
(await runtime.runRepository.findRunById(first.runId)).errorCode,
'RUN_LOST_RETRY_DISABLED',
);
await assert.rejects(
runtime.runLostRetry.reconcilePage({ limit: 65 }),
/page size/,
);
});
+16
View File
@@ -218,6 +218,12 @@
"run": [
"dist/run/run.d.ts"
],
"run-lost-retry": [
"dist/run/clusterRunLostRetry.d.ts"
],
"cluster-run-lost-retry": [
"dist/run/clusterRunLostRetry.d.ts"
],
"project-run-list": [
"dist/run/projectRunList.d.ts"
],
@@ -265,6 +271,16 @@
"require": "./dist/run/runRepositoryContract.js",
"default": "./dist/run/runRepositoryContract.js"
},
"./run-lost-retry": {
"types": "./dist/run/clusterRunLostRetry.d.ts",
"require": "./dist/run/clusterRunLostRetry.js",
"default": "./dist/run/clusterRunLostRetry.js"
},
"./cluster-run-lost-retry": {
"types": "./dist/run/clusterRunLostRetry.d.ts",
"require": "./dist/run/clusterRunLostRetry.js",
"default": "./dist/run/clusterRunLostRetry.js"
},
"./project-run-list": {
"types": "./dist/run/projectRunList.d.ts",
"require": "./dist/run/projectRunList.js",
@@ -1,12 +1,5 @@
import type {
RunAttemptRecord,
RunEventRecord,
RunRecord,
} from './run';
import {
runRetryDelayMs,
type RunRetryPolicyRecord,
} from './runRetryPolicy';
import type { RunAttemptRecord, RunEventRecord, RunRecord } from './run';
import { runRetryDelayMs, type RunRetryPolicyRecord } from './runRetryPolicy';
export const MAX_CLUSTER_RUN_LOST_RETRY_PAGE_SIZE = 64;
@@ -62,7 +55,7 @@ export class ClusterRunLostRetryUnavailableError extends Error {
readonly code = 'CLUSTER_RUN_LOST_RETRY_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Cluster Run lost retry is unavailable', options);
super('Run lost retry is unavailable', options);
this.name = 'ClusterRunLostRetryUnavailableError';
}
}
@@ -71,7 +64,7 @@ export class InvalidClusterRunLostRetryTransitionError extends TypeError {
readonly code = 'CLUSTER_RUN_LOST_RETRY_INVALID';
constructor(message: string) {
super(`Cluster Run lost retry is invalid: ${message}`);
super(`Run lost retry is invalid: ${message}`);
this.name = 'InvalidClusterRunLostRetryTransitionError';
}
}
@@ -182,10 +175,7 @@ function transitionTime(
function finish(
input: Readonly<ClusterRunLostRetryTransitionInput>,
atMs: number,
disposition:
| 'failed_disabled'
| 'failed_unsafe'
| 'failed_exhausted',
disposition: 'failed_disabled' | 'failed_unsafe' | 'failed_exhausted',
error: Readonly<{ code: string; summary: string }>,
): Readonly<ClusterRunLostRetryTransition> {
const run = reserve(input.run, 'failed', atMs, error);
@@ -209,7 +199,7 @@ function finish(
run,
input.attempt.id,
'run.failed',
`cluster-lost-retry:${disposition}:${input.attempt.id}`,
`run-lost-retry:${disposition}:${input.attempt.id}`,
atMs,
{
from_status: input.run.status,
@@ -271,8 +261,11 @@ export function buildClusterRunLostRetryTransition(
if (run.status === 'lost') {
const nextAttemptAtMs =
Math.max(run.createdAtMs, attempt.createdAtMs, attempt.finishedAtMs ?? 0) +
runRetryDelayMs(policy, attempt.attempt);
Math.max(
run.createdAtMs,
attempt.createdAtMs,
attempt.finishedAtMs ?? 0,
) + runRetryDelayMs(policy, attempt.attempt);
if (!Number.isSafeInteger(nextAttemptAtMs)) {
return invalid('next Attempt time overflowed');
}
@@ -297,7 +290,7 @@ export function buildClusterRunLostRetryTransition(
nextRun,
attempt.id,
'run.retry_wait',
`cluster-lost-retry:scheduled:${attempt.id}`,
`run-lost-retry:scheduled:${attempt.id}`,
atMs,
{
from_status: 'lost',
@@ -313,10 +306,7 @@ export function buildClusterRunLostRetryTransition(
});
}
if (
policy.nextAttemptAtMs === undefined ||
policy.nextAttemptAtMs > atMs
) {
if (policy.nextAttemptAtMs === undefined || policy.nextAttemptAtMs > atMs) {
return invalid('retry_wait policy is not due');
}
const nextAttemptId = identifier('replacement Attempt ID', input.attemptId);
@@ -352,7 +342,7 @@ export function buildClusterRunLostRetryTransition(
queued,
replacement.id,
'run.queued',
`cluster-lost-retry:queued:${replacement.id}`,
`run-lost-retry:queued:${replacement.id}`,
atMs,
{
from_status: 'retry_wait',
@@ -365,7 +355,7 @@ export function buildClusterRunLostRetryTransition(
claimed,
replacement.id,
'attempt.claimed',
`cluster-lost-retry:attempt-claimed:${replacement.id}`,
`run-lost-retry:attempt-claimed:${replacement.id}`,
atMs,
{
attempt: replacement.attempt,
@@ -387,11 +377,11 @@ export function normalizeClusterRunLostRetryPageCommand(
Object.keys(value).length !== 1 ||
!Object.prototype.hasOwnProperty.call(value, 'limit')
) {
throw new TypeError('Cluster Run lost retry command is invalid');
throw new TypeError('Run lost retry command is invalid');
}
return Object.freeze({
limit: boundedInteger(
'Cluster Run lost retry page size',
'Run lost retry page size',
value.limit,
1,
MAX_CLUSTER_RUN_LOST_RETRY_PAGE_SIZE,
@@ -410,40 +400,40 @@ export function normalizeClusterRunLostRetryPageResult(
Object.keys(value).sort().join(',') !==
'failed,hasMore,raced,requeued,scanned,scheduled'
) {
throw new TypeError('Cluster Run lost retry result is invalid');
throw new TypeError('Run lost retry result is invalid');
}
const maximum = boundedInteger(
'Cluster Run lost retry result limit',
'Run lost retry result limit',
limit,
1,
MAX_CLUSTER_RUN_LOST_RETRY_PAGE_SIZE,
);
const scanned = boundedInteger(
'Cluster Run lost retry scanned count',
'Run lost retry scanned count',
value.scanned,
0,
maximum,
);
const scheduled = boundedInteger(
'Cluster Run lost retry scheduled count',
'Run lost retry scheduled count',
value.scheduled,
0,
scanned,
);
const requeued = boundedInteger(
'Cluster Run lost retry requeued count',
'Run lost retry requeued count',
value.requeued,
0,
scanned,
);
const failed = boundedInteger(
'Cluster Run lost retry failed count',
'Run lost retry failed count',
value.failed,
0,
scanned,
);
const raced = boundedInteger(
'Cluster Run lost retry raced count',
'Run lost retry raced count',
value.raced,
0,
scanned,
@@ -452,7 +442,7 @@ export function normalizeClusterRunLostRetryPageResult(
scheduled + requeued + failed + raced !== scanned ||
typeof value.hasMore !== 'boolean'
) {
throw new TypeError('Cluster Run lost retry result counts are invalid');
throw new TypeError('Run lost retry result counts are invalid');
}
return Object.freeze({
scanned,
@@ -481,10 +471,10 @@ export class ClusterRunLostRetryCoordinator {
typeof options !== 'object' ||
Array.isArray(options)
) {
throw new TypeError('Cluster Run lost retry coordinator is invalid');
throw new TypeError('Run lost retry coordinator is invalid');
}
this.pageSize = boundedInteger(
'Cluster Run lost retry page size',
'Run lost retry page size',
options.pageSize ?? 16,
1,
MAX_CLUSTER_RUN_LOST_RETRY_PAGE_SIZE,
@@ -494,9 +484,7 @@ export class ClusterRunLostRetryCoordinator {
reconcile(): Promise<Readonly<ClusterRunLostRetryPageResult>> {
if (this.inFlight) return this.inFlight;
const operation = Promise.resolve()
.then(() =>
this.repository.reconcilePage({ limit: this.pageSize }),
)
.then(() => this.repository.reconcilePage({ limit: this.pageSize }))
.then((result) =>
normalizeClusterRunLostRetryPageResult(result, this.pageSize),
)
@@ -511,3 +499,32 @@ export class ClusterRunLostRetryCoordinator {
return operation;
}
}
/**
* Profile-neutral names are the canonical API for new adapters. The Cluster
* names remain exported because early 3.0 incubator consumers used them before
* Local Profiles acquired the same reconciliation lifecycle.
*/
export const MAX_RUN_LOST_RETRY_PAGE_SIZE =
MAX_CLUSTER_RUN_LOST_RETRY_PAGE_SIZE;
export type RunLostRetryDisposition = ClusterRunLostRetryDisposition;
export type RunLostRetryPageCommand = ClusterRunLostRetryPageCommand;
export type RunLostRetryPageResult = ClusterRunLostRetryPageResult;
export type RunLostRetryRepository = ClusterRunLostRetryRepository;
export type RunLostRetryCoordinatorOptions =
ClusterRunLostRetryCoordinatorOptions;
export type RunLostRetryTransitionInput = ClusterRunLostRetryTransitionInput;
export type RunLostRetryTransition = ClusterRunLostRetryTransition;
export type RunLostRetryUnavailableError = ClusterRunLostRetryUnavailableError;
export type InvalidRunLostRetryTransitionError =
InvalidClusterRunLostRetryTransitionError;
export type RunLostRetryCoordinator = ClusterRunLostRetryCoordinator;
export const RunLostRetryUnavailableError = ClusterRunLostRetryUnavailableError;
export const InvalidRunLostRetryTransitionError =
InvalidClusterRunLostRetryTransitionError;
export const buildRunLostRetryTransition = buildClusterRunLostRetryTransition;
export const normalizeRunLostRetryPageCommand =
normalizeClusterRunLostRetryPageCommand;
export const normalizeRunLostRetryPageResult =
normalizeClusterRunLostRetryPageResult;
export const RunLostRetryCoordinator = ClusterRunLostRetryCoordinator;
@@ -0,0 +1,25 @@
'use strict';
const assert = require('node:assert/strict');
const { test } = require('node:test');
const generic = require('../dist/run/clusterRunLostRetry');
test('publishes profile-neutral lost retry names without breaking Cluster consumers', () => {
assert.equal(
generic.MAX_RUN_LOST_RETRY_PAGE_SIZE,
generic.MAX_CLUSTER_RUN_LOST_RETRY_PAGE_SIZE,
);
assert.equal(
generic.buildRunLostRetryTransition,
generic.buildClusterRunLostRetryTransition,
);
assert.equal(
generic.RunLostRetryCoordinator,
generic.ClusterRunLostRetryCoordinator,
);
assert.equal(
generic.RunLostRetryUnavailableError,
generic.ClusterRunLostRetryUnavailableError,
);
});