Skip to content

Commit 745d7ee

Browse files
authored
feat: retry acquiring lockfile, change timeouts (#289)
1 parent 50e8eb8 commit 745d7ee

File tree

3 files changed

+149
-7
lines changed

3 files changed

+149
-7
lines changed

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,20 @@ The following options exist:
8282

8383
To create a compressed copy of the database in `/path/to/file.dump`, use the `dump()` method. If any data is written to the db during the dump, it is appended to the dump but most likely compressed.
8484

85-
### Changing where the lockfile is created
85+
### Lockfile-related options
8686

87-
Normally, the lockfile to avoid concurrent access to the DB file is created right next to the DB file. You can change this, e.g. to put the lockfile into a `tmpfs`:
87+
A lockfile is used to avoid concurrent access to the DB file. Multiple options exist to control where this lockfile is created and how it is accessed:
8888
```ts
89-
const db = new DB("/path/to/file", { lockfileDirectory: "/var/tmp" });
89+
const db = new DB("/path/to/file", { lockfile: { /* lockfile options */ } });
9090
```
91-
If the directory does not exist, it will be created when opening the DB.
91+
92+
| Option | Default | Description |
93+
|-----------------|---------|-------------|
94+
| `directory` | - | Change where the lockfile is created, e.g. to put the lockfile into a `tmpfs`. By default the lockfile is created in the same directory as the DB file. If the directory does not exist, it will be created when opening the DB. |
95+
| `staleMs` | `10000` | Duration after which the lock is considered stale. Minimum: `5000` |
96+
| `updateMs` | `staleMs/2` | The interval in which the lockfile's `mtime` will be updated. Range: `1000 ... staleMs/2` |
97+
| `retries` | `0` | How often to retry acquiring a lock before giving up. The retries progressively wait longer with an exponential backoff strategy. |
98+
9299

93100
### Copying and compressing the database
94101

src/lib/db.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,76 @@ describe("lib/db", () => {
132132
).toThrowError("maxBufferedCommands");
133133
});
134134
});
135+
136+
describe("validates lockfile options", () => {
137+
it("staleMs < 5000", () => {
138+
expect(
139+
() =>
140+
new JsonlDB("foo", {
141+
lockfile: {
142+
staleMs: 2500,
143+
},
144+
}),
145+
).toThrowError("staleMs");
146+
});
147+
148+
it("updateMs < 1000", () => {
149+
expect(
150+
() =>
151+
new JsonlDB("foo", {
152+
lockfile: {
153+
updateMs: 999,
154+
},
155+
}),
156+
).toThrowError("updateMs");
157+
});
158+
159+
it("updateMs > staleMs/2", () => {
160+
expect(
161+
() =>
162+
new JsonlDB("foo", {
163+
lockfile: {
164+
staleMs: 5000,
165+
updateMs: 10001,
166+
},
167+
}),
168+
).toThrowError("updateMs");
169+
});
170+
171+
it("retries < 0", () => {
172+
expect(
173+
() =>
174+
new JsonlDB("foo", {
175+
lockfile: {
176+
retries: -1,
177+
},
178+
}),
179+
).toThrowError("retries");
180+
});
181+
182+
it("retries > 10", () => {
183+
expect(
184+
() =>
185+
new JsonlDB("foo", {
186+
lockfile: {
187+
retries: 11,
188+
},
189+
}),
190+
).toThrowError("retries");
191+
});
192+
193+
it("lockfileDirectory and lockfile.directory both present", () => {
194+
expect(
195+
() =>
196+
new JsonlDB("foo", {
197+
lockfile: {
198+
directory: "/lock/dir",
199+
},
200+
lockfileDirectory: "/lock/dir2",
201+
}),
202+
).toThrowError("lockfileDirectory");
203+
});
204+
});
135205
});
136206

