mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:25:29 +08:00
273 lines
6.8 KiB
JavaScript
273 lines
6.8 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { test } = require('node:test');
|
|
const { ClusterControlRecoveryStoreError } = require('@qinglong/runtime-core');
|
|
const { PostgresClusterControlRecoveryClaimRepository } = require('../dist');
|
|
|
|
function sourceRows(observedAtMs = '1000') {
|
|
return [
|
|
{
|
|
observedAtMs,
|
|
kind: 'attempt',
|
|
id: 'attempt-1',
|
|
runId: 'run-1',
|
|
status: 'running',
|
|
createdAtMs: '900',
|
|
},
|
|
];
|
|
}
|
|
|
|
function claimRows() {
|
|
return [
|
|
{
|
|
targetKind: 'attempt',
|
|
targetId: 'attempt-1',
|
|
runId: 'run-1',
|
|
targetStatus: 'running',
|
|
targetCreatedAtMs: '900',
|
|
observedAtMs: '1000',
|
|
claimOwner: 'replica-a',
|
|
claimToken: '00000000-0000-4000-8000-000000000001',
|
|
claimVersion: 1,
|
|
claimExpiresAtMs: '31000',
|
|
},
|
|
];
|
|
}
|
|
|
|
test('discovers and claims one bounded page in a short transaction', async () => {
|
|
const calls = [];
|
|
let released = false;
|
|
const client = {
|
|
async query(text, values = []) {
|
|
calls.push({ text, values });
|
|
if (text.includes('run_candidates AS')) return { rows: sourceRows() };
|
|
if (text.startsWith('INSERT INTO "ql3"."run_recovery_controls"')) {
|
|
return { rows: [], rowCount: 1 };
|
|
}
|
|
if (text.includes('FOR UPDATE OF control SKIP LOCKED')) {
|
|
return { rows: claimRows(), rowCount: 1 };
|
|
}
|
|
return { rows: [] };
|
|
},
|
|
release() {
|
|
released = true;
|
|
},
|
|
};
|
|
const repository = new PostgresClusterControlRecoveryClaimRepository(
|
|
{
|
|
async connect() {
|
|
return client;
|
|
},
|
|
async query() {
|
|
throw new Error('pool query not expected');
|
|
},
|
|
},
|
|
() => '00000000-0000-4000-8000-000000000001',
|
|
);
|
|
|
|
const page = await repository.claim({
|
|
ownerId: 'replica-a',
|
|
limit: 4,
|
|
leaseMs: 30_000,
|
|
});
|
|
|
|
assert.equal(page.discovered, 1);
|
|
assert.equal(page.hasMore, false);
|
|
assert.deepEqual(page.claims[0], {
|
|
candidate: {
|
|
kind: 'attempt',
|
|
id: 'attempt-1',
|
|
runId: 'run-1',
|
|
status: 'running',
|
|
createdAtMs: 900,
|
|
},
|
|
observedAtMs: 1000,
|
|
ownerId: 'replica-a',
|
|
token: '00000000-0000-4000-8000-000000000001',
|
|
version: 1,
|
|
expiresAtMs: 31000,
|
|
});
|
|
assert.deepEqual(
|
|
calls.map(({ text }) => text.split('\n', 1)[0]),
|
|
[
|
|
'BEGIN ISOLATION LEVEL READ COMMITTED',
|
|
"SET LOCAL statement_timeout = '5000ms'",
|
|
"SET LOCAL lock_timeout = '1000ms'",
|
|
'WITH observation AS (',
|
|
'INSERT INTO "ql3"."run_recovery_controls" (',
|
|
'WITH discovered AS (',
|
|
'COMMIT',
|
|
],
|
|
);
|
|
assert.deepEqual(calls[5].values.slice(1), [
|
|
1000,
|
|
4,
|
|
'replica-a',
|
|
'00000000-0000-4000-8000-000000000001',
|
|
30000,
|
|
]);
|
|
assert.equal(released, true);
|
|
});
|
|
|
|
test('uses an injected runtime-only discovery source without widening claim authority', async () => {
|
|
let sourceQueryable;
|
|
let sourceLimit;
|
|
const client = {
|
|
async query(text) {
|
|
if (text.startsWith('INSERT INTO "ql3"."run_recovery_controls"')) {
|
|
return { rows: [], rowCount: 1 };
|
|
}
|
|
if (text.includes('FOR UPDATE OF control SKIP LOCKED')) {
|
|
return { rows: claimRows(), rowCount: 1 };
|
|
}
|
|
return { rows: [] };
|
|
},
|
|
release() {},
|
|
};
|
|
const repository = new PostgresClusterControlRecoveryClaimRepository(
|
|
{
|
|
async connect() {
|
|
return client;
|
|
},
|
|
async query() {
|
|
throw new Error('not expected');
|
|
},
|
|
},
|
|
() => '00000000-0000-4000-8000-000000000001',
|
|
(queryable) => {
|
|
sourceQueryable = queryable;
|
|
return {
|
|
async listOutstanding(limit) {
|
|
sourceLimit = limit;
|
|
return {
|
|
observedAtMs: 1000,
|
|
candidates: [{
|
|
kind: 'attempt',
|
|
id: 'attempt-1',
|
|
runId: 'run-1',
|
|
status: 'running',
|
|
createdAtMs: 900,
|
|
}],
|
|
hasMore: false,
|
|
};
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
const page = await repository.claim({
|
|
ownerId: 'replica-a',
|
|
limit: 4,
|
|
leaseMs: 30_000,
|
|
});
|
|
assert.equal(sourceQueryable, client);
|
|
assert.equal(sourceLimit, 4);
|
|
assert.equal(page.claims.length, 1);
|
|
});
|
|
|
|
test('rolls back and wraps claim-store failures without leaking the client', async () => {
|
|
const calls = [];
|
|
let released = false;
|
|
const repository = new PostgresClusterControlRecoveryClaimRepository(
|
|
{
|
|
async connect() {
|
|
return {
|
|
async query(text) {
|
|
calls.push(text);
|
|
if (text.includes('run_candidates AS')) throw new Error('offline');
|
|
return { rows: [] };
|
|
},
|
|
release() {
|
|
released = true;
|
|
},
|
|
};
|
|
},
|
|
async query() {
|
|
throw new Error('not expected');
|
|
},
|
|
},
|
|
() => '00000000-0000-4000-8000-000000000002',
|
|
);
|
|
|
|
await assert.rejects(
|
|
repository.claim({ ownerId: 'replica-b', limit: 1, leaseMs: 1000 }),
|
|
ClusterControlRecoveryStoreError,
|
|
);
|
|
assert.equal(calls.at(-1), 'ROLLBACK');
|
|
assert.equal(released, true);
|
|
});
|
|
|
|
test('settles only under the full owner-token-version-expiry fence', async () => {
|
|
const calls = [];
|
|
const results = [
|
|
{ rows: [{ targetId: 'attempt-1' }], rowCount: 1 },
|
|
{ rows: [], rowCount: 0 },
|
|
];
|
|
const repository = new PostgresClusterControlRecoveryClaimRepository({
|
|
async connect() {
|
|
throw new Error('not expected');
|
|
},
|
|
async query(text, values) {
|
|
calls.push({ text, values });
|
|
return results.shift();
|
|
},
|
|
});
|
|
const claim = Object.freeze({
|
|
candidate: Object.freeze({
|
|
kind: 'attempt',
|
|
id: 'attempt-1',
|
|
runId: 'run-1',
|
|
status: 'running',
|
|
createdAtMs: 900,
|
|
}),
|
|
observedAtMs: 1000,
|
|
ownerId: 'replica-a',
|
|
token: '00000000-0000-4000-8000-000000000001',
|
|
version: 7,
|
|
expiresAtMs: 31000,
|
|
});
|
|
|
|
assert.equal(
|
|
await repository.settle(claim, { status: 'retry', delayMs: 2500 }),
|
|
'settled',
|
|
);
|
|
assert.equal(
|
|
await repository.settle(claim, { status: 'resolved' }),
|
|
'fenced',
|
|
);
|
|
assert.match(
|
|
calls[0].text,
|
|
/claim_expires_at_ms > observation\.observed_at_ms/,
|
|
);
|
|
assert.deepEqual(calls[0].values, [
|
|
'attempt',
|
|
'attempt-1',
|
|
'replica-a',
|
|
'00000000-0000-4000-8000-000000000001',
|
|
7,
|
|
'retry',
|
|
2500,
|
|
]);
|
|
});
|
|
|
|
test('rejects unsafe options before acquiring a database connection', async () => {
|
|
let connects = 0;
|
|
const repository = new PostgresClusterControlRecoveryClaimRepository({
|
|
async connect() {
|
|
connects += 1;
|
|
throw new Error('not expected');
|
|
},
|
|
async query() {
|
|
throw new Error('not expected');
|
|
},
|
|
});
|
|
await assert.rejects(
|
|
repository.claim({ ownerId: 'bad owner', limit: 1, leaseMs: 1000 }),
|
|
/ownerId is invalid/,
|
|
);
|
|
await assert.rejects(
|
|
repository.claim({ ownerId: 'ok', limit: 129, leaseMs: 1000 }),
|
|
/claim limit/,
|
|
);
|
|
assert.equal(connects, 0);
|
|
});
|