zerobucket 0.10.1 → 0.11.1
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 +42 -35
- package/dist/bin.js +284 -6
- package/dist/index.cjs +282 -3
- 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 +282 -3
- 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
|
|
|
@@ -336,7 +339,9 @@ What is different from Postgres, and why (each verified against a real MariaDB 1
|
|
|
336
339
|
`deleteMany()` run `SELECT ... FOR UPDATE` and then `DELETE` in one transaction. That is also how a tiered row's object
|
|
337
340
|
gets cleaned up.
|
|
338
341
|
- **Timestamps are written as UTC strings** (`DATETIME(6)`, naive UTC, like the Python package), so your server's or your
|
|
339
|
-
process's timezone setting cannot change what is stored.
|
|
342
|
+
process's timezone setting cannot change what is stored. The age filter behind `olderThanDays` and `tier --older-than`
|
|
343
|
+
compares against `UTC_TIMESTAMP()`, never `NOW()`; since 0.11.1 a test runs it with the session time zone at `+13:00` and
|
|
344
|
+
`-12:00` (the old suites only ever ran on UTC servers, where the two agree).
|
|
340
345
|
- **Pooling.** `mysql2` opens connections on demand, so `poolMinSize` is accepted for parity but there is no minimum to hold.
|
|
341
346
|
`mysql2` has no acquire timeout, so `poolTimeoutMs` is implemented here: waiting longer than that for a free connection
|
|
342
347
|
throws a clear `StorageError`, which is **not** retried (retrying would only wait again).
|
|
@@ -370,9 +375,9 @@ What is different from Postgres, and why (each verified against a real MariaDB 1
|
|
|
370
375
|
skipped on your connection (it cannot know whether you will commit).
|
|
371
376
|
- **Streaming** uses ranged `SUBSTRING()` reads, so a delete mid-stream raises `StorageError` (like Postgres). Tiered images
|
|
372
377
|
stream with real S3 `Range` requests.
|
|
373
|
-
- **Tested on MariaDB 10.11 and MySQL 8.0
|
|
374
|
-
|
|
375
|
-
MariaDB 10.2.1+ or MySQL 8.0.16+: older servers accept the `CHECK` constraint syntax but ignore it.
|
|
378
|
+
- **Tested on MariaDB 10.11 and MySQL 8.0** (CI runs the MySQL, dedup and CLI test suites and the Node/Python conformance
|
|
379
|
+
harness on both; the schema comparison was also run by hand on 8.0.46). Other versions are expected to behave the same but have not
|
|
380
|
+
been run. Use MariaDB 10.2.1+ or MySQL 8.0.16+: older servers accept the `CHECK` constraint syntax but ignore it.
|
|
376
381
|
|
|
377
382
|
## Deduplication
|
|
378
383
|
|
|
@@ -400,7 +405,8 @@ await images.delete(b); // last reference gone: blob removed
|
|
|
400
405
|
**Moving existing data in:** `await images.migrateClassicToDedup()` copies every classic image into the dedup tables,
|
|
401
406
|
keeping each id, and never modifies the classic table. It runs in small batches (bounded memory), is safe to re-run
|
|
402
407
|
(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.
|
|
408
|
+
(their bytes are not in the database to copy, and dedup mode has no tiering), changing nothing. Works on PostgreSQL, SQLite
|
|
409
|
+
and MySQL/MariaDB (SQLite and MySQL since 0.11.0). If you would rather
|
|
404
410
|
migrate everything else, pass `{ skipTiered: true }` (CLI: `--skip-tiered`): tiered images are left untouched in the
|
|
405
411
|
classic table, counted in `tieredSkipped`, and **will not exist in the dedup tables**, so only use it if you can live
|
|
406
412
|
without them there.
|
|
@@ -453,29 +459,29 @@ same corpus of valid and corrupt files identically.
|
|
|
453
459
|
|
|
454
460
|
## Differences from the Python package
|
|
455
461
|
|
|
456
|
-
Python column = zerobucket on PyPI **0.
|
|
457
|
-
versions differ).
|
|
458
|
-
|
|
459
|
-
| | Python
|
|
460
|
-
| -------------------------------------------------------------- |
|
|
461
|
-
| API | sync + async clients
|
|
462
|
-
| `optimize`
|
|
463
|
-
|
|
|
464
|
-
| JPEG
|
|
465
|
-
|
|
|
466
|
-
|
|
|
467
|
-
|
|
|
468
|
-
|
|
|
469
|
-
|
|
|
470
|
-
| `migrateClassicToDedup`,
|
|
471
|
-
|
|
|
472
|
-
| SQLite `
|
|
473
|
-
| SQLite
|
|
474
|
-
|
|
|
475
|
-
|
|
|
476
|
-
| `connection=` without an open transaction | multi-statement ops rely on the driver's implicit transaction
|
|
477
|
-
| S3 delete failure after `delete()` |
|
|
478
|
-
| Options | snake_case kwargs
|
|
462
|
+
Python column = zerobucket on PyPI **0.33.0 or later** (several of its behaviours below were fixed or added in 0.25.1, 0.26.0 and
|
|
463
|
+
0.27.0 to 0.33.0; older versions differ).
|
|
464
|
+
|
|
465
|
+
| | Python | npm |
|
|
466
|
+
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
467
|
+
| API | sync + async clients | one async client |
|
|
468
|
+
| Async client feature set | everything the sync client has: dedup and tiering (since 0.30.0), hooks, `optimize` and `validator` (since 0.32.0), `connection=` (since 0.33.0); a hook may be a plain or an `async def` function | one async client, always, with all of them |
|
|
469
|
+
| `optimize` | Pillow; EXIF stripped, not applied | sharp; EXIF **applied then stripped**; transparency → **white** for JPEG |
|
|
470
|
+
| 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 |
|
|
471
|
+
| 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 |
|
|
472
|
+
| `putMany` inserts | pipelined | one transaction, statements run sequentially |
|
|
473
|
+
| Formats | JPEG, PNG, WebP, HEIC (`[heic]` extra) | JPEG, PNG, WebP, HEIC (optional `libheif-js`) |
|
|
474
|
+
| 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` |
|
|
475
|
+
| Event duration | `duration_seconds` | `durationMs` |
|
|
476
|
+
| `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 |
|
|
477
|
+
| `migrateClassicToDedup`, other behaviour | batched, safe to re-run. PostgreSQL: server-side cursor and **no lock**, so two runs at once make the second fail with a unique-key error and roll back (nothing is double-counted). SQLite: write lock. MySQL/MariaDB: named lock (the same one npm uses), so concurrent runs cannot race there | batched, safe to re-run (keyset paging); concurrent runs cannot race (PostgreSQL advisory lock, MySQL named lock, SQLite write lock) |
|
|
478
|
+
| 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) |
|
|
479
|
+
| 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`) |
|
|
480
|
+
| 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 |
|
|
481
|
+
| MySQL `onOperation` / retry | yes, sync and async (since 0.27.0) | yes (same client for everything) |
|
|
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
|
|
@@ -521,14 +527,15 @@ memory, event-loop impact, throughput, and the before and after of each release)
|
|
|
521
527
|
files must be backed up with `.backup`, not copied. See the
|
|
522
528
|
[Operations Guide](https://github.com/KedarGhadyalji/ZeroBucket/blob/main/docs/OPERATIONS.md).
|
|
523
529
|
- **Dedup and tiering cannot be combined** (a shared blob has many owners): `dedup: true` with `objectStorage` throws.
|
|
524
|
-
-
|
|
525
|
-
|
|
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 (npm since 0.11.0, the Python package since 0.31.0).
|
|
526
532
|
- **HEIC can be read and converted from, but not written.** There is no HEVC encoder for Node, so `optimize` cannot
|
|
527
533
|
produce HEIC.
|
|
528
534
|
- **SQLite allows one writer at a time**; lock waits are async and never block the event loop, but write-heavy
|
|
529
535
|
workloads belong on PostgreSQL or MySQL/MariaDB.
|
|
530
|
-
- **Tested versions:** CI runs PostgreSQL 16
|
|
531
|
-
|
|
536
|
+
- **Tested versions:** CI runs PostgreSQL 16, MariaDB 10.11 and MySQL 8.0 on Node 20, 22 and 24, and the SQLite and
|
|
537
|
+
filesystem tests on Windows (GitHub's Windows runners cannot run database servers, so PostgreSQL, MySQL and S3 are not
|
|
538
|
+
tested there). Other versions are expected to behave the same but have not been run.
|
|
532
539
|
|
|
533
540
|
## Runtime notes
|
|
534
541
|
|
package/dist/bin.js
CHANGED
|
@@ -1695,6 +1695,125 @@ var SQLiteBackend = class {
|
|
|
1695
1695
|
throw wrap("Failed to check image existence", exc);
|
|
1696
1696
|
}
|
|
1697
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
|
+
}
|
|
1698
1817
|
// ---- maintenance (CLI) -----------------------------------------------
|
|
1699
1818
|
async getInfo() {
|
|
1700
1819
|
try {
|
|
@@ -1905,6 +2024,7 @@ CREATE TABLE IF NOT EXISTS zerobucket_images (
|
|
|
1905
2024
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
|
|
1906
2025
|
var TIERING_CHECK = "chk_zerobucket_storage";
|
|
1907
2026
|
var MIGRATE_LOCK = "zerobucket_migrate";
|
|
2027
|
+
var MIGRATE_DEDUP_LOCK = "zerobucket_migrate_dedup";
|
|
1908
2028
|
var COLS2 = `id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, storage_backend, object_storage_bucket, object_storage_key`;
|
|
1909
2029
|
var INSERT3 = `INSERT INTO zerobucket_images (id, data, mime_type, original_filename, size_bytes, width, height, checksum_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
1910
2030
|
var SELECT_FULL3 = `SELECT ${COLS2} FROM zerobucket_images WHERE id = ?`;
|
|
@@ -2264,15 +2384,31 @@ var MySQLBackend = class {
|
|
|
2264
2384
|
/**
|
|
2265
2385
|
* Run `work` on a pooled connection. `tx` wraps it in a transaction. Transient failures retry the
|
|
2266
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.
|
|
2267
2394
|
*/
|
|
2268
|
-
async runPooled(operation, work, tx) {
|
|
2395
|
+
async runPooled(operation, work, tx, lock) {
|
|
2269
2396
|
const start = performance.now();
|
|
2270
2397
|
let attempt = 0;
|
|
2271
2398
|
for (; ; ) {
|
|
2272
2399
|
let conn;
|
|
2273
2400
|
let destroy = false;
|
|
2401
|
+
let held = false;
|
|
2274
2402
|
try {
|
|
2275
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
|
+
}
|
|
2276
2412
|
if (tx) await conn.beginTransaction();
|
|
2277
2413
|
const result = await work(conn);
|
|
2278
2414
|
if (tx) await conn.commit();
|
|
@@ -2294,6 +2430,13 @@ var MySQLBackend = class {
|
|
|
2294
2430
|
}
|
|
2295
2431
|
} finally {
|
|
2296
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
|
+
}
|
|
2297
2440
|
if (destroy) conn.destroy();
|
|
2298
2441
|
else conn.release();
|
|
2299
2442
|
}
|
|
@@ -2307,7 +2450,10 @@ var MySQLBackend = class {
|
|
|
2307
2450
|
* connection given -> run once on it; no retry, and no commit/rollback of the caller's transaction.
|
|
2308
2451
|
* connection absent -> pooled, retried (see runPooled).
|
|
2309
2452
|
*/
|
|
2310
|
-
async run(operation, connection, work, {
|
|
2453
|
+
async run(operation, connection, work, {
|
|
2454
|
+
tx = false,
|
|
2455
|
+
lock
|
|
2456
|
+
} = {}) {
|
|
2311
2457
|
if (connection) {
|
|
2312
2458
|
if (!isMysqlConnection(connection)) {
|
|
2313
2459
|
throw new TypeError(
|
|
@@ -2325,7 +2471,7 @@ var MySQLBackend = class {
|
|
|
2325
2471
|
}
|
|
2326
2472
|
}
|
|
2327
2473
|
await this.ready();
|
|
2328
|
-
return this.runPooled(operation, work, tx);
|
|
2474
|
+
return this.runPooled(operation, work, tx, lock);
|
|
2329
2475
|
}
|
|
2330
2476
|
/**
|
|
2331
2477
|
* Make multi-statement work all-or-nothing on a CALLER-supplied connection.
|
|
@@ -2599,6 +2745,139 @@ var MySQLBackend = class {
|
|
|
2599
2745
|
throw wrap("Failed to check image existence", exc);
|
|
2600
2746
|
}
|
|
2601
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
|
+
}
|
|
2602
2881
|
// ---- maintenance (CLI) -----------------------------------------------
|
|
2603
2882
|
/** Storage statistics, or null if the tables do not exist yet (nothing is created). */
|
|
2604
2883
|
async getInfo() {
|
|
@@ -3000,7 +3279,7 @@ Usage: zerobucket <command> [options]
|
|
|
3000
3279
|
Commands:
|
|
3001
3280
|
init Create the schema (tables and indexes) if missing.
|
|
3002
3281
|
migrate Apply schema migrations (currently: same as init).
|
|
3003
|
-
--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)
|
|
3004
3283
|
--skip-tiered with --to-dedup: skip images tiered to object storage instead of refusing
|
|
3005
3284
|
info Show storage stats: count, size, breakdown by format.
|
|
3006
3285
|
verify Re-checksum stored images to detect corruption.
|
|
@@ -3167,8 +3446,7 @@ async function cmdMigrate(v, env, io) {
|
|
|
3167
3446
|
throw usageError("Error: --skip-tiered only applies with --to-dedup.");
|
|
3168
3447
|
if (v["to-dedup"]) {
|
|
3169
3448
|
const target = resolveTarget(v, env);
|
|
3170
|
-
|
|
3171
|
-
throw usageError("Error: --to-dedup is only supported for PostgreSQL.");
|
|
3449
|
+
requireSqliteFile(target, "Nothing to migrate.");
|
|
3172
3450
|
const backend = openBackend(target, { dedup: true, autoMigrate: true });
|
|
3173
3451
|
await connect(backend, "connect");
|
|
3174
3452
|
try {
|