修复生产内存工作集回收与 OOM 恢复
Project CI / Repository checks (pull_request) Successful in 2m45s
Project CI / Frontend tests (pull_request) Successful in 2m47s
Project CI / Backend tests (pull_request) Successful in 6m38s
Project CI / Native shell tests (pull_request) Successful in 15m30s

回收持续队列中的已完成 worker 句柄并收紧脱管许可生命周期

统一 AI 任务终态写入的文本、结构化输出和 warning 内存上限

限制认证投影恢复的 retained refresh session 数量

为备份停库增加 marker 与 systemd OOM 兜底恢复

补充回归测试并同步后端、运维和项目决策文档
This commit is contained in:
2026-08-27 18:09:44 +08:00
parent 810bb1d12b
commit a02a318758
11 changed files with 498 additions and 49 deletions
+26
View File
@@ -50,6 +50,7 @@ async function main() {
assertDeferredArchiveDiscoveryIsBoundedAndDeterministic();
assertCanonicalQueryAndAuthorizationIncludeMultipartParameters();
assertInsufficientSpaceStopsBeforeServiceChanges();
assertStopFailureRetainsRecoveryMarker();
assertArchiveFailureStillRestoresDependentServices();
await assertMultipartUploadRetriesAndVerifiesRemoteLength();
await assertUploadBandwidthLimiterSharesBudgetAndPropagatesErrors();
@@ -748,6 +749,27 @@ function assertInsufficientSpaceStopsBeforeServiceChanges() {
assertFileMissing(fixture.tarLog, '空间不足时不能调用 tar。');
}
function assertStopFailureRetainsRecoveryMarker() {
const fixture = createFixture('stop-failure-marker');
writeExecutable(
path.join(fixture.binDir, 'systemctl'),
`#!/usr/bin/env bash
printf 'systemctl %s\\n' "$*" >> "${fixture.systemctlLog}"
if [ "$1" = stop ]; then
exit 9
fi
exit 0
`,
);
const result = runBackup(fixture, ['--stop-service', 'spacetimedb.service']);
assertStatus(result, 1, '停止服务失败时备份必须失败。');
assertTrue(
existsSync(path.join(fixture.workDir, '.spacetimedb-stopped')),
'停止服务命令失败时必须保留 marker,供 systemd ExecStopPost 兜底恢复。',
);
}
function assertArchiveFailureStillRestoresDependentServices() {
const fixture = createFixture('tar-failure');
const result = runBackup(fixture, [
@@ -776,6 +798,10 @@ function assertArchiveFailureStillRestoresDependentServices() {
for (const command of expectedCommands) {
assertIncludes(systemctlLog, command, `tar 失败后必须执行: ${command}`);
}
assertFileMissing(
path.join(fixture.workDir, '.spacetimedb-stopped'),
'正常执行 finally 恢复全部服务后必须清理停库 marker。',
);
}
async function assertMultipartUploadRetriesAndVerifiesRemoteLength() {
+14 -1
View File
@@ -827,12 +827,25 @@ const checks = [
reason:
'备份 Node 进程必须设置独立 heap 上限,避免目录扫描异常拖垮 release 主机。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes:
'Environment=GENARRATIVE_DATABASE_BACKUP_STOP_MARKER=/var/lib/genarrative/database-backups/.spacetimedb-stopped',
reason:
'备份停库 marker 必须固定在受保护的 release work-dir,供 OOM 后 systemd 兜底恢复服务。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: 'MemoryMax=1G',
reason:
'备份 service 必须设置 systemd 内存硬上限,避免异常进程消耗整机内存。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: 'ExecStopPost=/bin/sh -c',
reason:
'备份主进程被 OOM kill 后必须由 systemd 兜底恢复停掉的 SpacetimeDB、API、worker 和 controller。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
excludes: '--storage-format files',
@@ -965,7 +978,7 @@ const checks = [
{
file: 'scripts/database-backup-to-oss.mjs',
includes:
'restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter})',
'restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter, stopMarkerPath})',
reason: '生产冷备份打包失败时也必须恢复 SpacetimeDB 及依赖服务。',
},
{
+36 -6
View File
@@ -35,6 +35,7 @@ const DEFAULT_LOCAL_DATA_DIR = resolve(REPO_ROOT, 'server-rs/.spacetimedb/local/
const DEFAULT_LOCAL_WORK_DIR = resolve(REPO_ROOT, 'server-rs/.data/database-backups');
const DEFAULT_PRODUCTION_DATA_DIR = '/stdb';
const DEFAULT_PRODUCTION_WORK_DIR = '/var/lib/genarrative/database-backups';
const DEFAULT_DATABASE_BACKUP_STOP_MARKER = join(DEFAULT_PRODUCTION_WORK_DIR, '.spacetimedb-stopped');
const DEFAULT_SPACE_SAFETY_RATIO = 1.1;
const DEFAULT_EXTRA_FREE_BYTES = 512 * 1024 * 1024;
const OSS_ALGORITHM = 'OSS4-HMAC-SHA256';
@@ -890,11 +891,37 @@ function collectRestartServicesAfterBackup({args, env}) {
return [...new Set(serviceNames.filter(Boolean))];
}
function stopServiceIfNeeded(serviceName) {
function databaseBackupStopMarkerPath(workDir) {
return resolvePath(firstNonEmpty(
process.env.GENARRATIVE_DATABASE_BACKUP_STOP_MARKER,
workDir === DEFAULT_PRODUCTION_WORK_DIR
? DEFAULT_DATABASE_BACKUP_STOP_MARKER
: join(workDir, '.spacetimedb-stopped'),
));
}
function writeDatabaseBackupStopMarker(markerPath, serviceName) {
atomicWriteJson(markerPath, {
serviceName,
pid: process.pid,
stoppedAt: new Date().toISOString(),
});
}
function clearDatabaseBackupStopMarker(markerPath) {
if (markerPath) {
rmSync(markerPath, {force: true});
}
}
function stopServiceIfNeeded(serviceName, stopMarkerPath) {
if (!serviceName) {
return false;
}
console.log(`[database-backup] 停止服务以获取冷备份: ${serviceName}`);
writeDatabaseBackupStopMarker(stopMarkerPath, serviceName);
// stop 命令失败时仍保留 marker:systemd 的 ExecStopPost 需要它判断是否要
// 兜底恢复,不能因为当前进程还能捕获异常就抹掉上一次停库证据。
runCommand('systemctl', ['stop', serviceName], {stdio: 'inherit'});
return true;
}
@@ -925,7 +952,7 @@ function restartServicesAfterBackup(serviceNames) {
}
}
function restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter}) {
function restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter, stopMarkerPath}) {
const errors = [];
try {
startServiceIfNeeded(stopService, serviceStopped);
@@ -940,6 +967,7 @@ function restoreServicesAfterBackup({stopService, serviceStopped, restartService
if (errors.length > 0) {
throw new AggregateError(errors, `恢复冷备份相关服务失败: ${errors.map((error) => error.message).join('; ')}`);
}
clearDatabaseBackupStopMarker(stopMarkerPath);
}
function createArchive({dataDir, workDir, fileName}) {
@@ -3341,12 +3369,13 @@ async function main() {
}
const stopService = args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE);
const restartServicesAfter = collectRestartServicesAfterBackup({args, env});
const stopMarkerPath = databaseBackupStopMarkerPath(workDir);
let serviceStopped = false;
let backupError = null;
let restoreError = null;
try {
if (args.mode === 'full' && !args.dryRun) {
serviceStopped = stopServiceIfNeeded(stopService);
serviceStopped = stopServiceIfNeeded(stopService, stopMarkerPath);
}
await runDirectFilesBackup({
mode: args.mode,
@@ -3365,7 +3394,7 @@ async function main() {
} finally {
try {
if (serviceStopped) {
restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter});
restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter, stopMarkerPath});
} else if (!backupError && args.mode === 'full' && !args.dryRun) {
restartServicesAfterBackup(restartServicesAfter);
}
@@ -3419,16 +3448,17 @@ async function main() {
let restoreError = null;
const stopService = args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE);
const restartServicesAfter = collectRestartServicesAfterBackup({args, env});
const stopMarkerPath = databaseBackupStopMarkerPath(workDir);
try {
assertSufficientWorkDirSpace({dataDir, workDir, args, env});
serviceStopped = stopServiceIfNeeded(stopService);
serviceStopped = stopServiceIfNeeded(stopService, stopMarkerPath);
archivePath = createArchive({dataDir, workDir, fileName});
} catch (error) {
backupError = error;
} finally {
try {
if (serviceStopped) {
restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter});
restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter, stopMarkerPath});
} else if (!backupError) {
restartServicesAfterBackup(restartServicesAfter);
}