Skip to content

Commit 5e3114e

Browse files
test: add separate-thread concurrency test (#305)
* tmp: testing concurrent writes with worker threads * test: add separate-thread concurrency test * chore: re-throw errors from writer-worker * fix: replace fast-atomic-write with steno (#285) fixes #284 fast-write-atomic hasn't been updated in 5 years, is CJS, and is slower than steno (updated 2 months ago). Benchmarks for various content-types & libraries (though we only use Uint8Arrays) can be found at https://github.com/SgtPooki/fast-write-atomic#benchmarks However, there may be further room for improvement by moving to [fs.createWriteStream](https://nodejs.org/api/fs.html#fscreatewritestreampath-options) ``` ╰─ ✔ ❯ hyperfine --parameter-list branch 284-chore-replace-fast-write-atomic-with-steno,main --setup "git switch {branch} && npm run reset && npm i && npm run build" --runs 20 -w 1 "npm run test:node" Benchmark 1: npm run test:node (branch = 284-chore-replace-fast-write-atomic-with-steno) Time (mean ± σ): 27.212 s ± 0.832 s [User: 34.810 s, System: 6.051 s] Range (min … max): 25.927 s … 29.324 s 20 runs Benchmark 2: npm run test:node (branch = main) Time (mean ± σ): 42.971 s ± 0.637 s [User: 35.297 s, System: 7.534 s] Range (min … max): 42.178 s … 44.796 s 20 runs Summary npm run test:node (branch = 284-chore-replace-fast-write-atomic-with-steno) ran 1.58 ± 0.05 times faster than npm run test:node (branch = main) ``` --- ### Updated benchmarks of `npm run test` as of 2024-04-19 ``` ╭─    ~/code/work/protocol.ai/ipfs/js-stores    main ?1 ╰─ ✔ ❯ hyperfine --parameter-list branch main,test/not-same-event-loop-concurrency,284-chore-replace-fast-write-atomic-with-steno --setup "git switch {branch} && npm run reset && npm i && npm run build && cd packages/datastore-fs" "npm run test" Benchmark 1: npm run test (branch = main) Time (mean ± σ): 99.415 s ± 2.918 s [User: 69.659 s, System: 23.361 s] Range (min … max): 96.134 s … 105.200 s 10 runs Benchmark 2: npm run test (branch = test/not-same-event-loop-concurrency) Time (mean ± σ): 103.456 s ± 3.186 s [User: 74.442 s, System: 25.261 s] Range (min … max): 98.813 s … 108.429 s 10 runs Benchmark 3: npm run test (branch = 284-chore-replace-fast-write-atomic-with-steno) Time (mean ± σ): 80.308 s ± 2.107 s [User: 74.331 s, System: 22.228 s] Range (min … max): 78.219 s … 84.277 s 10 runs Summary npm run test (branch = 284-chore-replace-fast-write-atomic-with-steno) ran 1.24 ± 0.05 times faster than npm run test (branch = main) 1.29 ± 0.05 times faster than npm run test (branch = test/not-same-event-loop-concurrency) [49m1.944s] ╭─    ~/code/work/protocol.ai/ipfs/js-stores    284-chore-re…c-with-steno ?1 ╰─ ✔ ❯ hyperfine --parameter-list branch main,test/not-same-event-loop-concurrency,284-chore-replace-fast-write-atomic-with-steno --setup "git switch {branch} && npm run reset && npm i && npm run build && cd packages/blockstore-fs" "npm run test" Benchmark 1: npm run test (branch = main) Time (mean ± σ): 98.840 s ± 2.612 s [User: 68.486 s, System: 22.585 s] Range (min … max): 97.005 s … 104.396 s 10 runs Benchmark 2: npm run test (branch = test/not-same-event-loop-concurrency) Time (mean ± σ): 105.307 s ± 2.335 s [User: 72.442 s, System: 24.766 s] Range (min … max): 101.167 s … 109.007 s 10 runs Benchmark 3: npm run test (branch = 284-chore-replace-fast-write-atomic-with-steno) Time (mean ± σ): 77.012 s ± 1.829 s [User: 74.442 s, System: 21.938 s] Range (min … max): 75.258 s … 80.825 s 10 runs Summary npm run test (branch = 284-chore-replace-fast-write-atomic-with-steno) ran 1.28 ± 0.05 times faster than npm run test (branch = main) 1.37 ± 0.04 times faster than npm run test (branch = test/not-same-event-loop-concurrency) ``` * chore: fix lint error --------- Co-authored-by: Alex Potsides <[email protected]>
1 parent dd2f303 commit 5e3114e

File tree

8 files changed

+140
-27
lines changed

8 files changed

+140
-27
lines changed

packages/blockstore-fs/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,16 +162,17 @@
162162
"release": "aegir release"
163163
},
164164
"dependencies": {
165-
"fast-write-atomic": "^0.2.1",
166165
"interface-blockstore": "^5.0.0",
167166
"interface-store": "^6.0.0",
168167
"it-glob": "^3.0.1",
169168
"it-map": "^3.1.1",
170169
"it-parallel-batch": "^3.0.6",
171-
"multiformats": "^13.2.3"
170+
"multiformats": "^13.2.3",
171+
"steno": "^4.0.2"
172172
},
173173
"devDependencies": {
174174
"aegir": "^44.1.1",
175-
"interface-blockstore-tests": "^7.0.0"
175+
"interface-blockstore-tests": "^7.0.0",
176+
"threads": "^1.7.0"
176177
}
177178
}

