优化逐文件备份并发上传
增加files对象PUT和HEAD受控并发 限制并发范围并降低大目录进度日志量 补齐并发上限测试、环境示例与运维文档
This commit is contained in:
@@ -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]});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user