diff --git a/lib/index.js b/lib/index.js index ff0ecda..7a8d5eb 100644 --- a/lib/index.js +++ b/lib/index.js @@ -76,25 +76,37 @@ function initGC(peer, config) { function cleanup(cb) { assertDBExists(peer) assertGoalsExists(peer) - debug('cleanup goalless started') + debug('cleanup-per-purpose started') + const done = multicb({ pluck: 1 }) let waiting = false for (const rec of peer.db.records()) { if (!rec.msg) continue - const purpose = peer.goals.getRecordPurpose(rec) + const { id: msgID, msg } = rec + const [purpose, details] = peer.goals.getMsgPurpose(msgID, msg) if (purpose === 'none') { - peer.db.del(rec.id, done()) + peer.db.del(msgID, done()) + waiting = true + } else if (purpose === 'ghost') { + const { tangleID, span } = details + const cb = done() + // TODO: Could one msg be a ghostable in MANY tangles? Or just one? + peer.db.ghosts.add({ tangleID, msgID, span }, (err) => { + // prettier-ignore + if (err) return cb(new Error('gc failed to add ghost', { cause: err })) + peer.db.del(msgID, cb) + }) waiting = true } else if (purpose === 'trail') { - peer.db.erase(rec.id, done()) + peer.db.erase(msgID, done()) waiting = true } } /** @param {Error=} err */ function whenEnded(err) { // prettier-ignore - if (err) debug('cleanup goalless ended with an error %s', err.message ?? err) - else debug('cleanup goalless ended') + if (err) debug('cleanup-per-purpose ended with an error %s', err.message ?? err) + else debug('cleanup-per-purpose ended') cb() } if (waiting) done(whenEnded) diff --git a/package.json b/package.json index 051c0c3..4c73f2c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "ppppp-db": "github:staltz/ppppp-db", "ppppp-goals": "github:staltz/ppppp-goals", "ppppp-keypair": "github:staltz/ppppp-keypair", + "ppppp-record": "github:staltz/ppppp-record", "prettier": "^2.6.2", "pretty-quick": "^3.1.3", "rimraf": "^4.4.0", diff --git a/test/record-ghosts.test.js b/test/record-ghosts.test.js new file mode 100644 index 0000000..bbfcfac --- /dev/null +++ b/test/record-ghosts.test.js @@ -0,0 +1,99 @@ +const test = require('node:test') +const assert = require('node:assert') +const p = require('node:util').promisify +const { createPeer } = require('./util') + +function getFields(msgs) { + return msgs + .map((msg) => msg.data?.update) + .filter((x) => !!x) + .map((x) => x.age ?? x.name) +} + +function isErased(msg) { + return !msg.data +} + +function isDeleted(msg) { + return !msg +} + +function isPresent(msg) { + return !!msg.data.update +} + +test('record ghosts', async (t) => { + const alice = createPeer({ + name: 'alice', + gc: { maxLogBytes: 100 * 1024 * 1024 }, + record: { ghostSpan: 2 }, + }) + + await alice.db.loaded() + + // Alice creates her own account + const aliceID = await p(alice.db.account.create)({ + domain: 'account', + _nonce: 'alice', + }) + + // Alice constructs a record + await p(alice.record.load)(aliceID) + await p(alice.record.update)('profile', { name: 'alice' }) + await p(alice.record.update)('profile', { age: 24 }) + await p(alice.record.update)('profile', { name: 'Alice' }) + await p(alice.record.update)('profile', { age: 25 }) + await p(alice.record.update)('profile', { name: 'ALICE' }) + const recordID = alice.record.getFeedID('profile') + + let mootID + let msgID1 + let msgID2 + let msgID3 + let msgID4 + let msgID5 + for (const rec of alice.db.records()) { + if (rec.msg.metadata.dataSize === 0) mootID = rec.id + if (rec.msg.data?.update?.name === 'alice') msgID1 = rec.id + if (rec.msg.data?.update?.age === 24) msgID2 = rec.id + if (rec.msg.data?.update?.name === 'Alice') msgID3 = rec.id + if (rec.msg.data?.update?.age === 25) msgID4 = rec.id + if (rec.msg.data?.update?.name === 'ALICE') msgID5 = rec.id + } + + // Assert situation before GC + assert.deepEqual( + getFields([...alice.db.msgs()]), + ['alice', 24, 'Alice', 25, 'ALICE'], + 'has all record msgs' + ) + assert.ok(isErased(alice.db.get(mootID)), 'moot by def erased') + assert.ok(isPresent(alice.db.get(msgID1)), 'msg1 exists') + assert.ok(isPresent(alice.db.get(msgID2)), 'msg2 exists') + assert.ok(isPresent(alice.db.get(msgID3)), 'msg3 exists') + assert.ok(isPresent(alice.db.get(msgID4)), 'msg4 exists') + assert.ok(isPresent(alice.db.get(msgID5)), 'msg5 exists') + + // Perform garbage collection + alice.goals.set(aliceID, 'all') + alice.goals.set(recordID, 'record') + await p(alice.gc.forceImmediately)() + + // Assert situation after GC + assert.deepEqual( + getFields([...alice.db.msgs()]), + [25, 'ALICE'], + 'alice has only field root msgs' + ) + + assert.ok(isErased(alice.db.get(mootID)), 'moot by def erased') + assert.ok(isDeleted(alice.db.get(msgID1)), 'msg1 deleted') + assert.ok(isDeleted(alice.db.get(msgID2)), 'msg2 deleted') // ghost! + assert.ok(isErased(alice.db.get(msgID3)), 'msg3 erased') + assert.ok(isPresent(alice.db.get(msgID4)), 'msg4 exists') + assert.ok(isPresent(alice.db.get(msgID5)), 'msg5 exists') + + assert.deepEqual(alice.db.ghosts.get(recordID), [msgID2]) + + await p(alice.close)(true) +}) diff --git a/test/util.js b/test/util.js index 51b783a..90308e7 100644 --- a/test/util.js +++ b/test/util.js @@ -18,6 +18,7 @@ function createPeer(opts) { .use(require('secret-stack/plugins/net')) .use(require('secret-handshake-ext/secret-stack')) .use(require('ppppp-db')) + .use(require('ppppp-record')) .use(require('ppppp-goals')) .use(require('ssb-box')) .use(require('../lib'))