mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
345 lines
11 KiB
JavaScript
345 lines
11 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { test } = require('node:test');
|
|
|
|
const {
|
|
consumeApprovalRequest,
|
|
createApprovalRequest,
|
|
decideApprovalRequest,
|
|
} = require('@qinglong/runtime-core/approved-action');
|
|
const {
|
|
approvedActionExecutionEffectiveStatus,
|
|
claimApprovedActionExecution,
|
|
completeApprovedActionExecution,
|
|
createApprovedActionExecution,
|
|
releaseApprovedActionExecutionBeforeStart,
|
|
startApprovedActionExecution,
|
|
} = require('@qinglong/runtime-core/approved-action-execution');
|
|
const {
|
|
ApprovedActionDispatcher,
|
|
} = require('@qinglong/runtime-core/approved-action-dispatcher');
|
|
|
|
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
|
const DISPATCHER = Object.freeze({ type: 'system', id: 'dispatcher' });
|
|
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
|
const ACTION_DIGEST = 'a'.repeat(64);
|
|
const RESULT_DIGEST = 'c'.repeat(64);
|
|
|
|
function dispatch() {
|
|
const pending = createApprovalRequest({
|
|
id: 'approval-dispatcher-v1',
|
|
projectId: 'default',
|
|
action: {
|
|
permission: 'package.manage',
|
|
actionType: 'plugin_package.install',
|
|
actionRef: 'proposal:dispatcher-v1',
|
|
actionDigest: ACTION_DIGEST,
|
|
previewDigest: 'b'.repeat(64),
|
|
},
|
|
risk: 'high',
|
|
decisionMode: 'human_confirmation',
|
|
requestedBy: REQUESTER,
|
|
requestedAtMs: 10,
|
|
expiresAtMs: 10_000,
|
|
requestFence: FENCE,
|
|
});
|
|
const approved = decideApprovalRequest(pending, {
|
|
expectedVersion: 1,
|
|
decisionId: 'decision-dispatcher-v1',
|
|
decision: 'approved',
|
|
reasonCode: 'reviewed',
|
|
principal: {
|
|
subject: REQUESTER,
|
|
authenticationId: 'auth-owner',
|
|
authenticatedAtMs: 15,
|
|
expiresAtMs: 5_000,
|
|
assurance: 'local_console',
|
|
},
|
|
decidedAtMs: 20,
|
|
authorizationFence: FENCE,
|
|
});
|
|
return consumeApprovalRequest(approved, {
|
|
expectedVersion: 2,
|
|
consumptionId: 'consume-dispatcher-v1',
|
|
dispatchId: 'dispatch-dispatcher-v1',
|
|
action: pending.action,
|
|
requestedBy: REQUESTER,
|
|
consumedBy: DISPATCHER,
|
|
consumedAtMs: 30,
|
|
authorizationFence: FENCE,
|
|
}).dispatch;
|
|
}
|
|
|
|
class InMemoryExecutionRepository {
|
|
constructor(value, options = {}) {
|
|
this.dispatch = value;
|
|
this.execution = createApprovedActionExecution(value);
|
|
this.loseStartResponse = options.loseStartResponse === true;
|
|
this.loseCompletionResponse = options.loseCompletionResponse === true;
|
|
this.startCalls = 0;
|
|
this.completeCalls = 0;
|
|
}
|
|
|
|
snapshot() {
|
|
return Object.freeze({
|
|
dispatch: this.dispatch,
|
|
execution: this.execution,
|
|
});
|
|
}
|
|
|
|
async findExecutionByDispatchId(dispatchId) {
|
|
return dispatchId === this.dispatch.id ? this.snapshot() : null;
|
|
}
|
|
|
|
async listDueExecutions({ nowMs, limit, actionTypes }) {
|
|
const effective = approvedActionExecutionEffectiveStatus(
|
|
this.execution,
|
|
nowMs,
|
|
);
|
|
const due =
|
|
(effective === 'pending' || effective === 'retry_wait') &&
|
|
this.execution.eligibleAtMs <= nowMs &&
|
|
actionTypes.includes(this.dispatch.action.actionType);
|
|
return {
|
|
executions: due && limit > 0 ? [this.snapshot()] : [],
|
|
truncated: false,
|
|
};
|
|
}
|
|
|
|
async claimExecution(command) {
|
|
if (command.dispatchId !== this.dispatch.id) return { status: 'not_found' };
|
|
const effective = approvedActionExecutionEffectiveStatus(
|
|
this.execution,
|
|
command.nowMs,
|
|
);
|
|
if (effective !== 'pending' && effective !== 'retry_wait') {
|
|
return { status: effective, snapshot: this.snapshot() };
|
|
}
|
|
this.execution = claimApprovedActionExecution(this.execution, {
|
|
owner: command.owner,
|
|
leaseToken: command.leaseToken,
|
|
nowMs: command.nowMs,
|
|
leaseDurationMs: command.leaseDurationMs,
|
|
});
|
|
return { status: 'claimed', snapshot: this.snapshot() };
|
|
}
|
|
|
|
async startExecution(command) {
|
|
this.startCalls += 1;
|
|
this.execution = startApprovedActionExecution(this.snapshot(), command);
|
|
if (this.loseStartResponse) throw new Error('start response lost');
|
|
return this.snapshot();
|
|
}
|
|
|
|
async renewExecution() {
|
|
throw new Error('dispatcher must not renew after start');
|
|
}
|
|
|
|
async releaseExecutionBeforeStart(command) {
|
|
this.execution = releaseApprovedActionExecutionBeforeStart(
|
|
this.execution,
|
|
{
|
|
owner: command.owner,
|
|
leaseToken: command.leaseToken,
|
|
expectedVersion: command.expectedVersion,
|
|
resultMutationId: command.resultMutationId,
|
|
resultCode: command.resultCode,
|
|
atMs: command.atMs,
|
|
...(command.retryAtMs === undefined
|
|
? {}
|
|
: { retryAtMs: command.retryAtMs }),
|
|
},
|
|
);
|
|
return this.snapshot();
|
|
}
|
|
|
|
async completeExecution(command) {
|
|
this.completeCalls += 1;
|
|
this.execution = completeApprovedActionExecution(this.execution, {
|
|
owner: command.owner,
|
|
leaseToken: command.leaseToken,
|
|
expectedVersion: command.expectedVersion,
|
|
resultMutationId: command.resultMutationId,
|
|
outcome: command.outcome,
|
|
resultCode: command.resultCode,
|
|
...(command.resultDigest === undefined
|
|
? {}
|
|
: { resultDigest: command.resultDigest }),
|
|
completedAtMs: command.completedAtMs,
|
|
});
|
|
if (this.loseCompletionResponse) {
|
|
throw new Error('completion response lost');
|
|
}
|
|
return this.snapshot();
|
|
}
|
|
}
|
|
|
|
function createDispatcher(repository, handler) {
|
|
let id = 0;
|
|
return new ApprovedActionDispatcher(repository, [handler], {
|
|
owner: 'dispatcher_instance_1',
|
|
leaseDurationMs: 1_000,
|
|
retryBaseMs: 100,
|
|
retryMaxMs: 1_000,
|
|
defaultBatchSize: 1,
|
|
clock: () => 100,
|
|
createId: () => `dispatcher-id-${++id}`,
|
|
});
|
|
}
|
|
|
|
test('commits the start barrier before executing and completes one success', async () => {
|
|
const repository = new InMemoryExecutionRepository(dispatch());
|
|
let observed;
|
|
const dispatcher = createDispatcher(repository, {
|
|
actionType: 'plugin_package.install',
|
|
async inspect(value) {
|
|
return { status: 'ready', actionDigest: value.action.actionDigest };
|
|
},
|
|
async execute(context) {
|
|
observed = context;
|
|
return {
|
|
outcome: 'succeeded',
|
|
resultCode: 'package_admitted',
|
|
resultDigest: RESULT_DIGEST,
|
|
};
|
|
},
|
|
});
|
|
const summary = await dispatcher.dispatchBatch();
|
|
assert.deepEqual(summary, {
|
|
scanned: 1,
|
|
claimed: 1,
|
|
started: 1,
|
|
succeeded: 1,
|
|
failed: 0,
|
|
blocked: 0,
|
|
retrying: 0,
|
|
deferred: 0,
|
|
recoveryRequired: 0,
|
|
alreadyTerminal: 0,
|
|
unavailable: 0,
|
|
truncated: false,
|
|
});
|
|
assert.equal(observed.execution.status, 'executing');
|
|
assert.equal(observed.execution.version, observed.fence.version);
|
|
assert.equal(repository.execution.status, 'succeeded');
|
|
assert.equal(repository.execution.resultDigest, RESULT_DIGEST);
|
|
});
|
|
|
|
test('retries inspection only before start and blocks an exception after start', async () => {
|
|
const retryRepository = new InMemoryExecutionRepository(dispatch());
|
|
const retrying = await createDispatcher(retryRepository, {
|
|
actionType: 'plugin_package.install',
|
|
async inspect() {
|
|
return { status: 'retry', resultCode: 'proposal_unavailable' };
|
|
},
|
|
async execute() {
|
|
throw new Error('must not execute');
|
|
},
|
|
}).dispatchBatch();
|
|
assert.equal(retrying.retrying, 1);
|
|
assert.equal(retrying.started, 0);
|
|
assert.equal(retryRepository.execution.status, 'retry_wait');
|
|
|
|
const blockedRepository = new InMemoryExecutionRepository(dispatch());
|
|
const blocked = await createDispatcher(blockedRepository, {
|
|
actionType: 'plugin_package.install',
|
|
async inspect() {
|
|
return { status: 'ready', actionDigest: ACTION_DIGEST };
|
|
},
|
|
async execute() {
|
|
throw new Error('outcome is indeterminate');
|
|
},
|
|
}).dispatchBatch();
|
|
assert.equal(blocked.started, 1);
|
|
assert.equal(blocked.blocked, 1);
|
|
assert.equal(blocked.retrying, 0);
|
|
assert.equal(blockedRepository.execution.status, 'blocked');
|
|
assert.equal(
|
|
blockedRepository.execution.resultCode,
|
|
'handler_failed_after_start',
|
|
);
|
|
});
|
|
|
|
test('converges lost start and completion responses without repeating effects', async () => {
|
|
const repository = new InMemoryExecutionRepository(dispatch(), {
|
|
loseStartResponse: true,
|
|
loseCompletionResponse: true,
|
|
});
|
|
let effects = 0;
|
|
const summary = await createDispatcher(repository, {
|
|
actionType: 'plugin_package.install',
|
|
async inspect() {
|
|
return { status: 'ready', actionDigest: ACTION_DIGEST };
|
|
},
|
|
async execute() {
|
|
effects += 1;
|
|
return {
|
|
outcome: 'succeeded',
|
|
resultCode: 'package_admitted',
|
|
resultDigest: RESULT_DIGEST,
|
|
};
|
|
},
|
|
}).dispatchBatch();
|
|
assert.equal(summary.succeeded, 1);
|
|
assert.equal(summary.unavailable, 0);
|
|
assert.equal(repository.startCalls, 1);
|
|
assert.equal(repository.completeCalls, 1);
|
|
assert.equal(effects, 1);
|
|
});
|
|
|
|
test('does not claim an action without a matching handler', async () => {
|
|
const repository = new InMemoryExecutionRepository(dispatch());
|
|
let id = 0;
|
|
const dispatcher = new ApprovedActionDispatcher(repository, [], {
|
|
owner: 'dispatcher_instance_1',
|
|
clock: () => 100,
|
|
createId: () => `dispatcher-id-${++id}`,
|
|
});
|
|
const summary = await dispatcher.dispatchBatch({ limit: 1 });
|
|
assert.equal(summary.scanned, 0);
|
|
assert.equal(summary.claimed, 0);
|
|
assert.equal(summary.blocked, 0);
|
|
assert.equal(summary.started, 0);
|
|
assert.equal(repository.execution.status, 'pending');
|
|
assert.equal(repository.startCalls, 0);
|
|
});
|
|
|
|
test('dispatches only the requested durable action without a queue scan', async () => {
|
|
const repository = new InMemoryExecutionRepository(dispatch());
|
|
repository.listDueExecutions = async () => {
|
|
throw new Error('exact dispatch must not scan');
|
|
};
|
|
const summary = await createDispatcher(repository, {
|
|
actionType: 'plugin_package.install',
|
|
async inspect(value) {
|
|
return { status: 'ready', actionDigest: value.action.actionDigest };
|
|
},
|
|
async execute() {
|
|
return {
|
|
outcome: 'succeeded',
|
|
resultCode: 'package_admitted',
|
|
resultDigest: RESULT_DIGEST,
|
|
};
|
|
},
|
|
}).dispatchById({ dispatchId: 'dispatch-dispatcher-v1' });
|
|
assert.equal(summary.scanned, 1);
|
|
assert.equal(summary.claimed, 1);
|
|
assert.equal(summary.succeeded, 1);
|
|
assert.equal(summary.truncated, false);
|
|
});
|
|
|
|
test('exact dispatch does not claim an action outside configured authority', async () => {
|
|
const repository = new InMemoryExecutionRepository(dispatch());
|
|
const dispatcher = new ApprovedActionDispatcher(repository, [], {
|
|
owner: 'dispatcher_instance_1',
|
|
clock: () => 100,
|
|
createId: () => 'dispatcher-exact-id',
|
|
});
|
|
const summary = await dispatcher.dispatchById({
|
|
dispatchId: 'dispatch-dispatcher-v1',
|
|
});
|
|
assert.equal(summary.scanned, 1);
|
|
assert.equal(summary.claimed, 0);
|
|
assert.equal(summary.unavailable, 1);
|
|
assert.equal(repository.execution.status, 'pending');
|
|
});
|