backupdrill 2.1.5 → 2.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,37 @@ export declare function targetClient(config: BackupConfig): S3Client;
6
6
  export declare function resolveSnapshot(s3: S3Client, config: BackupConfig, explicit?: string): Promise<string>;
7
7
  export declare function getObjectText(s3: S3Client, bucket: string, key: string): Promise<string>;
8
8
  /** 下载对象到本地文件,顺带算 sha256(验证读回的归档没被损坏)。 */
9
- export declare function downloadToFile(s3: S3Client, bucket: string, key: string, dest: string): Promise<{
9
+ export declare const DOWNLOAD_MAX_ATTEMPTS = 8;
10
+ /** 最小的文件句柄接口:downloadToFile 只用这两样。测试可以注入假句柄(短写、慢写)。 */
11
+ export interface WritableHandle {
12
+ write(buffer: Buffer, offset: number, length: number, position: number): Promise<{
13
+ bytesWritten: number;
14
+ }>;
15
+ close(): Promise<void>;
16
+ }
17
+ /**
18
+ * 把一整块写进 position 起的位置,**写满为止**。Node 的 `FileHandle.write` 合约允许一次只写
19
+ * 一部分(返回 bytesWritten),按整块计数会让校验和给一份少了字节的文件盖章(交叉审查
20
+ * 2026-09-12 故障注入:写进 3 字节、报了 8 字节、校验和照样"通过")。
21
+ * 返回真正落盘的字节数;中途抛错时把已确认的字节数挂在错误上,调用方据此对齐哈希与起点。
22
+ */
23
+ export declare function writeFully(fh: WritableHandle, chunk: Buffer, position: number): Promise<number>;
24
+ /**
25
+ * 断点续传下载(2026-09-12):一个 12 GB 的 dump 走一条 HTTPS 流要 50 分钟,对端(用户的桶)
26
+ * 在第 10 分钟关掉连接,整次演练就没了 —— 而且此前没有任何续传:一次事故、两封 FAILED
27
+ * 邮件。这里按 HTTP Range 续传,三条铁律:
28
+ * 1. 续传起点 = **已确认写进 fd 的字节数**,不是流过管道的字节数。pipeline 出错时 Writable
29
+ * 缓冲区里的块会丢,按"流过多少"续传会在文件里留下一个洞;所以自写 Writable,
30
+ * `fh.write` 返回之后才计数、才哈希,哈希与磁盘内容永远一致;
31
+ * 2. 续传请求必须拿到 206:对端不认 Range 就会把整个对象再发一遍,追加上去就是脏文件,
32
+ * 这种情况直接失败,绝不"看起来成功";
33
+ * 3. ETag 变了 = 对象在下载中途被替换,两半拼不成一份,直接失败。
34
+ * 重试 8 次,退避 1s→30s;每次续传写一行日志(worker 的 journal 能看到断在哪、续在哪)。
35
+ */
36
+ export declare function downloadToFile(s3: S3Client, bucket: string, key: string, dest: string, opts?: {
37
+ maxAttempts?: number;
38
+ openFile?: (path: string) => Promise<WritableHandle>;
39
+ }): Promise<{
10
40
  bytes: number;
11
41
  sha256: string;
12
42
  }>;
package/dist/snapshots.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { createWriteStream } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
3
  import { pipeline } from "node:stream/promises";
4
- import { Transform } from "node:stream";
4
+ import { Writable } from "node:stream";
5
+ import { log } from "./log.js";
5
6
  import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
6
7
  /** 读备份桶(用户自己的桶)的 S3 客户端。drill/restore 共用。 */
7
8
  export function targetClient(config) {
@@ -46,19 +47,150 @@ export async function getObjectText(s3, bucket, key) {
46
47
  return res.Body.transformToString();
47
48
  }
48
49
  /** 下载对象到本地文件,顺带算 sha256(验证读回的归档没被损坏)。 */
49
- export async function downloadToFile(s3, bucket, key, dest) {
50
- const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
50
+ export const DOWNLOAD_MAX_ATTEMPTS = 8;
51
+ const DOWNLOAD_BACKOFF_MS = 1000;
52
+ const DOWNLOAD_BACKOFF_CAP_MS = 30_000;
53
+ /**
54
+ * 下载流半路断掉时值不值得从断点续传。SDK 自己的重试只管"请求建立"那一下;
55
+ * `send()` 一旦返回、body 流开始,后面任何断连都直接落到 pipeline 的 reject 上。
56
+ * 对端主动关连接(Node 报 "aborted")、连接重置、超时、5xx/429 都是重试对象;
57
+ * 4xx(404 NoSuchKey、416 越界、403)是事实,重试只会得到同样的答案。
58
+ */
59
+ function isRetryableDownloadError(error) {
60
+ const e = error;
61
+ const status = e.$metadata?.httpStatusCode;
62
+ if (status !== undefined)
63
+ return status >= 500 || status === 429;
64
+ if (["ECONNRESET", "ETIMEDOUT", "EPIPE", "ECONNREFUSED", "EAI_AGAIN"].includes(e.code ?? "")) {
65
+ return true;
66
+ }
67
+ return /aborted|socket hang up|premature close|short body/i.test(e.message ?? "");
68
+ }
69
+ class NonRetryableDownloadError extends Error {
70
+ }
71
+ /**
72
+ * 把一整块写进 position 起的位置,**写满为止**。Node 的 `FileHandle.write` 合约允许一次只写
73
+ * 一部分(返回 bytesWritten),按整块计数会让校验和给一份少了字节的文件盖章(交叉审查
74
+ * 2026-09-12 故障注入:写进 3 字节、报了 8 字节、校验和照样"通过")。
75
+ * 返回真正落盘的字节数;中途抛错时把已确认的字节数挂在错误上,调用方据此对齐哈希与起点。
76
+ */
77
+ export async function writeFully(fh, chunk, position) {
78
+ let done = 0;
79
+ while (done < chunk.length) {
80
+ let bytesWritten;
81
+ try {
82
+ ({ bytesWritten } = await fh.write(chunk, done, chunk.length - done, position + done));
83
+ }
84
+ catch (error) {
85
+ throw Object.assign(error, { confirmedBytes: done });
86
+ }
87
+ if (bytesWritten <= 0) {
88
+ throw Object.assign(new Error(`write returned ${bytesWritten} bytes at position ${position + done}`), {
89
+ confirmedBytes: done,
90
+ });
91
+ }
92
+ done += bytesWritten;
93
+ }
94
+ return done;
95
+ }
96
+ /**
97
+ * 断点续传下载(2026-09-12):一个 12 GB 的 dump 走一条 HTTPS 流要 50 分钟,对端(用户的桶)
98
+ * 在第 10 分钟关掉连接,整次演练就没了 —— 而且此前没有任何续传:一次事故、两封 FAILED
99
+ * 邮件。这里按 HTTP Range 续传,三条铁律:
100
+ * 1. 续传起点 = **已确认写进 fd 的字节数**,不是流过管道的字节数。pipeline 出错时 Writable
101
+ * 缓冲区里的块会丢,按"流过多少"续传会在文件里留下一个洞;所以自写 Writable,
102
+ * `fh.write` 返回之后才计数、才哈希,哈希与磁盘内容永远一致;
103
+ * 2. 续传请求必须拿到 206:对端不认 Range 就会把整个对象再发一遍,追加上去就是脏文件,
104
+ * 这种情况直接失败,绝不"看起来成功";
105
+ * 3. ETag 变了 = 对象在下载中途被替换,两半拼不成一份,直接失败。
106
+ * 重试 8 次,退避 1s→30s;每次续传写一行日志(worker 的 journal 能看到断在哪、续在哪)。
107
+ */
108
+ export async function downloadToFile(s3, bucket, key, dest, opts = {}) {
109
+ const maxAttempts = opts.maxAttempts ?? DOWNLOAD_MAX_ATTEMPTS;
51
110
  const hash = createHash("sha256");
52
- let bytes = 0;
53
- const hasher = new Transform({
54
- transform(chunk, _enc, cb) {
55
- hash.update(chunk);
56
- bytes += chunk.length;
57
- cb(null, chunk);
58
- },
59
- });
60
- await pipeline(res.Body, hasher, createWriteStream(dest));
61
- return { bytes, sha256: hash.digest("hex") };
111
+ const openFile = opts.openFile ?? ((path) => open(path, "w"));
112
+ // 目标文件推迟到首个响应到手后再打开(交叉审查 2026-09-12):"w" 会先截断,404/403 这种
113
+ // 请求都没成的失败不该顺手毁掉调用方路径上已有的文件。
114
+ let fh = null;
115
+ let written = 0; // 已落盘 = 已哈希 = 续传起点
116
+ let total = null;
117
+ let etag;
118
+ // 上一块的写还在途时 pipeline 就可能 reject(源先断)。重试前必须等它落定,否则 Range 用的
119
+ // 是过期的 written,写完成后 written 又往前走,续传起点与磁盘错位(交叉审查 2026-09-12)。
120
+ let inFlight = Promise.resolve();
121
+ try {
122
+ for (let attempt = 1;; attempt++) {
123
+ await inFlight.catch(() => { });
124
+ const from = written; // 本次请求的起点在发请求前冻结,后面的判断都用它
125
+ try {
126
+ const res = await s3.send(new GetObjectCommand({
127
+ Bucket: bucket,
128
+ Key: key,
129
+ ...(from > 0 ? { Range: `bytes=${from}-` } : {}),
130
+ }));
131
+ const body = res.Body;
132
+ const status = res.$metadata.httpStatusCode;
133
+ if (from > 0 && status !== 206) {
134
+ body.destroy();
135
+ throw new NonRetryableDownloadError(`resume rejected: expected 206 Partial Content from byte ${from}, got ${status} — refusing to append`);
136
+ }
137
+ if (etag === undefined) {
138
+ etag = res.ETag;
139
+ }
140
+ else if (res.ETag !== undefined && res.ETag !== etag) {
141
+ body.destroy();
142
+ throw new NonRetryableDownloadError(`object changed during download (ETag ${etag} → ${res.ETag}) — halves would not match`);
143
+ }
144
+ if (total === null && typeof res.ContentLength === "number")
145
+ total = res.ContentLength;
146
+ if (fh === null)
147
+ fh = await openFile(dest);
148
+ const handle = fh;
149
+ const sink = new Writable({
150
+ write(chunk, _enc, cb) {
151
+ inFlight = writeFully(handle, chunk, written).then((n) => {
152
+ hash.update(chunk.subarray(0, n));
153
+ written += n;
154
+ cb();
155
+ }, (err) => {
156
+ // 半块落盘也要如实计入:哈希与起点永远等于磁盘上真有的字节
157
+ const n = err.confirmedBytes ?? 0;
158
+ if (n > 0) {
159
+ hash.update(chunk.subarray(0, n));
160
+ written += n;
161
+ }
162
+ cb(err);
163
+ });
164
+ },
165
+ });
166
+ await pipeline(body, sink);
167
+ await inFlight.catch(() => { });
168
+ if (total !== null && written !== total) {
169
+ // 流"正常"结束但字节不够:对端掐断时 Node 有时不报错只是提前 end
170
+ throw new Error(`short body: got ${written} of ${total} bytes`);
171
+ }
172
+ return { bytes: written, sha256: hash.digest("hex") };
173
+ }
174
+ catch (error) {
175
+ if (error instanceof NonRetryableDownloadError ||
176
+ !isRetryableDownloadError(error) ||
177
+ attempt >= maxAttempts) {
178
+ throw error;
179
+ }
180
+ const wait = Math.min(DOWNLOAD_BACKOFF_MS * 2 ** (attempt - 1), DOWNLOAD_BACKOFF_CAP_MS);
181
+ const reason = (error.message ?? String(error)).split("\n")[0];
182
+ log.step(`Download interrupted at ${(written / 1048576).toFixed(0)} MB (${reason}); ` +
183
+ `resuming from byte ${written} in ${wait / 1000}s (attempt ${attempt + 1}/${maxAttempts})…`);
184
+ await new Promise((r) => setTimeout(r, wait));
185
+ }
186
+ }
187
+ }
188
+ finally {
189
+ // 终态失败时上一块的写可能还在途:先让它落定再关句柄,别让慢写撞上已关闭的 fd
190
+ await inFlight.catch(() => { });
191
+ if (fh !== null)
192
+ await fh.close();
193
+ }
62
194
  }
63
195
  /** 只算对象校验和、不落盘(Storage 文件完整性校验用,省内存/磁盘)。 */
64
196
  export async function hashObject(s3, bucket, key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.1.5",
3
+ "version": "2.1.6",
4
4
  "description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
5
5
  "type": "module",
6
6
  "bin": {