feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,297 @@
import {
LOCAL_COMPLETION_RECEIPT_JOURNAL_STATES,
type LocalCompletionReceiptJournalCandidate,
type LocalCompletionReceiptJournalCursor,
type LocalCompletionReceiptJournalPage,
type QuarantineLocalCompletionReceiptCommand,
type RegisterLocalCompletionReceiptCommand,
} from '@qinglong/runtime-core/local-completion-receipt-journal';
import {
RUN_ATTEMPT_STATUSES,
RunRepositoryConstraintError,
} from '@qinglong/runtime-core/run-repository';
import type { DatabaseSync } from 'node:sqlite';
type QueryRow = Record<string, unknown>;
function requiredString(row: QueryRow, property: string): string {
const value = row[property];
if (typeof value !== 'string' || value.length === 0) {
throw new RunRepositoryConstraintError(
`Local SQLite Run row has an invalid ${property}`,
);
}
return value;
}
function optionalString(row: QueryRow, property: string): string | undefined {
const value = row[property];
if (value === null || value === undefined) return undefined;
if (typeof value !== 'string') {
throw new RunRepositoryConstraintError(
`Local SQLite Run row has an invalid ${property}`,
);
}
return value;
}
function requiredInteger(row: QueryRow, property: string): number {
const value = row[property];
if (typeof value === 'number' && Number.isSafeInteger(value)) return value;
throw new RunRepositoryConstraintError(
`Local SQLite Run row has an invalid ${property}`,
);
}
function optionalInteger(row: QueryRow, property: string): number | undefined {
if (row[property] === null || row[property] === undefined) return undefined;
return requiredInteger(row, property);
}
function requiredEnum<T extends string>(
row: QueryRow,
property: string,
allowed: readonly T[],
): T {
const value = requiredString(row, property);
if (!allowed.includes(value as T)) {
throw new RunRepositoryConstraintError(
`Local SQLite Run row has an unsupported ${property}`,
);
}
return value as T;
}
function assignOptional<T extends object, K extends keyof T>(
record: T,
key: K,
value: T[K] | undefined,
): void {
if (value !== undefined) record[key] = value;
}
function singleRow(rows: QueryRow[]): QueryRow | null {
const [row] = rows;
if (!row) return null;
if (rows.length !== 1) {
throw new RunRepositoryConstraintError(
'Local SQLite Run repository returned duplicate identity rows',
);
}
return row;
}
/**
* Private synchronous storage collaborator. The owning Facade must call every
* method inside its single LocalSqliteOperationAuthority queue.
*/
export class LocalSqliteCompletionReceiptJournalStore {
constructor(private readonly client: DatabaseSync) {}
private queryRows(
sql: string,
values: readonly (string | number | bigint | Uint8Array | null)[] = [],
): QueryRow[] {
return this.client.prepare(sql).all(...values) as unknown as QueryRow[];
}
register(command: RegisterLocalCompletionReceiptCommand): void {
const attempt = this.client
.prepare(
`SELECT "run_id" AS "runId", "executor_type" AS "executorType"
FROM "RunAttempts" WHERE "id" = ? LIMIT 2`,
)
.all(command.attemptId) as unknown as QueryRow[];
const row = singleRow(attempt);
if (
!row ||
requiredString(row, 'runId') !== command.runId ||
requiredString(row, 'executorType') !== 'local_process'
) {
throw new RunRepositoryConstraintError(
'Completion receipt registration does not match a local Attempt',
);
}
const inserted = this.client
.prepare(
`INSERT INTO "LocalCompletionReceiptJournal"
(attempt_id, run_id, state, quarantine_ref, purge_after_ms,
registered_at_ms, updated_at_ms)
VALUES (?, ?, 'pending', NULL, NULL, ?, ?)
ON CONFLICT (attempt_id) DO NOTHING`,
)
.run(
command.attemptId,
command.runId,
command.registeredAtMs,
command.registeredAtMs,
);
if (inserted.changes === 1) return;
const current = singleRow(
this.queryRows(
`SELECT "run_id" AS "runId", "state" AS "state",
"registered_at_ms" AS "registeredAtMs"
FROM "LocalCompletionReceiptJournal"
WHERE "attempt_id" = ? LIMIT 2`,
[command.attemptId],
),
);
if (
current &&
requiredString(current, 'runId') === command.runId &&
requiredString(current, 'state') === 'pending' &&
requiredInteger(current, 'registeredAtMs') === command.registeredAtMs
) {
return;
}
throw new RunRepositoryConstraintError(
'Completion receipt registration conflicts with durable state',
);
}
markQuarantined(command: QuarantineLocalCompletionReceiptCommand): void {
const updated = this.client
.prepare(
`UPDATE "LocalCompletionReceiptJournal"
SET state = 'quarantined', quarantine_ref = ?, purge_after_ms = ?,
updated_at_ms = ?
WHERE attempt_id = ? AND state = 'pending'`,
)
.run(
command.quarantineRef,
command.purgeAfterMs,
command.updatedAtMs,
command.attemptId,
);
if (updated.changes === 1) return;
const current = singleRow(
this.queryRows(
`SELECT "state" AS "state", "quarantine_ref" AS "quarantineRef",
"purge_after_ms" AS "purgeAfterMs",
"updated_at_ms" AS "updatedAtMs"
FROM "LocalCompletionReceiptJournal"
WHERE "attempt_id" = ? LIMIT 2`,
[command.attemptId],
),
);
if (
current &&
requiredString(current, 'state') === 'quarantined' &&
requiredString(current, 'quarantineRef') === command.quarantineRef &&
requiredInteger(current, 'purgeAfterMs') === command.purgeAfterMs &&
requiredInteger(current, 'updatedAtMs') === command.updatedAtMs
) {
return;
}
throw new RunRepositoryConstraintError(
'Completion receipt quarantine transition conflicts',
);
}
resolve(attemptId: string): boolean {
return (
this.client
.prepare(
`DELETE FROM "LocalCompletionReceiptJournal"
WHERE "attempt_id" = ?`,
)
.run(attemptId).changes === 1
);
}
listCandidates(options: {
observedAtMs: number;
cursor?: LocalCompletionReceiptJournalCursor;
limit: number;
}): LocalCompletionReceiptJournalPage {
const rows = this.queryRows(
`SELECT
"journal"."attempt_id" AS "attemptId",
"journal"."run_id" AS "runId",
"journal"."state" AS "state",
"journal"."quarantine_ref" AS "quarantineRef",
"journal"."purge_after_ms" AS "purgeAfterMs",
"journal"."registered_at_ms" AS "registeredAtMs",
"journal"."updated_at_ms" AS "updatedAtMs",
"attempt"."run_id" AS "attemptRunId",
"attempt"."status" AS "attemptStatus",
"attempt"."executor_type" AS "executorType",
"attempt"."finished_at_ms" AS "finishedAtMs"
FROM "LocalCompletionReceiptJournal" AS "journal"
INNER JOIN "RunAttempts" AS "attempt"
ON "attempt"."id" = "journal"."attempt_id"
WHERE (
"journal"."state" = 'pending' OR
("journal"."state" = 'quarantined' AND
"journal"."purge_after_ms" <= ?)
)
AND (? IS NULL OR "journal"."updated_at_ms" > ? OR
("journal"."updated_at_ms" = ? AND "journal"."attempt_id" > ?))
ORDER BY "journal"."updated_at_ms", "journal"."attempt_id"
LIMIT ?`,
[
options.observedAtMs,
options.cursor?.updatedAtMs ?? null,
options.cursor?.updatedAtMs ?? null,
options.cursor?.updatedAtMs ?? null,
options.cursor?.attemptId ?? null,
options.limit + 1,
],
);
const truncated = rows.length > options.limit;
const candidates = rows.slice(0, options.limit).map((row) => {
const runId = requiredString(row, 'runId');
if (requiredString(row, 'attemptRunId') !== runId) {
throw new RunRepositoryConstraintError(
'Completion receipt journal Run identity is corrupt',
);
}
const candidate: LocalCompletionReceiptJournalCandidate = {
attemptId: requiredString(row, 'attemptId'),
runId,
state: requiredEnum(
row,
'state',
LOCAL_COMPLETION_RECEIPT_JOURNAL_STATES,
),
registeredAtMs: requiredInteger(row, 'registeredAtMs'),
updatedAtMs: requiredInteger(row, 'updatedAtMs'),
attemptStatus: requiredEnum(
row,
'attemptStatus',
RUN_ATTEMPT_STATUSES,
),
executorType: requiredString(row, 'executorType'),
};
assignOptional(
candidate,
'quarantineRef',
optionalString(row, 'quarantineRef'),
);
assignOptional(
candidate,
'purgeAfterMs',
optionalInteger(row, 'purgeAfterMs'),
);
assignOptional(
candidate,
'finishedAtMs',
optionalInteger(row, 'finishedAtMs'),
);
return Object.freeze(candidate);
});
const last = candidates.at(-1);
return Object.freeze({
candidates: Object.freeze(candidates),
truncated,
...(last === undefined
? {}
: {
nextCursor: Object.freeze({
updatedAtMs: last.updatedAtMs,
attemptId: last.attemptId,
}),
}),
});
}
}
@@ -0,0 +1,276 @@
// Run owns the Project-fenced durable cancellation intent mutation.
import {
InvalidRunCancellationError,
RunCancellationFenceRejectedError,
RunCancellationNotFoundError,
RunCancellationUnavailableError,
normalizeRunCancellationCommand,
normalizeRunCancellationResult,
type RunCancellationAllowedRole,
type RunCancellationCommand,
type RunCancellationRepository,
type RunCancellationResult,
} from '@qinglong/runtime-core/run-cancellation';
import { RUN_STATUSES, type RunStatus } from '@qinglong/runtime-core/run';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
import {
optionalInteger,
optionalString,
requiredInteger,
requiredString,
type QueryRow,
} from './runPersistence';
const ALLOWED_ROLES = new Set<RunCancellationAllowedRole>([
'owner',
'admin',
'operator',
]);
const TERMINAL = new Set<RunStatus>([
'succeeded',
'failed',
'cancelled',
'timed_out',
]);
const CANCEL_REASONS = new Set([
'user',
'policy',
'shutdown',
'reconcile',
'timeout',
]);
function timestamp(value: number): number {
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError('Local SQLite Run cancellation clock is invalid');
}
return value;
}
function status(row: QueryRow): RunStatus {
const value = requiredString(row, 'runStatus') as RunStatus;
if (!RUN_STATUSES.includes(value)) {
throw new TypeError('Local SQLite Run cancellation status is invalid');
}
return value;
}
function result(
disposition: RunCancellationResult['status'],
command: Readonly<RunCancellationCommand>,
row: QueryRow,
): Readonly<RunCancellationResult> {
const cancelRequestedAtMs = optionalInteger(row, 'cancelRequestedAtMs');
const cancelReason = optionalString(row, 'cancelReason');
if (
(cancelRequestedAtMs === undefined) !== (cancelReason === undefined) ||
(cancelReason !== undefined && !CANCEL_REASONS.has(cancelReason))
) {
throw new TypeError('Local SQLite Run cancellation intent is invalid');
}
return normalizeRunCancellationResult({
status: disposition,
projectId: command.projectId,
runId: command.runId,
runStatus: status(row),
runVersion: requiredInteger(row, 'runVersion'),
eventSequence: requiredInteger(row, 'eventSequence'),
...(cancelRequestedAtMs === undefined
? {}
: {
cancelRequestedAtMs,
cancelReason: cancelReason as NonNullable<
RunCancellationResult['cancelReason']
>,
}),
});
}
function rollback(authority: LocalSqliteOperationAuthority): void {
if (!authority.client.isTransaction) return;
try {
authority.client.exec('ROLLBACK');
} catch {
// Preserve the primary transaction failure; close owns broken handles.
}
}
export class LocalSqliteRunCancellationRepository
implements RunCancellationRepository
{
constructor(
private readonly authority: LocalSqliteOperationAuthority,
private readonly now: () => number = Date.now,
) {
if (
!(authority instanceof LocalSqliteOperationAuthority) ||
typeof now !== 'function'
) {
throw new TypeError(
'Local SQLite Run cancellation dependencies are invalid',
);
}
}
requestUserCancellation(
value: Readonly<RunCancellationCommand>,
): Promise<Readonly<RunCancellationResult>> {
let command: Readonly<RunCancellationCommand>;
try {
command = normalizeRunCancellationCommand(value);
} catch (error) {
return Promise.reject(error);
}
return this.authority.enqueue(
async () => {
const client = this.authority.client;
try {
client.exec('BEGIN IMMEDIATE');
const project = client
.prepare(
`SELECT "status" AS "projectStatus", "version" AS "projectVersion"
FROM "QingLong3Projects" WHERE "id" = ?`,
)
.get(command.projectId) as QueryRow | undefined;
if (!project) throw new RunCancellationNotFoundError();
const binding = client
.prepare(
`SELECT "version" AS "bindingVersion", "state" AS "bindingState",
"role" AS "bindingRole"
FROM "QingLong3ProjectRoleBindings"
WHERE "project_id" = ? AND "subject_type" = ?
AND "subject_id" = ?
ORDER BY "version" DESC LIMIT 1`,
)
.get(
command.projectId,
command.subject.type,
command.subject.id,
) as QueryRow | undefined;
if (
requiredString(project, 'projectStatus') !== 'active' ||
requiredInteger(project, 'projectVersion') !==
command.policyFence.projectVersion ||
!binding ||
requiredInteger(binding, 'bindingVersion') !==
command.policyFence.bindingVersion ||
requiredString(binding, 'bindingState') !== 'active' ||
!ALLOWED_ROLES.has(
requiredString(
binding,
'bindingRole',
) as RunCancellationAllowedRole,
)
) {
throw new RunCancellationFenceRejectedError(
'authorization_changed',
);
}
const run = client
.prepare(
`SELECT "project_id" AS "projectId", "status" AS "runStatus",
"version" AS "runVersion",
"event_sequence" AS "eventSequence",
"cancel_requested_at_ms" AS "cancelRequestedAtMs",
"cancel_reason" AS "cancelReason"
FROM "Runs" WHERE "id" = ?`,
)
.get(command.runId) as QueryRow | undefined;
if (
!run ||
requiredString(run, 'projectId') !== command.projectId
) {
throw new RunCancellationNotFoundError();
}
const runStatus = status(run);
if (TERMINAL.has(runStatus)) {
const outcome = result('already_terminal', command, run);
client.exec('COMMIT');
return outcome;
}
if (optionalInteger(run, 'cancelRequestedAtMs') !== undefined) {
const outcome = result('already_requested', command, run);
client.exec('COMMIT');
return outcome;
}
if (optionalString(run, 'cancelReason') !== undefined) {
throw new RunCancellationFenceRejectedError('state_mismatch');
}
const runVersion = requiredInteger(run, 'runVersion');
const eventSequence = requiredInteger(run, 'eventSequence');
if (runVersion >= 2_147_483_647 || eventSequence >= 2_147_483_647) {
throw new RunCancellationFenceRejectedError('state_mismatch');
}
const observedAtMs = timestamp(this.now());
const updated = client
.prepare(
`UPDATE "Runs"
SET "cancel_requested_at_ms" = ?, "cancel_reason" = 'user',
"version" = ?, "event_sequence" = ?
WHERE "id" = ? AND "project_id" = ? AND "version" = ?
AND "cancel_requested_at_ms" IS NULL
RETURNING "project_id" AS "projectId",
"status" AS "runStatus", "version" AS "runVersion",
"event_sequence" AS "eventSequence",
"cancel_requested_at_ms" AS "cancelRequestedAtMs",
"cancel_reason" AS "cancelReason"`,
)
.get(
observedAtMs,
runVersion + 1,
eventSequence + 1,
command.runId,
command.projectId,
runVersion,
) as QueryRow | undefined;
if (!updated) {
throw new RunCancellationFenceRejectedError('state_mismatch');
}
client
.prepare(
`INSERT INTO "RunEvents" (
"id", "run_id", "sequence", "type", "dedupe_key",
"actor_type", "actor_id", "attempt_id", "step_run_id",
"payload", "created_at_ms"
) VALUES (?, ?, ?, 'run.cancel_requested', ?, ?, ?,
NULL, NULL, ?, ?)`,
)
.run(
command.eventId,
command.runId,
eventSequence + 1,
`user-cancel:${command.mutationId}`,
command.subject.type,
command.subject.id,
JSON.stringify({
reason: 'user',
mutation_id: command.mutationId,
policy_fence: {
project_version: command.policyFence.projectVersion,
binding_version: command.policyFence.bindingVersion,
},
}),
observedAtMs,
);
const outcome = result('accepted', command, updated);
client.exec('COMMIT');
return outcome;
} catch (error) {
rollback(this.authority);
if (
error instanceof InvalidRunCancellationError ||
error instanceof RunCancellationNotFoundError ||
error instanceof RunCancellationFenceRejectedError
) {
throw error;
}
throw new RunCancellationUnavailableError({ cause: error });
}
},
() => new RunCancellationUnavailableError(),
);
}
}
@@ -0,0 +1,529 @@
import type {
RunAttemptRecord,
RunEventRecord,
RunRecord,
RunRetryPolicyRecord,
} from '@qinglong/runtime-core/run-repository';
import {
EXECUTION_ORIGINS,
MAX_RUN_EVENT_PAYLOAD_BYTES,
RUN_ATTEMPT_STATUSES,
RUN_CANCELLATION_REASONS,
RUN_EVENT_ACTOR_TYPES,
RUN_RETRY_SAFETIES,
RUN_STATUSES,
RunEventPayloadTooLargeError,
RunRepositoryBusyError,
RunRepositoryConstraintError,
RunRepositoryError,
RunRepositoryOperationError,
assertRunRetryPolicyRecord,
} from '@qinglong/runtime-core/run-repository';
import type { DatabaseSync } from 'node:sqlite';
import {
createSqlitePersistencePrimitives,
isSqliteDriverError,
sqliteDriverErrorCode,
sqliteDriverErrorMessage,
sqliteDriverErrorNumber,
type SqliteQueryRow,
} from '../storage/sqlitePersistence';
interface ColumnDefinition {
readonly column: string;
readonly property: string;
}
export type QueryRow = SqliteQueryRow;
const RUN_SQLITE_PERSISTENCE = createSqlitePersistencePrimitives({
invalidRowValue: (property) =>
new RunRepositoryConstraintError(
`Local SQLite Run row has an invalid ${property}`,
),
invalidJson: (property) =>
new RunRepositoryConstraintError(
`Local SQLite Run row has invalid ${property} JSON`,
),
unsupportedRowValue: (property) =>
new RunRepositoryConstraintError(
`Local SQLite Run row has an unsupported ${property}`,
),
duplicateIdentityRows: () =>
new RunRepositoryConstraintError(
'Local SQLite Run repository returned duplicate identity rows',
),
mapDriverError: mapSqliteError,
});
export const RUN_COLUMNS: readonly ColumnDefinition[] = Object.freeze([
{ column: 'id', property: 'id' },
{ column: 'project_id', property: 'projectId' },
{ column: 'task_id', property: 'taskId' },
{ column: 'task_revision', property: 'taskRevision' },
{ column: 'task_name', property: 'taskName' },
{ column: 'task_snapshot_ref', property: 'taskSnapshotRef' },
{ column: 'legacy_cron_id', property: 'legacyCronId' },
{ column: 'parent_run_id', property: 'parentRunId' },
{ column: 'retry_of_run_id', property: 'retryOfRunId' },
{ column: 'trigger_id', property: 'triggerId' },
{ column: 'trigger_type', property: 'triggerType' },
{ column: 'execution_origin', property: 'executionOrigin' },
{ column: 'execution_owner', property: 'executionOwner' },
{ column: 'triggered_by', property: 'triggeredBy' },
{ column: 'request_id', property: 'requestId' },
{ column: 'scheduled_for_ms', property: 'scheduledForMs' },
{ column: 'status', property: 'status' },
{ column: 'version', property: 'version' },
{ column: 'event_sequence', property: 'eventSequence' },
{ column: 'priority', property: 'priority' },
{ column: 'idempotency_key', property: 'idempotencyKey' },
{ column: 'input_ref', property: 'inputRef' },
{ column: 'output_ref', property: 'outputRef' },
{ column: 'created_at_ms', property: 'createdAtMs' },
{ column: 'queued_at_ms', property: 'queuedAtMs' },
{ column: 'started_at_ms', property: 'startedAtMs' },
{ column: 'finished_at_ms', property: 'finishedAtMs' },
{ column: 'cancel_requested_at_ms', property: 'cancelRequestedAtMs' },
{ column: 'cancel_reason', property: 'cancelReason' },
{ column: 'error_code', property: 'errorCode' },
{ column: 'error_summary', property: 'errorSummary' },
]);
export const ATTEMPT_COLUMNS: readonly ColumnDefinition[] = Object.freeze([
{ column: 'id', property: 'id' },
{ column: 'run_id', property: 'runId' },
{ column: 'step_run_id', property: 'stepRunId' },
{ column: 'attempt', property: 'attempt' },
{ column: 'status', property: 'status' },
{ column: 'executor_type', property: 'executorType' },
{ column: 'worker_id', property: 'workerId' },
{ column: 'worker_session_id', property: 'workerSessionId' },
{ column: 'worker_generation', property: 'workerGeneration' },
{ column: 'executor_handle', property: 'executorHandle' },
{ column: 'pid', property: 'pid' },
{ column: 'log_artifact_id', property: 'logArtifactId' },
{ column: 'lease_token', property: 'leaseToken' },
{ column: 'lease_token_digest', property: 'leaseTokenDigest' },
{ column: 'lease_generation', property: 'leaseGeneration' },
{ column: 'lease_version', property: 'leaseVersion' },
{ column: 'lease_expires_at_ms', property: 'leaseExpiresAtMs' },
{ column: 'offer_id', property: 'offerId' },
{ column: 'deadline_at_ms', property: 'deadlineAtMs' },
{ column: 'callback_token_hash', property: 'callbackTokenHash' },
{ column: 'callback_sequence', property: 'callbackSequence' },
{ column: 'created_at_ms', property: 'createdAtMs' },
{ column: 'started_at_ms', property: 'startedAtMs' },
{ column: 'finished_at_ms', property: 'finishedAtMs' },
{ column: 'exit_code', property: 'exitCode' },
{ column: 'error_code', property: 'errorCode' },
{ column: 'error_summary', property: 'errorSummary' },
]);
export const EVENT_COLUMNS: readonly ColumnDefinition[] = Object.freeze([
{ column: 'id', property: 'id' },
{ column: 'run_id', property: 'runId' },
{ column: 'sequence', property: 'sequence' },
{ column: 'type', property: 'type' },
{ column: 'dedupe_key', property: 'dedupeKey' },
{ column: 'actor_type', property: 'actorType' },
{ column: 'actor_id', property: 'actorId' },
{ column: 'attempt_id', property: 'attemptId' },
{ column: 'step_run_id', property: 'stepRunId' },
{ column: 'payload', property: 'payload' },
{ column: 'created_at_ms', property: 'createdAtMs' },
]);
export const RETRY_POLICY_COLUMNS: readonly ColumnDefinition[] = Object.freeze([
{ column: 'run_id', property: 'runId' },
{ column: 'max_attempts', property: 'maxAttempts' },
{ column: 'retry_on_lost', property: 'retryOnLost' },
{ column: 'safety', property: 'safety' },
{ column: 'backoff_base_ms', property: 'backoffBaseMs' },
{ column: 'backoff_max_ms', property: 'backoffMaxMs' },
{ column: 'next_attempt_at_ms', property: 'nextAttemptAtMs' },
{ column: 'version', property: 'version' },
{ column: 'created_at_ms', property: 'createdAtMs' },
{ column: 'updated_at_ms', property: 'updatedAtMs' },
]);
function quoted(identifier: string): string {
return `"${identifier}"`;
}
function selectColumns(columns: readonly ColumnDefinition[]): string {
return columns
.map(({ column, property }) => `${quoted(column)} AS ${quoted(property)}`)
.join(', ');
}
function insertSql(
tableName: string,
columns: readonly ColumnDefinition[],
): string {
return `INSERT INTO ${quoted(tableName)} (${columns
.map(({ column }) => quoted(column))
.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
}
function updateSql(
tableName: string,
columns: readonly ColumnDefinition[],
predicate: string,
): string {
return `UPDATE ${quoted(tableName)} SET ${columns
.slice(1)
.map(({ column }) => `${quoted(column)} = ?`)
.join(', ')} WHERE ${predicate}`;
}
export function writeValues(
record: object,
columns: readonly ColumnDefinition[],
): (string | number | bigint | Uint8Array | null)[] {
const values = record as Record<string, unknown>;
return columns.map(({ property }) => {
const value = values[property];
if (value === undefined || value === null) return null;
if (typeof value === 'boolean') return value ? 1 : 0;
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'bigint' ||
value instanceof Uint8Array
) {
return value;
}
throw new RunRepositoryConstraintError(
`Local SQLite write value ${property} is invalid`,
);
});
}
export function requiredString(row: QueryRow, property: string): string {
return RUN_SQLITE_PERSISTENCE.requiredString(row, property);
}
export function optionalString(
row: QueryRow,
property: string,
): string | undefined {
return RUN_SQLITE_PERSISTENCE.optionalString(row, property);
}
export function requiredInteger(row: QueryRow, property: string): number {
return RUN_SQLITE_PERSISTENCE.requiredInteger(row, property);
}
export function requiredBlob(row: QueryRow, property: string): Buffer {
try {
return RUN_SQLITE_PERSISTENCE.requiredBlob(row, property);
} catch (error) {
if (error instanceof RunRepositoryConstraintError) {
throw new RunRepositoryConstraintError(
`Local SQLite row has an invalid ${property}`,
);
}
throw error;
}
}
export function optionalInteger(
row: QueryRow,
property: string,
): number | undefined {
return RUN_SQLITE_PERSISTENCE.optionalInteger(row, property);
}
export function requiredBoolean(row: QueryRow, property: string): boolean {
return RUN_SQLITE_PERSISTENCE.requiredBoolean(row, property);
}
export function requiredJson(row: QueryRow, property: string): unknown {
return RUN_SQLITE_PERSISTENCE.requiredJson(row, property);
}
export function requiredEnum<T extends string>(
row: QueryRow,
property: string,
allowed: readonly T[],
): T {
return RUN_SQLITE_PERSISTENCE.requiredEnum(row, property, allowed);
}
function assignOptional<T extends object, K extends keyof T>(
record: T,
key: K,
value: T[K] | undefined,
): void {
if (value !== undefined) record[key] = value;
}
export function rowToRun(row: QueryRow): RunRecord {
const run: RunRecord = {
id: requiredString(row, 'id'),
projectId: requiredString(row, 'projectId'),
taskId: requiredString(row, 'taskId'),
taskRevision: requiredString(row, 'taskRevision'),
triggerType: requiredString(row, 'triggerType'),
executionOrigin: requiredEnum(row, 'executionOrigin', EXECUTION_ORIGINS),
executionOwner: requiredEnum(row, 'executionOwner', [
'legacy',
'runtime',
] as const),
status: requiredEnum(row, 'status', RUN_STATUSES),
version: requiredInteger(row, 'version'),
eventSequence: requiredInteger(row, 'eventSequence'),
priority: requiredInteger(row, 'priority'),
createdAtMs: requiredInteger(row, 'createdAtMs'),
};
assignOptional(run, 'taskName', optionalString(row, 'taskName'));
assignOptional(
run,
'taskSnapshotRef',
optionalString(row, 'taskSnapshotRef'),
);
assignOptional(run, 'legacyCronId', optionalInteger(row, 'legacyCronId'));
assignOptional(run, 'parentRunId', optionalString(row, 'parentRunId'));
assignOptional(run, 'retryOfRunId', optionalString(row, 'retryOfRunId'));
assignOptional(run, 'triggerId', optionalString(row, 'triggerId'));
assignOptional(run, 'triggeredBy', optionalString(row, 'triggeredBy'));
assignOptional(run, 'requestId', optionalString(row, 'requestId'));
assignOptional(run, 'scheduledForMs', optionalInteger(row, 'scheduledForMs'));
assignOptional(run, 'idempotencyKey', optionalString(row, 'idempotencyKey'));
assignOptional(run, 'inputRef', optionalString(row, 'inputRef'));
assignOptional(run, 'outputRef', optionalString(row, 'outputRef'));
assignOptional(run, 'queuedAtMs', optionalInteger(row, 'queuedAtMs'));
assignOptional(run, 'startedAtMs', optionalInteger(row, 'startedAtMs'));
assignOptional(run, 'finishedAtMs', optionalInteger(row, 'finishedAtMs'));
assignOptional(
run,
'cancelRequestedAtMs',
optionalInteger(row, 'cancelRequestedAtMs'),
);
if (row.cancelReason !== null && row.cancelReason !== undefined) {
run.cancelReason = requiredEnum(
row,
'cancelReason',
RUN_CANCELLATION_REASONS,
);
}
assignOptional(run, 'errorCode', optionalString(row, 'errorCode'));
assignOptional(run, 'errorSummary', optionalString(row, 'errorSummary'));
return run;
}
export function rowToAttempt(row: QueryRow): RunAttemptRecord {
const attempt: RunAttemptRecord = {
id: requiredString(row, 'id'),
runId: requiredString(row, 'runId'),
attempt: requiredInteger(row, 'attempt'),
status: requiredEnum(row, 'status', RUN_ATTEMPT_STATUSES),
executorType: requiredString(row, 'executorType'),
callbackSequence: requiredInteger(row, 'callbackSequence'),
createdAtMs: requiredInteger(row, 'createdAtMs'),
};
assignOptional(attempt, 'stepRunId', optionalString(row, 'stepRunId'));
assignOptional(attempt, 'workerId', optionalString(row, 'workerId'));
assignOptional(
attempt,
'workerSessionId',
optionalString(row, 'workerSessionId'),
);
assignOptional(
attempt,
'workerGeneration',
optionalInteger(row, 'workerGeneration'),
);
assignOptional(
attempt,
'executorHandle',
optionalString(row, 'executorHandle'),
);
assignOptional(attempt, 'pid', optionalInteger(row, 'pid'));
assignOptional(
attempt,
'logArtifactId',
optionalString(row, 'logArtifactId'),
);
assignOptional(attempt, 'leaseToken', optionalString(row, 'leaseToken'));
assignOptional(
attempt,
'leaseTokenDigest',
optionalString(row, 'leaseTokenDigest'),
);
assignOptional(
attempt,
'leaseGeneration',
optionalInteger(row, 'leaseGeneration'),
);
assignOptional(attempt, 'leaseVersion', optionalInteger(row, 'leaseVersion'));
assignOptional(
attempt,
'leaseExpiresAtMs',
optionalInteger(row, 'leaseExpiresAtMs'),
);
assignOptional(attempt, 'offerId', optionalString(row, 'offerId'));
assignOptional(attempt, 'deadlineAtMs', optionalInteger(row, 'deadlineAtMs'));
assignOptional(
attempt,
'callbackTokenHash',
optionalString(row, 'callbackTokenHash'),
);
assignOptional(attempt, 'startedAtMs', optionalInteger(row, 'startedAtMs'));
assignOptional(attempt, 'finishedAtMs', optionalInteger(row, 'finishedAtMs'));
assignOptional(attempt, 'exitCode', optionalInteger(row, 'exitCode'));
assignOptional(attempt, 'errorCode', optionalString(row, 'errorCode'));
assignOptional(attempt, 'errorSummary', optionalString(row, 'errorSummary'));
return attempt;
}
function normalizePayload(payload: unknown): Readonly<Record<string, unknown>> {
let value = payload;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (error) {
throw new RunRepositoryConstraintError(
'Local SQLite RunEvent payload is invalid JSON',
error,
);
}
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new RunRepositoryConstraintError(
'Local SQLite RunEvent payload is not a JSON object',
);
}
return value as Readonly<Record<string, unknown>>;
}
export function rowToEvent(row: QueryRow): RunEventRecord {
const event: RunEventRecord = {
id: requiredString(row, 'id'),
runId: requiredString(row, 'runId'),
sequence: requiredInteger(row, 'sequence'),
type: requiredString(row, 'type'),
actorType: requiredEnum(row, 'actorType', RUN_EVENT_ACTOR_TYPES),
payload: normalizePayload(row.payload),
createdAtMs: requiredInteger(row, 'createdAtMs'),
};
assignOptional(event, 'dedupeKey', optionalString(row, 'dedupeKey'));
assignOptional(event, 'actorId', optionalString(row, 'actorId'));
assignOptional(event, 'attemptId', optionalString(row, 'attemptId'));
assignOptional(event, 'stepRunId', optionalString(row, 'stepRunId'));
return event;
}
export function rowToRetryPolicy(row: QueryRow): RunRetryPolicyRecord {
const policy: RunRetryPolicyRecord = {
runId: requiredString(row, 'runId'),
maxAttempts: requiredInteger(row, 'maxAttempts'),
retryOnLost: requiredBoolean(row, 'retryOnLost'),
safety: requiredEnum(row, 'safety', RUN_RETRY_SAFETIES),
backoffBaseMs: requiredInteger(row, 'backoffBaseMs'),
backoffMaxMs: requiredInteger(row, 'backoffMaxMs'),
version: requiredInteger(row, 'version'),
createdAtMs: requiredInteger(row, 'createdAtMs'),
updatedAtMs: requiredInteger(row, 'updatedAtMs'),
};
assignOptional(
policy,
'nextAttemptAtMs',
optionalInteger(row, 'nextAttemptAtMs'),
);
assertRunRetryPolicyRecord(policy);
return policy;
}
function sqliteErrorCode(error: unknown): string | undefined {
return sqliteDriverErrorCode(error);
}
function sqliteErrorNumber(error: unknown): number | undefined {
return sqliteDriverErrorNumber(error);
}
export function sqliteErrorMessage(error: unknown): string {
return sqliteDriverErrorMessage(error);
}
export function isSqliteError(error: unknown): boolean {
return isSqliteDriverError(error);
}
export function mapSqliteError(error: unknown): RunRepositoryError {
if (error instanceof RunRepositoryError) return error;
const baseCode = (sqliteErrorNumber(error) ?? 0) & 0xff;
if (baseCode === 5 || baseCode === 6) {
return new RunRepositoryBusyError(error);
}
if (baseCode === 19 || sqliteErrorCode(error) === 'ERR_SQLITE_CONSTRAINT') {
return new RunRepositoryConstraintError(
'Local SQLite Run repository constraint violation',
error,
);
}
return new RunRepositoryOperationError(error);
}
export function assertEventPayloadSize(event: RunEventRecord): string {
let serialized: string | undefined;
try {
serialized = JSON.stringify(event.payload);
} catch (error) {
throw new RunRepositoryConstraintError(
'RunEvent payload is not JSON serializable',
error,
);
}
if (serialized === undefined) {
throw new RunRepositoryConstraintError(
'RunEvent payload is not JSON serializable',
);
}
const bytes = Buffer.byteLength(serialized, 'utf8');
if (bytes > MAX_RUN_EVENT_PAYLOAD_BYTES) {
throw new RunEventPayloadTooLargeError(bytes, MAX_RUN_EVENT_PAYLOAD_BYTES);
}
return serialized;
}
export function queryRows(
client: DatabaseSync,
sql: string,
values: readonly (string | number | bigint | Uint8Array | null)[] = [],
): QueryRow[] {
return RUN_SQLITE_PERSISTENCE.queryRows(client, sql, values);
}
export function singleRow(rows: QueryRow[]): QueryRow | null {
return RUN_SQLITE_PERSISTENCE.singleRow(rows);
}
export const RUN_SELECT = selectColumns(RUN_COLUMNS);
export const ATTEMPT_SELECT = selectColumns(ATTEMPT_COLUMNS);
export const EVENT_SELECT = selectColumns(EVENT_COLUMNS);
export const RETRY_POLICY_SELECT = selectColumns(RETRY_POLICY_COLUMNS);
export const INSERT_RUN_SQL = insertSql('Runs', RUN_COLUMNS);
export const INSERT_ATTEMPT_SQL = insertSql('RunAttempts', ATTEMPT_COLUMNS);
export const INSERT_EVENT_SQL = insertSql('RunEvents', EVENT_COLUMNS);
export const INSERT_RETRY_POLICY_SQL = insertSql(
'RunRetryPolicies',
RETRY_POLICY_COLUMNS,
);
export const UPDATE_RUN_SQL = updateSql(
'Runs',
RUN_COLUMNS,
'"id" = ? AND "version" = ?',
);
export const UPDATE_ATTEMPT_SQL = updateSql(
'RunAttempts',
ATTEMPT_COLUMNS,
'"id" = ? AND "status" = ? AND "callback_sequence" = ?',
);
export const UPDATE_RETRY_POLICY_SQL = updateSql(
'RunRetryPolicies',
RETRY_POLICY_COLUMNS,
'"run_id" = ? AND "version" = ?',
);
@@ -0,0 +1,700 @@
import type {
RunAttemptRecord,
RunEventRecord,
RunRecord,
RunRepositoryReader,
RunRetryPolicyRecord,
} from '@qinglong/runtime-core/run-repository';
import {
normalizeProjectRunListQuery,
type ProjectRunListQuery,
type ProjectRunListReader,
} from '@qinglong/runtime-core/project-run-list';
import {
MAX_LOCAL_RUN_STARTUP_RECOVERY_CANDIDATES,
type LocalRunStartupRecoveryCandidate,
type LocalRunStartupRecoveryPage,
} from '@qinglong/runtime-core/local-startup-recovery';
import {
LOCAL_PROCESS_EXECUTOR_TYPE,
assertLocalDispatchContextRef,
assertLocalDispatchPageSize,
normalizeLocalDispatchCandidate,
normalizeLocalExecutionContextRecipe,
normalizeLocalTaskExecutionRevision,
type LocalDispatchCandidateCursor,
type LocalDispatchCandidatePage,
type LocalExecutionContextRecipe,
type LocalTaskExecutionRevision,
} from '@qinglong/runtime-core/local-dispatch';
import {
assertLocalExecutionControlLimit,
normalizeLocalActiveExecutionCandidate,
normalizeLocalActiveExecutionCursor,
normalizeLocalExecutionControlCandidate,
normalizeLocalExecutionControlCursor,
type LocalActiveExecutionCursor,
type LocalActiveExecutionPage,
type LocalExecutionControlCursor,
type LocalExecutionControlPage,
} from '@qinglong/runtime-core/local-execution-control';
import {
MAX_CANCELLATION_RECOVERY_PAGE_SIZE,
MAX_RUN_EVENT_PAGE_SIZE,
RUN_CANCELLATION_REASONS,
} from '@qinglong/runtime-core/run-repository';
import type { DatabaseSync } from 'node:sqlite';
import {
ATTEMPT_SELECT,
EVENT_SELECT,
RETRY_POLICY_SELECT,
RUN_SELECT,
optionalInteger,
optionalString,
queryRows,
requiredEnum,
requiredInteger,
requiredJson,
requiredString,
rowToAttempt,
rowToEvent,
rowToRetryPolicy,
rowToRun,
singleRow,
} from './runPersistence';
const TERMINAL_RUN_STATUSES = Object.freeze([
'succeeded',
'failed',
'cancelled',
'timed_out',
] as const);
export class LocalSqliteRunReader
implements RunRepositoryReader, ProjectRunListReader
{
constructor(readonly client: DatabaseSync) {}
async findRunById(runId: string): Promise<RunRecord | null> {
const row = singleRow(
queryRows(
this.client,
`SELECT ${RUN_SELECT} FROM "Runs" WHERE "id" = ? LIMIT 2`,
[runId],
),
);
return row ? rowToRun(row) : null;
}
async listRunsByProject(
value: Readonly<ProjectRunListQuery>,
): Promise<readonly RunRecord[]> {
const query = normalizeProjectRunListQuery(value);
return queryRows(
this.client,
`SELECT ${RUN_SELECT} FROM "Runs"
WHERE "project_id" = ?
AND (
? IS NULL
OR "created_at_ms" < ?
OR ("created_at_ms" = ? AND "id" < ?)
)
ORDER BY "created_at_ms" DESC, "id" DESC
LIMIT ?`,
[
query.projectId,
query.after?.runId ?? null,
query.after?.createdAtMs ?? 0,
query.after?.createdAtMs ?? 0,
query.after?.runId ?? '',
query.limit,
],
).map(rowToRun);
}
async findAttemptById(attemptId: string): Promise<RunAttemptRecord | null> {
const row = singleRow(
queryRows(
this.client,
`SELECT ${ATTEMPT_SELECT} FROM "RunAttempts" WHERE "id" = ? LIMIT 2`,
[attemptId],
),
);
return row ? rowToAttempt(row) : null;
}
async findLatestAttemptByRunId(
runId: string,
): Promise<RunAttemptRecord | null> {
const row = singleRow(
queryRows(
this.client,
`SELECT ${ATTEMPT_SELECT} FROM "RunAttempts"
WHERE "run_id" = ? ORDER BY "attempt" DESC, "id" DESC LIMIT 1`,
[runId],
),
);
return row ? rowToAttempt(row) : null;
}
async findRetryPolicyByRunId(
runId: string,
): Promise<RunRetryPolicyRecord | null> {
const row = singleRow(
queryRows(
this.client,
`SELECT ${RETRY_POLICY_SELECT} FROM "RunRetryPolicies"
WHERE "run_id" = ? LIMIT 2`,
[runId],
),
);
return row ? rowToRetryPolicy(row) : null;
}
async listEvents(
runId: string,
options: { afterSequence?: number; limit?: number } = {},
): Promise<RunEventRecord[]> {
const afterSequence = options.afterSequence ?? 0;
const limit = options.limit ?? 100;
if (!Number.isInteger(afterSequence) || afterSequence < 0) {
throw new RangeError('afterSequence must be a non-negative integer');
}
if (
!Number.isInteger(limit) ||
limit < 1 ||
limit > MAX_RUN_EVENT_PAGE_SIZE
) {
throw new RangeError(
'limit must be between 1 and MAX_RUN_EVENT_PAGE_SIZE',
);
}
return queryRows(
this.client,
`SELECT ${EVENT_SELECT} FROM "RunEvents"
WHERE "run_id" = ? AND "sequence" > ?
ORDER BY "sequence", "id" LIMIT ?`,
[runId, afterSequence, limit],
).map(rowToEvent);
}
async listCancellationRequested(
options: { beforeMs?: number; limit?: number } = {},
): Promise<RunRecord[]> {
const beforeMs = options.beforeMs;
const limit = options.limit ?? 100;
if (
beforeMs !== undefined &&
(!Number.isSafeInteger(beforeMs) || beforeMs < 0)
) {
throw new RangeError('beforeMs must be a non-negative safe integer');
}
if (
!Number.isSafeInteger(limit) ||
limit < 1 ||
limit > MAX_CANCELLATION_RECOVERY_PAGE_SIZE
) {
throw new RangeError(
'limit must be between 1 and MAX_CANCELLATION_RECOVERY_PAGE_SIZE',
);
}
const terminalPlaceholders = TERMINAL_RUN_STATUSES.map(() => '?').join(',');
return queryRows(
this.client,
`SELECT ${RUN_SELECT} FROM "Runs"
WHERE "status" NOT IN (${terminalPlaceholders})
AND "cancel_requested_at_ms" IS NOT NULL
AND (? IS NULL OR "cancel_requested_at_ms" <= ?)
ORDER BY "cancel_requested_at_ms", "id" LIMIT ?`,
[...TERMINAL_RUN_STATUSES, beforeMs ?? null, beforeMs ?? null, limit],
).map(rowToRun);
}
async listLocalExecutionControlCandidates(options: {
readonly observedAtMs: number;
readonly limit: number;
readonly after?: LocalExecutionControlCursor;
}): Promise<LocalExecutionControlPage> {
assertLocalExecutionControlLimit(options.limit);
if (
!Number.isSafeInteger(options.observedAtMs) ||
options.observedAtMs < 0
) {
throw new RangeError('observedAtMs must be a non-negative safe integer');
}
const after =
options.after === undefined
? undefined
: normalizeLocalExecutionControlCursor(options.after);
const rows = queryRows(
this.client,
`WITH "control_candidates" AS (
SELECT 'cancellation' AS "kind", "run"."id" AS "runId",
"attempt"."id" AS "attemptId",
"run"."cancel_requested_at_ms" AS "dueAtMs",
"run"."cancel_reason" AS "cancelReason"
FROM "Runs" AS "run"
JOIN "RunAttempts" AS "attempt"
ON "attempt"."run_id" = "run"."id"
WHERE "run"."execution_owner" = 'runtime'
AND "run"."status" NOT IN ('succeeded','failed','cancelled','timed_out')
AND "run"."cancel_requested_at_ms" IS NOT NULL
AND "run"."cancel_requested_at_ms" <= ?
AND "attempt"."status" IN ('claimed','starting','running')
AND "attempt"."executor_type" = 'local_process'
AND (
(
"attempt"."step_run_id" IS NOT NULL
AND EXISTS (
SELECT 1
FROM "QingLong3PluginPackageWorkflowTaskAttemptAdmissions"
AS "workflow_task"
WHERE "workflow_task"."attempt_id" = "attempt"."id"
AND "workflow_task"."run_id" = "attempt"."run_id"
AND "workflow_task"."step_run_id" =
"attempt"."step_run_id"
)
)
OR NOT EXISTS (
SELECT 1 FROM "RunAttempts" AS "newer"
WHERE "newer"."run_id" = "attempt"."run_id"
AND "newer"."attempt" > "attempt"."attempt"
)
)
UNION ALL
SELECT 'deadline' AS "kind", "run"."id" AS "runId",
"attempt"."id" AS "attemptId",
"attempt"."deadline_at_ms" AS "dueAtMs",
NULL AS "cancelReason"
FROM "Runs" AS "run"
JOIN "RunAttempts" AS "attempt"
ON "attempt"."run_id" = "run"."id"
WHERE "run"."execution_owner" = 'runtime'
AND "run"."status" IN ('dispatching','running')
AND "run"."cancel_requested_at_ms" IS NULL
AND "attempt"."status" IN ('starting','running')
AND "attempt"."executor_type" = 'local_process'
AND "attempt"."deadline_at_ms" IS NOT NULL
AND "attempt"."deadline_at_ms" <= ?
AND (
(
"attempt"."step_run_id" IS NOT NULL
AND EXISTS (
SELECT 1
FROM "QingLong3PluginPackageWorkflowTaskAttemptAdmissions"
AS "workflow_task"
WHERE "workflow_task"."attempt_id" = "attempt"."id"
AND "workflow_task"."run_id" = "attempt"."run_id"
AND "workflow_task"."step_run_id" =
"attempt"."step_run_id"
)
)
OR NOT EXISTS (
SELECT 1 FROM "RunAttempts" AS "newer"
WHERE "newer"."run_id" = "attempt"."run_id"
AND "newer"."attempt" > "attempt"."attempt"
)
)
)
SELECT "kind", "runId", "attemptId", "dueAtMs", "cancelReason"
FROM "control_candidates"
WHERE ? IS NULL
OR "dueAtMs" > ?
OR ("dueAtMs" = ? AND "kind" > ?)
OR ("dueAtMs" = ? AND "kind" = ? AND "attemptId" > ?)
ORDER BY "dueAtMs", "kind", "attemptId"
LIMIT ?`,
[
options.observedAtMs,
options.observedAtMs,
after?.attemptId ?? null,
after?.dueAtMs ?? 0,
after?.dueAtMs ?? 0,
after?.kind ?? '',
after?.dueAtMs ?? 0,
after?.kind ?? '',
after?.attemptId ?? '',
options.limit + 1,
],
);
const truncated = rows.length > options.limit;
const candidates = rows.slice(0, options.limit).map((row) => {
const kind = requiredEnum(row, 'kind', [
'cancellation',
'deadline',
] as const);
return normalizeLocalExecutionControlCandidate({
kind,
runId: requiredString(row, 'runId'),
attemptId: requiredString(row, 'attemptId'),
dueAtMs: requiredInteger(row, 'dueAtMs'),
...(kind === 'cancellation'
? {
cancelReason: requiredEnum(
row,
'cancelReason',
RUN_CANCELLATION_REASONS,
),
}
: {}),
});
});
const last = candidates.at(-1);
return Object.freeze({
candidates: Object.freeze(candidates),
truncated,
...(truncated && last
? {
nextCursor: Object.freeze({
dueAtMs: last.dueAtMs,
kind: last.kind,
attemptId: last.attemptId,
}),
}
: {}),
});
}
async listLocalActiveExecutions(options: {
readonly limit: number;
readonly after?: LocalActiveExecutionCursor;
}): Promise<LocalActiveExecutionPage> {
assertLocalExecutionControlLimit(options.limit);
const after =
options.after === undefined
? undefined
: normalizeLocalActiveExecutionCursor(options.after);
const rows = queryRows(
this.client,
`SELECT "run"."id" AS "runId", "attempt"."id" AS "attemptId",
"attempt"."created_at_ms" AS "attemptCreatedAtMs"
FROM "Runs" AS "run"
JOIN "RunAttempts" AS "attempt"
ON "attempt"."run_id" = "run"."id"
WHERE "run"."execution_owner" = 'runtime'
AND "run"."status" NOT IN ('succeeded','failed','cancelled','timed_out')
AND "attempt"."status" IN ('claimed','starting','running')
AND "attempt"."executor_type" = 'local_process'
AND (
(
"attempt"."step_run_id" IS NOT NULL
AND EXISTS (
SELECT 1
FROM "QingLong3PluginPackageWorkflowTaskAttemptAdmissions"
AS "workflow_task"
WHERE "workflow_task"."attempt_id" = "attempt"."id"
AND "workflow_task"."run_id" = "attempt"."run_id"
AND "workflow_task"."step_run_id" =
"attempt"."step_run_id"
)
)
OR NOT EXISTS (
SELECT 1 FROM "RunAttempts" AS "newer"
WHERE "newer"."run_id" = "attempt"."run_id"
AND "newer"."attempt" > "attempt"."attempt"
)
)
AND (
? IS NULL
OR "attempt"."created_at_ms" > ?
OR ("attempt"."created_at_ms" = ? AND "attempt"."id" > ?)
)
ORDER BY "attempt"."created_at_ms", "attempt"."id"
LIMIT ?`,
[
after?.attemptId ?? null,
after?.attemptCreatedAtMs ?? 0,
after?.attemptCreatedAtMs ?? 0,
after?.attemptId ?? '',
options.limit + 1,
],
);
const truncated = rows.length > options.limit;
const candidates = rows.slice(0, options.limit).map((row) =>
normalizeLocalActiveExecutionCandidate({
runId: requiredString(row, 'runId'),
attemptId: requiredString(row, 'attemptId'),
attemptCreatedAtMs: requiredInteger(row, 'attemptCreatedAtMs'),
}),
);
const last = candidates.at(-1);
return Object.freeze({
candidates: Object.freeze(candidates),
truncated,
...(truncated && last
? {
nextCursor: Object.freeze({
attemptCreatedAtMs: last.attemptCreatedAtMs,
attemptId: last.attemptId,
}),
}
: {}),
});
}
async inspectStartupRecoveryCandidates(
options: { limit?: number } = {},
): Promise<LocalRunStartupRecoveryPage> {
const limit = options.limit ?? 64;
if (
!Number.isSafeInteger(limit) ||
limit < 1 ||
limit > MAX_LOCAL_RUN_STARTUP_RECOVERY_CANDIDATES
) {
throw new RangeError(
'limit must be between 1 and MAX_LOCAL_RUN_STARTUP_RECOVERY_CANDIDATES',
);
}
const rows = queryRows(
this.client,
`SELECT
"run"."id" AS "runId",
"run"."status" AS "runStatus",
(
SELECT COUNT(*) FROM "RunAttempts" AS "attempt"
WHERE "attempt"."run_id" = "run"."id"
AND "attempt"."status" IN ('claimed','starting','running')
) AS "activeAttemptCount"
FROM "Runs" AS "run"
WHERE "run"."execution_owner" = 'runtime'
AND "run"."status" IN ('dispatching','running')
AND NOT EXISTS (
SELECT 1
FROM "QingLong3PluginPackageWorkflowAdmissions" AS "workflow"
WHERE "workflow"."run_id" = "run"."id"
)
ORDER BY "run"."id"
LIMIT ?`,
[limit + 1],
);
const truncated = rows.length > limit;
const candidates = rows.slice(0, limit).map((row) => {
const runStatus = requiredEnum(row, 'runStatus', [
'dispatching',
'running',
] as const);
const candidate: LocalRunStartupRecoveryCandidate = {
runId: requiredString(row, 'runId'),
runStatus,
activeAttemptCount: requiredInteger(row, 'activeAttemptCount'),
};
return Object.freeze(candidate);
});
return Object.freeze({
candidates: Object.freeze(candidates),
truncated,
});
}
async listLocalDispatchCandidates(options: {
readonly limit: number;
readonly after?: LocalDispatchCandidateCursor;
}): Promise<LocalDispatchCandidatePage> {
assertLocalDispatchPageSize(options.limit);
const after = options.after;
if (after !== undefined) {
normalizeLocalDispatchCandidate({
runId: 'cursor-validation-run',
attemptId: after.attemptId,
projectId: 'cursor-validation-project',
taskId: 'cursor-validation-task',
taskRevision: 'cursor-validation-revision',
attemptNumber: 1,
executorType: LOCAL_PROCESS_EXECUTOR_TYPE,
priority: after.priority,
queuedAtMs: after.queuedAtMs,
attemptCreatedAtMs: after.attemptCreatedAtMs,
});
}
const rows = queryRows(
this.client,
`WITH "dispatch_candidates" AS (
SELECT
"run"."id" AS "runId", NULL AS "stepRunId",
"run"."project_id" AS "projectId",
"run"."task_id" AS "taskId",
"run"."task_revision" AS "taskRevision",
"run"."priority" AS "priority",
"run"."queued_at_ms" AS "queuedAtMs",
"attempt"."id" AS "attemptId",
"attempt"."attempt" AS "attemptNumber",
"attempt"."created_at_ms" AS "attemptCreatedAtMs",
"attempt"."executor_type" AS "executorType"
FROM "Runs" AS "run"
JOIN "RunAttempts" AS "attempt"
ON "attempt"."run_id" = "run"."id"
WHERE "run"."execution_owner" = 'runtime'
AND "run"."status" = 'queued'
AND "run"."cancel_requested_at_ms" IS NULL
AND "run"."queued_at_ms" IS NOT NULL
AND "attempt"."step_run_id" IS NULL
AND "attempt"."status" = 'claimed'
AND "attempt"."executor_type" = 'local_process'
AND NOT EXISTS (
SELECT 1 FROM "RunAttempts" AS "newer"
WHERE "newer"."run_id" = "attempt"."run_id"
AND "newer"."attempt" > "attempt"."attempt"
)
UNION ALL
SELECT
"run"."id" AS "runId", "step"."id" AS "stepRunId",
"task_attempt"."project_id" AS "projectId",
"task_attempt"."task_id" AS "taskId",
"task_attempt"."task_revision" AS "taskRevision",
"run"."priority" AS "priority",
"step"."ready_at_ms" AS "queuedAtMs",
"attempt"."id" AS "attemptId",
"attempt"."attempt" AS "attemptNumber",
"attempt"."created_at_ms" AS "attemptCreatedAtMs",
"attempt"."executor_type" AS "executorType"
FROM "QingLong3PluginPackageWorkflowTaskAttemptAdmissions"
AS "task_attempt"
JOIN "Runs" AS "run"
ON "run"."id" = "task_attempt"."run_id"
JOIN "RunAttempts" AS "attempt"
ON "attempt"."id" = "task_attempt"."attempt_id"
AND "attempt"."run_id" = "task_attempt"."run_id"
AND "attempt"."step_run_id" = "task_attempt"."step_run_id"
JOIN "StepRuns" AS "step"
ON "step"."run_id" = "task_attempt"."run_id"
AND "step"."id" = "task_attempt"."step_run_id"
WHERE "run"."execution_owner" = 'runtime'
AND "run"."status" = 'running'
AND "run"."cancel_requested_at_ms" IS NULL
AND "attempt"."status" = 'claimed'
AND "attempt"."executor_type" = 'local_process'
AND "step"."status" = 'ready'
AND "step"."ready_at_ms" IS NOT NULL
AND "step"."version" = "task_attempt"."step_run_version"
AND "step"."step_run_digest" =
"task_attempt"."step_run_digest"
)
SELECT *
FROM "dispatch_candidates"
WHERE (
? IS NULL
OR "priority" < ?
OR ("priority" = ? AND "queuedAtMs" > ?)
OR ("priority" = ? AND "queuedAtMs" = ?
AND "attemptCreatedAtMs" > ?)
OR ("priority" = ? AND "queuedAtMs" = ?
AND "attemptCreatedAtMs" = ? AND "attemptId" > ?)
)
ORDER BY "priority" DESC, "queuedAtMs",
"attemptCreatedAtMs", "attemptId"
LIMIT ?`,
[
after?.attemptId ?? null,
after?.priority ?? 0,
after?.priority ?? 0,
after?.queuedAtMs ?? 0,
after?.priority ?? 0,
after?.queuedAtMs ?? 0,
after?.attemptCreatedAtMs ?? 0,
after?.priority ?? 0,
after?.queuedAtMs ?? 0,
after?.attemptCreatedAtMs ?? 0,
after?.attemptId ?? '',
options.limit + 1,
],
);
const truncated = rows.length > options.limit;
const candidates = rows.slice(0, options.limit).map((row) =>
normalizeLocalDispatchCandidate({
runId: requiredString(row, 'runId'),
attemptId: requiredString(row, 'attemptId'),
...(optionalString(row, 'stepRunId') === undefined
? {}
: { stepRunId: optionalString(row, 'stepRunId')! }),
projectId: requiredString(row, 'projectId'),
taskId: requiredString(row, 'taskId'),
taskRevision: requiredString(row, 'taskRevision'),
attemptNumber: requiredInteger(row, 'attemptNumber'),
executorType: requiredEnum(row, 'executorType', [
LOCAL_PROCESS_EXECUTOR_TYPE,
] as const),
priority: requiredInteger(row, 'priority'),
queuedAtMs: requiredInteger(row, 'queuedAtMs'),
attemptCreatedAtMs: requiredInteger(row, 'attemptCreatedAtMs'),
}),
);
return Object.freeze({
candidates: Object.freeze(candidates),
truncated,
});
}
async resolveLocalTaskExecutionRevision(identity: {
readonly projectId: string;
readonly taskId: string;
readonly taskRevision: string;
}): Promise<LocalTaskExecutionRevision | null> {
const row = singleRow(
queryRows(
this.client,
`SELECT
"project_id" AS "projectId", "task_id" AS "taskId",
"task_revision" AS "taskRevision",
"executor_type" AS "executorType", "command_json" AS "commandJson",
"working_directory" AS "workingDirectory",
"timeout_ms" AS "timeoutMs", "context_ref" AS "contextRef",
"content_digest" AS "contentDigest",
"created_at_ms" AS "createdAtMs"
FROM "QingLong3LocalTaskExecutionRevisions"
WHERE "project_id" = ? AND "task_id" = ? AND "task_revision" = ?
LIMIT 2`,
[identity.projectId, identity.taskId, identity.taskRevision],
),
);
if (!row) return null;
return normalizeLocalTaskExecutionRevision({
projectId: requiredString(row, 'projectId'),
taskId: requiredString(row, 'taskId'),
taskRevision: requiredString(row, 'taskRevision'),
executorType: requiredEnum(row, 'executorType', [
LOCAL_PROCESS_EXECUTOR_TYPE,
] as const),
command: requiredJson(
row,
'commandJson',
) as LocalTaskExecutionRevision['command'],
...(optionalString(row, 'workingDirectory') === undefined
? {}
: { workingDirectory: optionalString(row, 'workingDirectory')! }),
...(optionalInteger(row, 'timeoutMs') === undefined
? {}
: { timeoutMs: optionalInteger(row, 'timeoutMs')! }),
contextRef: requiredString(row, 'contextRef'),
contentDigest: requiredString(row, 'contentDigest'),
createdAtMs: requiredInteger(row, 'createdAtMs'),
});
}
async resolveLocalExecutionContextRecipe(
contextRef: string,
): Promise<LocalExecutionContextRecipe | null> {
assertLocalDispatchContextRef(contextRef);
const row = singleRow(
queryRows(
this.client,
`SELECT "context_ref" AS "contextRef",
"environment_json" AS "environmentJson",
"content_digest" AS "contentDigest",
"created_at_ms" AS "createdAtMs"
FROM "QingLong3LocalExecutionContextRecipes"
WHERE "context_ref" = ? LIMIT 2`,
[contextRef],
),
);
if (!row) return null;
return normalizeLocalExecutionContextRecipe({
contextRef: requiredString(row, 'contextRef'),
environment: requiredJson(
row,
'environmentJson',
) as LocalExecutionContextRecipe['environment'],
contentDigest: requiredString(row, 'contentDigest'),
createdAtMs: requiredInteger(row, 'createdAtMs'),
});
}
}
@@ -0,0 +1,349 @@
import type {
RunAttemptRecord,
RunAttemptStatus,
RunEventRecord,
RunRecord,
RunRepository,
RunRepositoryTransaction,
RunRetryPolicyRecord,
} from '@qinglong/runtime-core/run-repository';
import type {
ProjectRunListQuery,
ProjectRunListReader,
} from '@qinglong/runtime-core/project-run-list';
import {
DuplicateIdempotencyKeyError,
DuplicateRunAttemptError,
DuplicateRunEventError,
RunRepositoryBusyError,
RunRepositoryConstraintError,
RunRepositoryError,
RunRepositoryOperationError,
assertRunRetryPolicyRecord,
} from '@qinglong/runtime-core/run-repository';
import type { DatabaseSync } from 'node:sqlite';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
import {
ATTEMPT_COLUMNS,
EVENT_COLUMNS,
INSERT_ATTEMPT_SQL,
INSERT_EVENT_SQL,
INSERT_RETRY_POLICY_SQL,
INSERT_RUN_SQL,
RETRY_POLICY_COLUMNS,
RUN_COLUMNS,
UPDATE_ATTEMPT_SQL,
UPDATE_RETRY_POLICY_SQL,
UPDATE_RUN_SQL,
assertEventPayloadSize,
isSqliteError,
mapSqliteError,
sqliteErrorMessage,
writeValues,
} from './runPersistence';
import { LocalSqliteRunReader } from './runReader';
class LocalSqliteRunTransaction
extends LocalSqliteRunReader
implements RunRepositoryTransaction
{
private assertRunTaskRevisionIsNotQuarantined(run: RunRecord): void {
if (run.status !== 'dispatching' && run.status !== 'running') return;
const quarantined = this.client
.prepare(
`SELECT 1
FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine
JOIN "QingLong3PluginPackageTaskReconciliations" AS reconciliation
ON reconciliation.project_id = quarantine.project_id
AND reconciliation.package_name = quarantine.package_name
AND reconciliation.lock_digest = quarantine.lock_digest
JOIN "QingLong3PluginPackageTaskReconciliationItems" AS item
ON item.generation_digest = reconciliation.generation_digest
AND item.task_id = ?
AND 'qltd:v1:' || item.revision || ':' || item.content_digest = ?
WHERE quarantine.project_id = ?
LIMIT 1`,
)
.get(run.taskId, run.taskRevision, run.projectId);
if (quarantined) {
throw new RunRepositoryConstraintError(
'Run Task revision belongs to a quarantined Package lock',
);
}
}
private assertRunTaskRevisionHasActivePackageLifecycle(run: RunRecord): void {
if (run.status !== 'dispatching' && run.status !== 'running') return;
const inactive = this.client
.prepare(
`SELECT 1
FROM "QingLong3PluginPackageLifecycleHeads" AS lifecycle
JOIN "QingLong3PluginPackageTaskReconciliations" AS reconciliation
ON reconciliation.project_id = lifecycle.project_id
AND reconciliation.package_name = lifecycle.package_name
AND reconciliation.lock_digest = lifecycle.lock_digest
JOIN "QingLong3PluginPackageTaskReconciliationItems" AS item
ON item.generation_digest = reconciliation.generation_digest
AND item.task_id = ?
AND 'qltd:v1:' || item.revision || ':' || item.content_digest = ?
WHERE lifecycle.project_id = ?
AND lifecycle.disposition <> 'active'
LIMIT 1`,
)
.get(run.taskId, run.taskRevision, run.projectId);
if (inactive) {
throw new RunRepositoryConstraintError(
'Run Task revision belongs to a non-active Package lifecycle',
);
}
}
async insertRun(run: RunRecord): Promise<void> {
try {
this.client.prepare(INSERT_RUN_SQL).run(...writeValues(run, RUN_COLUMNS));
} catch (error) {
if (
run.idempotencyKey &&
sqliteErrorMessage(error).includes(
'Runs.project_id, Runs.idempotency_key',
)
) {
throw new DuplicateIdempotencyKeyError(
run.projectId,
run.idempotencyKey,
);
}
throw mapSqliteError(error);
}
}
async insertAttempt(attempt: RunAttemptRecord): Promise<void> {
try {
this.client
.prepare(INSERT_ATTEMPT_SQL)
.run(...writeValues(attempt, ATTEMPT_COLUMNS));
} catch (error) {
if (
sqliteErrorMessage(error).includes(
'RunAttempts.run_id, RunAttempts.attempt',
)
) {
throw new DuplicateRunAttemptError(attempt.runId, attempt.attempt);
}
throw mapSqliteError(error);
}
}
async insertRetryPolicy(policy: RunRetryPolicyRecord): Promise<void> {
assertRunRetryPolicyRecord(policy);
try {
this.client
.prepare(INSERT_RETRY_POLICY_SQL)
.run(...writeValues(policy, RETRY_POLICY_COLUMNS));
} catch (error) {
throw mapSqliteError(error);
}
}
async compareAndSetRun(
run: RunRecord,
expectedVersion: number,
): Promise<boolean> {
if (run.version !== expectedVersion + 1) {
throw new RunRepositoryConstraintError(
'A compare-and-set Run write must increment version exactly once',
);
}
try {
this.assertRunTaskRevisionIsNotQuarantined(run);
this.assertRunTaskRevisionHasActivePackageLifecycle(run);
const values = writeValues(run, RUN_COLUMNS);
const result = this.client
.prepare(UPDATE_RUN_SQL)
.run(...values.slice(1), values[0]!, expectedVersion);
if (result.changes > 1) {
throw new RunRepositoryConstraintError(
'Local SQLite compare-and-set affected more than one Run',
);
}
return result.changes === 1;
} catch (error) {
throw mapSqliteError(error);
}
}
async compareAndSetAttempt(
attempt: RunAttemptRecord,
expected: { status: RunAttemptStatus; callbackSequence: number },
): Promise<boolean> {
try {
const values = writeValues(attempt, ATTEMPT_COLUMNS);
const result = this.client
.prepare(UPDATE_ATTEMPT_SQL)
.run(
...values.slice(1),
values[0]!,
expected.status,
expected.callbackSequence,
);
if (result.changes > 1) {
throw new RunRepositoryConstraintError(
'Local SQLite compare-and-set affected more than one Attempt',
);
}
return result.changes === 1;
} catch (error) {
throw mapSqliteError(error);
}
}
async compareAndSetRetryPolicy(
policy: RunRetryPolicyRecord,
expectedVersion: number,
): Promise<boolean> {
if (policy.version !== expectedVersion + 1) {
throw new RunRepositoryConstraintError(
'A compare-and-set retry policy write must increment version exactly once',
);
}
assertRunRetryPolicyRecord(policy);
try {
const values = writeValues(policy, RETRY_POLICY_COLUMNS);
const result = this.client
.prepare(UPDATE_RETRY_POLICY_SQL)
.run(...values.slice(1), values[0]!, expectedVersion);
if (result.changes > 1) {
throw new RunRepositoryConstraintError(
'Local SQLite compare-and-set affected more than one retry policy',
);
}
return result.changes === 1;
} catch (error) {
throw mapSqliteError(error);
}
}
async appendEvent(event: RunEventRecord): Promise<void> {
const serialized = assertEventPayloadSize(event);
try {
const values = writeValues(
{ ...event, payload: serialized },
EVENT_COLUMNS,
);
this.client.prepare(INSERT_EVENT_SQL).run(...values);
} catch (error) {
const message = sqliteErrorMessage(error);
if (
message.includes('RunEvents.run_id, RunEvents.sequence') ||
message.includes('RunEvents.run_id, RunEvents.dedupe_key')
) {
throw new DuplicateRunEventError(event.runId, event.dedupeKey);
}
throw mapSqliteError(error);
}
}
}
/**
* One Node SQLite connection is a single local authority. Every operation is
* serialized and every transaction uses BEGIN IMMEDIATE so async application
* code cannot interleave writes on the synchronous driver.
*/
export class LocalSqliteRunRepository
implements RunRepository, ProjectRunListReader
{
private readonly reader: LocalSqliteRunReader;
private readonly authority: LocalSqliteOperationAuthority;
private readonly client: DatabaseSync;
constructor(client: DatabaseSync | LocalSqliteOperationAuthority) {
this.authority =
client instanceof LocalSqliteOperationAuthority
? client
: new LocalSqliteOperationAuthority(client);
this.client = this.authority.client;
this.reader = new LocalSqliteRunReader(this.client);
}
private enqueue<T>(work: () => Promise<T>): Promise<T> {
return this.authority.enqueue(work, (reason) =>
reason === 'busy'
? new RunRepositoryBusyError()
: new RunRepositoryOperationError(
new Error('Local SQLite Run repository is closed'),
),
);
}
findRunById(runId: string): Promise<RunRecord | null> {
return this.enqueue(() => this.reader.findRunById(runId));
}
listRunsByProject(
query: Readonly<ProjectRunListQuery>,
): Promise<readonly RunRecord[]> {
return this.enqueue(() => this.reader.listRunsByProject(query));
}
findAttemptById(attemptId: string): Promise<RunAttemptRecord | null> {
return this.enqueue(() => this.reader.findAttemptById(attemptId));
}
findLatestAttemptByRunId(runId: string): Promise<RunAttemptRecord | null> {
return this.enqueue(() => this.reader.findLatestAttemptByRunId(runId));
}
findRetryPolicyByRunId(runId: string): Promise<RunRetryPolicyRecord | null> {
return this.enqueue(() => this.reader.findRetryPolicyByRunId(runId));
}
listEvents(
runId: string,
options?: { afterSequence?: number; limit?: number },
): Promise<RunEventRecord[]> {
return this.enqueue(() => this.reader.listEvents(runId, options));
}
listCancellationRequested(options?: {
beforeMs?: number;
limit?: number;
}): Promise<RunRecord[]> {
return this.enqueue(() => this.reader.listCancellationRequested(options));
}
transaction<T>(
work: (transaction: RunRepositoryTransaction) => Promise<T>,
): Promise<T> {
if (typeof work !== 'function') {
return Promise.reject(
new RunRepositoryConstraintError('transaction work must be a function'),
);
}
return this.enqueue(async () => {
let began = false;
try {
this.client.exec('BEGIN IMMEDIATE');
began = true;
const result = await work(new LocalSqliteRunTransaction(this.client));
this.client.exec('COMMIT');
began = false;
return result;
} catch (error) {
if (began && this.client.isTransaction) {
try {
this.client.exec('ROLLBACK');
} catch {
// Preserve the work failure; close will discard a broken handle.
}
}
if (error instanceof RunRepositoryError) throw error;
if (isSqliteError(error)) throw mapSqliteError(error);
throw error;
}
});
}
close(): Promise<void> {
return this.authority.close();
}
}
@@ -0,0 +1,252 @@
import {
assertLocalCompletionReceiptId,
assertLocalCompletionReceiptJournalCursor,
assertLocalCompletionReceiptJournalLimit,
assertLocalCompletionReceiptTimestamp,
type LocalCompletionReceiptJournal,
} from '@qinglong/runtime-core/local-completion-receipt-journal';
import {
normalizeLocalExecutionContextRecipe,
normalizeLocalTaskExecutionRevision,
type LocalDispatchStore,
} from '@qinglong/runtime-core/local-dispatch';
import type { LocalExecutionControlSource } from '@qinglong/runtime-core/local-execution-control';
import type { LocalRunStartupRecoverySource } from '@qinglong/runtime-core/local-startup-recovery';
import {
RunRepositoryBusyError,
RunRepositoryConstraintError,
RunRepositoryOperationError,
} from '@qinglong/runtime-core/run-repository';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
import {
LocalSqliteDispatchDefinitionConflictError,
LocalSqliteDispatchDefinitionStore,
} from '../task-definition/dispatchDefinitionStore';
import { LocalSqliteCompletionReceiptJournalStore } from './completionReceiptJournalStore';
import { mapSqliteError } from './runPersistence';
import { LocalSqliteRunReader } from './runReader';
export interface LocalSqliteRunRuntimeCapabilities {
readonly dispatch: LocalDispatchStore;
readonly executionControl: LocalExecutionControlSource;
readonly startupRecovery: LocalRunStartupRecoverySource;
readonly completionReceipts: LocalCompletionReceiptJournal;
}
function assertQuarantineReference(value: string): void {
if (
value.length < 1 ||
value.length > 255 ||
!value.startsWith('.quarantine/') ||
value.includes('..') ||
value.includes('\\') ||
value.includes('\0')
) {
throw new RunRepositoryConstraintError(
'Completion receipt quarantine reference is invalid',
);
}
}
function enqueueRunOperation<T>(
authority: LocalSqliteOperationAuthority,
work: () => Promise<T>,
): Promise<T> {
return authority.enqueue(work, (reason) =>
reason === 'busy'
? new RunRepositoryBusyError()
: new RunRepositoryOperationError(
new Error('Local SQLite Run repository is closed'),
),
);
}
/**
* Projects four least-authority runtime capabilities over one shared SQLite
* operation authority. The returned objects intentionally have disjoint
* method surfaces while retaining one connection, queue and close fence.
*/
export function createLocalSqliteRunRuntimeCapabilities(
authority: LocalSqliteOperationAuthority,
): LocalSqliteRunRuntimeCapabilities {
const reader = new LocalSqliteRunReader(authority.client);
const dispatchDefinitions = new LocalSqliteDispatchDefinitionStore(
authority.client,
);
const completionReceipts = new LocalSqliteCompletionReceiptJournalStore(
authority.client,
);
const enqueue = <T>(work: () => Promise<T>) =>
enqueueRunOperation(authority, work);
const dispatch: LocalDispatchStore = Object.freeze({
listLocalDispatchCandidates: (
options: Parameters<LocalDispatchStore['listLocalDispatchCandidates']>[0],
) => enqueue(() => reader.listLocalDispatchCandidates(options)),
resolveLocalTaskExecutionRevision: (
identity: Parameters<
LocalDispatchStore['resolveLocalTaskExecutionRevision']
>[0],
) => enqueue(() => reader.resolveLocalTaskExecutionRevision(identity)),
resolveLocalExecutionContextRecipe: (
contextRef: Parameters<
LocalDispatchStore['resolveLocalExecutionContextRecipe']
>[0],
) => enqueue(() => reader.resolveLocalExecutionContextRecipe(contextRef)),
appendLocalExecutionContextRecipe: (
value: Parameters<
LocalDispatchStore['appendLocalExecutionContextRecipe']
>[0],
) => {
const recipe = normalizeLocalExecutionContextRecipe(value);
return enqueue(async () => {
try {
return dispatchDefinitions.appendRecipe(recipe);
} catch (error) {
if (error instanceof LocalSqliteDispatchDefinitionConflictError) {
throw new RunRepositoryConstraintError(
'Local dispatch definition identity already exists',
error,
);
}
throw mapSqliteError(error);
}
});
},
appendLocalTaskExecutionRevision: (
value: Parameters<
LocalDispatchStore['appendLocalTaskExecutionRevision']
>[0],
) => {
const revision = normalizeLocalTaskExecutionRevision(value);
return enqueue(async () => {
try {
return dispatchDefinitions.appendRevision(revision);
} catch (error) {
if (error instanceof LocalSqliteDispatchDefinitionConflictError) {
throw new RunRepositoryConstraintError(
'Local dispatch definition identity already exists',
error,
);
}
throw mapSqliteError(error);
}
});
},
});
const executionControl: LocalExecutionControlSource = Object.freeze({
listLocalExecutionControlCandidates: (
options: Parameters<
LocalExecutionControlSource['listLocalExecutionControlCandidates']
>[0],
) => enqueue(() => reader.listLocalExecutionControlCandidates(options)),
listLocalActiveExecutions: (
options: Parameters<
LocalExecutionControlSource['listLocalActiveExecutions']
>[0],
) => enqueue(() => reader.listLocalActiveExecutions(options)),
});
const startupRecovery: LocalRunStartupRecoverySource = Object.freeze({
inspectCandidates: (
options?: Parameters<
LocalRunStartupRecoverySource['inspectCandidates']
>[0],
) => enqueue(() => reader.inspectStartupRecoveryCandidates(options)),
});
const completionReceiptJournal: LocalCompletionReceiptJournal = Object.freeze(
{
register: (
command: Parameters<LocalCompletionReceiptJournal['register']>[0],
) => {
assertLocalCompletionReceiptId(command.attemptId, 'attemptId');
assertLocalCompletionReceiptId(command.runId, 'runId');
assertLocalCompletionReceiptTimestamp(
command.registeredAtMs,
'registeredAtMs',
);
return enqueue(async () => {
try {
completionReceipts.register(command);
} catch (error) {
throw mapSqliteError(error);
}
});
},
markQuarantined: (
command: Parameters<
LocalCompletionReceiptJournal['markQuarantined']
>[0],
) => {
assertLocalCompletionReceiptId(command.attemptId, 'attemptId');
assertQuarantineReference(command.quarantineRef);
assertLocalCompletionReceiptTimestamp(
command.updatedAtMs,
'updatedAtMs',
);
assertLocalCompletionReceiptTimestamp(
command.purgeAfterMs,
'purgeAfterMs',
);
if (command.purgeAfterMs < command.updatedAtMs) {
return Promise.reject(
new RunRepositoryConstraintError(
'Completion receipt purge time precedes quarantine time',
),
);
}
return enqueue(async () => {
try {
completionReceipts.markQuarantined(command);
} catch (error) {
throw mapSqliteError(error);
}
});
},
resolve: (
attemptId: Parameters<LocalCompletionReceiptJournal['resolve']>[0],
) => {
assertLocalCompletionReceiptId(attemptId, 'attemptId');
return enqueue(async () => {
try {
return completionReceipts.resolve(attemptId);
} catch (error) {
throw mapSqliteError(error);
}
});
},
listCandidates: (
options: Parameters<LocalCompletionReceiptJournal['listCandidates']>[0],
) => {
assertLocalCompletionReceiptTimestamp(
options.observedAtMs,
'observedAtMs',
);
const limit = options.limit ?? 32;
assertLocalCompletionReceiptJournalLimit(limit);
if (options.cursor) {
assertLocalCompletionReceiptJournalCursor(options.cursor);
}
return enqueue(async () => {
try {
return completionReceipts.listCandidates({
...options,
limit,
});
} catch (error) {
throw mapSqliteError(error);
}
});
},
},
);
return Object.freeze({
dispatch,
executionControl,
startupRecovery,
completionReceipts: completionReceiptJournal,
});
}
@@ -0,0 +1,640 @@
import type { DatabaseSync } from 'node:sqlite';
import {
InvalidStepRunError,
MAX_STEP_RUNS_PER_RUN,
StepRunFenceConflictError,
StepRunMutationConflictError,
StepRunRepositoryUnavailableError,
StepRunStateConflictError,
normalizeListStepRunsQuery,
normalizeListStepRunsResult,
normalizeStepRunMutation,
normalizeStepRunRecord,
resolveStepRunMutation,
type ApplyStepRunMutationResult,
type ListStepRunsQuery,
type ListStepRunsResult,
type StepRunMutation,
type StepRunRecord,
type StepRunRepository,
} from '@qinglong/runtime-core/step-run';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
type Row = Record<string, unknown>;
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TERMINAL_RUN_STATUSES = new Set([
'succeeded',
'failed',
'cancelled',
'timed_out',
]);
const STEP_RUN_SELECT = `
"id" AS "id",
"run_id" AS "runId",
"parent_step_run_id" AS "parentStepRunId",
"step_key" AS "stepKey",
"kind" AS "kind",
"definition_ref" AS "definitionRef",
"definition_digest" AS "definitionDigest",
"required" AS "required",
"status" AS "status",
"version" AS "version",
"attempt_count" AS "attemptCount",
"input_ref" AS "inputRef",
"output_ref" AS "outputRef",
"approval_request_id" AS "approvalRequestId",
"ready_at_ms" AS "readyAtMs",
"started_at_ms" AS "startedAtMs",
"finished_at_ms" AS "finishedAtMs",
"result_code" AS "resultCode",
"error_summary" AS "errorSummary",
"created_at_ms" AS "createdAtMs",
"updated_at_ms" AS "updatedAtMs",
"last_mutation_id" AS "lastMutationId",
"step_run_digest" AS "stepRunDigest",
"step_run_json" AS "stepRunJson"
`;
function requiredText(row: Row, key: string): string {
const value = row[key];
if (typeof value !== 'string') {
throw new StepRunRepositoryUnavailableError();
}
return value;
}
function requiredInteger(row: Row, key: string): number {
const value = row[key];
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new StepRunRepositoryUnavailableError();
}
return value as number;
}
function nullableText(row: Row, key: string): string | null {
const value = row[key];
if (value !== null && typeof value !== 'string') {
throw new StepRunRepositoryUnavailableError();
}
return value as string | null;
}
function nullableInteger(row: Row, key: string): number | null {
const value = row[key];
if (
value !== null &&
(!Number.isSafeInteger(value) || (value as number) < 0)
) {
throw new StepRunRepositoryUnavailableError();
}
return value as number | null;
}
function identity(value: unknown, label: string): string {
if (typeof value !== 'string' || !IDENTITY_PATTERN.test(value)) {
throw new InvalidStepRunError(`${label} is invalid`);
}
return value;
}
function serializedRecordFromRow(row: Row): Readonly<StepRunRecord> {
let parsed: unknown;
try {
parsed = JSON.parse(requiredText(row, 'stepRunJson'));
} catch {
throw new StepRunRepositoryUnavailableError();
}
let record: Readonly<StepRunRecord>;
try {
record = normalizeStepRunRecord(parsed as StepRunRecord);
} catch {
throw new StepRunRepositoryUnavailableError();
}
return record;
}
function recordFromRow(row: Row): Readonly<StepRunRecord> {
const record = serializedRecordFromRow(row);
const requiredValue = requiredInteger(row, 'required');
if (
(requiredValue !== 0 && requiredValue !== 1) ||
record.id !== requiredText(row, 'id') ||
record.runId !== requiredText(row, 'runId') ||
record.parentStepRunId !== nullableText(row, 'parentStepRunId') ||
record.stepKey !== requiredText(row, 'stepKey') ||
record.kind !== requiredText(row, 'kind') ||
record.definitionRef !== requiredText(row, 'definitionRef') ||
record.definitionDigest !== requiredText(row, 'definitionDigest') ||
record.required !== (requiredValue === 1) ||
record.status !== requiredText(row, 'status') ||
record.version !== requiredInteger(row, 'version') ||
record.attemptCount !== requiredInteger(row, 'attemptCount') ||
record.inputRef !== nullableText(row, 'inputRef') ||
record.outputRef !== nullableText(row, 'outputRef') ||
record.approvalRequestId !== nullableText(row, 'approvalRequestId') ||
record.readyAtMs !== nullableInteger(row, 'readyAtMs') ||
record.startedAtMs !== nullableInteger(row, 'startedAtMs') ||
record.finishedAtMs !== nullableInteger(row, 'finishedAtMs') ||
record.resultCode !== nullableText(row, 'resultCode') ||
record.errorSummary !== nullableText(row, 'errorSummary') ||
record.createdAtMs !== requiredInteger(row, 'createdAtMs') ||
record.updatedAtMs !== requiredInteger(row, 'updatedAtMs') ||
record.lastMutationId !== requiredText(row, 'lastMutationId') ||
record.stepRunDigest !== requiredText(row, 'stepRunDigest')
) {
throw new StepRunRepositoryUnavailableError();
}
return record;
}
function sqliteErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object') return undefined;
const value = (error as { code?: unknown }).code;
return typeof value === 'string' ? value : undefined;
}
function sqliteErrorNumber(error: unknown): number | undefined {
if (!error || typeof error !== 'object') return undefined;
const value = (error as { errcode?: unknown }).errcode;
return typeof value === 'number' ? value : undefined;
}
function sqliteErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : '';
}
function mapStorageError(error: unknown): Error {
if (
error instanceof InvalidStepRunError ||
error instanceof StepRunFenceConflictError ||
error instanceof StepRunMutationConflictError ||
error instanceof StepRunRepositoryUnavailableError ||
error instanceof StepRunStateConflictError
) {
return error;
}
const baseCode = (sqliteErrorNumber(error) ?? 0) & 0xff;
const code = sqliteErrorCode(error);
const message = sqliteErrorMessage(error);
if (
baseCode === 19 ||
code === 'ERR_SQLITE_CONSTRAINT' ||
code?.startsWith('ERR_SQLITE_CONSTRAINT') === true
) {
if (
message.includes('StepRuns.run_id, StepRuns.step_key') ||
message.includes('ql3 StepRun reference mismatch')
) {
return new StepRunStateConflictError();
}
return new StepRunFenceConflictError();
}
return new StepRunRepositoryUnavailableError({
cause: error instanceof Error ? error : undefined,
});
}
function exactStoredEvent(
row: Row,
mutation: Readonly<StepRunMutation>,
): boolean {
let payload: unknown;
try {
payload = JSON.parse(requiredText(row, 'eventPayload'));
} catch {
throw new StepRunRepositoryUnavailableError();
}
const event = mutation.event;
return (
requiredText(row, 'eventId') === event.id &&
requiredText(row, 'eventRunId') === event.runId &&
requiredInteger(row, 'storedEventSequence') === event.sequence &&
requiredText(row, 'eventType') === event.type &&
requiredText(row, 'eventDedupeKey') === event.dedupeKey &&
requiredText(row, 'eventActorType') === event.actorType &&
(row.eventActorId === null ? undefined : row.eventActorId) ===
event.actorId &&
requiredText(row, 'eventStepRunId') === event.stepRunId &&
requiredInteger(row, 'eventCreatedAtMs') === event.createdAtMs &&
JSON.stringify(payload) === JSON.stringify(event.payload)
);
}
function storedMutationResult(
row: Row,
mutation: Readonly<StepRunMutation>,
): Readonly<ApplyStepRunMutationResult> {
const stepRun = serializedRecordFromRow(row);
if (
requiredText(row, 'mutationId') !== mutation.mutationId ||
requiredText(row, 'mutationDigest') !== mutation.mutationDigest ||
requiredText(row, 'storedRunId') !== mutation.runId ||
requiredText(row, 'stepRunId') !== mutation.stepRun.id ||
requiredText(row, 'storedStepRunDigest') !==
mutation.stepRun.stepRunDigest ||
JSON.stringify(stepRun) !== JSON.stringify(mutation.stepRun) ||
!exactStoredEvent(row, mutation)
) {
throw new StepRunMutationConflictError();
}
const runVersion = requiredInteger(row, 'runVersion');
const runEventSequence = requiredInteger(row, 'eventSequence');
if (
runVersion !== mutation.expectedRunVersion + 1 ||
runEventSequence !== mutation.expectedRunEventSequence + 1 ||
runEventSequence !== mutation.event.sequence
) {
throw new StepRunRepositoryUnavailableError();
}
return Object.freeze({
status: 'existing',
stepRun,
runVersion,
runEventSequence,
});
}
function insertStepRun(
client: DatabaseSync,
stepRun: Readonly<StepRunRecord>,
): void {
client
.prepare(
`INSERT INTO "StepRuns" (
"id", "run_id", "parent_step_run_id", "step_key", "kind",
"definition_ref", "definition_digest", "required", "status",
"version", "attempt_count", "input_ref", "output_ref",
"approval_request_id", "ready_at_ms", "started_at_ms",
"finished_at_ms", "result_code", "error_summary", "created_at_ms",
"updated_at_ms", "last_mutation_id", "step_run_digest",
"step_run_json"
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?
)`,
)
.run(
stepRun.id,
stepRun.runId,
stepRun.parentStepRunId,
stepRun.stepKey,
stepRun.kind,
stepRun.definitionRef,
stepRun.definitionDigest,
stepRun.required ? 1 : 0,
stepRun.status,
stepRun.version,
stepRun.attemptCount,
stepRun.inputRef,
stepRun.outputRef,
stepRun.approvalRequestId,
stepRun.readyAtMs,
stepRun.startedAtMs,
stepRun.finishedAtMs,
stepRun.resultCode,
stepRun.errorSummary,
stepRun.createdAtMs,
stepRun.updatedAtMs,
stepRun.lastMutationId,
stepRun.stepRunDigest,
JSON.stringify(stepRun),
);
}
function updateStepRun(
client: DatabaseSync,
mutation: Readonly<StepRunMutation>,
): void {
const stepRun = mutation.stepRun;
const result = client
.prepare(
`UPDATE "StepRuns"
SET "status" = ?, "version" = ?, "attempt_count" = ?,
"output_ref" = ?, "approval_request_id" = ?, "ready_at_ms" = ?,
"started_at_ms" = ?, "finished_at_ms" = ?, "result_code" = ?,
"error_summary" = ?, "updated_at_ms" = ?,
"last_mutation_id" = ?, "step_run_digest" = ?,
"step_run_json" = ?
WHERE "id" = ? AND "run_id" = ? AND "version" = ?
AND "step_run_digest" = ? AND "status" = ?`,
)
.run(
stepRun.status,
stepRun.version,
stepRun.attemptCount,
stepRun.outputRef,
stepRun.approvalRequestId,
stepRun.readyAtMs,
stepRun.startedAtMs,
stepRun.finishedAtMs,
stepRun.resultCode,
stepRun.errorSummary,
stepRun.updatedAtMs,
stepRun.lastMutationId,
stepRun.stepRunDigest,
JSON.stringify(stepRun),
stepRun.id,
stepRun.runId,
mutation.expectedStepRunVersion,
mutation.expectedStepRunDigest,
mutation.previousStatus,
);
if (result.changes !== 1) throw new StepRunFenceConflictError();
}
function appendRunEvent(
client: DatabaseSync,
mutation: Readonly<StepRunMutation>,
): void {
const event = mutation.event;
client
.prepare(
`INSERT INTO "RunEvents" (
"id", "run_id", "sequence", "type", "dedupe_key", "actor_type",
"actor_id", "attempt_id", "step_run_id", "payload", "created_at_ms"
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`,
)
.run(
event.id,
event.runId,
event.sequence,
event.type,
event.dedupeKey!,
event.actorType,
event.actorId ?? null,
mutation.stepRun.id,
JSON.stringify(event.payload),
event.createdAtMs,
);
}
export class LocalSqliteStepRunRepository implements StepRunRepository {
private readonly authority: LocalSqliteOperationAuthority;
private readonly client: DatabaseSync;
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
this.authority =
authority instanceof LocalSqliteOperationAuthority
? authority
: new LocalSqliteOperationAuthority(authority);
this.client = this.authority.client;
}
private enqueue<T>(work: () => T): Promise<T> {
return this.authority.enqueue(
async () => {
try {
return work();
} catch (error) {
throw mapStorageError(error);
}
},
() => new StepRunRepositoryUnavailableError(),
);
}
private findStoredById(id: string): Readonly<StepRunRecord> | null {
const row = this.client
.prepare(
`SELECT ${STEP_RUN_SELECT}
FROM "StepRuns" WHERE "id" = ? LIMIT 2`,
)
.all(id) as Row[];
if (row.length > 1) throw new StepRunRepositoryUnavailableError();
return row[0] ? recordFromRow(row[0]) : null;
}
findById(idValue: string): Promise<Readonly<StepRunRecord> | null> {
const id = identity(idValue, 'StepRun id');
return this.enqueue(() => this.findStoredById(id));
}
findByRunAndStepKey(
runIdValue: string,
stepKeyValue: string,
): Promise<Readonly<StepRunRecord> | null> {
const runId = identity(runIdValue, 'Run id');
const stepKey = identity(stepKeyValue, 'step key');
return this.enqueue(() => {
const rows = this.client
.prepare(
`SELECT ${STEP_RUN_SELECT}
FROM "StepRuns"
WHERE "run_id" = ? AND "step_key" = ?
LIMIT 2`,
)
.all(runId, stepKey) as Row[];
if (rows.length > 1) throw new StepRunRepositoryUnavailableError();
return rows[0] ? recordFromRow(rows[0]) : null;
});
}
listByRun(queryValue: ListStepRunsQuery): Promise<ListStepRunsResult> {
const query = normalizeListStepRunsQuery(queryValue);
return this.enqueue(() => {
const rows = this.client
.prepare(
`SELECT ${STEP_RUN_SELECT}
FROM "StepRuns"
WHERE "run_id" = ? AND (
? IS NULL OR "step_key" > ? OR
("step_key" = ? AND "id" > ?)
)
ORDER BY "step_key", "id"
LIMIT ?`,
)
.all(
query.runId,
query.after?.id ?? null,
query.after?.stepKey ?? '',
query.after?.stepKey ?? '',
query.after?.id ?? '',
query.limit + 1,
) as Row[];
const truncated = rows.length > query.limit;
const stepRuns = rows.slice(0, query.limit).map(recordFromRow);
const last = stepRuns.at(-1);
return normalizeListStepRunsResult(
{
stepRuns,
truncated,
...(truncated && last
? {
next: {
stepKey: last.stepKey,
id: last.id,
},
}
: {}),
},
query,
);
});
}
apply(
mutationValue: StepRunMutation,
): Promise<Readonly<ApplyStepRunMutationResult>> {
const mutation = normalizeStepRunMutation(mutationValue);
return this.enqueue(() => {
let began = false;
try {
this.client.exec('BEGIN IMMEDIATE');
began = true;
const stored = this.client
.prepare(
`SELECT
mutation."mutation_id" AS "mutationId",
mutation."mutation_digest" AS "mutationDigest",
mutation."run_id" AS "storedRunId",
mutation."step_run_id" AS "stepRunId",
mutation."step_run_digest" AS "storedStepRunDigest",
mutation."event_sequence" AS "eventSequence",
mutation."run_version" AS "runVersion",
mutation."step_run_json" AS "stepRunJson",
event."id" AS "eventId",
event."run_id" AS "eventRunId",
event."sequence" AS "storedEventSequence",
event."type" AS "eventType",
event."dedupe_key" AS "eventDedupeKey",
event."actor_type" AS "eventActorType",
event."actor_id" AS "eventActorId",
event."step_run_id" AS "eventStepRunId",
event."payload" AS "eventPayload",
event."created_at_ms" AS "eventCreatedAtMs"
FROM "StepRunMutations" AS mutation
JOIN "RunEvents" AS event
ON event."id" = mutation."event_id"
WHERE mutation."mutation_id" = ?
LIMIT 2`,
)
.all(mutation.mutationId) as Row[];
if (stored.length > 1) {
throw new StepRunRepositoryUnavailableError();
}
if (stored[0]) {
const result = storedMutationResult(stored[0], mutation);
this.client.exec('COMMIT');
began = false;
return result;
}
const run = this.client
.prepare(
`SELECT "status", "version",
"event_sequence" AS "eventSequence"
FROM "Runs" WHERE "id" = ? LIMIT 2`,
)
.all(mutation.runId) as Row[];
if (
run.length !== 1 ||
requiredInteger(run[0]!, 'version') !==
mutation.expectedRunVersion ||
requiredInteger(run[0]!, 'eventSequence') !==
mutation.expectedRunEventSequence
) {
throw new StepRunFenceConflictError();
}
if (TERMINAL_RUN_STATUSES.has(requiredText(run[0]!, 'status'))) {
throw new StepRunStateConflictError();
}
const current = this.findStoredById(mutation.stepRun.id);
const resolution = resolveStepRunMutation(current, mutation);
if (resolution === 'existing') {
throw new StepRunRepositoryUnavailableError();
}
if (mutation.expectedStepRunVersion === null) {
const count = this.client
.prepare(
`SELECT COUNT(*) AS "count"
FROM "StepRuns" WHERE "run_id" = ?`,
)
.get(mutation.runId) as Row | undefined;
if (
!count ||
requiredInteger(count, 'count') >= MAX_STEP_RUNS_PER_RUN
) {
throw new StepRunStateConflictError();
}
if (mutation.stepRun.parentStepRunId !== null) {
const parent = this.client
.prepare(
`SELECT 1 AS "present" FROM "StepRuns"
WHERE "id" = ? AND "run_id" = ? LIMIT 1`,
)
.get(
mutation.stepRun.parentStepRunId,
mutation.runId,
) as Row | undefined;
if (!parent) throw new StepRunStateConflictError();
}
insertStepRun(this.client, mutation.stepRun);
} else {
updateStepRun(this.client, mutation);
}
const runResult = this.client
.prepare(
`UPDATE "Runs"
SET "version" = "version" + 1,
"event_sequence" = "event_sequence" + 1
WHERE "id" = ? AND "version" = ? AND "event_sequence" = ?`,
)
.run(
mutation.runId,
mutation.expectedRunVersion,
mutation.expectedRunEventSequence,
);
if (runResult.changes !== 1) {
throw new StepRunFenceConflictError();
}
appendRunEvent(this.client, mutation);
this.client
.prepare(
`INSERT INTO "StepRunMutations" (
"mutation_id", "mutation_digest", "run_id", "step_run_id",
"step_run_digest", "event_id", "event_sequence",
"run_version", "step_run_json", "committed_at_ms"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
CAST(unixepoch('subsec') * 1000 AS INTEGER))`,
)
.run(
mutation.mutationId,
mutation.mutationDigest,
mutation.runId,
mutation.stepRun.id,
mutation.stepRun.stepRunDigest,
mutation.event.id,
mutation.event.sequence,
mutation.expectedRunVersion + 1,
JSON.stringify(mutation.stepRun),
);
this.client.exec('COMMIT');
began = false;
return Object.freeze({
status: 'applied',
stepRun: mutation.stepRun,
runVersion: mutation.expectedRunVersion + 1,
runEventSequence: mutation.expectedRunEventSequence + 1,
});
} catch (error) {
if (began && this.client.isTransaction) {
try {
this.client.exec('ROLLBACK');
} catch {
// Preserve the original failure; the shared authority owns close.
}
}
throw error;
}
});
}
}
@@ -0,0 +1,72 @@
export interface LocalStepRunReferenceTrigger {
readonly name: string;
readonly tableName: 'RunAttempts' | 'RunEvents';
readonly sql: string;
}
export const LOCAL_STEP_RUN_REFERENCE_TRIGGERS = Object.freeze([
Object.freeze({
name: 'ql3_local_attempt_step_run_insert_guard',
tableName: 'RunAttempts',
sql: `
CREATE TRIGGER ql3_local_attempt_step_run_insert_guard
BEFORE INSERT ON "RunAttempts"
FOR EACH ROW WHEN NEW.step_run_id IS NOT NULL
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM "StepRuns"
WHERE id = NEW.step_run_id AND run_id = NEW.run_id
) THEN RAISE(ABORT, 'ql3 StepRun reference mismatch') END;
END
`.trim(),
}),
Object.freeze({
name: 'ql3_local_attempt_step_run_update_guard',
tableName: 'RunAttempts',
sql: `
CREATE TRIGGER ql3_local_attempt_step_run_update_guard
BEFORE UPDATE OF run_id, step_run_id ON "RunAttempts"
FOR EACH ROW WHEN NEW.step_run_id IS NOT NULL
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM "StepRuns"
WHERE id = NEW.step_run_id AND run_id = NEW.run_id
) THEN RAISE(ABORT, 'ql3 StepRun reference mismatch') END;
END
`.trim(),
}),
Object.freeze({
name: 'ql3_local_event_step_run_insert_guard',
tableName: 'RunEvents',
sql: `
CREATE TRIGGER ql3_local_event_step_run_insert_guard
BEFORE INSERT ON "RunEvents"
FOR EACH ROW WHEN NEW.step_run_id IS NOT NULL
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM "StepRuns"
WHERE id = NEW.step_run_id AND run_id = NEW.run_id
) THEN RAISE(ABORT, 'ql3 StepRun reference mismatch') END;
END
`.trim(),
}),
Object.freeze({
name: 'ql3_local_event_step_run_update_guard',
tableName: 'RunEvents',
sql: `
CREATE TRIGGER ql3_local_event_step_run_update_guard
BEFORE UPDATE OF run_id, step_run_id ON "RunEvents"
FOR EACH ROW WHEN NEW.step_run_id IS NOT NULL
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM "StepRuns"
WHERE id = NEW.step_run_id AND run_id = NEW.run_id
) THEN RAISE(ABORT, 'ql3 StepRun reference mismatch') END;
END
`.trim(),
}),
] satisfies readonly Readonly<LocalStepRunReferenceTrigger>[]);
export function normalizeLocalSqliteSchemaSql(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}