From d44a0478343663f0b65eee786d4c439868e633de Mon Sep 17 00:00:00 2001 From: Andre Staltz Date: Tue, 2 Jan 2024 12:18:26 +0200 Subject: [PATCH] load() is idempotent and safe in race conditions --- lib/index.js | 90 +++++++++++++++++++++++++++------------------- test/index.test.js | 1 + 2 files changed, 55 insertions(+), 36 deletions(-) diff --git a/lib/index.js b/lib/index.js index a320662..550f3ca 100644 --- a/lib/index.js +++ b/lib/index.js @@ -81,7 +81,7 @@ function initDict(peer, config) { if (ghostSpan < 1) throw new Error('config.dict.ghostSpan must be >= 0') //#region state - let accountID = /** @type {string | null} */ (null) + let loadedAccountID = /** @type {string | null} */ (null) let loadPromise = /** @type {Promise | null} */ (null) let cancelOnRecordAdded = /** @type {CallableFunction | null} */ (null) const tangles = /** @type {Map} */ (new Map()) @@ -164,10 +164,10 @@ function initDict(peer, config) { */ function isValidDictMoot(msg) { if (!msg) return false - if (msg.metadata.account !== accountID) return false + if (msg.metadata.account !== loadedAccountID) return false const domain = msg.metadata.domain if (!domain.startsWith(PREFIX)) return false - return MsgV4.isMoot(msg, accountID, domain) + return MsgV4.isMoot(msg, loadedAccountID, domain) } /** @@ -178,7 +178,7 @@ function initDict(peer, config) { function isValidDictMsg(msg) { if (!msg) return false if (!msg.data) return false - if (msg.metadata.account !== accountID) return false + if (msg.metadata.account !== loadedAccountID) return false if (!msg.metadata.domain.startsWith(PREFIX)) return false if (!msg.data.update) return false if (typeof msg.data.update !== 'object') return false @@ -235,7 +235,7 @@ function initDict(peer, config) { * @param {Msg} msg */ function maybeLearnAboutDict(msgID, msg) { - if (msg.metadata.account !== accountID) return + if (msg.metadata.account !== loadedAccountID) return if (isValidDictMoot(msg)) { learnDictMoot(msgID, msg) return @@ -262,10 +262,11 @@ function initDict(peer, config) { */ function _squeezePotential(subdomain) { assertDBPlugin(peer) - if (!accountID) throw new Error('Cannot squeeze potential before loading') + // prettier-ignore + if (!loadedAccountID) throw new Error('Cannot squeeze potential before loading') // TODO: improve this so that the squeezePotential is the size of the // tangle suffix built as a slice from the fieldRoots - const mootID = MsgV4.getMootID(accountID, fromSubdomain(subdomain)) + const mootID = MsgV4.getMootID(loadedAccountID, fromSubdomain(subdomain)) const tangle = peer.db.getTangle(mootID) const maxDepth = tangle.maxDepth const fieldRoots = _getFieldRoots(subdomain) @@ -286,7 +287,7 @@ function initDict(peer, config) { */ function forceUpdate(subdomain, update, cb) { assertDBPlugin(peer) - if (!accountID) throw new Error('Cannot force update before loading') + if (!loadedAccountID) throw new Error('Cannot force update before loading') const domain = fromSubdomain(subdomain) // Populate supersedes @@ -297,7 +298,7 @@ function initDict(peer, config) { } peer.db.feed.publish( - { account: accountID, domain, data: { update, supersedes } }, + { account: loadedAccountID, domain, data: { update, supersedes } }, (err, rec) => { // prettier-ignore if (err) return cb(new Error('Failed to create msg when force-updating Dict', { cause: err })) @@ -310,28 +311,42 @@ function initDict(peer, config) { //#region public methods /** - * @param {string} id + * @param {string} accountID * @param {CB} cb */ - function load(id, cb) { + function load(accountID, cb) { assertDBPlugin(peer) - accountID = id + if (accountID === loadedAccountID) { + loaded(cb) + return + } + if (loadedAccountID !== null) { + // prettier-ignore + cb(new Error(`Cannot load Dict for account "${accountID}" because Dict for account "${loadedAccountID}" is already loaded`)) + return + } + loadedAccountID = accountID loadPromise = new Promise((resolve, reject) => { - for (const rec of peer.db.records()) { - if (!rec.msg) continue - maybeLearnAboutDict(rec.id, rec.msg) - } - cancelOnRecordAdded = peer.db.onRecordAdded( - (/** @type {RecPresent} */ rec) => { - try { - maybeLearnAboutDict(rec.id, rec.msg) - } catch (err) { - console.error(err) - } + // microtask is needed to ensure that loadPromise is assigned BEFORE this + // body is executed (which in turn does inversion of control when `cb` or + // `resolve` is called) + queueMicrotask(() => { + for (const rec of peer.db.records()) { + if (!rec.msg) continue + maybeLearnAboutDict(rec.id, rec.msg) } - ) - resolve() - cb() + cancelOnRecordAdded = peer.db.onRecordAdded( + (/** @type {RecPresent} */ rec) => { + try { + maybeLearnAboutDict(rec.id, rec.msg) + } catch (err) { + console.error(err) + } + } + ) + resolve() + cb() + }) }) } @@ -339,7 +354,8 @@ function initDict(peer, config) { * @param {string} subdomain */ function _getFieldRoots(subdomain) { - if (!accountID) throw new Error('Cannot getFieldRoots() before loading') + // prettier-ignore + if (!loadedAccountID) throw new Error('Cannot getFieldRoots() before loading') return fieldRoots.getAll(subdomain) } @@ -403,7 +419,7 @@ function initDict(peer, config) { const mootID = MsgV4.getMootID(id, domain) const tangle = peer.db.getTangle(mootID) if (!tangle || tangle.size === 0) { - if (id === accountID) return {} + if (id === loadedAccountID) return {} else return null } const msgIDs = tangle.topoSort() @@ -424,10 +440,10 @@ function initDict(peer, config) { * @returns {string} */ function getFeedID(subdomain) { - if (!accountID) throw new Error('Cannot getFeedID() before loading') + if (!loadedAccountID) throw new Error('Cannot getFeedID() before loading') assertDBPlugin(peer) const domain = fromSubdomain(subdomain) - return MsgV4.getMootID(accountID, domain) + return MsgV4.getMootID(loadedAccountID, domain) } /** @@ -474,11 +490,12 @@ function initDict(peer, config) { * @param {CB} cb */ function update(subdomain, update, cb) { - if (!accountID) return cb(new Error('Cannot update before loading')) + if (!loadedAccountID) return cb(new Error('Cannot update before loading')) loaded(() => { - if (!accountID) return cb(new Error('Expected account to be loaded')) - const dict = read(accountID, subdomain) + // prettier-ignore + if (!loadedAccountID) return cb(new Error('Expected account to be loaded')) + const dict = read(loadedAccountID, subdomain) // prettier-ignore if (!dict) return cb(new Error(`Cannot update non-existent dict "${subdomain}`)) @@ -499,13 +516,14 @@ function initDict(peer, config) { * @param {CB} cb */ function squeeze(subdomain, cb) { - if (!accountID) return cb(new Error('Cannot squeeze before loading')) + if (!loadedAccountID) return cb(new Error('Cannot squeeze before loading')) const potential = _squeezePotential(subdomain) if (potential < 1) return cb(null, false) loaded(() => { - if (!accountID) return cb(new Error('Expected account to be loaded')) - const dict = read(accountID, subdomain) + // prettier-ignore + if (!loadedAccountID) return cb(new Error('Expected account to be loaded')) + const dict = read(loadedAccountID, subdomain) // prettier-ignore if (!dict) return cb(new Error(`Cannot squeeze non-existent Dict "${subdomain}"`)) forceUpdate(subdomain, dict, (err, _forceUpdated) => { diff --git a/test/index.test.js b/test/index.test.js index 58e3046..2421dd6 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -42,6 +42,7 @@ test('setup', async (t) => { _nonce: 'alice', }) await p(peer.dict.load)(aliceID) + await p(peer.dict.load)(aliceID) // on purpose test that re-load is idempotent peer.dict.setGhostSpan(4) assert.equal(peer.dict.getGhostSpan(), 4, 'getGhostSpan')