优化逐文件小对象上传

小于等于16MiB的CAS对象改用单次PUT并执行HEAD验真
大对象继续保留multipart上传与失败清理
补齐单次PUT请求路径测试和运维说明
This commit is contained in:
2026-07-16 18:28:04 +08:00
parent 40684aafcd
commit 94014392ed
3 changed files with 132 additions and 2 deletions
+35
View File
@@ -17,6 +17,7 @@ import {
resumeUploadedHistoryBatch,
runDirectFilesBackup,
uploadArchive,
uploadDirectFile,
uploadHistoryArchiveWithCleanup,
uploadManifestFile,
} from './database-backup-to-oss.mjs';
@@ -46,6 +47,7 @@ async function main() {
assertInsufficientSpaceStopsBeforeServiceChanges();
assertArchiveFailureStillRestoresDependentServices();
await assertMultipartUploadRetriesAndVerifiesRemoteLength();
await assertDirectSmallFileUsesSinglePut();
await assertMissingPartEtagAbortsMultipartUpload();
await assertCompleteResponseAmbiguityUsesHeadVerification();
await assertHeadLengthMismatchAbortsMultipartUpload();
@@ -101,6 +103,39 @@ function createDirectOssHarness() {
return {objects, uploadedKeys, verifiedKeys, uploadFn, uploadManifestFn, verifyFn};
}
async function assertDirectSmallFileUsesSinglePut() {
const filePath = path.join(tmpRoot, 'direct-small-file.bin');
const body = Buffer.from('small direct object');
const sha256 = createHash('sha256').update(body).digest('hex');
writeFileSync(filePath, body);
const methods = [];
const fetchImpl = async (_url, options) => {
methods.push(options.method);
if (options.method === 'PUT') {
return new Response('', {status: 200, headers: {etag: '"single-etag"'}});
}
if (options.method === 'HEAD') {
return new Response(null, {status: 200, headers: {
'content-length': String(body.length),
'x-oss-meta-file-sha256': sha256,
}});
}
throw new Error(`unexpected method ${options.method}`);
};
const result = await uploadDirectFile({
archivePath: filePath,
bucket: 'backup-bucket',
endpoint: 'oss-cn-shanghai.aliyuncs.com',
objectKey: 'database-backups/test-db/files/small',
accessKeyId: 'test-id',
accessKeySecret: 'test-secret',
archiveSha256: sha256,
fetchImpl,
});
assertEqual(result.uploadMode, 'single', '小型逐文件对象必须使用单次 PUT。');
assertEqual(methods.join(','), 'PUT,HEAD', '小型逐文件对象只能执行 PUT 后 HEAD 验真,不得进入 multipart。');
}
async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload() {
const root = path.join(tmpRoot, 'direct-files-incremental');
const dataDir = path.join(root, 'stdb');
+96 -1
View File
@@ -49,6 +49,7 @@ 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 DIRECT_FILES_SINGLE_PUT_MAX_BYTES = 16 * 1024 * 1024;
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;
@@ -1348,7 +1349,7 @@ export async function runDirectFilesBackup({
dryRun = false,
resultFile = '',
uploadOptions,
uploadFn = uploadArchive,
uploadFn = uploadDirectFile,
uploadManifestFn = uploadManifestFile,
verifyFn = verifyOssObject,
concurrency = 1,
@@ -2279,6 +2280,100 @@ export async function uploadArchive({
}
}
export async function uploadDirectFile({
archivePath,
bucket,
endpoint,
objectKey,
accessKeyId,
accessKeySecret,
fetchImpl = globalThis.fetch,
nowFn = () => new Date(),
sleepImpl = sleep,
randomFn = Math.random,
maxAttempts = DEFAULT_OSS_REQUEST_MAX_ATTEMPTS,
retryBaseDelayMs = DEFAULT_OSS_RETRY_BASE_DELAY_MS,
retryMaxDelayMs = DEFAULT_OSS_RETRY_MAX_DELAY_MS,
backupKind = 'spacetimedb-direct-file',
archiveSha256 = '',
contentType = 'application/octet-stream',
allowEmpty = true,
}) {
const fileStat = statSync(archivePath);
if (!fileStat.isFile() || (!allowEmpty && fileStat.size <= 0)) {
throw new Error(`待上传备份必须是${allowEmpty ? '' : '非空'}普通文件: ${archivePath}`);
}
if (fileStat.size > DIRECT_FILES_SINGLE_PUT_MAX_BYTES) {
return uploadArchive({
archivePath,
bucket,
endpoint,
objectKey,
accessKeyId,
accessKeySecret,
fetchImpl,
nowFn,
sleepImpl,
randomFn,
maxAttempts,
retryBaseDelayMs,
retryMaxDelayMs,
backupKind,
archiveSha256,
contentType,
allowEmpty,
});
}
const verifiedArchiveSha256 = archiveSha256 || await sha256FileHex(archivePath);
if (!/^[a-f0-9]{64}$/u.test(verifiedArchiveSha256)) {
throw new Error(`归档 SHA-256 无效: ${verifiedArchiveSha256}`);
}
const requestOptions = {
bucket,
endpoint,
objectKey,
accessKeyId,
accessKeySecret,
fetchImpl,
nowFn,
sleepImpl,
randomFn,
maxAttempts,
retryBaseDelayMs,
retryMaxDelayMs,
};
await signedOssRequest({
...requestOptions,
method: 'PUT',
headers: {
'content-type': contentType,
'x-oss-meta-archive-sha256': verifiedArchiveSha256,
'x-oss-meta-file-sha256': verifiedArchiveSha256,
'x-oss-meta-file-size': String(fileStat.size),
'x-oss-meta-backup-kind': backupKind,
},
contentLength: fileStat.size,
bodyFactory: () => fileStat.size === 0 ? Buffer.alloc(0) : createReadStream(archivePath),
operation: '上传逐文件对象',
});
const verification = await verifyUploadedObject({
requestOptions,
expectedContentLength: fileStat.size,
expectedArchiveSha256: verifiedArchiveSha256,
});
return {
bucket,
objectKey,
contentLength: fileStat.size,
archiveSha256: verifiedArchiveSha256,
etag: '',
uploadMode: 'single',
partCount: 1,
partSizeBytes: fileStat.size,
verifiedAt: verification.verifiedAt,
};
}
export async function uploadManifestFile({
manifestPath,
bucket,