@agoric/swing-store 0.9.2-dev-914bee2.0 → 0.9.2-dev-07f6885.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.
@@ -32,14 +32,14 @@ Pruned spans are not available for export artifacts, of course, because the data
32
32
 
33
33
  Every transcript span, both current and historic, gets an export-data record. The record name is different for the two types of spans.
34
34
 
35
- Historical spans, which are "closed" and no longer growing, use a record name of
36
- `transcript.${vatID}.${startPos}`, where `startPos` is the delivery number of the first delivery included in the span. The value is a JSON-serialized record of `{ vatID, startPos, endPos, hash, isCurrent, incarnation }` (where `isCurrent = 0`).
35
+ Historical spans, which are "closed" and no longer growing, use a record name of `transcript.${vatID}.${startPos}.${endPos}`, where `startPos` is the delivery number of the first delivery included in the span and `endPos` is the the delivery number of the first delivery included in the **next** span (i.e., the former is an inclusive lower bound and the latter is an exclusive upper bound).
36
+ The value is a JSON-serialized record of `{ vatID, startPos, endPos, hash, isCurrent, incarnation }` (where `isCurrent = 0`).
37
37
 
38
38
  The current span, if any, uses a record name of `transcript.${vatID}.current`, and has the same value as historical spans (except `isCurrent = 1`). Current spans are growing: new transcript items are added as more deliveries are made, until the span is closed off (becomes historical) and replaced with a new current span. There is at most one current span per vatID.
39
39
 
40
40
  The available export *artifacts* will depend upon the export mode, and upon the swingstore's `keepTranscripts` setting. Each export artifact corresponds to a single span, and the artifact names are always `transcript.${vatID}.${startPos}.${endPos}` (for both historical and current spans).
41
41
 
42
- In the most-minimal `operational` mode, the export includes one artifact for each active (non-terminated) vat: just the current span. If `keepTranscripts` is true, these will be the only available artifacts anyways.
42
+ In the most-minimal `operational` mode, the export includes one artifact for each active (non-terminated) vat: just the current span. If `keepTranscripts` is not true, these will be the only available artifacts anyways.
43
43
 
44
44
  The `replay` mode includes all spans for each vat's current incarnation, but omits spans from earlier incarnations. The `archival` mode includes all spans from all incarnations.
45
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agoric/swing-store",
3
- "version": "0.9.2-dev-914bee2.0+914bee2",
3
+ "version": "0.9.2-dev-07f6885.0+07f6885",
4
4
  "description": "Persistent storage for SwingSet",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -21,7 +21,7 @@
21
21
  "lint:eslint": "eslint ."
22
22
  },
23
23
  "dependencies": {
24
- "@agoric/internal": "0.3.3-dev-914bee2.0+914bee2",
24
+ "@agoric/internal": "0.3.3-dev-07f6885.0+07f6885",
25
25
  "@endo/base64": "^1.0.7",
26
26
  "@endo/bundle-source": "^3.4.0",
27
27
  "@endo/check-bundle": "^1.0.9",
@@ -51,5 +51,5 @@
51
51
  "typeCoverage": {
52
52
  "atLeast": 78.77
53
53
  },
54
- "gitHead": "914bee2aa7472039ba230267d72027b940bf8a1c"
54
+ "gitHead": "07f6885c560333981d6564581b2a00735a6c428c"
55
55
  }
@@ -0,0 +1,80 @@
1
+ import { finished as streamFinishedCallback, Readable } from 'node:stream';
2
+ import { promisify } from 'node:util';
3
+ import { createGzip } from 'node:zlib';
4
+ import { withDeferredCleanup } from '@agoric/internal';
5
+
6
+ const streamFinished = promisify(streamFinishedCallback);
7
+
8
+ /*
9
+ * @param {string} dirPath
10
+ * @param {object} powers
11
+ * @param {Pick<import('fs'), 'createWriteStream' | 'mkdirSync' | 'renameSync'>} powers.fs
12
+ * @param {Pick<import('path'), 'join'>} powers.path
13
+ * @param {Pick<import('tmp'), 'fileSync'>} powers.tmp
14
+ */
15
+ export const makeArchiveSnapshot = (dirPath, powers) => {
16
+ const { fs, path, tmp } = powers;
17
+ fs.mkdirSync(dirPath, { recursive: true });
18
+ const archiveSnapshot = (name, gzData) => {
19
+ const destPath = path.join(dirPath, `${name}.gz`);
20
+ return withDeferredCleanup(async addCleanup => {
21
+ const {
22
+ name: tmpName,
23
+ fd,
24
+ removeCallback,
25
+ } = tmp.fileSync({
26
+ prefix: name,
27
+ postfix: '.gz',
28
+ detachDescriptor: true,
29
+ });
30
+ addCleanup(() => removeCallback());
31
+ const writer = fs.createWriteStream('', { fd, flush: true });
32
+ const reader = Readable.from(gzData);
33
+ const destroyReader = promisify(reader.destroy.bind(reader));
34
+ addCleanup(() => destroyReader(null));
35
+ reader.pipe(writer);
36
+ await streamFinished(writer);
37
+ fs.renameSync(tmpName, destPath);
38
+ });
39
+ };
40
+ return archiveSnapshot;
41
+ };
42
+ harden(makeArchiveSnapshot);
43
+
44
+ /*
45
+ * @param {string} dirPath
46
+ * @param {object} powers
47
+ * @param {Pick<import('fs'), 'createWriteStream' | 'mkdirSync' | 'renameSync'>} powers.fs
48
+ * @param {Pick<import('path'), 'join'>} powers.path
49
+ * @param {Pick<import('tmp'), 'fileSync'>} powers.tmp
50
+ */
51
+ export const makeArchiveTranscript = (dirPath, powers) => {
52
+ const { fs, path, tmp } = powers;
53
+ fs.mkdirSync(dirPath, { recursive: true });
54
+ const archiveTranscript = (spanName, entries) => {
55
+ const destPath = path.join(dirPath, `${spanName}.gz`);
56
+ return withDeferredCleanup(async addCleanup => {
57
+ const {
58
+ name: tmpName,
59
+ fd,
60
+ removeCallback,
61
+ } = tmp.fileSync({
62
+ prefix: spanName,
63
+ postfix: '.gz',
64
+ detachDescriptor: true,
65
+ });
66
+ addCleanup(() => removeCallback());
67
+ const writer = fs.createWriteStream('', { fd, flush: true });
68
+ const gzip = createGzip();
69
+ gzip.pipe(writer);
70
+ const reader = Readable.from(entries);
71
+ const destroyReader = promisify(reader.destroy.bind(reader));
72
+ addCleanup(() => destroyReader(null));
73
+ reader.pipe(gzip);
74
+ await streamFinished(gzip);
75
+ fs.renameSync(tmpName, destPath);
76
+ });
77
+ };
78
+ return archiveTranscript;
79
+ };
80
+ harden(makeArchiveTranscript);
package/src/index.js CHANGED
@@ -2,6 +2,8 @@ export { initSwingStore, openSwingStore, isSwingStore } from './swingStore.js';
2
2
  export { makeSwingStoreExporter } from './exporter.js';
