diff --git a/deploy/systemd/genarrative-database-backup-files-minimal.conf b/deploy/systemd/genarrative-database-backup-files-minimal.conf index 2124eb345..c8d2e43fe 100644 --- a/deploy/systemd/genarrative-database-backup-files-minimal.conf +++ b/deploy/systemd/genarrative-database-backup-files-minimal.conf @@ -2,4 +2,4 @@ Environment=GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY=4 Environment=GENARRATIVE_DATABASE_BACKUP_UPLOAD_MAX_BYTES_PER_SECOND=20971520 ExecStart= -ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --storage-format files --mode full --minimal --retain-snapshots 2 --data-dir /stdb --work-dir /var/lib/genarrative/database-backups/files-minimal +ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --storage-format files --mode full --minimal --retain-snapshots 2 --freeze-dir /var/lib/genarrative/database-backups/files-minimal-freeze --data-dir /stdb --work-dir /var/lib/genarrative/database-backups/files-minimal diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 4c6e65d85..93d81f106 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -1028,13 +1028,13 @@ const checks = [ }, { file: 'deploy/systemd/genarrative-database-backup-files-minimal.conf', - includes: '--mode full --minimal --retain-snapshots 2', + includes: '--mode full --minimal --retain-snapshots 2 --freeze-dir', reason: 'files-minimal 定时备份必须使用 minimal 口径并保留上游默认的最近 2 份 snapshot。', }, { file: 'scripts/deploy/production-stdb-publish.sh', - includes: '--minimal --retain-snapshots', + includes: '--freeze-dir', reason: '发布前备份默认使用 minimal 热备,避免 40G 级冷备空间门槛与停服。', }, diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index ba6e236fd..26d8cf232 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -107,6 +107,7 @@ function usage() { 将 SpacetimeDB 数据目录以 .tar.gz 或逐文件 catalog 形式上传到阿里云 OSS 指定 bucket。 默认 full 模式保持原有全量冷备行为;history 模式只归档已被最新 snapshot 覆盖的历史 commitlog 与旧 snapshot。 --storage-format files 不打包:按原相对路径建立 catalog,文件内容以 SHA-256 不可变对象上传;重复运行只上传新增或变化内容。 + --freeze-dir 仅用于 --minimal:先把选中子集 rsync 成冻结副本再上传,避免热备时最新 commitlog 追加导致 stat 漂移;副本内保持原始相对路径。 --minimal 只备份上游 retention 语义下仍需要的部分(最近 --retain-snapshots 份 snapshot + 其后 commitlog 段 + 身份/配置/控制库/模块字节);只支持 files 格式,且不停止任何服务。 archive history 必须有已验真的 full baseline state;files history 必须复用同一 work-dir 中已发布的 full catalog state。 --defer-upload 只生成本地冷备份和 manifest,不上传;后续用 --upload-archive 异步上传。 @@ -204,6 +205,7 @@ function parseArgs(argv) { minFreeBytes: '', mode: 'full', minimal: false, + freezeDir: '', retainSnapshots: DEFAULT_RETAIN_SNAPSHOTS, baselineState: '', baselineManifest: '', @@ -301,6 +303,9 @@ function parseArgs(argv) { case '--retain-snapshots': options.retainSnapshots = readValue(); break; + case '--freeze-dir': + options.freezeDir = readValue(); + break; case '--baseline-state': options.baselineState = readValue(); break; @@ -4387,7 +4392,7 @@ async function main() { const env = loadEffectiveEnv(args.envFiles); const isProductionLike = existsSync(DEFAULT_PRODUCTION_DATA_DIR) && process.platform !== 'win32'; - const dataDir = resolvePath( + let dataDir = resolvePath( firstNonEmpty( args.dataDir, env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, @@ -4466,6 +4471,51 @@ async function main() { throw new Error('--minimal 只支持 --mode full:minimal 自身就是自包含快照。'); } + if (args.minimal && args.freezeDir) { + if (storageFormat !== 'files') { + throw new Error('--freeze-dir 只支持 --minimal --storage-format files。'); + } + const freezeDir = resolvePath(args.freezeDir); + const plan = discoverMinimalPlan({ + dataDir, + retainSnapshots: args.retainSnapshots, + }); + console.log( + `[database-backup] minimal freeze: candidates=${plan.candidates.length}, size=${plan.totalSizeBytes}, freezeDir=${freezeDir}`, + ); + if (!args.dryRun) { + mkdirSync(freezeDir, { recursive: true }); + const listPath = join(workDir, 'minimal-freeze-files.txt'); + mkdirSync(workDir, { recursive: true }); + writeFileSync( + listPath, + `${plan.candidates.map((item) => item.path).join('\n')}\n`, + 'utf8', + ); + const result = spawnSync( + 'rsync', + [ + '-a', + '-r', + '--delete', + `--files-from=${listPath}`, + `${dataDir}/`, + `${freezeDir}/`, + ], + { stdio: 'inherit' }, + ); + if (result.status !== 0) { + throw new Error( + `minimal freeze 拷贝失败: rsync exit=${result.status ?? ''}`, + ); + } + console.log( + `[database-backup] minimal freeze 完成,后续从冻结副本上传: ${freezeDir}`, + ); + } + dataDir = freezeDir; + } + if (args.checkSpaceOnly) { assertSufficientWorkDirSpace({ dataDir, diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh index 3b16188f8..115cf43cf 100644 --- a/scripts/deploy/production-stdb-publish.sh +++ b/scripts/deploy/production-stdb-publish.sh @@ -856,7 +856,12 @@ case "${BACKUP_MODE}" in ) if [[ "${BACKUP_MINIMAL}" == "1" ]]; then echo "[production-stdb-publish] publish 前执行 minimal 热备(最近 ${BACKUP_RETAIN_SNAPSHOTS} 份 snapshot + 其后 commitlog),不停服务" - SYNC_BACKUP_ARGS+=(--mode full --minimal --retain-snapshots "${BACKUP_RETAIN_SNAPSHOTS}") + SYNC_BACKUP_ARGS+=( + --mode full + --minimal + --retain-snapshots "${BACKUP_RETAIN_SNAPSHOTS}" + --freeze-dir "${GENARRATIVE_STDB_PUBLISH_BACKUP_FREEZE_DIR:-/var/lib/genarrative/database-backups/publish-minimal-freeze}" + ) else echo "[production-stdb-publish] publish 前同步执行 OSS 冷备份(storage-format=${BACKUP_STORAGE_FORMAT}),失败会阻断发布" SYNC_BACKUP_ARGS+=(--stop-service spacetimedb.service)