Skip to content

Commit 3ec82a7

Browse files
authored
feat: make threadsafe with mutex (#19)
1 parent 5c2e6b0 commit 3ec82a7

13 files changed

+314
-88
lines changed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ The in-memory portion of the filesystem is persisted to IndexedDB with a debounc
3838
The files themselves are not currently cached in memory, because I don't want to waste a lot of memory.
3939
Applications can always *add* an LRU cache on top of `lightning-fs` - if I add one internally and it isn't tuned well for your application, it might be much harder to work around.
4040

41+
### Multi-threaded filesystem access
42+
43+
Multiple tabs (and web workers) can share a filesystem. However, because SharedArrayBuffer is still not available in most browsers, the in-memory cache that makes LightningFS fast cannot be shared. If each thread was allowed to update its cache independently, then you'd have a complex distributed system and would need a fancy algorithm to resolve conflicts. Instead, I'm counting on the fact that your multi-threaded applications will NOT be IO bound, and thus a simpler strategy for sharing the filesystem will work. Filesystem access is bottlenecked by a mutex (implemented via polling and an atomic compare-and-replace operation in IndexedDB) to ensure that only one thread has access to the filesystem at a time. If the active thread is constantly using the filesystem, no other threads will get a chance. However if the active thread's filesystem goes idle - no operations are pending and no new operations are started - then after 500ms its in-memory cache is serialized and saved to IndexedDB and the mutex is released. (500ms was chosen experimentally such that an [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) `clone` operation didn't thrash the mutex.)
44+
45+
While the mutex is being held by another thread, any fs operations will be stuck waiting until the mutex becomes available. If the mutex is not available even after ten minutes then the filesystem operations will fail with an error. This could happen if say, you are trying to write to a log file every 100ms. You can overcome this by making sure that the filesystem is allowed to go idle for >500ms every now and then.
46+
4147
## Usage
4248