137207
describe("open()", () => {

src/lib/db.ts

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,28 @@ export interface JsonlDBOptions<V> {
6868
maxBufferedCommands?: number;
6969
};
7070

71+
/** Configure settings related to the lockfile */
72+
lockfile?: Partial<{
73+
/**
74+
* Override in which directory the lockfile is created.
75+
* Defaults to the directory in which the DB file is located.
76+
*/
77+
directory?: string;
78+
79+
/** Duration after which the lock is considered stale. Minimum: 5000, Default: 10000 */
80+
staleMs?: number;
81+
/** The interval in which the lockfile's `mtime` will be updated. Range: [1000...staleMs/2]. Default: staleMs/2 */
82+
updateMs?: number;
83+
/**
84+
* How often to retry acquiring a lock before giving up. The retries progressively wait longer with an exponential backoff strategy.
85+
* Range: [0...10]. Default: 0
86+
*/
87+
retries?: number;
88+
}>;
89+
7190
/**
91+
* @deprecated Use lockfile.directory instead.
92+
*
7293
* Override in which directory the lockfile is created.
7394
* Defaults to the directory in which the DB file is located.
7495
*/
@@ -150,8 +171,10 @@ export class JsonlDB<V extends unknown = unknown> {
150171
this.filename = filename;
151172
this.dumpFilename = this.filename + ".dump";
152173
this.backupFilename = this.filename + ".bak";
153-
this.lockfileName = options.lockfileDirectory
154-
? path.join(options.lockfileDirectory, path.basename(this.filename))
174+
const lockfileDirectory =
175+
options.lockfile?.directory ?? options.lockfileDirectory;
176+
this.lockfileName = lockfileDirectory
177+
? path.join(lockfileDirectory, path.basename(this.filename))
155178
: this.filename;
156179

157180
this.options = options;
@@ -198,6 +221,37 @@ export class JsonlDB<V extends unknown = unknown> {
198221
throw new Error("maxBufferedCommands must be >= 0");
199222
}
200223
}
224+
if (options.lockfile) {
225+
const {
226+
directory,
227+
retries,
228+
staleMs = 10000,
229+
updateMs = staleMs / 2,
230+
} = options.lockfile;
231+
if (staleMs < 5000) {
232+
throw new Error("staleMs must be >= 5000");
233+
}
234+
if (updateMs < 1000) {
235+
throw new Error("updateMs must be >= 1000");
236+
}
237+
if (updateMs > staleMs / 2) {
238+
throw new Error(`updateMs must be <= ${staleMs / 2}`);
239+
}
240+
if (retries != undefined && retries < 0) {
241+
throw new Error("retries must be >= 0");
242+
}
243+
if (retries != undefined && retries > 10) {
244+
throw new Error("retries must be <= 10");
245+
}
246+
if (
247+
options.lockfileDirectory != undefined &&
248+
directory != undefined
249+
) {
250+
throw new Error(
251+
"lockfileDirectory and lockfile.directory must not both be specified",
252+
);
253+
}
254+
}
201255
}
202256

203257
public readonly filename: string;
@@ -252,6 +306,14 @@ export class JsonlDB<V extends unknown = unknown> {
252306
// Open the file for appending and reading
253307
await fs.ensureDir(path.dirname(this.filename));
254308

309+
let retryOptions: lockfile.LockOptions["retries"];
310+
if (this.options.lockfile?.retries) {
311+
retryOptions = {
312+
retries: this.options.lockfile.retries,
313+
factor: 1.25,
314+
};
315+
}
316+
255317
try {
256318
await fs.ensureDir(path.dirname(this.lockfileName));
257319
await lockfile.lock(this.lockfileName, {
@@ -262,7 +324,10 @@ export class JsonlDB<V extends unknown = unknown> {
262324
// Avoid timeouts during testing
263325
process.env.NODE_ENV === "test"
264326
? 100000
265-
: /* istanbul ignore next - this is impossible to test */ undefined,
327+
: /* istanbul ignore next - this is impossible to test */ this
328+
.options.lockfile?.staleMs,
329+
update: this.options.lockfile?.updateMs,
330+
retries: retryOptions,
266331

267332
onCompromised: /* istanbul ignore next */ () => {
268333
// do nothing

0 commit comments

Comments
 (0)