mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+840
@@ -0,0 +1,840 @@
|
||||
// PostgreSQL authorization authority for Plugin Package Workflow admission.
|
||||
import {
|
||||
RUN_CANCELLATION_REASONS,
|
||||
RUN_STATUSES,
|
||||
type PostgresClient,
|
||||
type PostgresPool,
|
||||
type RunCancellationReason,
|
||||
type RunStatus,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA,
|
||||
PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA,
|
||||
PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_SCHEMA,
|
||||
PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA,
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError,
|
||||
PluginPackageWorkflowAdministrationMutationConflictError,
|
||||
normalizeAuthorizedPluginPackageWorkflowRunEventList,
|
||||
normalizeAuthorizedPluginPackageWorkflowRunInspection,
|
||||
normalizeAuthorizedPluginPackageWorkflowRunList,
|
||||
normalizeAuthorizedPluginPackageWorkflowStepRunList,
|
||||
normalizeAuthorizedPluginPackageWorkflowAdmission,
|
||||
normalizePluginPackageWorkflowRunEventListResult,
|
||||
normalizePluginPackageWorkflowRunInspectionResult,
|
||||
normalizePluginPackageWorkflowRunListResult,
|
||||
normalizePluginPackageWorkflowStepRunListResult,
|
||||
type AuthorizedPluginPackageWorkflowAdmission,
|
||||
type AuthorizedPluginPackageWorkflowRunEventList,
|
||||
type AuthorizedPluginPackageWorkflowRunInspection,
|
||||
type AuthorizedPluginPackageWorkflowRunList,
|
||||
type AuthorizedPluginPackageWorkflowStepRunList,
|
||||
type PluginPackageWorkflowAdministrationRepository,
|
||||
type PluginPackageWorkflowRunEventListRepository,
|
||||
type PluginPackageWorkflowRunEventListResult,
|
||||
type PluginPackageWorkflowRunInspectionRepository,
|
||||
type PluginPackageWorkflowRunInspectionResult,
|
||||
type PluginPackageWorkflowRunListRepository,
|
||||
type PluginPackageWorkflowRunListResult,
|
||||
type PluginPackageWorkflowStepRunListItem,
|
||||
type PluginPackageWorkflowStepRunListRepository,
|
||||
type PluginPackageWorkflowStepRunListResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-administration';
|
||||
import type { PluginPackageWorkflowExecutionPlan } from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import {
|
||||
STEP_RUN_STATUSES,
|
||||
type StepRunStatus,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import { PostgresPluginPackageWorkflowAdmissionRepository } from './pluginPackageWorkflowAdmissionRepository';
|
||||
import {
|
||||
configureAdministrationTransaction,
|
||||
insertAdministrationAudit,
|
||||
requiredInteger,
|
||||
requiredString,
|
||||
rollbackAdministrationTransaction,
|
||||
} from '../../repository/administrationSupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const API_CREDENTIAL_AUTHENTICATION =
|
||||
/^api_credential:([A-Za-z0-9][A-Za-z0-9._:-]{0,63}):([1-9]\d*)$/;
|
||||
|
||||
interface WorkflowAuthorizationContext {
|
||||
readonly projectId: string;
|
||||
readonly actor: AuthorizedPluginPackageWorkflowAdmission['actor'];
|
||||
readonly fence: AuthorizedPluginPackageWorkflowAdmission['fence'];
|
||||
readonly audit: AuthorizedPluginPackageWorkflowAdmission['audit'];
|
||||
}
|
||||
|
||||
function integer(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value)) return value;
|
||||
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nullableInteger(row: Row, name: string): number | null {
|
||||
if (row[name] === null) return null;
|
||||
return requiredInteger(row, name);
|
||||
}
|
||||
|
||||
function nullableString(row: Row, name: string): string | null {
|
||||
if (row[name] === null) return null;
|
||||
return requiredString(row, name);
|
||||
}
|
||||
|
||||
function mutationConflict(): never {
|
||||
throw new PluginPackageWorkflowAdministrationMutationConflictError();
|
||||
}
|
||||
|
||||
function fenceConflict(): never {
|
||||
throw new PluginPackageWorkflowAdministrationAuthorizationFenceConflictError();
|
||||
}
|
||||
|
||||
async function confirmCredential(
|
||||
client: PostgresClient,
|
||||
authorization: Readonly<WorkflowAuthorizationContext>,
|
||||
): Promise<void> {
|
||||
const match = API_CREDENTIAL_AUTHENTICATION.exec(
|
||||
authorization.audit.authenticationId ?? '',
|
||||
);
|
||||
const credentialVersion = integer(match?.[2]);
|
||||
if (!match || credentialVersion === null || credentialVersion < 1) {
|
||||
return fenceConflict();
|
||||
}
|
||||
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [
|
||||
`ql3-api-credential:${match[1]}`,
|
||||
]);
|
||||
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [
|
||||
`ql3-identity:${authorization.actor.type}:${authorization.actor.id}`,
|
||||
]);
|
||||
const result = await client.query<Row>(
|
||||
`SELECT credential.version,
|
||||
credential.state,
|
||||
credential.subject_type AS "subjectType",
|
||||
credential.subject_id AS "subjectId",
|
||||
credential.not_before_at_ms AS "notBeforeAtMs",
|
||||
credential.expires_at_ms AS "expiresAtMs",
|
||||
subject.status AS "subjectStatus",
|
||||
floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AS "nowMs"
|
||||
FROM "ql3"."api_credentials" AS credential
|
||||
JOIN "ql3"."identity_subjects" AS subject
|
||||
ON subject.subject_type = credential.subject_type
|
||||
AND subject.subject_id = credential.subject_id
|
||||
WHERE credential.credential_id = $1
|
||||
ORDER BY credential.version DESC
|
||||
LIMIT 1`,
|
||||
[match[1]],
|
||||
);
|
||||
const row = result.rows.length === 1 ? result.rows[0]! : null;
|
||||
const nowMs = integer(row?.nowMs);
|
||||
if (
|
||||
!row ||
|
||||
result.rows.length !== 1 ||
|
||||
integer(row.version) !== credentialVersion ||
|
||||
row.state !== 'active' ||
|
||||
row.subjectStatus !== 'active' ||
|
||||
row.subjectType !== authorization.actor.type ||
|
||||
row.subjectId !== authorization.actor.id ||
|
||||
nowMs === null ||
|
||||
(integer(row.notBeforeAtMs) ?? Number.MAX_SAFE_INTEGER) > nowMs ||
|
||||
(integer(row.expiresAtMs) ?? -1) <= nowMs
|
||||
) {
|
||||
return fenceConflict();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmProjectPolicyFence(
|
||||
client: PostgresClient,
|
||||
authorization: Readonly<WorkflowAuthorizationContext>,
|
||||
): Promise<void> {
|
||||
const project = await client.query<Row>(
|
||||
`SELECT status, version
|
||||
FROM "ql3"."projects"
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
FOR SHARE`,
|
||||
[authorization.projectId],
|
||||
);
|
||||
const projectRow = project.rows.length === 1 ? project.rows[0]! : null;
|
||||
if (
|
||||
!projectRow ||
|
||||
projectRow.status !== 'active' ||
|
||||
integer(projectRow.version) !== authorization.fence.projectVersion
|
||||
) {
|
||||
return fenceConflict();
|
||||
}
|
||||
const binding = await client.query<Row>(
|
||||
`SELECT version, state
|
||||
FROM "ql3"."project_role_bindings"
|
||||
WHERE project_id = $1
|
||||
AND subject_type = $2
|
||||
AND subject_id = $3
|
||||
ORDER BY version DESC
|
||||
LIMIT 1`,
|
||||
[authorization.projectId, authorization.actor.type, authorization.actor.id],
|
||||
);
|
||||
const bindingRow = binding.rows.length === 1 ? binding.rows[0]! : null;
|
||||
if (
|
||||
!bindingRow ||
|
||||
bindingRow.state !== 'active' ||
|
||||
integer(bindingRow.version) !== authorization.fence.bindingVersion
|
||||
) {
|
||||
return fenceConflict();
|
||||
}
|
||||
}
|
||||
|
||||
async function insertAtomicAudit(
|
||||
client: PostgresClient,
|
||||
admission: Readonly<AuthorizedPluginPackageWorkflowAdmission>,
|
||||
replay: boolean,
|
||||
): Promise<void> {
|
||||
if (replay) return;
|
||||
const audit = admission.audit;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."security_audit_events" (
|
||||
event_id, request_id, operation_id, project_id,
|
||||
subject_type, subject_id, authentication_id, outcome, reasons,
|
||||
project_version, binding_version, occurred_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12
|
||||
)`,
|
||||
[
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds API credential, Project Policy fence and mutation-audit checks to the
|
||||
* PostgreSQL Workflow admission transaction used by cluster-control.
|
||||
*/
|
||||
export class PostgresAuthorizedPluginPackageWorkflowAdmissionRepository
|
||||
implements PluginPackageWorkflowAdministrationRepository
|
||||
{
|
||||
private readonly admissions: PostgresPluginPackageWorkflowAdmissionRepository;
|
||||
|
||||
constructor(pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
|
||||
'PostgreSQL pool is invalid',
|
||||
);
|
||||
}
|
||||
this.admissions = new PostgresPluginPackageWorkflowAdmissionRepository(
|
||||
pool,
|
||||
);
|
||||
}
|
||||
|
||||
findPlanByPlanId(
|
||||
planId: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowExecutionPlan> | null> {
|
||||
return this.admissions.findPlanByPlanId(planId);
|
||||
}
|
||||
|
||||
async admitAuthorized(input: AuthorizedPluginPackageWorkflowAdmission) {
|
||||
const admission = normalizeAuthorizedPluginPackageWorkflowAdmission(input);
|
||||
const authorization = Object.freeze({
|
||||
projectId: admission.plan.target.projectId,
|
||||
actor: admission.actor,
|
||||
fence: admission.fence,
|
||||
audit: admission.audit,
|
||||
});
|
||||
return this.admissions.admit(admission.plan, async ({ client, replay }) => {
|
||||
await confirmCredential(client, authorization);
|
||||
await confirmProjectPolicyFence(client, authorization);
|
||||
await insertAtomicAudit(client, admission, replay);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the low-sensitive Package-bound Workflow Run projection from one
|
||||
* serializable PostgreSQL snapshot after revalidating the credential and the
|
||||
* latest Project Policy fence. It deliberately does not expose plan, task,
|
||||
* attempt, executor, error, input/output or Secret material.
|
||||
*/
|
||||
export class PostgresAuthorizedPluginPackageWorkflowRunInspectionRepository
|
||||
implements PluginPackageWorkflowRunInspectionRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
|
||||
'PostgreSQL pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async inspectRunAuthorized(
|
||||
input: AuthorizedPluginPackageWorkflowRunInspection,
|
||||
): Promise<Readonly<PluginPackageWorkflowRunInspectionResult>> {
|
||||
const inspection =
|
||||
normalizeAuthorizedPluginPackageWorkflowRunInspection(input);
|
||||
let client: PostgresClient | undefined;
|
||||
let began = false;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
await configureAdministrationTransaction(client);
|
||||
began = true;
|
||||
await confirmCredential(client, inspection);
|
||||
await confirmProjectPolicyFence(client, inspection);
|
||||
|
||||
const targetRows = await client.query<Row>(
|
||||
`SELECT admission.workflow_id AS "workflowId",
|
||||
admission.step_count AS "stepCount",
|
||||
run.status AS "runStatus",
|
||||
run.version AS "runVersion",
|
||||
run.event_sequence AS "eventSequence",
|
||||
run.created_at_ms AS "createdAtMs",
|
||||
run.queued_at_ms AS "queuedAtMs",
|
||||
run.started_at_ms AS "startedAtMs",
|
||||
run.finished_at_ms AS "finishedAtMs",
|
||||
run.cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
run.cancel_reason AS "cancelReason"
|
||||
FROM "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
JOIN "ql3"."runs" AS run
|
||||
ON run.id = admission.run_id
|
||||
AND run.project_id = admission.project_id
|
||||
WHERE admission.run_id = $1
|
||||
AND admission.project_id = $2
|
||||
AND admission.package_name = $3
|
||||
AND admission.workflow_id = $4
|
||||
LIMIT 2`,
|
||||
[
|
||||
inspection.runId,
|
||||
inspection.projectId,
|
||||
inspection.packageName,
|
||||
inspection.workflowId,
|
||||
],
|
||||
);
|
||||
if (targetRows.rows.length === 0) {
|
||||
await insertAdministrationAudit(client, inspection.audit);
|
||||
const missing = normalizePluginPackageWorkflowRunInspectionResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA,
|
||||
found: false,
|
||||
projectId: inspection.projectId,
|
||||
packageName: inspection.packageName,
|
||||
workflowId: inspection.workflowId,
|
||||
runId: inspection.runId,
|
||||
run: null,
|
||||
stepCount: null,
|
||||
stepStatusCounts: null,
|
||||
});
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return missing;
|
||||
}
|
||||
if (targetRows.rows.length !== 1) mutationConflict();
|
||||
const row = targetRows.rows[0]!;
|
||||
if (requiredString(row, 'workflowId') !== inspection.workflowId) {
|
||||
mutationConflict();
|
||||
}
|
||||
const stepCount = requiredInteger(row, 'stepCount');
|
||||
if (stepCount < 1 || stepCount > 128) mutationConflict();
|
||||
|
||||
const statusRows = await client.query<Row>(
|
||||
`SELECT status AS "stepStatus", COUNT(*) AS "statusCount"
|
||||
FROM "ql3"."step_runs"
|
||||
WHERE run_id = $1
|
||||
GROUP BY status
|
||||
ORDER BY status`,
|
||||
[inspection.runId],
|
||||
);
|
||||
const stepStatusCounts = Object.fromEntries(
|
||||
STEP_RUN_STATUSES.map((status) => [status, 0]),
|
||||
) as Record<StepRunStatus, number>;
|
||||
const observedStatuses = new Set<StepRunStatus>();
|
||||
for (const statusRow of statusRows.rows) {
|
||||
const status = requiredString(statusRow, 'stepStatus') as StepRunStatus;
|
||||
if (
|
||||
!STEP_RUN_STATUSES.includes(status) ||
|
||||
observedStatuses.has(status)
|
||||
) {
|
||||
mutationConflict();
|
||||
}
|
||||
observedStatuses.add(status);
|
||||
stepStatusCounts[status] = requiredInteger(statusRow, 'statusCount');
|
||||
}
|
||||
if (
|
||||
Object.values(stepStatusCounts).reduce(
|
||||
(total, count) => total + count,
|
||||
0,
|
||||
) !== stepCount
|
||||
) {
|
||||
mutationConflict();
|
||||
}
|
||||
|
||||
const runStatus = requiredString(row, 'runStatus') as RunStatus;
|
||||
const cancelReason = nullableString(
|
||||
row,
|
||||
'cancelReason',
|
||||
) as RunCancellationReason | null;
|
||||
if (
|
||||
!RUN_STATUSES.includes(runStatus) ||
|
||||
(cancelReason !== null &&
|
||||
!RUN_CANCELLATION_REASONS.includes(cancelReason))
|
||||
) {
|
||||
mutationConflict();
|
||||
}
|
||||
const result = normalizePluginPackageWorkflowRunInspectionResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA,
|
||||
found: true,
|
||||
projectId: inspection.projectId,
|
||||
packageName: inspection.packageName,
|
||||
workflowId: inspection.workflowId,
|
||||
runId: inspection.runId,
|
||||
run: {
|
||||
status: runStatus,
|
||||
version: requiredInteger(row, 'runVersion'),
|
||||
eventSequence: requiredInteger(row, 'eventSequence'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
queuedAtMs: nullableInteger(row, 'queuedAtMs'),
|
||||
startedAtMs: nullableInteger(row, 'startedAtMs'),
|
||||
finishedAtMs: nullableInteger(row, 'finishedAtMs'),
|
||||
cancelRequestedAtMs: nullableInteger(row, 'cancelRequestedAtMs'),
|
||||
cancelReason,
|
||||
},
|
||||
stepCount,
|
||||
stepStatusCounts,
|
||||
});
|
||||
await insertAdministrationAudit(client, inspection.audit);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (client && began) await rollbackAdministrationTransaction(client);
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageWorkflowAdministrationMutationConflictError();
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists one newest-first, low-sensitive Workflow Run history page from a
|
||||
* serializable authorization snapshot. The dedicated target/time index keeps
|
||||
* the query bounded even when a Package owns many other Workflows.
|
||||
*/
|
||||
export class PostgresAuthorizedPluginPackageWorkflowRunListRepository
|
||||
implements PluginPackageWorkflowRunListRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
|
||||
'PostgreSQL pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listRunsAuthorized(
|
||||
input: AuthorizedPluginPackageWorkflowRunList,
|
||||
): Promise<Readonly<PluginPackageWorkflowRunListResult>> {
|
||||
const query = normalizeAuthorizedPluginPackageWorkflowRunList(input);
|
||||
let client: PostgresClient | undefined;
|
||||
let began = false;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
await configureAdministrationTransaction(client);
|
||||
began = true;
|
||||
await confirmCredential(client, query);
|
||||
await confirmProjectPolicyFence(client, query);
|
||||
|
||||
const page = await client.query<Row>(
|
||||
`SELECT admission.run_id AS "runId",
|
||||
admission.step_count AS "stepCount",
|
||||
admission.admitted_at_ms AS "admittedAtMs",
|
||||
run.status AS "runStatus",
|
||||
run.version AS "runVersion",
|
||||
run.event_sequence AS "eventSequence",
|
||||
run.queued_at_ms AS "queuedAtMs",
|
||||
run.started_at_ms AS "startedAtMs",
|
||||
run.finished_at_ms AS "finishedAtMs",
|
||||
run.cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
run.cancel_reason AS "cancelReason"
|
||||
FROM "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
JOIN "ql3"."runs" AS run
|
||||
ON run.id = admission.run_id
|
||||
AND run.project_id = admission.project_id
|
||||
WHERE admission.project_id = $1
|
||||
AND admission.package_name = $2
|
||||
AND admission.workflow_id = $3
|
||||
AND ($4::bigint IS NULL OR admission.admitted_at_ms < $4 OR
|
||||
(admission.admitted_at_ms = $4 AND admission.run_id < $5))
|
||||
ORDER BY admission.admitted_at_ms DESC, admission.run_id DESC
|
||||
LIMIT $6`,
|
||||
[
|
||||
query.projectId,
|
||||
query.packageName,
|
||||
query.workflowId,
|
||||
query.after?.admittedAtMs ?? null,
|
||||
query.after?.runId ?? null,
|
||||
query.limit + 1,
|
||||
],
|
||||
);
|
||||
const truncated = page.rows.length > query.limit;
|
||||
const runs = page.rows.slice(0, query.limit).map((row) => ({
|
||||
runId: requiredString(row, 'runId'),
|
||||
status: requiredString(row, 'runStatus') as RunStatus,
|
||||
version: requiredInteger(row, 'runVersion'),
|
||||
eventSequence: requiredInteger(row, 'eventSequence'),
|
||||
stepCount: requiredInteger(row, 'stepCount'),
|
||||
admittedAtMs: requiredInteger(row, 'admittedAtMs'),
|
||||
queuedAtMs: nullableInteger(row, 'queuedAtMs'),
|
||||
startedAtMs: nullableInteger(row, 'startedAtMs'),
|
||||
finishedAtMs: nullableInteger(row, 'finishedAtMs'),
|
||||
cancelRequestedAtMs: nullableInteger(row, 'cancelRequestedAtMs'),
|
||||
cancelReason: nullableString(
|
||||
row,
|
||||
'cancelReason',
|
||||
) as RunCancellationReason | null,
|
||||
}));
|
||||
const last = runs.at(-1);
|
||||
const result = normalizePluginPackageWorkflowRunListResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_SCHEMA,
|
||||
projectId: query.projectId,
|
||||
packageName: query.packageName,
|
||||
workflowId: query.workflowId,
|
||||
after: query.after,
|
||||
runs,
|
||||
truncated,
|
||||
next:
|
||||
truncated && last
|
||||
? { admittedAtMs: last.admittedAtMs, runId: last.runId }
|
||||
: null,
|
||||
});
|
||||
await insertAdministrationAudit(client, query.audit);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (client && began) await rollbackAdministrationTransaction(client);
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageWorkflowAdministrationMutationConflictError();
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists one bounded low-sensitive StepRun page behind the exact Package-bound
|
||||
* Workflow target and current authorization fence. The runtime role only
|
||||
* appends the allowed audit and never receives audit read authority.
|
||||
*/
|
||||
export class PostgresAuthorizedPluginPackageWorkflowStepRunListRepository
|
||||
implements PluginPackageWorkflowStepRunListRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
|
||||
'PostgreSQL pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listStepRunsAuthorized(
|
||||
input: AuthorizedPluginPackageWorkflowStepRunList,
|
||||
): Promise<Readonly<PluginPackageWorkflowStepRunListResult>> {
|
||||
const query = normalizeAuthorizedPluginPackageWorkflowStepRunList(input);
|
||||
let client: PostgresClient | undefined;
|
||||
let began = false;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
await configureAdministrationTransaction(client);
|
||||
began = true;
|
||||
await confirmCredential(client, query);
|
||||
await confirmProjectPolicyFence(client, query);
|
||||
|
||||
const targets = await client.query<Row>(
|
||||
`SELECT admission.step_count AS "stepCount",
|
||||
(SELECT COUNT(*) FROM "ql3"."step_runs" AS observed
|
||||
WHERE observed.run_id = admission.run_id) AS "observedStepCount"
|
||||
FROM "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
JOIN "ql3"."runs" AS run
|
||||
ON run.id = admission.run_id
|
||||
AND run.project_id = admission.project_id
|
||||
WHERE admission.run_id = $1
|
||||
AND admission.project_id = $2
|
||||
AND admission.package_name = $3
|
||||
AND admission.workflow_id = $4
|
||||
LIMIT 2`,
|
||||
[query.runId, query.projectId, query.packageName, query.workflowId],
|
||||
);
|
||||
if (targets.rows.length === 0) {
|
||||
await insertAdministrationAudit(client, query.audit);
|
||||
const missing = normalizePluginPackageWorkflowStepRunListResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA,
|
||||
found: false,
|
||||
projectId: query.projectId,
|
||||
packageName: query.packageName,
|
||||
workflowId: query.workflowId,
|
||||
runId: query.runId,
|
||||
stepRuns: [],
|
||||
truncated: false,
|
||||
next: null,
|
||||
});
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return missing;
|
||||
}
|
||||
if (
|
||||
targets.rows.length !== 1 ||
|
||||
requiredInteger(targets.rows[0]!, 'stepCount') !==
|
||||
requiredInteger(targets.rows[0]!, 'observedStepCount')
|
||||
) {
|
||||
mutationConflict();
|
||||
}
|
||||
|
||||
const page = await client.query<Row>(
|
||||
`SELECT id AS "id",
|
||||
parent_step_run_id AS "parentStepRunId",
|
||||
step_key AS "stepKey",
|
||||
kind AS "kind",
|
||||
required AS "required",
|
||||
status AS "status",
|
||||
version AS "version",
|
||||
attempt_count AS "attemptCount",
|
||||
ready_at_ms AS "readyAtMs",
|
||||
started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs",
|
||||
result_code AS "resultCode",
|
||||
created_at_ms AS "createdAtMs",
|
||||
updated_at_ms AS "updatedAtMs"
|
||||
FROM "ql3"."step_runs"
|
||||
WHERE run_id = $1
|
||||
AND ($2::varchar IS NULL OR step_key > $3 OR
|
||||
(step_key = $3 AND id > $2))
|
||||
ORDER BY step_key, id
|
||||
LIMIT $4`,
|
||||
[
|
||||
query.runId,
|
||||
query.after?.id ?? null,
|
||||
query.after?.stepKey ?? '',
|
||||
query.limit + 1,
|
||||
],
|
||||
);
|
||||
const truncated = page.rows.length > query.limit;
|
||||
const stepRuns = page.rows.slice(0, query.limit).map((row) => {
|
||||
if (typeof row.required !== 'boolean') mutationConflict();
|
||||
return {
|
||||
id: requiredString(row, 'id'),
|
||||
parentStepRunId: nullableString(row, 'parentStepRunId'),
|
||||
stepKey: requiredString(row, 'stepKey'),
|
||||
kind: requiredString(
|
||||
row,
|
||||
'kind',
|
||||
) as PluginPackageWorkflowStepRunListItem['kind'],
|
||||
required: row.required,
|
||||
status: requiredString(row, 'status') as StepRunStatus,
|
||||
version: requiredInteger(row, 'version'),
|
||||
attemptCount: requiredInteger(row, 'attemptCount'),
|
||||
readyAtMs: nullableInteger(row, 'readyAtMs'),
|
||||
startedAtMs: nullableInteger(row, 'startedAtMs'),
|
||||
finishedAtMs: nullableInteger(row, 'finishedAtMs'),
|
||||
resultCode: nullableString(row, 'resultCode'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
updatedAtMs: requiredInteger(row, 'updatedAtMs'),
|
||||
};
|
||||
});
|
||||
const last = stepRuns.at(-1);
|
||||
const result = normalizePluginPackageWorkflowStepRunListResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA,
|
||||
found: true,
|
||||
projectId: query.projectId,
|
||||
packageName: query.packageName,
|
||||
workflowId: query.workflowId,
|
||||
runId: query.runId,
|
||||
stepRuns,
|
||||
truncated,
|
||||
next: truncated && last ? { stepKey: last.stepKey, id: last.id } : null,
|
||||
});
|
||||
await insertAdministrationAudit(client, query.audit);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (client && began) await rollbackAdministrationTransaction(client);
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageWorkflowAdministrationMutationConflictError();
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists one bounded content-free RunEvent page behind the exact Package-bound
|
||||
* Workflow target and current authorization fence.
|
||||
*/
|
||||
export class PostgresAuthorizedPluginPackageWorkflowRunEventListRepository
|
||||
implements PluginPackageWorkflowRunEventListRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
|
||||
'PostgreSQL pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listRunEventsAuthorized(
|
||||
input: AuthorizedPluginPackageWorkflowRunEventList,
|
||||
): Promise<Readonly<PluginPackageWorkflowRunEventListResult>> {
|
||||
const query = normalizeAuthorizedPluginPackageWorkflowRunEventList(input);
|
||||
let client: PostgresClient | undefined;
|
||||
let began = false;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
await configureAdministrationTransaction(client);
|
||||
began = true;
|
||||
await confirmCredential(client, query);
|
||||
await confirmProjectPolicyFence(client, query);
|
||||
|
||||
const targets = await client.query<Row>(
|
||||
`SELECT run.event_sequence AS "headSequence"
|
||||
FROM "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
JOIN "ql3"."runs" AS run
|
||||
ON run.id = admission.run_id
|
||||
AND run.project_id = admission.project_id
|
||||
WHERE admission.run_id = $1
|
||||
AND admission.project_id = $2
|
||||
AND admission.package_name = $3
|
||||
AND admission.workflow_id = $4
|
||||
LIMIT 2`,
|
||||
[query.runId, query.projectId, query.packageName, query.workflowId],
|
||||
);
|
||||
if (targets.rows.length === 0) {
|
||||
await insertAdministrationAudit(client, query.audit);
|
||||
const missing = normalizePluginPackageWorkflowRunEventListResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA,
|
||||
found: false,
|
||||
projectId: query.projectId,
|
||||
packageName: query.packageName,
|
||||
workflowId: query.workflowId,
|
||||
runId: query.runId,
|
||||
afterSequence: query.afterSequence,
|
||||
headSequence: null,
|
||||
events: [],
|
||||
truncated: false,
|
||||
nextAfterSequence: null,
|
||||
});
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return missing;
|
||||
}
|
||||
if (targets.rows.length !== 1) mutationConflict();
|
||||
const headSequence = requiredInteger(targets.rows[0]!, 'headSequence');
|
||||
const page = await client.query<Row>(
|
||||
`SELECT id AS "id",
|
||||
sequence AS "sequence",
|
||||
type AS "type",
|
||||
step_run_id AS "stepRunId",
|
||||
created_at_ms AS "createdAtMs"
|
||||
FROM "ql3"."run_events"
|
||||
WHERE run_id = $1 AND sequence > $2
|
||||
ORDER BY sequence, id
|
||||
LIMIT $3`,
|
||||
[query.runId, query.afterSequence, query.limit + 1],
|
||||
);
|
||||
const truncated = page.rows.length > query.limit;
|
||||
const events = page.rows.slice(0, query.limit).map((row) => ({
|
||||
id: requiredString(row, 'id'),
|
||||
sequence: requiredInteger(row, 'sequence'),
|
||||
type: requiredString(row, 'type'),
|
||||
stepRunId: nullableString(row, 'stepRunId'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
}));
|
||||
const lastSequence = events.at(-1)?.sequence ?? null;
|
||||
const result = normalizePluginPackageWorkflowRunEventListResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA,
|
||||
found: true,
|
||||
projectId: query.projectId,
|
||||
packageName: query.packageName,
|
||||
workflowId: query.workflowId,
|
||||
runId: query.runId,
|
||||
afterSequence: query.afterSequence,
|
||||
headSequence,
|
||||
events,
|
||||
truncated,
|
||||
nextAfterSequence: truncated ? lastSequence : null,
|
||||
});
|
||||
await insertAdministrationAudit(client, query.audit);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (client && began) await rollbackAdministrationTransaction(client);
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageWorkflowAdministrationMutationConflictError();
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+843
@@ -0,0 +1,843 @@
|
||||
// PostgreSQL authority for atomic Plugin Package Workflow admission.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
PostgresQueryable,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError,
|
||||
PluginPackageWorkflowAdministrationMutationConflictError,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-administration';
|
||||
import {
|
||||
normalizePluginPackageAutomationPublication,
|
||||
type PluginPackageAutomationPublication,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import {
|
||||
createPluginPackageWorkflowAdmissionBundle,
|
||||
InvalidPluginPackageWorkflowExecutionPlanError,
|
||||
normalizePluginPackageWorkflowAdmissionReceipt,
|
||||
normalizePluginPackageWorkflowExecutionPlan,
|
||||
pluginPackageWorkflowDefinitionDigest,
|
||||
PluginPackageWorkflowAdmissionConflictError,
|
||||
PluginPackageWorkflowAdmissionNotAllowedError,
|
||||
PluginPackageWorkflowAdmissionUnavailableError,
|
||||
type PluginPackageWorkflowAdmissionBundle,
|
||||
type PluginPackageWorkflowAdmissionReceipt,
|
||||
type PluginPackageWorkflowAdmissionRepository,
|
||||
type PluginPackageWorkflowExecutionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import {
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredJsonObject,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const WORKFLOW_RUN_STATUSES = new Set([
|
||||
'running',
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
]);
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackageWorkflowAdmissionUnavailableError {
|
||||
return new PluginPackageWorkflowAdmissionUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY.test(value)) {
|
||||
throw new InvalidPluginPackageWorkflowExecutionPlanError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof InvalidPluginPackageWorkflowExecutionPlanError ||
|
||||
error instanceof PluginPackageWorkflowAdmissionConflictError ||
|
||||
error instanceof PluginPackageWorkflowAdmissionNotAllowedError ||
|
||||
error instanceof PluginPackageWorkflowAdmissionUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (['23503', '23505', '23514'].includes(postgresSqlState(error) ?? '')) {
|
||||
return new PluginPackageWorkflowAdmissionConflictError(
|
||||
'durable Run, plan, StepRun, event, or receipt identity changed',
|
||||
);
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
export interface PostgresPluginPackageWorkflowAdmissionTransactionContext {
|
||||
readonly client: PostgresClient;
|
||||
readonly replay: boolean;
|
||||
readonly plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
readonly receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
|
||||
}
|
||||
|
||||
export type PostgresPluginPackageWorkflowAdmissionTransactionGuard = (
|
||||
context: Readonly<PostgresPluginPackageWorkflowAdmissionTransactionContext>,
|
||||
) => void | Promise<void>;
|
||||
|
||||
function json(value: unknown): string {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'string'
|
||||
) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => json(entry ?? null)).join(',')}]`;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const record = value as Readonly<Record<string, unknown>>;
|
||||
return `{${Object.keys(record)
|
||||
.filter((key) => record[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${json(record[key])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalText(row: Row, key: string): string | undefined {
|
||||
const value = row[key];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return integer(row, key);
|
||||
}
|
||||
|
||||
function exactArray(
|
||||
left: readonly string[],
|
||||
right: readonly string[],
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value, index) => value === right[index])
|
||||
);
|
||||
}
|
||||
|
||||
function parseStored(row: Row): Readonly<{
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>;
|
||||
}> {
|
||||
try {
|
||||
const plan = normalizePluginPackageWorkflowExecutionPlan(
|
||||
postgresRequiredJsonObject(
|
||||
row.planJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageWorkflowExecutionPlan,
|
||||
);
|
||||
const receipt = normalizePluginPackageWorkflowAdmissionReceipt(
|
||||
postgresRequiredJsonObject(
|
||||
row.receiptJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageWorkflowAdmissionReceipt,
|
||||
);
|
||||
const bundle = createPluginPackageWorkflowAdmissionBundle(plan);
|
||||
if (
|
||||
plan.planDigest !== text(row, 'planDigest') ||
|
||||
plan.planId !== text(row, 'planId') ||
|
||||
plan.runId !== text(row, 'runId') ||
|
||||
plan.target.projectId !== text(row, 'projectId') ||
|
||||
plan.target.packageName !== text(row, 'packageName') ||
|
||||
plan.target.installationId !== text(row, 'installationId') ||
|
||||
plan.target.lockDigest !== text(row, 'lockDigest') ||
|
||||
plan.target.generation !== integer(row, 'generation') ||
|
||||
plan.target.generationDigest !== text(row, 'generationDigest') ||
|
||||
plan.target.materializedRevisionDigest !==
|
||||
text(row, 'materializedRevisionDigest') ||
|
||||
plan.target.publicationDigest !== text(row, 'publicationDigest') ||
|
||||
plan.target.workflowId !== text(row, 'workflowId') ||
|
||||
plan.target.workflowDefinitionDigest !==
|
||||
text(row, 'workflowDefinitionDigest') ||
|
||||
plan.steps.length !== integer(row, 'stepCount') ||
|
||||
receipt.admittedAtMs !== integer(row, 'admittedAtMs') ||
|
||||
receipt.finalRunVersion !== integer(row, 'finalRunVersion') ||
|
||||
receipt.finalRunEventSequence !== integer(row, 'finalRunEventSequence') ||
|
||||
receipt.receiptDigest !== text(row, 'receiptDigest') ||
|
||||
json(bundle.receipt) !== json(receipt)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return Object.freeze({ plan, receipt, bundle });
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageWorkflowAdmissionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertStoredEvidence(
|
||||
queryable: PostgresQueryable,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): Promise<void> {
|
||||
const run = bundle.run;
|
||||
const storedRun = await queryable.query<Row>(
|
||||
`SELECT project_id AS "projectId", task_id AS "taskId",
|
||||
task_revision AS "taskRevision",
|
||||
task_snapshot_ref AS "taskSnapshotRef",
|
||||
trigger_type AS "triggerType",
|
||||
execution_origin AS "executionOrigin",
|
||||
execution_owner AS "executionOwner",
|
||||
request_id AS "requestId", status, version,
|
||||
event_sequence AS "eventSequence", priority,
|
||||
idempotency_key AS "idempotencyKey",
|
||||
created_at_ms AS "createdAtMs",
|
||||
started_at_ms AS "startedAtMs"
|
||||
FROM "ql3"."runs" WHERE id = $1`,
|
||||
[run.id],
|
||||
);
|
||||
const runRow = storedRun.rows.length === 1 ? storedRun.rows[0]! : null;
|
||||
const storedRunVersion = runRow ? integer(runRow, 'version') : -1;
|
||||
const storedEventSequence = runRow ? integer(runRow, 'eventSequence') : -1;
|
||||
if (
|
||||
!runRow ||
|
||||
text(runRow, 'projectId') !== run.projectId ||
|
||||
text(runRow, 'taskId') !== run.taskId ||
|
||||
text(runRow, 'taskRevision') !== run.taskRevision ||
|
||||
optionalText(runRow, 'taskSnapshotRef') !== run.taskSnapshotRef ||
|
||||
text(runRow, 'triggerType') !== run.triggerType ||
|
||||
text(runRow, 'executionOrigin') !== run.executionOrigin ||
|
||||
text(runRow, 'executionOwner') !== run.executionOwner ||
|
||||
optionalText(runRow, 'requestId') !== run.requestId ||
|
||||
!WORKFLOW_RUN_STATUSES.has(text(runRow, 'status')) ||
|
||||
storedRunVersion < run.version ||
|
||||
storedEventSequence < run.eventSequence ||
|
||||
storedRunVersion !== storedEventSequence ||
|
||||
integer(runRow, 'priority') !== run.priority ||
|
||||
optionalText(runRow, 'idempotencyKey') !== run.idempotencyKey ||
|
||||
integer(runRow, 'createdAtMs') !== run.createdAtMs ||
|
||||
optionalInteger(runRow, 'startedAtMs') !== run.startedAtMs
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
const storedEvents = await queryable.query<Row>(
|
||||
`SELECT id, sequence, type, dedupe_key AS "dedupeKey",
|
||||
actor_type AS "actorType", actor_id AS "actorId",
|
||||
step_run_id AS "stepRunId", payload,
|
||||
created_at_ms AS "createdAtMs"
|
||||
FROM "ql3"."run_events"
|
||||
WHERE run_id = $1 AND sequence <= $2
|
||||
ORDER BY sequence`,
|
||||
[run.id, bundle.receipt.finalRunEventSequence],
|
||||
);
|
||||
const expectedEvents = [
|
||||
bundle.admissionEvent,
|
||||
...bundle.stepMutations.map(({ event }) => event),
|
||||
];
|
||||
if (
|
||||
storedEvents.rows.length !== expectedEvents.length ||
|
||||
storedEvents.rows.some((row, index) => {
|
||||
const event = expectedEvents[index]!;
|
||||
return (
|
||||
text(row, 'id') !== event.id ||
|
||||
integer(row, 'sequence') !== event.sequence ||
|
||||
text(row, 'type') !== event.type ||
|
||||
optionalText(row, 'dedupeKey') !== event.dedupeKey ||
|
||||
text(row, 'actorType') !== event.actorType ||
|
||||
optionalText(row, 'actorId') !== event.actorId ||
|
||||
optionalText(row, 'stepRunId') !== event.stepRunId ||
|
||||
json(row.payload) !== json(event.payload) ||
|
||||
integer(row, 'createdAtMs') !== event.createdAtMs
|
||||
);
|
||||
})
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
for (const mutation of bundle.stepMutations) {
|
||||
const stored = await queryable.query<Row>(
|
||||
`SELECT
|
||||
admission_step.step_run_id AS "stepRunId",
|
||||
admission_step.task_id AS "taskId",
|
||||
admission_step.task_definition_ref AS "taskDefinitionRef",
|
||||
admission_step.task_definition_digest AS "taskDefinitionDigest",
|
||||
admission_step.needs_json AS "needsJson",
|
||||
admission_step.initial_status AS "initialStatus",
|
||||
admission_step.mutation_id AS "mutationId",
|
||||
admission_step.event_id AS "eventId",
|
||||
runtime.step_key AS "currentStepKey",
|
||||
runtime.kind AS "currentKind",
|
||||
runtime.definition_ref AS "currentDefinitionRef",
|
||||
runtime.definition_digest AS "currentDefinitionDigest",
|
||||
runtime.required AS "currentRequired",
|
||||
runtime.status AS "currentStatus",
|
||||
runtime.version AS "currentVersion",
|
||||
runtime.last_mutation_id AS "currentLastMutationId",
|
||||
runtime.step_run_digest AS "currentStepRunDigest",
|
||||
runtime.step_run_json AS "currentStepRunJson",
|
||||
mutation.mutation_digest AS "mutationDigest",
|
||||
mutation.event_sequence AS "eventSequence",
|
||||
mutation.run_version AS "runVersion",
|
||||
mutation.step_run_digest AS "initialStepRunDigest",
|
||||
mutation.step_run_json AS "initialStepRunJson"
|
||||
FROM "ql3"."plugin_package_workflow_admission_steps"
|
||||
AS admission_step
|
||||
JOIN "ql3"."step_runs" AS runtime
|
||||
ON runtime.run_id = admission_step.run_id
|
||||
AND runtime.id = admission_step.step_run_id
|
||||
JOIN "ql3"."step_run_mutations" AS mutation
|
||||
ON mutation.mutation_id = admission_step.mutation_id
|
||||
WHERE admission_step.plan_digest = $1
|
||||
AND admission_step.step_key = $2`,
|
||||
[bundle.plan.planDigest, mutation.stepRun.stepKey],
|
||||
);
|
||||
const row = stored.rows.length === 1 ? stored.rows[0]! : null;
|
||||
const planStep = bundle.plan.steps.find(
|
||||
({ stepKey }) => stepKey === mutation.stepRun.stepKey,
|
||||
);
|
||||
let currentStepRun: Readonly<StepRunRecord> | null = null;
|
||||
if (row) {
|
||||
try {
|
||||
currentStepRun = normalizeStepRunRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.currentStepRunJson,
|
||||
unavailable,
|
||||
) as unknown as StepRunRecord,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
if (
|
||||
!row ||
|
||||
!planStep ||
|
||||
!currentStepRun ||
|
||||
text(row, 'stepRunId') !== mutation.stepRun.id ||
|
||||
text(row, 'taskId') !== planStep.taskId ||
|
||||
text(row, 'taskDefinitionRef') !== planStep.taskDefinitionRef ||
|
||||
text(row, 'taskDefinitionDigest') !== planStep.taskDefinitionDigest ||
|
||||
json(row.needsJson) !== json(planStep.needs) ||
|
||||
text(row, 'initialStatus') !== planStep.initialStatus ||
|
||||
text(row, 'mutationId') !== mutation.mutationId ||
|
||||
text(row, 'eventId') !== mutation.event.id ||
|
||||
text(row, 'mutationDigest') !== mutation.mutationDigest ||
|
||||
integer(row, 'eventSequence') !== mutation.event.sequence ||
|
||||
integer(row, 'runVersion') !== mutation.expectedRunVersion + 1 ||
|
||||
text(row, 'initialStepRunDigest') !== mutation.stepRun.stepRunDigest ||
|
||||
json(row.initialStepRunJson) !== json(mutation.stepRun) ||
|
||||
currentStepRun.id !== mutation.stepRun.id ||
|
||||
currentStepRun.runId !== mutation.runId ||
|
||||
currentStepRun.stepKey !== planStep.stepKey ||
|
||||
currentStepRun.kind !== 'task' ||
|
||||
currentStepRun.definitionRef !== planStep.taskDefinitionRef ||
|
||||
currentStepRun.definitionDigest !== planStep.taskDefinitionDigest ||
|
||||
currentStepRun.required !== planStep.required ||
|
||||
currentStepRun.version < mutation.stepRun.version ||
|
||||
text(row, 'currentStepKey') !== currentStepRun.stepKey ||
|
||||
text(row, 'currentKind') !== currentStepRun.kind ||
|
||||
text(row, 'currentDefinitionRef') !== currentStepRun.definitionRef ||
|
||||
text(row, 'currentDefinitionDigest') !==
|
||||
currentStepRun.definitionDigest ||
|
||||
row.currentRequired !== currentStepRun.required ||
|
||||
text(row, 'currentStatus') !== currentStepRun.status ||
|
||||
integer(row, 'currentVersion') !== currentStepRun.version ||
|
||||
text(row, 'currentLastMutationId') !== currentStepRun.lastMutationId ||
|
||||
text(row, 'currentStepRunDigest') !== currentStepRun.stepRunDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findStored(
|
||||
queryable: PostgresQueryable,
|
||||
column: 'plan_id' | 'run_id',
|
||||
value: string,
|
||||
): Promise<ReturnType<typeof parseStored> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT
|
||||
plan_digest AS "planDigest", plan_id AS "planId",
|
||||
run_id AS "runId", project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
installation_id AS "installationId",
|
||||
lock_digest AS "lockDigest", generation,
|
||||
generation_digest AS "generationDigest",
|
||||
materialized_revision_digest AS "materializedRevisionDigest",
|
||||
publication_digest AS "publicationDigest",
|
||||
workflow_id AS "workflowId",
|
||||
workflow_definition_digest AS "workflowDefinitionDigest",
|
||||
step_count AS "stepCount", admitted_at_ms AS "admittedAtMs",
|
||||
final_run_version AS "finalRunVersion",
|
||||
final_run_event_sequence AS "finalRunEventSequence",
|
||||
receipt_digest AS "receiptDigest",
|
||||
plan_json AS "planJson", receipt_json AS "receiptJson"
|
||||
FROM "ql3"."plugin_package_workflow_admissions"
|
||||
WHERE ${column} = $1
|
||||
LIMIT 2`,
|
||||
[value],
|
||||
);
|
||||
if (result.rows.length > 1) throw unavailable();
|
||||
if (!result.rows[0]) return null;
|
||||
const stored = parseStored(result.rows[0]);
|
||||
await assertStoredEvidence(queryable, stored.bundle);
|
||||
return stored;
|
||||
}
|
||||
|
||||
function assertSnapshot(
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>,
|
||||
row: Row,
|
||||
): void {
|
||||
let publication: Readonly<PluginPackageAutomationPublication>;
|
||||
let resources: unknown;
|
||||
try {
|
||||
publication = normalizePluginPackageAutomationPublication(
|
||||
postgresRequiredJsonObject(
|
||||
row.publicationJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageAutomationPublication,
|
||||
);
|
||||
resources = postgresRequiredJsonObject(
|
||||
row.revisionJson,
|
||||
unavailable,
|
||||
).resources;
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (!Array.isArray(resources)) throw unavailable();
|
||||
const target = plan.target;
|
||||
const workflow = publication.definitions.workflows.find(
|
||||
({ id }) => id === target.workflowId,
|
||||
);
|
||||
if (
|
||||
publication.publicationDigest !== target.publicationDigest ||
|
||||
publication.target.projectId !== target.projectId ||
|
||||
publication.target.packageName !== target.packageName ||
|
||||
publication.target.installationId !== target.installationId ||
|
||||
publication.target.lockDigest !== target.lockDigest ||
|
||||
publication.target.generation !== target.generation ||
|
||||
publication.target.generationDigest !== target.generationDigest ||
|
||||
publication.target.materializedRevisionDigest !==
|
||||
target.materializedRevisionDigest ||
|
||||
publication.state !== 'active' ||
|
||||
!workflow ||
|
||||
!workflow.enabled ||
|
||||
pluginPackageWorkflowDefinitionDigest(workflow) !==
|
||||
target.workflowDefinitionDigest ||
|
||||
workflow.steps.length !== plan.steps.length
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionConflictError(
|
||||
'the exact Workflow publication drifted',
|
||||
);
|
||||
}
|
||||
for (const step of plan.steps) {
|
||||
const workflowStep = workflow.steps.find(({ id }) => id === step.stepKey);
|
||||
const matches = resources.filter((resource) => {
|
||||
if (!resource || typeof resource !== 'object') return false;
|
||||
const candidate = resource as {
|
||||
kind?: unknown;
|
||||
sourceDigest?: unknown;
|
||||
value?: { id?: unknown; enabled?: unknown };
|
||||
};
|
||||
return (
|
||||
candidate.kind === 'task' &&
|
||||
candidate.sourceDigest === step.taskDefinitionDigest &&
|
||||
candidate.value?.id === step.taskId &&
|
||||
candidate.value.enabled === true
|
||||
);
|
||||
});
|
||||
if (
|
||||
!workflowStep ||
|
||||
workflowStep.task !== step.taskId ||
|
||||
!exactArray(workflowStep.needs, step.needs) ||
|
||||
step.initialStatus !==
|
||||
(workflowStep.needs.length === 0 ? 'ready' : 'pending') ||
|
||||
step.taskDefinitionRef !==
|
||||
`plugin-package:${target.materializedRevisionDigest}:task:${step.taskId}` ||
|
||||
matches.length !== 1
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionConflictError(
|
||||
'the exact Workflow step or Task evidence drifted',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function insertRun(
|
||||
client: PostgresClient,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): Promise<void> {
|
||||
const run = bundle.run;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."runs" (
|
||||
id, project_id, task_id, task_revision, task_snapshot_ref,
|
||||
trigger_type, execution_origin, execution_owner, request_id,
|
||||
status, version, event_sequence, priority, idempotency_key,
|
||||
created_at_ms, started_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
|
||||
$15, $16
|
||||
)`,
|
||||
[
|
||||
run.id,
|
||||
run.projectId,
|
||||
run.taskId,
|
||||
run.taskRevision,
|
||||
run.taskSnapshotRef ?? null,
|
||||
run.triggerType,
|
||||
run.executionOrigin,
|
||||
run.executionOwner,
|
||||
run.requestId ?? null,
|
||||
run.status,
|
||||
run.version,
|
||||
run.eventSequence,
|
||||
run.priority,
|
||||
run.idempotencyKey ?? null,
|
||||
run.createdAtMs,
|
||||
run.startedAtMs ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function insertEvent(
|
||||
client: PostgresClient,
|
||||
event: Readonly<PluginPackageWorkflowAdmissionBundle['admissionEvent']>,
|
||||
stepRunId: string | null,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."run_events" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9::jsonb, $10)`,
|
||||
[
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
stepRunId,
|
||||
json(event.payload),
|
||||
event.createdAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function insertStepEvidence(
|
||||
client: PostgresClient,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): Promise<void> {
|
||||
for (const mutation of bundle.stepMutations) {
|
||||
const step = mutation.stepRun;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."step_runs" (
|
||||
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 (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
|
||||
$15, $16, $17, $18, $19, $20, $21, $22, $23, $24::jsonb
|
||||
)`,
|
||||
[
|
||||
step.id,
|
||||
step.runId,
|
||||
step.parentStepRunId,
|
||||
step.stepKey,
|
||||
step.kind,
|
||||
step.definitionRef,
|
||||
step.definitionDigest,
|
||||
step.required,
|
||||
step.status,
|
||||
step.version,
|
||||
step.attemptCount,
|
||||
step.inputRef,
|
||||
step.outputRef,
|
||||
step.approvalRequestId,
|
||||
step.readyAtMs,
|
||||
step.startedAtMs,
|
||||
step.finishedAtMs,
|
||||
step.resultCode,
|
||||
step.errorSummary,
|
||||
step.createdAtMs,
|
||||
step.updatedAtMs,
|
||||
step.lastMutationId,
|
||||
step.stepRunDigest,
|
||||
json(step),
|
||||
],
|
||||
);
|
||||
await insertEvent(client, mutation.event, step.id);
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."step_run_mutations" (
|
||||
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
|
||||
[
|
||||
mutation.mutationId,
|
||||
mutation.mutationDigest,
|
||||
mutation.runId,
|
||||
step.id,
|
||||
step.stepRunDigest,
|
||||
mutation.event.id,
|
||||
mutation.event.sequence,
|
||||
mutation.expectedRunVersion + 1,
|
||||
json(step),
|
||||
bundle.receipt.admittedAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function insertAdmission(
|
||||
client: PostgresClient,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): Promise<void> {
|
||||
const { plan, receipt } = bundle;
|
||||
const target = plan.target;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_workflow_admissions" (
|
||||
plan_digest, plan_id, run_id, project_id, package_name,
|
||||
installation_id, lock_digest, generation, generation_digest,
|
||||
materialized_revision_digest, publication_digest, workflow_id,
|
||||
workflow_definition_digest, step_count, admitted_at_ms,
|
||||
final_run_version, final_run_event_sequence, receipt_digest,
|
||||
plan_json, receipt_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
|
||||
$15, $16, $17, $18, $19::jsonb, $20::jsonb
|
||||
)`,
|
||||
[
|
||||
plan.planDigest,
|
||||
plan.planId,
|
||||
plan.runId,
|
||||
target.projectId,
|
||||
target.packageName,
|
||||
target.installationId,
|
||||
target.lockDigest,
|
||||
target.generation,
|
||||
target.generationDigest,
|
||||
target.materializedRevisionDigest,
|
||||
target.publicationDigest,
|
||||
target.workflowId,
|
||||
target.workflowDefinitionDigest,
|
||||
plan.steps.length,
|
||||
receipt.admittedAtMs,
|
||||
receipt.finalRunVersion,
|
||||
receipt.finalRunEventSequence,
|
||||
receipt.receiptDigest,
|
||||
json(plan),
|
||||
json(receipt),
|
||||
],
|
||||
);
|
||||
for (const step of plan.steps) {
|
||||
const mutation = bundle.stepMutations.find(
|
||||
({ stepRun }) => stepRun.stepKey === step.stepKey,
|
||||
);
|
||||
if (!mutation) throw unavailable();
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_workflow_admission_steps" (
|
||||
plan_digest, run_id, step_key, step_run_id, task_id,
|
||||
task_definition_ref, task_definition_digest, needs_json,
|
||||
initial_status, mutation_id, event_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11
|
||||
)`,
|
||||
[
|
||||
plan.planDigest,
|
||||
plan.runId,
|
||||
step.stepKey,
|
||||
step.stepRunId,
|
||||
step.taskId,
|
||||
step.taskDefinitionRef,
|
||||
step.taskDefinitionDigest,
|
||||
json(step.needs),
|
||||
step.initialStatus,
|
||||
mutation.mutationId,
|
||||
mutation.event.id,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageWorkflowAdmissionRepository
|
||||
implements PluginPackageWorkflowAdmissionRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async findByPlanId(
|
||||
planIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowAdmissionReceipt> | null> {
|
||||
const planId = identity(planIdValue, 'planId');
|
||||
try {
|
||||
return (await findStored(this.pool, 'plan_id', planId))?.receipt ?? null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findByRunId(
|
||||
runIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowAdmissionReceipt> | null> {
|
||||
const runId = identity(runIdValue, 'runId');
|
||||
try {
|
||||
return (await findStored(this.pool, 'run_id', runId))?.receipt ?? null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findPlanByPlanId(
|
||||
planIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowExecutionPlan> | null> {
|
||||
const planId = identity(planIdValue, 'planId');
|
||||
try {
|
||||
return (await findStored(this.pool, 'plan_id', planId))?.plan ?? null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async admit(
|
||||
planValue: Readonly<PluginPackageWorkflowExecutionPlan>,
|
||||
transactionGuard?: PostgresPluginPackageWorkflowAdmissionTransactionGuard,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
|
||||
}>
|
||||
> {
|
||||
if (
|
||||
transactionGuard !== undefined &&
|
||||
typeof transactionGuard !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowExecutionPlanError(
|
||||
'transaction guard is invalid',
|
||||
);
|
||||
}
|
||||
const plan = normalizePluginPackageWorkflowExecutionPlan(planValue);
|
||||
const bundle = createPluginPackageWorkflowAdmissionBundle(plan);
|
||||
let client: PostgresClient | undefined;
|
||||
let began = false;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const existing = await findStored(client, 'plan_id', plan.planId);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.plan.planDigest !== plan.planDigest ||
|
||||
json(existing.plan) !== json(plan)
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionConflictError(
|
||||
'planId is already bound to another plan',
|
||||
);
|
||||
}
|
||||
await transactionGuard?.(
|
||||
Object.freeze({
|
||||
client,
|
||||
replay: true,
|
||||
plan: existing.plan,
|
||||
receipt: existing.receipt,
|
||||
}),
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
receipt: existing.receipt,
|
||||
});
|
||||
}
|
||||
await transactionGuard?.(
|
||||
Object.freeze({
|
||||
client,
|
||||
replay: false,
|
||||
plan,
|
||||
receipt: bundle.receipt,
|
||||
}),
|
||||
);
|
||||
const snapshot = await client.query<Row>(
|
||||
`SELECT publication_json AS "publicationJson",
|
||||
revision_json AS "revisionJson"
|
||||
FROM "ql3"."plugin_package_workflow_admission_snapshot"(
|
||||
$1, $2, $3
|
||||
)`,
|
||||
[
|
||||
plan.target.projectId,
|
||||
plan.target.packageName,
|
||||
plan.target.publicationDigest,
|
||||
],
|
||||
);
|
||||
if (snapshot.rows.length === 0) {
|
||||
throw new PluginPackageWorkflowAdmissionNotAllowedError();
|
||||
}
|
||||
if (snapshot.rows.length !== 1) throw unavailable();
|
||||
assertSnapshot(plan, snapshot.rows[0]!);
|
||||
await insertRun(client, bundle);
|
||||
await insertEvent(client, bundle.admissionEvent, null);
|
||||
await insertStepEvidence(client, bundle);
|
||||
await insertAdmission(client, bundle);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
receipt: bundle.receipt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client && began) await rollbackPostgresDefinitionTransaction(client);
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+736
@@ -0,0 +1,736 @@
|
||||
// PostgreSQL authority for advancing the Plugin Package Workflow frontier.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
RunRecord,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageWorkflowFrontierError,
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_FRONTIER_PAGE_SIZE,
|
||||
PluginPackageWorkflowFrontierConflictError,
|
||||
PluginPackageWorkflowFrontierUnavailableError,
|
||||
resolvePluginPackageWorkflowFrontier,
|
||||
type PluginPackageWorkflowFrontierAdvanceResult,
|
||||
type PluginPackageWorkflowFrontierCandidate,
|
||||
type PluginPackageWorkflowFrontierCursor,
|
||||
type PluginPackageWorkflowFrontierPage,
|
||||
type PluginPackageWorkflowFrontierRepository,
|
||||
type PluginPackageWorkflowTerminalStatus,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-frontier';
|
||||
import {
|
||||
normalizePluginPackageWorkflowExecutionPlan,
|
||||
type PluginPackageWorkflowExecutionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunMutation,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredBoolean,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const TERMINAL_RUN_STATUSES = new Set<PluginPackageWorkflowTerminalStatus>([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
]);
|
||||
|
||||
const RUN_SELECT = `
|
||||
id, project_id AS "projectId", task_id AS "taskId",
|
||||
task_revision AS "taskRevision", task_name AS "taskName",
|
||||
task_snapshot_ref AS "taskSnapshotRef", legacy_cron_id AS "legacyCronId",
|
||||
parent_run_id AS "parentRunId", retry_of_run_id AS "retryOfRunId",
|
||||
trigger_id AS "triggerId", trigger_type AS "triggerType",
|
||||
execution_origin AS "executionOrigin",
|
||||
execution_owner AS "executionOwner", triggered_by AS "triggeredBy",
|
||||
request_id AS "requestId", scheduled_for_ms AS "scheduledForMs",
|
||||
status, version, event_sequence AS "eventSequence", priority,
|
||||
idempotency_key AS "idempotencyKey", input_ref AS "inputRef",
|
||||
output_ref AS "outputRef", created_at_ms AS "createdAtMs",
|
||||
queued_at_ms AS "queuedAtMs", started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs",
|
||||
cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
cancel_reason AS "cancelReason", error_code AS "errorCode",
|
||||
error_summary AS "errorSummary"
|
||||
`.trim();
|
||||
|
||||
const STEP_RUN_SELECT = `
|
||||
id, run_id AS "runId", parent_step_run_id AS "parentStepRunId",
|
||||
step_key AS "stepKey", kind, definition_ref AS "definitionRef",
|
||||
definition_digest AS "definitionDigest", required, status, 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"
|
||||
`.trim();
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackageWorkflowFrontierUnavailableError {
|
||||
return new PluginPackageWorkflowFrontierUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
return postgresRequiredString(row[key], unavailable);
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = postgresRequiredInteger(row[key], unavailable);
|
||||
if (value < 0) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalText(row: Row, key: string): string | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return text(row, key);
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return integer(row, key);
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY.test(value)) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageWorkflowFrontierError ||
|
||||
error instanceof PluginPackageWorkflowFrontierConflictError ||
|
||||
error instanceof PluginPackageWorkflowFrontierUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
return ['23503', '23505', '23514'].includes(
|
||||
postgresSqlState(error) ?? '',
|
||||
)
|
||||
? new PluginPackageWorkflowFrontierConflictError()
|
||||
: unavailable(error);
|
||||
}
|
||||
|
||||
function pageLimit(value: unknown): number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > MAX_PLUGIN_PACKAGE_WORKFLOW_FRONTIER_PAGE_SIZE
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
`page limit must be between 1 and ${MAX_PLUGIN_PACKAGE_WORKFLOW_FRONTIER_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function cursor(
|
||||
value: Readonly<PluginPackageWorkflowFrontierCursor> | undefined,
|
||||
): Readonly<PluginPackageWorkflowFrontierCursor> | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Reflect.ownKeys(value).length !== 2 ||
|
||||
!Reflect.has(value, 'admittedAtMs') ||
|
||||
!Reflect.has(value, 'planDigest') ||
|
||||
!Number.isSafeInteger(value.admittedAtMs) ||
|
||||
value.admittedAtMs < 0 ||
|
||||
!DIGEST.test(value.planDigest)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
'frontier cursor is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
admittedAtMs: value.admittedAtMs,
|
||||
planDigest: value.planDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function runFromRow(row: Row): Readonly<RunRecord> {
|
||||
const run: RunRecord = {
|
||||
id: text(row, 'id'),
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
taskRevision: text(row, 'taskRevision'),
|
||||
triggerType: text(row, 'triggerType'),
|
||||
executionOrigin: text(
|
||||
row,
|
||||
'executionOrigin',
|
||||
) as RunRecord['executionOrigin'],
|
||||
executionOwner: text(row, 'executionOwner') as RunRecord['executionOwner'],
|
||||
status: text(row, 'status') as RunRecord['status'],
|
||||
version: integer(row, 'version'),
|
||||
eventSequence: integer(row, 'eventSequence'),
|
||||
priority: integer(row, 'priority'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
};
|
||||
const optionalTexts = [
|
||||
['taskName', 'taskName'],
|
||||
['taskSnapshotRef', 'taskSnapshotRef'],
|
||||
['parentRunId', 'parentRunId'],
|
||||
['retryOfRunId', 'retryOfRunId'],
|
||||
['triggerId', 'triggerId'],
|
||||
['triggeredBy', 'triggeredBy'],
|
||||
['requestId', 'requestId'],
|
||||
['idempotencyKey', 'idempotencyKey'],
|
||||
['inputRef', 'inputRef'],
|
||||
['outputRef', 'outputRef'],
|
||||
['errorCode', 'errorCode'],
|
||||
['errorSummary', 'errorSummary'],
|
||||
] as const;
|
||||
for (const [property, key] of optionalTexts) {
|
||||
const value = optionalText(row, key);
|
||||
if (value !== undefined) {
|
||||
(run as unknown as Record<string, unknown>)[property] = value;
|
||||
}
|
||||
}
|
||||
const optionalIntegers = [
|
||||
['legacyCronId', 'legacyCronId'],
|
||||
['scheduledForMs', 'scheduledForMs'],
|
||||
['queuedAtMs', 'queuedAtMs'],
|
||||
['startedAtMs', 'startedAtMs'],
|
||||
['finishedAtMs', 'finishedAtMs'],
|
||||
['cancelRequestedAtMs', 'cancelRequestedAtMs'],
|
||||
] as const;
|
||||
for (const [property, key] of optionalIntegers) {
|
||||
const value = optionalInteger(row, key);
|
||||
if (value !== undefined) {
|
||||
(run as unknown as Record<string, unknown>)[property] = value;
|
||||
}
|
||||
}
|
||||
const cancelReason = optionalText(row, 'cancelReason');
|
||||
if (cancelReason !== undefined) {
|
||||
run.cancelReason =
|
||||
cancelReason as NonNullable<RunRecord['cancelReason']>;
|
||||
}
|
||||
return Object.freeze(run);
|
||||
}
|
||||
|
||||
function stepRunFromRow(row: Row): Readonly<StepRunRecord> {
|
||||
let stepRun: Readonly<StepRunRecord>;
|
||||
try {
|
||||
stepRun = normalizeStepRunRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.stepRunJson,
|
||||
unavailable,
|
||||
) as unknown as StepRunRecord,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
text(row, 'id') !== stepRun.id ||
|
||||
text(row, 'runId') !== stepRun.runId ||
|
||||
optionalText(row, 'parentStepRunId') !==
|
||||
(stepRun.parentStepRunId ?? undefined) ||
|
||||
text(row, 'stepKey') !== stepRun.stepKey ||
|
||||
text(row, 'kind') !== stepRun.kind ||
|
||||
text(row, 'definitionRef') !== stepRun.definitionRef ||
|
||||
text(row, 'definitionDigest') !== stepRun.definitionDigest ||
|
||||
postgresRequiredBoolean(row.required, unavailable) !== stepRun.required ||
|
||||
text(row, 'status') !== stepRun.status ||
|
||||
integer(row, 'version') !== stepRun.version ||
|
||||
integer(row, 'attemptCount') !== stepRun.attemptCount ||
|
||||
optionalText(row, 'inputRef') !== (stepRun.inputRef ?? undefined) ||
|
||||
optionalText(row, 'outputRef') !== (stepRun.outputRef ?? undefined) ||
|
||||
optionalText(row, 'approvalRequestId') !==
|
||||
(stepRun.approvalRequestId ?? undefined) ||
|
||||
optionalInteger(row, 'readyAtMs') !==
|
||||
(stepRun.readyAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'startedAtMs') !==
|
||||
(stepRun.startedAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'finishedAtMs') !==
|
||||
(stepRun.finishedAtMs ?? undefined) ||
|
||||
optionalText(row, 'resultCode') !==
|
||||
(stepRun.resultCode ?? undefined) ||
|
||||
optionalText(row, 'errorSummary') !==
|
||||
(stepRun.errorSummary ?? undefined) ||
|
||||
integer(row, 'createdAtMs') !== stepRun.createdAtMs ||
|
||||
integer(row, 'updatedAtMs') !== stepRun.updatedAtMs ||
|
||||
text(row, 'lastMutationId') !== stepRun.lastMutationId ||
|
||||
text(row, 'stepRunDigest') !== stepRun.stepRunDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return stepRun;
|
||||
}
|
||||
|
||||
function assertRunIdentity(
|
||||
run: Readonly<RunRecord>,
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>,
|
||||
): void {
|
||||
if (
|
||||
run.id !== plan.runId ||
|
||||
run.projectId !== plan.target.projectId ||
|
||||
run.taskId !== plan.target.workflowId ||
|
||||
run.taskRevision !== plan.target.publicationDigest ||
|
||||
run.triggerType !== 'plugin_package_workflow' ||
|
||||
run.executionOrigin !== 'system' ||
|
||||
run.executionOwner !== 'runtime' ||
|
||||
run.requestId !== plan.planId ||
|
||||
run.idempotencyKey !== `plugin-package-workflow:${plan.planId}` ||
|
||||
run.version !== run.eventSequence
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStepRun(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): Promise<void> {
|
||||
const stepRun = mutation.stepRun;
|
||||
const updated = await client.query(
|
||||
`UPDATE "ql3"."step_runs"
|
||||
SET status = $1, version = $2, attempt_count = $3, output_ref = $4,
|
||||
approval_request_id = $5, ready_at_ms = $6, started_at_ms = $7,
|
||||
finished_at_ms = $8, result_code = $9, error_summary = $10,
|
||||
updated_at_ms = $11, last_mutation_id = $12,
|
||||
step_run_digest = $13, step_run_json = $14::jsonb
|
||||
WHERE id = $15 AND run_id = $16 AND version = $17
|
||||
AND step_run_digest = $18 AND status = $19`,
|
||||
[
|
||||
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 (updated.rowCount !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
async function insertStepMutation(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
committedAtMs: number,
|
||||
): Promise<void> {
|
||||
const event = mutation.event;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."run_events" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9::jsonb, $10)`,
|
||||
[
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
mutation.stepRun.id,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."step_run_mutations" (
|
||||
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
|
||||
[
|
||||
mutation.mutationId,
|
||||
mutation.mutationDigest,
|
||||
mutation.runId,
|
||||
mutation.stepRun.id,
|
||||
mutation.stepRun.stepRunDigest,
|
||||
event.id,
|
||||
event.sequence,
|
||||
mutation.expectedRunVersion + 1,
|
||||
JSON.stringify(mutation.stepRun),
|
||||
committedAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageWorkflowFrontierRepository
|
||||
implements PluginPackageWorkflowFrontierRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async listCandidates(queryValue: Readonly<{
|
||||
limit: number;
|
||||
after?: Readonly<PluginPackageWorkflowFrontierCursor>;
|
||||
}>): Promise<Readonly<PluginPackageWorkflowFrontierPage>> {
|
||||
if (
|
||||
!queryValue ||
|
||||
typeof queryValue !== 'object' ||
|
||||
Array.isArray(queryValue) ||
|
||||
!Reflect.has(queryValue, 'limit') ||
|
||||
Reflect.ownKeys(queryValue).some(
|
||||
(key) => key !== 'limit' && key !== 'after',
|
||||
)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
'page query is invalid',
|
||||
);
|
||||
}
|
||||
const limit = pageLimit(queryValue.limit);
|
||||
const after = cursor(queryValue.after);
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT admission.run_id AS "runId",
|
||||
admission.plan_digest AS "planDigest",
|
||||
admission.admitted_at_ms AS "admittedAtMs"
|
||||
FROM "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
JOIN "ql3"."runs" AS run ON run.id = admission.run_id
|
||||
WHERE run.status = 'running'
|
||||
AND run.cancel_requested_at_ms IS NULL
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_workflow_admission_steps" AS step
|
||||
JOIN "ql3"."step_runs" AS current
|
||||
ON current.run_id = step.run_id
|
||||
AND current.id = step.step_run_id
|
||||
WHERE step.plan_digest = admission.plan_digest
|
||||
AND current.status = 'pending'
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(step.needs_json)
|
||||
AS need(value)
|
||||
LEFT JOIN
|
||||
"ql3"."plugin_package_workflow_admission_steps"
|
||||
AS dependency_step
|
||||
ON dependency_step.plan_digest = step.plan_digest
|
||||
AND dependency_step.step_key = need.value
|
||||
LEFT JOIN "ql3"."step_runs" AS dependency
|
||||
ON dependency.run_id = dependency_step.run_id
|
||||
AND dependency.id = dependency_step.step_run_id
|
||||
WHERE dependency.id IS NULL
|
||||
OR dependency.status <> 'succeeded'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(step.needs_json)
|
||||
AS need(value)
|
||||
JOIN "ql3"."plugin_package_workflow_admission_steps"
|
||||
AS dependency_step
|
||||
ON dependency_step.plan_digest = step.plan_digest
|
||||
AND dependency_step.step_key = need.value
|
||||
JOIN "ql3"."step_runs" AS dependency
|
||||
ON dependency.run_id = dependency_step.run_id
|
||||
AND dependency.id = dependency_step.step_run_id
|
||||
WHERE dependency.status IN (
|
||||
'failed', 'skipped', 'cancelled', 'timed_out'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_workflow_admission_steps" AS step
|
||||
JOIN "ql3"."step_runs" AS current
|
||||
ON current.run_id = step.run_id
|
||||
AND current.id = step.step_run_id
|
||||
WHERE step.plan_digest = admission.plan_digest
|
||||
AND current.status NOT IN (
|
||||
'succeeded', 'failed', 'skipped', 'cancelled', 'timed_out'
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
$1::bigint IS NULL OR admission.admitted_at_ms > $1 OR
|
||||
(admission.admitted_at_ms = $1
|
||||
AND admission.plan_digest > $2)
|
||||
)
|
||||
ORDER BY admission.admitted_at_ms, admission.plan_digest
|
||||
LIMIT $3`,
|
||||
[
|
||||
after?.admittedAtMs ?? null,
|
||||
after?.planDigest ?? '',
|
||||
limit + 1,
|
||||
],
|
||||
);
|
||||
const mapped = result.rows.map(
|
||||
(row): Readonly<PluginPackageWorkflowFrontierCandidate> =>
|
||||
Object.freeze({
|
||||
runId: identity(text(row, 'runId'), 'candidate runId'),
|
||||
planDigest: digest(row.planDigest),
|
||||
admittedAtMs: integer(row, 'admittedAtMs'),
|
||||
}),
|
||||
);
|
||||
const truncated = mapped.length > limit;
|
||||
const candidates = Object.freeze(mapped.slice(0, limit));
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
admittedAtMs: last.admittedAtMs,
|
||||
planDigest: last.planDigest,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async advance(
|
||||
runIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowFrontierAdvanceResult>> {
|
||||
const runId = identity(runIdValue, 'runId');
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const admission = await client.query<Row>(
|
||||
`SELECT plan_digest AS "planDigest", plan_json AS "planJson"
|
||||
FROM "ql3"."plugin_package_workflow_admissions"
|
||||
WHERE run_id = $1 LIMIT 2`,
|
||||
[runId],
|
||||
);
|
||||
if (admission.rows.length !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
let plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
try {
|
||||
plan = normalizePluginPackageWorkflowExecutionPlan(
|
||||
postgresRequiredJsonObject(
|
||||
admission.rows[0]!.planJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageWorkflowExecutionPlan,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (plan.planDigest !== digest(admission.rows[0]!.planDigest)) {
|
||||
throw unavailable();
|
||||
}
|
||||
const runRows = await client.query<Row>(
|
||||
`SELECT ${RUN_SELECT}
|
||||
FROM "ql3"."runs" WHERE id = $1 LIMIT 2 FOR UPDATE`,
|
||||
[runId],
|
||||
);
|
||||
if (runRows.rows.length !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
const run = runFromRow(runRows.rows[0]!);
|
||||
assertRunIdentity(run, plan);
|
||||
const stepRows = await client.query<Row>(
|
||||
`SELECT ${STEP_RUN_SELECT}
|
||||
FROM "ql3"."step_runs" WHERE run_id = $1
|
||||
ORDER BY step_key, id FOR UPDATE`,
|
||||
[runId],
|
||||
);
|
||||
const stepRuns = stepRows.rows.map(stepRunFromRow);
|
||||
const clock = await client.query<Row>(
|
||||
`SELECT floor(
|
||||
extract(epoch FROM transaction_timestamp()) * 1000
|
||||
)::bigint AS "observedAtMs"`,
|
||||
);
|
||||
if (clock.rows.length !== 1) throw unavailable();
|
||||
const observedAtMs = integer(clock.rows[0]!, 'observedAtMs');
|
||||
const currentStatus = run.status;
|
||||
const resolution = resolvePluginPackageWorkflowFrontier({
|
||||
plan,
|
||||
run: {
|
||||
...run,
|
||||
...(TERMINAL_RUN_STATUSES.has(
|
||||
currentStatus as PluginPackageWorkflowTerminalStatus,
|
||||
)
|
||||
? { status: 'running' as const }
|
||||
: {}),
|
||||
},
|
||||
stepRuns,
|
||||
observedAtMs,
|
||||
});
|
||||
if (
|
||||
TERMINAL_RUN_STATUSES.has(
|
||||
currentStatus as PluginPackageWorkflowTerminalStatus,
|
||||
)
|
||||
) {
|
||||
if (
|
||||
resolution.stepMutations.length !== 0 ||
|
||||
resolution.terminalStatus !== currentStatus
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'settled' as const,
|
||||
runId,
|
||||
planDigest: plan.planDigest,
|
||||
stepMutationCount: 0,
|
||||
readyStepRunIds: Object.freeze([]),
|
||||
terminalStatus:
|
||||
currentStatus as PluginPackageWorkflowTerminalStatus,
|
||||
runVersion: run.version,
|
||||
runEventSequence: run.eventSequence,
|
||||
observedAtMs,
|
||||
});
|
||||
}
|
||||
if (currentStatus !== 'running') throw unavailable();
|
||||
const increment =
|
||||
resolution.stepMutations.length +
|
||||
(resolution.terminalTransition === null ? 0 : 1);
|
||||
if (increment === 0) {
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'unchanged' as const,
|
||||
runId,
|
||||
planDigest: plan.planDigest,
|
||||
stepMutationCount: 0,
|
||||
readyStepRunIds: resolution.readyStepRunIds,
|
||||
terminalStatus: null,
|
||||
runVersion: run.version,
|
||||
runEventSequence: run.eventSequence,
|
||||
observedAtMs,
|
||||
});
|
||||
}
|
||||
for (const mutation of resolution.stepMutations) {
|
||||
await updateStepRun(client, mutation);
|
||||
}
|
||||
const terminal = resolution.terminalTransition;
|
||||
const updatedRun = await client.query(
|
||||
`UPDATE "ql3"."runs"
|
||||
SET status = $1, version = version + $2,
|
||||
event_sequence = event_sequence + $2,
|
||||
finished_at_ms = $3, error_code = $4, error_summary = NULL
|
||||
WHERE id = $5 AND status = 'running'
|
||||
AND version = $6 AND event_sequence = $7`,
|
||||
[
|
||||
terminal?.status ?? 'running',
|
||||
increment,
|
||||
terminal?.finishedAtMs ?? null,
|
||||
terminal?.errorCode ?? null,
|
||||
runId,
|
||||
run.version,
|
||||
run.eventSequence,
|
||||
],
|
||||
);
|
||||
if (updatedRun.rowCount !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
for (const mutation of resolution.stepMutations) {
|
||||
await insertStepMutation(client, mutation, observedAtMs);
|
||||
}
|
||||
if (terminal) {
|
||||
const event = terminal.event;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."run_events" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, NULL, NULL, $8::jsonb, $9
|
||||
)`,
|
||||
[
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: terminal ? ('terminal' as const) : ('advanced' as const),
|
||||
runId,
|
||||
planDigest: plan.planDigest,
|
||||
stepMutationCount: resolution.stepMutations.length,
|
||||
readyStepRunIds: terminal
|
||||
? Object.freeze([])
|
||||
: resolution.readyStepRunIds,
|
||||
terminalStatus: terminal?.status ?? null,
|
||||
runVersion: run.version + increment,
|
||||
runEventSequence: run.eventSequence + increment,
|
||||
observedAtMs,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
+777
@@ -0,0 +1,777 @@
|
||||
// PostgreSQL authority for admitting Plugin Package Workflow task attempts.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
RunRecord,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizeClusterTaskExecutionRevision,
|
||||
type ClusterTaskExecutionRevision,
|
||||
} from '@qinglong/runtime-core/cluster-execution-revision';
|
||||
import {
|
||||
createPluginPackageWorkflowTaskAttemptAdmission,
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_PAGE_SIZE,
|
||||
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt,
|
||||
PluginPackageWorkflowTaskAttemptAdmissionConflictError,
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionCandidate,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionCursor,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionPage,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionReceipt,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission';
|
||||
import {
|
||||
normalizePluginPackageTaskReconciliationReceipt,
|
||||
type PluginPackageTaskReconciliationReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-reconciliation';
|
||||
import {
|
||||
normalizePluginPackageWorkflowExecutionPlan,
|
||||
type PluginPackageWorkflowExecutionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredBoolean,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
const RUN_SELECT = `
|
||||
id, project_id AS "projectId", task_id AS "taskId",
|
||||
task_revision AS "taskRevision", task_name AS "taskName",
|
||||
task_snapshot_ref AS "taskSnapshotRef", legacy_cron_id AS "legacyCronId",
|
||||
parent_run_id AS "parentRunId", retry_of_run_id AS "retryOfRunId",
|
||||
trigger_id AS "triggerId", trigger_type AS "triggerType",
|
||||
execution_origin AS "executionOrigin",
|
||||
execution_owner AS "executionOwner", triggered_by AS "triggeredBy",
|
||||
request_id AS "requestId", scheduled_for_ms AS "scheduledForMs",
|
||||
status, version, event_sequence AS "eventSequence", priority,
|
||||
idempotency_key AS "idempotencyKey", input_ref AS "inputRef",
|
||||
output_ref AS "outputRef", created_at_ms AS "createdAtMs",
|
||||
queued_at_ms AS "queuedAtMs", started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs",
|
||||
cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
cancel_reason AS "cancelReason", error_code AS "errorCode",
|
||||
error_summary AS "errorSummary"
|
||||
`.trim();
|
||||
|
||||
const STEP_RUN_SELECT = `
|
||||
id, run_id AS "runId", parent_step_run_id AS "parentStepRunId",
|
||||
step_key AS "stepKey", kind, definition_ref AS "definitionRef",
|
||||
definition_digest AS "definitionDigest", required, status, 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"
|
||||
`.trim();
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackageWorkflowTaskAttemptAdmissionUnavailableError {
|
||||
return new PluginPackageWorkflowTaskAttemptAdmissionUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
return postgresRequiredString(row[key], unavailable);
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = postgresRequiredInteger(row[key], unavailable);
|
||||
if (value < 0) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalText(row: Row, key: string): string | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return text(row, key);
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return integer(row, key);
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY.test(value)) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowTaskAttemptAdmissionConflictError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
return ['23503', '23505', '23514'].includes(
|
||||
postgresSqlState(error) ?? '',
|
||||
)
|
||||
? new PluginPackageWorkflowTaskAttemptAdmissionConflictError()
|
||||
: unavailable(error);
|
||||
}
|
||||
|
||||
function pageLimit(value: unknown): number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) >
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_PAGE_SIZE
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
`page limit must be between 1 and ${MAX_PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function cursor(
|
||||
value:
|
||||
| Readonly<PluginPackageWorkflowTaskAttemptAdmissionCursor>
|
||||
| undefined,
|
||||
):
|
||||
| Readonly<PluginPackageWorkflowTaskAttemptAdmissionCursor>
|
||||
| undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Reflect.ownKeys(value).length !== 2 ||
|
||||
!Reflect.has(value, 'readyAtMs') ||
|
||||
!Reflect.has(value, 'stepRunId') ||
|
||||
!Number.isSafeInteger(value.readyAtMs) ||
|
||||
value.readyAtMs < 0 ||
|
||||
typeof value.stepRunId !== 'string' ||
|
||||
!IDENTITY.test(value.stepRunId)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
'candidate cursor is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
readyAtMs: value.readyAtMs,
|
||||
stepRunId: value.stepRunId,
|
||||
});
|
||||
}
|
||||
|
||||
function runFromRow(row: Row): Readonly<RunRecord> {
|
||||
const run: RunRecord = {
|
||||
id: text(row, 'id'),
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
taskRevision: text(row, 'taskRevision'),
|
||||
triggerType: text(row, 'triggerType'),
|
||||
executionOrigin: text(
|
||||
row,
|
||||
'executionOrigin',
|
||||
) as RunRecord['executionOrigin'],
|
||||
executionOwner: text(row, 'executionOwner') as RunRecord['executionOwner'],
|
||||
status: text(row, 'status') as RunRecord['status'],
|
||||
version: integer(row, 'version'),
|
||||
eventSequence: integer(row, 'eventSequence'),
|
||||
priority: integer(row, 'priority'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
};
|
||||
const optionalTexts = [
|
||||
['taskName', 'taskName'],
|
||||
['taskSnapshotRef', 'taskSnapshotRef'],
|
||||
['parentRunId', 'parentRunId'],
|
||||
['retryOfRunId', 'retryOfRunId'],
|
||||
['triggerId', 'triggerId'],
|
||||
['triggeredBy', 'triggeredBy'],
|
||||
['requestId', 'requestId'],
|
||||
['idempotencyKey', 'idempotencyKey'],
|
||||
['inputRef', 'inputRef'],
|
||||
['outputRef', 'outputRef'],
|
||||
['errorCode', 'errorCode'],
|
||||
['errorSummary', 'errorSummary'],
|
||||
] as const;
|
||||
for (const [property, key] of optionalTexts) {
|
||||
const value = optionalText(row, key);
|
||||
if (value !== undefined) {
|
||||
(run as unknown as Record<string, unknown>)[property] = value;
|
||||
}
|
||||
}
|
||||
const optionalIntegers = [
|
||||
['legacyCronId', 'legacyCronId'],
|
||||
['scheduledForMs', 'scheduledForMs'],
|
||||
['queuedAtMs', 'queuedAtMs'],
|
||||
['startedAtMs', 'startedAtMs'],
|
||||
['finishedAtMs', 'finishedAtMs'],
|
||||
['cancelRequestedAtMs', 'cancelRequestedAtMs'],
|
||||
] as const;
|
||||
for (const [property, key] of optionalIntegers) {
|
||||
const value = optionalInteger(row, key);
|
||||
if (value !== undefined) {
|
||||
(run as unknown as Record<string, unknown>)[property] = value;
|
||||
}
|
||||
}
|
||||
const cancelReason = optionalText(row, 'cancelReason');
|
||||
if (cancelReason !== undefined) {
|
||||
run.cancelReason =
|
||||
cancelReason as NonNullable<RunRecord['cancelReason']>;
|
||||
}
|
||||
return Object.freeze(run);
|
||||
}
|
||||
|
||||
function stepRunFromRow(row: Row): Readonly<StepRunRecord> {
|
||||
let stepRun: Readonly<StepRunRecord>;
|
||||
try {
|
||||
stepRun = normalizeStepRunRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.stepRunJson,
|
||||
unavailable,
|
||||
) as unknown as StepRunRecord,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
text(row, 'id') !== stepRun.id ||
|
||||
text(row, 'runId') !== stepRun.runId ||
|
||||
optionalText(row, 'parentStepRunId') !==
|
||||
(stepRun.parentStepRunId ?? undefined) ||
|
||||
text(row, 'stepKey') !== stepRun.stepKey ||
|
||||
text(row, 'kind') !== stepRun.kind ||
|
||||
text(row, 'definitionRef') !== stepRun.definitionRef ||
|
||||
text(row, 'definitionDigest') !== stepRun.definitionDigest ||
|
||||
postgresRequiredBoolean(row.required, unavailable) !== stepRun.required ||
|
||||
text(row, 'status') !== stepRun.status ||
|
||||
integer(row, 'version') !== stepRun.version ||
|
||||
integer(row, 'attemptCount') !== stepRun.attemptCount ||
|
||||
optionalText(row, 'inputRef') !== (stepRun.inputRef ?? undefined) ||
|
||||
optionalText(row, 'outputRef') !== (stepRun.outputRef ?? undefined) ||
|
||||
optionalText(row, 'approvalRequestId') !==
|
||||
(stepRun.approvalRequestId ?? undefined) ||
|
||||
optionalInteger(row, 'readyAtMs') !==
|
||||
(stepRun.readyAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'startedAtMs') !==
|
||||
(stepRun.startedAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'finishedAtMs') !==
|
||||
(stepRun.finishedAtMs ?? undefined) ||
|
||||
optionalText(row, 'resultCode') !==
|
||||
(stepRun.resultCode ?? undefined) ||
|
||||
optionalText(row, 'errorSummary') !==
|
||||
(stepRun.errorSummary ?? undefined) ||
|
||||
integer(row, 'createdAtMs') !== stepRun.createdAtMs ||
|
||||
integer(row, 'updatedAtMs') !== stepRun.updatedAtMs ||
|
||||
text(row, 'lastMutationId') !== stepRun.lastMutationId ||
|
||||
text(row, 'stepRunDigest') !== stepRun.stepRunDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return stepRun;
|
||||
}
|
||||
|
||||
function planFromRow(row: Row): Readonly<PluginPackageWorkflowExecutionPlan> {
|
||||
try {
|
||||
return normalizePluginPackageWorkflowExecutionPlan(
|
||||
postgresRequiredJsonObject(
|
||||
row.planJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageWorkflowExecutionPlan,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function reconciliationFromRow(
|
||||
row: Row,
|
||||
): Readonly<PluginPackageTaskReconciliationReceipt> {
|
||||
try {
|
||||
return normalizePluginPackageTaskReconciliationReceipt(
|
||||
postgresRequiredJsonObject(
|
||||
row.reconciliationJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageTaskReconciliationReceipt,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function executionFromRow(
|
||||
row: Row,
|
||||
): Readonly<ClusterTaskExecutionRevision> {
|
||||
try {
|
||||
const plan = postgresRequiredJsonObject(row.executionPlanJson, unavailable);
|
||||
const keys = Object.keys(plan);
|
||||
if (
|
||||
!keys.includes('command') ||
|
||||
!keys.includes('environment') ||
|
||||
keys.some(
|
||||
(key) =>
|
||||
![
|
||||
'command',
|
||||
'environment',
|
||||
'placement',
|
||||
'timeoutMs',
|
||||
'workingDirectory',
|
||||
].includes(key),
|
||||
)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return normalizeClusterTaskExecutionRevision({
|
||||
projectId: text(row, 'executionProjectId'),
|
||||
taskId: text(row, 'executionTaskId'),
|
||||
sourceRevision: integer(row, 'executionSourceRevision'),
|
||||
taskRevision: text(row, 'executionTaskRevision'),
|
||||
sourceContentDigest: text(row, 'executionSourceContentDigest'),
|
||||
executorType: text(
|
||||
row,
|
||||
'executionExecutorType',
|
||||
) as ClusterTaskExecutionRevision['executorType'],
|
||||
planSchema: text(
|
||||
row,
|
||||
'executionPlanSchema',
|
||||
) as ClusterTaskExecutionRevision['planSchema'],
|
||||
command: plan.command as ClusterTaskExecutionRevision['command'],
|
||||
environment:
|
||||
plan.environment as ClusterTaskExecutionRevision['environment'],
|
||||
...(plan.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: plan.workingDirectory as string }),
|
||||
...(plan.timeoutMs === undefined
|
||||
? {}
|
||||
: { timeoutMs: plan.timeoutMs as number }),
|
||||
...(plan.placement === undefined
|
||||
? {}
|
||||
: {
|
||||
placement:
|
||||
plan.placement as unknown as NonNullable<
|
||||
ClusterTaskExecutionRevision['placement']
|
||||
>,
|
||||
}),
|
||||
contentDigest: text(row, 'executionContentDigest'),
|
||||
createdAtMs: integer(row, 'executionCreatedAtMs'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function receiptFromRow(
|
||||
row: Row,
|
||||
): Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt> {
|
||||
try {
|
||||
const receipt =
|
||||
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt(
|
||||
postgresRequiredJsonObject(
|
||||
row.receiptJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageWorkflowTaskAttemptAdmissionReceipt,
|
||||
);
|
||||
if (
|
||||
row.receiptDigest !== undefined &&
|
||||
text(row, 'receiptDigest') !== receipt.receiptDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageWorkflowTaskAttemptAdmissionRepository
|
||||
implements PluginPackageWorkflowTaskAttemptAdmissionRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (!pool || typeof pool.connect !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL Workflow Task Attempt admission pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listCandidates(queryValue: Readonly<{
|
||||
limit: number;
|
||||
after?: Readonly<PluginPackageWorkflowTaskAttemptAdmissionCursor>;
|
||||
}>): Promise<
|
||||
Readonly<PluginPackageWorkflowTaskAttemptAdmissionPage>
|
||||
> {
|
||||
if (
|
||||
!queryValue ||
|
||||
typeof queryValue !== 'object' ||
|
||||
Array.isArray(queryValue) ||
|
||||
!Reflect.has(queryValue, 'limit') ||
|
||||
Reflect.ownKeys(queryValue).some(
|
||||
(key) => key !== 'limit' && key !== 'after',
|
||||
)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
'page query is invalid',
|
||||
);
|
||||
}
|
||||
const limit = pageLimit(queryValue.limit);
|
||||
const after = cursor(queryValue.after);
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
try {
|
||||
const result = await client.query<Row>(
|
||||
`SELECT current.run_id AS "runId",
|
||||
current.id AS "stepRunId",
|
||||
current.ready_at_ms AS "readyAtMs",
|
||||
admission.plan_digest AS "planDigest"
|
||||
FROM "ql3"."step_runs" AS current
|
||||
JOIN "ql3"."plugin_package_workflow_admission_steps" AS source
|
||||
ON source.run_id = current.run_id
|
||||
AND source.step_run_id = current.id
|
||||
JOIN "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
ON admission.plan_digest = source.plan_digest
|
||||
AND admission.run_id = source.run_id
|
||||
JOIN "ql3"."runs" AS run ON run.id = current.run_id
|
||||
WHERE run.status = 'running'
|
||||
AND run.cancel_requested_at_ms IS NULL
|
||||
AND current.kind = 'task'
|
||||
AND current.status = 'ready'
|
||||
AND current.ready_at_ms IS NOT NULL
|
||||
AND current.attempt_count < 64
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_workflow_task_attempt_admissions"
|
||||
AS task_attempt
|
||||
WHERE task_attempt.run_id = current.run_id
|
||||
AND task_attempt.step_run_id = current.id
|
||||
AND task_attempt.step_run_version = current.version
|
||||
)
|
||||
AND (
|
||||
$1::varchar IS NULL OR current.ready_at_ms > $2 OR
|
||||
(current.ready_at_ms = $2 AND current.id > $1)
|
||||
)
|
||||
ORDER BY current.ready_at_ms, current.id
|
||||
LIMIT $3`,
|
||||
[
|
||||
after?.stepRunId ?? null,
|
||||
after?.readyAtMs ?? 0,
|
||||
limit + 1,
|
||||
],
|
||||
);
|
||||
const mapped = result.rows.map(
|
||||
(row): Readonly<PluginPackageWorkflowTaskAttemptAdmissionCandidate> =>
|
||||
Object.freeze({
|
||||
runId: identity(text(row, 'runId'), 'candidate runId'),
|
||||
stepRunId: identity(
|
||||
text(row, 'stepRunId'),
|
||||
'candidate stepRunId',
|
||||
),
|
||||
readyAtMs: integer(row, 'readyAtMs'),
|
||||
planDigest: digest(row.planDigest),
|
||||
}),
|
||||
);
|
||||
const truncated = mapped.length > limit;
|
||||
const candidates = Object.freeze(mapped.slice(0, limit));
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
readyAtMs: last.readyAtMs,
|
||||
stepRunId: last.stepRunId,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async admit(
|
||||
runIdValue: string,
|
||||
stepRunIdValue: string,
|
||||
): Promise<
|
||||
Readonly<PluginPackageWorkflowTaskAttemptAdmissionResult>
|
||||
> {
|
||||
const runId = identity(runIdValue, 'runId');
|
||||
const stepRunId = identity(stepRunIdValue, 'stepRunId');
|
||||
for (
|
||||
let transactionAttempt = 0;
|
||||
transactionAttempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
transactionAttempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const runRows = await client.query<Row>(
|
||||
`SELECT ${RUN_SELECT}
|
||||
FROM "ql3"."runs" WHERE id = $1 LIMIT 2 FOR UPDATE`,
|
||||
[runId],
|
||||
);
|
||||
if (runRows.rows.length !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const run = runFromRow(runRows.rows[0]!);
|
||||
const stepRows = await client.query<Row>(
|
||||
`SELECT ${STEP_RUN_SELECT}
|
||||
FROM "ql3"."step_runs"
|
||||
WHERE run_id = $1 AND id = $2 LIMIT 2 FOR UPDATE`,
|
||||
[runId, stepRunId],
|
||||
);
|
||||
if (stepRows.rows.length !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const stepRun = stepRunFromRow(stepRows.rows[0]!);
|
||||
const existing = await client.query<Row>(
|
||||
`SELECT receipt_digest AS "receiptDigest",
|
||||
receipt_json AS "receiptJson"
|
||||
FROM "ql3"."plugin_package_workflow_task_attempt_admissions"
|
||||
WHERE run_id = $1 AND step_run_id = $2
|
||||
AND step_run_version = $3
|
||||
LIMIT 2`,
|
||||
[runId, stepRunId, stepRun.version],
|
||||
);
|
||||
if (existing.rows.length > 1) throw unavailable();
|
||||
if (existing.rows.length === 1) {
|
||||
const receipt = receiptFromRow(existing.rows[0]!);
|
||||
if (
|
||||
receipt.runId !== runId ||
|
||||
receipt.stepRunId !== stepRunId ||
|
||||
receipt.stepRunVersion !== stepRun.version ||
|
||||
receipt.stepRunDigest !== stepRun.stepRunDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
receipt,
|
||||
});
|
||||
}
|
||||
if (stepRun.status !== 'ready') {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const snapshot = await client.query<Row>(
|
||||
`SELECT plan_json AS "planJson",
|
||||
reconciliation_json AS "reconciliationJson",
|
||||
execution_project_id AS "executionProjectId",
|
||||
execution_task_id AS "executionTaskId",
|
||||
execution_source_revision AS "executionSourceRevision",
|
||||
execution_task_revision AS "executionTaskRevision",
|
||||
execution_source_content_digest
|
||||
AS "executionSourceContentDigest",
|
||||
execution_executor_type AS "executionExecutorType",
|
||||
execution_plan_schema AS "executionPlanSchema",
|
||||
execution_plan_json AS "executionPlanJson",
|
||||
execution_content_digest AS "executionContentDigest",
|
||||
execution_created_at_ms AS "executionCreatedAtMs"
|
||||
FROM "ql3"."plugin_package_workflow_task_attempt_snapshot"($1, $2)`,
|
||||
[runId, stepRunId],
|
||||
);
|
||||
if (snapshot.rows.length !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const snapshotRow = snapshot.rows[0]!;
|
||||
const plan = planFromRow(snapshotRow);
|
||||
const taskReconciliation = reconciliationFromRow(snapshotRow);
|
||||
const execution = executionFromRow(snapshotRow);
|
||||
const clock = await client.query<Row>(
|
||||
`SELECT floor(
|
||||
extract(epoch FROM transaction_timestamp()) * 1000
|
||||
)::bigint AS "admittedAtMs"`,
|
||||
);
|
||||
if (clock.rows.length !== 1) throw unavailable();
|
||||
const attemptNumberRows = await client.query<Row>(
|
||||
`SELECT COALESCE(MAX(attempt), 0) + 1 AS "attemptNumber"
|
||||
FROM "ql3"."run_attempts" WHERE run_id = $1`,
|
||||
[runId],
|
||||
);
|
||||
if (attemptNumberRows.rows.length !== 1) throw unavailable();
|
||||
const bundle = createPluginPackageWorkflowTaskAttemptAdmission({
|
||||
plan,
|
||||
run,
|
||||
stepRun,
|
||||
taskReconciliation,
|
||||
execution,
|
||||
attemptNumber: integer(
|
||||
attemptNumberRows.rows[0]!,
|
||||
'attemptNumber',
|
||||
),
|
||||
admittedAtMs: integer(clock.rows[0]!, 'admittedAtMs'),
|
||||
});
|
||||
const updated = await client.query(
|
||||
`UPDATE "ql3"."runs"
|
||||
SET version = $1, event_sequence = $2
|
||||
WHERE id = $3 AND status = 'running'
|
||||
AND cancel_requested_at_ms IS NULL
|
||||
AND version = $4 AND event_sequence = $5`,
|
||||
[
|
||||
bundle.run.version,
|
||||
bundle.run.eventSequence,
|
||||
run.id,
|
||||
run.version,
|
||||
run.eventSequence,
|
||||
],
|
||||
);
|
||||
if (updated.rowCount !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const attempt = bundle.attempt;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."run_attempts" (
|
||||
id, run_id, step_run_id, attempt, status, executor_type,
|
||||
callback_sequence, created_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
attempt.id,
|
||||
attempt.runId,
|
||||
attempt.stepRunId ?? null,
|
||||
attempt.attempt,
|
||||
attempt.status,
|
||||
attempt.executorType,
|
||||
attempt.callbackSequence,
|
||||
attempt.createdAtMs,
|
||||
],
|
||||
);
|
||||
const event = bundle.event;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."run_events" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11
|
||||
)`,
|
||||
[
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
event.attemptId ?? null,
|
||||
event.stepRunId ?? null,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
],
|
||||
);
|
||||
const receipt = bundle.receipt;
|
||||
await client.query(
|
||||
`INSERT INTO
|
||||
"ql3"."plugin_package_workflow_task_attempt_admissions" (
|
||||
receipt_digest, attempt_id, plan_digest, run_id,
|
||||
step_run_id, step_run_version, step_run_digest,
|
||||
generation_digest, resource_task_id,
|
||||
task_reconciliation_receipt_digest, project_id, task_id,
|
||||
source_revision, task_revision, task_definition_digest,
|
||||
executor_type, execution_digest, attempt_number, event_id,
|
||||
run_version, run_event_sequence, admitted_at_ms, receipt_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23::jsonb
|
||||
)`,
|
||||
[
|
||||
receipt.receiptDigest,
|
||||
receipt.attemptId,
|
||||
receipt.planDigest,
|
||||
receipt.runId,
|
||||
receipt.stepRunId,
|
||||
receipt.stepRunVersion,
|
||||
receipt.stepRunDigest,
|
||||
plan.target.generationDigest,
|
||||
receipt.resourceTaskId,
|
||||
receipt.taskReconciliationReceiptDigest,
|
||||
execution.projectId,
|
||||
receipt.taskId,
|
||||
execution.sourceRevision,
|
||||
receipt.taskRevision,
|
||||
receipt.taskDefinitionDigest,
|
||||
receipt.executorType,
|
||||
receipt.executionDigest,
|
||||
receipt.attemptNumber,
|
||||
receipt.eventId,
|
||||
receipt.runVersion,
|
||||
receipt.runEventSequence,
|
||||
receipt.admittedAtMs,
|
||||
JSON.stringify(receipt),
|
||||
],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
receipt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
transactionAttempt + 1 <
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user