3
3
  export { importSwingStore } from './importer.js';
4
4
 
5
+ export { makeArchiveSnapshot, makeArchiveTranscript } from './archiver.js';
6
+
5
7
  // temporary, for the benefit of SwingSet/misc-tools/replay-transcript.js
6
8
  export { makeSnapStore } from './snapStore.js';
7
9
  // and less temporary, for SwingSet/test/vat-warehouse/test-reload-snapshot.js
package/src/snapStore.js CHANGED
@@ -4,10 +4,7 @@ import { finished as finishedCallback, PassThrough, Readable } from 'stream';
4
4
  import { promisify } from 'util';
5
5
  import { createGzip, createGunzip } from 'zlib';
6
6
  import { Fail, q } from '@endo/errors';
7
- import {
8
- aggregateTryFinally,
9
- PromiseAllOrErrors,
10
- } from '@agoric/internal/src/node/utils.js';
7
+ import { withDeferredCleanup } from '@agoric/internal';
11
8
  import { buffer } from './util.js';
12
9
 
13
10
  /**
@@ -17,6 +14,7 @@ import { buffer } from './util.js';
17
14
  * @property {number} dbSaveSeconds time to write snapshot in DB
18
15
  * @property {number} compressedSize size of (compressed) snapshot
19
16
  * @property {number} compressSeconds time to generate and compress the snapshot
17
+ * @property {number} [archiveWriteSeconds] time to write an archive to disk (if applicable)
20
18
  */
21
19
 
22
20
  /**
@@ -73,6 +71,7 @@ const finished = promisify(finishedCallback);
73
71
  * @param {(key: string, value: string | undefined) => void} noteExport
74
72
  * @param {object} [options]
75
73
  * @param {boolean | undefined} [options.keepSnapshots]
74
+ * @param {(name: string, compressedData: Parameters<import('stream').Readable.from>[0]) => Promise<void>} [options.archiveSnapshot]
76
75
  * @returns {SnapStore & SnapStoreInternal & SnapStoreDebug}
77
76
  */