4349
### `new FS(name, opts?)`
@@ -49,7 +55,7 @@ import FS from '@isomorphic-git/lightning-fs';
4955
const fs = new FS("testfs")
5056
```
5157

52-
**Note: do not create multiple `fs` instances using the same name.** If you do, you'll have two distinct FileSystems both fighting over the same IndexedDb store.
58+
**Note: It is better not to create multiple `FS` instances using the same name in a single thread.** Memory usage will be higher as each instance maintains its own cache, and throughput may be lower as each instance will have to compete over the mutex for access to the IndexedDb store.
5359

5460
Options object:
5561

karma.conf.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ module.exports = function (config) {
3434
watched: false,
3535
included: false
3636
},
37+
{
38+
pattern: 'src/**/*.worker.js',
39+
served: true,
40+
watched: true,
41+
included: false
42+
},
43+
{
44+
pattern: 'dist/**',
45+
served: true,
46+
watched: true,
47+
included: false
48+
},
3749
],
3850
// list of files to exclude
3951
// exclude: [

package-lock.json

Lines changed: 35 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"semantic-release": "semantic-release"
1717
},
1818
"dependencies": {
19-
"idb-keyval": "3.1.0",
19+
"@wmhilton/idb-keyval": "^3.3.0",
2020
"isomorphic-textencoder": "1.0.1",
2121
"just-debounce-it": "1.1.0",
2222
"just-once": "1.1.0"

src/CacheFS.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,26 @@ const STAT = 0;
55

66
module.exports = class CacheFS {
77
constructor() {
8-
this._root = new Map([["/", this._makeRoot()]]);
98
}
109
_makeRoot(root = new Map()) {
1110
root.set(STAT, { mode: 0o777, type: "dir", size: 0, ino: 0, mtimeMs: Date.now() });
1211
return root
1312
}
14-
loadSuperBlock(superblock) {
15-
if (typeof superblock === 'string') {
13+
activate(superblock = null) {
14+
if (superblock === null) {
15+
this._root = new Map([["/", this._makeRoot()]]);
16+
} else if (typeof superblock === 'string') {
1617
this._root = new Map([["/", this._makeRoot(this.parse(superblock))]]);
1718
} else {
1819
this._root = superblock
1920
}
2021
}
22+
get activated () {
23+
return !!this._root
24+
}
25+
deactivate () {
26+
this._root = void 0
27+
}
2128
size () {
2229
// subtract 1 to ignore the root directory itself from the count.
2330
return this._countInodes(this._root.get("/")) - 1;

src/IdbBackend.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const idb = require("idb-keyval");
1+
const idb = require("@wmhilton/idb-keyval");
22

33
module.exports = class IdbBackend {
44
constructor(name) {
@@ -23,4 +23,7 @@ module.exports = class IdbBackend {
2323
wipe() {
2424
return idb.clear(this._store)
2525
}
26+
close() {
27+
return idb.close(this._store)
28+
}
2629
}

src/Mutex.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
const idb = require("@wmhilton/idb-keyval");
2+
3+
const sleep = ms => new Promise(r => setTimeout(r, ms))
4+
5+
module.exports = class Mutex {
6+
constructor(name) {
7+
this._id = Math.random()
8+
this._database = name
9+
this._store = new idb.Store(this._database + "_lock", this._database + "_lock")
10+
this._has = false
11+
this._keepAliveTimeout = null
12+
}
13+
has () {
14+
return this._has
15+
}
16+
// Returns true if successful
17+
async acquire ({ ttl = 5000, refreshPeriod } = {}) {
18+
let success
19+
let expired
20+
let doubleLock
21+
await idb.update("lock", (current) => {
22+
const now = Date.now()
23+
expired = current && current.expires < now
24+
success = current === undefined || expired
25+
doubleLock = current && current.holder === this._id
26+
this._has = success || doubleLock
27+
return success ? { holder: this._id, expires: now + ttl } : current
28+
}, this._store)
29+
if (doubleLock) {
30+
throw new Error('Mutex double-locked')
31+
}
32+
if (success) {
33+
this._keepAlive({ ttl, refreshPeriod })
34+
}
35+
return success
36+
}
37+
// check at 10Hz, give up after 10 minutes
38+
async wait ({ interval = 100, limit = 6000, ttl, refreshPeriod } = {}) {
39+
while (limit--) {
40+
if (await this.acquire({ ttl, refreshPeriod })) return true
41+
await sleep(interval)
42+
}
43+
throw new Error('Mutex timeout')
44+
}
45+
// Returns true if successful
46+
async release ({ force = false } = {}) {
47+
let success
48+
let doubleFree
49+
let someoneElseHasIt
50+
this._stopKeepAlive()
51+
await idb.update("lock", (current) => {
52+
success = force || (current && current.holder === this._id)
53+
doubleFree = current === void 0
54+
someoneElseHasIt = current && current.holder !== this._id
55+
this._has = !success
56+
return success ? void 0 : current
57+
}, this._store)
58+
if (!this._has) {
59+
await idb.close(this._store)
60+
}
61+
if (!success && !force) {
62+
if (doubleFree) throw new Error('Mutex double-freed')
63+
if (someoneElseHasIt) throw new Error('Mutex lost ownership')
64+
}
65+
return success
66+
}
67+
// Note: Chrome throttles & batches timers in background tabs to 1Hz,
68+
// so there's not much point in having a refreshPeriod shorter than 1000.
69+
// And TTL obviously needs to be greater than refreshPeriod.
70+
async _keepAlive ({ ttl = 5000, refreshPeriod = 3000 } = {}) {
71+
const keepAliveFn = async () => {
72+
let success
73+
let someoneDeletedIt
74+
let someoneElseHasIt
75+
await idb.update("lock", (current) => {
76+
const now = Date.now()
77+
someoneDeletedIt = current === void 0
78+
someoneElseHasIt = current && current.holder !== this._id
79+
success = !someoneDeletedIt && !someoneElseHasIt
80+
this._has = success
81+
return success ? { holder: this._id, expires: now + ttl } : current
82+
}, this._store)
83+
if (!success) this._stopKeepAlive()
84+
if (someoneDeletedIt) throw new Error('Mutex was deleted')
85+
if (someoneElseHasIt) throw new Error('Mutex lost ownership')
86+
}
87+
this._keepAliveTimeout = setInterval(keepAliveFn, refreshPeriod)
88+
}
89+
_stopKeepAlive () {
90+
if (this._keepAliveTimeout) {
91+
clearInterval(this._keepAliveTimeout)
92+
}
93+
}
94+
}

0 commit comments

Comments
 (0)