packages/blockstore-fs/src/index.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,39 +14,34 @@
1414

1515
import fs from 'node:fs/promises'
1616
import path from 'node:path'
17-
import { promisify } from 'node:util'
18-
// @ts-expect-error no types
19-
import fwa from 'fast-write-atomic'
2017
import { OpenFailedError, type AwaitIterable, PutFailedError, NotFoundError, DeleteFailedError } from 'interface-store'
2118
import glob from 'it-glob'
2219
import map from 'it-map'
2320
import parallelBatch from 'it-parallel-batch'
21+
import { Writer } from 'steno'
2422
import { NextToLast } from './sharding.js'
2523
import type { ShardingStrategy } from './sharding.js'
2624
import type { Blockstore, Pair } from 'interface-blockstore'
2725
import type { CID } from 'multiformats/cid'
2826

29-
const writeAtomic = promisify(fwa)
30-
3127
/**
3228
* Write a file atomically
3329
*/
3430
async function writeFile (file: string, contents: Uint8Array): Promise<void> {
3531
try {
36-
await writeAtomic(file, contents)
32+
const writer = new Writer(file)
33+
await writer.write(contents)
3734
} catch (err: any) {
38-
if (err.code === 'EPERM' && err.syscall === 'rename') {
39-
// fast-write-atomic writes a file to a temp location before renaming it.
40-
// On Windows, if the final file already exists this error is thrown.
41-
// No such error is thrown on Linux/Mac
35+
if (err.syscall === 'rename' && ['ENOENT', 'EPERM'].includes(err.code)) {
36+
// steno writes a file to a temp location before renaming it.
37+
// If the final file already exists this error is thrown.
4238
// Make sure we can read & write to this file
4339
await fs.access(file, fs.constants.F_OK | fs.constants.W_OK)
4440

4541
// The file was created by another context - this means there were
4642
// attempts to write the same block by two different function calls
4743
return
4844
}
49-
5045
throw err
5146
}
5247
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { CID } from 'multiformats/cid'
2+
import { expose } from 'threads/worker'
3+
import { FsBlockstore } from '../../src/index.js'
4+
5+
let fs: FsBlockstore
6+
expose({
7+
async isReady (path) {
8+
fs = new FsBlockstore(path)
9+
try {
10+
await fs.open()
11+
return true
12+
} catch (err) {
13+
// eslint-disable-next-line no-console
14+
console.error('Error opening blockstore', err)
15+
throw err
16+
}
17+
},
18+
async put (cidString, value) {
19+
const key = CID.parse(cidString)
20+
try {
21+
return await fs.put(key, value)
22+
} catch (err) {
23+
// eslint-disable-next-line no-console
24+
console.error('Error putting block', err)
25+
throw err
26+
}
27+
}
28+
})

packages/blockstore-fs/test/index.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { expect } from 'aegir/chai'
55
import { interfaceBlockstoreTests } from 'interface-blockstore-tests'
66
import { base32 } from 'multiformats/bases/base32'
77
import { CID } from 'multiformats/cid'
8+
import { spawn, Thread, Worker } from 'threads'
89
import { FsBlockstore } from '../src/index.js'
910
import { FlatDirectory, NextToLast } from '../src/sharding.js'
1011

@@ -157,4 +158,35 @@ describe('FsBlockstore', () => {
157158

158159
expect(res).to.deep.equal(value)
159160
})
161+
162+
/**
163+
* This test spawns 10 workers that concurrently write to the same blockstore.
164+
* it's different from the previous test because it uses workers to write to the blockstore
165+
* which means that the writes are happening in parallel in different threads.
166+
*/
167+
it('can survive concurrent worker writes', async () => {
168+
const dir = path.join(os.tmpdir(), `test-${Math.random()}`)
169+
const key = CID.parse('QmeimKZyjcBnuXmAD9zMnSjM9JodTbgGT3gutofkTqz9rE')
170+
const workers = await Promise.all(new Array(10).fill(0).map(async () => {
171+
const worker = await spawn(new Worker('./fixtures/writer-worker.js'))
172+
await worker.isReady(dir)
173+
return worker
174+
}))
175+
176+
try {
177+
const value = utf8Encoder.encode('Hello world')
178+
// 100 iterations of looping over all workers and putting the same key value pair
179+
await Promise.all(new Array(100).fill(0).map(async () => {
180+
return Promise.all(workers.map(async (worker) => worker.put(key.toString(), value)))
181+
}))
182+
183+
const fs = new FsBlockstore(dir)
184+
await fs.open()
185+
const res = await fs.get(key)
186+
187+
expect(res).to.deep.equal(value)
188+
} finally {
189+
await Promise.all(workers.map(async (worker) => Thread.terminate(worker)))
190+
}
191+
})
160192
})

