@liorandb/core 1.0.19 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-2FSI7HX7.js +1689 -0
- package/dist/index.d.ts +24 -11
- package/dist/index.js +5 -1725
- package/dist/queue-YILKSUEI.js +179 -0
- package/package.json +1 -1
- package/src/LioranManager.ts +99 -44
- package/src/core/checkpoint.ts +86 -34
- package/src/core/collection.ts +39 -8
- package/src/core/compaction.ts +53 -38
- package/src/core/database.ts +83 -38
- package/src/core/wal.ts +113 -29
- package/src/ipc/index.ts +41 -19
- package/src/ipc/pool.ts +136 -0
- package/src/ipc/queue.ts +85 -31
- package/src/ipc/worker.ts +72 -0
- package/src/ipc/client.ts +0 -85
- package/src/ipc/server.ts +0 -147
- package/src/ipc/socketPath.ts +0 -10
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LioranManager,
|
|
3
|
+
getDefaultRootPath
|
|
4
|
+
} from "./chunk-2FSI7HX7.js";
|
|
5
|
+
|
|
6
|
+
// src/ipc/pool.ts
|
|
7
|
+
import { Worker } from "worker_threads";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
var IPCWorkerPool = class {
|
|
12
|
+
workers = [];
|
|
13
|
+
workerCount;
|
|
14
|
+
shuttingDown = false;
|
|
15
|
+
rrIndex = 0;
|
|
16
|
+
constructor() {
|
|
17
|
+
this.workerCount = Math.max(2, os.cpus().length);
|
|
18
|
+
}
|
|
19
|
+
/* -------------------------------------------------- */
|
|
20
|
+
/* START POOL */
|
|
21
|
+
/* -------------------------------------------------- */
|
|
22
|
+
start() {
|
|
23
|
+
for (let i = 0; i < this.workerCount; i++) {
|
|
24
|
+
this.spawnWorker();
|
|
25
|
+
}
|
|
26
|
+
console.log(
|
|
27
|
+
`[WorkerPool] Started ${this.workerCount} worker threads`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
/* -------------------------------------------------- */
|
|
31
|
+
/* SPAWN WORKER */
|
|
32
|
+
/* -------------------------------------------------- */
|
|
33
|
+
spawnWorker() {
|
|
34
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
35
|
+
const __dirname = path.dirname(__filename);
|
|
36
|
+
const workerFile = path.join(__dirname, "worker.js");
|
|
37
|
+
const worker = new Worker(workerFile);
|
|
38
|
+
worker.on("exit", (code) => {
|
|
39
|
+
if (this.shuttingDown) return;
|
|
40
|
+
console.error(
|
|
41
|
+
`[WorkerPool] Worker exited (code=${code}). Restarting...`
|
|
42
|
+
);
|
|
43
|
+
this.workers = this.workers.filter((w) => w !== worker);
|
|
44
|
+
setTimeout(() => {
|
|
45
|
+
this.spawnWorker();
|
|
46
|
+
}, 500);
|
|
47
|
+
});
|
|
48
|
+
worker.on("error", (err) => {
|
|
49
|
+
console.error("[WorkerPool] Worker error:", err);
|
|
50
|
+
});
|
|
51
|
+
this.workers.push(worker);
|
|
52
|
+
}
|
|
53
|
+
/* -------------------------------------------------- */
|
|
54
|
+
/* EXECUTE TASK */
|
|
55
|
+
/* -------------------------------------------------- */
|
|
56
|
+
exec(task) {
|
|
57
|
+
if (this.workers.length === 0) {
|
|
58
|
+
throw new Error("No workers available");
|
|
59
|
+
}
|
|
60
|
+
const worker = this.workers[this.rrIndex];
|
|
61
|
+
this.rrIndex = (this.rrIndex + 1) % this.workers.length;
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const id = Date.now() + Math.random();
|
|
64
|
+
const messageHandler = (msg) => {
|
|
65
|
+
if (msg.id !== id) return;
|
|
66
|
+
worker.off("message", messageHandler);
|
|
67
|
+
if (msg.ok) resolve(msg.result);
|
|
68
|
+
else reject(new Error(msg.error));
|
|
69
|
+
};
|
|
70
|
+
worker.on("message", messageHandler);
|
|
71
|
+
worker.postMessage({
|
|
72
|
+
id,
|
|
73
|
+
task
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/* -------------------------------------------------- */
|
|
78
|
+
/* SHUTDOWN */
|
|
79
|
+
/* -------------------------------------------------- */
|
|
80
|
+
async shutdown() {
|
|
81
|
+
this.shuttingDown = true;
|
|
82
|
+
console.log("[WorkerPool] Shutting down worker threads...");
|
|
83
|
+
for (const worker of this.workers) {
|
|
84
|
+
try {
|
|
85
|
+
await worker.terminate();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error("[WorkerPool] Worker terminate error:", err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
this.workers = [];
|
|
91
|
+
}
|
|
92
|
+
/* -------------------------------------------------- */
|
|
93
|
+
/* INFO */
|
|
94
|
+
/* -------------------------------------------------- */
|
|
95
|
+
get size() {
|
|
96
|
+
return this.workerCount;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// src/ipc/queue.ts
|
|
101
|
+
var DBQueue = class {
|
|
102
|
+
constructor(rootPath = getDefaultRootPath()) {
|
|
103
|
+
this.rootPath = rootPath;
|
|
104
|
+
this.manager = new LioranManager({ rootPath });
|
|
105
|
+
this.pool = new IPCWorkerPool();
|
|
106
|
+
this.pool.start();
|
|
107
|
+
}
|
|
108
|
+
manager;
|
|
109
|
+
pool;
|
|
110
|
+
destroyed = false;
|
|
111
|
+
/* -------------------------------- EXEC -------------------------------- */
|
|
112
|
+
async exec(action, args) {
|
|
113
|
+
if (this.destroyed) {
|
|
114
|
+
throw new Error("DBQueue already shutdown");
|
|
115
|
+
}
|
|
116
|
+
switch (action) {
|
|
117
|
+
/* ---------------- DB ---------------- */
|
|
118
|
+
case "db":
|
|
119
|
+
await this.manager.db(args.db);
|
|
120
|
+
return true;
|
|
121
|
+
/* ---------------- CRUD OPS ---------------- */
|
|
122
|
+
case "op": {
|
|
123
|
+
const { db, col, method, params } = args;
|
|
124
|
+
const collection = (await this.manager.db(db)).collection(col);
|
|
125
|
+
return await collection[method](...params);
|
|
126
|
+
}
|
|
127
|
+
/* ---------------- INDEX OPS ---------------- */
|
|
128
|
+
case "index": {
|
|
129
|
+
const { db, col, method, params } = args;
|
|
130
|
+
const collection = (await this.manager.db(db)).collection(col);
|
|
131
|
+
return await collection[method](...params);
|
|
132
|
+
}
|
|
133
|
+
/* ---------------- COMPACTION ---------------- */
|
|
134
|
+
case "compact:collection": {
|
|
135
|
+
const { db, col } = args;
|
|
136
|
+
const collection = (await this.manager.db(db)).collection(col);
|
|
137
|
+
await collection.compact();
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
case "compact:db": {
|
|
141
|
+
const { db } = args;
|
|
142
|
+
const database = await this.manager.db(db);
|
|
143
|
+
await database.compactAll();
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
case "compact:all": {
|
|
147
|
+
for (const db of this.manager.openDBs.values()) {
|
|
148
|
+
await db.compactAll();
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
/* ---------------- SNAPSHOT ---------------- */
|
|
153
|
+
case "snapshot":
|
|
154
|
+
await this.manager.snapshot(args.path);
|
|
155
|
+
return true;
|
|
156
|
+
case "restore":
|
|
157
|
+
await this.manager.restore(args.path);
|
|
158
|
+
return true;
|
|
159
|
+
/* ---------------- CONTROL ---------------- */
|
|
160
|
+
case "shutdown":
|
|
161
|
+
await this.shutdown();
|
|
162
|
+
return true;
|
|
163
|
+
default:
|
|
164
|
+
throw new Error(`Unknown action: ${action}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/* ------------------------------ SHUTDOWN ------------------------------ */
|
|
168
|
+
async shutdown() {
|
|
169
|
+
if (this.destroyed) return;
|
|
170
|
+
this.destroyed = true;
|
|
171
|
+
await this.manager.closeAll();
|
|
172
|
+
await this.pool.shutdown();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
var dbQueue = new DBQueue();
|
|
176
|
+
export {
|
|
177
|
+
DBQueue,
|
|
178
|
+
dbQueue
|
|
179
|
+
};
|
package/package.json
CHANGED
package/src/LioranManager.ts
CHANGED
|
@@ -4,29 +4,34 @@ import process from "process";
|
|
|
4
4
|
import { LioranDB } from "./core/database.js";
|
|
5
5
|
import { setEncryptionKey } from "./utils/encryption.js";
|
|
6
6
|
import { getDefaultRootPath } from "./utils/rootpath.js";
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
|
|
8
|
+
/* ---------------- PROCESS MODE ---------------- */
|
|
9
9
|
|
|
10
10
|
enum ProcessMode {
|
|
11
11
|
PRIMARY = "primary",
|
|
12
|
-
CLIENT = "client"
|
|
12
|
+
CLIENT = "client",
|
|
13
|
+
READONLY = "readonly"
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/* ---------------- OPTIONS ---------------- */
|
|
17
|
+
|
|
15
18
|
export interface LioranManagerOptions {
|
|
16
19
|
rootPath?: string;
|
|
17
20
|
encryptionKey?: string | Buffer;
|
|
21
|
+
ipc?: "primary" | "client" | "readonly";
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
/* ---------------- MANAGER ---------------- */
|
|
25
|
+
|
|
20
26
|
export class LioranManager {
|
|
21
27
|
rootPath: string;
|
|
22
28
|
openDBs: Map<string, LioranDB>;
|
|
23
29
|
private closed = false;
|
|
24
30
|
private mode: ProcessMode;
|
|
25
31
|
private lockFd?: number;
|
|
26
|
-
private ipcServer?: IPCServer;
|
|
27
32
|
|
|
28
33
|
constructor(options: LioranManagerOptions = {}) {
|
|
29
|
-
const { rootPath, encryptionKey } = options;
|
|
34
|
+
const { rootPath, encryptionKey, ipc } = options;
|
|
30
35
|
|
|
31
36
|
this.rootPath = rootPath || getDefaultRootPath();
|
|
32
37
|
|
|
@@ -40,17 +45,48 @@ export class LioranManager {
|
|
|
40
45
|
|
|
41
46
|
this.openDBs = new Map();
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
/* ---------------- MODE RESOLUTION ---------------- */
|
|
49
|
+
|
|
50
|
+
if (ipc === "readonly") {
|
|
51
|
+
this.mode = ProcessMode.READONLY;
|
|
52
|
+
} else if (ipc === "client") {
|
|
53
|
+
this.mode = ProcessMode.CLIENT;
|
|
54
|
+
} else if (ipc === "primary") {
|
|
55
|
+
this.mode = ProcessMode.PRIMARY;
|
|
56
|
+
this.tryAcquireLock();
|
|
57
|
+
} else {
|
|
58
|
+
// auto-detect (default behavior)
|
|
59
|
+
this.mode = this.tryAcquireLock()
|
|
60
|
+
? ProcessMode.PRIMARY
|
|
61
|
+
: ProcessMode.CLIENT;
|
|
62
|
+
}
|
|
46
63
|
|
|
47
64
|
if (this.mode === ProcessMode.PRIMARY) {
|
|
48
|
-
this.ipcServer = new IPCServer(this, this.rootPath);
|
|
49
|
-
this.ipcServer.start();
|
|
50
65
|
this._registerShutdownHooks();
|
|
51
66
|
}
|
|
52
67
|
}
|
|
53
68
|
|
|
69
|
+
/* ---------------- MODE HELPERS ---------------- */
|
|
70
|
+
|
|
71
|
+
isPrimary() {
|
|
72
|
+
return this.mode === ProcessMode.PRIMARY;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
isClient() {
|
|
76
|
+
return this.mode === ProcessMode.CLIENT;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
isReadOnly() {
|
|
80
|
+
return this.mode === ProcessMode.READONLY;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ---------------- QUEUE HELPER ---------------- */
|
|
84
|
+
|
|
85
|
+
private async getQueue() {
|
|
86
|
+
const { dbQueue } = await import("./ipc/queue.js");
|
|
87
|
+
return dbQueue;
|
|
88
|
+
}
|
|
89
|
+
|
|
54
90
|
/* ---------------- LOCK MANAGEMENT ---------------- */
|
|
55
91
|
|
|
56
92
|
private isProcessAlive(pid: number): boolean {
|
|
@@ -78,7 +114,7 @@ export class LioranManager {
|
|
|
78
114
|
fs.writeSync(this.lockFd, String(process.pid));
|
|
79
115
|
return true;
|
|
80
116
|
}
|
|
81
|
-
} catch {
|
|
117
|
+
} catch {}
|
|
82
118
|
return false;
|
|
83
119
|
}
|
|
84
120
|
}
|
|
@@ -87,9 +123,11 @@ export class LioranManager {
|
|
|
87
123
|
|
|
88
124
|
async db(name: string): Promise<LioranDB> {
|
|
89
125
|
if (this.mode === ProcessMode.CLIENT) {
|
|
90
|
-
await
|
|
126
|
+
const queue = await this.getQueue();
|
|
127
|
+
await queue.exec("db", { db: name });
|
|
91
128
|
return new IPCDatabase(name) as any;
|
|
92
129
|
}
|
|
130
|
+
|
|
93
131
|
return this.openDatabase(name);
|
|
94
132
|
}
|
|
95
133
|
|
|
@@ -108,44 +146,51 @@ export class LioranManager {
|
|
|
108
146
|
return db;
|
|
109
147
|
}
|
|
110
148
|
|
|
111
|
-
/* ---------------- SNAPSHOT
|
|
149
|
+
/* ---------------- SNAPSHOT ---------------- */
|
|
112
150
|
|
|
113
|
-
/**
|
|
114
|
-
* Create TAR snapshot of full DB directory
|
|
115
|
-
*/
|
|
116
151
|
async snapshot(snapshotPath: string) {
|
|
117
152
|
if (this.mode === ProcessMode.CLIENT) {
|
|
118
|
-
|
|
153
|
+
const queue = await this.getQueue();
|
|
154
|
+
return queue.exec("snapshot", { path: snapshotPath });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (this.mode === ProcessMode.READONLY) {
|
|
158
|
+
throw new Error("Snapshot not allowed in readonly mode");
|
|
119
159
|
}
|
|
120
160
|
|
|
121
|
-
// Flush all DBs safely
|
|
122
161
|
for (const db of this.openDBs.values()) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
162
|
+
try {
|
|
163
|
+
await db.close();
|
|
164
|
+
} catch {}
|
|
126
165
|
}
|
|
127
166
|
|
|
128
|
-
// Ensure backup directory exists
|
|
129
167
|
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
|
130
168
|
|
|
131
169
|
const tar = await import("tar");
|
|
132
170
|
|
|
133
|
-
await tar.c(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
171
|
+
await tar.c(
|
|
172
|
+
{
|
|
173
|
+
gzip: true,
|
|
174
|
+
file: snapshotPath,
|
|
175
|
+
cwd: this.rootPath,
|
|
176
|
+
portable: true
|
|
177
|
+
},
|
|
178
|
+
["./"]
|
|
179
|
+
);
|
|
139
180
|
|
|
140
181
|
return true;
|
|
141
182
|
}
|
|
142
183
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
*/
|
|
184
|
+
/* ---------------- RESTORE ---------------- */
|
|
185
|
+
|
|
146
186
|
async restore(snapshotPath: string) {
|
|
147
187
|
if (this.mode === ProcessMode.CLIENT) {
|
|
148
|
-
|
|
188
|
+
const queue = await this.getQueue();
|
|
189
|
+
return queue.exec("restore", { path: snapshotPath });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (this.mode === ProcessMode.READONLY) {
|
|
193
|
+
throw new Error("Restore not allowed in readonly mode");
|
|
149
194
|
}
|
|
150
195
|
|
|
151
196
|
await this.closeAll();
|
|
@@ -171,22 +216,26 @@ export class LioranManager {
|
|
|
171
216
|
this.closed = true;
|
|
172
217
|
|
|
173
218
|
if (this.mode === ProcessMode.CLIENT) {
|
|
174
|
-
await
|
|
219
|
+
const queue = await this.getQueue();
|
|
220
|
+
await queue.shutdown();
|
|
175
221
|
return;
|
|
176
222
|
}
|
|
177
223
|
|
|
178
224
|
for (const db of this.openDBs.values()) {
|
|
179
|
-
try {
|
|
225
|
+
try {
|
|
226
|
+
await db.close();
|
|
227
|
+
} catch {}
|
|
180
228
|
}
|
|
181
229
|
|
|
182
230
|
this.openDBs.clear();
|
|
183
231
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
232
|
+
// Only primary owns lock
|
|
233
|
+
if (this.mode === ProcessMode.PRIMARY) {
|
|
234
|
+
try {
|
|
235
|
+
if (this.lockFd) fs.closeSync(this.lockFd);
|
|
236
|
+
fs.unlinkSync(path.join(this.rootPath, ".lioran.lock"));
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|
|
190
239
|
}
|
|
191
240
|
|
|
192
241
|
async close(): Promise<void> {
|
|
@@ -213,7 +262,7 @@ export class LioranManager {
|
|
|
213
262
|
/* ---------------- IPC PROXY DB ---------------- */
|
|
214
263
|
|
|
215
264
|
class IPCDatabase {
|
|
216
|
-
constructor(private name: string) {
|
|
265
|
+
constructor(private name: string) {}
|
|
217
266
|
|
|
218
267
|
collection(name: string) {
|
|
219
268
|
return new IPCCollection(this.name, name);
|
|
@@ -224,10 +273,16 @@ class IPCCollection {
|
|
|
224
273
|
constructor(
|
|
225
274
|
private db: string,
|
|
226
275
|
private col: string
|
|
227
|
-
) {
|
|
276
|
+
) {}
|
|
277
|
+
|
|
278
|
+
private async getQueue() {
|
|
279
|
+
const { dbQueue } = await import("./ipc/queue.js");
|
|
280
|
+
return dbQueue;
|
|
281
|
+
}
|
|
228
282
|
|
|
229
|
-
private call(method: string, params: any[]) {
|
|
230
|
-
|
|
283
|
+
private async call(method: string, params: any[]) {
|
|
284
|
+
const queue = await this.getQueue();
|
|
285
|
+
return queue.exec("op", {
|
|
231
286
|
db: this.db,
|
|
232
287
|
col: this.col,
|
|
233
288
|
method,
|
package/src/core/checkpoint.ts
CHANGED
|
@@ -9,27 +9,56 @@ export interface CheckpointData {
|
|
|
9
9
|
lsn: number; // Last durable LSN
|
|
10
10
|
walGen: number; // WAL generation at checkpoint
|
|
11
11
|
time: number; // Timestamp (ms)
|
|
12
|
-
version: number; //
|
|
12
|
+
version: number; // Format version
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface StoredCheckpoint {
|
|
16
|
+
data: CheckpointData;
|
|
17
|
+
crc: number;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
/* =========================
|
|
16
21
|
CONSTANTS
|
|
17
22
|
========================= */
|
|
18
23
|
|
|
19
|
-
const
|
|
20
|
-
const
|
|
24
|
+
const CHECKPOINT_A = "__checkpoint_A.json";
|
|
25
|
+
const CHECKPOINT_B = "__checkpoint_B.json";
|
|
21
26
|
const FORMAT_VERSION = 1;
|
|
22
27
|
|
|
28
|
+
/* =========================
|
|
29
|
+
CRC32 (no deps)
|
|
30
|
+
========================= */
|
|
31
|
+
|
|
32
|
+
const CRC32_TABLE = (() => {
|
|
33
|
+
const table = new Uint32Array(256);
|
|
34
|
+
for (let i = 0; i < 256; i++) {
|
|
35
|
+
let c = i;
|
|
36
|
+
for (let k = 0; k < 8; k++) {
|
|
37
|
+
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
38
|
+
}
|
|
39
|
+
table[i] = c >>> 0;
|
|
40
|
+
}
|
|
41
|
+
return table;
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
function crc32(input: string): number {
|
|
45
|
+
let crc = 0xFFFFFFFF;
|
|
46
|
+
for (let i = 0; i < input.length; i++) {
|
|
47
|
+
crc = CRC32_TABLE[(crc ^ input.charCodeAt(i)) & 0xFF] ^ (crc >>> 8);
|
|
48
|
+
}
|
|
49
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
/* =========================
|
|
24
53
|
CHECKPOINT MANAGER
|
|
25
54
|
========================= */
|
|
26
55
|
|
|
27
56
|
export class CheckpointManager {
|
|
28
|
-
private
|
|
57
|
+
private baseDir: string;
|
|
29
58
|
private data: CheckpointData;
|
|
30
59
|
|
|
31
60
|
constructor(baseDir: string) {
|
|
32
|
-
this.
|
|
61
|
+
this.baseDir = baseDir;
|
|
33
62
|
this.data = {
|
|
34
63
|
lsn: 0,
|
|
35
64
|
walGen: 1,
|
|
@@ -41,61 +70,84 @@ export class CheckpointManager {
|
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
/* -------------------------
|
|
44
|
-
LOAD (
|
|
73
|
+
LOAD (CRC + FALLBACK)
|
|
45
74
|
------------------------- */
|
|
46
75
|
|
|
47
76
|
private load() {
|
|
48
|
-
|
|
77
|
+
const a = this.readCheckpoint(CHECKPOINT_A);
|
|
78
|
+
const b = this.readCheckpoint(CHECKPOINT_B);
|
|
79
|
+
|
|
80
|
+
if (a && b) {
|
|
81
|
+
// pick newest valid checkpoint
|
|
82
|
+
this.data = a.data.lsn >= b.data.lsn ? a.data : b.data;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (a) {
|
|
87
|
+
this.data = a.data;
|
|
49
88
|
return;
|
|
50
89
|
}
|
|
51
90
|
|
|
91
|
+
if (b) {
|
|
92
|
+
this.data = b.data;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.warn("No valid checkpoint found, starting from zero");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private readCheckpoint(file: string): StoredCheckpoint | null {
|
|
100
|
+
const filePath = path.join(this.baseDir, file);
|
|
101
|
+
if (!fs.existsSync(filePath)) return null;
|
|
102
|
+
|
|
52
103
|
try {
|
|
53
|
-
const raw = fs.readFileSync(
|
|
54
|
-
const parsed = JSON.parse(raw) as
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
typeof parsed.walGen === "number"
|
|
59
|
-
) {
|
|
60
|
-
this.data = parsed;
|
|
104
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
105
|
+
const parsed = JSON.parse(raw) as StoredCheckpoint;
|
|
106
|
+
|
|
107
|
+
if (!parsed?.data || typeof parsed.crc !== "number") {
|
|
108
|
+
return null;
|
|
61
109
|
}
|
|
110
|
+
|
|
111
|
+
const expected = crc32(JSON.stringify(parsed.data));
|
|
112
|
+
if (expected !== parsed.crc) {
|
|
113
|
+
console.error(`Checkpoint CRC mismatch: ${file}`);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return parsed;
|
|
62
118
|
} catch {
|
|
63
|
-
|
|
64
|
-
this.data = {
|
|
65
|
-
lsn: 0,
|
|
66
|
-
walGen: 1,
|
|
67
|
-
time: 0,
|
|
68
|
-
version: FORMAT_VERSION
|
|
69
|
-
};
|
|
119
|
+
return null;
|
|
70
120
|
}
|
|
71
121
|
}
|
|
72
122
|
|
|
73
123
|
/* -------------------------
|
|
74
|
-
SAVE (
|
|
124
|
+
SAVE (DUAL WRITE)
|
|
75
125
|
------------------------- */
|
|
76
126
|
|
|
77
127
|
save(lsn: number, walGen: number) {
|
|
78
|
-
const
|
|
128
|
+
const data: CheckpointData = {
|
|
79
129
|
lsn,
|
|
80
130
|
walGen,
|
|
81
131
|
time: Date.now(),
|
|
82
132
|
version: FORMAT_VERSION
|
|
83
133
|
};
|
|
84
134
|
|
|
85
|
-
const
|
|
135
|
+
const stored: StoredCheckpoint = {
|
|
136
|
+
data,
|
|
137
|
+
crc: crc32(JSON.stringify(data))
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// alternate between A/B for crash safety
|
|
141
|
+
const target =
|
|
142
|
+
lsn % 2 === 0 ? CHECKPOINT_A : CHECKPOINT_B;
|
|
86
143
|
|
|
87
144
|
try {
|
|
88
|
-
// Write to temp file first
|
|
89
145
|
fs.writeFileSync(
|
|
90
|
-
|
|
91
|
-
JSON.stringify(
|
|
92
|
-
|
|
146
|
+
path.join(this.baseDir, target),
|
|
147
|
+
JSON.stringify(stored, null, 2),
|
|
148
|
+
"utf8"
|
|
93
149
|
);
|
|
94
|
-
|
|
95
|
-
// Atomic rename
|
|
96
|
-
fs.renameSync(tmpPath, this.filePath);
|
|
97
|
-
|
|
98
|
-
this.data = newData;
|
|
150
|
+
this.data = data;
|
|
99
151
|
} catch (err) {
|
|
100
152
|
console.error("Failed to write checkpoint:", err);
|
|
101
153
|
}
|