orez-lite 0.16.6 → 0.16.7
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 +11 -0
- package/dist/cf-do/lite-data-worker.d.ts +1 -1
- package/dist/cf-do/lite-data-worker.d.ts.map +1 -1
- package/dist/cf-do/lite-data-worker.js +5 -1
- package/dist/cf-do/lite-data-worker.js.map +1 -1
- package/dist/cf-do/namespace-backup.d.ts +18 -0
- package/dist/cf-do/namespace-backup.d.ts.map +1 -1
- package/dist/cf-do/namespace-backup.js +359 -198
- package/dist/cf-do/namespace-backup.js.map +1 -1
- package/dist/client/transport.d.ts +1 -0
- package/dist/client/transport.d.ts.map +1 -1
- package/dist/client/transport.js +4 -1
- package/dist/client/transport.js.map +1 -1
- package/package.json +4 -4
|
@@ -159,6 +159,10 @@ export function createNamespaceBackupManager(options) {
|
|
|
159
159
|
const keepControlPlane = options.keepControlPlane ?? 30;
|
|
160
160
|
const controlPlaneNamespace = options.controlPlaneNamespace ?? 'singleton';
|
|
161
161
|
const runBudgetMs = options.runBudgetMs ?? 10 * 60 * 1000;
|
|
162
|
+
const scanChunkBytes = options.scanChunkBytes ?? partBytes;
|
|
163
|
+
const maxInflightParts = Math.max(1, options.maxInflightParts ?? 4);
|
|
164
|
+
const scanAttempts = Math.max(1, options.scanAttempts ?? 3);
|
|
165
|
+
const chunkAttempts = Math.max(1, options.chunkAttempts ?? 3);
|
|
162
166
|
const excludedTables = new Set(options.excludedTables ?? []);
|
|
163
167
|
const acceptedFormats = new Set([options.format, ...(options.acceptedFormats ?? [])]);
|
|
164
168
|
const backupPrefix = options.prefix ?? ((namespace) => `backups/${namespace.replace(':', '/')}/`);
|
|
@@ -186,227 +190,385 @@ export function createNamespaceBackupManager(options) {
|
|
|
186
190
|
}
|
|
187
191
|
};
|
|
188
192
|
const readMarker = (env, namespace) => readMarkerWith((sql, params = []) => options.query(env, namespace, sql, params));
|
|
189
|
-
const
|
|
190
|
-
|
|
193
|
+
const encoder = new TextEncoder();
|
|
194
|
+
const encodeLine = (value, digested = true) => ({
|
|
195
|
+
bytes: encoder.encode(`${JSON.stringify(value)}\n`),
|
|
196
|
+
digested,
|
|
197
|
+
});
|
|
198
|
+
/**
|
|
199
|
+
* Run one bounded piece of the scan in its own read session, retrying it when
|
|
200
|
+
* a writer preempts the session.
|
|
201
|
+
*
|
|
202
|
+
* `work` re-runs from the start on a retry, so it must return everything it
|
|
203
|
+
* produced rather than publish it, and the caller commits that only once.
|
|
204
|
+
*/
|
|
205
|
+
const readChunk = async (env, namespace, work) => {
|
|
206
|
+
for (let attempt = 0; attempt < chunkAttempts; attempt++) {
|
|
207
|
+
try {
|
|
208
|
+
return {
|
|
209
|
+
outcome: 'read',
|
|
210
|
+
value: await options.readSession(env, namespace, work),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (!(error instanceof ApplicationSqlSessionPreemptedError))
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { outcome: 'preempted' };
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* Read the schema, the write marker, and the primary key of every WITHOUT
|
|
222
|
+
* ROWID table in one session, so the scan below knows what to page and what
|
|
223
|
+
* marker every one of its chunks has to agree with.
|
|
224
|
+
*/
|
|
225
|
+
const readScanSchema = async (read) => {
|
|
226
|
+
// Read before scanning. A concurrent write then leaves the live marker
|
|
227
|
+
// ahead of latest.json and guarantees another backup.
|
|
228
|
+
const marker = await readMarkerWith(read);
|
|
229
|
+
const master = await read("SELECT name, sql, type, tbl_name FROM sqlite_master WHERE type IN ('table', 'index') AND sql IS NOT NULL ORDER BY name", []);
|
|
230
|
+
const unorderedTables = master.filter((row) => row.type === 'table' && !isExcluded(row.name));
|
|
231
|
+
const tableNames = unorderedTables.map((row) => String(row.name));
|
|
232
|
+
const tableNamesBySqlIdentity = tableIdentities(tableNames);
|
|
233
|
+
// sqlite_master already carries every CREATE statement in this bounded
|
|
234
|
+
// schema read. Derive FK edges from those statements instead of asking
|
|
235
|
+
// pragma_foreign_key_list to re-walk the complete schema once per table.
|
|
236
|
+
const dependencies = new Map(unorderedTables.map((table) => [
|
|
237
|
+
String(table.name),
|
|
238
|
+
tableDependencies(table.sql, String(table.name), tableNamesBySqlIdentity),
|
|
239
|
+
]));
|
|
240
|
+
const orderedNames = dependencyOrder(tableNames, dependencies);
|
|
241
|
+
const tableByName = new Map(unorderedTables.map((table) => [String(table.name), table]));
|
|
242
|
+
const indexes = master.filter((row) => row.type === 'index' && !isExcluded(row.name) && !isExcluded(row.tbl_name));
|
|
243
|
+
const tables = [];
|
|
244
|
+
for (const name of orderedNames) {
|
|
245
|
+
const row = tableByName.get(name);
|
|
246
|
+
const sql = String(row.sql);
|
|
247
|
+
const withoutRowid = /\bWITHOUT\s+ROWID\b/i.test(sql);
|
|
248
|
+
const primaryKeyColumns = withoutRowid
|
|
249
|
+
? (await read(`PRAGMA table_info("${quoteIdentifier(name)}")`, []))
|
|
250
|
+
.filter((column) => Number(column.pk) > 0)
|
|
251
|
+
.sort((left, right) => Number(left.pk) - Number(right.pk))
|
|
252
|
+
.map((column) => String(column.name))
|
|
253
|
+
: [];
|
|
254
|
+
if (withoutRowid && primaryKeyColumns.length === 0) {
|
|
255
|
+
throw new Error(`WITHOUT ROWID table ${name} has no primary key`);
|
|
256
|
+
}
|
|
257
|
+
tables.push({
|
|
258
|
+
name,
|
|
259
|
+
sql,
|
|
260
|
+
indexes: indexes
|
|
261
|
+
.filter((index) => index.tbl_name === name)
|
|
262
|
+
.map((index) => String(index.sql)),
|
|
263
|
+
withoutRowid,
|
|
264
|
+
primaryKeyColumns,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return { marker, tables };
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Page rows until this chunk has produced `scanChunkBytes`, inside one
|
|
271
|
+
* session that also reads the marker.
|
|
272
|
+
*
|
|
273
|
+
* No writer is admitted while the session is open, so the marker and the rows
|
|
274
|
+
* describe the same committed state. A marker that no longer matches the one
|
|
275
|
+
* the schema read observed means a transaction landed since the scan started
|
|
276
|
+
* and the pages already collected are from a state that no longer exists.
|
|
277
|
+
*/
|
|
278
|
+
const readScanChunk = (fenceMarker, tables, cursor) => async (read) => {
|
|
279
|
+
const marker = await readMarkerWith(read);
|
|
280
|
+
if (marker !== fenceMarker)
|
|
281
|
+
return { torn: true, marker };
|
|
282
|
+
const lines = [];
|
|
283
|
+
const tableRows = {};
|
|
284
|
+
const next = { ...cursor };
|
|
285
|
+
let produced = 0;
|
|
286
|
+
const openNextTable = () => {
|
|
287
|
+
next.tableIndex++;
|
|
288
|
+
next.tableOpened = false;
|
|
289
|
+
next.rowidCursor = 0;
|
|
290
|
+
next.primaryKeyCursor = null;
|
|
291
|
+
next.limit = 200;
|
|
292
|
+
};
|
|
293
|
+
while (next.tableIndex < tables.length && produced < scanChunkBytes) {
|
|
294
|
+
const table = tables[next.tableIndex];
|
|
295
|
+
if (!next.tableOpened) {
|
|
296
|
+
const line = encodeLine({
|
|
297
|
+
kind: 'table',
|
|
298
|
+
name: table.name,
|
|
299
|
+
sql: table.sql,
|
|
300
|
+
indexes: table.indexes,
|
|
301
|
+
});
|
|
302
|
+
lines.push(line);
|
|
303
|
+
produced += line.bytes.byteLength;
|
|
304
|
+
next.tableOpened = true;
|
|
305
|
+
tableRows[table.name] = tableRows[table.name] ?? 0;
|
|
306
|
+
}
|
|
307
|
+
const quotedPrimaryKey = table.primaryKeyColumns
|
|
308
|
+
.map((column) => `"${quoteIdentifier(column)}"`)
|
|
309
|
+
.join(', ');
|
|
310
|
+
const usedLimit = next.limit;
|
|
311
|
+
const rows = table.withoutRowid
|
|
312
|
+
? await read(next.primaryKeyCursor
|
|
313
|
+
? `SELECT * FROM "${quoteIdentifier(table.name)}" WHERE (${quotedPrimaryKey}) > (${table.primaryKeyColumns.map(() => '?').join(', ')}) ORDER BY ${quotedPrimaryKey} LIMIT ?`
|
|
314
|
+
: `SELECT * FROM "${quoteIdentifier(table.name)}" ORDER BY ${quotedPrimaryKey} LIMIT ?`, next.primaryKeyCursor ? [...next.primaryKeyCursor, usedLimit] : [usedLimit])
|
|
315
|
+
: await read(`SELECT rowid AS __orez_backup_rowid, * FROM "${quoteIdentifier(table.name)}" WHERE rowid > ? ORDER BY rowid LIMIT ?`, [next.rowidCursor, usedLimit]);
|
|
316
|
+
if (rows.length === 0) {
|
|
317
|
+
openNextTable();
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (table.withoutRowid) {
|
|
321
|
+
const last = rows.at(-1);
|
|
322
|
+
next.primaryKeyCursor = table.primaryKeyColumns.map((column) => last[column]);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
next.rowidCursor = rows.at(-1)?.__orez_backup_rowid;
|
|
326
|
+
for (const row of rows)
|
|
327
|
+
delete row.__orez_backup_rowid;
|
|
328
|
+
}
|
|
329
|
+
const line = encodeLine({ kind: 'rows', table: table.name, rows });
|
|
330
|
+
lines.push(line);
|
|
331
|
+
produced += line.bytes.byteLength;
|
|
332
|
+
tableRows[table.name] = (tableRows[table.name] ?? 0) + rows.length;
|
|
333
|
+
const perRow = Math.max(1, Math.ceil(line.bytes.byteLength / rows.length));
|
|
334
|
+
next.limit = Math.max(20, Math.min(1000, Math.floor(chunkTargetBytes / perRow)));
|
|
335
|
+
if (rows.length < usedLimit)
|
|
336
|
+
openNextTable();
|
|
337
|
+
}
|
|
338
|
+
return { torn: false, lines, tableRows, next };
|
|
339
|
+
};
|
|
340
|
+
/**
|
|
341
|
+
* Scan a namespace in short read sessions fenced by the write marker.
|
|
342
|
+
*
|
|
343
|
+
* A single session covering the whole scan has to stay open across every R2
|
|
344
|
+
* upload, and an arriving writer preempts it, so on a busy namespace the
|
|
345
|
+
* export cannot finish: production's control plane lost roughly twenty
|
|
346
|
+
* consecutive attempts that way while every quiet project namespace exported
|
|
347
|
+
* normally.
|
|
348
|
+
*
|
|
349
|
+
* The dump only has to be one state the database actually had, and
|
|
350
|
+
* `write_seq` answers that directly: it advances on every committed
|
|
351
|
+
* application-SQL mutation, which is the same invariant `runScheduledBackups`
|
|
352
|
+
* already trusts when it skips a namespace whose marker has not moved. So
|
|
353
|
+
* each chunk reads the marker and its pages inside one short session, and
|
|
354
|
+
* every chunk has to observe the marker the schema read did. Equal markers at
|
|
355
|
+
* both ends of a monotonic counter means no transaction committed in between,
|
|
356
|
+
* which is the same guarantee the single session bought, without holding the
|
|
357
|
+
* database across the network.
|
|
358
|
+
*
|
|
359
|
+
* Uploads happen between chunks with no session open, and are not awaited
|
|
360
|
+
* until the scan finishes or `maxInflightParts` are outstanding, so R2 stays
|
|
361
|
+
* off the fenced window entirely for a dump that fits in the in-flight bound.
|
|
362
|
+
*/
|
|
363
|
+
const runScanAttempt = async (env, namespace, key, exportedAt) => {
|
|
191
364
|
const files = options.files(env);
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
365
|
+
const schemaChunk = await readChunk(env, namespace, readScanSchema);
|
|
366
|
+
if (schemaChunk.outcome === 'preempted')
|
|
367
|
+
return { outcome: 'preempted' };
|
|
368
|
+
const { marker, tables } = schemaChunk.value;
|
|
369
|
+
const upload = await files.createMultipartUpload(key);
|
|
370
|
+
const partUploads = [];
|
|
371
|
+
let chunks = [];
|
|
372
|
+
let bufferedBytes = 0;
|
|
373
|
+
let totalBytes = 0;
|
|
374
|
+
let rowTotal = 0;
|
|
375
|
+
const tableRows = {};
|
|
376
|
+
const digest = sha256.create();
|
|
377
|
+
// an abort that races an in-flight uploadPart can leave the part behind, and
|
|
378
|
+
// this bucket already carries thousands of orphans. settle first, always.
|
|
379
|
+
const abortUpload = async () => {
|
|
380
|
+
await Promise.allSettled(partUploads);
|
|
381
|
+
await upload.abort().catch(() => { });
|
|
382
|
+
};
|
|
383
|
+
const sendPart = async (value) => {
|
|
384
|
+
const pending = upload.uploadPart(partUploads.length + 1, value);
|
|
385
|
+
// Nothing awaits this until the scan is done, so keep workerd from
|
|
386
|
+
// reporting a rejection that the final Promise.all will surface anyway.
|
|
387
|
+
void pending.catch(() => { });
|
|
388
|
+
partUploads.push(pending);
|
|
389
|
+
const bound = partUploads.length - maxInflightParts;
|
|
390
|
+
if (bound >= 0)
|
|
391
|
+
await partUploads[bound];
|
|
392
|
+
};
|
|
393
|
+
const flushParts = async (final) => {
|
|
394
|
+
if (!final && bufferedBytes < partBytes)
|
|
395
|
+
return;
|
|
396
|
+
let merged = new Uint8Array(bufferedBytes);
|
|
397
|
+
let offset = 0;
|
|
398
|
+
for (const chunk of chunks) {
|
|
399
|
+
merged.set(chunk, offset);
|
|
400
|
+
offset += chunk.byteLength;
|
|
401
|
+
}
|
|
402
|
+
while (merged.byteLength >= partBytes) {
|
|
403
|
+
await sendPart(merged.slice(0, partBytes));
|
|
404
|
+
merged = merged.slice(partBytes);
|
|
405
|
+
}
|
|
406
|
+
if (final && (merged.byteLength > 0 || partUploads.length === 0)) {
|
|
407
|
+
await sendPart(merged);
|
|
408
|
+
merged = new Uint8Array(0);
|
|
409
|
+
}
|
|
410
|
+
chunks = merged.byteLength ? [merged] : [];
|
|
411
|
+
bufferedBytes = merged.byteLength;
|
|
412
|
+
};
|
|
413
|
+
const appendLine = (line) => {
|
|
414
|
+
if (line.digested)
|
|
415
|
+
digest.update(line.bytes);
|
|
416
|
+
chunks.push(line.bytes);
|
|
417
|
+
bufferedBytes += line.bytes.byteLength;
|
|
418
|
+
totalBytes += line.bytes.byteLength;
|
|
419
|
+
};
|
|
420
|
+
try {
|
|
421
|
+
appendLine(encodeLine({
|
|
422
|
+
kind: 'header',
|
|
423
|
+
format: options.format,
|
|
424
|
+
integrity: 'sha256',
|
|
425
|
+
ns: namespace,
|
|
426
|
+
exportedAt,
|
|
427
|
+
marker,
|
|
428
|
+
orderedTables: true,
|
|
429
|
+
}));
|
|
430
|
+
let cursor = {
|
|
431
|
+
tableIndex: 0,
|
|
432
|
+
tableOpened: false,
|
|
433
|
+
rowidCursor: 0,
|
|
434
|
+
primaryKeyCursor: null,
|
|
435
|
+
limit: 200,
|
|
436
|
+
};
|
|
437
|
+
while (cursor.tableIndex < tables.length) {
|
|
438
|
+
const chunk = await readChunk(env, namespace, readScanChunk(marker, tables, cursor));
|
|
439
|
+
if (chunk.outcome === 'preempted') {
|
|
440
|
+
await abortUpload();
|
|
441
|
+
return { outcome: 'preempted' };
|
|
231
442
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
443
|
+
if (chunk.value.torn) {
|
|
444
|
+
await abortUpload();
|
|
445
|
+
return { outcome: 'torn', marker, observed: chunk.value.marker };
|
|
235
446
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
447
|
+
for (const line of chunk.value.lines)
|
|
448
|
+
appendLine(line);
|
|
449
|
+
for (const [table, count] of Object.entries(chunk.value.tableRows)) {
|
|
450
|
+
tableRows[table] = (tableRows[table] ?? 0) + count;
|
|
451
|
+
rowTotal += count;
|
|
239
452
|
}
|
|
240
|
-
|
|
241
|
-
bufferedBytes = merged.byteLength;
|
|
242
|
-
};
|
|
243
|
-
const writeLine = async (value, includeInDigest = true) => {
|
|
244
|
-
const bytes = encoder.encode(`${JSON.stringify(value)}\n`);
|
|
245
|
-
if (includeInDigest)
|
|
246
|
-
digest.update(bytes);
|
|
247
|
-
chunks.push(bytes);
|
|
248
|
-
bufferedBytes += bytes.byteLength;
|
|
249
|
-
totalBytes += bytes.byteLength;
|
|
453
|
+
cursor = chunk.value.next;
|
|
250
454
|
await flushParts(false);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
455
|
+
}
|
|
456
|
+
appendLine(encodeLine({
|
|
457
|
+
kind: 'footer',
|
|
458
|
+
tables: tables.length,
|
|
459
|
+
rows: rowTotal,
|
|
460
|
+
sha256: hex(digest.digest()),
|
|
461
|
+
}, false));
|
|
462
|
+
await flushParts(true);
|
|
463
|
+
await upload.complete(await Promise.all(partUploads));
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
await abortUpload();
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
outcome: 'scanned',
|
|
471
|
+
marker,
|
|
472
|
+
tables: tables.length,
|
|
473
|
+
rows: rowTotal,
|
|
474
|
+
tableRows,
|
|
475
|
+
bytes: totalBytes,
|
|
476
|
+
parts: partUploads.length,
|
|
477
|
+
};
|
|
478
|
+
};
|
|
479
|
+
const exportNamespace = async (env, namespace) => {
|
|
480
|
+
const startedAt = Date.now();
|
|
481
|
+
const files = options.files(env);
|
|
482
|
+
let torn = 0;
|
|
483
|
+
for (let attempt = 0; attempt < scanAttempts; attempt++) {
|
|
484
|
+
const exportedAt = new Date().toISOString();
|
|
485
|
+
const key = `${backupPrefix(namespace)}${Date.now()}.ndjson`;
|
|
486
|
+
let scan;
|
|
255
487
|
try {
|
|
256
|
-
await
|
|
257
|
-
kind: 'header',
|
|
258
|
-
format: options.format,
|
|
259
|
-
integrity: 'sha256',
|
|
260
|
-
ns: namespace,
|
|
261
|
-
exportedAt,
|
|
262
|
-
marker,
|
|
263
|
-
orderedTables: true,
|
|
264
|
-
});
|
|
265
|
-
for (const table of tables) {
|
|
266
|
-
const name = String(table.name);
|
|
267
|
-
const withoutRowid = /\bWITHOUT\s+ROWID\b/i.test(String(table.sql));
|
|
268
|
-
const primaryKeyColumns = withoutRowid
|
|
269
|
-
? (await read(`PRAGMA table_info("${quoteIdentifier(name)}")`, []))
|
|
270
|
-
.filter((column) => Number(column.pk) > 0)
|
|
271
|
-
.sort((left, right) => Number(left.pk) - Number(right.pk))
|
|
272
|
-
.map((column) => String(column.name))
|
|
273
|
-
: [];
|
|
274
|
-
if (withoutRowid && primaryKeyColumns.length === 0) {
|
|
275
|
-
throw new Error(`WITHOUT ROWID table ${name} has no primary key`);
|
|
276
|
-
}
|
|
277
|
-
const quotedPrimaryKey = primaryKeyColumns
|
|
278
|
-
.map((column) => `"${quoteIdentifier(column)}"`)
|
|
279
|
-
.join(', ');
|
|
280
|
-
let tableRowTotal = 0;
|
|
281
|
-
await writeLine({
|
|
282
|
-
kind: 'table',
|
|
283
|
-
name,
|
|
284
|
-
sql: table.sql,
|
|
285
|
-
indexes: indexes
|
|
286
|
-
.filter((index) => index.tbl_name === name)
|
|
287
|
-
.map((index) => index.sql),
|
|
288
|
-
});
|
|
289
|
-
let rowidCursor = 0;
|
|
290
|
-
let primaryKeyCursor = null;
|
|
291
|
-
let limit = 200;
|
|
292
|
-
while (true) {
|
|
293
|
-
const usedLimit = limit;
|
|
294
|
-
const rows = withoutRowid
|
|
295
|
-
? await read(primaryKeyCursor
|
|
296
|
-
? `SELECT * FROM "${quoteIdentifier(name)}" WHERE (${quotedPrimaryKey}) > (${primaryKeyColumns.map(() => '?').join(', ')}) ORDER BY ${quotedPrimaryKey} LIMIT ?`
|
|
297
|
-
: `SELECT * FROM "${quoteIdentifier(name)}" ORDER BY ${quotedPrimaryKey} LIMIT ?`, primaryKeyCursor ? [...primaryKeyCursor, usedLimit] : [usedLimit])
|
|
298
|
-
: await read(`SELECT rowid AS __orez_backup_rowid, * FROM "${quoteIdentifier(name)}" WHERE rowid > ? ORDER BY rowid LIMIT ?`, [rowidCursor, usedLimit]);
|
|
299
|
-
if (rows.length === 0)
|
|
300
|
-
break;
|
|
301
|
-
if (withoutRowid) {
|
|
302
|
-
const last = rows.at(-1);
|
|
303
|
-
primaryKeyCursor = primaryKeyColumns.map((column) => last[column]);
|
|
304
|
-
}
|
|
305
|
-
else {
|
|
306
|
-
rowidCursor = rows.at(-1)?.__orez_backup_rowid;
|
|
307
|
-
for (const row of rows)
|
|
308
|
-
delete row.__orez_backup_rowid;
|
|
309
|
-
}
|
|
310
|
-
const lineBytes = await writeLine({ kind: 'rows', table: name, rows });
|
|
311
|
-
rowTotal += rows.length;
|
|
312
|
-
tableRowTotal += rows.length;
|
|
313
|
-
const perRow = Math.max(1, Math.ceil(lineBytes / rows.length));
|
|
314
|
-
limit = Math.max(20, Math.min(1000, Math.floor(chunkTargetBytes / perRow)));
|
|
315
|
-
if (rows.length < usedLimit)
|
|
316
|
-
break;
|
|
317
|
-
}
|
|
318
|
-
tableRows[name] = tableRowTotal;
|
|
319
|
-
}
|
|
320
|
-
await writeLine({
|
|
321
|
-
kind: 'footer',
|
|
322
|
-
tables: tables.length,
|
|
323
|
-
rows: rowTotal,
|
|
324
|
-
sha256: hex(digest.digest()),
|
|
325
|
-
}, false);
|
|
326
|
-
await flushParts(true);
|
|
327
|
-
await upload.complete(uploadedParts);
|
|
488
|
+
scan = await runScanAttempt(env, namespace, key, exportedAt);
|
|
328
489
|
}
|
|
329
490
|
catch (error) {
|
|
491
|
+
log({
|
|
492
|
+
phase: 'export_upload',
|
|
493
|
+
outcome: 'error',
|
|
494
|
+
namespace,
|
|
495
|
+
durationMs: Date.now() - startedAt,
|
|
496
|
+
error: errorMessage(error),
|
|
497
|
+
});
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
if (scan.outcome === 'preempted') {
|
|
501
|
+
log({
|
|
502
|
+
phase: 'export',
|
|
503
|
+
outcome: 'preempted',
|
|
504
|
+
namespace,
|
|
505
|
+
reason: 'session_preempted',
|
|
506
|
+
torn,
|
|
507
|
+
durationMs: Date.now() - startedAt,
|
|
508
|
+
});
|
|
509
|
+
return { outcome: 'preempted', namespace };
|
|
510
|
+
}
|
|
511
|
+
if (scan.outcome === 'torn') {
|
|
512
|
+
torn++;
|
|
513
|
+
log({
|
|
514
|
+
phase: 'export_scan',
|
|
515
|
+
outcome: 'torn',
|
|
516
|
+
namespace,
|
|
517
|
+
marker: scan.marker,
|
|
518
|
+
observedMarker: scan.observed,
|
|
519
|
+
attempt: attempt + 1,
|
|
520
|
+
durationMs: Date.now() - startedAt,
|
|
521
|
+
});
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const summary = {
|
|
525
|
+
ns: namespace,
|
|
526
|
+
key,
|
|
527
|
+
exportedAt,
|
|
528
|
+
marker: scan.marker,
|
|
529
|
+
tables: scan.tables,
|
|
530
|
+
rows: scan.rows,
|
|
531
|
+
tableRows: scan.tableRows,
|
|
532
|
+
bytes: scan.bytes,
|
|
533
|
+
parts: scan.parts,
|
|
534
|
+
};
|
|
535
|
+
let keepPreviousLatest = false;
|
|
536
|
+
if (scan.rows === 0) {
|
|
330
537
|
try {
|
|
331
|
-
await
|
|
538
|
+
const previous = await files.get(`${backupPrefix(namespace)}latest.json`);
|
|
539
|
+
if (previous) {
|
|
540
|
+
const previousSummary = (await previous.json());
|
|
541
|
+
keepPreviousLatest = Number(previousSummary.rows) > 0;
|
|
542
|
+
}
|
|
332
543
|
}
|
|
333
544
|
catch {
|
|
334
|
-
//
|
|
545
|
+
// A missing/corrupt pointer must not prevent a new valid backup.
|
|
335
546
|
}
|
|
336
|
-
if (error instanceof ApplicationSqlSessionPreemptedError) {
|
|
337
|
-
log({
|
|
338
|
-
phase: 'export_upload',
|
|
339
|
-
outcome: 'preempted',
|
|
340
|
-
namespace,
|
|
341
|
-
durationMs: Date.now() - startedAt,
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
else {
|
|
345
|
-
log({
|
|
346
|
-
phase: 'export_upload',
|
|
347
|
-
outcome: 'error',
|
|
348
|
-
namespace,
|
|
349
|
-
durationMs: Date.now() - startedAt,
|
|
350
|
-
error: errorMessage(error),
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
throw error;
|
|
354
547
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
rows: rowTotal,
|
|
359
|
-
tableRows,
|
|
360
|
-
bytes: totalBytes,
|
|
361
|
-
parts: uploadedParts.length,
|
|
362
|
-
};
|
|
363
|
-
})
|
|
364
|
-
.catch((error) => {
|
|
365
|
-
if (error instanceof ApplicationSqlSessionPreemptedError)
|
|
366
|
-
return null;
|
|
367
|
-
throw error;
|
|
368
|
-
});
|
|
369
|
-
if (scan === null) {
|
|
548
|
+
if (!keepPreviousLatest) {
|
|
549
|
+
await files.put(`${backupPrefix(namespace)}latest.json`, JSON.stringify(summary));
|
|
550
|
+
}
|
|
370
551
|
log({
|
|
371
552
|
phase: 'export',
|
|
372
|
-
outcome: '
|
|
553
|
+
outcome: 'success',
|
|
373
554
|
namespace,
|
|
374
555
|
durationMs: Date.now() - startedAt,
|
|
556
|
+
rows: summary.rows,
|
|
557
|
+
bytes: summary.bytes,
|
|
558
|
+
parts: summary.parts,
|
|
559
|
+
torn,
|
|
375
560
|
});
|
|
376
|
-
return { outcome: '
|
|
377
|
-
}
|
|
378
|
-
const summary = {
|
|
379
|
-
ns: namespace,
|
|
380
|
-
key,
|
|
381
|
-
exportedAt,
|
|
382
|
-
...scan,
|
|
383
|
-
};
|
|
384
|
-
let keepPreviousLatest = false;
|
|
385
|
-
if (scan.rows === 0) {
|
|
386
|
-
try {
|
|
387
|
-
const previous = await files.get(`${backupPrefix(namespace)}latest.json`);
|
|
388
|
-
if (previous) {
|
|
389
|
-
const previousSummary = (await previous.json());
|
|
390
|
-
keepPreviousLatest = Number(previousSummary.rows) > 0;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
catch {
|
|
394
|
-
// A missing/corrupt pointer must not prevent a new valid backup.
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
if (!keepPreviousLatest) {
|
|
398
|
-
await files.put(`${backupPrefix(namespace)}latest.json`, JSON.stringify(summary));
|
|
561
|
+
return { outcome: 'exported', summary };
|
|
399
562
|
}
|
|
400
563
|
log({
|
|
401
564
|
phase: 'export',
|
|
402
|
-
outcome: '
|
|
565
|
+
outcome: 'preempted',
|
|
403
566
|
namespace,
|
|
567
|
+
reason: 'torn',
|
|
568
|
+
torn,
|
|
404
569
|
durationMs: Date.now() - startedAt,
|
|
405
|
-
rows: summary.rows,
|
|
406
|
-
bytes: summary.bytes,
|
|
407
|
-
parts: summary.parts,
|
|
408
570
|
});
|
|
409
|
-
return { outcome: '
|
|
571
|
+
return { outcome: 'preempted', namespace };
|
|
410
572
|
};
|
|
411
573
|
const importNamespace = async (env, namespace, key, importOptions = {}) => {
|
|
412
574
|
const startedAt = Date.now();
|
|
@@ -418,7 +580,6 @@ export function createNamespaceBackupManager(options) {
|
|
|
418
580
|
let validatedFooter;
|
|
419
581
|
let validatedRows = 0;
|
|
420
582
|
const validationDigest = sha256.create();
|
|
421
|
-
const encoder = new TextEncoder();
|
|
422
583
|
const tableEntries = [];
|
|
423
584
|
const seenTables = new Set();
|
|
424
585
|
for await (const line of ndjsonLines(validationObject.body)) {
|