Skip to content

Commit

Permalink
feat: on open, recover from crashes during DB compaction
Browse files Browse the repository at this point in the history
  • Loading branch information
AlCalzone committed Jun 22, 2021
1 parent cc0f03b commit 6cdbcdb
Show file tree
Hide file tree
Showing 2 changed files with 226 additions and 3 deletions.
146 changes: 145 additions & 1 deletion src/lib/db.test.ts
Expand Up @@ -689,8 +689,9 @@ describe("lib/db", () => {
db.set("21", 21);

await db.close();
await db.open();
// The dumpdb must be opened before the db, because the open call will delete the old dump
await dumpdb.open();
await db.open();
assertEqual(db, dumpdb);
});
});
Expand Down Expand Up @@ -1368,4 +1369,147 @@ describe("lib/db", () => {
);
});
});

describe("crash recovery", () => {
let testFS: TestFS;
let testFSRoot: string;
let db: JsonlDB;
const testFilename = "recovery.jsonl";
let testFilenameFull: string;

beforeEach(async () => {
testFS = new TestFS();
testFSRoot = await testFS.getRoot();
testFilenameFull = path.join(testFSRoot, testFilename);
});

afterEach(async () => {
if (db) await db.close();
await testFS.remove();
});

async function assertCleanedUp() {
// The other files should have been cleaned up
await expect(fs.pathExists(testFilenameFull)).resolves.toBeTrue();
await expect(
fs.pathExists(testFilenameFull + ".bak"),
).resolves.toBeFalse();
await expect(
fs.pathExists(testFilenameFull + ".dump"),
).resolves.toBeFalse();
}

it("db truncated, .bak ok -> use .bak", async () => {
await testFS.create({
// Original, uncompressed db in the .bak file
[testFilename + ".bak"]: `
{"k":"key1","v":1}
{"k":"key2","v":"2"}
{"k":"key3","v":3}
{"k":"key2"}
{"k":"key3","v":3.5}`,
// empty, broken db file
[testFilename]: "",
// (probably) half-complete .dump file
[testFilename + ".dump"]: `
{"k":"key1","v":1}
{"k":"key3","v":3}`,
});

const db = new JsonlDB(testFilenameFull);
await db.open();

expect(db.size).toBe(2);
expect(db.has("key1")).toBeTrue();
expect(db.has("key2")).toBeFalse();
expect(db.has("key3")).toBeTrue();
expect(db.get("key1")).toBe(1);
expect(db.get("key3")).toBe(3.5);

await assertCleanedUp();
await db.close();
});

it("db missing, .bak ok -> use .bak", async () => {
await testFS.create({
// Original, uncompressed db in the .bak file
[testFilename + ".bak"]: `
{"k":"key1","v":1}
{"k":"key2","v":"2"}
{"k":"key3","v":3}
{"k":"key2"}
{"k":"key3","v":3.5}`,
// (probably) half-complete .dump file
[testFilename + ".dump"]: `
{"k":"key1","v":1}
{"k":"key3","v":3}`,
});

const db = new JsonlDB(testFilenameFull);
await db.open();

expect(db.size).toBe(2);
expect(db.has("key1")).toBeTrue();
expect(db.has("key2")).toBeFalse();
expect(db.has("key3")).toBeTrue();
expect(db.get("key1")).toBe(1);
expect(db.get("key3")).toBe(3.5);

await assertCleanedUp();

await db.close();
});

it("db truncated, .bak truncated, .dump ok -> use .dump", async () => {
await testFS.create({
// empty, broken .bak file
[testFilename + ".bak"]: "",
// empty, broken db file
[testFilename]: "",
// (probably) half-complete .dump file, but better than nothing
[testFilename + ".dump"]: `
{"k":"key1","v":1}
{"k":"key3","v":3}`,
});

const db = new JsonlDB(testFilenameFull);
await db.open();

expect(db.size).toBe(2);
expect(db.has("key1")).toBeTrue();
expect(db.has("key2")).toBeFalse();
expect(db.has("key3")).toBeTrue();
expect(db.get("key1")).toBe(1);
expect(db.get("key3")).toBe(3);

await assertCleanedUp();

await db.close();
});

it("db truncated, .bak missing, .dump ok -> use .dump", async () => {
await testFS.create({
// empty, broken db file
[testFilename]: "",
// (probably) half-complete .dump file, but better than nothing
[testFilename + ".dump"]: `
{"k":"key1","v":1}
{"k":"key3","v":3}`,
});

const db = new JsonlDB(testFilenameFull);
await db.open();

expect(db.size).toBe(2);
expect(db.has("key1")).toBeTrue();
expect(db.has("key2")).toBeFalse();
expect(db.has("key3")).toBeTrue();
expect(db.get("key1")).toBe(1);
expect(db.get("key3")).toBe(3);

await assertCleanedUp();

await db.close();
});
});
});
83 changes: 81 additions & 2 deletions src/lib/db.ts
Expand Up @@ -87,6 +87,8 @@ export class JsonlDB<V extends unknown = unknown> {

this.filename = filename;
this.dumpFilename = this.filename + ".dump";
this.backupFilename = this.filename + ".bak";

this.options = options;
// Bind all map properties we can use directly
this.forEach = this._db.forEach.bind(this._db);
Expand Down Expand Up @@ -135,6 +137,7 @@ export class JsonlDB<V extends unknown = unknown> {

public readonly filename: string;
public readonly dumpFilename: string;
public readonly backupFilename: string;

private options: JsonlDBOptions<V>;

Expand Down Expand Up @@ -200,6 +203,10 @@ export class JsonlDB<V extends unknown = unknown> {
} catch (e) {
throw new Error(`Failed to lock DB file "${this.filename}"!`);
}

// If the application crashed previously, try to recover from it
await this.tryRecoverDBFiles();

this._fd = await fs.open(this.filename, "a+");
const readStream = fs.createReadStream(this.filename, {
encoding: "utf8",
Expand Down Expand Up @@ -292,6 +299,78 @@ export class JsonlDB<V extends unknown = unknown> {
}
}

/**
* Makes sure that there are no remains of a previous broken compress attempt and restores
* a DB backup if it exists.
*/
private async tryRecoverDBFiles(): Promise<void> {
// During the compression, the following sequence of events happens:
// 1. A .jsonl.dump file gets written with a compressed copy of the data
// 2. Files get renamed: .jsonl -> .jsonl.bak, .jsonl.dump -> .jsonl
// 3. .bak file gets removed
// 4. Buffered data gets written to the .jsonl file

// This means if the .jsonl file is absent or truncated, we should be able to pick either the .dump or the .bak file
// and restore the .jsonl file from it
let dbFileIsOK = false;
try {
const dbFileStats = await fs.stat(this.filename);
dbFileIsOK = dbFileStats.isFile() && dbFileStats.size > 0;
} catch {
// ignore
}

// Prefer the DB file if it exists, remove the others in case they exist
if (dbFileIsOK) {
await fs.remove(this.backupFilename).catch(() => {
// ignore errors
});
await fs.remove(this.dumpFilename).catch(() => {
// ignore errors
});
return;
}

// The backup file should have complete data - the dump file could be subject to an incomplete write
let bakFileIsOK = false;
try {
const bakFileStats = await fs.stat(this.backupFilename);
bakFileIsOK = bakFileStats.isFile() && bakFileStats.size > 0;
} catch {
// ignore
}

if (bakFileIsOK) {
// Overwrite the broken db file with it and delete the dump file
await fs.move(this.backupFilename, this.filename, {
overwrite: true,
});
await fs.remove(this.dumpFilename).catch(() => {
// ignore errors
});
return;
}

// Try the dump file as a last attempt
let dumpFileIsOK = false;
try {
const dumpFileStats = await fs.stat(this.dumpFilename);
dumpFileIsOK = dumpFileStats.isFile() && dumpFileStats.size > 0;
} catch {
// ignore
}
if (dumpFileIsOK) {
// Overwrite the broken db file with the dump file and delete the backup file
await fs.move(this.dumpFilename, this.filename, {
overwrite: true,
});
await fs.remove(this.backupFilename).catch(() => {
// ignore errors
});
return;
}
}

/** Reads a line and extracts the key without doing a full-blown JSON.parse() */
private parseKey(line: string): string {
if (0 !== line.indexOf(`{"k":"`)) {
Expand Down Expand Up @@ -592,11 +671,11 @@ export class JsonlDB<V extends unknown = unknown> {
}

// Replace the aof file
await fs.move(this.filename, this.filename + ".bak", {
await fs.move(this.filename, this.backupFilename, {
overwrite: true,
});
await fs.move(this.dumpFilename, this.filename, { overwrite: true });
await fs.unlink(this.filename + ".bak");
await fs.unlink(this.backupFilename);

if (this._isOpen) {
// Start the write thread again
Expand Down

0 comments on commit 6cdbcdb

Please sign in to comment.