mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): add cluster legacy env migration plan ledger
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ClusterLegacyEnvMigrationPlanConflictError,
|
||||
ClusterLegacyEnvMigrationPlanUnavailableError,
|
||||
} = require('@qinglong/runtime-core/cluster-legacy-env-migration-plan');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
PostgresClusterLegacyEnvMigrationPlanRepository,
|
||||
} = require('@qinglong/cluster-postgres/cluster-legacy-env-migration-plan');
|
||||
|
||||
function intent(overrides = {}) {
|
||||
const projectId = overrides.projectId ?? 'project-a';
|
||||
return {
|
||||
planId: 'legacy-env-plan-a',
|
||||
mutationId: 'legacy-env-mutation-a',
|
||||
projectId,
|
||||
source: {
|
||||
reconciliationBundleDigest: '1'.repeat(64),
|
||||
decisionDigest: '2'.repeat(64),
|
||||
candidateSetDigest: '3'.repeat(64),
|
||||
sourceRowCount: 3,
|
||||
activeRowCount: 2,
|
||||
disabledRowCount: 1,
|
||||
effectiveBindingCount: 2,
|
||||
},
|
||||
target: {
|
||||
secretRef: createSecretRef({
|
||||
projectId,
|
||||
name: 'legacy-env-bundle',
|
||||
version: 7,
|
||||
}),
|
||||
taskRevisionSetDigest: '4'.repeat(64),
|
||||
triggerRevisionSetDigest: '5'.repeat(64),
|
||||
taskCount: 2,
|
||||
triggerCount: 3,
|
||||
totalEffectiveBytes: 1024,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(options = {}) {
|
||||
const plansById = new Map();
|
||||
const plansByMutation = new Map();
|
||||
const queries = [];
|
||||
let serializationFailures = options.serializationFailures ?? 0;
|
||||
let connections = 0;
|
||||
|
||||
const pool = {
|
||||
async query(text, values) {
|
||||
queries.push({ scope: 'pool', text, values });
|
||||
if (text.includes('WHERE plan_id')) {
|
||||
const plan = plansById.get(values[0]);
|
||||
return {
|
||||
rows: plan ? [{ planJson: plan }] : [],
|
||||
rowCount: plan ? 1 : 0,
|
||||
};
|
||||
}
|
||||
throw new Error('unexpected pool query');
|
||||
},
|
||||
async connect() {
|
||||
connections += 1;
|
||||
return {
|
||||
async query(text, values) {
|
||||
queries.push({ scope: 'client', text, values });
|
||||
if (
|
||||
text === 'BEGIN ISOLATION LEVEL SERIALIZABLE' ||
|
||||
text === 'COMMIT' ||
|
||||
text === 'ROLLBACK' ||
|
||||
text.includes("set_config('")
|
||||
) {
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (text.includes('WHERE mutation_id')) {
|
||||
if (serializationFailures > 0) {
|
||||
serializationFailures -= 1;
|
||||
throw Object.assign(new Error('serialization retry'), {
|
||||
code: '40001',
|
||||
});
|
||||
}
|
||||
const plan = plansByMutation.get(values[0]);
|
||||
return {
|
||||
rows: plan ? [{ planJson: plan }] : [],
|
||||
rowCount: plan ? 1 : 0,
|
||||
};
|
||||
}
|
||||
if (text.includes('FROM "ql3"."projects"')) {
|
||||
return options.projectStatus === 'archived'
|
||||
? { rows: [{ status: 'archived' }], rowCount: 1 }
|
||||
: { rows: [{ status: 'active' }], rowCount: 1 };
|
||||
}
|
||||
if (text.includes('WHERE plan_id')) {
|
||||
const plan = plansById.get(values[0]);
|
||||
return {
|
||||
rows: plan ? [{ planJson: plan }] : [],
|
||||
rowCount: plan ? 1 : 0,
|
||||
};
|
||||
}
|
||||
if (text.includes('transaction_timestamp')) {
|
||||
return { rows: [{ plannedAtMs: '12345' }], rowCount: 1 };
|
||||
}
|
||||
if (text.includes('INSERT INTO')) {
|
||||
const plan = JSON.parse(values[18]);
|
||||
plansById.set(plan.planId, plan);
|
||||
plansByMutation.set(plan.mutationId, plan);
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
if (text === 'SELECT hook_boundary') {
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
throw new Error(`unexpected client query: ${text}`);
|
||||
},
|
||||
release() {
|
||||
queries.push({ scope: 'client', text: 'RELEASE' });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
repository: new PostgresClusterLegacyEnvMigrationPlanRepository(pool),
|
||||
plansById,
|
||||
plansByMutation,
|
||||
queries,
|
||||
connectionCount: () => connections,
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes and exactly replays one content-free plan in serializable transactions', async () => {
|
||||
const state = fixture();
|
||||
const hookContexts = [];
|
||||
const hook = async (client, context) => {
|
||||
hookContexts.push(context);
|
||||
await client.query('SELECT hook_boundary');
|
||||
};
|
||||
const created = await state.repository.publish(intent(), hook);
|
||||
const replay = await state.repository.publish(intent(), hook);
|
||||
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay.plan, created.plan);
|
||||
assert.equal(state.plansById.size, 1);
|
||||
assert.equal(hookContexts[0].replay, null);
|
||||
assert.deepEqual(hookContexts[1].replay, created.plan);
|
||||
assert.equal(
|
||||
state.queries.filter(
|
||||
({ text }) => text === 'BEGIN ISOLATION LEVEL SERIALIZABLE',
|
||||
).length,
|
||||
2,
|
||||
);
|
||||
assert.equal(state.queries.filter(({ text }) => text === 'COMMIT').length, 2);
|
||||
|
||||
const insert = state.queries.find(({ text }) => text.includes('INSERT INTO'));
|
||||
const encodedPlan = insert.values[18];
|
||||
assert.doesNotMatch(encodedPlan, /TOKEN|secretValue|ciphertext|keyId/i);
|
||||
assert.equal(
|
||||
JSON.parse(encodedPlan).target.secretRef,
|
||||
intent().target.secretRef,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects mutation replay drift and inactive Projects without writing', async () => {
|
||||
const replayState = fixture();
|
||||
await replayState.repository.publish(intent());
|
||||
await assert.rejects(
|
||||
replayState.repository.publish(
|
||||
intent({
|
||||
target: { ...intent().target, taskCount: 3 },
|
||||
}),
|
||||
),
|
||||
ClusterLegacyEnvMigrationPlanConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
replayState.queries.filter(({ text }) => text.includes('INSERT INTO'))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
replayState.queries.some(({ text }) => text === 'ROLLBACK'),
|
||||
true,
|
||||
);
|
||||
|
||||
const archivedState = fixture({ projectStatus: 'archived' });
|
||||
await assert.rejects(
|
||||
archivedState.repository.publish(intent()),
|
||||
ClusterLegacyEnvMigrationPlanConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
archivedState.queries.some(({ text }) => text.includes('INSERT INTO')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('retries bounded serializable failures and preserves the transaction hook error', async () => {
|
||||
const state = fixture({ serializationFailures: 1 });
|
||||
const created = await state.repository.publish(intent());
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(state.connectionCount(), 2);
|
||||
assert.equal(
|
||||
state.queries.filter(({ text }) => text === 'ROLLBACK').length,
|
||||
1,
|
||||
);
|
||||
|
||||
const hookError = new Error('caller hook failed');
|
||||
await assert.rejects(
|
||||
fixture().repository.publish(intent(), async () => {
|
||||
throw hookError;
|
||||
}),
|
||||
(error) => error === hookError,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on malformed durable JSON and hides raw storage errors', async () => {
|
||||
const malformedPool = {
|
||||
async query() {
|
||||
return { rows: [{ planJson: { schema: 'wrong' } }], rowCount: 1 };
|
||||
},
|
||||
async connect() {
|
||||
throw new Error('unused');
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
new PostgresClusterLegacyEnvMigrationPlanRepository(
|
||||
malformedPool,
|
||||
).findByPlanId('legacy-env-plan-a'),
|
||||
ClusterLegacyEnvMigrationPlanUnavailableError,
|
||||
);
|
||||
|
||||
const identityDrift = fixture();
|
||||
const created = await identityDrift.repository.publish(intent());
|
||||
identityDrift.plansById.set('legacy-env-plan-b', created.plan);
|
||||
await assert.rejects(
|
||||
identityDrift.repository.findByPlanId('legacy-env-plan-b'),
|
||||
ClusterLegacyEnvMigrationPlanUnavailableError,
|
||||
);
|
||||
identityDrift.plansByMutation.set('legacy-env-mutation-b', created.plan);
|
||||
await assert.rejects(
|
||||
identityDrift.repository.publish(
|
||||
intent({
|
||||
planId: 'legacy-env-plan-b',
|
||||
mutationId: 'legacy-env-mutation-b',
|
||||
}),
|
||||
),
|
||||
ClusterLegacyEnvMigrationPlanUnavailableError,
|
||||
);
|
||||
|
||||
const failedPool = {
|
||||
async query() {
|
||||
throw new Error('password=do-not-leak');
|
||||
},
|
||||
async connect() {
|
||||
throw new Error('unused');
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
new PostgresClusterLegacyEnvMigrationPlanRepository(
|
||||
failedPool,
|
||||
).findByPlanId('legacy-env-plan-a'),
|
||||
(error) => {
|
||||
assert.ok(error instanceof ClusterLegacyEnvMigrationPlanUnavailableError);
|
||||
assert.doesNotMatch(error.message, /password|do-not-leak/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the append authority behind its explicit package subpath', () => {
|
||||
const root = require('@qinglong/cluster-postgres');
|
||||
const runtime = require('@qinglong/cluster-postgres/runtime');
|
||||
const admin = require('@qinglong/cluster-postgres/admin');
|
||||
const authority = require('@qinglong/cluster-postgres/cluster-legacy-env-migration-plan');
|
||||
assert.equal(root.PostgresClusterLegacyEnvMigrationPlanRepository, undefined);
|
||||
assert.equal(
|
||||
runtime.PostgresClusterLegacyEnvMigrationPlanRepository,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
admin.PostgresClusterLegacyEnvMigrationPlanRepository,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
typeof authority.PostgresClusterLegacyEnvMigrationPlanRepository,
|
||||
'function',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user