Skip to content

Commit 33d0f20

Browse files
gmaclennanachou11
andauthored
fix: fix file storage race condition (#388)
* WIP initial work * rename Rpc to LocalPeers * Handle deviceInfo internally, id -> deviceId * Tests for stream error handling * remove unnecessary constructor * return replication stream * Attach protomux instance to peer info * rename and re-organize * revert changes outside scope of PR * WIP initial work * Tie everything together * rename getProjectInstance * feat: client.listLocalPeers() & `local-peers` evt * feat: add $sync API methods For now this simplifies the API (because we are only supporting local sync, not remote sync over the internet) to: - `project.$sync.getState()` - `project.$sync.start()` - `project.$sync.stop()` - Events - `sync-state` It's currently not possible to stop local discovery, nor is it possible to stop sync of the metadata namespaces (auth, config, blobIndex). The start and stop methods stop the sync of the data and blob namespaces. Fixes #134. Stacked on #360, #358 and #356. * feat: Add project.$waitForInitialSync() method Fixes Add project method to download auth + config cores #233 Rather than call this inside the `client.addProject()` method, instead I think it is better for the API consumer to call `project.$waitForInitialSync()` after adding a project, since this allows the implementer to give user feedback about what is happening. * Wait for initial sync within addProject() * fix: don't add core bitfield until core is ready * feat: expose deviceId on coreManager * fix: wait for project.ready() in waitForInitialSync * fix: skip waitForSync in tests * don't enable/disable namespace if not needed * start core download when created via sparse: false * Add debug logging This was a big lift, but necessary to be able to debug sync issues since temporarily adding console.log statements was too much work, and debugging requires knowing the deviceId associated with each message. * fix timeout * fix: Add new cores to the indexer (!!!) This caused a day of work: a bug from months back * remove unnecessary log stmt * get capabilities.getMany() to include creator * fix invite test * keep blob cores sparse * optional param for LocalPeers * re-org sync and replication Removes old replication code attached to CoreManager Still needs tests to be updated * update package-lock * chore: Add debug logging * Add new logger to discovery + dnssd * Get invite test working * fix manager logger * cleanup invite test (and make it fail :( * fix: handle duplicate connections to LocalPeers * fix stream close before channel open * send invite to non-existent peer * fixed fake timers implementation for tests * new tests for duplicate connections * cleanup and small fix * Better state debug logging * chain of invites test * fix max listeners and add skipped test * fix: only request a core key from one peer Reduces the number of duplicate requests for the same keys. * cleanup members tests with new helprs * wait for project ready when adding * only create 4 clients for chain of invites test * add e2e sync tests * add published @mapeo/mock-data * fix: don't open cores in sparse mode Turns out this changes how core.length etc. work, which confuses things * fix: option to skip auto download for tests * e2e test for stop-start sync * fix coreManager unit tests * fix blob store tests * fix discovery-key event * add coreCount to sync state * test sync with blocked peer & fix bugs * fix datatype unit tests * fix blobs server unit tests * remote peer-sync-controller unit test This is now tested in e2e tests * fix type issues caused by bad lockfile * ignore debug type errors * fixes for review comments * move utils-new into utils * Add debug info to test that sometimes fails * Update package-lock.json version * remove project.ready() (breaks things) * wait for coreOwnership write before returning * use file storage in tests (breaks things) * Catch race condition in CRUD tests * fix race condition with parallel writes * fix tests for new createManagers syntax * fix flakey test This test relied on `peer.connectedAt` changing in order to distinguish connections, but sometimes `connectedAt` was the same for both peers. This adds a 1ms delay before making the second connection, to attempt to stop the flakiness. --------- Co-authored-by: Andrew Chou <andrewchou@fastmail.com>
1 parent c60b92c commit 33d0f20

7 files changed

Lines changed: 81 additions & 46 deletions

File tree

src/datastore/index.js

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ export class DataStore extends TypedEmitter {
4646
#writerCore
4747
#coreIndexer
4848
/** @type {Map<string, import('p-defer').DeferredPromise<void>>} */
49-
#pending = new Map()
49+
#pendingIndex = new Map()
50+
/** @type {Set<import('p-defer').DeferredPromise<void>['promise']>} */
51+
#pendingAppends = new Set()
5052

5153
/**
5254
* @param {object} opts
@@ -104,11 +106,8 @@ export class DataStore extends TypedEmitter {
104106
* @param {MultiCoreIndexer.Entry<'binary'>[]} entries
105107
*/
106108
async #handleEntries(entries) {
107-
// This is needed to avoid the batch running before the append resolves, but
108-
// I think this is only necessary when using random-access-memory, and will
109-
// not be an issue when the indexer is on a separate thread.
110-
await new Promise((res) => setTimeout(res, 0))
111109
await this.#batch(entries)
110+
await Promise.all(this.#pendingAppends)
112111
// Writes to the writerCore need to wait until the entry is indexed before
113112
// returning, so we check if any incoming entry has a pending promise
114113
for (const entry of entries) {
@@ -117,7 +116,7 @@ export class DataStore extends TypedEmitter {
117116
coreDiscoveryKey: discoveryKey(entry.key),
118117
index: entry.index,
119118
})
120-
const pending = this.#pending.get(versionId)
119+
const pending = this.#pendingIndex.get(versionId)
121120
if (!pending) continue
122121
pending.resolve()
123122
}
@@ -143,8 +142,16 @@ export class DataStore extends TypedEmitter {
143142
)
144143
}
145144
const block = encode(doc)
146-
145+
// The indexer batch can sometimes complete before the append below
146+
// resolves, so in the batch function we await any pending appends. We can't
147+
// know the versionId before the append, because docs can be written in the
148+
// same tick, so we can't know their index before append resolves.
149+
const deferredAppend = pDefer()
150+
this.#pendingAppends.add(deferredAppend.promise)
147151
const { length } = await this.#writerCore.append(block)
152+
deferredAppend.resolve()
153+
this.#pendingAppends.delete(deferredAppend.promise)
154+
148155
const index = length - 1
149156
const coreDiscoveryKey = this.#writerCore.discoveryKey
150157
if (!coreDiscoveryKey) {
@@ -153,7 +160,7 @@ export class DataStore extends TypedEmitter {
153160
const versionId = getVersionId({ coreDiscoveryKey, index })
154161
/** @type {import('p-defer').DeferredPromise<void>} */
155162
const deferred = pDefer()
156-
this.#pending.set(versionId, deferred)
163+
this.#pendingIndex.set(versionId, deferred)
157164
await deferred.promise
158165

159166
return /** @type {Extract<MapeoDoc, TDoc>} */ (

test-e2e/manager-invite.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from './utils.js'
1111

1212
test('member invite accepted', async (t) => {
13-
const [creator, joiner] = await createManagers(2)
13+
const [creator, joiner] = await createManagers(2, t)
1414
connectPeers([creator, joiner])
1515
await waitForPeers([creator, joiner])
1616

@@ -64,7 +64,7 @@ test('member invite accepted', async (t) => {
6464
})
6565

6666
test('chain of invites', async (t) => {
67-
const managers = await createManagers(4)
67+
const managers = await createManagers(4, t)
6868
const [creator, ...joiners] = managers
6969
connectPeers(managers)
7070
await waitForPeers(managers)
@@ -114,7 +114,7 @@ test('chain of invites', async (t) => {
114114

115115
// TODO: Needs fix to inviteApi to check capabilities before sending invite
116116
skip("member can't invite", async (t) => {
117-
const managers = await createManagers(3)
117+
const managers = await createManagers(3, t)
118118
const [creator, member, joiner] = managers
119119
connectPeers(managers)
120120
await waitForPeers(managers)
@@ -149,7 +149,7 @@ skip("member can't invite", async (t) => {
149149
})
150150

151151
test('member invite rejected', async (t) => {
152-
const [creator, joiner] = await createManagers(2)
152+
const [creator, joiner] = await createManagers(2, t)
153153
connectPeers([creator, joiner])
154154
await waitForPeers([creator, joiner])
155155

test-e2e/members.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from './utils.js'
1818

1919
test('getting yourself after creating project', async (t) => {
20-
const [manager] = await createManagers(1)
20+
const [manager] = await createManagers(1, t)
2121

2222
const deviceInfo = await manager.getDeviceInfo()
2323
const project = await manager.getProject(await manager.createProject())
@@ -49,7 +49,7 @@ test('getting yourself after creating project', async (t) => {
4949
})
5050

5151
test('getting yourself after adding project (but not yet synced)', async (t) => {
52-
const [manager] = await createManagers(1)
52+
const [manager] = await createManagers(1, t)
5353

5454
const deviceInfo = await manager.getDeviceInfo()
5555
const project = await manager.getProject(
@@ -89,7 +89,7 @@ test('getting yourself after adding project (but not yet synced)', async (t) =>
8989
})
9090

9191
test('getting invited member after invite rejected', async (t) => {
92-
const managers = await createManagers(2)
92+
const managers = await createManagers(2, t)
9393
const [invitor, invitee] = managers
9494
connectPeers(managers)
9595
await waitForPeers(managers)
@@ -120,7 +120,7 @@ test('getting invited member after invite rejected', async (t) => {
120120
})
121121

122122
test('getting invited member after invite accepted', async (t) => {
123-
const managers = await createManagers(2)
123+
const managers = await createManagers(2, t)
124124
const [invitor, invitee] = managers
125125
connectPeers(managers)
126126
await waitForPeers(managers)

test-e2e/project-crud.js

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { test } from 'brittle'
22
import { randomBytes } from 'crypto'
3-
import { KeyManager } from '@mapeo/crypto'
43
import { valueOf } from '../src/utils.js'
5-
import { MapeoManager } from '../src/mapeo-manager.js'
6-
import RAM from 'random-access-memory'
7-
import { stripUndef } from './utils.js'
4+
import { createManager, stripUndef } from './utils.js'
85
import { round } from './utils.js'
6+
import { generate } from '@mapeo/mock-data'
7+
import { setTimeout as delay } from 'timers/promises'
98

109
/** @satisfies {Array<import('@mapeo/schema').MapeoValue>} */
1110
const fixtures = [
@@ -64,34 +63,40 @@ function getUpdateFixture(value) {
6463
}
6564
}
6665

66+
const CREATE_COUNT = 100
67+
6768
test('CRUD operations', async (t) => {
68-
const manager = new MapeoManager({
69-
rootKey: KeyManager.generateRootKey(),
70-
projectMigrationsFolder: new URL('../drizzle/project', import.meta.url)
71-
.pathname,
72-
clientMigrationsFolder: new URL('../drizzle/client', import.meta.url)
73-
.pathname,
74-
dbFolder: ':memory:',
75-
coreStorage: () => new RAM(),
76-
})
69+
const manager = createManager('device0', t)
7770

7871
for (const value of fixtures) {
7972
const { schemaName } = value
80-
t.test(`create and read ${schemaName}`, async (st) => {
73+
await t.test(`create and read ${schemaName}`, async (st) => {
8174
const projectId = await manager.createProject()
8275
const project = await manager.getProject(projectId)
83-
// @ts-ignore - TS can't figure this out, but we're not testing types here so ok to ignore
84-
const written = await project[schemaName].create(value)
85-
const read = await project[schemaName].getByDocId(written.docId)
86-
st.alike(valueOf(stripUndef(written)), value, 'expected value is written')
76+
const values = []
77+
const writePromises = []
78+
let i = 0
79+
while (i++ < CREATE_COUNT) {
80+
const value = valueOf(generate(schemaName)[0])
81+
values.push(value)
82+
writePromises.push(
83+
// @ts-ignore
84+
project[schemaName].create(value)
85+
)
86+
}
87+
const written = await Promise.all(writePromises)
88+
const read = await Promise.all(
89+
written.map((doc) => project[schemaName].getByDocId(doc.docId))
90+
)
8791
st.alike(written, read, 'return create() matches return of getByDocId()')
8892
})
89-
t.test('update', async (st) => {
93+
await t.test('update', async (st) => {
9094
const projectId = await manager.createProject()
9195
const project = await manager.getProject(projectId)
9296
// @ts-ignore
9397
const written = await project[schemaName].create(value)
9498
const updateValue = getUpdateFixture(value)
99+
await delay(1) // delay to ensure updatedAt is different to createdAt
95100
// @ts-ignore
96101
const updated = await project[schemaName].update(
97102
written.versionId,
@@ -112,7 +117,7 @@ test('CRUD operations', async (t) => {
112117
st.is(written.createdAt, updated.createdAt, 'createdAt does not change')
113118
st.is(written.createdBy, updated.createdBy, 'createdBy does not change')
114119
})
115-
t.test('getMany', async (st) => {
120+
await t.test('getMany', async (st) => {
116121
const projectId = await manager.createProject()
117122
const project = await manager.getProject(projectId)
118123
const values = new Array(5).fill(null).map(() => {

test-e2e/sync.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const SCHEMAS_INITIAL_SYNC = ['preset', 'field']
2020

2121
test('Create and sync data', async function (t) {
2222
const COUNT = 5
23-
const managers = await createManagers(COUNT)
23+
const managers = await createManagers(COUNT, t)
2424
const [invitor, ...invitees] = managers
2525
const disconnect = connectPeers(managers, { discovery: false })
2626
const projectId = await invitor.createProject()
@@ -88,7 +88,7 @@ test('start and stop sync', async function (t) {
8888
// Checks that both peers need to start syncing for data to sync, and that
8989
// $sync.stop() actually stops data syncing
9090
const COUNT = 2
91-
const managers = await createManagers(COUNT)
91+
const managers = await createManagers(COUNT, t)
9292
const [invitor, ...invitees] = managers
9393
const disconnect = connectPeers(managers, { discovery: false })
9494
const projectId = await invitor.createProject()
@@ -154,7 +154,7 @@ test('start and stop sync', async function (t) {
154154

155155
test('shares cores', async function (t) {
156156
const COUNT = 5
157-
const managers = await createManagers(COUNT)
157+
const managers = await createManagers(COUNT, t)
158158
const [invitor, ...invitees] = managers
159159
connectPeers(managers, { discovery: false })
160160
const projectId = await invitor.createProject()
@@ -192,7 +192,7 @@ test('shares cores', async function (t) {
192192

193193
test('no sync capabilities === no namespaces sync apart from auth', async (t) => {
194194
const COUNT = 3
195-
const managers = await createManagers(COUNT)
195+
const managers = await createManagers(COUNT, t)
196196
const [invitor, invitee, blocked] = managers
197197
const disconnect1 = connectPeers(managers, { discovery: false })
198198
const projectId = await invitor.createProject()

test-e2e/utils.js

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { once } from 'node:events'
99
import { generate } from '@mapeo/mock-data'
1010
import { valueOf } from '../src/utils.js'
1111
import { randomInt } from 'node:crypto'
12+
import { temporaryDirectory } from 'tempy'
13+
import fsPromises from 'node:fs/promises'
1214

15+
const FAST_TESTS = true
1316
const projectMigrationsFolder = new URL('../drizzle/project', import.meta.url)
1417
.pathname
1518
const clientMigrationsFolder = new URL('../drizzle/client', import.meta.url)
@@ -143,30 +146,48 @@ export async function waitForPeers(managers) {
143146
*
144147
* @template {number} T
145148
* @param {T} count
149+
* @param {import('brittle').TestInstance} t
146150
* @returns {Promise<import('type-fest').ReadonlyTuple<MapeoManager, T>>}
147151
*/
148-
export async function createManagers(count) {
152+
export async function createManagers(count, t) {
149153
// @ts-ignore
150154
return Promise.all(
151155
Array(count)
152156
.fill(null)
153157
.map(async (_, i) => {
154158
const name = 'device' + i
155-
const manager = createManager(name)
159+
const manager = createManager(name, t)
156160
await manager.setDeviceInfo({ name })
157161
return manager
158162
})
159163
)
160164
}
161165

162-
/** @param {string} [seed] */
163-
export function createManager(seed) {
166+
/**
167+
* @param {string} seed
168+
* @param {import('brittle').TestInstance} t
169+
*/
170+
export function createManager(seed, t) {
171+
const dbFolder = FAST_TESTS ? ':memory:' : temporaryDirectory()
172+
const coreStorage = FAST_TESTS ? () => new RAM() : temporaryDirectory()
173+
t.teardown(async () => {
174+
if (FAST_TESTS) return
175+
await Promise.all([
176+
fsPromises.rm(dbFolder, { recursive: true, force: true, maxRetries: 2 }),
177+
// @ts-ignore
178+
fsPromises.rm(coreStorage, {
179+
recursive: true,
180+
force: true,
181+
maxRetries: 2,
182+
}),
183+
])
184+
})
164185
return new MapeoManager({
165186
rootKey: getRootKey(seed),
166187
projectMigrationsFolder,
167188
clientMigrationsFolder,
168-
dbFolder: ':memory:',
169-
coreStorage: () => new RAM(),
189+
dbFolder,
190+
coreStorage,
170191
})
171192
}
172193

tests/local-peers.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { replicate } from './helpers/local-peers.js'
1313
import { randomBytes } from 'node:crypto'
1414
import NoiseSecretStream from '@hyperswarm/secret-stream'
1515
import Protomux from 'protomux'
16+
import { setTimeout as delay } from 'timers/promises'
1617

1718
test('Send invite and accept', async (t) => {
1819
t.plan(3)
@@ -83,6 +84,7 @@ test('Send invite, duplicate connections', async (t) => {
8384

8485
const destroy1 = replicate(r1, r2, { kp1, kp2 })
8586
const [peers1] = await once(r1, 'peers')
87+
await delay(1) // Ensure that connectedAt is different
8688
const destroy2 = replicate(r1, r2, { kp1, kp2 })
8789
const [peers2] = await once(r1, 'peers')
8890

0 commit comments

Comments
 (0)