zerobucket 0.7.0 → 0.8.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 +31 -12
- package/dist/bin.js +984 -5
- package/dist/index.cjs +278 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +278 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -295,7 +295,11 @@ interface MySQLBackendOptions {
|
|
|
295
295
|
onOperation?: (event: OperationEvent) => void;
|
|
296
296
|
/** Enables transparent reads of tiered rows and `tierToObjectStorage()`. */
|
|
297
297
|
objectStorage?: ObjectStorageLike;
|
|
298
|
-
/**
|
|
298
|
+
/**
|
|
299
|
+
* Content-addressed storage: identical bytes are stored once and shared by every image that has them
|
|
300
|
+
* (reference counted). Same tables as the Python package's dedup mode. Cannot be combined with
|
|
301
|
+
* `objectStorage` (a blob may be shared by many images).
|
|
302
|
+
*/
|
|
299
303
|
dedup?: boolean;
|
|
300
304
|
}
|
|
301
305
|
declare class MySQLBackend implements StorageBackend {
|
|
@@ -308,6 +312,8 @@ declare class MySQLBackend implements StorageBackend {
|
|
|
308
312
|
private readonly retryBaseDelayMs;
|
|
309
313
|
private readonly poolTimeoutMs;
|
|
310
314
|
private readonly autoMigrate;
|
|
315
|
+
private readonly dedup;
|
|
316
|
+
private readonly sql;
|
|
311
317
|
constructor(opts: MySQLBackendOptions);
|
|
312
318
|
ready(): Promise<void>;
|
|
313
319
|
private init;
|
|
@@ -317,6 +323,8 @@ declare class MySQLBackend implements StorageBackend {
|
|
|
317
323
|
* re-running is a no-op.
|
|
318
324
|
*/
|
|
319
325
|
private migrate;
|
|
326
|
+
/** Dedup tables, created only if missing (same no-needless-DDL rule as the classic table). */
|
|
327
|
+
private migrateDedup;
|
|
320
328
|
close(): Promise<void>;
|
|
321
329
|
private emit;
|
|
322
330
|
/** Wait for a pool slot, but never longer than poolTimeoutMs. */
|
|
@@ -351,6 +359,17 @@ declare class MySQLBackend implements StorageBackend {
|
|
|
351
359
|
delete(id: string, { connection }?: CallOptions): Promise<boolean>;
|
|
352
360
|
deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
|
|
353
361
|
exists(id: string, { connection }?: CallOptions): Promise<boolean>;
|
|
362
|
+
/** Storage statistics, or null if the tables do not exist yet (nothing is created). */
|
|
363
|
+
getInfo(): Promise<StorageInfo | null>;
|
|
364
|
+
/** Ids matching the filters: oldest first, or a random sample. Used by the CLI's verify and tier. */
|
|
365
|
+
listImages(o?: ImageListOptions): Promise<ImageListEntry[]>;
|
|
366
|
+
/**
|
|
367
|
+
* Lock the ref row, delete it, then decrement its blob (the UPDATE takes the blob row lock, so two
|
|
368
|
+
* deletes of different refs to one blob serialize instead of racing) and remove the blob once nothing
|
|
369
|
+
* references it. All in one transaction: a failure can never leave a ref without its count or the reverse.
|
|
370
|
+
*/
|
|
371
|
+
private deleteDedup;
|
|
372
|
+
private deleteManyDedup;
|
|
354
373
|
private hydrate;
|
|
355
374
|
/**
|
|
356
375
|
* Remove tiered objects AFTER their rows are gone: once the row is deleted the image is correctly
|
package/dist/index.d.ts
CHANGED
|
@@ -295,7 +295,11 @@ interface MySQLBackendOptions {
|
|
|
295
295
|
onOperation?: (event: OperationEvent) => void;
|
|
296
296
|
/** Enables transparent reads of tiered rows and `tierToObjectStorage()`. */
|
|
297
297
|
objectStorage?: ObjectStorageLike;
|
|
298
|
-
/**
|
|
298
|
+
/**
|
|
299
|
+
* Content-addressed storage: identical bytes are stored once and shared by every image that has them
|
|
300
|
+
* (reference counted). Same tables as the Python package's dedup mode. Cannot be combined with
|
|
301
|
+
* `objectStorage` (a blob may be shared by many images).
|
|
302
|
+
*/
|
|
299
303
|
dedup?: boolean;
|
|
300
304
|
}
|
|
301
305
|
declare class MySQLBackend implements StorageBackend {
|
|
@@ -308,6 +312,8 @@ declare class MySQLBackend implements StorageBackend {
|
|
|
308
312
|
private readonly retryBaseDelayMs;
|
|
309
313
|
private readonly poolTimeoutMs;
|
|
310
314
|
private readonly autoMigrate;
|
|
315
|
+
private readonly dedup;
|
|
316
|
+
private readonly sql;
|
|
311
317
|
constructor(opts: MySQLBackendOptions);
|
|
312
318
|
ready(): Promise<void>;
|
|
313
319
|
private init;
|
|
@@ -317,6 +323,8 @@ declare class MySQLBackend implements StorageBackend {
|
|
|
317
323
|
* re-running is a no-op.
|
|
318
324
|
*/
|
|
319
325
|
private migrate;
|
|
326
|
+
/** Dedup tables, created only if missing (same no-needless-DDL rule as the classic table). */
|
|
327
|
+
private migrateDedup;
|
|
320
328
|
close(): Promise<void>;
|
|
321
329
|
private emit;
|
|
322
330
|
/** Wait for a pool slot, but never longer than poolTimeoutMs. */
|
|
@@ -351,6 +359,17 @@ declare class MySQLBackend implements StorageBackend {
|
|
|
351
359
|
delete(id: string, { connection }?: CallOptions): Promise<boolean>;
|
|
352
360
|
deleteMany(ids: string[], { connection }?: CallOptions): Promise<string[]>;
|
|
353
361
|
exists(id: string, { connection }?: CallOptions): Promise<boolean>;
|
|
362
|
+
/** Storage statistics, or null if the tables do not exist yet (nothing is created). */
|
|
363
|
+
getInfo(): Promise<StorageInfo | null>;
|
|
364
|
+
/** Ids matching the filters: oldest first, or a random sample. Used by the CLI's verify and tier. */
|
|
365
|
+
listImages(o?: ImageListOptions): Promise<ImageListEntry[]>;
|
|
366
|
+
/**
|
|
367
|
+
* Lock the ref row, delete it, then decrement its blob (the UPDATE takes the blob row lock, so two
|
|
368
|
+
* deletes of different refs to one blob serialize instead of racing) and remove the blob once nothing
|
|
369
|
+
* references it. All in one transaction: a failure can never leave a ref without its count or the reverse.
|
|
370
|
+
*/
|
|
371
|
+
private deleteDedup;
|
|
372
|
+
private deleteManyDedup;
|
|
354
373
|
private hydrate;
|
|
355
374
|
/**
|
|
356
375
|
* Remove tiered objects AFTER their rows are gone: once the row is deleted the image is correctly
|
package/dist/index.js
CHANGED
|
@@ -1909,11 +1909,60 @@ var CHUNK2 = `SELECT SUBSTRING(data FROM ? FOR ?) AS chunk FROM zerobucket_image
|
|
|
1909
1909
|
var EXISTS3 = `SELECT 1 FROM zerobucket_images WHERE id = ?`;
|
|
1910
1910
|
var LOCK_ONE = `SELECT storage_backend, object_storage_key FROM zerobucket_images WHERE id = ? FOR UPDATE`;
|
|
1911
1911
|
var DELETE_ONE2 = `DELETE FROM zerobucket_images WHERE id = ?`;
|
|
1912
|
-
var LOCK_MANY = `SELECT id, storage_backend, object_storage_key FROM zerobucket_images WHERE id IN (?) ORDER BY id FOR UPDATE`;
|
|
1913
|
-
var DELETE_MANY2 = `DELETE FROM zerobucket_images WHERE id IN (?)`;
|
|
1912
|
+
var LOCK_MANY = `SELECT id, storage_backend, object_storage_key FROM zerobucket_images FORCE INDEX (PRIMARY) WHERE id IN (?) ORDER BY id FOR UPDATE`;
|
|
1914
1913
|
var SELECT_FOR_TIERING3 = `SELECT data, mime_type, size_bytes, storage_backend FROM zerobucket_images WHERE id = ? FOR UPDATE`;
|
|
1915
1914
|
var UPDATE_AFTER_TIERING3 = `UPDATE zerobucket_images SET data = NULL, storage_backend = 'object_storage', object_storage_bucket = ?, object_storage_key = ?, updated_at = ? WHERE id = ?`;
|
|
1916
1915
|
var LOCAL = "mysql";
|
|
1916
|
+
var DEDUP_BLOBS_SCHEMA = `
|
|
1917
|
+
CREATE TABLE IF NOT EXISTS zerobucket_blobs (
|
|
1918
|
+
checksum_sha256 CHAR(64) PRIMARY KEY,
|
|
1919
|
+
data LONGBLOB NOT NULL,
|
|
1920
|
+
mime_type VARCHAR(255) NOT NULL,
|
|
1921
|
+
size_bytes INT NOT NULL,
|
|
1922
|
+
width INT,
|
|
1923
|
+
height INT,
|
|
1924
|
+
ref_count INT NOT NULL DEFAULT 0,
|
|
1925
|
+
created_at DATETIME(6) NOT NULL
|
|
1926
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
|
|
1927
|
+
var DEDUP_REFS_SCHEMA = `
|
|
1928
|
+
CREATE TABLE IF NOT EXISTS zerobucket_image_refs (
|
|
1929
|
+
id CHAR(36) PRIMARY KEY,
|
|
1930
|
+
checksum_sha256 CHAR(64) NOT NULL,
|
|
1931
|
+
original_filename TEXT,
|
|
1932
|
+
created_at DATETIME(6) NOT NULL,
|
|
1933
|
+
updated_at DATETIME(6) NOT NULL,
|
|
1934
|
+
INDEX idx_zerobucket_image_refs_checksum (checksum_sha256),
|
|
1935
|
+
INDEX idx_zerobucket_image_refs_created_at (created_at),
|
|
1936
|
+
FOREIGN KEY (checksum_sha256) REFERENCES zerobucket_blobs(checksum_sha256)
|
|
1937
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`;
|
|
1938
|
+
function upsertBlobSql(n) {
|
|
1939
|
+
if (!Number.isInteger(n) || n < 1) throw new RangeError("invalid ref count");
|
|
1940
|
+
return `INSERT INTO zerobucket_blobs (checksum_sha256, data, mime_type, size_bytes, width, height, ref_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ${n}, ?) ON DUPLICATE KEY UPDATE ref_count = ref_count + ${n}`;
|
|
1941
|
+
}
|
|
1942
|
+
var D_INSERT_REF3 = `INSERT INTO zerobucket_image_refs (id, checksum_sha256, original_filename, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`;
|
|
1943
|
+
var D_COLS3 = `r.id, b.data, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256, '${LOCAL}' AS storage_backend, NULL AS object_storage_bucket, NULL AS object_storage_key`;
|
|
1944
|
+
var D_FROM3 = `FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256`;
|
|
1945
|
+
var D_LOCK_REF = `SELECT checksum_sha256 FROM zerobucket_image_refs WHERE id = ? FOR UPDATE`;
|
|
1946
|
+
var D_DELETE_REF3 = `DELETE FROM zerobucket_image_refs WHERE id = ?`;
|
|
1947
|
+
var D_LOCK_REFS = `SELECT id, checksum_sha256 FROM zerobucket_image_refs FORCE INDEX (PRIMARY) WHERE id IN (?) ORDER BY id FOR UPDATE`;
|
|
1948
|
+
var D_DECREMENT3 = `UPDATE zerobucket_blobs SET ref_count = ref_count - ? WHERE checksum_sha256 = ?`;
|
|
1949
|
+
var D_DELETE_EMPTY3 = `DELETE FROM zerobucket_blobs WHERE checksum_sha256 = ? AND ref_count <= 0`;
|
|
1950
|
+
var CLASSIC_SQL2 = {
|
|
1951
|
+
selectFull: SELECT_FULL3,
|
|
1952
|
+
selectMany: SELECT_MANY2,
|
|
1953
|
+
selectMetadata: SELECT_METADATA3,
|
|
1954
|
+
streamInfo: STREAM_INFO2,
|
|
1955
|
+
chunk: CHUNK2,
|
|
1956
|
+
exists: EXISTS3
|
|
1957
|
+
};
|
|
1958
|
+
var DEDUP_SQL2 = {
|
|
1959
|
+
selectFull: `SELECT ${D_COLS3} ${D_FROM3} WHERE r.id = ?`,
|
|
1960
|
+
selectMany: `SELECT ${D_COLS3} ${D_FROM3} WHERE r.id IN (?)`,
|
|
1961
|
+
selectMetadata: `SELECT r.id, b.mime_type, r.original_filename, b.size_bytes, b.width, b.height, r.checksum_sha256 ${D_FROM3} WHERE r.id = ?`,
|
|
1962
|
+
streamInfo: `SELECT b.size_bytes, '${LOCAL}' AS storage_backend, NULL AS object_storage_key ${D_FROM3} WHERE r.id = ?`,
|
|
1963
|
+
chunk: `SELECT SUBSTRING(b.data FROM ? FOR ?) AS chunk ${D_FROM3} WHERE r.id = ?`,
|
|
1964
|
+
exists: `SELECT 1 FROM zerobucket_image_refs WHERE id = ?`
|
|
1965
|
+
};
|
|
1917
1966
|
var RETRYABLE_ERRNOS = /* @__PURE__ */ new Set([
|
|
1918
1967
|
1205,
|
|
1919
1968
|
// ER_LOCK_WAIT_TIMEOUT
|
|
@@ -2003,11 +2052,13 @@ var MySQLBackend = class {
|
|
|
2003
2052
|
"MySQLBackend requires either `connectionString` or `pool`"
|
|
2004
2053
|
);
|
|
2005
2054
|
}
|
|
2006
|
-
|
|
2055
|
+
this.dedup = opts.dedup ?? false;
|
|
2056
|
+
if (this.dedup && opts.objectStorage) {
|
|
2007
2057
|
throw new TypeError(
|
|
2008
|
-
"
|
|
2058
|
+
"objectStorage is not supported together with dedup: true (same restriction as the Python package). Tiering moves one image's bytes, but a deduplicated blob can be shared by many images."
|
|
2009
2059
|
);
|
|
2010
2060
|
}
|
|
2061
|
+
this.sql = this.dedup ? DEDUP_SQL2 : CLASSIC_SQL2;
|
|
2011
2062
|
this.ownsPool = !opts.pool;
|
|
2012
2063
|
this.pool = opts.pool;
|
|
2013
2064
|
this.maxRetries = opts.maxRetries ?? 3;
|
|
@@ -2024,6 +2075,8 @@ var MySQLBackend = class {
|
|
|
2024
2075
|
retryBaseDelayMs;
|
|
2025
2076
|
poolTimeoutMs;
|
|
2026
2077
|
autoMigrate;
|
|
2078
|
+
dedup;
|
|
2079
|
+
sql;
|
|
2027
2080
|
// ---- lifecycle -------------------------------------------------------
|
|
2028
2081
|
ready() {
|
|
2029
2082
|
if (this.closed)
|
|
@@ -2089,6 +2142,10 @@ var MySQLBackend = class {
|
|
|
2089
2142
|
);
|
|
2090
2143
|
}
|
|
2091
2144
|
try {
|
|
2145
|
+
if (this.dedup) {
|
|
2146
|
+
await this.migrateDedup(q);
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2092
2149
|
const tableExists = (await q.query(
|
|
2093
2150
|
"SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'zerobucket_images'"
|
|
2094
2151
|
))[0].length > 0;
|
|
@@ -2130,6 +2187,16 @@ var MySQLBackend = class {
|
|
|
2130
2187
|
});
|
|
2131
2188
|
}
|
|
2132
2189
|
}
|
|
2190
|
+
/** Dedup tables, created only if missing (same no-needless-DDL rule as the classic table). */
|
|
2191
|
+
async migrateDedup(q) {
|
|
2192
|
+
const have = new Set(
|
|
2193
|
+
(await q.query(
|
|
2194
|
+
"SELECT TABLE_NAME AS t FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('zerobucket_blobs', 'zerobucket_image_refs')"
|
|
2195
|
+
))[0].map((r) => r.t)
|
|
2196
|
+
);
|
|
2197
|
+
if (!have.has("zerobucket_blobs")) await q.query(DEDUP_BLOBS_SCHEMA);
|
|
2198
|
+
if (!have.has("zerobucket_image_refs")) await q.query(DEDUP_REFS_SCHEMA);
|
|
2199
|
+
}
|
|
2133
2200
|
async close() {
|
|
2134
2201
|
this.closed = true;
|
|
2135
2202
|
const pool = this.pool;
|
|
@@ -2295,6 +2362,19 @@ var MySQLBackend = class {
|
|
|
2295
2362
|
async put(row, { connection } = {}) {
|
|
2296
2363
|
try {
|
|
2297
2364
|
const id = randomUUID2();
|
|
2365
|
+
if (this.dedup) {
|
|
2366
|
+
await this.run(
|
|
2367
|
+
"put",
|
|
2368
|
+
connection,
|
|
2369
|
+
async (q) => {
|
|
2370
|
+
const now = mysqlNow();
|
|
2371
|
+
await q.execute(upsertBlobSql(1), blobParams2(row, now));
|
|
2372
|
+
await q.execute(D_INSERT_REF3, refParams(id, row, now));
|
|
2373
|
+
},
|
|
2374
|
+
{ tx: true }
|
|
2375
|
+
);
|
|
2376
|
+
return id;
|
|
2377
|
+
}
|
|
2298
2378
|
await this.run("put", connection, async (q) => {
|
|
2299
2379
|
await q.execute(INSERT3, rowParams2(id, row, mysqlNow()));
|
|
2300
2380
|
});
|
|
@@ -2312,6 +2392,21 @@ var MySQLBackend = class {
|
|
|
2312
2392
|
connection,
|
|
2313
2393
|
async (q) => {
|
|
2314
2394
|
const now = mysqlNow();
|
|
2395
|
+
if (this.dedup) {
|
|
2396
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2397
|
+
for (const r of rows) {
|
|
2398
|
+
const g = groups.get(r.checksumSha256);
|
|
2399
|
+
if (g) g.n += 1;
|
|
2400
|
+
else groups.set(r.checksumSha256, { row: r, n: 1 });
|
|
2401
|
+
}
|
|
2402
|
+
for (const checksum of [...groups.keys()].sort()) {
|
|
2403
|
+
const g = groups.get(checksum);
|
|
2404
|
+
await q.execute(upsertBlobSql(g.n), blobParams2(g.row, now));
|
|
2405
|
+
}
|
|
2406
|
+
for (let i = 0; i < rows.length; i++)
|
|
2407
|
+
await q.execute(D_INSERT_REF3, refParams(ids[i], rows[i], now));
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2315
2410
|
for (let i = 0; i < rows.length; i++)
|
|
2316
2411
|
await q.execute(INSERT3, rowParams2(ids[i], rows[i], now));
|
|
2317
2412
|
},
|
|
@@ -2329,7 +2424,7 @@ var MySQLBackend = class {
|
|
|
2329
2424
|
row = await this.run(
|
|
2330
2425
|
"get",
|
|
2331
2426
|
connection,
|
|
2332
|
-
async (q) => (await q.query(
|
|
2427
|
+
async (q) => (await q.query(this.sql.selectFull, [id]))[0][0]
|
|
2333
2428
|
);
|
|
2334
2429
|
} catch (exc) {
|
|
2335
2430
|
throw wrap("Failed to retrieve image", exc);
|
|
@@ -2344,7 +2439,7 @@ var MySQLBackend = class {
|
|
|
2344
2439
|
rows = await this.run(
|
|
2345
2440
|
"get_many",
|
|
2346
2441
|
connection,
|
|
2347
|
-
async (q) => (await q.query(
|
|
2442
|
+
async (q) => (await q.query(this.sql.selectMany, [valid]))[0]
|
|
2348
2443
|
);
|
|
2349
2444
|
} catch (exc) {
|
|
2350
2445
|
throw wrap("Failed to retrieve image batch", exc);
|
|
@@ -2358,7 +2453,7 @@ var MySQLBackend = class {
|
|
|
2358
2453
|
row = await this.run(
|
|
2359
2454
|
"get_metadata",
|
|
2360
2455
|
connection,
|
|
2361
|
-
async (q) => (await q.query(
|
|
2456
|
+
async (q) => (await q.query(this.sql.selectMetadata, [id]))[0][0]
|
|
2362
2457
|
);
|
|
2363
2458
|
} catch (exc) {
|
|
2364
2459
|
throw wrap("Failed to retrieve image metadata", exc);
|
|
@@ -2384,7 +2479,7 @@ var MySQLBackend = class {
|
|
|
2384
2479
|
info = await this.run(
|
|
2385
2480
|
"get_metadata",
|
|
2386
2481
|
connection,
|
|
2387
|
-
async (q) => (await q.query(
|
|
2482
|
+
async (q) => (await q.query(this.sql.streamInfo, [id]))[0][0]
|
|
2388
2483
|
);
|
|
2389
2484
|
} catch (exc) {
|
|
2390
2485
|
throw wrap("Failed to retrieve image metadata", exc);
|
|
@@ -2417,7 +2512,7 @@ var MySQLBackend = class {
|
|
|
2417
2512
|
row = await self.run(
|
|
2418
2513
|
"get_stream",
|
|
2419
2514
|
connection,
|
|
2420
|
-
async (q) => (await q.query(
|
|
2515
|
+
async (q) => (await q.query(self.sql.chunk, [offset + 1, length, id]))[0][0]
|
|
2421
2516
|
);
|
|
2422
2517
|
} catch (exc) {
|
|
2423
2518
|
throw wrap("Failed to stream image", exc);
|
|
@@ -2442,6 +2537,7 @@ var MySQLBackend = class {
|
|
|
2442
2537
|
}
|
|
2443
2538
|
async delete(id, { connection } = {}) {
|
|
2444
2539
|
if (!isValidId(id)) return false;
|
|
2540
|
+
if (this.dedup) return this.deleteDedup(id, connection);
|
|
2445
2541
|
try {
|
|
2446
2542
|
const row = await this.run(
|
|
2447
2543
|
"delete",
|
|
@@ -2464,14 +2560,14 @@ var MySQLBackend = class {
|
|
|
2464
2560
|
async deleteMany(ids, { connection } = {}) {
|
|
2465
2561
|
const valid = [...new Set(ids.filter(isValidId))];
|
|
2466
2562
|
if (valid.length === 0) return [];
|
|
2563
|
+
if (this.dedup) return this.deleteManyDedup(valid, connection);
|
|
2467
2564
|
try {
|
|
2468
2565
|
const rows = await this.run(
|
|
2469
2566
|
"delete_many",
|
|
2470
2567
|
connection,
|
|
2471
2568
|
async (q) => {
|
|
2472
2569
|
const found = (await q.query(LOCK_MANY, [valid]))[0];
|
|
2473
|
-
|
|
2474
|
-
await q.query(DELETE_MANY2, [found.map((r) => r.id)]);
|
|
2570
|
+
for (const r of found) await q.query(DELETE_ONE2, [r.id]);
|
|
2475
2571
|
return found;
|
|
2476
2572
|
},
|
|
2477
2573
|
{ tx: true }
|
|
@@ -2491,12 +2587,163 @@ var MySQLBackend = class {
|
|
|
2491
2587
|
return await this.run(
|
|
2492
2588
|
"exists",
|
|
2493
2589
|
connection,
|
|
2494
|
-
async (q) => (await q.query(
|
|
2590
|
+
async (q) => (await q.query(this.sql.exists, [id]))[0].length > 0
|
|
2495
2591
|
);
|
|
2496
2592
|
} catch (exc) {
|
|
2497
2593
|
throw wrap("Failed to check image existence", exc);
|
|
2498
2594
|
}
|
|
2499
2595
|
}
|
|
2596
|
+
// ---- maintenance (CLI) -----------------------------------------------
|
|
2597
|
+
/** Storage statistics, or null if the tables do not exist yet (nothing is created). */
|
|
2598
|
+
async getInfo() {
|
|
2599
|
+
try {
|
|
2600
|
+
return await this.run("info", void 0, async (q) => {
|
|
2601
|
+
const need = this.dedup ? ["zerobucket_image_refs", "zerobucket_blobs"] : ["zerobucket_images"];
|
|
2602
|
+
const present = new Set(
|
|
2603
|
+
(await q.query(
|
|
2604
|
+
"SELECT TABLE_NAME AS t FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)",
|
|
2605
|
+
[need]
|
|
2606
|
+
))[0].map((r) => r.t)
|
|
2607
|
+
);
|
|
2608
|
+
if (!need.every((t) => present.has(t))) return null;
|
|
2609
|
+
const j = this.dedup ? "FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256" : "FROM zerobucket_images";
|
|
2610
|
+
const size = this.dedup ? "b.size_bytes" : "size_bytes";
|
|
2611
|
+
const created = this.dedup ? "r.created_at" : "created_at";
|
|
2612
|
+
const mime = this.dedup ? "b.mime_type" : "mime_type";
|
|
2613
|
+
const tot = (await q.query(
|
|
2614
|
+
`SELECT COUNT(*) AS n, COALESCE(SUM(${size}), 0) AS bytes, CAST(MIN(${created}) AS CHAR) AS oldest, CAST(MAX(${created}) AS CHAR) AS newest ${j}`
|
|
2615
|
+
))[0][0];
|
|
2616
|
+
const disk = (await q.query(
|
|
2617
|
+
"SELECT COALESCE(SUM(DATA_LENGTH + INDEX_LENGTH), 0) AS s FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)",
|
|
2618
|
+
[need]
|
|
2619
|
+
))[0][0];
|
|
2620
|
+
const fmt = (await q.query(
|
|
2621
|
+
`SELECT ${mime} AS m, COUNT(*) AS n, COALESCE(SUM(${size}), 0) AS bytes ${j} GROUP BY ${mime} ORDER BY COUNT(*) DESC, ${mime}`
|
|
2622
|
+
))[0];
|
|
2623
|
+
const utc = (v) => v ? `${v}+00:00` : null;
|
|
2624
|
+
const info = {
|
|
2625
|
+
mode: this.dedup ? "dedup" : "classic",
|
|
2626
|
+
count: Number(tot.n),
|
|
2627
|
+
totalBytes: Number(tot.bytes),
|
|
2628
|
+
oldest: utc(tot.oldest),
|
|
2629
|
+
newest: utc(tot.newest),
|
|
2630
|
+
onDisk: prettySize(Number(disk.s)),
|
|
2631
|
+
byFormat: fmt.map((r) => ({
|
|
2632
|
+
mimeType: r.m,
|
|
2633
|
+
count: Number(r.n),
|
|
2634
|
+
bytes: Number(r.bytes)
|
|
2635
|
+
}))
|
|
2636
|
+
};
|
|
2637
|
+
if (this.dedup) {
|
|
2638
|
+
const b = (await q.query(
|
|
2639
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes FROM zerobucket_blobs"
|
|
2640
|
+
))[0][0];
|
|
2641
|
+
info.dedup = { blobs: Number(b.n), storedBytes: Number(b.bytes) };
|
|
2642
|
+
} else {
|
|
2643
|
+
const t = (await q.query(
|
|
2644
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes FROM zerobucket_images WHERE storage_backend = 'object_storage'"
|
|
2645
|
+
))[0][0];
|
|
2646
|
+
info.tiered = { count: Number(t.n), bytes: Number(t.bytes) };
|
|
2647
|
+
}
|
|
2648
|
+
return info;
|
|
2649
|
+
});
|
|
2650
|
+
} catch (exc) {
|
|
2651
|
+
throw wrap("Failed to read storage info", exc);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
/** Ids matching the filters: oldest first, or a random sample. Used by the CLI's verify and tier. */
|
|
2655
|
+
async listImages(o = {}) {
|
|
2656
|
+
const where = [];
|
|
2657
|
+
const params = [];
|
|
2658
|
+
const created = this.dedup ? "r.created_at" : "created_at";
|
|
2659
|
+
if (o.minSize !== void 0) {
|
|
2660
|
+
where.push(`${this.dedup ? "b.size_bytes" : "size_bytes"} >= ?`);
|
|
2661
|
+
params.push(o.minSize);
|
|
2662
|
+
}
|
|
2663
|
+
if (o.olderThanDays !== void 0) {
|
|
2664
|
+
where.push(`${created} <= UTC_TIMESTAMP(6) - INTERVAL ? DAY`);
|
|
2665
|
+
params.push(o.olderThanDays);
|
|
2666
|
+
}
|
|
2667
|
+
if (o.onlyUntiered && !this.dedup)
|
|
2668
|
+
where.push(`storage_backend = '${LOCAL}'`);
|
|
2669
|
+
const from = this.dedup ? "FROM zerobucket_image_refs r JOIN zerobucket_blobs b ON r.checksum_sha256 = b.checksum_sha256" : "FROM zerobucket_images";
|
|
2670
|
+
const idCol = this.dedup ? "r.id" : "id";
|
|
2671
|
+
const tierCol = this.dedup ? "0 AS tiered" : "(storage_backend = 'object_storage') AS tiered";
|
|
2672
|
+
const order = o.sample !== void 0 ? "ORDER BY RAND()" : `ORDER BY ${created}, ${idCol}`;
|
|
2673
|
+
const lim = o.sample ?? o.limit;
|
|
2674
|
+
let sql = `SELECT ${idCol} AS id, ${tierCol} ${from}${where.length ? " WHERE " + where.join(" AND ") : ""} ${order}`;
|
|
2675
|
+
if (lim !== void 0) {
|
|
2676
|
+
if (!Number.isInteger(lim) || lim < 0)
|
|
2677
|
+
throw new RangeError("limit/sample must be a non-negative integer");
|
|
2678
|
+
sql += ` LIMIT ${lim}`;
|
|
2679
|
+
}
|
|
2680
|
+
try {
|
|
2681
|
+
const rows = await this.run(
|
|
2682
|
+
"list_images",
|
|
2683
|
+
void 0,
|
|
2684
|
+
async (q) => (await q.query(sql, params))[0]
|
|
2685
|
+
);
|
|
2686
|
+
return rows.map((r) => ({
|
|
2687
|
+
id: String(r.id),
|
|
2688
|
+
tiered: Boolean(Number(r.tiered))
|
|
2689
|
+
}));
|
|
2690
|
+
} catch (exc) {
|
|
2691
|
+
throw wrap("Failed to list images", exc);
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
// ---- dedup deletes ---------------------------------------------------
|
|
2695
|
+
/**
|
|
2696
|
+
* Lock the ref row, delete it, then decrement its blob (the UPDATE takes the blob row lock, so two
|
|
2697
|
+
* deletes of different refs to one blob serialize instead of racing) and remove the blob once nothing
|
|
2698
|
+
* references it. All in one transaction: a failure can never leave a ref without its count or the reverse.
|
|
2699
|
+
*/
|
|
2700
|
+
async deleteDedup(id, connection) {
|
|
2701
|
+
try {
|
|
2702
|
+
return await this.run(
|
|
2703
|
+
"delete",
|
|
2704
|
+
connection,
|
|
2705
|
+
async (q) => {
|
|
2706
|
+
const ref = (await q.query(D_LOCK_REF, [id]))[0][0];
|
|
2707
|
+
if (!ref) return false;
|
|
2708
|
+
const checksum = ref.checksum_sha256;
|
|
2709
|
+
await q.query(D_DELETE_REF3, [id]);
|
|
2710
|
+
await q.query(D_DECREMENT3, [1, checksum]);
|
|
2711
|
+
await q.query(D_DELETE_EMPTY3, [checksum]);
|
|
2712
|
+
return true;
|
|
2713
|
+
},
|
|
2714
|
+
{ tx: true }
|
|
2715
|
+
);
|
|
2716
|
+
} catch (exc) {
|
|
2717
|
+
throw wrap("Failed to delete image", exc);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
async deleteManyDedup(ids, connection) {
|
|
2721
|
+
try {
|
|
2722
|
+
return await this.run(
|
|
2723
|
+
"delete_many",
|
|
2724
|
+
connection,
|
|
2725
|
+
async (q) => {
|
|
2726
|
+
const found = (await q.query(D_LOCK_REFS, [ids]))[0];
|
|
2727
|
+
if (found.length === 0) return [];
|
|
2728
|
+
for (const r of found) await q.query(D_DELETE_REF3, [r.id]);
|
|
2729
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2730
|
+
for (const r of found)
|
|
2731
|
+
counts.set(
|
|
2732
|
+
r.checksum_sha256,
|
|
2733
|
+
(counts.get(r.checksum_sha256) ?? 0) + 1
|
|
2734
|
+
);
|
|
2735
|
+
for (const checksum of [...counts.keys()].sort()) {
|
|
2736
|
+
await q.query(D_DECREMENT3, [counts.get(checksum), checksum]);
|
|
2737
|
+
await q.query(D_DELETE_EMPTY3, [checksum]);
|
|
2738
|
+
}
|
|
2739
|
+
return found.map((r) => String(r.id));
|
|
2740
|
+
},
|
|
2741
|
+
{ tx: true }
|
|
2742
|
+
);
|
|
2743
|
+
} catch (exc) {
|
|
2744
|
+
throw wrap("Failed to delete image batch", exc);
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2500
2747
|
// ---- helpers ---------------------------------------------------------
|
|
2501
2748
|
hydrate(row, id) {
|
|
2502
2749
|
return hydrateRow(row, id, this.opts.objectStorage);
|
|
@@ -2543,6 +2790,11 @@ var MySQLBackend = class {
|
|
|
2543
2790
|
* id) makes the re-upload an idempotent overwrite.
|
|
2544
2791
|
*/
|
|
2545
2792
|
async tierToObjectStorage(id, { connection } = {}) {
|
|
2793
|
+
if (this.dedup) {
|
|
2794
|
+
throw new StorageError(
|
|
2795
|
+
"tierToObjectStorage() is not supported in dedup mode (a blob may be shared by many images)."
|
|
2796
|
+
);
|
|
2797
|
+
}
|
|
2546
2798
|
const os = this.opts.objectStorage;
|
|
2547
2799
|
if (!os) {
|
|
2548
2800
|
throw new StorageError(
|
|
@@ -2570,6 +2822,20 @@ var MySQLBackend = class {
|
|
|
2570
2822
|
}
|
|
2571
2823
|
}
|
|
2572
2824
|
};
|
|
2825
|
+
function blobParams2(r, now) {
|
|
2826
|
+
return [
|
|
2827
|
+
r.checksumSha256,
|
|
2828
|
+
r.data,
|
|
2829
|
+
r.mimeType,
|
|
2830
|
+
r.sizeBytes,
|
|
2831
|
+
r.width,
|
|
2832
|
+
r.height,
|
|
2833
|
+
now
|
|
2834
|
+
];
|
|
2835
|
+
}
|
|
2836
|
+
function refParams(id, r, now) {
|
|
2837
|
+
return [id, r.checksumSha256, r.originalFilename, now, now];
|
|
2838
|
+
}
|
|
2573
2839
|
function rowParams2(id, r, now) {
|
|
2574
2840
|
return [
|
|
2575
2841
|
id,
|