@liorandb/core 1.0.19 → 1.1.0
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/index.d.ts +3 -0
- package/dist/index.js +163 -60
- package/package.json +1 -1
- package/src/core/checkpoint.ts +86 -34
- package/src/core/compaction.ts +53 -38
- package/src/core/database.ts +34 -14
- package/src/core/wal.ts +80 -25
package/dist/index.d.ts
CHANGED
|
@@ -93,6 +93,8 @@ declare class WALManager {
|
|
|
93
93
|
constructor(baseDir: string);
|
|
94
94
|
private walPath;
|
|
95
95
|
private detectLastGeneration;
|
|
96
|
+
private recoverLSNFromExistingLogs;
|
|
97
|
+
private getSortedWalFiles;
|
|
96
98
|
private open;
|
|
97
99
|
private rotate;
|
|
98
100
|
append(record: Omit<WALRecord, "lsn">): Promise<number>;
|
|
@@ -130,6 +132,7 @@ declare class LioranDB {
|
|
|
130
132
|
constructor(basePath: string, dbName: string, manager: LioranManager);
|
|
131
133
|
private initialize;
|
|
132
134
|
private recoverFromWAL;
|
|
135
|
+
advanceCheckpoint(lsn: number): void;
|
|
133
136
|
private loadMeta;
|
|
134
137
|
private saveMeta;
|
|
135
138
|
getSchemaVersion(): string;
|
package/dist/index.js
CHANGED
|
@@ -303,8 +303,10 @@ async function compactCollectionEngine(col) {
|
|
|
303
303
|
safeRemove(tmpDir);
|
|
304
304
|
safeRemove(oldDir);
|
|
305
305
|
await snapshotRebuild(col, tmpDir);
|
|
306
|
-
atomicSwap(baseDir, tmpDir, oldDir);
|
|
306
|
+
await atomicSwap(baseDir, tmpDir, oldDir);
|
|
307
307
|
safeRemove(oldDir);
|
|
308
|
+
await reopenCollectionDB(col);
|
|
309
|
+
await rebuildIndexes(col);
|
|
308
310
|
}
|
|
309
311
|
async function snapshotRebuild(col, tmpDir) {
|
|
310
312
|
fs2.mkdirSync(tmpDir, { recursive: true });
|
|
@@ -319,9 +321,16 @@ async function snapshotRebuild(col, tmpDir) {
|
|
|
319
321
|
await tmpDB.close();
|
|
320
322
|
await col.db.close();
|
|
321
323
|
}
|
|
322
|
-
function atomicSwap(base, tmp, old) {
|
|
324
|
+
async function atomicSwap(base, tmp, old) {
|
|
323
325
|
fs2.renameSync(base, old);
|
|
324
|
-
|
|
326
|
+
try {
|
|
327
|
+
fs2.renameSync(tmp, base);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (fs2.existsSync(old)) {
|
|
330
|
+
fs2.renameSync(old, base);
|
|
331
|
+
}
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
325
334
|
}
|
|
326
335
|
async function crashRecovery(baseDir) {
|
|
327
336
|
const tmp = baseDir + TMP_SUFFIX;
|
|
@@ -343,9 +352,15 @@ async function crashRecovery(baseDir) {
|
|
|
343
352
|
safeRemove(tmp);
|
|
344
353
|
}
|
|
345
354
|
}
|
|
355
|
+
async function reopenCollectionDB(col) {
|
|
356
|
+
col.db = new ClassicLevel2(col.dir, {
|
|
357
|
+
valueEncoding: "utf8"
|
|
358
|
+
});
|
|
359
|
+
}
|
|
346
360
|
async function rebuildIndexes(col) {
|
|
347
361
|
const indexRoot = path2.join(col.dir, INDEX_DIR);
|
|
348
|
-
|
|
362
|
+
const oldIndexes = new Map(col["indexes"]);
|
|
363
|
+
for (const idx of oldIndexes.values()) {
|
|
349
364
|
try {
|
|
350
365
|
await idx.close();
|
|
351
366
|
} catch {
|
|
@@ -353,19 +368,22 @@ async function rebuildIndexes(col) {
|
|
|
353
368
|
}
|
|
354
369
|
safeRemove(indexRoot);
|
|
355
370
|
fs2.mkdirSync(indexRoot, { recursive: true });
|
|
356
|
-
const
|
|
357
|
-
for (const idx of
|
|
371
|
+
const rebuiltIndexes = /* @__PURE__ */ new Map();
|
|
372
|
+
for (const idx of oldIndexes.values()) {
|
|
358
373
|
const rebuilt = new Index(col.dir, idx.field, {
|
|
359
374
|
unique: idx.unique
|
|
360
375
|
});
|
|
361
376
|
for await (const [, enc] of col.db.iterator()) {
|
|
362
377
|
if (!enc) continue;
|
|
363
|
-
|
|
364
|
-
|
|
378
|
+
try {
|
|
379
|
+
const doc = decryptData(enc);
|
|
380
|
+
await rebuilt.insert(doc);
|
|
381
|
+
} catch {
|
|
382
|
+
}
|
|
365
383
|
}
|
|
366
|
-
|
|
384
|
+
rebuiltIndexes.set(idx.field, rebuilt);
|
|
367
385
|
}
|
|
368
|
-
col["indexes"] =
|
|
386
|
+
col["indexes"] = rebuiltIndexes;
|
|
369
387
|
}
|
|
370
388
|
function safeRemove(p) {
|
|
371
389
|
if (fs2.existsSync(p)) {
|
|
@@ -848,8 +866,7 @@ var CRC32_TABLE = (() => {
|
|
|
848
866
|
function crc32(input) {
|
|
849
867
|
let crc = 4294967295;
|
|
850
868
|
for (let i = 0; i < input.length; i++) {
|
|
851
|
-
|
|
852
|
-
crc = CRC32_TABLE[(crc ^ byte) & 255] ^ crc >>> 8;
|
|
869
|
+
crc = CRC32_TABLE[(crc ^ input.charCodeAt(i)) & 255] ^ crc >>> 8;
|
|
853
870
|
}
|
|
854
871
|
return (crc ^ 4294967295) >>> 0;
|
|
855
872
|
}
|
|
@@ -862,6 +879,7 @@ var WALManager = class {
|
|
|
862
879
|
this.walDir = path4.join(baseDir, WAL_DIR);
|
|
863
880
|
fs4.mkdirSync(this.walDir, { recursive: true });
|
|
864
881
|
this.currentGen = this.detectLastGeneration();
|
|
882
|
+
this.recoverLSNFromExistingLogs();
|
|
865
883
|
}
|
|
866
884
|
/* -------------------------
|
|
867
885
|
INTERNAL HELPERS
|
|
@@ -878,10 +896,40 @@ var WALManager = class {
|
|
|
878
896
|
let max = 0;
|
|
879
897
|
for (const f of files) {
|
|
880
898
|
const m = f.match(/^wal-(\d+)\.log$/);
|
|
881
|
-
if (m)
|
|
899
|
+
if (m) {
|
|
900
|
+
const gen = Number(m[1]);
|
|
901
|
+
if (!Number.isNaN(gen)) {
|
|
902
|
+
max = Math.max(max, gen);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
882
905
|
}
|
|
883
906
|
return max || 1;
|
|
884
907
|
}
|
|
908
|
+
recoverLSNFromExistingLogs() {
|
|
909
|
+
const files = this.getSortedWalFiles();
|
|
910
|
+
for (const file of files) {
|
|
911
|
+
const filePath = path4.join(this.walDir, file);
|
|
912
|
+
const lines = fs4.readFileSync(filePath, "utf8").split("\n");
|
|
913
|
+
for (const line of lines) {
|
|
914
|
+
if (!line.trim()) continue;
|
|
915
|
+
try {
|
|
916
|
+
const parsed = JSON.parse(line);
|
|
917
|
+
const { crc, ...record } = parsed;
|
|
918
|
+
if (crc32(JSON.stringify(record)) !== crc) break;
|
|
919
|
+
this.lsn = Math.max(this.lsn, record.lsn);
|
|
920
|
+
} catch {
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
getSortedWalFiles() {
|
|
927
|
+
return fs4.readdirSync(this.walDir).filter((f) => /^wal-\d+\.log$/.test(f)).sort((a, b) => {
|
|
928
|
+
const ga = Number(a.match(/^wal-(\d+)\.log$/)[1]);
|
|
929
|
+
const gb = Number(b.match(/^wal-(\d+)\.log$/)[1]);
|
|
930
|
+
return ga - gb;
|
|
931
|
+
});
|
|
932
|
+
}
|
|
885
933
|
async open() {
|
|
886
934
|
if (!this.fd) {
|
|
887
935
|
this.fd = await fs4.promises.open(this.walPath(), "a");
|
|
@@ -889,13 +937,14 @@ var WALManager = class {
|
|
|
889
937
|
}
|
|
890
938
|
async rotate() {
|
|
891
939
|
if (this.fd) {
|
|
940
|
+
await this.fd.sync();
|
|
892
941
|
await this.fd.close();
|
|
893
942
|
this.fd = null;
|
|
894
943
|
}
|
|
895
944
|
this.currentGen++;
|
|
896
945
|
}
|
|
897
946
|
/* -------------------------
|
|
898
|
-
APPEND
|
|
947
|
+
APPEND (Crash-safe)
|
|
899
948
|
------------------------- */
|
|
900
949
|
async append(record) {
|
|
901
950
|
await this.open();
|
|
@@ -908,7 +957,8 @@ var WALManager = class {
|
|
|
908
957
|
...full,
|
|
909
958
|
crc: crc32(body)
|
|
910
959
|
};
|
|
911
|
-
|
|
960
|
+
const line = JSON.stringify(stored) + "\n";
|
|
961
|
+
await this.fd.write(line);
|
|
912
962
|
await this.fd.sync();
|
|
913
963
|
const stat = await this.fd.stat();
|
|
914
964
|
if (stat.size >= MAX_WAL_SIZE) {
|
|
@@ -917,38 +967,44 @@ var WALManager = class {
|
|
|
917
967
|
return full.lsn;
|
|
918
968
|
}
|
|
919
969
|
/* -------------------------
|
|
920
|
-
REPLAY
|
|
970
|
+
REPLAY (Auto-heal tail)
|
|
921
971
|
------------------------- */
|
|
922
972
|
async replay(fromLSN, apply) {
|
|
923
973
|
if (!fs4.existsSync(this.walDir)) return;
|
|
924
|
-
const files =
|
|
974
|
+
const files = this.getSortedWalFiles();
|
|
925
975
|
for (const file of files) {
|
|
926
976
|
const filePath = path4.join(this.walDir, file);
|
|
927
|
-
const
|
|
928
|
-
const
|
|
977
|
+
const fd = fs4.openSync(filePath, "r+");
|
|
978
|
+
const content = fs4.readFileSync(filePath, "utf8");
|
|
979
|
+
const lines = content.split("\n");
|
|
980
|
+
let validOffset = 0;
|
|
929
981
|
for (let i = 0; i < lines.length; i++) {
|
|
930
982
|
const line = lines[i];
|
|
931
|
-
if (!line.trim())
|
|
983
|
+
if (!line.trim()) {
|
|
984
|
+
validOffset += line.length + 1;
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
932
987
|
let parsed;
|
|
933
988
|
try {
|
|
934
989
|
parsed = JSON.parse(line);
|
|
935
990
|
} catch {
|
|
936
|
-
|
|
937
|
-
return;
|
|
991
|
+
break;
|
|
938
992
|
}
|
|
939
993
|
const { crc, ...record } = parsed;
|
|
940
994
|
const expected = crc32(JSON.stringify(record));
|
|
941
995
|
if (expected !== crc) {
|
|
942
|
-
|
|
943
|
-
"WAL checksum mismatch, stopping replay",
|
|
944
|
-
{ file, line: i + 1 }
|
|
945
|
-
);
|
|
946
|
-
return;
|
|
996
|
+
break;
|
|
947
997
|
}
|
|
998
|
+
validOffset += line.length + 1;
|
|
948
999
|
if (record.lsn <= fromLSN) continue;
|
|
949
1000
|
this.lsn = Math.max(this.lsn, record.lsn);
|
|
950
1001
|
await apply(record);
|
|
951
1002
|
}
|
|
1003
|
+
const stat = fs4.fstatSync(fd);
|
|
1004
|
+
if (validOffset < stat.size) {
|
|
1005
|
+
fs4.ftruncateSync(fd, validOffset);
|
|
1006
|
+
}
|
|
1007
|
+
fs4.closeSync(fd);
|
|
952
1008
|
}
|
|
953
1009
|
}
|
|
954
1010
|
/* -------------------------
|
|
@@ -980,14 +1036,32 @@ var WALManager = class {
|
|
|
980
1036
|
// src/core/checkpoint.ts
|
|
981
1037
|
import fs5 from "fs";
|
|
982
1038
|
import path5 from "path";
|
|
983
|
-
var
|
|
984
|
-
var
|
|
1039
|
+
var CHECKPOINT_A = "__checkpoint_A.json";
|
|
1040
|
+
var CHECKPOINT_B = "__checkpoint_B.json";
|
|
985
1041
|
var FORMAT_VERSION = 1;
|
|
1042
|
+
var CRC32_TABLE2 = (() => {
|
|
1043
|
+
const table = new Uint32Array(256);
|
|
1044
|
+
for (let i = 0; i < 256; i++) {
|
|
1045
|
+
let c = i;
|
|
1046
|
+
for (let k = 0; k < 8; k++) {
|
|
1047
|
+
c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
1048
|
+
}
|
|
1049
|
+
table[i] = c >>> 0;
|
|
1050
|
+
}
|
|
1051
|
+
return table;
|
|
1052
|
+
})();
|
|
1053
|
+
function crc322(input) {
|
|
1054
|
+
let crc = 4294967295;
|
|
1055
|
+
for (let i = 0; i < input.length; i++) {
|
|
1056
|
+
crc = CRC32_TABLE2[(crc ^ input.charCodeAt(i)) & 255] ^ crc >>> 8;
|
|
1057
|
+
}
|
|
1058
|
+
return (crc ^ 4294967295) >>> 0;
|
|
1059
|
+
}
|
|
986
1060
|
var CheckpointManager = class {
|
|
987
|
-
|
|
1061
|
+
baseDir;
|
|
988
1062
|
data;
|
|
989
1063
|
constructor(baseDir) {
|
|
990
|
-
this.
|
|
1064
|
+
this.baseDir = baseDir;
|
|
991
1065
|
this.data = {
|
|
992
1066
|
lsn: 0,
|
|
993
1067
|
walGen: 1,
|
|
@@ -997,47 +1071,66 @@ var CheckpointManager = class {
|
|
|
997
1071
|
this.load();
|
|
998
1072
|
}
|
|
999
1073
|
/* -------------------------
|
|
1000
|
-
LOAD (
|
|
1074
|
+
LOAD (CRC + FALLBACK)
|
|
1001
1075
|
------------------------- */
|
|
1002
1076
|
load() {
|
|
1003
|
-
|
|
1077
|
+
const a = this.readCheckpoint(CHECKPOINT_A);
|
|
1078
|
+
const b = this.readCheckpoint(CHECKPOINT_B);
|
|
1079
|
+
if (a && b) {
|
|
1080
|
+
this.data = a.data.lsn >= b.data.lsn ? a.data : b.data;
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if (a) {
|
|
1084
|
+
this.data = a.data;
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
if (b) {
|
|
1088
|
+
this.data = b.data;
|
|
1004
1089
|
return;
|
|
1005
1090
|
}
|
|
1091
|
+
console.warn("No valid checkpoint found, starting from zero");
|
|
1092
|
+
}
|
|
1093
|
+
readCheckpoint(file) {
|
|
1094
|
+
const filePath = path5.join(this.baseDir, file);
|
|
1095
|
+
if (!fs5.existsSync(filePath)) return null;
|
|
1006
1096
|
try {
|
|
1007
|
-
const raw = fs5.readFileSync(
|
|
1097
|
+
const raw = fs5.readFileSync(filePath, "utf8");
|
|
1008
1098
|
const parsed = JSON.parse(raw);
|
|
1009
|
-
if (
|
|
1010
|
-
|
|
1099
|
+
if (!parsed?.data || typeof parsed.crc !== "number") {
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
const expected = crc322(JSON.stringify(parsed.data));
|
|
1103
|
+
if (expected !== parsed.crc) {
|
|
1104
|
+
console.error(`Checkpoint CRC mismatch: ${file}`);
|
|
1105
|
+
return null;
|
|
1011
1106
|
}
|
|
1107
|
+
return parsed;
|
|
1012
1108
|
} catch {
|
|
1013
|
-
|
|
1014
|
-
this.data = {
|
|
1015
|
-
lsn: 0,
|
|
1016
|
-
walGen: 1,
|
|
1017
|
-
time: 0,
|
|
1018
|
-
version: FORMAT_VERSION
|
|
1019
|
-
};
|
|
1109
|
+
return null;
|
|
1020
1110
|
}
|
|
1021
1111
|
}
|
|
1022
1112
|
/* -------------------------
|
|
1023
|
-
SAVE (
|
|
1113
|
+
SAVE (DUAL WRITE)
|
|
1024
1114
|
------------------------- */
|
|
1025
1115
|
save(lsn, walGen) {
|
|
1026
|
-
const
|
|
1116
|
+
const data = {
|
|
1027
1117
|
lsn,
|
|
1028
1118
|
walGen,
|
|
1029
1119
|
time: Date.now(),
|
|
1030
1120
|
version: FORMAT_VERSION
|
|
1031
1121
|
};
|
|
1032
|
-
const
|
|
1122
|
+
const stored = {
|
|
1123
|
+
data,
|
|
1124
|
+
crc: crc322(JSON.stringify(data))
|
|
1125
|
+
};
|
|
1126
|
+
const target = lsn % 2 === 0 ? CHECKPOINT_A : CHECKPOINT_B;
|
|
1033
1127
|
try {
|
|
1034
1128
|
fs5.writeFileSync(
|
|
1035
|
-
|
|
1036
|
-
JSON.stringify(
|
|
1037
|
-
|
|
1129
|
+
path5.join(this.baseDir, target),
|
|
1130
|
+
JSON.stringify(stored, null, 2),
|
|
1131
|
+
"utf8"
|
|
1038
1132
|
);
|
|
1039
|
-
|
|
1040
|
-
this.data = newData;
|
|
1133
|
+
this.data = data;
|
|
1041
1134
|
} catch (err) {
|
|
1042
1135
|
console.error("Failed to write checkpoint:", err);
|
|
1043
1136
|
}
|
|
@@ -1076,24 +1169,22 @@ var DBTransactionContext = class {
|
|
|
1076
1169
|
}
|
|
1077
1170
|
async commit() {
|
|
1078
1171
|
for (const op of this.ops) {
|
|
1079
|
-
|
|
1172
|
+
await this.db.wal.append({
|
|
1080
1173
|
tx: this.txId,
|
|
1081
1174
|
type: "op",
|
|
1082
1175
|
payload: op
|
|
1083
|
-
};
|
|
1084
|
-
await this.db.wal.append(recordOp);
|
|
1176
|
+
});
|
|
1085
1177
|
}
|
|
1086
|
-
const
|
|
1178
|
+
const commitLSN = await this.db.wal.append({
|
|
1087
1179
|
tx: this.txId,
|
|
1088
1180
|
type: "commit"
|
|
1089
|
-
};
|
|
1090
|
-
await this.db.wal.append(commitRecord);
|
|
1181
|
+
});
|
|
1091
1182
|
await this.db.applyTransaction(this.ops);
|
|
1092
|
-
const
|
|
1183
|
+
const appliedLSN = await this.db.wal.append({
|
|
1093
1184
|
tx: this.txId,
|
|
1094
1185
|
type: "applied"
|
|
1095
|
-
};
|
|
1096
|
-
|
|
1186
|
+
});
|
|
1187
|
+
this.db.advanceCheckpoint(appliedLSN);
|
|
1097
1188
|
await this.db.postCommitMaintenance();
|
|
1098
1189
|
}
|
|
1099
1190
|
};
|
|
@@ -1141,13 +1232,25 @@ var LioranDB = class _LioranDB {
|
|
|
1141
1232
|
ops.get(record.tx).push(record.payload);
|
|
1142
1233
|
}
|
|
1143
1234
|
});
|
|
1235
|
+
let highestAppliedLSN = fromLSN;
|
|
1144
1236
|
for (const tx of committed) {
|
|
1145
1237
|
if (applied.has(tx)) continue;
|
|
1146
1238
|
const txOps = ops.get(tx);
|
|
1147
1239
|
if (txOps) {
|
|
1148
1240
|
await this.applyTransaction(txOps);
|
|
1241
|
+
highestAppliedLSN = this.wal.getCurrentLSN();
|
|
1149
1242
|
}
|
|
1150
1243
|
}
|
|
1244
|
+
this.advanceCheckpoint(highestAppliedLSN);
|
|
1245
|
+
}
|
|
1246
|
+
/* ------------------------- CHECKPOINT ADVANCE ------------------------- */
|
|
1247
|
+
advanceCheckpoint(lsn) {
|
|
1248
|
+
const current = this.checkpoint.get();
|
|
1249
|
+
if (lsn > current.lsn) {
|
|
1250
|
+
this.checkpoint.save(lsn, this.wal.getCurrentGen());
|
|
1251
|
+
this.wal.cleanup(this.wal.getCurrentGen() - 1).catch(() => {
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1151
1254
|
}
|
|
1152
1255
|
/* ------------------------- META ------------------------- */
|
|
1153
1256
|
loadMeta() {
|
package/package.json
CHANGED
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
|
}
|
package/src/core/compaction.ts
CHANGED
|
@@ -18,42 +18,40 @@ const INDEX_DIR = "__indexes";
|
|
|
18
18
|
--------------------------------------------------------- */
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Full safe compaction
|
|
21
|
+
* Full production-safe compaction:
|
|
22
22
|
* 1. Crash recovery
|
|
23
23
|
* 2. Snapshot rebuild
|
|
24
|
-
* 3. Atomic
|
|
25
|
-
* 4.
|
|
24
|
+
* 3. Atomic swap
|
|
25
|
+
* 4. Reopen DB
|
|
26
|
+
* 5. Rebuild indexes
|
|
26
27
|
*/
|
|
27
28
|
export async function compactCollectionEngine(col: Collection) {
|
|
28
29
|
const baseDir = col.dir;
|
|
29
30
|
const tmpDir = baseDir + TMP_SUFFIX;
|
|
30
31
|
const oldDir = baseDir + OLD_SUFFIX;
|
|
31
32
|
|
|
32
|
-
// Recover from any previous crash mid-compaction
|
|
33
33
|
await crashRecovery(baseDir);
|
|
34
34
|
|
|
35
|
-
// Clean leftovers (paranoia safety)
|
|
36
35
|
safeRemove(tmpDir);
|
|
37
36
|
safeRemove(oldDir);
|
|
38
37
|
|
|
39
|
-
// Step 1: rebuild snapshot
|
|
40
38
|
await snapshotRebuild(col, tmpDir);
|
|
41
39
|
|
|
42
|
-
|
|
43
|
-
atomicSwap(baseDir, tmpDir, oldDir);
|
|
40
|
+
await atomicSwap(baseDir, tmpDir, oldDir);
|
|
44
41
|
|
|
45
|
-
// Cleanup
|
|
46
42
|
safeRemove(oldDir);
|
|
43
|
+
|
|
44
|
+
// Reopen DB after swap
|
|
45
|
+
await reopenCollectionDB(col);
|
|
46
|
+
|
|
47
|
+
// Rebuild indexes after compaction
|
|
48
|
+
await rebuildIndexes(col);
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/* ---------------------------------------------------------
|
|
50
52
|
SNAPSHOT REBUILD
|
|
51
53
|
--------------------------------------------------------- */
|
|
52
54
|
|
|
53
|
-
/**
|
|
54
|
-
* Rebuilds DB by copying only live keys
|
|
55
|
-
* WAL is assumed already checkpointed
|
|
56
|
-
*/
|
|
57
55
|
async function snapshotRebuild(col: Collection, tmpDir: string) {
|
|
58
56
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
59
57
|
|
|
@@ -68,28 +66,33 @@ async function snapshotRebuild(col: Collection, tmpDir: string) {
|
|
|
68
66
|
}
|
|
69
67
|
|
|
70
68
|
await tmpDB.close();
|
|
71
|
-
await col.db.close();
|
|
69
|
+
await col.db.close(); // important: close before swap
|
|
72
70
|
}
|
|
73
71
|
|
|
74
72
|
/* ---------------------------------------------------------
|
|
75
|
-
ATOMIC SWAP
|
|
73
|
+
ATOMIC SWAP (HARDENED)
|
|
76
74
|
--------------------------------------------------------- */
|
|
77
75
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
*/
|
|
81
|
-
function atomicSwap(base: string, tmp: string, old: string) {
|
|
76
|
+
async function atomicSwap(base: string, tmp: string, old: string) {
|
|
77
|
+
// Phase 1: rename base → old
|
|
82
78
|
fs.renameSync(base, old);
|
|
83
|
-
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// Phase 2: rename tmp → base
|
|
82
|
+
fs.renameSync(tmp, base);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Rollback if tmp rename fails
|
|
85
|
+
if (fs.existsSync(old)) {
|
|
86
|
+
fs.renameSync(old, base);
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
/* ---------------------------------------------------------
|
|
87
93
|
CRASH RECOVERY
|
|
88
94
|
--------------------------------------------------------- */
|
|
89
95
|
|
|
90
|
-
/**
|
|
91
|
-
* Handles all partial-compaction states
|
|
92
|
-
*/
|
|
93
96
|
export async function crashRecovery(baseDir: string) {
|
|
94
97
|
const tmp = baseDir + TMP_SUFFIX;
|
|
95
98
|
const old = baseDir + OLD_SUFFIX;
|
|
@@ -106,7 +109,7 @@ export async function crashRecovery(baseDir: string) {
|
|
|
106
109
|
return;
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
// Case 2:
|
|
112
|
+
// Case 2: base→old happened but tmp missing
|
|
110
113
|
if (!baseExists && oldExists) {
|
|
111
114
|
fs.renameSync(old, baseDir);
|
|
112
115
|
return;
|
|
@@ -119,44 +122,56 @@ export async function crashRecovery(baseDir: string) {
|
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
/* ---------------------------------------------------------
|
|
122
|
-
|
|
125
|
+
REOPEN DB
|
|
126
|
+
--------------------------------------------------------- */
|
|
127
|
+
|
|
128
|
+
async function reopenCollectionDB(col: Collection) {
|
|
129
|
+
col.db = new ClassicLevel(col.dir, {
|
|
130
|
+
valueEncoding: "utf8"
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ---------------------------------------------------------
|
|
135
|
+
INDEX REBUILD (SAFE)
|
|
123
136
|
--------------------------------------------------------- */
|
|
124
137
|
|
|
125
|
-
/**
|
|
126
|
-
* Rebuilds all indexes from compacted DB
|
|
127
|
-
* Guarantees index consistency
|
|
128
|
-
*/
|
|
129
138
|
export async function rebuildIndexes(col: Collection) {
|
|
130
139
|
const indexRoot = path.join(col.dir, INDEX_DIR);
|
|
131
140
|
|
|
132
|
-
|
|
133
|
-
|
|
141
|
+
const oldIndexes = new Map(col["indexes"]);
|
|
142
|
+
|
|
143
|
+
// Close old index handles
|
|
144
|
+
for (const idx of oldIndexes.values()) {
|
|
134
145
|
try {
|
|
135
146
|
await idx.close();
|
|
136
147
|
} catch {}
|
|
137
148
|
}
|
|
138
149
|
|
|
139
|
-
// Destroy index directory
|
|
140
150
|
safeRemove(indexRoot);
|
|
141
151
|
fs.mkdirSync(indexRoot, { recursive: true });
|
|
142
152
|
|
|
143
|
-
const
|
|
153
|
+
const rebuiltIndexes = new Map<string, Index>();
|
|
144
154
|
|
|
145
|
-
for (const idx of
|
|
155
|
+
for (const idx of oldIndexes.values()) {
|
|
146
156
|
const rebuilt = new Index(col.dir, idx.field, {
|
|
147
157
|
unique: idx.unique
|
|
148
158
|
});
|
|
149
159
|
|
|
150
160
|
for await (const [, enc] of col.db.iterator()) {
|
|
151
161
|
if (!enc) continue;
|
|
152
|
-
|
|
153
|
-
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const doc = decryptData(enc);
|
|
165
|
+
await rebuilt.insert(doc);
|
|
166
|
+
} catch {
|
|
167
|
+
// Skip corrupted doc safely
|
|
168
|
+
}
|
|
154
169
|
}
|
|
155
170
|
|
|
156
|
-
|
|
171
|
+
rebuiltIndexes.set(idx.field, rebuilt);
|
|
157
172
|
}
|
|
158
173
|
|
|
159
|
-
col["indexes"] =
|
|
174
|
+
col["indexes"] = rebuiltIndexes;
|
|
160
175
|
}
|
|
161
176
|
|
|
162
177
|
/* ---------------------------------------------------------
|
package/src/core/database.ts
CHANGED
|
@@ -13,9 +13,6 @@ import { CheckpointManager } from "./checkpoint.js";
|
|
|
13
13
|
/* ----------------------------- TYPES ----------------------------- */
|
|
14
14
|
|
|
15
15
|
type TXOp = { tx: number; col: string; op: string; args: any[] };
|
|
16
|
-
type TXCommit = { tx: number; commit: true };
|
|
17
|
-
type TXApplied = { tx: number; applied: true };
|
|
18
|
-
type WALEntry = TXOp | TXCommit | TXApplied;
|
|
19
16
|
|
|
20
17
|
type IndexMeta = {
|
|
21
18
|
field: string;
|
|
@@ -58,28 +55,32 @@ class DBTransactionContext {
|
|
|
58
55
|
}
|
|
59
56
|
|
|
60
57
|
async commit() {
|
|
58
|
+
// 1️⃣ Write all operations
|
|
61
59
|
for (const op of this.ops) {
|
|
62
|
-
|
|
60
|
+
await this.db.wal.append({
|
|
63
61
|
tx: this.txId,
|
|
64
62
|
type: "op",
|
|
65
63
|
payload: op
|
|
66
|
-
};
|
|
67
|
-
await this.db.wal.append(recordOp);
|
|
64
|
+
} as any);
|
|
68
65
|
}
|
|
69
66
|
|
|
70
|
-
|
|
67
|
+
// 2️⃣ Commit marker
|
|
68
|
+
const commitLSN = await this.db.wal.append({
|
|
71
69
|
tx: this.txId,
|
|
72
70
|
type: "commit"
|
|
73
|
-
};
|
|
74
|
-
await this.db.wal.append(commitRecord);
|
|
71
|
+
} as any);
|
|
75
72
|
|
|
73
|
+
// 3️⃣ Apply to storage
|
|
76
74
|
await this.db.applyTransaction(this.ops);
|
|
77
75
|
|
|
78
|
-
|
|
76
|
+
// 4️⃣ Applied marker
|
|
77
|
+
const appliedLSN = await this.db.wal.append({
|
|
79
78
|
tx: this.txId,
|
|
80
79
|
type: "applied"
|
|
81
|
-
};
|
|
82
|
-
|
|
80
|
+
} as any);
|
|
81
|
+
|
|
82
|
+
// 5️⃣ Advance checkpoint to durable applied LSN
|
|
83
|
+
this.db.advanceCheckpoint(appliedLSN);
|
|
83
84
|
|
|
84
85
|
await this.db.postCommitMaintenance();
|
|
85
86
|
}
|
|
@@ -143,18 +144,37 @@ export class LioranDB {
|
|
|
143
144
|
applied.add(record.tx);
|
|
144
145
|
} else if (record.type === "op") {
|
|
145
146
|
if (!ops.has(record.tx)) ops.set(record.tx, []);
|
|
146
|
-
ops.get(record.tx)!.push(record.payload);
|
|
147
|
+
ops.get(record.tx)!.push(record.payload as TXOp);
|
|
147
148
|
}
|
|
148
149
|
});
|
|
149
150
|
|
|
151
|
+
let highestAppliedLSN = fromLSN;
|
|
152
|
+
|
|
150
153
|
for (const tx of committed) {
|
|
151
154
|
if (applied.has(tx)) continue;
|
|
152
155
|
|
|
153
156
|
const txOps = ops.get(tx);
|
|
154
157
|
if (txOps) {
|
|
155
158
|
await this.applyTransaction(txOps);
|
|
159
|
+
highestAppliedLSN = this.wal.getCurrentLSN();
|
|
156
160
|
}
|
|
157
161
|
}
|
|
162
|
+
|
|
163
|
+
// Advance checkpoint after recovery
|
|
164
|
+
this.advanceCheckpoint(highestAppliedLSN);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ------------------------- CHECKPOINT ADVANCE ------------------------- */
|
|
168
|
+
|
|
169
|
+
public advanceCheckpoint(lsn: number) {
|
|
170
|
+
const current = this.checkpoint.get();
|
|
171
|
+
|
|
172
|
+
if (lsn > current.lsn) {
|
|
173
|
+
this.checkpoint.save(lsn, this.wal.getCurrentGen());
|
|
174
|
+
|
|
175
|
+
// Optional WAL cleanup (safe because checkpoint advanced)
|
|
176
|
+
this.wal.cleanup(this.wal.getCurrentGen() - 1).catch(() => {});
|
|
177
|
+
}
|
|
158
178
|
}
|
|
159
179
|
|
|
160
180
|
/* ------------------------- META ------------------------- */
|
|
@@ -307,7 +327,7 @@ export class LioranDB {
|
|
|
307
327
|
/* ------------------------- POST COMMIT ------------------------- */
|
|
308
328
|
|
|
309
329
|
public async postCommitMaintenance() {
|
|
310
|
-
//
|
|
330
|
+
// Hook for background compaction, stats, etc.
|
|
311
331
|
}
|
|
312
332
|
|
|
313
333
|
/* ------------------------- SHUTDOWN ------------------------- */
|
package/src/core/wal.ts
CHANGED
|
@@ -16,12 +16,11 @@ type StoredRecord = WALRecord & { crc: number };
|
|
|
16
16
|
CONSTANTS
|
|
17
17
|
========================= */
|
|
18
18
|
|
|
19
|
-
const MAX_WAL_SIZE = 16 * 1024 * 1024; //
|
|
19
|
+
const MAX_WAL_SIZE = 16 * 1024 * 1024; // 16MB
|
|
20
20
|
const WAL_DIR = "__wal";
|
|
21
21
|
|
|
22
22
|
/* =========================
|
|
23
|
-
CRC32
|
|
24
|
-
(no dependencies)
|
|
23
|
+
CRC32 (no deps)
|
|
25
24
|
========================= */
|
|
26
25
|
|
|
27
26
|
const CRC32_TABLE = (() => {
|
|
@@ -37,12 +36,11 @@ const CRC32_TABLE = (() => {
|
|
|
37
36
|
})();
|
|
38
37
|
|
|
39
38
|
function crc32(input: string): number {
|
|
40
|
-
let crc =
|
|
39
|
+
let crc = 0xffffffff;
|
|
41
40
|
for (let i = 0; i < input.length; i++) {
|
|
42
|
-
|
|
43
|
-
crc = CRC32_TABLE[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
|
|
41
|
+
crc = CRC32_TABLE[(crc ^ input.charCodeAt(i)) & 0xff] ^ (crc >>> 8);
|
|
44
42
|
}
|
|
45
|
-
return (crc ^
|
|
43
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
/* =========================
|
|
@@ -58,7 +56,9 @@ export class WALManager {
|
|
|
58
56
|
constructor(baseDir: string) {
|
|
59
57
|
this.walDir = path.join(baseDir, WAL_DIR);
|
|
60
58
|
fs.mkdirSync(this.walDir, { recursive: true });
|
|
59
|
+
|
|
61
60
|
this.currentGen = this.detectLastGeneration();
|
|
61
|
+
this.recoverLSNFromExistingLogs();
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/* -------------------------
|
|
@@ -80,12 +80,52 @@ export class WALManager {
|
|
|
80
80
|
|
|
81
81
|
for (const f of files) {
|
|
82
82
|
const m = f.match(/^wal-(\d+)\.log$/);
|
|
83
|
-
if (m)
|
|
83
|
+
if (m) {
|
|
84
|
+
const gen = Number(m[1]);
|
|
85
|
+
if (!Number.isNaN(gen)) {
|
|
86
|
+
max = Math.max(max, gen);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
return max || 1;
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
private recoverLSNFromExistingLogs() {
|
|
95
|
+
const files = this.getSortedWalFiles();
|
|
96
|
+
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const filePath = path.join(this.walDir, file);
|
|
99
|
+
const lines = fs.readFileSync(filePath, "utf8").split("\n");
|
|
100
|
+
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
if (!line.trim()) continue;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const parsed: StoredRecord = JSON.parse(line);
|
|
106
|
+
const { crc, ...record } = parsed;
|
|
107
|
+
|
|
108
|
+
if (crc32(JSON.stringify(record)) !== crc) break;
|
|
109
|
+
|
|
110
|
+
this.lsn = Math.max(this.lsn, record.lsn);
|
|
111
|
+
} catch {
|
|
112
|
+
break; // stop on corruption
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private getSortedWalFiles(): string[] {
|
|
119
|
+
return fs
|
|
120
|
+
.readdirSync(this.walDir)
|
|
121
|
+
.filter(f => /^wal-\d+\.log$/.test(f))
|
|
122
|
+
.sort((a, b) => {
|
|
123
|
+
const ga = Number(a.match(/^wal-(\d+)\.log$/)![1]);
|
|
124
|
+
const gb = Number(b.match(/^wal-(\d+)\.log$/)![1]);
|
|
125
|
+
return ga - gb;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
89
129
|
private async open() {
|
|
90
130
|
if (!this.fd) {
|
|
91
131
|
this.fd = await fs.promises.open(this.walPath(), "a");
|
|
@@ -94,6 +134,7 @@ export class WALManager {
|
|
|
94
134
|
|
|
95
135
|
private async rotate() {
|
|
96
136
|
if (this.fd) {
|
|
137
|
+
await this.fd.sync();
|
|
97
138
|
await this.fd.close();
|
|
98
139
|
this.fd = null;
|
|
99
140
|
}
|
|
@@ -101,7 +142,7 @@ export class WALManager {
|
|
|
101
142
|
}
|
|
102
143
|
|
|
103
144
|
/* -------------------------
|
|
104
|
-
APPEND
|
|
145
|
+
APPEND (Crash-safe)
|
|
105
146
|
------------------------- */
|
|
106
147
|
|
|
107
148
|
async append(record: Omit<WALRecord, "lsn">): Promise<number> {
|
|
@@ -113,12 +154,15 @@ export class WALManager {
|
|
|
113
154
|
};
|
|
114
155
|
|
|
115
156
|
const body = JSON.stringify(full);
|
|
157
|
+
|
|
116
158
|
const stored: StoredRecord = {
|
|
117
159
|
...full,
|
|
118
160
|
crc: crc32(body)
|
|
119
161
|
};
|
|
120
162
|
|
|
121
|
-
|
|
163
|
+
const line = JSON.stringify(stored) + "\n";
|
|
164
|
+
|
|
165
|
+
await this.fd!.write(line);
|
|
122
166
|
await this.fd!.sync();
|
|
123
167
|
|
|
124
168
|
const stat = await this.fd!.stat();
|
|
@@ -130,7 +174,7 @@ export class WALManager {
|
|
|
130
174
|
}
|
|
131
175
|
|
|
132
176
|
/* -------------------------
|
|
133
|
-
REPLAY
|
|
177
|
+
REPLAY (Auto-heal tail)
|
|
134
178
|
------------------------- */
|
|
135
179
|
|
|
136
180
|
async replay(
|
|
@@ -139,44 +183,54 @@ export class WALManager {
|
|
|
139
183
|
): Promise<void> {
|
|
140
184
|
if (!fs.existsSync(this.walDir)) return;
|
|
141
185
|
|
|
142
|
-
const files =
|
|
143
|
-
.readdirSync(this.walDir)
|
|
144
|
-
.filter(f => f.startsWith("wal-"))
|
|
145
|
-
.sort();
|
|
186
|
+
const files = this.getSortedWalFiles();
|
|
146
187
|
|
|
147
188
|
for (const file of files) {
|
|
148
189
|
const filePath = path.join(this.walDir, file);
|
|
149
|
-
|
|
150
|
-
const
|
|
190
|
+
|
|
191
|
+
const fd = fs.openSync(filePath, "r+");
|
|
192
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
193
|
+
const lines = content.split("\n");
|
|
194
|
+
|
|
195
|
+
let validOffset = 0;
|
|
151
196
|
|
|
152
197
|
for (let i = 0; i < lines.length; i++) {
|
|
153
198
|
const line = lines[i];
|
|
154
|
-
if (!line.trim())
|
|
199
|
+
if (!line.trim()) {
|
|
200
|
+
validOffset += line.length + 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
155
203
|
|
|
156
204
|
let parsed: StoredRecord;
|
|
205
|
+
|
|
157
206
|
try {
|
|
158
207
|
parsed = JSON.parse(line);
|
|
159
208
|
} catch {
|
|
160
|
-
|
|
161
|
-
return;
|
|
209
|
+
break;
|
|
162
210
|
}
|
|
163
211
|
|
|
164
212
|
const { crc, ...record } = parsed;
|
|
165
213
|
const expected = crc32(JSON.stringify(record));
|
|
166
214
|
|
|
167
215
|
if (expected !== crc) {
|
|
168
|
-
|
|
169
|
-
"WAL checksum mismatch, stopping replay",
|
|
170
|
-
{ file, line: i + 1 }
|
|
171
|
-
);
|
|
172
|
-
return;
|
|
216
|
+
break;
|
|
173
217
|
}
|
|
174
218
|
|
|
219
|
+
validOffset += line.length + 1;
|
|
220
|
+
|
|
175
221
|
if (record.lsn <= fromLSN) continue;
|
|
176
222
|
|
|
177
223
|
this.lsn = Math.max(this.lsn, record.lsn);
|
|
178
224
|
await apply(record);
|
|
179
225
|
}
|
|
226
|
+
|
|
227
|
+
// Truncate corrupted tail (auto-heal)
|
|
228
|
+
const stat = fs.fstatSync(fd);
|
|
229
|
+
if (validOffset < stat.size) {
|
|
230
|
+
fs.ftruncateSync(fd, validOffset);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
fs.closeSync(fd);
|
|
180
234
|
}
|
|
181
235
|
}
|
|
182
236
|
|
|
@@ -188,6 +242,7 @@ export class WALManager {
|
|
|
188
242
|
if (!fs.existsSync(this.walDir)) return;
|
|
189
243
|
|
|
190
244
|
const files = fs.readdirSync(this.walDir);
|
|
245
|
+
|
|
191
246
|
for (const f of files) {
|
|
192
247
|
const m = f.match(/^wal-(\d+)\.log$/);
|
|
193
248
|
if (!m) continue;
|