import { performance } from 'node:perf_hooks'; import { qualified, sha256 } from './core.mjs'; import { applyChanges } from './journal.mjs'; import { verifySourceBatch } from './source-batch.mjs'; /** * Switch a verified batch in one caller-session REPEATABLE READ transaction. * Source validation must be the first operation after BEGIN: it establishes * the source range lock before the transaction's consistent-read snapshot. */ export async function switchFiles(db, config, items) { if (!Array.isArray(items)) throw new TypeError('文件切换批次必须为数组'); const timings = { sourceMs: 0, writeMs: 0, statusMs: 0, commitMs: 0 }; if (!items.length) return timings; const changes = items.flatMap(item => { if (!Array.isArray(item?.files)) throw new TypeError('文件切换项必须包含 files 数组(无文件记录时使用空数组)'); return item.files.map(file => ({ schema: config.mysql.database, table: 'infra_file', primaryKey: 'id', pkValue: file.id, before: { config_id: file.config_id, path: file.path, url: file.url, type: file.type, size: file.size, deleted: Buffer.from([0]) }, after: { config_id: config.targetConfigId, path: item.key, url: item.url, type: item.type, size: item.size } })); }); const keys = items.map(item => sha256(`${item.configId}\0${item.path}`)); if (new Set(keys).size !== keys.length) throw new TypeError('文件切换批次不能包含重复源路径'); if (new Set(changes.map(change => String(change.pkValue))).size !== changes.length) { throw new TypeError('文件切换批次不能包含重复文件记录'); } const measure = async (name, action) => { const started = performance.now(); try { return await action(); } finally { timings[name] += performance.now() - started; } }; await db.beginTransaction(); let committing = false; try { // Do not insert metadata lookups or other consistent reads before this. await measure('sourceMs', () => verifySourceBatch(db, config, items)); await measure('writeMs', async () => { // One source path may have many infra_file rows. Bound prepared-statement // size while retaining the same source locks and transaction for all rows. for (let offset = 0; offset < changes.length; offset += 100) { await applyChanges(db, config.mysql.database, config.runId, changes.slice(offset, offset + 100)); } }); await measure('statusMs', async () => { const [result] = await db.execute( `UPDATE ${qualified(config.mysql.database, 'infra_file_oss_migration_items')} SET status='switched',error=NULL,updated_at=CURRENT_TIMESTAMP WHERE run_id=? AND status='verified' AND item_key IN (${keys.map(() => '?').join(',')})`, [config.runId, ...keys]); if (result.affectedRows !== items.length) { throw Object.assign(new Error('部分迁移项已变化或不再处于 verified 状态,必须回滚整批事务'), { code: 'MIGRATION_CAS_CONFLICT' }); } if (result.warningStatus > 0) { throw Object.assign(new Error('批量迁移状态更新产生数据库警告,必须回滚整批事务'), { code: 'MIGRATION_SQL_WARNING' }); } }); committing = true; await measure('commitMs', () => db.commit()); return timings; } catch (error) { try { await db.rollback(); } catch (rollbackError) { throw Object.assign(new Error('事务回滚失败,连接状态不确定,禁止降级重试', { cause: error }), { code: 'MIGRATION_ROLLBACK_FAILED', fatal: true, noFallback: true, rollbackError }); } if (committing) { // A failed COMMIT acknowledgement does not prove the server rolled back. throw Object.assign(new Error('事务提交结果不确定,禁止降级重试,请重新核对迁移状态', { cause: error }), { code: error.code || 'MIGRATION_COMMIT_FAILED', fatal: true, noFallback: true }); } throw error; } }