feat(M01-C): 建立MySQL异步任务基础

This commit is contained in:
Codex
2026-06-18 10:20:39 +08:00
parent a2d578f73b
commit 2b90d1f101
15 changed files with 609 additions and 30 deletions
+14 -1
View File
@@ -12,6 +12,9 @@ const downSql = read('database/migrations/2026061601_m01b_core_schema.down.sql')
const verifySql = read('database/migrations/2026061601_m01b_core_schema.verify.sql');
const seedSql = read('database/seeds/2026061601_m01b_minimal_seed.sql');
const legacyFixtureSql = read('database/fixtures/2026061801_m01b_legacy_schema.sql');
const asyncUpSql = read('database/migrations/2026061802_m01c_async_tasks.up.sql');
const asyncDownSql = read('database/migrations/2026061802_m01c_async_tasks.down.sql');
const asyncVerifySql = read('database/migrations/2026061802_m01c_async_tasks.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -73,4 +76,14 @@ assert.match(legacyFixtureSql, /DECIMAL\(10,\s*2\)/);
assert.match(legacyFixtureSql, /LEGACY-SANITIZED-001/);
assert.doesNotMatch(legacyFixtureSql, /(?:https?:\/\/|@|password|secret|token)/i);
console.log('PASS: M01-B migration contract is present.');
for (const table of ['qipai_outbox_events', 'qipai_async_tasks']) {
assert.match(asyncUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`));
assert.match(asyncDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`));
assert.match(asyncVerifySql, new RegExp(`'${table}'`));
}
assert.match(asyncUpSql, /UNIQUE KEY uq_qipai_outbox_events_idempotency/);
assert.match(asyncUpSql, /UNIQUE KEY uq_qipai_async_tasks_idempotency/);
assert.match(asyncUpSql, /lease_expires_at DATETIME\(3\)/);
assert.match(asyncUpSql, /COMPENSATION_REQUIRED|status VARCHAR/);
console.log('PASS: M01-B/M01-C migration contracts are present.');
+2 -1
View File
@@ -12,7 +12,8 @@ assert.deepEqual(
const plan = await loadMigrationPlan('up');
assert.equal(plan.direction, 'up');
assert.match(plan.file, /2026061601_m01b_core_schema\.up\.sql$/);
assert.match(plan.file, /2026061601_m01b_core_schema\.up\.sql/);
assert.match(plan.file, /2026061802_m01c_async_tasks\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
import { loadConfig } from '../dist/config.js';
import { closeMySqlPool, createMySqlPool } from '../dist/db/mysql.js';
import { LegacyReadRepository } from '../dist/db/legacy-read-repository.js';
import { TaskRepository } from '../dist/tasks/task-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -12,11 +13,13 @@ import {
} from '../dist/db/migration-runner.js';
const expectedTables = [
'qipai_async_tasks',
'qipai_audit_logs',
'qipai_devices',
'qipai_legacy_table_mappings',
'qipai_members',
'qipai_orders',
'qipai_outbox_events',
'qipai_payments',
'qipai_rooms',
'qipai_schema_migrations',
@@ -38,12 +41,13 @@ async function readCoreTables(pool) {
return rows.map((row) => row.tableName);
}
async function readMigrationVersion(pool) {
async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version = ?`,
['2026061601']
WHERE version IN (?, ?)
ORDER BY version`,
['2026061601', '2026061802']
);
return rows;
}
@@ -79,11 +83,48 @@ async function assertLegacyCompatibility(pool) {
assert.equal(orders[0].refundAmountCents, null);
}
async function assertTaskDurability(pool) {
await pool.query(
`INSERT INTO qipai_tenants (code, name)
VALUES ('M01C-TEST', 'M01C sanitized test tenant')`
);
const firstRepository = new TaskRepository(pool);
const firstEnqueue = await firstRepository.enqueue({
tenantId: '1',
taskType: 'order.advance',
idempotencyKey: 'order:501:paid',
payload: { orderId: '501' },
maxAttempts: 3
});
const duplicateEnqueue = await firstRepository.enqueue({
tenantId: '1',
taskType: 'order.advance',
idempotencyKey: 'order:501:paid',
payload: { orderId: '501' },
maxAttempts: 3
});
assert.equal(firstEnqueue.created, true);
assert.equal(duplicateEnqueue.created, false);
assert.equal(duplicateEnqueue.id, firstEnqueue.id);
const restartedRepository = new TaskRepository(pool);
const claimed = await restartedRepository.claimNext('worker-after-restart', 30_000);
assert.equal(claimed?.id, firstEnqueue.id);
assert.equal(claimed?.attempts, 1);
assert.equal(await restartedRepository.complete(claimed.id, 'worker-after-restart'), true);
const [rows] = await pool.query(
`SELECT status, attempts FROM qipai_async_tasks WHERE id = ?`,
[firstEnqueue.id]
);
assert.deepEqual(rows, [{ status: 'SUCCEEDED', attempts: 1 }]);
}
const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match(
config.mysql.database,
/^qipai_m01b_test_[a-z0-9_]+$/,
/^qipai_m01c_test_[a-z0-9_]+$/,
'Live migration test refuses to use a non-temporary database.'
);
@@ -102,25 +143,26 @@ try {
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
assert.deepEqual(await readCoreTables(pool), expectedTables);
assert.deepEqual(await readMigrationVersion(pool), [{
version: '2026061601',
name: 'm01b_core_schema'
}]);
assert.deepEqual(await readMigrationVersions(pool), [
{ version: '2026061601', name: 'm01b_core_schema' },
{ version: '2026061802', name: 'm01c_async_tasks' }
]);
await assertTaskDurability(pool);
await assertLegacyCompatibility(pool);
console.log('PASS: first up and verify completed.');
console.log('PASS: first up, verify and durable task restart check completed.');
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B core tables.');
console.log('PASS: down removed all M01-B/M01-C tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
assert.deepEqual(await readCoreTables(pool), expectedTables);
assert.deepEqual(await readMigrationVersion(pool), [{
version: '2026061601',
name: 'm01b_core_schema'
}]);
assert.deepEqual(await readMigrationVersions(pool), [
{ version: '2026061601', name: 'm01b_core_schema' },
{ version: '2026061802', name: 'm01c_async_tasks' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { OutboxRepository } from '../dist/tasks/outbox-repository.js';
import { retryDelayMs, taskTypes } from '../dist/tasks/task-repository.js';
import { TaskWorker } from '../dist/tasks/worker.js';
assert.deepEqual(taskTypes, [
'notification.dispatch',
'order.advance',
'device.command',
'refund.query',
'statistics.aggregate',
'outbox.publish'
]);
assert.equal(retryDelayMs(1), 1000);
assert.equal(retryDelayMs(4), 8000);
assert.equal(retryDelayMs(99), 60 * 60 * 1000);
const outboxCalls = [];
const outbox = new OutboxRepository({
async execute(sql, params) {
outboxCalls.push([sql, params]);
return [{ insertId: 7, affectedRows: 1 }, []];
}
});
assert.deepEqual(
await outbox.append({
tenantId: '1',
aggregateType: 'order',
aggregateId: '88',
eventType: 'order.paid',
idempotencyKey: 'order:88:paid',
payload: { orderId: '88' }
}),
{ id: '7', created: true }
);
assert.match(outboxCalls[0][0], /INSERT IGNORE INTO qipai_outbox_events/);
const calls = [];
const task = {
id: '42',
tenantId: '1',
taskType: 'notification.dispatch',
idempotencyKey: 'notice:42',
payload: { channel: 'test' },
status: 'RUNNING',
attempts: 1,
maxAttempts: 3
};
const repository = {
async claimNext() {
calls.push('claim');
return task;
},
async complete(id) {
calls.push(`complete:${id}`);
return true;
},
async fail() {
calls.push('fail');
}
};
const worker = new TaskWorker({
repository,
handlers: new Map([['notification.dispatch', async (claimed) => {
calls.push(`handle:${claimed.id}`);
}]]),
workerId: 'test-worker'
});
assert.equal(await worker.runOnce(), true);
assert.deepEqual(calls, ['claim', 'handle:42', 'complete:42']);
const failedCalls = [];
const failedWorker = new TaskWorker({
repository: {
async claimNext() { return task; },
async complete() { return true; },
async fail(claimed, workerId, error) {
failedCalls.push([claimed.id, workerId, error.message]);
}
},
handlers: new Map(),
workerId: 'test-worker'
});
assert.equal(await failedWorker.runOnce(), true);
assert.deepEqual(failedCalls, [['42', 'test-worker', 'No handler registered for task type notification.dispatch.']]);
console.log('PASS: M01-C task retry and worker contracts are present.');