diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index b2f4725e4..de8d780f7 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -3997,3 +3997,11 @@
- 参数刷新:Jenkinsfile 是参数事实源。推送后必须让 Full 与 API Deploy live Job 安全加载一次新 Jenkinsfile,再只读确认两个参数已进入 `config.xml`;只在 Jenkins UI 手工加参数不是持久修复。
- 影响范围:Full / API Deploy Jenkinsfile、API 发布脚本、生产 API deploy fixture、生产运维门禁与 live Job 参数 schema。
- 验证方式:`bash -n scripts/deploy/production-api-deploy.sh`、`npm run check:production-api-deploy`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`。
+
+## 2026-07-13 数据库冷备使用 OSS Multipart 上传并在清理前验真
+
+- 背景:SpacetimeDB 冷备归档已经超过 OSS 单次 PutObject 的 5 GiB 上限,单请求上传会稳定失败并让本地归档持续积压;网络中断还可能让 CompleteMultipartUpload 的客户端结果不确定。
+- 决策:`scripts/database-backup-to-oss.mjs` 对备份归档统一使用 OSS Multipart Upload,默认按 128 MiB 顺序分片;每个分片请求重新创建文件流、时间和 V4 签名,仅对网络错误、HTTP 408 / 429 / 5xx 做有限重试。V4 canonical query 必须同时支持无等号的 `uploads` 子资源和带值的 `partNumber` / `uploadId` 参数。
+- 验真与清理边界:Complete 后必须发送签名 HEAD,并严格核对 OSS `Content-Length` 与本地归档大小;Complete 响应不确定时也先用 HEAD 判定对象是否已经完整落盘。只有验真成功后才能把 manifest 标记为 `uploaded`,并按 `keepLocal` 决定是否删除本地归档;失败时 best-effort AbortMultipartUpload,不得提前更新 manifest 或清理本地文件。
+- 影响范围:数据库备份 OSS 上传实现、备份回归门禁、release 本地归档保留与 timer 恢复流程。
+- 验证方式:`npm run check:database-backup`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`;线上先对既有归档使用 `--upload-archive ... --keep-local`,确认 OSS 对象长度和可恢复性后再清理积压并恢复 timer。
diff --git a/scripts/check-database-backup-to-oss.mjs b/scripts/check-database-backup-to-oss.mjs
index af154f6cf..bbc8b0fb2 100644
--- a/scripts/check-database-backup-to-oss.mjs
+++ b/scripts/check-database-backup-to-oss.mjs
@@ -5,12 +5,14 @@ import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync}
import {tmpdir} from 'node:os';
import path from 'node:path';
+import {buildAuthorization, buildCanonicalQuery, uploadArchive} from './database-backup-to-oss.mjs';
+
const BACKUP_SCRIPT = path.resolve('scripts/database-backup-to-oss.mjs');
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'genarrative-database-backup-check-'));
const failures = [];
try {
- main();
+ await main();
} finally {
rmSync(tmpRoot, {recursive: true, force: true});
}
@@ -25,9 +27,58 @@ if (failures.length > 0) {
console.log('[check:database-backup] OK');
-function main() {
+async function main() {
+ assertCanonicalQueryAndAuthorizationIncludeMultipartParameters();
assertInsufficientSpaceStopsBeforeServiceChanges();
assertArchiveFailureStillRestoresDependentServices();
+ await assertMultipartUploadRetriesAndVerifiesRemoteLength();
+ await assertMissingPartEtagAbortsMultipartUpload();
+ await assertCompleteResponseAmbiguityUsesHeadVerification();
+ await assertHeadLengthMismatchAbortsMultipartUpload();
+}
+
+function assertCanonicalQueryAndAuthorizationIncludeMultipartParameters() {
+ assertEqual(buildCanonicalQuery({uploads: null}), 'uploads', 'InitiateMultipartUpload 必须使用无等号的 uploads 参数。');
+ assertEqual(
+ buildCanonicalQuery({uploadId: 'abc+/= xyz', partNumber: 12}),
+ 'partNumber=12&uploadId=abc%2B%2F%3D%20xyz',
+ 'multipart query 必须按 key 排序并使用 RFC3986 编码。',
+ );
+
+ const date = new Date('2026-07-13T10:20:30.000Z');
+ const headers = {
+ host: 'genarrative-test.oss-cn-shanghai.aliyuncs.com',
+ 'content-type': 'application/octet-stream',
+ 'x-oss-content-sha256': 'UNSIGNED-PAYLOAD',
+ 'x-oss-date': '20260713T102030Z',
+ };
+ const withoutQuery = buildAuthorization({
+ method: 'PUT',
+ bucket: 'genarrative-test',
+ endpoint: 'oss-cn-shanghai.aliyuncs.com',
+ objectKey: 'database-backups/test/archive.tar.gz',
+ accessKeyId: 'test-access-key',
+ accessKeySecret: 'test-access-secret',
+ headers,
+ date,
+ });
+ const withQuery = buildAuthorization({
+ method: 'PUT',
+ bucket: 'genarrative-test',
+ endpoint: 'oss-cn-shanghai.aliyuncs.com',
+ objectKey: 'database-backups/test/archive.tar.gz',
+ accessKeyId: 'test-access-key',
+ accessKeySecret: 'test-access-secret',
+ headers,
+ date,
+ queries: {partNumber: 12, uploadId: 'abc+/= xyz'},
+ });
+ assertNotEqual(withQuery, withoutQuery, 'multipart query 必须参与 V4 Authorization 计算。');
+ assertEqual(
+ withQuery,
+ 'OSS4-HMAC-SHA256 Credential=test-access-key/20260713/cn-shanghai/oss/aliyun_v4_request,AdditionalHeaders=host,Signature=9323dd3b7272b52f416c4d32115fcc00460eaccdcdaf011575c2502a63a27b1f',
+ 'multipart V4 Authorization 必须保持固定签名向量。',
+ );
}
function assertInsufficientSpaceStopsBeforeServiceChanges() {
@@ -78,6 +129,279 @@ function assertArchiveFailureStillRestoresDependentServices() {
}
}
+async function assertMultipartUploadRetriesAndVerifiesRemoteLength() {
+ const root = path.join(tmpRoot, 'multipart-success');
+ const archivePath = path.join(root, 'backup.tar.gz');
+ const partSizeBytes = 100 * 1024;
+ const payload = Buffer.concat([
+ Buffer.alloc(partSizeBytes, 'a'),
+ Buffer.alloc(partSizeBytes, 'b'),
+ Buffer.alloc(17, 'c'),
+ ]);
+ mkdirSync(root, {recursive: true});
+ writeFileSync(archivePath, payload);
+
+ const requests = [];
+ const retryDelays = [];
+ let firstPartAttempts = 0;
+ const uploadId = 'upload+/= id';
+ const fetchImpl = async (url, options) => {
+ const body = await readRequestBody(options.body);
+ requests.push({url, method: options.method, headers: options.headers, body});
+ const parsedUrl = new URL(url);
+
+ if (options.method === 'POST' && parsedUrl.search === '?uploads') {
+ return new Response(`${uploadId}`, {status: 200});
+ }
+ if (options.method === 'PUT') {
+ const partNumber = Number(parsedUrl.searchParams.get('partNumber'));
+ if (partNumber === 1) {
+ firstPartAttempts += 1;
+ if (firstPartAttempts === 1) {
+ return new Response('ServiceUnavailable', {status: 503});
+ }
+ }
+ return new Response('', {status: 200, headers: {etag: `"etag-${partNumber}"`}});
+ }
+ if (options.method === 'POST' && parsedUrl.searchParams.has('uploadId')) {
+ return new Response('', {status: 200, headers: {etag: '"complete-etag"'}});
+ }
+ if (options.method === 'HEAD') {
+ return new Response(null, {status: 200, headers: {'content-length': String(payload.length)}});
+ }
+ throw new Error(`unexpected request: ${options.method} ${url}`);
+ };
+
+ const result = await uploadArchive({
+ archivePath,
+ bucket: 'genarrative-test',
+ endpoint: 'oss-cn-shanghai.aliyuncs.com',
+ objectKey: 'database-backups/test/backup.tar.gz',
+ accessKeyId: 'test-access-key',
+ accessKeySecret: 'test-access-secret',
+ partSizeBytes,
+ maxAttempts: 3,
+ retryBaseDelayMs: 1,
+ retryMaxDelayMs: 1,
+ fetchImpl,
+ nowFn: () => new Date('2026-07-13T10:20:30.000Z'),
+ sleepImpl: async (delayMs) => retryDelays.push(delayMs),
+ randomFn: () => 0,
+ });
+
+ assertEqual(result.uploadMode, 'multipart', '上传结果必须记录 multipart 模式。');
+ assertEqual(result.partCount, 3, 'multipart 应按配置大小切成三段。');
+ assertEqual(result.contentLength, payload.length, '上传结果应保留完整归档长度。');
+ assertEqual(result.etag, 'complete-etag', '上传结果应保留 CompleteMultipartUpload ETag。');
+ assertEqual(firstPartAttempts, 2, '503 后应仅重试失败的第一段。');
+ assertEqual(retryDelays.length, 1, '一次可重试失败应触发一次退避。');
+
+ const initiateRequest = requests[0];
+ assertTrue(initiateRequest.url.endsWith('?uploads'), 'InitiateMultipartUpload URL 必须使用裸 uploads 参数。');
+ assertTrue(!initiateRequest.url.endsWith('?uploads='), 'InitiateMultipartUpload URL 不能把裸参数写成 uploads=。');
+ const firstPartRequests = requests.filter(({method, url}) => method === 'PUT' && new URL(url).searchParams.get('partNumber') === '1');
+ assertEqual(firstPartRequests.length, 2, '第一段应产生原请求和一次重试。');
+ assertBufferEqual(firstPartRequests[0].body, payload.subarray(0, partSizeBytes), '第一段原请求内容必须完整。');
+ assertBufferEqual(firstPartRequests[1].body, payload.subarray(0, partSizeBytes), '第一段重试必须重新创建并完整读取 stream。');
+ assertTrue(
+ firstPartRequests[0].url.includes('?partNumber=1&uploadId=upload%2B%2F%3D%20id'),
+ 'UploadPart URL 必须使用排序并编码后的 canonical query。',
+ );
+
+ const completeRequest = requests.find(({method, url}) => method === 'POST' && new URL(url).searchParams.has('uploadId'));
+ assertIncludes(completeRequest?.body.toString('utf8') ?? '', '1"etag-1"', 'Complete XML 应包含第一段 ETag。');
+ assertIncludes(completeRequest?.body.toString('utf8') ?? '', '3"etag-3"', 'Complete XML 应包含最后一段 ETag。');
+ assertTrue(requests.some(({method}) => method === 'HEAD'), 'Complete 后必须执行签名 HEAD 验证。');
+ for (const request of requests) {
+ assertTrue(String(request.headers.authorization ?? '').startsWith('OSS4-HMAC-SHA256 '), `${request.method} 请求必须携带 V4 Authorization。`);
+ }
+}
+
+async function assertHeadLengthMismatchAbortsMultipartUpload() {
+ const root = path.join(tmpRoot, 'multipart-head-mismatch');
+ const archivePath = path.join(root, 'backup.tar.gz');
+ const partSizeBytes = 100 * 1024;
+ const payload = Buffer.alloc(partSizeBytes + 1, 'x');
+ mkdirSync(root, {recursive: true});
+ writeFileSync(archivePath, payload);
+
+ const requests = [];
+ const fetchImpl = async (url, options) => {
+ await readRequestBody(options.body);
+ requests.push({url, method: options.method});
+ const parsedUrl = new URL(url);
+ if (options.method === 'POST' && parsedUrl.search === '?uploads') {
+ return new Response('mismatch-upload', {status: 200});
+ }
+ if (options.method === 'PUT') {
+ return new Response('', {status: 200, headers: {etag: `"etag-${parsedUrl.searchParams.get('partNumber')}"`}});
+ }
+ if (options.method === 'POST') {
+ return new Response('', {status: 200, headers: {etag: '"complete-etag"'}});
+ }
+ if (options.method === 'HEAD') {
+ return new Response(null, {status: 200, headers: {'content-length': String(payload.length - 1)}});
+ }
+ if (options.method === 'DELETE') {
+ return new Response(null, {status: 204});
+ }
+ throw new Error(`unexpected request: ${options.method} ${url}`);
+ };
+
+ let uploadError = null;
+ try {
+ await uploadArchive({
+ archivePath,
+ bucket: 'genarrative-test',
+ endpoint: 'oss-cn-shanghai.aliyuncs.com',
+ objectKey: 'database-backups/test/mismatch.tar.gz',
+ accessKeyId: 'test-access-key',
+ accessKeySecret: 'test-access-secret',
+ partSizeBytes,
+ maxAttempts: 2,
+ retryBaseDelayMs: 1,
+ retryMaxDelayMs: 1,
+ fetchImpl,
+ nowFn: () => new Date('2026-07-13T10:20:30.000Z'),
+ sleepImpl: async () => {},
+ randomFn: () => 0,
+ });
+ } catch (error) {
+ uploadError = error;
+ }
+
+ assertTrue(uploadError instanceof Error, 'HEAD 长度不一致时上传必须失败。');
+ assertIncludes(uploadError?.message ?? '', 'HEAD 验证长度不一致', 'HEAD 长度不一致错误应保留本地和远端长度。');
+ const abortRequest = requests.find(({method}) => method === 'DELETE');
+ assertTrue(Boolean(abortRequest), 'HEAD 长度不一致后必须 best-effort AbortMultipartUpload。');
+ assertTrue(abortRequest?.url.endsWith('?uploadId=mismatch-upload'), 'AbortMultipartUpload 必须携带同一 uploadId。');
+}
+
+async function assertMissingPartEtagAbortsMultipartUpload() {
+ const root = path.join(tmpRoot, 'multipart-missing-etag');
+ const archivePath = path.join(root, 'backup.tar.gz');
+ mkdirSync(root, {recursive: true});
+ writeFileSync(archivePath, Buffer.alloc(100 * 1024, 'e'));
+
+ const requests = [];
+ const fetchImpl = async (url, options) => {
+ await readRequestBody(options.body);
+ requests.push({url, method: options.method});
+ const parsedUrl = new URL(url);
+ if (options.method === 'POST' && parsedUrl.search === '?uploads') {
+ return new Response('missing-etag-upload', {status: 200});
+ }
+ if (options.method === 'PUT') {
+ return new Response('', {status: 200});
+ }
+ if (options.method === 'DELETE') {
+ return new Response(null, {status: 204});
+ }
+ throw new Error(`unexpected request: ${options.method} ${url}`);
+ };
+
+ let uploadError = null;
+ try {
+ await uploadArchive({
+ archivePath,
+ bucket: 'genarrative-test',
+ endpoint: 'oss-cn-shanghai.aliyuncs.com',
+ objectKey: 'database-backups/test/missing-etag.tar.gz',
+ accessKeyId: 'test-access-key',
+ accessKeySecret: 'test-access-secret',
+ partSizeBytes: 100 * 1024,
+ maxAttempts: 1,
+ fetchImpl,
+ nowFn: () => new Date('2026-07-13T10:20:30.000Z'),
+ sleepImpl: async () => {},
+ randomFn: () => 0,
+ });
+ } catch (error) {
+ uploadError = error;
+ }
+
+ assertIncludes(uploadError?.message ?? '', '响应缺少 ETag', 'UploadPart 缺少 ETag 时必须失败。');
+ assertTrue(
+ requests.some(({method, url}) => method === 'DELETE' && url.endsWith('?uploadId=missing-etag-upload')),
+ 'UploadPart 缺少 ETag 后必须 AbortMultipartUpload。',
+ );
+}
+
+async function assertCompleteResponseAmbiguityUsesHeadVerification() {
+ const root = path.join(tmpRoot, 'multipart-complete-ambiguity');
+ const archivePath = path.join(root, 'backup.tar.gz');
+ const payload = Buffer.alloc(100 * 1024, 'c');
+ mkdirSync(root, {recursive: true});
+ writeFileSync(archivePath, payload);
+
+ const requests = [];
+ let completeAttempts = 0;
+ const fetchImpl = async (url, options) => {
+ await readRequestBody(options.body);
+ requests.push({url, method: options.method});
+ const parsedUrl = new URL(url);
+ if (options.method === 'POST' && parsedUrl.search === '?uploads') {
+ return new Response('ambiguous-upload', {status: 200});
+ }
+ if (options.method === 'PUT') {
+ return new Response('', {status: 200, headers: {etag: '"part-etag"'}});
+ }
+ if (options.method === 'POST' && parsedUrl.searchParams.has('uploadId')) {
+ completeAttempts += 1;
+ if (completeAttempts === 1) {
+ throw new TypeError('socket closed after remote complete');
+ }
+ return new Response('NoSuchUpload', {status: 404});
+ }
+ if (options.method === 'HEAD') {
+ return new Response(null, {status: 200, headers: {'content-length': String(payload.length)}});
+ }
+ if (options.method === 'DELETE') {
+ return new Response(null, {status: 204});
+ }
+ throw new Error(`unexpected request: ${options.method} ${url}`);
+ };
+
+ const result = await uploadArchive({
+ archivePath,
+ bucket: 'genarrative-test',
+ endpoint: 'oss-cn-shanghai.aliyuncs.com',
+ objectKey: 'database-backups/test/complete-ambiguity.tar.gz',
+ accessKeyId: 'test-access-key',
+ accessKeySecret: 'test-access-secret',
+ partSizeBytes: 100 * 1024,
+ maxAttempts: 2,
+ retryBaseDelayMs: 1,
+ retryMaxDelayMs: 1,
+ fetchImpl,
+ nowFn: () => new Date('2026-07-13T10:20:30.000Z'),
+ sleepImpl: async () => {},
+ randomFn: () => 0,
+ });
+
+ assertEqual(completeAttempts, 2, 'Complete 网络错误后应按策略重试。');
+ assertEqual(result.contentLength, payload.length, 'Complete 结果不确定时应以 HEAD 长度验真收口。');
+ assertTrue(requests.some(({method}) => method === 'HEAD'), 'Complete 结果不确定时必须执行 HEAD 验真。');
+ assertTrue(!requests.some(({method}) => method === 'DELETE'), 'HEAD 已证实对象完整时不得 Abort 已完成上传。');
+}
+
+async function readRequestBody(body) {
+ if (body === undefined || body === null) {
+ return Buffer.alloc(0);
+ }
+ if (typeof body === 'string') {
+ return Buffer.from(body);
+ }
+ if (Buffer.isBuffer(body)) {
+ return body;
+ }
+ const chunks = [];
+ for await (const chunk of body) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ return Buffer.concat(chunks);
+}
+
function createFixture(name) {
const root = path.join(tmpRoot, name);
const binDir = path.join(root, 'bin');
@@ -160,6 +484,30 @@ function assertIncludes(content, expected, reason) {
}
}
+function assertEqual(actual, expected, reason) {
+ if (actual !== expected) {
+ failures.push(`${reason} 预期: ${String(expected)},实际: ${String(actual)}`);
+ }
+}
+
+function assertNotEqual(actual, expected, reason) {
+ if (actual === expected) {
+ failures.push(`${reason} 两者均为: ${String(actual)}`);
+ }
+}
+
+function assertTrue(condition, reason) {
+ if (!condition) {
+ failures.push(reason);
+ }
+}
+
+function assertBufferEqual(actual, expected, reason) {
+ if (!Buffer.isBuffer(actual) || !actual.equals(expected)) {
+ failures.push(`${reason} 预期 ${expected.length} bytes,实际 ${actual?.length ?? ''} bytes。`);
+ }
+}
+
function assertFileMissing(filePath, reason) {
if (existsSync(filePath)) {
failures.push(`${reason} 实际存在: ${filePath}\n${readFile(filePath)}`);
diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs
index 4d4e969a2..d38a28598 100644
--- a/scripts/database-backup-to-oss.mjs
+++ b/scripts/database-backup-to-oss.mjs
@@ -1,8 +1,9 @@
#!/usr/bin/env node
import {spawnSync} from 'node:child_process';
import {createHash, createHmac} from 'node:crypto';
-import {createReadStream, existsSync, mkdirSync, readFileSync, rmSync, statSync, statfsSync, writeFileSync} from 'node:fs';
+import {createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, statfsSync, writeFileSync} from 'node:fs';
import {basename, dirname, isAbsolute, resolve} from 'node:path';
+import {setTimeout as sleep} from 'node:timers/promises';
import {fileURLToPath} from 'node:url';
const __filename = fileURLToPath(import.meta.url);
@@ -18,6 +19,14 @@ const OSS_ALGORITHM = 'OSS4-HMAC-SHA256';
const OSS_SERVICE = 'oss';
const OSS_REQUEST = 'aliyun_v4_request';
const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
+const DEFAULT_OSS_MULTIPART_PART_SIZE_BYTES = 128 * 1024 * 1024;
+const OSS_MIN_MULTIPART_PART_SIZE_BYTES = 100 * 1024;
+const OSS_MAX_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024 * 1024;
+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 RETRYABLE_OSS_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
function usage() {
console.log(`用法:
@@ -484,11 +493,33 @@ function encodePath(path) {
.join('/');
}
+function encodeQueryComponent(value) {
+ return encodeURIComponent(String(value)).replace(
+ /[!'()*]/gu,
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
+ );
+}
+
+export function buildCanonicalQuery(queries = {}) {
+ return Object.entries(queries)
+ .map(([key, value]) => [encodeQueryComponent(key), value === null ? null : encodeQueryComponent(value)])
+ .sort(([leftKey, leftValue], [rightKey, rightValue]) => {
+ if (leftKey !== rightKey) {
+ return leftKey < rightKey ? -1 : 1;
+ }
+ const left = leftValue ?? '';
+ const right = rightValue ?? '';
+ return left === right ? 0 : left < right ? -1 : 1;
+ })
+ .map(([key, value]) => value === null ? key : `${key}=${value}`)
+ .join('&');
+}
+
function canonicalHeaderValue(value) {
return String(value).trim().replace(/\s+/gu, ' ');
}
-function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, accessKeySecret, headers, date}) {
+export function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, accessKeySecret, headers, date, queries = {}}) {
const region = regionFromEndpoint(endpoint);
const scopeDate = formatScopeDate(date);
const scope = `${scopeDate}/${region}/${OSS_SERVICE}/${OSS_REQUEST}`;
@@ -504,7 +535,7 @@ function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, a
const canonicalRequest = [
method,
canonicalUri,
- '',
+ buildCanonicalQuery(queries),
canonicalHeaders,
additionalHeaders,
UNSIGNED_PAYLOAD,
@@ -518,56 +549,304 @@ function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, a
return `${OSS_ALGORITHM} Credential=${accessKeyId}/${scope},AdditionalHeaders=${additionalHeaders},Signature=${finalSignature}`;
}
-async function uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret}) {
+function buildOssUrl({bucket, endpoint, objectKey, queries = {}}) {
+ const canonicalQuery = buildCanonicalQuery(queries);
+ return `https://${bucket}.${endpoint}/${encodePath(objectKey)}${canonicalQuery ? `?${canonicalQuery}` : ''}`;
+}
+
+function isRetryableOssStatus(status) {
+ return RETRYABLE_OSS_HTTP_STATUSES.has(status);
+}
+
+function retryDelayMs({attempt, baseDelayMs, maxDelayMs, randomFn}) {
+ const ceiling = Math.min(maxDelayMs, baseDelayMs * (2 ** Math.max(0, attempt - 1)));
+ return Math.floor(randomFn() * ceiling);
+}
+
+async function signedOssRequest({
+ method,
+ bucket,
+ endpoint,
+ objectKey,
+ accessKeyId,
+ accessKeySecret,
+ queries = {},
+ headers = {},
+ bodyFactory,
+ contentLength,
+ operation,
+ fetchImpl,
+ nowFn,
+ sleepImpl,
+ randomFn,
+ maxAttempts,
+ retryBaseDelayMs,
+ retryMaxDelayMs,
+}) {
+ const targetUrl = buildOssUrl({bucket, endpoint, objectKey, queries});
+ let lastError = null;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ const now = nowFn();
+ const signedHeaders = {
+ host: `${bucket}.${endpoint}`,
+ ...headers,
+ 'x-oss-content-sha256': UNSIGNED_PAYLOAD,
+ 'x-oss-date': formatOssDate(now),
+ };
+ const authorization = buildAuthorization({
+ method,
+ bucket,
+ endpoint,
+ objectKey,
+ accessKeyId,
+ accessKeySecret,
+ headers: signedHeaders,
+ date: now,
+ queries,
+ });
+ const requestHeaders = {...signedHeaders, authorization};
+ if (contentLength !== undefined) {
+ requestHeaders['content-length'] = String(contentLength);
+ }
+ const body = bodyFactory ? bodyFactory() : undefined;
+ const requestOptions = {method, headers: requestHeaders};
+ if (body !== undefined) {
+ requestOptions.body = body;
+ requestOptions.duplex = 'half';
+ }
+
+ let response;
+ try {
+ response = await fetchImpl(targetUrl, requestOptions);
+ } catch (error) {
+ lastError = new Error(`OSS ${operation}请求失败: oss://${bucket}/${objectKey}`, {cause: error});
+ }
+
+ if (response?.ok) {
+ return response;
+ }
+ if (response) {
+ const responseText = await response.text();
+ const requestId = response.headers.get('x-oss-request-id');
+ lastError = new Error(
+ `OSS ${operation}失败 HTTP ${response.status}${requestId ? ` requestId=${requestId}` : ''}: ${responseText.slice(0, 500)}`,
+ );
+ lastError.status = response.status;
+ }
+
+ const retryable = !response || isRetryableOssStatus(response.status);
+ if (!retryable || attempt >= maxAttempts) {
+ throw lastError;
+ }
+ const delayMs = retryDelayMs({attempt, baseDelayMs: retryBaseDelayMs, maxDelayMs: retryMaxDelayMs, randomFn});
+ console.warn(`[database-backup] OSS ${operation}失败,${delayMs}ms 后重试 (${attempt}/${maxAttempts})`);
+ await sleepImpl(delayMs);
+ }
+
+ throw lastError ?? new Error(`OSS ${operation}失败: oss://${bucket}/${objectKey}`);
+}
+
+function readXmlTag(xml, tagName) {
+ const match = new RegExp(`<${tagName}>([\\s\\S]*?)${tagName}>`, 'u').exec(xml);
+ if (!match) {
+ return '';
+ }
+ return match[1]
+ .replace(/</gu, '<')
+ .replace(/>/gu, '>')
+ .replace(/"/gu, '"')
+ .replace(/'/gu, "'")
+ .replace(/&/gu, '&')
+ .trim();
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/gu, '&')
+ .replace(//gu, '>')
+ .replace(/"/gu, '"')
+ .replace(/'/gu, ''');
+}
+
+function buildCompleteMultipartBody(parts) {
+ const partXml = parts
+ .map(({partNumber, etag}) => [
+ '',
+ `${partNumber}`,
+ `${escapeXml(etag)}`,
+ '',
+ ].join(''))
+ .join('');
+ return `${partXml}`;
+}
+
+function resolveMultipartPartSize(fileSize, configuredPartSize) {
+ if (!Number.isSafeInteger(configuredPartSize) || configuredPartSize < OSS_MIN_MULTIPART_PART_SIZE_BYTES) {
+ throw new Error(`OSS multipart part size 必须是 >= ${OSS_MIN_MULTIPART_PART_SIZE_BYTES} 的安全整数`);
+ }
+ const minimumForPartLimit = Math.ceil(fileSize / OSS_MAX_MULTIPART_PARTS);
+ const partSize = Math.max(configuredPartSize, minimumForPartLimit);
+ if (partSize > OSS_MAX_MULTIPART_PART_SIZE_BYTES) {
+ throw new Error(`OSS multipart part size 超过 5GiB: ${partSize}`);
+ }
+ return partSize;
+}
+
+async function verifyUploadedObject({requestOptions, expectedContentLength}) {
+ const response = await signedOssRequest({
+ ...requestOptions,
+ method: 'HEAD',
+ operation: 'HEAD 验证',
+ });
+ const contentLengthHeader = response.headers.get('content-length');
+ if (!contentLengthHeader || !/^\d+$/u.test(contentLengthHeader)) {
+ throw new Error(`OSS HEAD 验证缺少有效 content-length: ${contentLengthHeader ?? ''}`);
+ }
+ const remoteContentLength = Number(contentLengthHeader);
+ if (remoteContentLength !== expectedContentLength) {
+ throw new Error(`OSS HEAD 验证长度不一致: local=${expectedContentLength}, remote=${remoteContentLength}`);
+ }
+ return {verifiedAt: new Date().toISOString(), remoteContentLength};
+}
+
+async function abortMultipartUpload({requestOptions, uploadId}) {
+ try {
+ await signedOssRequest({
+ ...requestOptions,
+ method: 'DELETE',
+ queries: {uploadId},
+ operation: 'AbortMultipartUpload',
+ maxAttempts: Math.min(2, requestOptions.maxAttempts),
+ });
+ console.warn(`[database-backup] 已清理失败的 multipart upload: ${uploadId}`);
+ } catch (error) {
+ console.warn(`[database-backup] 清理 multipart upload 失败: ${error.message}`);
+ }
+}
+
+export async function uploadArchive({
+ archivePath,
+ bucket,
+ endpoint,
+ objectKey,
+ accessKeyId,
+ accessKeySecret,
+ partSizeBytes = DEFAULT_OSS_MULTIPART_PART_SIZE_BYTES,
+ maxAttempts = DEFAULT_OSS_REQUEST_MAX_ATTEMPTS,
+ retryBaseDelayMs = DEFAULT_OSS_RETRY_BASE_DELAY_MS,
+ retryMaxDelayMs = DEFAULT_OSS_RETRY_MAX_DELAY_MS,
+ fetchImpl = globalThis.fetch,
+ nowFn = () => new Date(),
+ sleepImpl = sleep,
+ randomFn = Math.random,
+}) {
const fileStat = statSync(archivePath);
- const now = new Date();
- const targetUrl = `https://${bucket}.${endpoint}/${encodePath(objectKey)}`;
- const headers = {
- host: `${bucket}.${endpoint}`,
- 'content-type': 'application/gzip',
- 'x-oss-content-sha256': UNSIGNED_PAYLOAD,
- 'x-oss-date': formatOssDate(now),
- 'x-oss-meta-backup-kind': 'spacetimedb-data-dir',
- };
- const authorization = buildAuthorization({
- method: 'PUT',
+ if (!fileStat.isFile() || fileStat.size <= 0) {
+ throw new Error(`待上传备份必须是非空文件: ${archivePath}`);
+ }
+ const partSize = resolveMultipartPartSize(fileStat.size, partSizeBytes);
+ const partCount = Math.ceil(fileStat.size / partSize);
+ const requestOptions = {
bucket,
endpoint,
objectKey,
accessKeyId,
accessKeySecret,
- headers,
- date: now,
- });
-
- console.log(`[database-backup] 上传 OSS: oss://${bucket}/${objectKey}`);
- let response;
- try {
- response = await fetch(targetUrl, {
- method: 'PUT',
- headers: {
- ...headers,
- authorization,
- 'content-length': String(fileStat.size),
- },
- body: createReadStream(archivePath),
- duplex: 'half',
- });
- } catch (error) {
- throw new Error(`OSS 上传请求失败: oss://${bucket}/${objectKey}`, {cause: error});
- }
-
- const responseText = await response.text();
- if (!response.ok) {
- throw new Error(`OSS 上传失败 HTTP ${response.status}: ${responseText.slice(0, 500)}`);
- }
-
- return {
- bucket,
- objectKey,
- contentLength: fileStat.size,
- etag: response.headers.get('etag')?.replace(/^"|"$/gu, '') ?? '',
+ fetchImpl,
+ nowFn,
+ sleepImpl,
+ randomFn,
+ maxAttempts,
+ retryBaseDelayMs,
+ retryMaxDelayMs,
};
+ let uploadId = '';
+ let uploadCompleted = false;
+
+ console.log(`[database-backup] multipart 上传 OSS: oss://${bucket}/${objectKey} (${partCount} parts)`);
+ try {
+ const initiateResponse = await signedOssRequest({
+ ...requestOptions,
+ method: 'POST',
+ queries: {uploads: null},
+ headers: {
+ 'content-type': 'application/gzip',
+ 'x-oss-meta-backup-kind': 'spacetimedb-data-dir',
+ },
+ operation: 'InitiateMultipartUpload',
+ });
+ uploadId = readXmlTag(await initiateResponse.text(), 'UploadId');
+ if (!uploadId) {
+ throw new Error('OSS InitiateMultipartUpload 响应缺少 UploadId');
+ }
+
+ const parts = [];
+ for (let partNumber = 1; partNumber <= partCount; partNumber += 1) {
+ const start = (partNumber - 1) * partSize;
+ const end = Math.min(fileStat.size, start + partSize) - 1;
+ const contentLength = end - start + 1;
+ const response = await signedOssRequest({
+ ...requestOptions,
+ method: 'PUT',
+ queries: {partNumber, uploadId},
+ headers: {'content-type': 'application/octet-stream'},
+ contentLength,
+ bodyFactory: () => createReadStream(archivePath, {start, end}),
+ operation: `UploadPart ${partNumber}/${partCount}`,
+ });
+ const etag = response.headers.get('etag');
+ if (!etag) {
+ throw new Error(`OSS UploadPart ${partNumber}/${partCount} 响应缺少 ETag`);
+ }
+ parts.push({partNumber, etag});
+ console.log(`[database-backup] multipart 进度: ${partNumber}/${partCount}`);
+ }
+
+ const completeBody = buildCompleteMultipartBody(parts);
+ let completeResponse;
+ try {
+ completeResponse = await signedOssRequest({
+ ...requestOptions,
+ method: 'POST',
+ queries: {uploadId},
+ headers: {'content-type': 'application/xml'},
+ contentLength: Buffer.byteLength(completeBody),
+ bodyFactory: () => completeBody,
+ operation: 'CompleteMultipartUpload',
+ });
+ const completeResponseText = await completeResponse.text();
+ if (/)/u.test(completeResponseText)) {
+ throw new Error(`OSS CompleteMultipartUpload 返回错误: ${completeResponseText.slice(0, 500)}`);
+ }
+ } catch (completeError) {
+ try {
+ await verifyUploadedObject({requestOptions, expectedContentLength: fileStat.size});
+ completeResponse = null;
+ } catch {
+ throw completeError;
+ }
+ }
+
+ const verification = await verifyUploadedObject({requestOptions, expectedContentLength: fileStat.size});
+ uploadCompleted = true;
+ return {
+ bucket,
+ objectKey,
+ contentLength: fileStat.size,
+ etag: completeResponse?.headers.get('etag')?.replace(/^"|"$/gu, '') ?? '',
+ uploadMode: 'multipart',
+ partCount,
+ partSizeBytes: partSize,
+ verifiedAt: verification.verifiedAt,
+ };
+ } catch (error) {
+ if (uploadId && !uploadCompleted) {
+ await abortMultipartUpload({requestOptions, uploadId});
+ }
+ throw error;
+ }
}
async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix}) {
@@ -603,6 +882,10 @@ async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId,
objectKey: result.objectKey,
contentLength: result.contentLength,
etag: result.etag,
+ uploadMode: result.uploadMode,
+ partCount: result.partCount,
+ partSizeBytes: result.partSizeBytes,
+ verifiedAt: result.verifiedAt,
uploadedAt,
uploadStatus: 'uploaded',
},
@@ -736,6 +1019,10 @@ async function main() {
archivePath,
contentLength: result.contentLength,
etag: result.etag,
+ uploadMode: result.uploadMode,
+ partCount: result.partCount,
+ partSizeBytes: result.partSizeBytes,
+ verifiedAt: result.verifiedAt,
uploadedAt: new Date().toISOString(),
uploadStatus: 'uploaded',
},
@@ -791,9 +1078,11 @@ function describeError(error) {
return lines;
}
-main().catch((error) => {
- for (const line of describeError(error)) {
- console.error(`[database-backup] ${line}`);
- }
- process.exit(1);
-});
+if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(__filename)) {
+ main().catch((error) => {
+ for (const line of describeError(error)) {
+ console.error(`[database-backup] ${line}`);
+ }
+ process.exit(1);
+ });
+}