自动清理失效数据库备份锁
失效进程锁自动清理后重试获取 仍存活进程持锁时继续阻断并发备份 补充锁生命周期文档与回归测试
This commit is contained in:
@@ -431,7 +431,7 @@ UI 相关修改要重点验证:
|
||||
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service
|
||||
```
|
||||
|
||||
脚本会将数据目录打包成 `tar.gz`,上传到 `oss://<bucket>/<prefix>/<database>/<database>-<UTC时间>.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都依赖 `spacetimedb.service`,生产定时冷备份还必须传入对应的 `--restart-service-after`,确保备份后 API、保底 worker 和 controller 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉 API 重启参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;`2026-06-24` release 又出现同类依赖停机后只恢复 API、未恢复外部生成 worker/controller,导致图片画布生成任务长期停留在队列中。后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用独立 `systemd-run` transient service 执行 `--upload-deferred-dir <backup-dir>`,串行补传该目录内同库的 `deferred/pending` 归档,不依赖 Jenkins 作业进程树存活。任一归档只有在 OSS archive、manifest 和 baseline state 全部上传并验真后,才按 `keep-local` 规则删除;失败归档保留原 manifest,由下次 publish 重试。`Genarrative-Full-Build-And-Deploy` 必须显式暴露并透传同一个 `DATABASE_BACKUP_MODE`,不得静默使用下游 `async`;release 已有验真冷备且明确禁止再上传时,Full 必须选择 `skip`。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
|
||||
脚本会将数据目录打包成 `tar.gz`,上传到 `oss://<bucket>/<prefix>/<database>/<database>-<UTC时间>.tar.gz`。备份使用同库进程锁:仍存活的备份进程会阻断并发执行;持有锁的进程已退出时,脚本会自动清理失效锁并重试获取,不需要人工删除锁文件。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都依赖 `spacetimedb.service`,生产定时冷备份还必须传入对应的 `--restart-service-after`,确保备份后 API、保底 worker 和 controller 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉 API 重启参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;`2026-06-24` release 又出现同类依赖停机后只恢复 API、未恢复外部生成 worker/controller,导致图片画布生成任务长期停留在队列中。后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用独立 `systemd-run` transient service 执行 `--upload-deferred-dir <backup-dir>`,串行补传该目录内同库的 `deferred/pending` 归档,不依赖 Jenkins 作业进程树存活。任一归档只有在 OSS archive、manifest 和 baseline state 全部上传并验真后,才按 `keep-local` 规则删除;失败归档保留原 manifest,由下次 publish 重试。`Genarrative-Full-Build-And-Deploy` 必须显式暴露并透传同一个 `DATABASE_BACKUP_MODE`,不得静默使用下游 `async`;release 已有验真冷备且明确禁止再上传时,Full 必须选择 `skip`。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
|
||||
|
||||
生产环境变量模板在 `deploy/env/api-server.env.example`:
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ async function main() {
|
||||
await assertManifestUploadUsesShaAndHeadVerification();
|
||||
assertHistoryDiscoversDevAndProductionLayoutsWithMultipleReplicas();
|
||||
assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch();
|
||||
assertHistoryBackupLockRejectsLiveAndStaleOwners();
|
||||
assertHistoryBackupLockRejectsLiveAndCleansStaleOwners();
|
||||
assertHistorySkipsReplicaWithoutSnapshotAndRejectsMalformedNames();
|
||||
assertHistoryStatDriftPreventsAnyCleanup();
|
||||
await assertHistoryUploadFailureDoesNotDeleteSources();
|
||||
@@ -2140,7 +2140,7 @@ function assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch() {
|
||||
}
|
||||
}
|
||||
|
||||
function assertHistoryBackupLockRejectsLiveAndStaleOwners() {
|
||||
function assertHistoryBackupLockRejectsLiveAndCleansStaleOwners() {
|
||||
const liveOwner = createHistoryFixture('history-live-lock', {
|
||||
nestedData: false,
|
||||
});
|
||||
@@ -2176,17 +2176,17 @@ function assertHistoryBackupLockRejectsLiveAndStaleOwners() {
|
||||
const staleResult = runHistoryCommand(staleOwner, ['--defer-upload']);
|
||||
assertStatus(
|
||||
staleResult,
|
||||
1,
|
||||
'失效 owner pid 的 backup lock 也必须失败关闭,避免并发抢锁。',
|
||||
0,
|
||||
'失效 owner pid 的 backup lock 应自动清理并成功获取新锁。',
|
||||
);
|
||||
assertIncludes(
|
||||
staleResult.stderr,
|
||||
'拒绝自动抢锁',
|
||||
'失效 backup lock 应要求人工核对 multipart 与进程。',
|
||||
`${staleResult.stdout}\n${staleResult.stderr}`,
|
||||
'失效数据库备份锁',
|
||||
'自动清理失效 backup lock 时应输出可审计日志。',
|
||||
);
|
||||
assertTrue(
|
||||
existsSync(staleLockPath),
|
||||
'失效 backup lock 未经人工核对不得自动删除。',
|
||||
!existsSync(staleLockPath),
|
||||
'成功获取并释放新锁后,失效 backup lock 不得残留。',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -456,43 +456,65 @@ function acquireBackupLock({ workDir, database }) {
|
||||
workDir,
|
||||
`${sanitizeObjectPart(database, 'spacetimedb')}.backup.lock`,
|
||||
);
|
||||
try {
|
||||
const fd = openSync(lockPath, 'wx', 0o600);
|
||||
writeFileSync(fd, `${process.pid}\n`, 'utf8');
|
||||
closeSync(fd);
|
||||
const release = () => {
|
||||
try {
|
||||
const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim());
|
||||
if (ownerPid === process.pid) {
|
||||
rmSync(lockPath, { force: true });
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const fd = openSync(lockPath, 'wx', 0o600);
|
||||
writeFileSync(fd, `${process.pid}\n`, 'utf8');
|
||||
closeSync(fd);
|
||||
const release = () => {
|
||||
try {
|
||||
const ownerPid = Number(
|
||||
String(readFileSync(lockPath, 'utf8')).trim(),
|
||||
);
|
||||
if (ownerPid === process.pid) {
|
||||
rmSync(lockPath, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// The lock may already have been removed by the normal exit path.
|
||||
}
|
||||
} catch {
|
||||
// The lock may already have been removed by the normal exit path.
|
||||
};
|
||||
process.once('exit', release);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, () => {
|
||||
release();
|
||||
process.exit(signal === 'SIGINT' ? 130 : 143);
|
||||
});
|
||||
}
|
||||
return lockPath;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
process.once('exit', release);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, () => {
|
||||
release();
|
||||
process.exit(signal === 'SIGINT' ? 130 : 143);
|
||||
});
|
||||
}
|
||||
return lockPath;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') {
|
||||
let ownerPid = 0;
|
||||
try {
|
||||
ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim());
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim());
|
||||
if (
|
||||
Number.isSafeInteger(ownerPid) &&
|
||||
ownerPid > 0 &&
|
||||
processIsAlive(ownerPid)
|
||||
) {
|
||||
throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`);
|
||||
if (
|
||||
Number.isSafeInteger(ownerPid) &&
|
||||
ownerPid > 0 &&
|
||||
processIsAlive(ownerPid)
|
||||
) {
|
||||
throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`);
|
||||
}
|
||||
try {
|
||||
rmSync(lockPath);
|
||||
console.log(
|
||||
`[database-backup] 已清理失效数据库备份锁,准备重新获取: ${lockPath} pid=${ownerPid || '<invalid>'}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`发现失效数据库备份锁,拒绝自动抢锁;请核对 OSS multipart 与进程后手工删除: ${lockPath} pid=${ownerPid || '<invalid>'}`,
|
||||
`数据库备份锁在清理后仍无法获取,可能存在并发进程: ${lockPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user