backupdrill 2.2.5 → 2.3.0

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.
package/README.md CHANGED
@@ -161,6 +161,8 @@ backupdrill drill --verify-all-files # also checksum every Storage file, not jus
161
161
 
162
162
  Backups record your installed Postgres extensions in the manifest, and the drill pre-installs them in the sandbox before restoring — including switching to a pgvector-enabled sandbox image when your database uses `vector` columns.
163
163
 
164
+ The sandbox restore runs up to 4 parallel `pg_restore` jobs, sized from the CPUs and memory Docker reports, so a 2 vCPU / 8 GB machine gets 2. Table data and index builds run in parallel. Foreign keys, triggers and policies run on a single connection to keep their error messages separate, including errors about Supabase-managed objects such as `auth.users`. `backupdrill restore` into a real Supabase project stays on a single connection.
165
+
164
166
  ### Run your own checks against the restored copy
165
167
 
166
168
  The drill's structural checks (tables, rows, checksums) can't know what "working" means for *your* app — business invariants only have answers in your code. `--check-cmd` hands you the restored sandbox before it's destroyed:
package/dist/drill.d.ts CHANGED
@@ -44,8 +44,10 @@ export interface SandboxTuning {
44
44
  settings: string[];
45
45
  /** docker --shm-size 的值;并行建索引走动态共享内存,要跟 maintenance_work_mem 一起长 */
46
46
  shmSize: string;
47
+ /** pg_restore --jobs;1 = 单连接 */
48
+ restoreJobs: number;
47
49
  }
48
- export declare function sandboxTuning(totalMemBytes: number): SandboxTuning;
50
+ export declare function sandboxTuning(totalMemBytes: number, cpus?: number): SandboxTuning;
49
51
  export declare function sandboxDataVolumeTarget(major: number): string;
50
52
  /** post-data 第二遍恢复的结果:用户对象失败 vs Supabase 托管对象的沙箱预期跳过。 */
