zerobucket 0.10.0 → 0.11.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 +46 -36
- package/dist/bin.js +292 -12
- package/dist/index.cjs +290 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +290 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -243,7 +243,7 @@ zerobucket verify --sample 200 # ...or just a random sample (good for a nightly
|
|
|
243
243
|
zerobucket tier IMAGE_ID --bucket my-images # move one image to S3
|
|
244
244
|
zerobucket tier --older-than 90 --min-size 500000 --bucket my-images --dry-run # preview a bulk move
|
|
245
245
|
zerobucket tier --all --limit 1000 --bucket my-images --endpoint-url https://<acct>.r2.cloudflarestorage.com
|
|
246
|
-
zerobucket migrate --to-dedup #
|
|
246
|
+
zerobucket migrate --to-dedup # copy classic images into the dedup tables (PostgreSQL, SQLite and MySQL; non-destructive, re-runnable)
|
|
247
247
|
zerobucket migrate --to-dedup --skip-tiered # ...skipping images tiered to object storage instead of refusing
|
|
248
248
|
```
|
|
249
249
|
|
|
@@ -306,7 +306,8 @@ How it behaves in Node, and why:
|
|
|
306
306
|
- **Node 22+ for SQLite.** `better-sqlite3` 13 requires Node 22 or newer and crashes on older versions. It ships its Windows,
|
|
307
307
|
macOS and Linux binaries inside the npm package, so no compiler or Visual Studio is needed. Postgres users can stay on Node 20.
|
|
308
308
|
(`better-sqlite3` 12 declares Node 20 support, but it is untested here.)
|
|
309
|
-
-
|
|
309
|
+
- `migrateClassicToDedup()` works on SQLite (since 0.11.0): one `BEGIN IMMEDIATE` transaction, so it is all-or-nothing, and it
|
|
310
|
+
holds SQLite's single write lock for the whole copy, so other writers to the file wait until it finishes (readers do not).
|
|
310
311
|
|
|
311
312
|
## MySQL / MariaDB
|
|
312
313
|
|
|
@@ -328,7 +329,9 @@ dedup tables), so a Node app and a Python app can share one MySQL/MariaDB databa
|
|
|
328
329
|
directions in both modes, including tiered rows and shared dedup blobs.
|
|
329
330
|
|
|
330
331
|
As everywhere, dedup and tiering cannot be combined (`dedup: true` with `objectStorage` throws). Copying an existing
|
|
331
|
-
classic table into the dedup tables (`migrate --to-dedup`)
|
|
332
|
+
classic table into the dedup tables (`migrate --to-dedup`) works on MySQL/MariaDB since 0.11.0: one transaction (all-or-nothing)
|
|
333
|
+
and a named lock (`GET_LOCK`) so two migrations started at once cannot race. Timestamps are copied as text, never as JS dates,
|
|
334
|
+
because `DATETIME(6)` keeps microseconds and a JS date does not.
|
|
332
335
|
|
|
333
336
|
What is different from Postgres, and why (each verified against a real MariaDB 10.11):
|
|
334
337
|
|
|
@@ -400,7 +403,8 @@ await images.delete(b); // last reference gone: blob removed
|
|
|
400
403
|
**Moving existing data in:** `await images.migrateClassicToDedup()` copies every classic image into the dedup tables,
|
|
401
404
|
keeping each id, and never modifies the classic table. It runs in small batches (bounded memory), is safe to re-run
|
|
402
405
|
(already-migrated ids are skipped and counted), and refuses up front if any classic image is tiered to object storage
|
|
403
|
-
(their bytes are not in the database to copy, and dedup mode has no tiering), changing nothing.
|
|
406
|
+
(their bytes are not in the database to copy, and dedup mode has no tiering), changing nothing. Works on PostgreSQL, SQLite
|
|
407
|
+
and MySQL/MariaDB (SQLite and MySQL since 0.11.0). If you would rather
|
|
404
408
|
migrate everything else, pass `{ skipTiered: true }` (CLI: `--skip-tiered`): tiered images are left untouched in the
|
|
405
409
|
classic table, counted in `tieredSkipped`, and **will not exist in the dedup tables**, so only use it if you can live
|
|
406
410
|
without them there.
|
|
@@ -453,29 +457,31 @@ same corpus of valid and corrupt files identically.
|
|
|
453
457
|
|
|
454
458
|
## Differences from the Python package
|
|
455
459
|
|
|
456
|
-
Python column = zerobucket on PyPI **0.
|
|
457
|
-
versions differ).
|
|
458
|
-
|
|
459
|
-
| | Python
|
|
460
|
-
| -------------------------------------------------------------- |
|
|
461
|
-
| API | sync + async clients
|
|
462
|
-
|
|
|
463
|
-
|
|
|
464
|
-
| JPEG
|
|
465
|
-
|
|
|
466
|
-
|
|
|
467
|
-
|
|
|
468
|
-
|
|
|
469
|
-
|
|
|
470
|
-
| `migrateClassicToDedup`,
|
|
471
|
-
|
|
|
472
|
-
|
|
|
473
|
-
| SQLite
|
|
474
|
-
|
|
|
475
|
-
|
|
|
476
|
-
| `
|
|
477
|
-
|
|
|
478
|
-
|
|
|
460
|
+
Python column = zerobucket on PyPI **0.30.0 or later** (several of its behaviours below were fixed or added in 0.25.1, 0.26.0 and
|
|
461
|
+
0.27.0 to 0.30.0; older versions differ).
|
|
462
|
+
|
|
463
|
+
| | Python | npm |
|
|
464
|
+
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
465
|
+
| API | sync + async clients | one async client |
|
|
466
|
+
| Async clients: dedup and tiering | every async adapter (since 0.30.0) | one async client, always |
|
|
467
|
+
| `optimize` | Pillow; EXIF stripped, not applied | sharp; EXIF **applied then stripped**; transparency → **white** for JPEG |
|
|
468
|
+
| JPEG encoder in `optimize` | Pillow, progressive + optimized | sharp with `mozjpeg`, progressive: on smooth photos roughly **half the size** at about the same SSIM (measured); WebP and PNG match |
|
|
469
|
+
| JPEG missing only its last 1-2 bytes (the end-of-image marker) | accepted (Pillow reads every pixel) | **rejected** (libvips treats a premature end as an error); truncated interlaced PNGs are rejected by both since npm 0.10.0 |
|
|
470
|
+
| `putMany` inserts | pipelined | one transaction, statements run sequentially |
|
|
471
|
+
| Formats | JPEG, PNG, WebP, HEIC (`[heic]` extra) | JPEG, PNG, WebP, HEIC (optional `libheif-js`) |
|
|
472
|
+
| Convert TO HEIC with `optimize` | yes (pillow-heif) | no (no HEVC encoder for Node); converting FROM HEIC works; `optimize: true` on a HEIC needs an explicit `format` |
|
|
473
|
+
| Event duration | `duration_seconds` | `durationMs` |
|
|
474
|
+
| `migrateClassicToDedup`, tiered classic rows | always **skips** them and reports `tiered_rows_skipped` | **refuses** by default (changes nothing); `skipTiered: true` / `--skip-tiered` skips and reports like Python |
|
|
475
|
+
| `migrateClassicToDedup`, other behaviour | batched, safe to re-run (server-side cursor) | batched, safe to re-run (keyset paging); concurrent runs cannot race (PostgreSQL advisory lock, MySQL named lock, SQLite write lock) |
|
|
476
|
+
| `migrateClassicToDedup`, databases | PostgreSQL only | PostgreSQL, SQLite **and** MySQL/MariaDB (SQLite and MySQL since 0.11.0) |
|
|
477
|
+
| SQLite API | synchronous `SQLiteBackend` + separate async backend | one async client; the sync driver runs on the event loop, lock waits are async (never block it) |
|
|
478
|
+
| SQLite `onOperation` / busy retry | `on_operation` events and busy retry, sync and async (since 0.27.0) | `onOperation` events and async `SQLITE_BUSY` retry (`busyTimeoutMs`) |
|
|
479
|
+
| SQLite stream deleted mid-read | sync adapter keeps reading a WAL snapshot (async adapter raises) | raises `StorageError`, like Postgres; use `connection` in a transaction for a snapshot |
|
|
480
|
+
| MySQL `onOperation` / retry | yes, sync and async (since 0.27.0) | yes (same client for everything) |
|
|
481
|
+
| CLI | PostgreSQL, SQLite **and** MySQL/MariaDB, classic **and** dedup; `verify --bucket` checks tiered images (since 0.28.0); `migrate --to-dedup` on PostgreSQL only | PostgreSQL, SQLite **and** MySQL/MariaDB, classic **and** dedup; `verify --bucket` also checks tiered images; `migrate --to-dedup` on all three |
|
|
482
|
+
| `connection=` without an open transaction | multi-statement ops rely on the driver's implicit transaction | always atomic (own transaction or savepoint) |
|
|
483
|
+
| S3 delete failure after `delete()` | never fails the delete; reported as an `object_storage_delete` event (since 0.29.0) | never fails the delete; reported via `onOperation` |
|
|
484
|
+
| Options | snake_case kwargs | camelCase options object |
|
|
479
485
|
|
|
480
486
|
Both packages treat a malformed id as "not found" on every backend (Python 0.26.0+, npm 0.9.0+) and never pass one to a
|
|
481
487
|
`before_get`/`beforeGet` hook. Both packages now leave a tiered image's object in place on `delete(id, connection=...)` (a rolled-back transaction can
|
|
@@ -485,18 +491,22 @@ MySQL `ALTER TABLE` at startup when the schema is current.
|
|
|
485
491
|
## Performance
|
|
486
492
|
|
|
487
493
|
Measured on one single-core container, so treat it as directional; the full tables (PostgreSQL, SQLite, MariaDB, per size,
|
|
488
|
-
memory, event-loop impact, throughput, and the
|
|
494
|
+
memory, event-loop impact, throughput, and the before and after of each release) are in the
|
|
489
495
|
[benchmark results](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/benchmarks/node/RESULTS.md).
|
|
490
496
|
|
|
491
|
-
| Image size | `put()` (PostgreSQL) | `get()`
|
|
492
|
-
| ------------ | -------------------- |
|
|
493
|
-
| up to ~500KB | under 20ms | under 5ms
|
|
494
|
-
| 0.5-1.5MB | about 20-45ms | under 10ms
|
|
495
|
-
| about 5MB | about 170ms | about
|
|
496
|
-
| about 10MB | about 350ms | about
|
|
497
|
+
| Image size | `put()` (PostgreSQL) | `get()` (PostgreSQL) | Notes |
|
|
498
|
+
| ------------ | -------------------- | -------------------- | ----------------------------------------------------------------- |
|
|
499
|
+
| up to ~500KB | under 20ms | under 5ms | Feels like any other write. |
|
|
500
|
+
| 0.5-1.5MB | about 20-45ms | under 10ms | Fine for uploads, including in a loop. |
|
|
501
|
+
| about 5MB | about 170ms | about 35ms | A few MB of transient memory to write a baseline JPEG or PNG. |
|
|
502
|
+
| about 10MB | about 350ms | about 70ms | Default limit is 8 MiB. Memory depends on the format (see below). |
|
|
497
503
|
|
|
498
504
|
- Since 0.10.0, `put()` is 2.5-4x faster than 0.9.x and about as fast as the Python package for large images. Validation is now
|
|
499
505
|
roughly half of `put()` on PostgreSQL.
|
|
506
|
+
- Since 0.10.1, reading from PostgreSQL asks for base64 instead of PostgreSQL's default hex text: about 30% faster for images over
|
|
507
|
+
2MB, and `get()` needs 37-47% less memory (about 6.5 times the image size, down from about 10). Streaming, which is how
|
|
508
|
+
`createImageHandler` serves images, is bounded in memory either way. Between roughly 0.5MB and 1.3MB there is no reliable
|
|
509
|
+
difference (a cost of under a millisecond may exist). SQLite and MySQL/MariaDB were already fast readers and are unchanged.
|
|
500
510
|
- The decode runs off the main thread: the event loop stalled at most about 14ms during a 10MB `put()`.
|
|
501
511
|
- Transient memory depends on the format. A baseline JPEG or non-interlaced PNG validates in streaming fashion (about 11MB for a
|
|
502
512
|
45-megapixel JPEG, down from about 148MB in 0.9.x). WebP, progressive JPEG and interlaced PNG still need roughly the decoded
|
|
@@ -517,8 +527,8 @@ memory, event-loop impact, throughput, and the 0.9.0 to 0.10.0 before and after)
|
|
|
517
527
|
files must be backed up with `.backup`, not copied. See the
|
|
518
528
|
[Operations Guide](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/docs/OPERATIONS.md).
|
|
519
529
|
- **Dedup and tiering cannot be combined** (a shared blob has many owners): `dedup: true` with `objectStorage` throws.
|
|
520
|
-
-
|
|
521
|
-
|
|
530
|
+
- **Tiered classic images cannot be migrated** by `migrate --to-dedup` (see Deduplication for `skipTiered`). The migration itself
|
|
531
|
+
works on PostgreSQL, SQLite and MySQL/MariaDB since 0.11.0; the Python package still has it for PostgreSQL only.
|
|
522
532
|
- **HEIC can be read and converted from, but not written.** There is no HEVC encoder for Node, so `optimize` cannot
|
|
523
533
|
produce HEIC.
|
|
524
534
|
- **SQLite allows one writer at a time**; lock waits are async and never block the event loop, but write-heavy
|
package/dist/bin.js
CHANGED
|
@@ -150,17 +150,17 @@ INSERT INTO zerobucket_images
|
|
|
150
150
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
151
151
|
RETURNING id;`;
|
|
152
152
|
var SELECT_FULL = `
|
|
153
|
-
SELECT id, data, mime_type, original_filename, size_bytes, width, height,
|
|
153
|
+
SELECT id, encode(data, 'base64') AS data_b64, mime_type, original_filename, size_bytes, width, height,
|
|
154
154
|
checksum_sha256, storage_backend, object_storage_bucket, object_storage_key
|
|
155
155
|
FROM zerobucket_images WHERE id = $1;`;
|
|
156
156
|
var SELECT_MANY = `
|
|
157
|
-
SELECT id, data, mime_type, original_filename, size_bytes, width, height,
|
|
157
|
+
SELECT id, encode(data, 'base64') AS data_b64, mime_type, original_filename, size_bytes, width, height,
|
|
158
158
|
checksum_sha256, storage_backend, object_storage_bucket, object_storage_key
|
|
159
159
|
FROM zerobucket_images WHERE id = ANY($1::uuid[]);`;
|
|
160
160
|
var SELECT_METADATA = `
|
|
161
161
|
SELECT id, mime_type, original_filename, size_bytes, width, height, checksum_sha256
|
|
162
162
|
FROM zerobucket_images WHERE id = $1;`;
|
|
163
|
-
var SELECT_CHUNK = `SELECT substring(data FROM $1::int FOR $2::int) AS chunk FROM zerobucket_images WHERE id = $3;`;
|
|
163
|
+
var SELECT_CHUNK = `SELECT encode(substring(data FROM $1::int FOR $2::int), 'base64') AS chunk FROM zerobucket_images WHERE id = $3;`;
|
|
164
164
|
var SELECT_STREAM_INFO = `SELECT size_bytes, storage_backend, object_storage_key FROM zerobucket_images WHERE id = $1;`;
|
|
165
165
|
var DELETE_RETURNING = `DELETE FROM zerobucket_images WHERE id = $1 RETURNING storage_backend, object_storage_key;`;
|
|
166
166
|
var DELETE_MANY = `DELETE FROM zerobucket_images WHERE id = ANY($1::uuid[]) RETURNING id, storage_backend, object_storage_key;`;
|
|
@@ -193,7 +193,7 @@ INSERT INTO zerobucket_blobs (checksum_sha256, data, mime_type, size_bytes, widt
|
|
|
193
193
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
194
194
|
ON CONFLICT (checksum_sha256) DO UPDATE SET ref_count = zerobucket_blobs.ref_count + EXCLUDED.ref_count;`;
|
|
195
195
|
var D_INSERT_REF = `INSERT INTO zerobucket_image_refs (checksum_sha256, original_filename) VALUES ($1, $2) RETURNING id;`;
|
|
196
|
-
var D_COLS = `r.id, b.data, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256,
|
|
196
|
+
var D_COLS = `r.id, encode(b.data, 'base64') AS data_b64, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256,
|
|
197
197
|
'postgres'::text AS storage_backend, NULL::text AS object_storage_bucket, NULL::text AS object_storage_key`;
|
|
198
198
|
var D_FROM = `FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256`;
|
|
199
199
|
var D_DELETE_REF = `DELETE FROM zerobucket_image_refs WHERE id = $1 RETURNING checksum_sha256;`;
|
|
@@ -215,7 +215,7 @@ var DEDUP_SQL = {
|
|
|
215
215
|
selectMany: `SELECT ${D_COLS} ${D_FROM} WHERE r.id = ANY($1::uuid[]);`,
|
|
216
216
|
selectMetadata: `SELECT r.id, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256 ${D_FROM} WHERE r.id = $1;`,
|
|
217
217
|
selectStreamInfo: `SELECT b.size_bytes, 'postgres'::text AS storage_backend, NULL::text AS object_storage_key ${D_FROM} WHERE r.id = $1;`,
|
|
218
|
-
selectChunk: `SELECT substring(b.data FROM $1::int FOR $2::int) AS chunk ${D_FROM} WHERE r.id = $3;`,
|
|
218
|
+
selectChunk: `SELECT encode(substring(b.data FROM $1::int FOR $2::int), 'base64') AS chunk ${D_FROM} WHERE r.id = $3;`,
|
|
219
219
|
exists: `SELECT 1 FROM zerobucket_image_refs WHERE id = $1;`
|
|
220
220
|
};
|
|
221
221
|
var RETRYABLE_SQLSTATES = /* @__PURE__ */ new Set([
|
|
@@ -672,7 +672,7 @@ var PostgresBackend = class {
|
|
|
672
672
|
`Image ${JSON.stringify(id)} was deleted while streaming (delivered ${delivered} of ${wanted} bytes). Pass connection with your own open transaction if you need a consistent read across concurrent writers.`
|
|
673
673
|
);
|
|
674
674
|
}
|
|
675
|
-
const chunk = row.chunk;
|
|
675
|
+
const chunk = row.chunk == null ? null : Buffer.from(row.chunk, "base64");
|
|
676
676
|
if (!chunk || chunk.length === 0) {
|
|
677
677
|
throw new StorageError(
|
|
678
678
|
`Image ${JSON.stringify(id)} ended early while streaming (delivered ${delivered} of ${wanted} bytes).`
|
|
@@ -996,6 +996,8 @@ var PostgresBackend = class {
|
|
|
996
996
|
}
|
|
997
997
|
// ---- helpers ---------------------------------------------------------
|
|
998
998
|
hydrate(row, id) {
|
|
999
|
+
row.data = row.data_b64 == null ? null : Buffer.from(row.data_b64, "base64");
|
|
1000
|
+
delete row.data_b64;
|
|
999
1001
|
return hydrateRow(row, id, this.opts.objectStorage);
|
|
1000
1002
|
}
|
|
1001
1003
|
/**
|
|
@@ -1693,6 +1695,125 @@ var SQLiteBackend = class {
|
|
|
1693
1695
|
throw wrap("Failed to check image existence", exc);
|
|
1694
1696
|
}
|
|
1695
1697
|
}
|
|
1698
|
+
// ---- migration: classic -> dedup --------------------------------------
|
|
1699
|
+
/**
|
|
1700
|
+
* One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
|
|
1701
|
+
* dedup instance's tables, preserving every id (external references keep working) and
|
|
1702
|
+
* deduplicating identical content on the way. The classic table is never modified. Same contract
|
|
1703
|
+
* as `PostgresBackend.migrateClassicToDedup()`.
|
|
1704
|
+
*
|
|
1705
|
+
* Rows are copied in small batches (bounded memory instead of loading the whole table);
|
|
1706
|
+
* re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
|
|
1707
|
+
* already tiered to object storage are refused up front with a clear message (their bytes are
|
|
1708
|
+
* not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
|
|
1709
|
+
* All-or-nothing: ONE `BEGIN IMMEDIATE` transaction, so a failure part-way leaves the dedup
|
|
1710
|
+
* tables exactly as they were. That transaction holds SQLite's single write lock for the whole
|
|
1711
|
+
* copy, so other writers to this database file wait until it finishes (readers are not blocked).
|
|
1712
|
+
*/
|
|
1713
|
+
async migrateClassicToDedup(opts = {}) {
|
|
1714
|
+
if (!this.dedup) {
|
|
1715
|
+
throw new StorageError(
|
|
1716
|
+
"migrateClassicToDedup() must be called on an instance created with dedup: true."
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
try {
|
|
1720
|
+
return await this.execute(
|
|
1721
|
+
"migrate_to_dedup",
|
|
1722
|
+
void 0,
|
|
1723
|
+
"write",
|
|
1724
|
+
(db) => {
|
|
1725
|
+
if (!db.prepare(
|
|
1726
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'zerobucket_images'"
|
|
1727
|
+
).get()) {
|
|
1728
|
+
throw new StorageError(
|
|
1729
|
+
"No classic zerobucket_images table found in this database -- nothing to migrate."
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
let tieredSkipped = 0;
|
|
1733
|
+
const hasTierCol = db.prepare("PRAGMA table_info(zerobucket_images)").all().some((c) => c.name === "storage_backend");
|
|
1734
|
+
if (hasTierCol) {
|
|
1735
|
+
const tiered = db.prepare(
|
|
1736
|
+
"SELECT count(*) AS n FROM zerobucket_images WHERE storage_backend <> 'sqlite'"
|
|
1737
|
+
).get().n;
|
|
1738
|
+
if (tiered > 0 && !opts.skipTiered) {
|
|
1739
|
+
throw new StorageError(
|
|
1740
|
+
`${tiered} classic image(s) are tiered to object storage, so their bytes are not in the database to migrate. Dedup mode does not support tiering. Migrate only an un-tiered table, bring those images back first, or pass skipTiered: true (CLI: --skip-tiered) to skip them and migrate the rest.`
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1743
|
+
tieredSkipped = tiered;
|
|
1744
|
+
}
|
|
1745
|
+
const onlyInDb = hasTierCol && tieredSkipped > 0;
|
|
1746
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1747
|
+
let migrated = 0;
|
|
1748
|
+
let already = 0;
|
|
1749
|
+
let cursor = null;
|
|
1750
|
+
const BATCH = 20;
|
|
1751
|
+
const columns = "id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at, updated_at";
|
|
1752
|
+
for (; ; ) {
|
|
1753
|
+
const page = cursor ? db.prepare(
|
|
1754
|
+
`SELECT ${columns} FROM zerobucket_images WHERE (created_at, id) > (?, ?)${onlyInDb ? " AND storage_backend = 'sqlite'" : ""} ORDER BY created_at, id LIMIT ${BATCH}`
|
|
1755
|
+
).all(cursor.createdAt, cursor.id) : db.prepare(
|
|
1756
|
+
`SELECT ${columns} FROM zerobucket_images${onlyInDb ? " WHERE storage_backend = 'sqlite'" : ""} ORDER BY created_at, id LIMIT ${BATCH}`
|
|
1757
|
+
).all();
|
|
1758
|
+
if (page.length === 0) break;
|
|
1759
|
+
const last = page[page.length - 1];
|
|
1760
|
+
if (cursor && cursor.createdAt === last.created_at && cursor.id === last.id) {
|
|
1761
|
+
throw new StorageError(
|
|
1762
|
+
"Migration made no progress (keyset cursor did not advance); aborting instead of looping."
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
cursor = { createdAt: last.created_at, id: last.id };
|
|
1766
|
+
const have = new Set(
|
|
1767
|
+
db.prepare(
|
|
1768
|
+
`SELECT id FROM zerobucket_image_refs WHERE id IN (${placeholders(page.length)})`
|
|
1769
|
+
).all(...page.map((r) => r.id)).map((r) => r.id)
|
|
1770
|
+
);
|
|
1771
|
+
const fresh = page.filter((r) => !have.has(r.id));
|
|
1772
|
+
already += page.length - fresh.length;
|
|
1773
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1774
|
+
for (const r of fresh) {
|
|
1775
|
+
const g = groups.get(r.checksum_sha256);
|
|
1776
|
+
if (g) g.n += 1;
|
|
1777
|
+
else groups.set(r.checksum_sha256, { row: r, n: 1 });
|
|
1778
|
+
}
|
|
1779
|
+
for (const checksum of [...groups.keys()].sort()) {
|
|
1780
|
+
const { row, n } = groups.get(checksum);
|
|
1781
|
+
db.prepare(D_UPSERT).run(
|
|
1782
|
+
checksum,
|
|
1783
|
+
row.data,
|
|
1784
|
+
row.mime_type,
|
|
1785
|
+
row.size_bytes,
|
|
1786
|
+
row.width,
|
|
1787
|
+
row.height,
|
|
1788
|
+
n,
|
|
1789
|
+
row.created_at
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
for (const r of fresh) {
|
|
1793
|
+
db.prepare(D_INSERT_REF2).run(
|
|
1794
|
+
r.id,
|
|
1795
|
+
r.checksum_sha256,
|
|
1796
|
+
r.original_filename,
|
|
1797
|
+
r.created_at,
|
|
1798
|
+
r.updated_at
|
|
1799
|
+
);
|
|
1800
|
+
seen.add(r.checksum_sha256);
|
|
1801
|
+
migrated += 1;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
return {
|
|
1805
|
+
imagesMigrated: migrated,
|
|
1806
|
+
distinctBlobsCreated: seen.size,
|
|
1807
|
+
duplicateReferencesFound: migrated - seen.size,
|
|
1808
|
+
alreadyMigrated: already,
|
|
1809
|
+
tieredSkipped
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
);
|
|
1813
|
+
} catch (exc) {
|
|
1814
|
+
throw wrap("Migration to dedup failed", exc);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1696
1817
|
// ---- maintenance (CLI) -----------------------------------------------
|
|
1697
1818
|
async getInfo() {
|
|
1698
1819
|
try {
|
|
@@ -1903,6 +2024,7 @@ CREATE TABLE IF NOT EXISTS zerobucket_images (
|
|
|
1903
2024
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
|
|
1904
2025
|
var TIERING_CHECK = "chk_zerobucket_storage";
|
|
1905
2026
|
var MIGRATE_LOCK = "zerobucket_migrate";
|
|
2027
|
+
var MIGRATE_DEDUP_LOCK = "zerobucket_migrate_dedup";
|
|
1906
2028
|
var COLS2 = `id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, storage_backend, object_storage_bucket, object_storage_key`;
|
|
1907
2029
|
var INSERT3 = `INSERT INTO zerobucket_images (id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
1908
2030
|
var SELECT_FULL3 = `SELECT ${COLS2} FROM zerobucket_images WHERE id = ?`;
|
|
@@ -2262,15 +2384,31 @@ var MySQLBackend = class {
|
|
|
2262
2384
|
/**
|
|
2263
2385
|
* Run `work` on a pooled connection. `tx` wraps it in a transaction. Transient failures retry the
|
|
2264
2386
|
* WHOLE closure on a fresh connection after a full rollback.
|
|
2387
|
+
*
|
|
2388
|
+
* `lock` serializes this work against other callers holding the same named lock (`GET_LOCK`). The
|
|
2389
|
+
* ORDER matters and is the whole point: the lock is taken BEFORE the transaction begins (so the
|
|
2390
|
+
* transaction's snapshot is taken after any earlier holder has finished) and released AFTER the
|
|
2391
|
+
* transaction commits or rolls back (so a later holder can never start while this one's rows are
|
|
2392
|
+
* still uncommitted and invisible to it). Releasing inside `work`, before the commit, lets a second
|
|
2393
|
+
* holder read a stale snapshot and collide on primary keys.
|
|
2265
2394
|
*/
|
|
2266
|
-
async runPooled(operation, work, tx) {
|
|
2395
|
+
async runPooled(operation, work, tx, lock) {
|
|
2267
2396
|
const start = performance.now();
|
|
2268
2397
|
let attempt = 0;
|
|
2269
2398
|
for (; ; ) {
|
|
2270
2399
|
let conn;
|
|
2271
2400
|
let destroy = false;
|
|
2401
|
+
let held = false;
|
|
2272
2402
|
try {
|
|
2273
2403
|
conn = await this.acquire();
|
|
2404
|
+
if (lock) {
|
|
2405
|
+
const got = (await conn.query("SELECT GET_LOCK(?, ?) AS got", [
|
|
2406
|
+
lock.name,
|
|
2407
|
+
lock.timeoutSec
|
|
2408
|
+
]))[0][0]?.got;
|
|
2409
|
+
if (got !== 1) throw new StorageError(lock.timeoutMessage);
|
|
2410
|
+
held = true;
|
|
2411
|
+
}
|
|
2274
2412
|
if (tx) await conn.beginTransaction();
|
|
2275
2413
|
const result = await work(conn);
|
|
2276
2414
|
if (tx) await conn.commit();
|
|
@@ -2292,6 +2430,13 @@ var MySQLBackend = class {
|
|
|
2292
2430
|
}
|
|
2293
2431
|
} finally {
|
|
2294
2432
|
if (conn) {
|
|
2433
|
+
if (held && lock && !destroy) {
|
|
2434
|
+
try {
|
|
2435
|
+
await conn.query("SELECT RELEASE_LOCK(?)", [lock.name]);
|
|
2436
|
+
} catch {
|
|
2437
|
+
destroy = true;
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2295
2440
|
if (destroy) conn.destroy();
|
|
2296
2441
|
else conn.release();
|
|
2297
2442
|
}
|
|
@@ -2305,7 +2450,10 @@ var MySQLBackend = class {
|
|
|
2305
2450
|
* connection given -> run once on it; no retry, and no commit/rollback of the caller's transaction.
|
|
2306
2451
|
* connection absent -> pooled, retried (see runPooled).
|
|
2307
2452
|
*/
|
|
2308
|
-
async run(operation, connection, work, {
|
|
2453
|
+
async run(operation, connection, work, {
|
|
2454
|
+
tx = false,
|
|
2455
|
+
lock
|
|
2456
|
+
} = {}) {
|
|
2309
2457
|
if (connection) {
|
|
2310
2458
|
if (!isMysqlConnection(connection)) {
|
|
2311
2459
|
throw new TypeError(
|
|
@@ -2323,7 +2471,7 @@ var MySQLBackend = class {
|
|
|
2323
2471
|
}
|
|
2324
2472
|
}
|
|
2325
2473
|
await this.ready();
|
|
2326
|
-
return this.runPooled(operation, work, tx);
|
|
2474
|
+
return this.runPooled(operation, work, tx, lock);
|
|
2327
2475
|
}
|
|
2328
2476
|
/**
|
|
2329
2477
|
* Make multi-statement work all-or-nothing on a CALLER-supplied connection.
|
|
@@ -2597,6 +2745,139 @@ var MySQLBackend = class {
|
|
|
2597
2745
|
throw wrap("Failed to check image existence", exc);
|
|
2598
2746
|
}
|
|
2599
2747
|
}
|
|
2748
|
+
// ---- migration: classic -> dedup --------------------------------------
|
|
2749
|
+
/**
|
|
2750
|
+
* One-time, NON-DESTRUCTIVE copy of every row in the classic `zerobucket_images` table into this
|
|
2751
|
+
* dedup instance's tables, preserving every id (external references keep working) and
|
|
2752
|
+
* deduplicating identical content on the way. The classic table is never modified. Same contract
|
|
2753
|
+
* as `PostgresBackend.migrateClassicToDedup()`.
|
|
2754
|
+
*
|
|
2755
|
+
* Rows are copied in small batches (bounded memory instead of loading the whole table);
|
|
2756
|
+
* re-running is safe (already-migrated ids are skipped and reported, not an error). Classic rows
|
|
2757
|
+
* already tiered to object storage are refused up front with a clear message (their bytes are
|
|
2758
|
+
* not in the database to copy) unless `skipTiered: true`, which skips and counts them instead.
|
|
2759
|
+
* All-or-nothing: ONE transaction, so a failure part-way leaves the dedup tables exactly as they
|
|
2760
|
+
* were. A named lock (`GET_LOCK`) serializes two migrations started at once; it is taken before the
|
|
2761
|
+
* transaction and released only after it commits (see `runPooled`), so a second migration always sees
|
|
2762
|
+
* the first one's rows.
|
|
2763
|
+
*/
|
|
2764
|
+
async migrateClassicToDedup(opts = {}) {
|
|
2765
|
+
if (!this.dedup) {
|
|
2766
|
+
throw new StorageError(
|
|
2767
|
+
"migrateClassicToDedup() must be called on an instance created with dedup: true."
|
|
2768
|
+
);
|
|
2769
|
+
}
|
|
2770
|
+
try {
|
|
2771
|
+
return await this.run(
|
|
2772
|
+
"migrate_to_dedup",
|
|
2773
|
+
void 0,
|
|
2774
|
+
(q) => this.copyClassicToDedup(q, opts),
|
|
2775
|
+
{
|
|
2776
|
+
tx: true,
|
|
2777
|
+
lock: {
|
|
2778
|
+
name: MIGRATE_DEDUP_LOCK,
|
|
2779
|
+
timeoutSec: 30,
|
|
2780
|
+
timeoutMessage: "Timed out waiting for another migration to dedup to finish."
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
);
|
|
2784
|
+
} catch (exc) {
|
|
2785
|
+
throw wrap("Migration to dedup failed", exc);
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
async copyClassicToDedup(q, opts) {
|
|
2789
|
+
const rowsOf = async (sql, params) => (await q.query(sql, params))[0];
|
|
2790
|
+
if ((await rowsOf(
|
|
2791
|
+
"SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'zerobucket_images'"
|
|
2792
|
+
)).length === 0) {
|
|
2793
|
+
throw new StorageError(
|
|
2794
|
+
"No classic zerobucket_images table found in this database -- nothing to migrate."
|
|
2795
|
+
);
|
|
2796
|
+
}
|
|
2797
|
+
let tieredSkipped = 0;
|
|
2798
|
+
const hasTierCol = (await rowsOf(
|
|
2799
|
+
"SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'zerobucket_images' AND COLUMN_NAME = 'storage_backend'"
|
|
2800
|
+
)).length > 0;
|
|
2801
|
+
if (hasTierCol) {
|
|
2802
|
+
const tiered = Number(
|
|
2803
|
+
(await rowsOf(
|
|
2804
|
+
`SELECT COUNT(*) AS n FROM zerobucket_images WHERE storage_backend <> '${LOCAL}'`
|
|
2805
|
+
))[0].n
|
|
2806
|
+
);
|
|
2807
|
+
if (tiered > 0 && !opts.skipTiered) {
|
|
2808
|
+
throw new StorageError(
|
|
2809
|
+
`${tiered} classic image(s) are tiered to object storage, so their bytes are not in the database to migrate. Dedup mode does not support tiering. Migrate only an un-tiered table, bring those images back first, or pass skipTiered: true (CLI: --skip-tiered) to skip them and migrate the rest.`
|
|
2810
|
+
);
|
|
2811
|
+
}
|
|
2812
|
+
tieredSkipped = tiered;
|
|
2813
|
+
}
|
|
2814
|
+
const onlyInDb = hasTierCol && tieredSkipped > 0;
|
|
2815
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2816
|
+
let migrated = 0;
|
|
2817
|
+
let already = 0;
|
|
2818
|
+
let cursor = null;
|
|
2819
|
+
const BATCH = 20;
|
|
2820
|
+
const columns = "id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, CAST(created_at AS CHAR) AS created_at_txt, CAST(updated_at AS CHAR) AS updated_at_txt";
|
|
2821
|
+
for (; ; ) {
|
|
2822
|
+
const page = cursor ? await rowsOf(
|
|
2823
|
+
`SELECT ${columns} FROM zerobucket_images WHERE (created_at, id) > (?, ?)${onlyInDb ? ` AND storage_backend = '${LOCAL}'` : ""} ORDER BY created_at, id LIMIT ${BATCH}`,
|
|
2824
|
+
[cursor.createdAt, cursor.id]
|
|
2825
|
+
) : await rowsOf(
|
|
2826
|
+
`SELECT ${columns} FROM zerobucket_images${onlyInDb ? ` WHERE storage_backend = '${LOCAL}'` : ""} ORDER BY created_at, id LIMIT ${BATCH}`
|
|
2827
|
+
);
|
|
2828
|
+
if (page.length === 0) break;
|
|
2829
|
+
const last = page[page.length - 1];
|
|
2830
|
+
if (cursor && cursor.createdAt === last.created_at_txt && cursor.id === last.id) {
|
|
2831
|
+
throw new StorageError(
|
|
2832
|
+
"Migration made no progress (keyset cursor did not advance); aborting instead of looping."
|
|
2833
|
+
);
|
|
2834
|
+
}
|
|
2835
|
+
cursor = { createdAt: last.created_at_txt, id: last.id };
|
|
2836
|
+
const have = new Set(
|
|
2837
|
+
(await rowsOf("SELECT id FROM zerobucket_image_refs WHERE id IN (?)", [
|
|
2838
|
+
page.map((r) => r.id)
|
|
2839
|
+
])).map((r) => String(r.id))
|
|
2840
|
+
);
|
|
2841
|
+
const fresh = page.filter((r) => !have.has(String(r.id)));
|
|
2842
|
+
already += page.length - fresh.length;
|
|
2843
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2844
|
+
for (const r of fresh) {
|
|
2845
|
+
const g = groups.get(r.checksum_sha256);
|
|
2846
|
+
if (g) g.n += 1;
|
|
2847
|
+
else groups.set(r.checksum_sha256, { row: r, n: 1 });
|
|
2848
|
+
}
|
|
2849
|
+
for (const checksum of [...groups.keys()].sort()) {
|
|
2850
|
+
const { row, n } = groups.get(checksum);
|
|
2851
|
+
await q.query(upsertBlobSql(n), [
|
|
2852
|
+
checksum,
|
|
2853
|
+
row.data,
|
|
2854
|
+
row.mime_type,
|
|
2855
|
+
row.size_bytes,
|
|
2856
|
+
row.width,
|
|
2857
|
+
row.height,
|
|
2858
|
+
row.created_at_txt
|
|
2859
|
+
]);
|
|
2860
|
+
}
|
|
2861
|
+
for (const r of fresh) {
|
|
2862
|
+
await q.query(D_INSERT_REF3, [
|
|
2863
|
+
r.id,
|
|
2864
|
+
r.checksum_sha256,
|
|
2865
|
+
r.original_filename,
|
|
2866
|
+
r.created_at_txt,
|
|
2867
|
+
r.updated_at_txt
|
|
2868
|
+
]);
|
|
2869
|
+
seen.add(r.checksum_sha256);
|
|
2870
|
+
migrated += 1;
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
return {
|
|
2874
|
+
imagesMigrated: migrated,
|
|
2875
|
+
distinctBlobsCreated: seen.size,
|
|
2876
|
+
duplicateReferencesFound: migrated - seen.size,
|
|
2877
|
+
alreadyMigrated: already,
|
|
2878
|
+
tieredSkipped
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
2600
2881
|
// ---- maintenance (CLI) -----------------------------------------------
|
|
2601
2882
|
/** Storage statistics, or null if the tables do not exist yet (nothing is created). */
|
|
2602
2883
|
async getInfo() {
|
|
@@ -2998,7 +3279,7 @@ Usage: zerobucket <command> [options]
|
|
|
2998
3279
|
Commands:
|
|
2999
3280
|
init Create the schema (tables and indexes) if missing.
|
|
3000
3281
|
migrate Apply schema migrations (currently: same as init).
|
|
3001
|
-
--to-dedup copy every classic image into the dedup tables (PostgreSQL
|
|
3282
|
+
--to-dedup copy every classic image into the dedup tables (PostgreSQL, SQLite and MySQL; non-destructive)
|
|
3002
3283
|
--skip-tiered with --to-dedup: skip images tiered to object storage instead of refusing
|
|
3003
3284
|
info Show storage stats: count, size, breakdown by format.
|
|
3004
3285
|
verify Re-checksum stored images to detect corruption.
|
|
@@ -3165,8 +3446,7 @@ async function cmdMigrate(v, env, io) {
|
|
|
3165
3446
|
throw usageError("Error: --skip-tiered only applies with --to-dedup.");
|
|
3166
3447
|
if (v["to-dedup"]) {
|
|
3167
3448
|
const target = resolveTarget(v, env);
|
|
3168
|
-
|
|
3169
|
-
throw usageError("Error: --to-dedup is only supported for PostgreSQL.");
|
|
3449
|
+
requireSqliteFile(target, "Nothing to migrate.");
|
|
3170
3450
|
const backend = openBackend(target, { dedup: true, autoMigrate: true });
|
|
3171
3451
|
await connect(backend, "connect");
|
|
3172
3452
|
try {
|