78
77
  export function makeSnapStore(
@@ -80,7 +79,7 @@ export function makeSnapStore(
80
79
  ensureTxn,
81
80
  { measureSeconds },
82
81
  noteExport = () => {},
83
- { keepSnapshots = false } = {},
82
+ { keepSnapshots = false, archiveSnapshot } = {},
84
83
  ) {
85
84
  db.exec(`
86
85
  CREATE TABLE IF NOT EXISTS snapshots (
@@ -196,7 +195,8 @@ export function makeSnapStore(
196
195
 
197
196
  /**
198
197
  * Generates a new XS heap snapshot, stores a gzipped copy of it into the
199
- * snapshots table, and reports information about the process, including
198
+ * snapshots table (and also to an archiveSnapshot callback if provided for
199
+ * e.g. disk archival), and reports information about the process, including
200
200
  * snapshot size and timing metrics.
201
201
  *
202
202
  * @param {string} vatID
@@ -205,74 +205,62 @@ export function makeSnapStore(
205
205
  * @returns {Promise<SnapshotResult>}
206
206
  */
207
207
  async function saveSnapshot(vatID, snapPos, snapshotStream) {
208
- const cleanup = [];
209
- return aggregateTryFinally(
210
- async () => {
211
- const hashStream = createHash('sha256');
212
- const gzip = createGzip();
213
- let compressedSize = 0;
214
- let uncompressedSize = 0;
215
-
216
- const { duration: compressSeconds, result: compressedSnapshot } =
217
- await measureSeconds(async () => {
218
- const snapReader = Readable.from(snapshotStream);
219
- cleanup.push(
220
- () =>
221
- new Promise((resolve, reject) =>
222
- snapReader.destroy(
223
- null,
224
- // @ts-expect-error incorrect types
225
- err => (err ? reject(err) : resolve()),
226
- ),
227
- ),
228
- );
229
-
230
- snapReader.on('data', chunk => {
231
- uncompressedSize += chunk.length;
232
- });
233
- snapReader.pipe(hashStream);
234
- const compressedSnapshotData = await buffer(snapReader.pipe(gzip));
235
- await finished(snapReader);
236
- return compressedSnapshotData;
208
+ return withDeferredCleanup(async addCleanup => {
209
+ const hashStream = createHash('sha256');
210
+ const gzip = createGzip();
211
+ let compressedSize = 0;
212
+ let uncompressedSize = 0;
213
+
214
+ const { duration: compressSeconds, result: compressedSnapshot } =
215
+ await measureSeconds(async () => {
216
+ const snapReader = Readable.from(snapshotStream);
217
+ const destroyReader = promisify(snapReader.destroy.bind(snapReader));
218
+ addCleanup(() => destroyReader(null));
219
+ snapReader.on('data', chunk => {
220
+ uncompressedSize += chunk.length;
237
221
  });
238
- const hash = hashStream.digest('hex');
239
-
240
- const { duration: dbSaveSeconds } = await measureSeconds(async () => {
241
- ensureTxn();
242
- stopUsingLastSnapshot(vatID);
243
- compressedSize = compressedSnapshot.length;
244
- sqlSaveSnapshot.run(
245
- vatID,
246
- snapPos,
247
- 1,
248
- hash,
249
- uncompressedSize,
250
- compressedSize,
251
- compressedSnapshot,
252
- );
253
- const rec = snapshotRec(vatID, snapPos, hash, 1);
254
- const exportKey = snapshotMetadataKey(rec);
255
- noteExport(exportKey, JSON.stringify(rec));
256
- noteExport(
257
- currentSnapshotMetadataKey(rec),
258
- snapshotArtifactName(rec),
259
- );
222
+ snapReader.pipe(hashStream);
223
+ const compressedSnapshotData = await buffer(snapReader.pipe(gzip));
224
+ await finished(snapReader);
225
+ return compressedSnapshotData;
260
226
  });
227
+ const hash = hashStream.digest('hex');
228
+ const rec = snapshotRec(vatID, snapPos, hash, 1);
229
+ const exportKey = snapshotMetadataKey(rec);
261
230
 
262
- return harden({
231
+ const { duration: dbSaveSeconds } = await measureSeconds(async () => {
232
+ ensureTxn();
233
+ stopUsingLastSnapshot(vatID);
234
+ compressedSize = compressedSnapshot.length;
235
+ sqlSaveSnapshot.run(
236
+ vatID,
237
+ snapPos,
238
+ 1,
263
239
  hash,
264
240
  uncompressedSize,
265
- compressSeconds,
266
- dbSaveSeconds,
267
241
  compressedSize,
268
- });
269
- },
270
- async () => {
271
- await PromiseAllOrErrors(
272
- cleanup.reverse().map(fn => Promise.resolve().then(() => fn())),
242
+ compressedSnapshot,
273
243
  );
274
- },
275
- );
244
+ noteExport(exportKey, JSON.stringify(rec));
245
+ noteExport(currentSnapshotMetadataKey(rec), snapshotArtifactName(rec));
246
+ });
247
+
248
+ let archiveWriteSeconds;
249
+ if (archiveSnapshot) {
250
+ ({ duration: archiveWriteSeconds } = await measureSeconds(async () => {
251
+ await archiveSnapshot(exportKey, compressedSnapshot);
252
+ }));
253
+ }
254
+
255
+ return harden({
256
+ hash,
257
+ uncompressedSize,
258
+ compressSeconds,
259
+ dbSaveSeconds,
260
+ archiveWriteSeconds,
261
+ compressedSize,
262
+ });
263
+ });
276
264
  }
277
265
 
278
266
  const sqlGetSnapshot = db.prepare(`
package/src/swingStore.js CHANGED
@@ -169,7 +169,13 @@ export function makeSwingStore(dirPath, forceReset, options = {}) {
169
169
  filePath = ':memory:';
170
170
  }
171
171
 
172
- const { traceFile, keepSnapshots, keepTranscripts } = options;
172
+ const {
173
+ traceFile,
174
+ keepSnapshots,
175
+ keepTranscripts,
176
+ archiveSnapshot,
177
+ archiveTranscript,
178
+ } = options;
173
179
 
174
180
  let traceOutput = traceFile
175
181
  ? fs.createWriteStream(path.resolve(traceFile), {
@@ -297,6 +303,7 @@ export function makeSwingStore(dirPath, forceReset, options = {}) {
297
303
  noteExport,
298
304
  {
299
305
  keepTranscripts,
306
+ archiveTranscript,
300
307
  },
301
308
  );
302
309
  const { dumpSnapshots, ...snapStore } = makeSnapStore(
@@ -306,6 +313,7 @@ export function makeSwingStore(dirPath, forceReset, options = {}) {
306
313
  noteExport,
307
314
  {
308
315
  keepSnapshots,
316
+ archiveSnapshot,
309
317
  },
310
318
  );
311
319
  const { dumpBundles, ...bundleStore } = makeBundleStore(
@@ -15,10 +15,10 @@ import { createSHA256 } from './hasher.js';
15
15
  *
16
16
  * @typedef {{
17
17
  * initTranscript: (vatID: string) => void,
18
- * rolloverSpan: (vatID: string) => number,
19
- * rolloverIncarnation: (vatID: string) => number,
18
+ * rolloverSpan: (vatID: string) => Promise<number>,
19
+ * rolloverIncarnation: (vatID: string) => Promise<number>,
20
20
  * getCurrentSpanBounds: (vatID: string) => { startPos: number, endPos: number, hash: string, incarnation: number },
21
- * stopUsingTranscript: (vatID: string) => void,
21
+ * stopUsingTranscript: (vatID: string) => Promise<void>,
22
22
  * deleteVatTranscripts: (vatID: string, budget?: number) => { done: boolean, cleanups: number },
23
23
  * addItem: (vatID: string, item: string) => void,
24
24
  * readSpan: (vatID: string, startPos?: number) => IterableIterator<string>,
@@ -61,14 +61,15 @@ function insistTranscriptPosition(position) {
61
61
  * @param {() => void} ensureTxn
62
62
  * @param {(key: string, value: string | undefined ) => void} noteExport
63
63
  * @param {object} [options]
64
- * @param {boolean | undefined} [options.keepTranscripts]
64
+ * @param {boolean} [options.keepTranscripts]
65
+ * @param {(spanName: string, entries: ReturnType<exportSpan>) => Promise<void>} [options.archiveTranscript]
65
66
  * @returns { TranscriptStore & TranscriptStoreInternal & TranscriptStoreDebug }
66
67
  */
67
68
  export function makeTranscriptStore(
68
69
  db,
69
70
  ensureTxn,
70
71
  noteExport = () => {},
71
- { keepTranscripts = true } = {},
72
+ { keepTranscripts = true, archiveTranscript } = {},
72
73
  ) {
73
74
  db.exec(`
74
75
  CREATE TABLE IF NOT EXISTS transcriptItems (
@@ -246,6 +247,102 @@ export function makeTranscriptStore(
246
247
  return bounds;
247
248
  }
248
249
 
250
+ const sqlGetSpanEndPos = db.prepare(`
251
+ SELECT endPos
252
+ FROM transcriptSpans
253
+ WHERE vatID = ? AND startPos = ?
254
+ `);
255
+ sqlGetSpanEndPos.pluck(true);
256
+
257
+ const sqlReadSpanItems = db.prepare(`
258
+ SELECT item
259
+ FROM transcriptItems
260
+ WHERE vatID = ? AND ? <= position AND position < ?
261
+ ORDER BY position
262
+ `);
263
+
264
+ /**
265
+ * Read the items in a transcript span
266
+ *
267
+ * @param {string} vatID The vat whose transcript is being read
268
+ * @param {number} [startPos] A start position identifying the span to be
269
+ * read; defaults to the current span, whatever it is
270
+ *
271
+ * @returns {IterableIterator<string>} An iterator over the items in the indicated span
272
+ */
273
+ function readSpan(vatID, startPos) {
274
+ /** @type {number | undefined} */
275
+ let endPos;
276
+ if (startPos === undefined) {
277
+ ({ startPos, endPos } = getCurrentSpanBounds(vatID));
278
+ } else {
279
+ insistTranscriptPosition(startPos);
280
+ endPos = sqlGetSpanEndPos.get(vatID, startPos);
281
+ if (typeof endPos !== 'number') {
282
+ throw Fail`no transcript span for ${q(vatID)} at ${q(startPos)}`;
283
+ }
284
+ }
285
+ startPos <= endPos || Fail`${q(startPos)} <= ${q(endPos)}}`;
286
+ const expectedCount = endPos - startPos;
287
+
288
+ function* reader() {
289
+ let count = 0;
290
+ for (const { item } of sqlReadSpanItems.iterate(
291
+ vatID,
292
+ startPos,
293
+ endPos,
294
+ )) {
295
+ yield item;
296
+ count += 1;
297
+ }
298
+ count === expectedCount ||
299
+ Fail`read ${q(count)} transcript entries (expected ${q(
300
+ expectedCount,
301
+ )})`;
302
+ }
303
+ harden(reader);
304
+
305
+ if (startPos === endPos) {
306
+ return empty();
307
+ }
308
+
309
+ return reader();
310
+ }
311
+
312
+ const sqlGetSpanIsCurrent = db.prepare(`
313
+ SELECT isCurrent
314
+ FROM transcriptSpans
315
+ WHERE vatID = ? AND startPos = ?
316
+ `);
317
+ sqlGetSpanIsCurrent.pluck(true);
318
+
319
+ /**
320
+ * Read a transcript span and return it as a stream of data suitable for
321
+ * export to another store. Transcript items are terminated by newlines.
322
+ *
323
+ * Transcript span artifact names should be strings of the form:
324
+ * `transcript.${vatID}.${startPos}.${endPos}`
325
+ *
326
+ * @param {string} name The name of the transcript artifact to be read
327
+ * @returns {AsyncIterableIterator<Uint8Array>}
328
+ * @yields {Uint8Array}
329
+ */
330
+ async function* exportSpan(name) {
331
+ typeof name === 'string' || Fail`artifact name must be a string`;
332
+ const parts = name.split('.');
333
+ const [type, vatID, pos] = parts;
334
+ // prettier-ignore
335
+ (parts.length === 4 && type === 'transcript') ||
336
+ Fail`expected artifact name of the form 'transcript.{vatID}.{startPos}.{endPos}', saw ${q(name)}`;
337
+ const isCurrent = sqlGetSpanIsCurrent.get(vatID, pos);
338
+ isCurrent !== undefined || Fail`transcript span ${q(name)} not available`;
339
+ const startPos = Number(pos);
340
+ for (const entry of readSpan(vatID, startPos)) {
341
+ yield Buffer.from(`${entry}\n`);
342
+ }
343
+ }
344
+ harden(exportSpan);
345
+
249
346
  const sqlEndCurrentSpan = db.prepare(`
250
347
  UPDATE transcriptSpans
251
348
  SET isCurrent = null
@@ -267,7 +364,7 @@ export function makeTranscriptStore(
267
364
  * @param {string} vatID
268
365
  * @param {ReturnType<getCurrentSpanBounds>} bounds
269
366
  */
270
- function closeSpan(vatID, bounds) {
367
+ async function closeSpan(vatID, bounds) {
271
368
  ensureTxn();
272
369
  const { startPos, endPos, hash, incarnation } = bounds;
273
370
  const rec = spanRec(vatID, startPos, endPos, hash, false, incarnation);
@@ -278,6 +375,12 @@ export function makeTranscriptStore(
278
375
  // and change its DB row to isCurrent=null
279
376
  sqlEndCurrentSpan.run(vatID);
280
377
 
378
+ await null;
379
+ if (archiveTranscript) {
380
+ const spanName = spanArtifactName(rec);
381
+ const entries = exportSpan(spanName);
382
+ await archiveTranscript(spanName, entries);
383
+ }
281
384
  if (!keepTranscripts) {
282
385
  // Delete items of the previously-current span.
283
386
  // There may still be items associated with even older spans, but we leave
@@ -289,13 +392,13 @@ export function makeTranscriptStore(
289
392
  }
290
393
  }
291
394
 
292
- function doSpanRollover(vatID, isNewIncarnation) {
395
+ async function doSpanRollover(vatID, isNewIncarnation) {
293
396
  ensureTxn();
294
397
  const bounds = getCurrentSpanBounds(vatID);
295
398
  const { endPos, incarnation } = bounds;
296
399
 
297
400
  // deal with the now-old span
298
- closeSpan(vatID, bounds);
401
+ await closeSpan(vatID, bounds);
299
402
 
300
403
  // create a new (empty) DB row, with isCurrent=1
301
404
  const newSpanIncarnation = isNewIncarnation ? incarnation + 1 : incarnation;
@@ -322,9 +425,9 @@ export function makeTranscriptStore(
322
425
  * @param {string} vatID The vat whose transcript is to rollover to a new
323
426
  * span.
324
427
  *
325
- * @returns {number} the new incarnation number
428
+ * @returns {Promise<number>} the new incarnation number
326
429
  */
327
- function rolloverIncarnation(vatID) {
430
+ async function rolloverIncarnation(vatID) {
328
431
  return doSpanRollover(vatID, true);
329
432
  }
330
433
 
@@ -335,9 +438,9 @@ export function makeTranscriptStore(
335
438
  * @param {string} vatID The vat whose transcript is to rollover to a new
336
439
  * span.
337
440
  *
338
- * @returns {number} the incarnation number
441
+ * @returns {Promise<number>} the incarnation number
339
442
  */
340
- function rolloverSpan(vatID) {
443
+ async function rolloverSpan(vatID) {
341
444
  return doSpanRollover(vatID, false);
342
445
  }
343
446
 
@@ -387,13 +490,13 @@ export function makeTranscriptStore(
387
490
  *
388
491
  * @param {string} vatID The vat being terminated/deleted.
389
492
  */
390
- function stopUsingTranscript(vatID) {
493
+ async function stopUsingTranscript(vatID) {
391
494
  ensureTxn();
392
495
  const bounds = sqlGetCurrentSpanBounds.get(vatID);
393
496
  if (!bounds) return;
394
497
 
395
498
  // deal with the now-old span
396
- closeSpan(vatID, bounds);
499
+ await closeSpan(vatID, bounds);
397
500
 
398
501
  // remove the transcript.${vatID}.current record
399
502
  noteExport(spanMetadataKey({ vatID, isCurrent: true }), undefined);
@@ -584,102 +687,6 @@ export function makeTranscriptStore(
584
687
  }
585
688
  harden(getArtifactNames);
586
689
 
587
- const sqlGetSpanEndPos = db.prepare(`
588
- SELECT endPos
589
- FROM transcriptSpans
590
- WHERE vatID = ? AND startPos = ?
591
- `);
592
- sqlGetSpanEndPos.pluck(true);
593
-
594
- const sqlReadSpanItems = db.prepare(`
595
- SELECT item
596
- FROM transcriptItems
597
- WHERE vatID = ? AND ? <= position AND position < ?
598
- ORDER BY position
599
- `);
600
-
601
- /**
602
- * Read the items in a transcript span
603
- *
604
- * @param {string} vatID The vat whose transcript is being read
605
- * @param {number} [startPos] A start position identifying the span to be
606
- * read; defaults to the current span, whatever it is
607
- *
608
- * @returns {IterableIterator<string>} An iterator over the items in the indicated span
609
- */
610
- function readSpan(vatID, startPos) {
611
- /** @type {number | undefined} */
612
- let endPos;
613
- if (startPos === undefined) {
614
- ({ startPos, endPos } = getCurrentSpanBounds(vatID));
615
- } else {
616
- insistTranscriptPosition(startPos);
617
- endPos = sqlGetSpanEndPos.get(vatID, startPos);
618
- if (typeof endPos !== 'number') {
619
- throw Fail`no transcript span for ${q(vatID)} at ${q(startPos)}`;
620
- }
621
- }
622
- startPos <= endPos || Fail`${q(startPos)} <= ${q(endPos)}}`;
623
- const expectedCount = endPos - startPos;
624
-
625
- function* reader() {
626
- let count = 0;
627
- for (const { item } of sqlReadSpanItems.iterate(
628
- vatID,
629
- startPos,
630
- endPos,
631
- )) {
632
- yield item;
633
- count += 1;
634
- }
635
- count === expectedCount ||
636
- Fail`read ${q(count)} transcript entries (expected ${q(
637
- expectedCount,
638
- )})`;
639
- }
640
- harden(reader);
641
-
642
- if (startPos === endPos) {
643
- return empty();
644
- }
645
-
646
- return reader();
647
- }
648
-
649
- const sqlGetSpanIsCurrent = db.prepare(`
650
- SELECT isCurrent
651
- FROM transcriptSpans
652
- WHERE vatID = ? AND startPos = ?
653
- `);
654
- sqlGetSpanIsCurrent.pluck(true);
655
-
656
- /**
657
- * Read a transcript span and return it as a stream of data suitable for
658
- * export to another store. Transcript items are terminated by newlines.
659
- *
660
- * Transcript span artifact names should be strings of the form:
661
- * `transcript.${vatID}.${startPos}.${endPos}`
662
- *
663
- * @param {string} name The name of the transcript artifact to be read
664
- * @returns {AsyncIterableIterator<Uint8Array>}
665
- * @yields {Uint8Array}
666
- */
667
- async function* exportSpan(name) {
668
- typeof name === 'string' || Fail`artifact name must be a string`;
669
- const parts = name.split('.');
670
- const [type, vatID, pos] = parts;
671
- // prettier-ignore
672
- (parts.length === 4 && type === 'transcript') ||
673
- Fail`expected artifact name of the form 'transcript.{vatID}.{startPos}.{endPos}', saw ${q(name)}`;
674
- const isCurrent = sqlGetSpanIsCurrent.get(vatID, pos);
675
- isCurrent !== undefined || Fail`transcript span ${q(name)} not available`;
676
- const startPos = Number(pos);
677
- for (const entry of readSpan(vatID, startPos)) {
678
- yield Buffer.from(`${entry}\n`);
679
- }
680
- }
681
- harden(exportSpan);
682
-
683
690
  const sqlAddItem = db.prepare(`
684
691
  INSERT INTO transcriptItems (vatID, item, position, incarnation)
685
692
  VALUES (?, ?, ?, ?)
@@ -1,12 +1,16 @@
1
1
  // @ts-check
2
2
  import test from 'ava';
3
- import path from 'path';
4
3
 
5
4
  import { Buffer } from 'node:buffer';
5
+ import path from 'node:path';
6
+ import fs from 'node:fs';
7
+ import zlib from 'node:zlib';
6
8
  import sqlite3 from 'better-sqlite3';
9
+ import tmp from 'tmp';
7
10
  import { arrayIsLike } from '@agoric/internal/tools/ava-assertions.js';
8
11
  import { tmpDir } from './util.js';
9
12
  import { initSwingStore } from '../src/swingStore.js';
13
+ import { makeArchiveSnapshot, makeArchiveTranscript } from '../src/archiver.js';
10
14
  import { makeSwingStoreExporter } from '../src/exporter.js';
11
15
  import { importSwingStore } from '../src/importer.js';
12
16
 
@@ -95,7 +99,7 @@ test('delete transcripts with export callback', async t => {
95
99
  transcriptStore.addItem('v1', 'aaa');
96
100
  transcriptStore.addItem('v1', 'bbb');
97
101
  transcriptStore.addItem('v1', 'ccc');
98
- transcriptStore.rolloverSpan('v1');
102
+ await transcriptStore.rolloverSpan('v1');
99
103
  transcriptStore.addItem('v1', 'ddd');
100
104
  transcriptStore.addItem('v1', 'eee');
101
105
  transcriptStore.addItem('v1', 'fff');
@@ -168,7 +172,17 @@ const setupTranscript = async (t, keepTranscripts) => {
168
172
  };
169
173
  const [dbDir, cleanup] = await tmpDir('testdb');
170
174
  t.teardown(cleanup);
171
- const store = initSwingStore(dbDir, { exportCallback, keepTranscripts });
175
+ const [archiveDir, cleanupArchives] = await tmpDir('archives');
176
+ t.teardown(cleanupArchives);
177
+ const fsPowers = { fs, path, tmp };
178
+ const archiveSnapshot = makeArchiveSnapshot(archiveDir, fsPowers);
179
+ const archiveTranscript = makeArchiveTranscript(archiveDir, fsPowers);
180
+ const store = initSwingStore(dbDir, {
181
+ exportCallback,
182
+ keepTranscripts,
183
+ archiveSnapshot,
184
+ archiveTranscript,
185
+ });
172
186
  const { kernelStorage, hostStorage } = store;
173
187
  const { transcriptStore } = kernelStorage;
174
188
  const { commit } = hostStorage;
@@ -179,13 +193,13 @@ const setupTranscript = async (t, keepTranscripts) => {
179
193
  transcriptStore.initTranscript(vatID);
180
194
  transcriptStore.addItem(vatID, 'aaa');
181
195
  transcriptStore.addItem(vatID, 'bbb');
182
- transcriptStore.rolloverSpan(vatID);
196
+ await transcriptStore.rolloverSpan(vatID);
183
197
  transcriptStore.addItem(vatID, 'ccc');
184
198
  transcriptStore.addItem(vatID, 'ddd');
185
- transcriptStore.rolloverIncarnation(vatID);
199
+ await transcriptStore.rolloverIncarnation(vatID);
186
200
  transcriptStore.addItem(vatID, 'eee');
187
201
  transcriptStore.addItem(vatID, 'fff');
188
- transcriptStore.rolloverSpan(vatID);
202
+ await transcriptStore.rolloverSpan(vatID);
189
203
  transcriptStore.addItem(vatID, 'ggg');
190
204
  transcriptStore.addItem(vatID, 'hhh');
191
205
  await commit();
@@ -193,6 +207,7 @@ const setupTranscript = async (t, keepTranscripts) => {
193
207
  return {
194
208
  db,
195
209
  dbDir,
210
+ archiveDir,
196
211
  commit,
197
212
  transcriptStore,
198
213
  exportLog,
@@ -212,6 +227,7 @@ const execSlowTranscriptDeletion = async (t, { keepTranscripts }) => {
212
227
  const {
213
228
  db,
214
229
  dbDir,
230
+ archiveDir,
215
231
  commit,
216
232
  transcriptStore,
217
233
  exportLog,
@@ -241,12 +257,13 @@ const execSlowTranscriptDeletion = async (t, { keepTranscripts }) => {
241
257
  'transcript.v1.4': t4,
242
258
  'transcript.v1.6': t6,
243
259
  };
244
- const expectedArtifactNames = [
245
- 'transcript.v1.0.2',
246
- 'transcript.v1.2.4',
247
- 'transcript.v1.4.6',
248
- 'transcript.v1.6.8',
249
- ];
260
+ const expectedArtifactContents = {
261
+ 'transcript.v1.0.2': 'aaa\nbbb\n',
262
+ 'transcript.v1.2.4': 'ccc\nddd\n',
263
+ 'transcript.v1.4.6': 'eee\nfff\n',
264
+ 'transcript.v1.6.8': 'ggg\nhhh\n',
265
+ };
266
+ const expectedArtifactNames = Object.keys(expectedArtifactContents);
250
267
 
251
268
  t.deepEqual(stripHashes(currentExportData), expectedLiveExportData);
252
269
  t.is(
@@ -255,6 +272,17 @@ const execSlowTranscriptDeletion = async (t, { keepTranscripts }) => {
255
272
  );
256
273
  t.is(db.prepare('SELECT COUNT(*) FROM transcriptSpans').pluck().get(), 4);
257
274
 
275
+ // verify archived transcripts
276
+ t.deepEqual(
277
+ fs.readdirSync(archiveDir),
278
+ expectedArtifactNames.slice(0, -1).map(name => `${name}.gz`),
279
+ );
280
+ for (const name of expectedArtifactNames.slice(0, -1)) {
281
+ const filePath = path.join(archiveDir, `${name}.gz`);
282
+ const contents = zlib.gunzipSync(fs.readFileSync(filePath)).toString();
283
+ t.is(contents, expectedArtifactContents[name], `${filePath} contents`);
284
+ }
285
+
258
286
  // an "operational"-mode export should list all spans, but only have
259
287
  // artifacts for the current one
260
288
  {
@@ -284,15 +312,24 @@ const execSlowTranscriptDeletion = async (t, { keepTranscripts }) => {
284
312
  // deletes the .current record (i.e. it transforms .current into a
285
313
  // closed record)
286
314
  {
287
- transcriptStore.stopUsingTranscript(vatID);
315
+ await transcriptStore.stopUsingTranscript(vatID);
288
316
  await commit();
289
317
  t.deepEqual(stripHashes(currentExportData), expectedStoppedExportData);
290
318
  exportLog.length = 0;
291
319
  // stopUsingTranscript is idempotent
292
- transcriptStore.stopUsingTranscript(vatID);
320
+ await transcriptStore.stopUsingTranscript(vatID);
293
321
  await commit();
294
322
  t.deepEqual(exportLog, []);
295
323
  }
324
+ t.deepEqual(
325
+ fs.readdirSync(archiveDir),
326
+ expectedArtifactNames.map(name => `${name}.gz`),
327
+ );
328
+ for (const name of expectedArtifactNames) {
329
+ const filePath = path.join(archiveDir, `${name}.gz`);
330
+ const contents = zlib.gunzipSync(fs.readFileSync(filePath)).toString();
331
+ t.is(contents, expectedArtifactContents[name], `${filePath} contents`);
332
+ }
296
333
 
297
334
  // All exports (debug and non-debug) in this "terminated but not
298
335
  // deleted" state will still have the export-data keys. Only
@@ -47,7 +47,7 @@ const exportTest = test.macro(async (t, mode) => {
47
47
  // incarnation 0
48
48
  ks.transcriptStore.addItem('v1', 'start-worker'); // 0
49
49
  ks.transcriptStore.addItem('v1', 'shutdown-worker'); // 1
50
- ks.transcriptStore.rolloverIncarnation('v1');
50
+ await ks.transcriptStore.rolloverIncarnation('v1');
51
51
  const spanHash0 =
52
52
  '5bee0f44eca02f23eab03703e84ed2647d5d117fed99e1c30a3b424b7f082ab9';
53
53
 
@@ -56,7 +56,7 @@ const exportTest = test.macro(async (t, mode) => {
56
56
  ks.transcriptStore.addItem('v1', 'delivery1'); // 3
57
57
  await ks.snapStore.saveSnapshot('v1', 4, getSnapshotStream(snapshotData));
58
58
  ks.transcriptStore.addItem('v1', 'save-snapshot'); // 4
59
- ks.transcriptStore.rolloverSpan('v1'); // range= 2..5
59
+ await ks.transcriptStore.rolloverSpan('v1'); // range= 2..5
60
60
  const spanHash1 =
61
61
  '57152efdd7fdf75c03371d2b4f1088d5bf3eae7fe643babce527ff81df38998c';
62
62
 
@@ -64,7 +64,7 @@ const exportTest = test.macro(async (t, mode) => {
64
64
  ks.transcriptStore.addItem('v1', 'delivery2'); // 6
65
65
  await ks.snapStore.saveSnapshot('v1', 7, getSnapshotStream(snapshotData));
66
66
  ks.transcriptStore.addItem('v1', 'save-snapshot'); // 7
67
- ks.transcriptStore.rolloverSpan('v1'); // range= 5..8
67
+ await ks.transcriptStore.rolloverSpan('v1'); // range= 5..8
68
68
  const spanHash2 =
69
69
  '1947001e78e01bd1e773feb22b4ffc530447373b9de9274d5d5fbda3f23dbf2b';
70
70
 
@@ -251,7 +251,7 @@ test('export omits pruned span artifacts', async t => {
251
251
  ks.transcriptStore.addItem('v1', 'delivery1'); // 1
252
252
  await ks.snapStore.saveSnapshot('v1', 2, getSnapshotStream(snapshotData));
253
253
  ks.transcriptStore.addItem('v1', 'save-snapshot'); // 2
254
- ks.transcriptStore.rolloverSpan('v1'); // range= 0..3
254
+ await ks.transcriptStore.rolloverSpan('v1'); // range= 0..3
255
255
  const spanHash1 =
256
256
  '57152efdd7fdf75c03371d2b4f1088d5bf3eae7fe643babce527ff81df38998c';
257
257
  // rolloverSpan prunes the contents of the old span
@@ -87,7 +87,7 @@ async function fakeAVatSnapshot(vat, ks) {
87
87
  await ks.snapStore.saveSnapshot(vat.vatID, vat.endPos, getSnapshotStream());
88
88
  ks.transcriptStore.addItem(vat.vatID, 'save-snapshot');
89
89
  vat.endPos += 1;
90
- ks.transcriptStore.rolloverSpan(vat.vatID);
90
+ await ks.transcriptStore.rolloverSpan(vat.vatID);
91
91
  ks.transcriptStore.addItem(vat.vatID, 'load-snapshot');
92
92
  vat.endPos += 1;
93
93
  }
@@ -1,12 +1,17 @@
1
1
  // @ts-check
2
+ import test from 'ava';
2
3
 
3
4
  import { Buffer } from 'node:buffer';
4
- import zlib from 'zlib';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import zlib from 'node:zlib';
5
8
  import sqlite3 from 'better-sqlite3';
9
+ import tmp from 'tmp';
6
10
 
7
- import test from 'ava';
8
11
  import { makeMeasureSeconds } from '@agoric/internal';
9
12
  import { makeSnapStore } from '../src/snapStore.js';
13
+ import { makeArchiveSnapshot } from '../src/archiver.js';
14
+ import { tmpDir } from './util.js';
10
15
 
11
16
  function makeExportLog() {
12
17
  const exportLog = [];
@@ -31,6 +36,10 @@ harden(getSnapshotStream);
31
36
  test('compress to cache file; closes snapshot stream', async t => {
32
37
  const db = sqlite3(':memory:');
33
38
  const exportLog = makeExportLog();
39
+ const [archiveDir, cleanupArchives] = await tmpDir('archives');
40
+ t.teardown(cleanupArchives);
41
+ const fsPowers = { fs, path, tmp };
42
+ const archiveSnapshot = makeArchiveSnapshot(archiveDir, fsPowers);
34
43
  const store = makeSnapStore(
35
44
  db,
36
45
  ensureTxn,
@@ -38,6 +47,7 @@ test('compress to cache file; closes snapshot stream', async t => {
38
47
  measureSeconds: makeMeasureSeconds(() => 0),
39
48
  },
40
49
  exportLog.noteExport,
50
+ { archiveSnapshot },
41
51
  );
42
52
 
43
53
  const snapshotStream = getSnapshotStream('abc');
@@ -50,6 +60,7 @@ test('compress to cache file; closes snapshot stream', async t => {
50
60
  uncompressedSize: 3,
51
61
  compressedSize: 23,
52
62
  dbSaveSeconds: 0,
63
+ archiveWriteSeconds: 0,
53
64
  compressSeconds: 0,
54
65
  });
55
66
  const snapshotInfo = store.getSnapshotInfo('fakeVatID');
@@ -89,6 +100,26 @@ test('compress to cache file; closes snapshot stream', async t => {
89
100
  ['snapshot.fakeVatID.47', JSON.stringify(logInfo)],
90
101
  ['snapshot.fakeVatID.current', `snapshot.fakeVatID.47`],
91
102
  ]);
103
+
104
+ // verify disk archive
105
+ t.deepEqual(
106
+ fs.readdirSync(archiveDir),
107
+ ['snapshot.fakeVatID.47.gz'],
108
+ 'archive must be written to disk',
109
+ );
110
+ const fileContents = fs.readFileSync(
111
+ path.join(archiveDir, 'snapshot.fakeVatID.47.gz'),
112
+ );
113
+ t.is(
114
+ fileContents.length,
115
+ dbInfo.compressedSize,
116
+ 'file size must match database compressedSize',
117
+ );
118
+ t.is(
119
+ zlib.gunzipSync(fileContents).toString(),
120
+ 'abc',
121
+ 'gunzip(fileContents) must match input data',
122
+ );
92
123
  });
93
124
 
94
125
  test('snapStore prepare / commit delete is robust', async t => {
@@ -96,11 +127,17 @@ test('snapStore prepare / commit delete is robust', async t => {
96
127
  measureSeconds: makeMeasureSeconds(() => 0),
97
128
  };
98
129
  const db = sqlite3(':memory:');
130
+ const [archiveDir, cleanupArchives] = await tmpDir('archives');
131
+ t.teardown(cleanupArchives);
132
+ const fsPowers = { fs, path, tmp };
133
+ const archiveSnapshot = makeArchiveSnapshot(archiveDir, fsPowers);
99
134
  const store = makeSnapStore(db, ensureTxn, io, () => {}, {
100
135
  keepSnapshots: true,
136
+ archiveSnapshot,
101
137
  });
102
138
 
103
139
  const hashes = [];
140
+ const expectedFiles = [];
104
141
  for (let i = 0; i < 5; i += 1) {
105
142
  const { hash } = await store.saveSnapshot(
106
143
  'fakeVatID2',
@@ -108,6 +145,7 @@ test('snapStore prepare / commit delete is robust', async t => {
108
145
  getSnapshotStream(`file ${i}`),
109
146
  );
110
147
  hashes.push(hash);
148
+ expectedFiles.push(`snapshot.fakeVatID2.${i}.gz`);
111
149
  }
112
150
  const sqlCountSnapshots = db.prepare(`
113
151
  SELECT COUNT(*)
@@ -116,19 +154,24 @@ test('snapStore prepare / commit delete is robust', async t => {
116
154
  sqlCountSnapshots.pluck(true);
117
155
 
118
156
  t.is(sqlCountSnapshots.get(), 5);
157
+ t.deepEqual(fs.readdirSync(archiveDir), expectedFiles);
119
158
 
120
159
  store.deleteSnapshotByHash('fakeVatID2', hashes[2]);
121
160
  t.is(sqlCountSnapshots.get(), 4);
161
+ t.deepEqual(fs.readdirSync(archiveDir), expectedFiles);
122
162
 
123
163
  // Restore (re-save) between prepare and commit.
124
164
  store.deleteSnapshotByHash('fakeVatID2', hashes[3]);
125
165
  await store.saveSnapshot('fakeVatID3', 29, getSnapshotStream(`file 3`));
166
+ expectedFiles.push(`snapshot.fakeVatID3.29.gz`);
126
167
  t.true(store.hasHash('fakeVatID3', hashes[3]));
168
+ t.deepEqual(fs.readdirSync(archiveDir), expectedFiles);
127
169
 
128
170
  store.deleteVatSnapshots('fakeVatID2');
129
171
  t.is(sqlCountSnapshots.get(), 1);
130
172
  store.deleteVatSnapshots('fakeVatID3');
131
173
  t.is(sqlCountSnapshots.get(), 0);
174
+ t.deepEqual(fs.readdirSync(archiveDir), expectedFiles);
132
175
 
133
176
  for (let i = 0; i < 5; i += 1) {
134
177
  const { hash } = await store.saveSnapshot(
@@ -137,8 +180,10 @@ test('snapStore prepare / commit delete is robust', async t => {
137
180
  getSnapshotStream(`file ${i}`),
138
181
  );
139
182
  hashes.push(hash);
183
+ expectedFiles.push(`snapshot.fakeVatID4.${i}.gz`);
140
184
  }
141
185
  t.is(sqlCountSnapshots.get(), 5);
142
186
  store.deleteAllUnusedSnapshots();
143
187
  t.is(sqlCountSnapshots.get(), 1);
188
+ t.deepEqual(fs.readdirSync(archiveDir), expectedFiles);
144
189
  });
@@ -181,10 +181,10 @@ const testTranscriptStore = test.macro({
181
181
  transcriptStore.initTranscript('st1');
182
182
  transcriptStore.initTranscript('st2');
183
183
  transcriptStore.addItem('st1', 'zeroth');
184
- transcriptStore.rolloverSpan('st1');
184
+ await transcriptStore.rolloverSpan('st1');
185
185
  transcriptStore.addItem('st1', 'first');
186
186
  transcriptStore.addItem('st1', 'second');
187
- transcriptStore.rolloverSpan('st1');
187
+ await transcriptStore.rolloverSpan('st1');
188
188
  transcriptStore.addItem('st1', 'third');
189
189
  transcriptStore.addItem('st2', 'oneth');
190
190
  transcriptStore.addItem('st1', 'fourth');