packages/datastore-fs/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,16 +141,17 @@
141141
},
142142
"dependencies": {
143143
"datastore-core": "^10.0.0",
144-
"fast-write-atomic": "^0.2.1",
145144
"interface-datastore": "^8.0.0",
146145
"interface-store": "^6.0.0",
147146
"it-glob": "^3.0.1",
148147
"it-map": "^3.1.1",
149-
"it-parallel-batch": "^3.0.6"
148+
"it-parallel-batch": "^3.0.6",
149+
"steno": "^4.0.2"
150150
},
151151
"devDependencies": {
152152
"aegir": "^44.1.1",
153153
"interface-datastore-tests": "^6.0.0",
154-
"ipfs-utils": "^9.0.14"
154+
"ipfs-utils": "^9.0.14",
155+
"threads": "^1.7.0"
155156
}
156157
}

packages/datastore-fs/src/index.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,31 +14,27 @@
1414

1515
import fs from 'node:fs/promises'
1616
import path from 'node:path'
17-
import { promisify } from 'util'
1817
import { BaseDatastore } from 'datastore-core'
19-
// @ts-expect-error no types
20-
import fwa from 'fast-write-atomic'
2118
import { Key } from 'interface-datastore'
2219
import { OpenFailedError, NotFoundError, PutFailedError, DeleteFailedError } from 'interface-store'
2320
import glob from 'it-glob'
2421
import map from 'it-map'
2522
import parallel from 'it-parallel-batch'
23+
import { Writer } from 'steno'
2624
import type { KeyQuery, Pair, Query } from 'interface-datastore'
2725
import type { AwaitIterable } from 'interface-store'
2826

