Files
qinglong/packages/ql3-cluster-postgres/test/pluginPackageWorkflowAdmissionRepository.test.cjs

436 lines
13 KiB
JavaScript

const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
createInitialPluginPackageAutomationPublication,
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
const {
createPluginPackageWorkflowAdmissionBundle,
createPluginPackageWorkflowExecutionPlan,
PluginPackageWorkflowAdmissionNotAllowedError,
PluginPackageWorkflowAdmissionUnavailableError,
} = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
const {
transitionStepRunMutation,
} = require('@qinglong/runtime-core/step-run');
const {
pluginPackageTaskReconciliationFixture,
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
const {
PostgresPluginPackageWorkflowAdmissionRepository,
} = require('../dist/plugin-package/workflow/pluginPackageWorkflowAdmissionRepository');
function fixture(namespace = 'postgres-workflow-admission') {
const value = pluginPackageTaskReconciliationFixture(namespace, {
workflows: [
{
schema: 'qinglong/plugin-package-workflow-resource@v1',
id: 'daily',
name: 'Daily workflow',
enabled: true,
steps: [
{ id: 'collect', task: 'alpha', needs: [] },
{ id: 'summarize', task: 'beta', needs: ['collect'] },
],
},
],
});
const publication = createInitialPluginPackageAutomationPublication(
value.revision,
value.registry,
2_000,
);
const plan = createPluginPackageWorkflowExecutionPlan({
planId: `workflow-plan-${namespace}`,
runId: `run-${namespace}`,
workflowId: 'daily',
stepRunIds: {
collect: `step-collect-${namespace}`,
summarize: `step-summarize-${namespace}`,
},
publication,
revision: value.revision,
taskSpecSemanticRegistry: value.registry,
plannedAtMs: 3_000,
});
return { ...value, publication, plan };
}
function poolWithClient(client) {
return {
async query(text, values) {
return client.query(text, values);
},
async connect() {
return client;
},
};
}
function admissionRow(bundle) {
const { plan, receipt } = bundle;
return {
planDigest: plan.planDigest,
planId: plan.planId,
runId: plan.runId,
projectId: plan.target.projectId,
packageName: plan.target.packageName,
installationId: plan.target.installationId,
lockDigest: plan.target.lockDigest,
generation: plan.target.generation,
generationDigest: plan.target.generationDigest,
materializedRevisionDigest: plan.target.materializedRevisionDigest,
publicationDigest: plan.target.publicationDigest,
workflowId: plan.target.workflowId,
workflowDefinitionDigest: plan.target.workflowDefinitionDigest,
stepCount: plan.steps.length,
admittedAtMs: receipt.admittedAtMs,
finalRunVersion: receipt.finalRunVersion,
finalRunEventSequence: receipt.finalRunEventSequence,
receiptDigest: receipt.receiptDigest,
planJson: plan,
receiptJson: receipt,
};
}
function runRow(run) {
return {
projectId: run.projectId,
taskId: run.taskId,
taskRevision: run.taskRevision,
taskSnapshotRef: run.taskSnapshotRef ?? null,
triggerType: run.triggerType,
executionOrigin: run.executionOrigin,
executionOwner: run.executionOwner,
requestId: run.requestId ?? null,
status: run.status,
version: run.version,
eventSequence: run.eventSequence,
priority: run.priority,
idempotencyKey: run.idempotencyKey ?? null,
createdAtMs: run.createdAtMs,
startedAtMs: run.startedAtMs ?? null,
};
}
function eventRow(event) {
return {
id: event.id,
sequence: event.sequence,
type: event.type,
dedupeKey: event.dedupeKey ?? null,
actorType: event.actorType,
actorId: event.actorId ?? null,
stepRunId: event.stepRunId ?? null,
payload: event.payload,
createdAtMs: event.createdAtMs,
};
}
function stepEvidenceRow(bundle, stepKey, currentStepRun) {
const mutation = bundle.stepMutations.find(
({ stepRun }) => stepRun.stepKey === stepKey,
);
const step = bundle.plan.steps.find(
(candidate) => candidate.stepKey === stepKey,
);
assert.ok(mutation);
assert.ok(step);
return {
stepRunId: mutation.stepRun.id,
taskId: step.taskId,
taskDefinitionRef: step.taskDefinitionRef,
taskDefinitionDigest: step.taskDefinitionDigest,
needsJson: step.needs,
initialStatus: step.initialStatus,
mutationId: mutation.mutationId,
eventId: mutation.event.id,
currentStepKey: (currentStepRun ?? mutation.stepRun).stepKey,
currentKind: (currentStepRun ?? mutation.stepRun).kind,
currentDefinitionRef: (currentStepRun ?? mutation.stepRun).definitionRef,
currentDefinitionDigest:
(currentStepRun ?? mutation.stepRun).definitionDigest,
currentRequired: (currentStepRun ?? mutation.stepRun).required,
currentStatus: (currentStepRun ?? mutation.stepRun).status,
currentVersion: (currentStepRun ?? mutation.stepRun).version,
currentLastMutationId: (currentStepRun ?? mutation.stepRun).lastMutationId,
currentStepRunDigest: (currentStepRun ?? mutation.stepRun).stepRunDigest,
currentStepRunJson: currentStepRun ?? mutation.stepRun,
mutationDigest: mutation.mutationDigest,
eventSequence: mutation.event.sequence,
runVersion: mutation.expectedRunVersion + 1,
initialStepRunDigest: mutation.stepRun.stepRunDigest,
initialStepRunJson: mutation.stepRun,
};
}
test('admits the complete Workflow evidence in one SERIALIZABLE transaction', async () => {
const value = fixture();
const queries = [];
let released = false;
const client = {
async query(text, values) {
queries.push({ text, values });
if (
text.includes('plugin_package_workflow_admissions') &&
text.includes('WHERE plan_id')
) {
return { rows: [] };
}
if (text.includes('plugin_package_workflow_admission_snapshot')) {
return {
rows: [
{
publicationJson: value.publication,
revisionJson: value.revision,
},
],
};
}
return { rows: [] };
},
release() {
released = true;
},
};
const repository = new PostgresPluginPackageWorkflowAdmissionRepository(
poolWithClient(client),
);
const result = await repository.admit(value.plan);
assert.equal(result.status, 'created');
assert.equal(result.receipt.finalRunVersion, 3);
assert.equal(queries[0].text, 'BEGIN ISOLATION LEVEL SERIALIZABLE');
assert.equal(queries.at(-1).text, 'COMMIT');
assert.equal(released, true);
assert.equal(
queries.some(
({ text }) =>
text.includes('plugin_package_workflow_admissions') &&
text.includes('FOR SHARE'),
),
false,
'append-only replay must not require UPDATE authority',
);
assert.equal(
queries.filter(({ text }) => /INSERT INTO "ql3"\."runs"/.test(text)).length,
1,
);
assert.equal(
queries.filter(({ text }) => /INSERT INTO "ql3"\."step_runs"/.test(text))
.length,
2,
);
assert.equal(
queries.filter(({ text }) => /INSERT INTO "ql3"\."run_events"/.test(text))
.length,
3,
);
assert.equal(
queries.filter(({ text }) =>
/INSERT INTO "ql3"\."step_run_mutations"/.test(text),
).length,
2,
);
assert.equal(
queries.filter(({ text }) =>
/INSERT INTO "ql3"\."plugin_package_workflow_admissions"/.test(text),
).length,
1,
);
});
test('exactly replays durable admission after current StepRun progression', async () => {
const value = fixture('postgres-workflow-replay');
const bundle = createPluginPackageWorkflowAdmissionBundle(value.plan);
const collect = bundle.stepMutations.find(
({ stepRun }) => stepRun.stepKey === 'collect',
).stepRun;
const running = transitionStepRunMutation(
collect,
{
expectedVersion: collect.version,
expectedDigest: collect.stepRunDigest,
mutationId: 'postgres-workflow-progress-running',
to: 'running',
atMs: 4_000,
},
{
expectedRunVersion: bundle.run.version,
expectedRunEventSequence: bundle.run.eventSequence,
eventId: 'postgres-workflow-running-event',
dedupeKey: 'postgres-workflow-running-event',
actor: { type: 'executor' },
},
);
const succeeded = transitionStepRunMutation(
running.stepRun,
{
expectedVersion: running.stepRun.version,
expectedDigest: running.stepRun.stepRunDigest,
mutationId: 'postgres-workflow-progress-success',
to: 'succeeded',
atMs: 5_000,
},
{
expectedRunVersion: bundle.run.version + 1,
expectedRunEventSequence: bundle.run.eventSequence + 1,
eventId: 'postgres-workflow-success-event',
dedupeKey: 'postgres-workflow-success-event',
actor: { type: 'executor' },
},
);
const queries = [];
const client = {
async query(text, values) {
queries.push({ text, values });
if (
text.includes('plugin_package_workflow_admissions') &&
text.includes('WHERE plan_id')
) {
return {
rows: [admissionRow(bundle)],
};
}
if (text.includes('FROM "ql3"."runs" WHERE id')) {
return {
rows: [
runRow({
...bundle.run,
version: bundle.run.version + 2,
eventSequence: bundle.run.eventSequence + 2,
}),
],
};
}
if (text.includes('FROM "ql3"."run_events"')) {
return {
rows: [
eventRow(bundle.admissionEvent),
...bundle.stepMutations.map(({ event }) => eventRow(event)),
],
};
}
if (
text.includes('FROM "ql3"."plugin_package_workflow_admission_steps"')
) {
return {
rows: [
stepEvidenceRow(
bundle,
values[1],
values[1] === 'collect' ? succeeded.stepRun : undefined,
),
],
};
}
return { rows: [] };
},
release() {},
};
const repository = new PostgresPluginPackageWorkflowAdmissionRepository(
poolWithClient(client),
);
assert.deepEqual(await repository.findPlanByPlanId(value.plan.planId), value.plan);
assert.deepEqual(await repository.admit(value.plan), {
status: 'existing',
receipt: bundle.receipt,
});
assert.equal(
queries.some(({ text }) =>
text.includes('plugin_package_workflow_admission_snapshot'),
),
false,
);
const initialEventsQuery = queries.find(({ text }) =>
text.includes('FROM "ql3"."run_events"'),
);
assert.match(initialEventsQuery.text, /sequence <= \$2/);
assert.deepEqual(initialEventsQuery.values, [
value.plan.runId,
bundle.receipt.finalRunEventSequence,
]);
assert.equal(queries.at(-1).text, 'COMMIT');
});
test('rolls back without Run evidence when the database guard denies admission', async () => {
const value = fixture('postgres-workflow-denied');
const queries = [];
const client = {
async query(text, values) {
queries.push({ text, values });
if (
text.includes('plugin_package_workflow_admissions') &&
text.includes('WHERE plan_id')
) {
return { rows: [] };
}
if (text.includes('plugin_package_workflow_admission_snapshot')) {
return { rows: [] };
}
return { rows: [] };
},
release() {},
};
const repository = new PostgresPluginPackageWorkflowAdmissionRepository(
poolWithClient(client),
);
await assert.rejects(
repository.admit(value.plan),
PluginPackageWorkflowAdmissionNotAllowedError,
);
assert.equal(queries.at(-1).text, 'ROLLBACK');
assert.equal(
queries.some(({ text }) => /INSERT INTO "ql3"\."runs"/.test(text)),
false,
);
});
test('surfaces serialization failure as bounded retryable unavailability', async () => {
const value = fixture('postgres-workflow-serialization');
const serializationFailure = Object.assign(
new Error('could not serialize access'),
{ code: '40001' },
);
const client = {
async query(text) {
if (
text.includes('plugin_package_workflow_admissions') &&
text.includes('WHERE plan_id')
) {
throw serializationFailure;
}
return { rows: [] };
},
release() {},
};
const repository = new PostgresPluginPackageWorkflowAdmissionRepository(
poolWithClient(client),
);
await assert.rejects(
repository.admit(value.plan),
PluginPackageWorkflowAdmissionUnavailableError,
);
});
test('publishes Workflow admission only through its explicit cluster subpath', () => {
const authority = require('@qinglong/cluster-postgres/plugin-package-workflow-admission');
const root = require('../dist');
const runtime = require('@qinglong/cluster-postgres/runtime');
assert.equal(
authority.PostgresPluginPackageWorkflowAdmissionRepository,
PostgresPluginPackageWorkflowAdmissionRepository,
);
assert.equal(
root.PostgresPluginPackageWorkflowAdmissionRepository,
undefined,
);
assert.equal(
runtime.PostgresPluginPackageWorkflowAdmissionRepository,
undefined,
);
});