收口生产内存工作集
限制外部生成 worker 脱管任务的执行容量 限制 AI 任务文本与终态内存 清理过期认证会话并限制临时状态 降低备份扫描与 catalog 哈希峰值 禁止 release 使用高风险 files-history 并增加备份资源护栏 同步后端、运维与项目决策文档
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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-full;files-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
|
||||
|
||||
Reference in New Issue
Block a user