优化逐文件备份并发上传

增加files对象PUT和HEAD受控并发
限制并发范围并降低大目录进度日志量
补齐并发上限测试、环境示例与运维文档
This commit is contained in:
2026-07-16 18:18:26 +08:00
parent 56d70bf720
commit 40684aafcd
4 changed files with 84 additions and 17 deletions
+39
View File
@@ -60,6 +60,7 @@ async function main() {
await assertHistorySuccessfulUploadCleansAndIsIdempotent();
await assertHistoryResumeReverifiesArchiveAndManifest();
await assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload();
await assertDirectFilesConcurrencyIsBounded();
await assertDirectHistoryPublishesCatalogBeforeCleanup();
await assertDirectHistoryWithoutCandidatesPublishesLatest();
await assertDirectFilesRestoreDownloadsCatalogAndObjects();
@@ -153,6 +154,44 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload(
assertEqual(incremental.reusedCount, 1, '增量 files full 应复用未变化 snapshot 文件。');
}
async function assertDirectFilesConcurrencyIsBounded() {
const root = path.join(tmpRoot, 'direct-files-concurrency');
const dataDir = path.join(root, 'stdb');
const workDir = path.join(root, 'work');
mkdirSync(dataDir, {recursive: true});
for (let index = 0; index < 12; index += 1) {
writeFileSync(path.join(dataDir, `file-${index}.bin`), `content-${index}`);
}
const harness = createDirectOssHarness();
let activeUploads = 0;
let maxActiveUploads = 0;
const uploadFn = async (options) => {
activeUploads += 1;
maxActiveUploads = Math.max(maxActiveUploads, activeUploads);
await new Promise((resolve) => setTimeout(resolve, 5));
try {
return await harness.uploadFn(options);
} finally {
activeUploads -= 1;
}
};
await runDirectFilesBackup({
mode: 'full',
dataDir,
workDir,
database: 'test-db',
bucket: 'backup-bucket',
objectPrefix: 'database-backups',
uploadOptions: {},
uploadFn,
uploadManifestFn: harness.uploadManifestFn,
verifyFn: harness.verifyFn,
concurrency: 3,
});
assertTrue(maxActiveUploads > 1, 'files 备份应按配置并发处理多个对象。');
assertTrue(maxActiveUploads <= 3, 'files 备份对象并发数不得超过配置上限。');
}
async function assertDirectHistoryPublishesCatalogBeforeCleanup() {
const fixture = createHistoryFixture('direct-files-history-cleanup', {nestedData: false});
createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]});
+41 -16
View File
@@ -47,6 +47,8 @@ const OSS_MAX_MULTIPART_PARTS = 10_000;
const DEFAULT_OSS_REQUEST_MAX_ATTEMPTS = 5;
const DEFAULT_OSS_RETRY_BASE_DELAY_MS = 1_000;
const DEFAULT_OSS_RETRY_MAX_DELAY_MS = 30_000;
const DEFAULT_DIRECT_FILES_CONCURRENCY = 16;
const MAX_DIRECT_FILES_CONCURRENCY = 64;
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;
@@ -277,6 +279,14 @@ function firstNonEmpty(...values) {
return values.map((value) => String(value ?? '').trim()).find(Boolean) ?? '';
}
function parseDirectFilesConcurrency(rawValue) {
const value = Number(String(rawValue ?? DEFAULT_DIRECT_FILES_CONCURRENCY).trim());
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_DIRECT_FILES_CONCURRENCY) {
throw new Error(`GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY 必须是 1-${MAX_DIRECT_FILES_CONCURRENCY} 的整数,实际: ${rawValue}`);
}
return value;
}
function resolvePath(value) {
return isAbsolute(value) ? value : resolve(REPO_ROOT, value);
}
@@ -1341,6 +1351,7 @@ export async function runDirectFilesBackup({
uploadFn = uploadArchive,
uploadManifestFn = uploadManifestFile,
verifyFn = verifyOssObject,
concurrency = 1,
}) {
mkdirSync(workDir, {recursive: true});
const statePath = directFilesStatePath({workDir, database});
@@ -1449,23 +1460,35 @@ export async function runDirectFilesBackup({
const previousFiles = new Map((state?.latestCatalog?.files ?? []).map((file) => [file.path, file]));
let uploadedCount = 0;
let reusedCount = 0;
for (const [index, file] of collected.files.entries()) {
const result = await ensureDirectObject({
file,
dataDir,
uploadOptions,
previousFile: previousFiles.get(file.path),
verifyCatalogReuse: mode === 'history',
uploadFn,
verifyFn,
});
if (result.status === 'uploaded') {
uploadedCount += 1;
} else {
reusedCount += 1;
let nextIndex = 0;
let completedCount = 0;
const workerCount = Math.min(concurrency, collected.files.length);
const workers = Array.from({length: workerCount}, async () => {
while (nextIndex < collected.files.length) {
const index = nextIndex;
nextIndex += 1;
const file = collected.files[index];
const result = await ensureDirectObject({
file,
dataDir,
uploadOptions,
previousFile: previousFiles.get(file.path),
verifyCatalogReuse: mode === 'history',
uploadFn,
verifyFn,
});
if (result.status === 'uploaded') {
uploadedCount += 1;
} else {
reusedCount += 1;
}
completedCount += 1;
if (collected.files.length <= 100 || completedCount % 1000 === 0 || completedCount === collected.files.length) {
console.log(`[database-backup] files 进度: ${completedCount}/${collected.files.length} (${result.status}) ${file.path}`);
}
}
console.log(`[database-backup] files 进度: ${index + 1}/${collected.files.length} (${result.status}) ${file.path}`);
}
});
await Promise.all(workers);
const catalogUpload = await ensureDirectManifest({
manifestPath: catalogPath,
objectKey: catalogObjectKey,
@@ -2704,6 +2727,7 @@ async function main() {
const database = firstNonEmpty(args.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir));
const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true';
const storageFormat = firstNonEmpty(args.storageFormat, env.GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT, 'archive');
const directFilesConcurrency = parseDirectFilesConcurrency(env.GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY);
if (!['full', 'history'].includes(args.mode)) {
throw new Error(`--mode 只能是 full 或 history,实际: ${args.mode}`);
@@ -2793,6 +2817,7 @@ async function main() {
dryRun: args.dryRun,
resultFile: args.resultFile,
uploadOptions: {bucket, endpoint, accessKeyId, accessKeySecret},
concurrency: directFilesConcurrency,
});
} catch (error) {
backupError = error;