51
53
  interface PostDataResult {
package/dist/drill.js CHANGED
@@ -60,23 +60,31 @@ const MIB = 1024 * 1024;
60
60
  function clampMib(bytes, minMib, maxMib) {
61
61
  return Math.min(maxMib, Math.max(minMib, Math.floor(bytes / MIB)));
62
62
  }
63
- /** Docker 引擎可用内存(字节);问不到就返回 0,让 sandboxTuning 落到下限值,宁慢勿炸。 */
64
- async function dockerMemoryBudget() {
63
+ /** Docker 引擎可用内存(字节)和 CPU 数;问不到就返回 0,让 sandboxTuning 落到下限值、单连接,宁慢勿炸。 */
64
+ async function dockerResources() {
65
65
  try {
66
66
  // 探测是可选的,绝不能卡住演练:守护进程假死时 5 秒后放弃,落到下限值继续跑
67
- const { stdout } = await execFileAsync("docker", ["info", "--format", "{{.MemTotal}}"], {
67
+ const { stdout } = await execFileAsync("docker", ["info", "--format", "{{.MemTotal}} {{.NCPU}}"], {
68
68
  timeout: 5_000,
69
69
  });
70
- const bytes = Number.parseInt(stdout.trim(), 10);
71
- return Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
70
+ const [memoryBytes, cpus] = stdout.trim().split(/\s+/).map((value) => Number.parseInt(value, 10));
71
+ const positive = (value) => (value !== undefined && Number.isFinite(value) && value > 0 ? value : 0);
72
+ return { memoryBytes: positive(memoryBytes), cpus: positive(cpus) };
72
73
  }
73
74
  catch {
74
- return 0;
75
+ return { memoryBytes: 0, cpus: 0 };
75
76
  }
76
77
  }
77
- export function sandboxTuning(totalMemBytes) {
78
+ export function sandboxTuning(totalMemBytes, cpus = 1) {
79
+ const budgetMib = Math.floor(totalMemBytes / MIB);
78
80
  const sharedBuffersMib = clampMib(totalMemBytes / 4, 256, 2048);
79
81
  const maintenanceWorkMemMib = clampMib(totalMemBytes / 8, 256, 1024);
82
+ // 并行恢复时每一路都可能同时在建索引,各自吃满 maintenance_work_mem:路数 × maintenance_work_mem
83
+ // 加上 shared_buffers 不超过预算的一半,另一半留给 pg_restore、Docker 和同机的其他任务
84
+ // (8 GB 的 worker 正好 2 路,2026-10-17 在它上面实测 -j2 没出内存问题)。
85
+ // 再封顶 4 路:pg_restore 的并行度受表和索引个数限制,再多收益很小,演练还常跑在用户自己的电脑上。
86
+ const memoryBoundJobs = Math.floor((budgetMib / 2 - sharedBuffersMib) / maintenanceWorkMemMib);
87
+ const restoreJobs = Math.max(1, Math.min(cpus, memoryBoundJobs, 4));
80
88
  return {
81
89
  settings: [
82
90
  "fsync=off",
@@ -91,8 +99,9 @@ export function sandboxTuning(totalMemBytes) {
91
99
  ],
92
100
  // Docker 默认 /dev/shm 只有 64 MB;并行建索引(pgvector 的 HNSW 尤其)走动态共享内存,
93
101
  // 64 MB 会撞 "No space left on device" 把好备份误判失败(交叉审查 2026-09-12 复现)。
94
- // 给 maintenance_work_mem 的两倍、至少 1 GB;tmpfs 只按实际用量占内存,给大不吃亏。
95
- shmSize: `${Math.max(1024, maintenanceWorkMemMib * 2)}m`,
102
+ // 每一路并行恢复给 maintenance_work_mem 的两倍、至少 1 GB;tmpfs 只按实际用量占内存,给大不吃亏。
103
+ shmSize: `${Math.max(1024, maintenanceWorkMemMib * 2 * restoreJobs)}m`,
104
+ restoreJobs,
96
105
  };
97
106
  }
98
107
  function validateSupervision(supervision, appCheckCommand, keepSandboxOnFailure) {
@@ -132,7 +141,8 @@ async function startEphemeralPostgres(image, major, resourceName) {
132
141
  }
133
142
  }
134
143
  log.step(`Starting ephemeral ${image}…`);
135
- const tuning = sandboxTuning(await dockerMemoryBudget());
144
+ const resources = await dockerResources();
145
+ const tuning = sandboxTuning(resources.memoryBytes, resources.cpus);
136
146
  const { stdout } = await execFileAsync("docker", [
137
147
  "run",
138
148
  "-d",
@@ -187,7 +197,7 @@ async function startEphemeralPostgres(image, major, resourceName) {
187
197
  "postgres",
188
198
  "-q",
189
199
  ]);
190
- return { containerId, port, connString, volumeName };
200
+ return { containerId, port, connString, volumeName, restoreJobs: tuning.restoreJobs };
191
201
  }
192
202
  catch {
193
203
  await new Promise((r) => setTimeout(r, 500));
@@ -222,8 +232,8 @@ export function postDataResult(code, stderr) {
222
232
  * 的托管对象跳过如实返回。pre-data 的 schema 冲突跳过(dump 自带 CREATE SCHEMA,
223
233
  * 容器恒有 public)是非事件,不计入报告。
224
234
  */
225
- async function pgRestore(dumpPath, connString, dumpedSchemas) {
226
- const result = await restoreDatabaseArtifact({ dumpPath, connString, target: "sandbox", dumpedSchemas });
235
+ async function pgRestore(dumpPath, connString, dumpedSchemas, jobs) {
236
+ const result = await restoreDatabaseArtifact({ dumpPath, connString, target: "sandbox", dumpedSchemas, jobs });
227
237
  if (result.preData.failures.length) {
228
238
  throw new Error(`pg_restore failed: ${result.preData.failures.join(" | ")}`);
229
239
  }
@@ -478,7 +488,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
478
488
  log.step("Restoring into ephemeral Postgres…");
479
489
  let postData;
480
490
  try {
481
- postData = await pgRestore(dumpPath, pg.connString, manifest.database.schemas);
491
+ postData = await pgRestore(dumpPath, pg.connString, manifest.database.schemas, pg.restoreJobs);
482
492
  }
483
493
  catch (error) {
484
494
  // 沙箱装不上扩展 + 错误特征命中"缺类型/schema/扩展" → 假设式归因到沙箱环境
@@ -491,7 +501,11 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
491
501
  throw error;
492
502
  }
493
503
  const restoreSeconds = Math.round((Date.now() - started) / 100) / 10;
494
- checks.push({ name: "pg_restore", pass: true, detail: `completed in ${restoreSeconds}s` });
504
+ checks.push({
505
+ name: "pg_restore",
506
+ pass: true,
507
+ detail: `completed in ${restoreSeconds}s` + (pg.restoreJobs > 1 ? ` (${pg.restoreJobs} parallel jobs)` : ""),
508
+ });
495
509
  // 用户自己的 post-data 对象(索引/约束/触发器)必须全部恢复;Supabase 托管对象
496
510
  // 的沙箱预期跳过如实写进报告(演练不假装它们恢复了)。
497
511
  checks.push({
@@ -91,8 +91,25 @@ export declare function classifyBlocks(stderr: string, allow: RegExp): Classifie
91
91
  * 的半截恢复就会被报成功(评审第 7 轮抓出的假成功路径)。
92
92
  * 2. 非零退出且零错误块(归档器致命错、非英文 locale…)同样不能谎报成功。
93
93
  * 非零退出但每个错误都已分类 = pg_restore 对被忽略错误的正常退出形态,通过。
94
+ * 3. 并行遍(`parallel`)不能只信切块:多个 worker 进程共用一个 stderr,而 PostgreSQL 一条
95
+ * 报错分好几次 write(`pg_restore:`、` `、`error: `、正文,见 src/common/logging.c),
96
+ * 两条同时写会交错,一条真错误的前缀可能被拆散、正文挂到另一条预期跳过的块里——切块
97
+ * 看不到它,演练照样 PASS。所以并行遍只在两种情况下通过:退出码 0(一个错误都没有),
98
+ * 或 pg_restore 最后打印的 "errors ignored on restore: N" 恰好等于预期跳过数。
99
+ * worker 里出的错 PostgreSQL 会多算一次(17 实测 4 个错报 8,mark_restore_job_done
100
+ * 又 +1),所以只要 worker 出过错 N 就一定大于预期跳过数——这条规则只放行"所有错误都出在
101
+ * fork 之前的单进程阶段"的那种并行遍,调用方要据此安排哪些对象放进并行遍。
102
+ * 没有这行汇总的非零退出 = 中途致命退出,同样失败。
94
103
  */
95
- export declare function finalizePass(code: number | null, stderr: string, classified: ClassifiedPass): ClassifiedPass;
104
+ export declare function finalizePass(code: number | null, stderr: string, classified: ClassifiedPass, opts?: {
105
+ parallel?: boolean;
106
+ }): ClassifiedPass;
107
+ /**
108
+ * pg_restore 正常跑完时打印的错误总数;没跑完(致命退出)或认不出时返回 null。
109
+ * 只认 stderr 的最后一个非空行、且整行就是这句汇总:这行在所有 worker 退出之后由主进程打印,
110
+ * 不会和别的输出交错;前面 "Command was:" 里的用户 SQL 就算含同样字样也冒充不了它。
111
+ */
112
+ export declare function ignoredErrorTotal(stderr: string): number | null;
96
113
  /** 标识符转义(内嵌引号翻倍)。导出:任何把目录里读到的名字拼进 SQL 的地方都必须用它。 */
97
114
  export declare function quoteIdent(name: string): string;
98
115
  /**
@@ -159,10 +176,46 @@ export declare function credentialSafeDbArgs(connString: string): {
159
176
  * 目标)的 options 没有租户语义,照常放行。
160
177
  */
161
178
  export declare function assertNoHostOverride(connString: string): void;
179
+ /**
180
+ * 把 `pg_restore --list --section=post-data` 的目录拆成两份 -L 清单:
181
+ * - `indexBuilds`:INDEX 和 CONSTRAINT(主键/唯一/排除约束,本质也是建索引)。恢复里最慢的就是
182
+ * 它们(2026-10-17 实测 post-data 占 pg_restore 时间的 67–77%,大头是建索引)。并行跑;
183
+ * 出过错的条目再单连接重跑一遍定结论(见 restoreDatabaseArtifact)。
184
+ * - `rest`:FK CONSTRAINT、TRIGGER、POLICY、INDEX ATTACH、COMMENT 等。指向 auth.users 的外键
185
+ * 这类预期跳过全在这里,并行时 worker 报错会交错、计数也不准(见 finalizePass 规则 3),
186
+ * 所以这份始终单连接跑,保留逐块分类。外键依赖主键/唯一约束,也必须排在 indexBuilds 之后。
187
+ * 注释行(`;` 开头)和空行丢掉:-L 只按行首的 dumpId 认条目。
188
+ */
189
+ export declare function splitPostDataList(listing: string): {
190
+ indexBuilds: string[];
191
+ rest: string[];
192
+ };
193
+ /**
194
+ * 并行建索引那遍(带 --verbose)里出过错的条目 dumpId。pg_restore 对每个出错条目先打一行
195
+ * `from TOC entry <dumpId>; …`(warn_or_exit_horribly,只在 verbose 下输出)。这行正文是一次
196
+ * write 写完的:并行时它的 `pg_restore: ` 前缀可能被别的 worker 拆开,正文本身不会,
197
+ * 所以按子串找、不按行首找。
198
+ */
199
+ export declare function failedTocEntries(stderr: string): Set<string>;
200
+ /**
201
+ * 两遍恢复(形状见 credentialSafeDbArgs 上方那段"两遍恢复"注释),`jobs` > 1 时按下面的方式并行(2026-10-17 实测:2 核机器上同一份
202
+ * dump,原生 -j2 比 -j1 在 5 GB 快 14%、10 GB 快 12%):
203
+ * 1. pre-data+data 一遍加 --jobs。pg_restore 并行时先在主进程里单独跑完 pre-data,fork 之后
204
+ * 才并行灌数据,所以唯一的预期冲突(schema 已存在)一定出在单进程阶段,finalizePass
205
+ * 规则 3 的计数核对成立。同一遍里建表再灌数据,pg_restore 才会用 TRUNCATE+COPY,
206
+ * wal_level=minimal 的沙箱因此不写这部分 WAL——拆成两遍就没了这个加速。
207
+ * 2. post-data 拆成两份清单(splitPostDataList):建索引那份并行,退出码 0 即通过,报过错
208
+ * 的条目单连接重跑定结论;其余单连接、照旧按目标类型分类。
209
+ * 两遍下来,同一份备份不管几路并行,结论都和 jobs=1 一样。
210
+ * 真实 Supabase 目标(restore.ts)不传 jobs:每一路都是一条 pooler 连接,小规格项目的连接数
211
+ * 和算力都紧,并行没测过,保持原来的单连接两遍。
212
+ */
162
213
  export declare function restoreDatabaseArtifact(opts: {
163
214
  dumpPath: string;
164
215
  connString: string;
165
216
  target: RestoreTargetKind;
166
217
  /** manifest.database.schemas:沙箱 allowlist 据此剔掉转储自带的托管 schema(见 sandboxManagedError)。 */
167
218
  dumpedSchemas?: string[];
219
+ /** pg_restore 并行路数;不传或 ≤1 = 单连接两遍。 */
220
+ jobs?: number;
168
221
  }): Promise<EngineResult>;
@@ -9,6 +9,9 @@
9
9
  // 3. dump 自带 CREATE SCHEMA,而目标(新容器或 Supabase)恒有同名 schema →
10
10
  // pre-data 阶段的 schema "already exists" 是两类目标共同的预期冲突。
11
11
  import { spawn } from "node:child_process";
12
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
12
15
  import { resolvePgRestoreBin } from "./pgbin.js";
13
16
  import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
14
17
  /** 主机名规范化:先按驱动语义百分号解码(pg-connection-string / libpq 都会解码主机名,
@@ -268,8 +271,17 @@ export function classifyBlocks(stderr, allow) {
268
271
  * 的半截恢复就会被报成功(评审第 7 轮抓出的假成功路径)。
269
272
  * 2. 非零退出且零错误块(归档器致命错、非英文 locale…)同样不能谎报成功。
270
273
  * 非零退出但每个错误都已分类 = pg_restore 对被忽略错误的正常退出形态,通过。
274
+ * 3. 并行遍(`parallel`)不能只信切块:多个 worker 进程共用一个 stderr,而 PostgreSQL 一条
275
+ * 报错分好几次 write(`pg_restore:`、` `、`error: `、正文,见 src/common/logging.c),
276
+ * 两条同时写会交错,一条真错误的前缀可能被拆散、正文挂到另一条预期跳过的块里——切块
277
+ * 看不到它,演练照样 PASS。所以并行遍只在两种情况下通过:退出码 0(一个错误都没有),
278
+ * 或 pg_restore 最后打印的 "errors ignored on restore: N" 恰好等于预期跳过数。
279
+ * worker 里出的错 PostgreSQL 会多算一次(17 实测 4 个错报 8,mark_restore_job_done
280
+ * 又 +1),所以只要 worker 出过错 N 就一定大于预期跳过数——这条规则只放行"所有错误都出在
281
+ * fork 之前的单进程阶段"的那种并行遍,调用方要据此安排哪些对象放进并行遍。
282
+ * 没有这行汇总的非零退出 = 中途致命退出,同样失败。
271
283
  */
272
- export function finalizePass(code, stderr, classified) {
284
+ export function finalizePass(code, stderr, classified, opts = {}) {
273
285
  const detail = stderr.trim().replace(/\s+/g, " ").slice(0, 200) || "(no stderr)";
274
286
  if (code === null) {
275
287
  return {
@@ -280,8 +292,30 @@ export function finalizePass(code, stderr, classified) {
280
292
  if (code !== 0 && classified.expectedSkips === 0 && classified.failures.length === 0) {
281
293
  return { ...classified, failures: [`pg_restore exited with code ${code}: ${detail}`] };
282
294
  }
295
+ if (opts.parallel && code !== 0 && classified.failures.length === 0) {
296
+ const reported = ignoredErrorTotal(stderr);
297
+ if (reported === null || reported !== classified.expectedSkips) {
298
+ return {
299
+ ...classified,
300
+ failures: [
301
+ `parallel pg_restore reported ${reported ?? "an unknown number of"} error(s), ` +
302
+ `but only ${classified.expectedSkips} could be attributed to expected skips: ${detail}`,
303
+ ],
304
+ };
305
+ }
306
+ }
283
307
  return classified;
284
308
  }
309
+ /**
310
+ * pg_restore 正常跑完时打印的错误总数;没跑完(致命退出)或认不出时返回 null。
311
+ * 只认 stderr 的最后一个非空行、且整行就是这句汇总:这行在所有 worker 退出之后由主进程打印,
312
+ * 不会和别的输出交错;前面 "Command was:" 里的用户 SQL 就算含同样字样也冒充不了它。
313
+ */
314
+ export function ignoredErrorTotal(stderr) {
315
+ const lastLine = stderr.trimEnd().split("\n").pop() ?? "";
316
+ const match = /^pg_restore: warning: errors ignored on restore: (\d+)$/.exec(lastLine);
317
+ return match ? Number(match[1]) : null;
318
+ }
285
319
  /** 标识符转义(内嵌引号翻倍)。导出:任何把目录里读到的名字拼进 SQL 的地方都必须用它。 */
286
320
  export function quoteIdent(name) {
287
321
  return `"${name.replace(/"/g, '""')}"`;
@@ -526,6 +560,76 @@ export function assertNoHostOverride(connString) {
526
560
  }
527
561
  }
528
562
  }
563
+ /**
564
+ * 把 `pg_restore --list --section=post-data` 的目录拆成两份 -L 清单:
565
+ * - `indexBuilds`:INDEX 和 CONSTRAINT(主键/唯一/排除约束,本质也是建索引)。恢复里最慢的就是
566
+ * 它们(2026-10-17 实测 post-data 占 pg_restore 时间的 67–77%,大头是建索引)。并行跑;
567
+ * 出过错的条目再单连接重跑一遍定结论(见 restoreDatabaseArtifact)。
568
+ * - `rest`:FK CONSTRAINT、TRIGGER、POLICY、INDEX ATTACH、COMMENT 等。指向 auth.users 的外键
569
+ * 这类预期跳过全在这里,并行时 worker 报错会交错、计数也不准(见 finalizePass 规则 3),
570
+ * 所以这份始终单连接跑,保留逐块分类。外键依赖主键/唯一约束,也必须排在 indexBuilds 之后。
571
+ * 注释行(`;` 开头)和空行丢掉:-L 只按行首的 dumpId 认条目。
572
+ */
573
+ export function splitPostDataList(listing) {
574
+ const indexBuilds = [];
575
+ const rest = [];
576
+ for (const line of listing.split("\n")) {
577
+ if (!/^\d+;/.test(line))
578
+ continue;
579
+ // 格式:`<dumpId>; <tableoid> <oid> <类型> <schema> <名字> <属主>`,类型可含空格
580
+ // (FK CONSTRAINT、INDEX ATTACH),所以按类型开头匹配并单独排除 INDEX ATTACH
581
+ if (/^\d+; \d+ \d+ (INDEX|CONSTRAINT) /.test(line) && !/^\d+; \d+ \d+ INDEX ATTACH /.test(line)) {
582
+ indexBuilds.push(line);
583
+ }
584
+ else {
585
+ rest.push(line);
586
+ }
587
+ }
588
+ return { indexBuilds, rest };
589
+ }
590
+ function listPostData(bin, dumpPath) {
591
+ return new Promise((resolve, reject) => {
592
+ // 只读归档、不连库,但同样钉 C locale,和恢复子进程一致
593
+ const proc = spawn(bin, ["--list", "--section=post-data", dumpPath], {
594
+ stdio: ["ignore", "pipe", "pipe"],
595
+ env: libpqChildEnv(),
596
+ });
597
+ let stdout = "";
598
+ let stderr = "";
599
+ proc.stdout.on("data", (d) => (stdout += d.toString()));
600
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
601
+ proc.on("error", (error) => reject(new Error(`${bin} failed to start: ${error.code ?? error.message}`)));
602
+ proc.on("close", (code) => resolve({ code, stdout, stderr }));
603
+ });
604
+ }
605
+ /**
606
+ * 并行建索引那遍(带 --verbose)里出过错的条目 dumpId。pg_restore 对每个出错条目先打一行
607
+ * `from TOC entry <dumpId>; …`(warn_or_exit_horribly,只在 verbose 下输出)。这行正文是一次
608
+ * write 写完的:并行时它的 `pg_restore: ` 前缀可能被别的 worker 拆开,正文本身不会,
609
+ * 所以按子串找、不按行首找。
610
+ */
611
+ export function failedTocEntries(stderr) {
612
+ return new Set([...stderr.matchAll(/from TOC entry (\d+);/g)].map((match) => match[1]));
613
+ }
614
+ function mergePasses(passes) {
615
+ return {
616
+ expectedSkips: passes.reduce((sum, pass) => sum + pass.expectedSkips, 0),
617
+ failures: passes.flatMap((pass) => pass.failures),
618
+ };
619
+ }
620
+ /**
621
+ * 两遍恢复(形状见 credentialSafeDbArgs 上方那段"两遍恢复"注释),`jobs` > 1 时按下面的方式并行(2026-10-17 实测:2 核机器上同一份
622
+ * dump,原生 -j2 比 -j1 在 5 GB 快 14%、10 GB 快 12%):
623
+ * 1. pre-data+data 一遍加 --jobs。pg_restore 并行时先在主进程里单独跑完 pre-data,fork 之后
624
+ * 才并行灌数据,所以唯一的预期冲突(schema 已存在)一定出在单进程阶段,finalizePass
625
+ * 规则 3 的计数核对成立。同一遍里建表再灌数据,pg_restore 才会用 TRUNCATE+COPY,
626
+ * wal_level=minimal 的沙箱因此不写这部分 WAL——拆成两遍就没了这个加速。
627
+ * 2. post-data 拆成两份清单(splitPostDataList):建索引那份并行,退出码 0 即通过,报过错
628
+ * 的条目单连接重跑定结论;其余单连接、照旧按目标类型分类。
629
+ * 两遍下来,同一份备份不管几路并行,结论都和 jobs=1 一样。
630
+ * 真实 Supabase 目标(restore.ts)不传 jobs:每一路都是一条 pooler 连接,小规格项目的连接数
631
+ * 和算力都紧,并行没测过,保持原来的单连接两遍。
632
+ */
529
633
  export async function restoreDatabaseArtifact(opts) {
530
634
  const bin = resolvePgRestoreBin();
531
635
  // TLS 全链路 verify-full(创始人承诺项):pg_restore 与 pg_dump 同一改写——
@@ -534,12 +638,71 @@ export async function restoreDatabaseArtifact(opts) {
534
638
  const { url, env } = credentialSafeDbArgs(dumpUrlFor(opts.connString));
535
639
  const common = ["--no-owner", "--no-privileges", "--dbname", url, opts.dumpPath];
536
640
  const childOpts = { supabaseHost: isSupabaseHost(opts.connString), urlSslMode: urlSslModeOf(url) };
537
- const first = await spawnPgRestore(bin, ["--section=pre-data", "--section=data", ...common], env, childOpts);
538
- const preData = finalizePass(first.code, first.stderr, classifyBlocks(first.stderr, SCHEMA_EXISTS_ERROR));
641
+ const jobs = Math.max(1, Math.floor(opts.jobs ?? 1));
642
+ const parallel = jobs > 1;
643
+ const jobsArg = parallel ? [`--jobs=${jobs}`] : [];
644
+ const first = await spawnPgRestore(bin, ["--section=pre-data", "--section=data", ...jobsArg, ...common], env, childOpts);
645
+ const preData = finalizePass(first.code, first.stderr, classifyBlocks(first.stderr, SCHEMA_EXISTS_ERROR), { parallel });
539
646
  if (preData.failures.length) {
540
647
  return { preData, postData: { expectedSkips: 0, failures: [] }, ok: false };
541
648
  }
542
- const second = await spawnPgRestore(bin, ["--section=post-data", ...common], env, childOpts);
543
- const postData = finalizePass(second.code, second.stderr, classifyBlocks(second.stderr, opts.target === "sandbox" ? sandboxManagedError(opts.dumpedSchemas ?? []) : NEVER_MATCH));
544
- return { preData, postData, ok: postData.failures.length === 0 };
649
+ const postDataAllow = opts.target === "sandbox" ? sandboxManagedError(opts.dumpedSchemas ?? []) : NEVER_MATCH;
650
+ if (!parallel) {
651
+ const second = await spawnPgRestore(bin, ["--section=post-data", ...common], env, childOpts);
652
+ const postData = finalizePass(second.code, second.stderr, classifyBlocks(second.stderr, postDataAllow));
653
+ return { preData, postData, ok: postData.failures.length === 0 };
654
+ }
655
+ const listing = await listPostData(bin, opts.dumpPath);
656
+ if (listing.code !== 0) {
657
+ const detail = listing.stderr.trim().replace(/\s+/g, " ").slice(0, 200) || "(no stderr)";
658
+ const postData = { expectedSkips: 0, failures: [`pg_restore --list exited with code ${listing.code}: ${detail}`] };
659
+ return { preData, postData, ok: false };
660
+ }
661
+ const { indexBuilds, rest } = splitPostDataList(listing.stdout);
662
+ const listDir = await mkdtemp(join(tmpdir(), "backupdrill-restore-"));
663
+ try {
664
+ const passes = [];
665
+ if (indexBuilds.length) {
666
+ const listFile = join(listDir, "index-builds.list");
667
+ await writeFile(listFile, indexBuilds.join("\n") + "\n");
668
+ const run = await spawnPgRestore(bin, ["--section=post-data", `--use-list=${listFile}`, "--verbose", ...jobsArg, ...common], env, childOpts);
669
+ // 退出码 0 = 一个错都没有。出过错时不读交错的报错正文,只取出错条目的 dumpId,把这些条目
670
+ // 单连接重跑、照单连接的规则分类:一条 CREATE INDEX / ADD CONSTRAINT 失败不留半成品,
671
+ // 重跑得到的就是 jobs=1 时的结论(交叉审查:引用托管 schema 函数的索引在单连接时是预期
672
+ // 跳过,并行时不能因此判失败;也不能靠忽略"已存在"去猜哪些建好了,沙箱里预装的扩展
673
+ // 对象会和用户索引撞名)。资源原因偶发失败的条目,重跑成功即通过,和单连接本来一样。
674
+ if (run.code !== 0) {
675
+ const failedIds = failedTocEntries(run.stderr);
676
+ const failed = indexBuilds.filter((line) => failedIds.has(line.slice(0, line.indexOf(";"))));
677
+ // 被信号杀死、没有汇总行(中途致命退出,后面的条目根本没跑)、或认不出任何出错条目:
678
+ // 都说明这一遍没走完或没法归因,直接失败,不重跑
679
+ // 出错条目的编号即使被用户 SQL 冒充,也只会多重跑一条、让它报"已存在"而判失败,不会漏掉真错误
680
+ const finished = run.code !== null && ignoredErrorTotal(run.stderr) !== null && failed.length > 0;
681
+ if (!finished) {
682
+ const detail = run.stderr.trim().replace(/\s+/g, " ").slice(-200) || "(no stderr)";
683
+ passes.push({
684
+ expectedSkips: 0,
685
+ failures: [`parallel index builds did not finish cleanly (exit ${run.code ?? "signal"}): ${detail}`],
686
+ });
687
+ }
688
+ else {
689
+ const rerunFile = join(listDir, "index-builds-rerun.list");
690
+ await writeFile(rerunFile, failed.join("\n") + "\n");
691
+ const rerun = await spawnPgRestore(bin, ["--section=post-data", `--use-list=${rerunFile}`, ...common], env, childOpts);
692
+ passes.push(finalizePass(rerun.code, rerun.stderr, classifyBlocks(rerun.stderr, postDataAllow)));
693
+ }
694
+ }
695
+ }
696
+ if (rest.length) {
697
+ const listFile = join(listDir, "rest.list");
698
+ await writeFile(listFile, rest.join("\n") + "\n");
699
+ const run = await spawnPgRestore(bin, ["--section=post-data", `--use-list=${listFile}`, ...common], env, childOpts);
700
+ passes.push(finalizePass(run.code, run.stderr, classifyBlocks(run.stderr, postDataAllow)));
701
+ }
702
+ const postData = mergePasses(passes);
703
+ return { preData, postData, ok: postData.failures.length === 0 };
704
+ }
705
+ finally {
706
+ await rm(listDir, { recursive: true, force: true });
707
+ }
545
708
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.2.5",
3
+ "version": "2.3.0",
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": {