backupdrill 2.2.1 → 2.2.2
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/dist/backup.js +15 -2
- package/dist/estimate.d.ts +8 -0
- package/dist/estimate.js +13 -8
- package/dist/part-size.d.ts +4 -0
- package/dist/part-size.js +27 -0
- package/dist/storage.js +3 -0
- package/package.json +1 -1
package/dist/backup.js
CHANGED
|
@@ -12,6 +12,8 @@ import { projectRefOf, refFromStorageEndpoint, assertNoHostOverride } from "./re
|
|
|
12
12
|
import { log } from "./log.js";
|
|
13
13
|
import { TOOL_VERSION } from "./version.js";
|
|
14
14
|
import { parsePgDumpMajor } from "./pgbin.js";
|
|
15
|
+
import { relationBytes } from "./estimate.js";
|
|
16
|
+
import { dumpPartSizeFor } from "./part-size.js";
|
|
15
17
|
// 解析函数搬到了 pgbin(演练与恢复都要读 pg_restore 版本,放这里会成环);保留旧出口
|
|
16
18
|
export { parsePgDumpMajor };
|
|
17
19
|
import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
|
|
@@ -66,6 +68,15 @@ async function inspectDatabase(databaseUrl, schemas) {
|
|
|
66
68
|
join pg_namespace n on n.oid = e.extnamespace
|
|
67
69
|
where e.extname <> 'plpgsql'
|
|
68
70
|
order by e.extname`);
|
|
71
|
+
// 量体积失败不该让备份失败:分片退回保底值,200 GiB 以内的转储照常上传
|
|
72
|
+
let measuredBytes = null;
|
|
73
|
+
try {
|
|
74
|
+
measuredBytes = await relationBytes(client, schemas);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
log.warn(`Could not measure database size (${error.message}); ` +
|
|
78
|
+
`uploading with the baseline part size, which fits a dump of up to 200 GiB.`);
|
|
79
|
+
}
|
|
69
80
|
return {
|
|
70
81
|
serverVersion: version.rows[0].server_version,
|
|
71
82
|
serverVersionNum: Number(versionNum.rows[0].server_version_num),
|
|
@@ -75,6 +86,7 @@ async function inspectDatabase(databaseUrl, schemas) {
|
|
|
75
86
|
estimatedRows: Number(r.estimated_rows),
|
|
76
87
|
})),
|
|
77
88
|
extensions: extensions.rows,
|
|
89
|
+
relationBytes: measuredBytes,
|
|
78
90
|
};
|
|
79
91
|
}
|
|
80
92
|
finally {
|
|
@@ -82,7 +94,7 @@ async function inspectDatabase(databaseUrl, schemas) {
|
|
|
82
94
|
}
|
|
83
95
|
}
|
|
84
96
|
/** 流式跑 pg_dump,边写 S3 边算 sha256 与字节数。返回校验和与大小。 */
|
|
85
|
-
async function dumpToS3(config, s3, key, pgDumpBin) {
|
|
97
|
+
async function dumpToS3(config, s3, key, pgDumpBin, partSize) {
|
|
86
98
|
const hash = createHash("sha256");
|
|
87
99
|
let bytes = 0;
|
|
88
100
|
const meter = new Transform({
|
|
@@ -124,6 +136,7 @@ async function dumpToS3(config, s3, key, pgDumpBin) {
|
|
|
124
136
|
});
|
|
125
137
|
const upload = new Upload({
|
|
126
138
|
client: s3,
|
|
139
|
+
partSize,
|
|
127
140
|
params: {
|
|
128
141
|
Bucket: config.storage.bucket,
|
|
129
142
|
Key: key,
|
|
@@ -188,7 +201,7 @@ export async function runBackup(config) {
|
|
|
188
201
|
const base = `${config.storage.prefix}/${config.projectName}/${timestamp}`;
|
|
189
202
|
const dumpKey = `${base}/dump.pgcustom`;
|
|
190
203
|
log.step(`Dumping → s3://${config.storage.bucket}/${dumpKey}`);
|
|
191
|
-
const { bytes, sha256 } = await dumpToS3(config, s3, dumpKey, pgDumpBin);
|
|
204
|
+
const { bytes, sha256 } = await dumpToS3(config, s3, dumpKey, pgDumpBin, dumpPartSizeFor(db.relationBytes));
|
|
192
205
|
log.ok(`Database dumped (${(bytes / 1024 / 1024).toFixed(1)} MB, sha256 ${sha256.slice(0, 12)}…)`);
|
|
193
206
|
// Storage 文件同步(源配置存在才做;否则只备数据库)
|
|
194
207
|
let storageResult = null;
|
package/dist/estimate.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Client } from "pg";
|
|
1
2
|
import type { BackupConfig } from "./config.js";
|
|
2
3
|
/**
|
|
3
4
|
* Supabase egress 定价(2026-07-10 经核实,来源见 README「Egress & cost」)。
|
|
@@ -11,6 +12,13 @@ export interface EgressPricing {
|
|
|
11
12
|
planLabel: string;
|
|
12
13
|
hardCap: boolean;
|
|
13
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* 要转储的 schema 里所有表的磁盘占用。pg_total_relation_size 含索引+TOAST,是 egress
|
|
17
|
+
* 上界(真实转储经压缩、不含索引,更小)。备份也拿它估转储体积来定分片大小。
|
|
18
|
+
* 直接传 relid 而不是按名字转 regclass:统计视图读出来之后表被并发删掉,
|
|
19
|
+
* 按名字转会报错,按 oid 查只会得到 NULL。
|
|
20
|
+
*/
|
|
21
|
+
export declare function relationBytes(client: Client, schemas: string[]): Promise<number>;
|
|
14
22
|
export interface EgressProjection {
|
|
15
23
|
dbBytes: number;
|
|
16
24
|
storageBytes: number;
|
package/dist/estimate.js
CHANGED
|
@@ -4,17 +4,22 @@ import { connectPg } from "./supabase-ca.js";
|
|
|
4
4
|
// 超出这个月度成本就该提醒用户"备份的 egress 账单快赶上订阅费了"
|
|
5
5
|
const SUBSCRIPTION_FLOOR_USD = 19;
|
|
6
6
|
const GB = 1024 * 1024 * 1024;
|
|
7
|
+
/**
|
|
8
|
+
* 要转储的 schema 里所有表的磁盘占用。pg_total_relation_size 含索引+TOAST,是 egress
|
|
9
|
+
* 上界(真实转储经压缩、不含索引,更小)。备份也拿它估转储体积来定分片大小。
|
|
10
|
+
* 直接传 relid 而不是按名字转 regclass:统计视图读出来之后表被并发删掉,
|
|
11
|
+
* 按名字转会报错,按 oid 查只会得到 NULL。
|
|
12
|
+
*/
|
|
13
|
+
export async function relationBytes(client, schemas) {
|
|
14
|
+
const res = await client.query(`select coalesce(sum(pg_total_relation_size(relid)), 0)::bigint as bytes
|
|
15
|
+
from pg_stat_user_tables
|
|
16
|
+
where schemaname = any($1::text[])`, [schemas]);
|
|
17
|
+
return Number(res.rows[0].bytes);
|
|
18
|
+
}
|
|
7
19
|
async function measureDbBytes(databaseUrl, schemas) {
|
|
8
20
|
const client = await connectPg(databaseUrl);
|
|
9
21
|
try {
|
|
10
|
-
|
|
11
|
-
const res = await client.query(`select coalesce(
|
|
12
|
-
sum(pg_total_relation_size(format('%I.%I', schemaname, relname)::regclass)),
|
|
13
|
-
0
|
|
14
|
-
)::bigint as bytes
|
|
15
|
-
from pg_stat_user_tables
|
|
16
|
-
where schemaname = any($1::text[])`, [schemas]);
|
|
17
|
-
return Number(res.rows[0].bytes);
|
|
22
|
+
return await relationBytes(client, schemas);
|
|
18
23
|
}
|
|
19
24
|
finally {
|
|
20
25
|
await client.end();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// S3 / R2 / B2 的分片上传都限 10,000 片,除最后一片外每片至少 5 MiB。
|
|
2
|
+
// lib-storage 只有在知道总长度时才会自己放大分片;长度未知的流(pg_dump、
|
|
3
|
+
// 没传长度的 Storage 拷贝)一律按 5 MiB 切,单个对象最多约 48.8 GiB,
|
|
4
|
+
// 再大就在上传途中报 "Exceeded 10000 parts" 失败。
|
|
5
|
+
const MAX_PARTS = 10_000;
|
|
6
|
+
const MIB = 1024 * 1024;
|
|
7
|
+
const GIB = 1024 * MIB;
|
|
8
|
+
const MIN_PART_BYTES = 5 * MIB;
|
|
9
|
+
// 转储体积事先不知道,只能拿表的磁盘占用(含索引与 TOAST)去估。pg_dump 的
|
|
10
|
+
// custom 格式默认 gzip 压缩,实测转储只有磁盘占用的零头(LZ4 TOAST 的重复文本
|
|
11
|
+
// 约 0.16 倍、jsonb 约 0.53 倍);已压缩的 bytea 会以 hex 导出再 gzip,略大于 1 倍。
|
|
12
|
+
// 所以按 2 倍留余量。
|
|
13
|
+
const DUMP_SIZE_HEADROOM = 2;
|
|
14
|
+
// 估算总有失准的时候(比如 pg_dump 没带 zlib、转储不压缩,重复文本能膨胀上百倍),
|
|
15
|
+
// 所以分片再设一个保底:按 200 GiB 的转储算,也就是付费最高档每项目 100 GB 的
|
|
16
|
+
// 2 倍。这样托管服务里任何套餐允许的转储都传得完,和估算准不准无关。
|
|
17
|
+
// 代价是每次上传最多多占几十 MiB 内存(21 MiB 一片,lib-storage 默认并发 4 片)。
|
|
18
|
+
const BASELINE_DUMP_BYTES = 200 * GIB;
|
|
19
|
+
/** 能把 expectedBytes 装进 10,000 片的最小分片大小,按 MiB 取整,不低于 5 MiB。 */
|
|
20
|
+
export function partSizeFor(expectedBytes) {
|
|
21
|
+
const neededBytes = Math.ceil(Math.max(0, expectedBytes) / MAX_PARTS);
|
|
22
|
+
return Math.max(MIN_PART_BYTES, Math.ceil(neededBytes / MIB) * MIB);
|
|
23
|
+
}
|
|
24
|
+
/** 数据库转储的分片大小:按估算体积 × 2 算,不低于 200 GiB 转储所需;量不出体积就用保底。 */
|
|
25
|
+
export function dumpPartSizeFor(relationBytes) {
|
|
26
|
+
return partSizeFor(Math.max(BASELINE_DUMP_BYTES, (relationBytes ?? 0) * DUMP_SIZE_HEADROOM));
|
|
27
|
+
}
|
package/dist/storage.js
CHANGED
|
@@ -4,6 +4,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
4
4
|
import { S3Client, ListBucketsCommand, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
|
|
5
5
|
import { Upload } from "@aws-sdk/lib-storage";
|
|
6
6
|
import { log } from "./log.js";
|
|
7
|
+
import { partSizeFor } from "./part-size.js";
|
|
7
8
|
/**
|
|
8
9
|
* Supabase Storage 通过 S3 兼容端点访问(endpoint 形如
|
|
9
10
|
* https://<ref>.storage.supabase.co/storage/v1/s3)。这里的凭据是"读取源"的密钥,
|
|
@@ -104,6 +105,8 @@ export async function syncStorage(cfg, target) {
|
|
|
104
105
|
const copy = pipeline(body, meter);
|
|
105
106
|
const upload = new Upload({
|
|
106
107
|
client: target.client,
|
|
108
|
+
// Body 是流,lib-storage 不知道长度,会固定按 5 MiB 切;用响应里的长度算分片
|
|
109
|
+
partSize: partSizeFor(got.ContentLength ?? obj.size),
|
|
107
110
|
params: {
|
|
108
111
|
Bucket: target.bucket,
|
|
109
112
|
Key: `${target.base}/storage/${bucket}/${obj.key}`,
|
package/package.json
CHANGED