mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user