29-
const writeAtomic = promisify(fwa)
30-
3127
/**
3228
* Write a file atomically
3329
*/
3430
async function writeFile (path: string, contents: Uint8Array): Promise<void> {
3531
try {
36-
await writeAtomic(path, contents)
32+
const writer = new Writer(path)
33+
await writer.write(contents)
3734
} catch (err: any) {
38-
if (err.code === 'EPERM' && err.syscall === 'rename') {
39-
// fast-write-atomic writes a file to a temp location before renaming it.
40-
// On Windows, if the final file already exists this error is thrown.
41-
// No such error is thrown on Linux/Mac
35+
if (err.syscall === 'rename' && ['ENOENT', 'EPERM'].includes(err.code)) {
36+
// steno writes a file to a temp location before renaming it.
37+
// If the final file already exists this error is thrown.
4238
// Make sure we can read & write to this file
4339
await fs.access(path, fs.constants.F_OK | fs.constants.W_OK)
4440

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Key } from 'interface-datastore'
2+
import { expose } from 'threads/worker'
3+
import { FsDatastore } from '../../src/index.js'
4+
5+
let fs: FsDatastore
6+
expose({
7+
async isReady (path) {
8+
fs = new FsDatastore(path)
9+
try {
10+
await fs.open()
11+
return true
12+
} catch (err) {
13+
// eslint-disable-next-line no-console
14+
console.error('Error opening blockstore', err)
15+
throw err
16+
}
17+
},
18+
async put (keyString, value) {
19+
const key = new Key(keyString)
20+
try {
21+
return await fs.put(key, value)
22+
} catch (err) {
23+
// eslint-disable-next-line no-console
24+
console.error('Error putting block', err)
25+
throw err
26+
}
27+
}
28+
})

packages/datastore-fs/test/index.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ShardingDatastore, shard } from 'datastore-core'
66
import { Key } from 'interface-datastore'
77
import { interfaceDatastoreTests } from 'interface-datastore-tests'
88
import tempdir from 'ipfs-utils/src/temp-dir.js'
9+
import { spawn, Thread, Worker } from 'threads'
910
import { FsDatastore } from '../src/index.js'
1011

1112
const utf8Encoder = new TextEncoder()
@@ -170,4 +171,35 @@ describe('FsDatastore', () => {
170171

171172
expect(res).to.deep.equal(value)
172173
})
174+
175+
/**
176+
* This test spawns 10 workers that concurrently write to the same blockstore.
177+
* it's different from the previous test because it uses workers to write to the blockstore
178+
* which means that the writes are happening in parallel in different threads.
179+
*/
180+
it('can survive concurrent worker writes', async () => {
181+
const dir = tempdir()
182+
const key = new Key('CIQGFTQ7FSI2COUXWWLOQ45VUM2GUZCGAXLWCTOKKPGTUWPXHBNIVOY')
183+
const workers = await Promise.all(new Array(10).fill(0).map(async () => {
184+
const worker = await spawn(new Worker('./fixtures/writer-worker.js'))
185+
await worker.isReady(dir)
186+
return worker
187+
}))
188+
189+
try {
190+
const value = utf8Encoder.encode('Hello world')
191+
// 100 iterations of looping over all workers and putting the same key value pair
192+
await Promise.all(new Array(100).fill(0).map(async () => {
193+
return Promise.all(workers.map(async (worker) => worker.put(key.toString(), value)))
194+
}))
195+
196+
const fs = new FsDatastore(dir)
197+
await fs.open()
198+
const res = await fs.get(key)
199+
200+
expect(res).to.deep.equal(value)
201+
} finally {
202+
await Promise.all(workers.map(async (worker) => Thread.terminate(worker)))
203+
}
204+
})
173205
})

0 commit comments

Comments
 (0)