压缩并清理逐文件备份元数据
Project CI / Repository checks (push) Successful in 1m11s
Project CI / Native shell tests (push) Successful in 2m41s
Project CI / Frontend tests (push) Successful in 2m51s
Project CI / Backend tests (push) Successful in 3m15s

将 files state 升级为去重 gzip v2 并兼容旧格式迁移
压缩保留最新 full catalog,清理历史 catalog 与冗余结果
补充损坏拒绝、恢复兼容和清理顺序回归测试
更新生产运维与项目记忆文档
This commit is contained in:
2026-07-27 11:56:23 +08:00
parent 60a4548524
commit d38e8c913e
5 changed files with 397 additions and 26 deletions
@@ -227,6 +227,14 @@
- 验证方式:`npm run check:database-backup`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`;dev 现场必须完成逐文件 full catalog、重复 full 零 PUT、history dry-run、上传后清理、STDB 重启和按 catalog 隔离恢复 roundtrip。
- 关联:<https://github.com/clockworklabs/SpacetimeDB/issues/5542#issuecomment-4981566448>。
## 2026-07-27 逐文件备份本地元数据采用去重 state 与 gzip 保留
- 背景:files v1 本地 state 同时在 `baselineCatalog`、`latestCatalog` 和每个 `historyCatalogs[]` 中嵌入完整文件清单,且每次成功 history 的本地 catalog 与 `--result-file` 再复制同一清单;release 独立 work-dir 已由此累积约 840 MiB JSON,但 OSS-only 恢复实际只依赖 `latest.json`、catalog 引用和 CAS 对象。
- 决策:OSS catalog/latest schema、对象 key、序列化字节与恢复链保持不变。本地 state 升级为 gzip v2,只保存去重后的 catalog 引用;旧 v1 JSON 可读,并且只在非 dry-run 成功发布、验真 latest、原子写入 v2 后删除。full 增量复用从本地 latest full catalog gzip 缓存读取,缓存缺失时退化为逐对象 OSS HEAD,不影响正确性;缓存长度或 SHA 与 state 不一致时失败关闭。
- 清理边界:成功运行后只压缩保留 latest full catalog;已验真的本地 history catalog、旧 full catalog和严格文件名匹配的失败/dry-run 遗留 catalog 清理。`--result-file` 只写紧凑引用和计数。任一 state 压缩或本地 metadata 清理失败都发生在 `/stdb` history 源文件删除之前;OSS catalog、latest 和 CAS 对象永不由本地 metadata 清理删除。
- 兼容与验证:`--restore-files-state` 同时接受 v1 JSON 与 v2 gzip,`--restore-files-latest` 不受本地格式影响。门禁覆盖 v1 迁移、gzip/state/catalog 损坏拒绝、full 增量复用、history catalog 本地清理、紧凑 result、pointer 失败不清源和 OSS-only 恢复。
- 关联:`scripts/database-backup-to-oss.mjs`、`scripts/check-database-backup-to-oss.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
## 2026-07-14 后台账号采用 owner 引导账号与一级 Tab 实时授权
- 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。
@@ -3239,8 +3239,8 @@
- 现象:看到最新 `N.snapshot_dir` 后,把所有起始 offset 小于 `N` 的 `.stdb.log` 删除,或者只把旧日志上传 OSS 就宣称已有完整增量灾备。
- 原因:segment 文件名只表示该段最早事务;起始 offset 小于等于最新 snapshot 的最后一个 segment 可能跨越 snapshot 边界,重启仍需要它。历史归档也不会及时覆盖 control-db、program bytes、最新 snapshot 和 active segment。
- 处理:latest snapshot 必须是未锁定且存在同 offset `.snapshot_bsatn` 的完整目录,空目录或同名 `.lock` 存在时忽略。每个 replica 独立保留 `max(segment_start <= latest_snapshot)` 及全部后缀,只处理更早 segment 对;旧 snapshot 只保留最新一个。`--storage-format files` 必须先发布完整 full catalog;history 对每个候选文件 CAS 对象、history catalog 和 full catalog 执行 HEAD 长度/SHA 验真,再复算边界与 stat fingerprint,最后发布并验真固定 `latest.json`;pointer 失败时不得推进 state 或删除源文件。不要把在线逐文件 full 扫描当成跨文件一致备份,基线必须来自停库目录或已验证冻结副本。SSH 或工具超时后先检查 work-dir PID lock 与原进程,不要直接并发重跑;不要在 history 模式传 `--stop-service`。定时任务通过 Server-Provision 的显式 profile 和仓库 drop-in 管理,启用前 dry-run 验证 baseline,切回 archive 时同时移除托管与现场遗留 drop-in;不要在 `/etc/systemd/system` 长期保留手写覆盖,release 必须建立和验证自己的 full baseline 与 work-dir,不能直接复用 dev 的本地 state。
- 验证:dry-run 输出 replica 的 `latestSnapshot`、`boundarySegment` 和候选清单;从另一台机器仅凭 OSS `latest.json` 自动定位 full catalog,创建目录、下载文件并逐项校验长度/SHA,启动隔离 data-dir 验证 `/v1/ping`、snapshot restore、commitlog replay、module launch、代表性 SQL 与 reducer。
- 处理:latest snapshot 必须是未锁定且存在同 offset `.snapshot_bsatn` 的完整目录,空目录或同名 `.lock` 存在时忽略。每个 replica 独立保留 `max(segment_start <= latest_snapshot)` 及全部后缀,只处理更早 segment 对;旧 snapshot 只保留最新一个。`--storage-format files` 必须先发布完整 full catalog;history 对每个候选文件 CAS 对象、history catalog 和 full catalog 执行 HEAD 长度/SHA 验真,再复算边界与 stat fingerprint,最后发布并验真固定 `latest.json`;pointer 失败时不得推进 state 或删除源文件。不要把在线逐文件 full 扫描当成跨文件一致备份,基线必须来自停库目录或已验证冻结副本。SSH 或工具超时后先检查 work-dir PID lock 与原进程,不要直接并发重跑;不要在 history 模式传 `--stop-service`。定时任务通过 Server-Provision 的显式 profile 和仓库 drop-in 管理,启用前 dry-run 验证 baseline,切回 archive 时同时移除托管与现场遗留 drop-in;不要在 `/etc/systemd/system` 长期保留手写覆盖,release 必须建立和验证自己的 full baseline 与 work-dir,不能直接复用 dev 的本地 state。files 本地 state 只能保存去重后的 catalog 引用并使用 gzip 原子落盘;本地只保留 latest full catalog 压缩缓存,history/旧 full catalog 和紧凑 result 不得再次复制完整清单。metadata 压缩或清理失败必须早于 `/stdb` history 源文件删除。
- 验证:dry-run 输出 replica 的 `latestSnapshot`、`boundarySegment` 和候选清单;从另一台机器仅凭 OSS `latest.json` 自动定位 full catalog,创建目录、下载文件并逐项校验长度/SHA,启动隔离 data-dir 验证 `/v1/ping`、snapshot restore、commitlog replay、module launch、代表性 SQL 与 reducer。备份门禁还必须覆盖 v1 JSON 到 v2 gzip 迁移、损坏 gzip 不回退、full 增量复用、本地 catalog SHA 校验、history catalog 清理与紧凑 result。
- 关联:`scripts/database-backup-to-oss.mjs`、`scripts/check-database-backup-to-oss.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
## Procedure 事务鉴权要兼容 SpacetimeDB 2.6.0 的匿名 TxContext
@@ -398,6 +398,8 @@ files full 会递归扫描 data-dir,保留空目录、每个普通文件的相
history 的安全边界按每个 replica 独立计算。设最新完整且未锁定的 snapshot offset 为 `S`;数字更大但缺少同 offset `.snapshot_bsatn`、仍存在同名 `.lock` 的目录不能参与边界计算。脚本必须保留起始 offset 小于等于 `S` 的最后一个 commitlog segment,以及它之后的全部 segment;只处理更早的 `.stdb.log` / `.stdb.ofs`,snapshot 只处理最新目录之前的旧目录。files history 会递归展开候选目录,逐对象复用或上传,随后依次验真候选对象、history catalog 与 full baseline catalog,再重新扫描边界和 stat fingerprint,最后覆盖发布并验真 `latest.json`;任何一步失败都不推进 state 或删除源文件。脚本在 work-dir 使用 PID lock 拒绝同库并发上传,SSH 超时后必须先检查原进程,不能直接重跑。
files 本地续跑 state 使用 `<database>-files-state.json.gz` v2:只保存 full/history catalog 的 object key、长度、SHA 与验真时间,不再重复嵌入每份 catalog 的完整 `files` / `symlinks` 清单。旧 v1 `.json` 仍可读取,并且只在一次非 dry-run 备份的 OSS catalog、`latest.json` 与本地新 state 全部成功后原子迁移为 gzip v2,再删除旧 state;gzip 已存在但损坏时必须失败,不能回退到可能过期的旧 JSON。成功运行后,本地只压缩保留 latest full catalog 作为 full 增量复用缓存,已上传并验真的 history catalog、旧 full catalog、失败或 dry-run 遗留 catalog 自动清理;OSS catalog、CAS 对象与 `latest.json` 不删除、不改 schema。`--result-file` 只写 catalog 引用和计数,不再复制完整文件清单。metadata 压缩或清理失败时不得继续删除 `/stdb` history 源文件。
```bash
# 从停库目录或已验证冻结副本建立逐文件完整基线;相同 work-dir 重跑只传变化内容。
node -- scripts/database-backup-to-oss.mjs \
@@ -431,7 +433,7 @@ node -- scripts/database-backup-to-oss.mjs \
dev 出口过慢时,可以把冻结基线经内网 rsync 到 release 独立 staging,再由 release 上传 dev bucket。staging 必须位于 `/var/lib/genarrative/dev-database-backup-staging/` 一类隔离目录,命令显式传 staging `--data-dir`、独立 `--work-dir`、dev `--bucket`,且不得传 `--stop-service`;禁止指向或修改 release `/stdb`。中转 key 只为本次传输临时授权,结束后从 dev 私钥和 release `authorized_keys` 同时移除。上传完成后把整个 files work-dir/state 回传 dev,history 才能延续同一 baseline catalog。
完整恢复默认从 OSS 固定 `latest.json` 读取最新 full catalog:先创建 `directories`,再把每个 `files[].objectKey` 下载到 `<restore-root>/<files[].path>` 并逐项核对 `sizeBytes` / `sha256`;history catalog 用于证明已清理历史仍有 OSS 对象,不需要把已被 full baseline 覆盖的旧文件叠回当前恢复目录。本地 state 仍可作为兼容入口,但不再是异机恢复的前置条件。随后用隔离 data-dir 启动同版本 standalone,验证 `/v1/ping`、日志中的 snapshot restore / commitlog replay / module launch、代表性 SQL 和 reducer。dev 已完成这轮 OSS-only 异机恢复与重启演练;当前 live release 仍保持 `archive-full`,需要切换时先为 release 建立并恢复验证独立 full baseline,再通过 Server-Provision 显式选择 `files-history`,无需修改代码或解除额外硬门禁。
完整恢复默认从 OSS 固定 `latest.json` 读取最新 full catalog:先创建 `directories`,再把每个 `files[].objectKey` 下载到 `<restore-root>/<files[].path>` 并逐项核对 `sizeBytes` / `sha256`;history catalog 用于证明已清理历史仍有 OSS 对象,不需要把已被 full baseline 覆盖的旧文件叠回当前恢复目录。本地 state 仍可作为兼容入口,并同时支持旧 v1 JSON 与 v2 gzip,但不再是异机恢复的前置条件。随后用隔离 data-dir 启动同版本 standalone,验证 `/v1/ping`、日志中的 snapshot restore / commitlog replay / module launch、代表性 SQL 和 reducer。dev 已完成这轮 OSS-only 异机恢复与重启演练;release 已使用独立 `/var/lib/genarrative/database-backups/release-files` full baseline 和 `files-history` profile,现场最终 `ExecStart`、timer 状态与最近备份结果仍须在变更时重新核对。
```bash
node -- scripts/database-backup-to-oss.mjs \
+128 -4
View File
@@ -6,6 +6,7 @@ import {chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync,
import {tmpdir} from 'node:os';
import path from 'node:path';
import {Readable} from 'node:stream';
import {gunzipSync, gzipSync} from 'node:zlib';
import {
buildAuthorization,
@@ -65,12 +66,17 @@ async function main() {
await assertHistorySuccessfulUploadCleansAndIsIdempotent();
await assertHistoryResumeReverifiesArchiveAndManifest();
await assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload();
await assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs();
await assertDirectFilesConcurrencyIsBounded();
await assertDirectHistoryPublishesCatalogBeforeCleanup();
await assertDirectHistoryWithoutCandidatesPublishesLatest();
await assertDirectFilesRestoreDownloadsCatalogAndObjects();
}
function readGzipJson(filePath) {
return JSON.parse(gunzipSync(readFileSync(filePath)).toString('utf8'));
}
function createDirectOssHarness() {
const objects = new Map();
const uploadedKeys = [];
@@ -217,6 +223,13 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload(
const first = await runDirectFilesBackup(options);
assertEqual(first.uploadedCount, 2, '首次 files full 应上传全部普通文件。');
assertTrue(!Object.hasOwn(first.catalog, 'dataDir'), '远端 files catalog 不得绑定 staging 主机的绝对 data-dir。');
assertTrue(first.statePath.endsWith('.json.gz'), 'files state 必须使用 gzip 压缩文件。');
const compactState = readGzipJson(first.statePath);
assertEqual(compactState.schemaVersion, 2, 'files state 必须使用去重后的 v2 契约。');
assertTrue(!Object.hasOwn(compactState.baselineCatalog, 'files'), 'baseline ref 不得重复嵌入 files。');
assertTrue(!Object.hasOwn(compactState.latestCatalog, 'files'), 'latest ref 不得重复嵌入 files。');
assertTrue(!existsSync(first.catalogPath), '本地 full catalog 原始 JSON 应在成功后压缩。');
assertTrue(existsSync(`${first.catalogPath}.gz`), '本地应保留压缩后的 latest full catalog 供增量复用。');
const latestObjectKey = 'database-backups/test-db/latest.json';
const latest = JSON.parse(harness.objects.get(latestObjectKey).body.toString('utf8'));
assertEqual(latest.latestFullCatalog.catalogId, first.catalogId, 'latest pointer 必须指向已验真的最新 full catalog。');
@@ -231,11 +244,77 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload(
'相同 catalog 重跑不得重复 PUT 文件或 catalog,但应覆盖验真 latest pointer。',
);
rmSync(`${first.catalogPath}.gz`, {force: false});
writeFileSync(path.join(dataDir, 'control-db'), 'control changed');
writeFileSync(path.join(dataDir, 'new-program.bin'), 'new program');
const incremental = await runDirectFilesBackup(options);
assertEqual(incremental.uploadedCount, 2, '增量 files full 只应上传新增和变化文件。');
assertEqual(incremental.reusedCount, 1, '增量 files full 应复用未变化 snapshot 文件。');
assertEqual(incremental.reusedCount, 1, '本地 full catalog 缓存缺失时仍应通过 OSS HEAD 复用未变化文件。');
}
async function assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs() {
const root = path.join(tmpRoot, 'direct-files-state-migration');
const dataDir = path.join(root, 'stdb');
const workDir = path.join(root, 'work');
mkdirSync(dataDir, {recursive: true});
writeFileSync(path.join(dataDir, 'control-db'), 'control');
const harness = createDirectOssHarness();
const options = {
mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups',
uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn,
};
const first = await runDirectFilesBackup(options);
const compactState = readGzipJson(first.statePath);
const catalog = JSON.parse(gunzipSync(readFileSync(`${first.catalogPath}.gz`)).toString('utf8'));
const legacyCatalogRef = {
...compactState.latestCatalog,
files: catalog.files,
symlinks: catalog.symlinks,
};
const legacyStatePath = first.statePath.slice(0, -3);
writeFileSync(legacyStatePath, `${JSON.stringify({
...compactState,
schemaVersion: 1,
baselineCatalog: legacyCatalogRef,
latestCatalog: legacyCatalogRef,
}, null, 2)}\n`);
rmSync(first.statePath, {force: false});
const migrated = await runDirectFilesBackup(options);
assertTrue(migrated.unchanged, '旧 state 迁移不得改变相同 full catalog 的零上传语义。');
assertTrue(existsSync(migrated.statePath), '旧 state 成功运行后必须生成压缩 state。');
assertTrue(!existsSync(legacyStatePath), '压缩 state 原子落盘后应删除旧未压缩 state。');
const migratedState = readGzipJson(migrated.statePath);
assertEqual(migratedState.schemaVersion, 2, '旧 state 必须迁移到 v2。');
assertTrue(!Object.hasOwn(migratedState.latestCatalog, 'files'), '迁移后 state 不得保留重复 files 清单。');
const latestCatalogPath = `${migrated.catalogPath}.gz`;
const validCatalogBody = readFileSync(latestCatalogPath);
writeFileSync(latestCatalogPath, gzipSync(Buffer.from('{}\n')));
writeFileSync(path.join(dataDir, 'control-db'), 'control changed');
let corruptCatalogFailure = null;
try {
await runDirectFilesBackup(options);
} catch (error) {
corruptCatalogFailure = error;
}
assertIncludes(
corruptCatalogFailure?.message ?? '',
'长度或 SHA 与 state 引用不匹配',
'本地 latest full catalog 损坏时不得作为增量复用缓存。',
);
writeFileSync(latestCatalogPath, validCatalogBody);
writeFileSync(legacyStatePath, `${JSON.stringify({...migratedState, schemaVersion: 1})}\n`);
writeFileSync(migrated.statePath, 'not-a-gzip-state');
let corruptStateFailure = null;
try {
await runDirectFilesBackup(options);
} catch (error) {
corruptStateFailure = error;
}
assertTrue(corruptStateFailure instanceof Error, '压缩 state 损坏时必须失败。');
assertTrue(existsSync(legacyStatePath), '压缩 state 损坏时不得静默回退并删除旧 state。');
}
async function assertDirectFilesConcurrencyIsBounded() {
@@ -290,7 +369,12 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() {
uploadFn: harness.uploadFn,
verifyFn: harness.verifyFn,
};
await runDirectFilesBackup({...common, mode: 'full', uploadManifestFn: harness.uploadManifestFn});
const baseline = await runDirectFilesBackup({...common, mode: 'full', uploadManifestFn: harness.uploadManifestFn});
const legacyResultFile = path.join(fixture.workDir, 'legacy-full-result.json');
writeFileSync(legacyResultFile, `${JSON.stringify({
...baseline,
catalog: baseline.catalog,
}, null, 2)}\n`);
const plan = discoverHistoryPlan({dataDir: fixture.dataDir});
let failure = null;
try {
@@ -330,7 +414,13 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() {
assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `latest pointer 发布失败不得删除: ${candidate.path}`);
}
const success = await runDirectFilesBackup({...common, mode: 'history', uploadManifestFn: harness.uploadManifestFn});
const resultFile = path.join(fixture.workDir, 'history-result.json');
const success = await runDirectFilesBackup({
...common,
mode: 'history',
resultFile,
uploadManifestFn: harness.uploadManifestFn,
});
assertEqual(success.uploadedCount, 0, 'history 文件已在 full CAS baseline 时不应重复上传内容。');
for (const file of success.catalog.files) {
assertTrue(
@@ -340,7 +430,18 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() {
}
assertEqual(success.cleanup?.deletedCount, plan.candidates.length, 'catalog 和 baseline 验真后才应清理全部安全候选。');
const state = JSON.parse(readFileSync(success.statePath, 'utf8'));
const state = readGzipJson(success.statePath);
assertEqual(state.schemaVersion, 2, 'files state 必须迁移为去重后的 v2 契约。');
assertTrue(!Object.hasOwn(state.latestCatalog, 'files'), 'files state latest ref 不得重复嵌入 files。');
assertTrue(state.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), 'files state history ref 不得重复嵌入 files。');
assertTrue(!existsSync(success.catalogPath), '已上传并验真的 history catalog 本地 JSON 应被清理。');
assertTrue(!existsSync(`${success.catalogPath}.gz`), 'history catalog 本地压缩副本也不应保留。');
const diskResult = JSON.parse(readFileSync(resultFile, 'utf8'));
assertTrue(!Object.hasOwn(diskResult.catalog, 'files'), 'files result 文件不得重复写入完整 files 清单。');
assertEqual(diskResult.catalog.fileCount, success.fileCount, '紧凑 result 仍应保留文件计数。');
const compactedLegacyResult = JSON.parse(readFileSync(legacyResultFile, 'utf8'));
assertTrue(!Object.hasOwn(compactedLegacyResult.catalog, 'files'), '旧 result 中重复的 files 清单应在成功运行后压缩。');
assertTrue((success.metadataCleanup?.compactedResultCount ?? 0) >= 1, 'metadata 清理应报告已压缩旧 result。');
const historyCatalogObjectKey = state.historyCatalogs[0].objectKey;
harness.objects.delete(historyCatalogObjectKey);
let brokenHistoryFailure = null;
@@ -425,6 +526,29 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() {
assertTrue(lstatSync(path.join(restoreDir, 'bin', 'current')).isSymbolicLink(), 'files restore 必须重建符号链接。');
assertEqual(readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), '2.6.0', 'files restore 必须保留符号链接目标。');
rmSync(restoreDir, {recursive: true, force: true});
const legacyRestoreStatePath = path.join(workDir, 'legacy-restore-state.json');
writeFileSync(legacyRestoreStatePath, `${JSON.stringify({
...readGzipJson(latestFull.statePath),
schemaVersion: 1,
})}\n`);
const legacyRestored = await restoreDirectFilesBackup({
statePath: legacyRestoreStatePath,
restoreDir,
database: 'test-db',
bucket: 'backup-bucket',
uploadOptions: {},
downloadBufferFn: async ({objectKey}) => Buffer.from(harness.objects.get(objectKey)?.body ?? ''),
downloadFileFn: async ({objectKey, destinationPath}) => {
const object = harness.objects.get(objectKey);
if (!object) {
throw new Error(`missing ${objectKey}`);
}
writeFileSync(destinationPath, object.body);
},
});
assertEqual(legacyRestored.downloadedCount, 1, 'files restore 必须继续兼容 v1 JSON state。');
rmSync(restoreDir, {recursive: true, force: true});
const downloadBufferFn = async ({objectKey}) => {
const object = harness.objects.get(objectKey);
+256 -19
View File
@@ -26,6 +26,7 @@ import {Readable} from 'node:stream';
import {pipeline} from 'node:stream/promises';
import {setTimeout as sleep} from 'node:timers/promises';
import {fileURLToPath} from 'node:url';
import {gunzipSync, gzipSync} from 'node:zlib';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -53,7 +54,8 @@ const DIRECT_FILES_SINGLE_PUT_MAX_BYTES = 16 * 1024 * 1024;
const RETRYABLE_OSS_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
const HISTORY_STATE_SCHEMA_VERSION = 1;
const HISTORY_MANIFEST_SCHEMA_VERSION = 1;
const DIRECT_FILES_STATE_SCHEMA_VERSION = 1;
const DIRECT_FILES_STATE_SCHEMA_VERSION = 2;
const LEGACY_DIRECT_FILES_STATE_SCHEMA_VERSION = 1;
const DIRECT_FILES_CATALOG_SCHEMA_VERSION = 1;
const DIRECT_FILES_LATEST_SCHEMA_VERSION = 1;
@@ -370,14 +372,23 @@ function buildBackupNames({database, dataDir, objectPrefix}) {
return {fileName, objectKey};
}
function atomicWriteJson(filePath, payload) {
function atomicWriteBuffer(filePath, body) {
mkdirSync(dirname(filePath), {recursive: true});
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, {encoding: 'utf8', mode: 0o600});
writeFileSync(tempPath, body, {mode: 0o600});
chmodSync(tempPath, 0o600);
renameSync(tempPath, filePath);
}
function atomicWriteJson(filePath, payload) {
atomicWriteBuffer(filePath, Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf8'));
}
function atomicWriteGzipJson(filePath, payload) {
const body = Buffer.from(`${JSON.stringify(payload)}\n`, 'utf8');
atomicWriteBuffer(filePath, gzipSync(body, {level: 9}));
}
function processIsAlive(pid) {
try {
process.kill(pid, 0);
@@ -1070,9 +1081,127 @@ async function sha256FileHex(filePath) {
}
function directFilesStatePath({workDir, database}) {
return join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json.gz`);
}
function legacyDirectFilesStatePath({workDir, database}) {
return join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json`);
}
function directCatalogLocalPaths({workDir, database, catalog}) {
const baseName = `${sanitizeObjectPart(database, 'spacetimedb')}-${catalog.mode}-${catalog.catalogId}.catalog.json`;
return {
jsonPath: join(workDir, baseName),
gzipPath: join(workDir, `${baseName}.gz`),
};
}
function readLocalDirectCatalog({workDir, database, catalog}) {
const {jsonPath, gzipPath} = directCatalogLocalPaths({workDir, database, catalog});
let body = null;
if (existsSync(jsonPath)) {
body = readFileSync(jsonPath);
} else if (existsSync(gzipPath)) {
body = gunzipSync(readFileSync(gzipPath));
}
if (!body) {
return null;
}
if (body.length !== catalog.contentLength || sha256Hex(body) !== catalog.sha256) {
throw new Error(`本地 files catalog 长度或 SHA 与 state 引用不匹配: ${jsonPath}`);
}
const payload = JSON.parse(body.toString('utf8'));
if (
payload.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION
|| payload.database !== database
|| payload.mode !== catalog.mode
|| payload.catalogId !== catalog.catalogId
|| !Array.isArray(payload.files)
) {
throw new Error(`本地 files catalog 与 state 引用不匹配: ${jsonPath}`);
}
return payload;
}
function readJsonOrGzip(filePath) {
const body = readFileSync(filePath);
const decoded = filePath.endsWith('.gz') || (body[0] === 0x1f && body[1] === 0x8b)
? gunzipSync(body)
: body;
return JSON.parse(decoded.toString('utf8'));
}
function compactDirectCatalogFile({workDir, database, catalog}) {
const {jsonPath, gzipPath} = directCatalogLocalPaths({workDir, database, catalog});
if (!existsSync(jsonPath)) {
return existsSync(gzipPath) ? {compressed: false, gzipPath} : null;
}
const body = readFileSync(jsonPath);
atomicWriteBuffer(gzipPath, gzipSync(body, {level: 9}));
rmSync(jsonPath, {force: false});
return {compressed: true, gzipPath};
}
function compactDirectFilesLocalMetadata({workDir, database, nextState, transientCatalogPaths = []}) {
const keepCatalog = nextState.latestCatalog;
const keepCatalogIds = new Set([keepCatalog?.catalogId].filter(Boolean));
const databasePart = sanitizeObjectPart(database, 'spacetimedb');
const catalogPattern = new RegExp(`^${databasePart.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}-(full|history)-([a-f0-9]{64})\\.catalog\\.json(?:\\.gz)?$`, 'u');
let compressedCatalogCount = 0;
let deletedCatalogCount = 0;
let compactedResultCount = 0;
for (const entry of readdirSync(workDir, {withFileTypes: true})) {
if (!entry.isFile()) {
continue;
}
const match = catalogPattern.exec(entry.name);
if (!match) {
continue;
}
const catalog = {mode: match[1], catalogId: match[2]};
if (keepCatalogIds.has(catalog.catalogId) && catalog.mode === 'full') {
readLocalDirectCatalog({workDir, database, catalog: keepCatalog});
if (!entry.name.endsWith('.gz') && compactDirectCatalogFile({workDir, database, catalog})?.compressed) {
compressedCatalogCount += 1;
}
continue;
}
rmSync(join(workDir, entry.name), {force: false});
deletedCatalogCount += 1;
}
for (const filePath of transientCatalogPaths) {
if (existsSync(filePath)) {
rmSync(filePath, {force: false});
deletedCatalogCount += 1;
}
}
for (const entry of readdirSync(workDir, {withFileTypes: true})) {
if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.endsWith('.catalog.json')) {
continue;
}
const filePath = join(workDir, entry.name);
let payload = null;
try {
payload = JSON.parse(readFileSync(filePath, 'utf8'));
} catch {
continue;
}
if (
payload?.catalog?.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION
|| payload.catalog.database !== database
|| payload.catalog.bucket !== nextState.bucket
|| !Array.isArray(payload.catalog.files)
) {
continue;
}
atomicWriteJson(filePath, compactDirectFilesResult(payload));
compactedResultCount += 1;
}
const result = {compressedCatalogCount, deletedCatalogCount, compactedResultCount};
console.log(`[database-backup] files 本地元数据清理: ${JSON.stringify(result)}`);
return result;
}
function normalizeObjectPrefix(objectPrefix, database) {
const prefix = String(objectPrefix || 'database-backups')
.trim()
@@ -1185,12 +1314,16 @@ function directCatalogIdentity({mode, baselineCatalogId, rootName, directories,
}
function readDirectFilesState(statePath, {database, bucket}) {
if (!existsSync(statePath)) {
const candidates = statePath.endsWith('.gz')
? [statePath, statePath.slice(0, -3)]
: [statePath, `${statePath}.gz`];
const existingPath = candidates.find((candidate) => existsSync(candidate));
if (!existingPath) {
return null;
}
const state = readManifest(statePath);
const state = readJsonOrGzip(existingPath);
if (
state.schemaVersion !== DIRECT_FILES_STATE_SCHEMA_VERSION
![LEGACY_DIRECT_FILES_STATE_SCHEMA_VERSION, DIRECT_FILES_STATE_SCHEMA_VERSION].includes(state.schemaVersion)
|| state.backupKind !== 'spacetimedb-direct-files-state'
|| state.database !== database
|| state.bucket !== bucket
@@ -1200,6 +1333,16 @@ function readDirectFilesState(statePath, {database, bucket}) {
return state;
}
function directPreviousFiles({state, workDir, database}) {
if (!state?.latestCatalog) {
return [];
}
if (Array.isArray(state?.latestCatalog?.files)) {
return state.latestCatalog.files;
}
return readLocalDirectCatalog({workDir, database, catalog: state?.latestCatalog})?.files ?? [];
}
async function ensureDirectObject({
file,
dataDir,
@@ -1290,6 +1433,52 @@ function directCatalogRef(catalog) {
};
}
function normalizeDirectFilesState({state, dataDir, database, bucket}) {
return {
schemaVersion: DIRECT_FILES_STATE_SCHEMA_VERSION,
backupKind: 'spacetimedb-direct-files-state',
database,
dataDir,
bucket,
updatedAt: new Date().toISOString(),
baselineCatalog: assertDirectCatalogRef(state?.baselineCatalog, 'full', 'baseline full'),
latestCatalog: assertDirectCatalogRef(state?.latestCatalog, 'full', 'latest full'),
historyCatalogs: (state?.historyCatalogs ?? []).map((catalog) => (
assertDirectCatalogRef(catalog, 'history', 'history')
)),
};
}
function persistDirectFilesState({statePath, legacyStatePath, state}) {
atomicWriteGzipJson(statePath, state);
if (legacyStatePath !== statePath && existsSync(legacyStatePath)) {
rmSync(legacyStatePath, {force: false});
}
}
function compactDirectFilesResult(result) {
if (!result.catalog) {
return result;
}
const {catalog, ...rest} = result;
return {
...rest,
catalog: {
schemaVersion: catalog.schemaVersion,
backupKind: catalog.backupKind,
database: catalog.database,
bucket: catalog.bucket,
mode: catalog.mode,
catalogId: catalog.catalogId,
catalogObjectKey: catalog.catalogObjectKey,
baselineCatalogId: catalog.baselineCatalogId,
rootName: catalog.rootName,
fileCount: catalog.files.length,
symlinkCount: catalog.symlinks.length,
},
};
}
function assertDirectCatalogRef(catalog, expectedMode, label) {
if (
!catalog
@@ -1397,6 +1586,7 @@ export async function runDirectFilesBackup({
}) {
mkdirSync(workDir, {recursive: true});
const statePath = directFilesStatePath({workDir, database});
const legacyStatePath = legacyDirectFilesStatePath({workDir, database});
const state = readDirectFilesState(statePath, {database, bucket});
if (mode === 'history' && (!state?.baselineCatalog || state?.latestCatalog?.mode !== 'full')) {
throw new Error(`files history 模式缺少已发布 full baseline catalog: ${statePath}`);
@@ -1464,10 +1654,26 @@ export async function runDirectFilesBackup({
uploadManifestFn,
verifyFn,
});
const compactedState = normalizeDirectFilesState({state, dataDir, database, bucket});
persistDirectFilesState({statePath, legacyStatePath, state: compactedState});
const metadataCleanup = compactDirectFilesLocalMetadata({
workDir,
database,
nextState: compactedState,
transientCatalogPaths: [catalogPath],
});
console.log('[database-backup] files history 没有可归档候选。');
const emptyResult = {...summary, catalog, latestPointer, uploadedCount: 0, reusedCount: 0, cleanup: null};
const emptyResult = {
...summary,
catalog,
latestPointer,
uploadedCount: 0,
reusedCount: 0,
cleanup: null,
metadataCleanup,
};
if (resultFile) {
atomicWriteJson(resolvePath(resultFile), emptyResult);
atomicWriteJson(resolvePath(resultFile), compactDirectFilesResult(emptyResult));
}
return emptyResult;
}
@@ -1485,8 +1691,23 @@ export async function runDirectFilesBackup({
uploadManifestFn,
verifyFn,
});
const compactedState = normalizeDirectFilesState({state, dataDir, database, bucket});
persistDirectFilesState({statePath, legacyStatePath, state: compactedState});
const metadataCleanup = compactDirectFilesLocalMetadata({
workDir,
database,
nextState: compactedState,
});
console.log('[database-backup] files catalog 未变化,无文件需要上传。');
return {...summary, catalog, latestPointer, uploadedCount: 0, reusedCount: collected.files.length, unchanged: true};
return {
...summary,
catalog,
latestPointer,
uploadedCount: 0,
reusedCount: collected.files.length,
unchanged: true,
metadataCleanup,
};
}
}
@@ -1499,7 +1720,7 @@ export async function runDirectFilesBackup({
});
}
const previousFiles = new Map((state?.latestCatalog?.files ?? []).map((file) => [file.path, file]));
const previousFiles = new Map(directPreviousFiles({state, workDir, database}).map((file) => [file.path, file]));
let uploadedCount = 0;
let reusedCount = 0;
let nextIndex = 0;
@@ -1555,8 +1776,6 @@ export async function runDirectFilesBackup({
contentLength: catalogUpload.contentLength,
sha256: catalogUpload.archiveSha256,
verifiedAt: catalogUpload.verifiedAt,
files: catalog.files,
symlinks: catalog.symlinks,
};
const nextState = {
schemaVersion: DIRECT_FILES_STATE_SCHEMA_VERSION,
@@ -1565,11 +1784,16 @@ export async function runDirectFilesBackup({
dataDir,
bucket,
updatedAt: new Date().toISOString(),
baselineCatalog: state?.baselineCatalog ?? catalogRef,
latestCatalog: mode === 'full' ? catalogRef : state.latestCatalog,
baselineCatalog: directCatalogRef(state?.baselineCatalog ?? catalogRef),
latestCatalog: directCatalogRef(mode === 'full' ? catalogRef : state.latestCatalog),
historyCatalogs: mode === 'history'
? [...(state.historyCatalogs ?? []).filter((item) => item.catalogId !== catalogId), catalogRef]
: (state?.historyCatalogs ?? []),
? [
...(state.historyCatalogs ?? [])
.filter((item) => item.catalogId !== catalogId)
.map((item) => directCatalogRef(item)),
directCatalogRef(catalogRef),
]
: (state?.historyCatalogs ?? []).map((item) => directCatalogRef(item)),
};
const latestPointer = await publishDirectFilesLatest({
workDir,
@@ -1581,14 +1805,27 @@ export async function runDirectFilesBackup({
uploadManifestFn,
verifyFn,
});
atomicWriteJson(statePath, nextState);
persistDirectFilesState({statePath, legacyStatePath, state: nextState});
const metadataCleanup = compactDirectFilesLocalMetadata({
workDir,
database,
nextState,
});
let cleanup = null;
if (mode === 'history') {
cleanup = cleanupHistoryCandidates({dataDir, candidates: plan.candidates});
}
const finalResult = {...summary, catalog, latestPointer, uploadedCount, reusedCount, cleanup};
const finalResult = {
...summary,
catalog,
latestPointer,
uploadedCount,
reusedCount,
cleanup,
metadataCleanup,
};
if (resultFile) {
atomicWriteJson(resolvePath(resultFile), finalResult);
atomicWriteJson(resolvePath(resultFile), compactDirectFilesResult(finalResult));
}
return finalResult;
}