收口生产内存工作集
Project CI / Repository checks (pull_request) Failing after 8s
Project CI / Native shell tests (pull_request) Failing after 10m34s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m38s

限制外部生成 worker 脱管任务的执行容量

限制 AI 任务文本与终态内存

清理过期认证会话并限制临时状态

降低备份扫描与 catalog 哈希峰值

禁止 release 使用高风险 files-history 并增加备份资源护栏

同步后端、运维与项目决策文档
This commit is contained in:
2026-08-27 17:12:34 +08:00
parent 5942ff25e7
commit 0ef3461df9
12 changed files with 550 additions and 70 deletions
+14 -2
View File
@@ -821,6 +821,18 @@ const checks = [
reason:
'生产冷备份 service 必须用 node -- 分隔脚本参数,避免 Node 22 抢占业务 --env-file。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: 'Environment=NODE_OPTIONS=--max-old-space-size=768',
reason:
'备份 Node 进程必须设置独立 heap 上限,避免目录扫描异常拖垮 release 主机。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: 'MemoryMax=1G',
reason:
'备份 service 必须设置 systemd 内存硬上限,避免异常进程消耗整机内存。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
excludes: '--storage-format files',
@@ -901,10 +913,10 @@ const checks = [
},
{
file: 'jenkins/Jenkinsfile.production-server-provision',
excludes:
includes:
"params.DEPLOY_TARGET == 'release' && databaseBackupProfile == 'files-history'",
reason:
'release 必须能在显式选择 profile 且 baseline 预检通过后启用 files-history。',
'release 必须拒绝 files-history,避免逐文件 catalog 扫描再次触发生产内存峰值。',
},
{
file: 'scripts/database-backup-to-oss.mjs',
+55 -16
View File
@@ -555,7 +555,11 @@ function assertSafeRelativePath(dataDir, absolutePath) {
}
function statFingerprint(absolutePath, rootPath = absolutePath) {
const entries = [];
// 候选 snapshot 可能包含数十万条目录项;增量更新摘要,避免把每条
// fingerprint 字符串同时保存在 entries[] 后再 join,造成一次性内存峰值。
const fingerprintHash = createHash('sha256');
let isFirstEntry = true;
let entryCount = 0;
let totalSize = 0n;
const visit = (currentPath) => {
const stat = lstatSync(currentPath, {bigint: true});
@@ -567,7 +571,7 @@ function statFingerprint(absolutePath, rootPath = absolutePath) {
if (kind === 'other') {
throw new Error(`history 候选只允许普通文件或目录: ${currentPath}`);
}
entries.push([
const entry = [
entryPath,
kind,
stat.dev.toString(),
@@ -575,7 +579,13 @@ function statFingerprint(absolutePath, rootPath = absolutePath) {
stat.mode.toString(),
stat.size.toString(),
stat.mtimeNs.toString(),
].join('\0'));
].join('\0');
if (!isFirstEntry) {
fingerprintHash.update('\n');
}
fingerprintHash.update(entry);
isFirstEntry = false;
entryCount += 1;
if (stat.isFile()) {
totalSize += stat.size;
} else {
@@ -586,9 +596,9 @@ function statFingerprint(absolutePath, rootPath = absolutePath) {
};
visit(rootPath);
return {
fingerprint: sha256Hex(entries.join('\n')),
fingerprint: fingerprintHash.digest('hex'),
sizeBytes: totalSize.toString(),
entryCount: entries.length,
entryCount,
};
}
@@ -1285,14 +1295,17 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje
throw new Error(`files 扫描期间源文件发生变化: ${relativePath}`);
}
const basePrefix = normalizeObjectPrefix(objectPrefix, database);
files.set(relativePath, {
const file = {
path: relativePath,
sizeBytes: Number(after.size),
sha256,
mode: after.mode,
objectKey: `${basePrefix}/files/sha256/${sha256.slice(0, 2)}/${sha256}`,
sourceStat: after,
});
};
// 上传前后的 inode/stat 仍用于防止在线扫描漂移,但设为不可枚举,避免
// 把仅供本地校验的副本再次写入 catalog 或 result JSON。
Object.defineProperty(file, 'sourceStat', {value: after, enumerable: false});
files.set(relativePath, file);
};
for (const root of roots.sort((left, right) => left.relativePath.localeCompare(right.relativePath))) {
@@ -1309,14 +1322,40 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje
}
function directCatalogIdentity({mode, baselineCatalogId, rootName, directories, files, symlinks}) {
return sha256Hex(JSON.stringify({
mode,
baselineCatalogId: baselineCatalogId || '',
rootName,
directories,
files: files.map(({path, sizeBytes, sha256, mode, objectKey}) => ({path, sizeBytes, sha256, mode, objectKey})),
symlinks,
// 不把数十万条文件元数据先拼成一个巨型 JSON 字符串;分段写入 hash
// 保持与 JSON.stringify 同样的字段顺序和转义结果,同时把峰值降到单条记录。
const hash = createHash('sha256');
hash.update('{"mode":');
hash.update(JSON.stringify(mode));
hash.update(',"baselineCatalogId":');
hash.update(JSON.stringify(baselineCatalogId || ''));
hash.update(',"rootName":');
hash.update(JSON.stringify(rootName));
hash.update(',"directories":');
updateJsonArrayHash(hash, directories, (directory) => JSON.stringify(directory));
hash.update(',"files":');
updateJsonArrayHash(hash, files, (file) => JSON.stringify({
path: file.path,
sizeBytes: file.sizeBytes,
sha256: file.sha256,
mode: file.mode,
objectKey: file.objectKey,
}));
hash.update(',"symlinks":');
updateJsonArrayHash(hash, symlinks, (symlink) => JSON.stringify(symlink));
hash.update('}');
return hash.digest('hex');
}
function updateJsonArrayHash(hash, values, serialize) {
hash.update('[');
values.forEach((value, index) => {
if (index > 0) {
hash.update(',');
}
hash.update(serialize(value));
});
hash.update(']');
}
function readDirectFilesState(statePath, {database, bucket}) {
@@ -1621,7 +1660,7 @@ export async function runDirectFilesBackup({
baselineCatalogId,
rootName,
directories: collected.directories,
files: collected.files.map(({sourceStat: _sourceStat, ...file}) => file),
files: collected.files,
symlinks: collected.symlinks,
};
writeManifest({manifestPath: catalogPath, payload: catalog});
+4
View File
@@ -78,6 +78,10 @@ validate_database_backup_profile() {
exit 1
;;
esac
if [[ "${DEPLOY_TARGET}" == "release" && "${DATABASE_BACKUP_PROFILE}" == "files-history" ]]; then
echo "[server-provision] release 仅允许 archive-fullfiles-history 会把整棵历史目录加载到 Node 内存,需先完成流式 catalog 改造后才能重新启用。" >&2
exit 1
fi
if [[ ! "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" =~ ^/var/lib/genarrative/database-backups/[A-Za-z0-9._/-]+$ || "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" == *..* ]]; then
echo "[server-provision] DATABASE_BACKUP_FILES_HISTORY_WORK_DIR 必须是 /var/lib/genarrative/database-backups/ 下不含连续点号的绝对路径,当前值: ${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" >&2
exit 1