jexidb 2.2.3 → 2.2.6
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/README.md +50 -0
- package/dist/Database.cjs +745 -157
- package/package.json +1 -1
- package/src/Database.mjs +274 -28
- package/src/FileHandler.mjs +88 -1
- package/src/managers/IndexManager.mjs +55 -6
- package/src/managers/QueryManager.mjs +320 -132
- package/src/utils/ioTimeout.mjs +36 -0
package/package.json
CHANGED
package/src/Database.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import fs from 'fs'
|
|
|
6
6
|
import readline from 'readline'
|
|
7
7
|
import pRetry from 'p-retry'
|
|
8
8
|
import { OperationQueue } from './OperationQueue.mjs'
|
|
9
|
+
import { createIoTimeoutError, isIoTimeoutError } from './utils/ioTimeout.mjs'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* IterateEntry class for intuitive API with automatic change detection
|
|
@@ -359,6 +360,9 @@ class Database extends EventEmitter {
|
|
|
359
360
|
enableArraySerialization: opts.enableArraySerialization !== false, // Enable array serialization by default
|
|
360
361
|
// Index rebuild options
|
|
361
362
|
allowIndexRebuild: opts.allowIndexRebuild === true, // Allow automatic index rebuild when corrupted (default false - throws error)
|
|
363
|
+
// Read-only mode: never create, write, rebuild or auto-flush. Use it to
|
|
364
|
+
// open a DB purely for reads (e.g. while another process is the writer).
|
|
365
|
+
readOnly: opts.readOnly === true,
|
|
362
366
|
}, opts)
|
|
363
367
|
|
|
364
368
|
// CRITICAL FIX: Initialize AbortController for lifecycle management
|
|
@@ -387,6 +391,14 @@ class Database extends EventEmitter {
|
|
|
387
391
|
this.initialized = false
|
|
388
392
|
this._offsetRecoveryInProgress = false
|
|
389
393
|
this.writeBufferTotalSize = 0
|
|
394
|
+
|
|
395
|
+
// Swap detection state: the data file may be atomically replaced by another
|
|
396
|
+
// process while this instance holds offsets/index computed for the previous
|
|
397
|
+
// file. _offsetsFileSignature remembers which on-disk file the offsets
|
|
398
|
+
// describe (size + mtime) so the read path can detect a swap cheaply.
|
|
399
|
+
this._offsetsFileSignature = null
|
|
400
|
+
this._dataFingerprint = null // cached stat (short TTL) for the read path
|
|
401
|
+
this._offsetsReloadPromise = null // single-flight clean reload after a swap
|
|
390
402
|
|
|
391
403
|
|
|
392
404
|
// Initialize managers
|
|
@@ -779,8 +791,8 @@ class Database extends EventEmitter {
|
|
|
779
791
|
const fileExists = await this.fileHandler.exists()
|
|
780
792
|
|
|
781
793
|
if (!fileExists) {
|
|
782
|
-
if (!this.opts.create) {
|
|
783
|
-
throw new Error(`Database file '${this.normalizedFile}' does not exist and create option is disabled`)
|
|
794
|
+
if (!this.opts.create || this.opts.readOnly) {
|
|
795
|
+
throw new Error(`Database file '${this.normalizedFile}' does not exist and ${this.opts.readOnly ? 'readOnly mode is enabled' : 'create option is disabled'}`)
|
|
784
796
|
}
|
|
785
797
|
// File will be created when first data is written
|
|
786
798
|
} else {
|
|
@@ -975,6 +987,10 @@ class Database extends EventEmitter {
|
|
|
975
987
|
}
|
|
976
988
|
}
|
|
977
989
|
}
|
|
990
|
+
|
|
991
|
+
// Remember which on-disk data file these offsets describe, so the
|
|
992
|
+
// read path can detect an atomic swap performed by another process.
|
|
993
|
+
this._offsetsFileSignature = await this._statDataFingerprint()
|
|
978
994
|
|
|
979
995
|
// Load indexOffset for proper range calculations
|
|
980
996
|
if (parsedIdxData.indexOffset !== undefined) {
|
|
@@ -1306,6 +1322,14 @@ class Database extends EventEmitter {
|
|
|
1306
1322
|
async _doSave() {
|
|
1307
1323
|
// CRITICAL FIX: Check if database is destroyed
|
|
1308
1324
|
if (this.destroyed) return
|
|
1325
|
+
|
|
1326
|
+
// Read-only mode: never persist data to disk
|
|
1327
|
+
if (this.opts.readOnly) {
|
|
1328
|
+
if (this.opts.debugMode) {
|
|
1329
|
+
console.log('💾 _doSave: Database is read-only, skipping save')
|
|
1330
|
+
}
|
|
1331
|
+
return
|
|
1332
|
+
}
|
|
1309
1333
|
|
|
1310
1334
|
// CRITICAL FIX: Use atomic check-and-set to prevent concurrent save operations
|
|
1311
1335
|
if (this.isSaving) {
|
|
@@ -1690,9 +1714,10 @@ class Database extends EventEmitter {
|
|
|
1690
1714
|
console.log(`💾 Save: Calculated indexOffset: ${this.indexOffset}, allData.length: ${allData.length}`)
|
|
1691
1715
|
}
|
|
1692
1716
|
|
|
1693
|
-
// CRITICAL FIX: Write main data file first
|
|
1717
|
+
// CRITICAL FIX: Write main data file first (atomically: temp + rename so a
|
|
1718
|
+
// concurrent reader never sees a half-written file).
|
|
1694
1719
|
// Index will be saved AFTER reconstruction to ensure it contains correct data
|
|
1695
|
-
await this.fileHandler.
|
|
1720
|
+
await this.fileHandler.replaceDataFile(jsonlData)
|
|
1696
1721
|
|
|
1697
1722
|
if (this.opts.debugMode) {
|
|
1698
1723
|
console.log(`💾 Saved ${allData.length} records to ${this.normalizedFile}`)
|
|
@@ -1700,6 +1725,10 @@ class Database extends EventEmitter {
|
|
|
1700
1725
|
|
|
1701
1726
|
// CRITICAL FIX: Invalidate file size cache after save operation
|
|
1702
1727
|
this._cachedFileStats = null
|
|
1728
|
+
|
|
1729
|
+
// The in-memory offsets now describe the file we just wrote. Refresh the
|
|
1730
|
+
// swap-detection signature so the next indexed read does not reload.
|
|
1731
|
+
this._offsetsFileSignature = await this._statDataFingerprint()
|
|
1703
1732
|
|
|
1704
1733
|
this.shouldSave = false
|
|
1705
1734
|
this.lastSaveTime = Date.now()
|
|
@@ -2120,6 +2149,10 @@ class Database extends EventEmitter {
|
|
|
2120
2149
|
*/
|
|
2121
2150
|
async insert(data) {
|
|
2122
2151
|
this._validateInitialization('insert')
|
|
2152
|
+
|
|
2153
|
+
if (this.opts.readOnly) {
|
|
2154
|
+
throw new Error('Database is read-only')
|
|
2155
|
+
}
|
|
2123
2156
|
|
|
2124
2157
|
return this.operationQueue.enqueue(async () => {
|
|
2125
2158
|
this.isInsideOperationQueue = true
|
|
@@ -2210,6 +2243,10 @@ class Database extends EventEmitter {
|
|
|
2210
2243
|
*/
|
|
2211
2244
|
async insertBatch(dataArray) {
|
|
2212
2245
|
this._validateInitialization('insertBatch')
|
|
2246
|
+
|
|
2247
|
+
if (this.opts.readOnly) {
|
|
2248
|
+
throw new Error('Database is read-only')
|
|
2249
|
+
}
|
|
2213
2250
|
|
|
2214
2251
|
// If we're already inside the operation queue (e.g., from insert()), avoid re-enqueueing to prevent deadlocks
|
|
2215
2252
|
if (this.isInsideOperationQueue) {
|
|
@@ -2236,6 +2273,11 @@ class Database extends EventEmitter {
|
|
|
2236
2273
|
* Internal implementation for insertBatch to allow inline execution when already inside the queue
|
|
2237
2274
|
*/
|
|
2238
2275
|
async _insertBatchInternal(dataArray) {
|
|
2276
|
+
// Read-only mode: never write to disk
|
|
2277
|
+
if (this.opts.readOnly) {
|
|
2278
|
+
throw new Error('Database is read-only')
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2239
2281
|
// CRITICAL FIX: Validate state before insert operation
|
|
2240
2282
|
this.validateState()
|
|
2241
2283
|
|
|
@@ -2374,15 +2416,12 @@ class Database extends EventEmitter {
|
|
|
2374
2416
|
console.warn(`⚠️ Frequent integrity corrections (${this.integrityCorrections.dataIntegrity} times) - this indicates a systemic issue`)
|
|
2375
2417
|
}
|
|
2376
2418
|
|
|
2419
|
+
// Reconcile the in-memory totalLines with the actual persisted offset
|
|
2420
|
+
// count. NOTE: deliberately NOT persisted here - find() is a read path
|
|
2421
|
+
// and must never rewrite the .idx file (especially while a concurrent
|
|
2422
|
+
// writer may be replacing it). A later save() persists a fully
|
|
2423
|
+
// consistent .idx anyway.
|
|
2377
2424
|
this.indexManager.setTotalLines(offsetsLength)
|
|
2378
|
-
|
|
2379
|
-
// Try to persist the fix, but don't fail the operation if it doesn't work
|
|
2380
|
-
try {
|
|
2381
|
-
await this._saveIndexDataToFile()
|
|
2382
|
-
} catch (error) {
|
|
2383
|
-
// Just track the failure - don't throw since this is a safety net
|
|
2384
|
-
this.integrityCorrections.indexSaveFailures++
|
|
2385
|
-
}
|
|
2386
2425
|
}
|
|
2387
2426
|
}
|
|
2388
2427
|
|
|
@@ -3070,7 +3109,16 @@ class Database extends EventEmitter {
|
|
|
3070
3109
|
}
|
|
3071
3110
|
if (!this._indexRebuildNeeded) return
|
|
3072
3111
|
if (!this.indexManager || !this.indexManager.indexedFields || this.indexManager.indexedFields.length === 0) return
|
|
3073
|
-
|
|
3112
|
+
|
|
3113
|
+
// If the index was idle-unloaded (or never loaded), recover it cheaply from
|
|
3114
|
+
// the on-disk .idx file BEFORE considering a full rebuild. A rebuild is only
|
|
3115
|
+
// needed when the on-disk index is genuinely missing/corrupt. Mirrors the
|
|
3116
|
+
// score() hot path and prevents an idle-unload from triggering a full-file
|
|
3117
|
+
// rebuild (or a throw when allowIndexRebuild is false).
|
|
3118
|
+
if (!this.indexManager.indexLoaded) {
|
|
3119
|
+
await this._ensureLazyIndexLoaded()
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3074
3122
|
// Check if index actually needs rebuilding
|
|
3075
3123
|
let needsRebuild = false
|
|
3076
3124
|
for (const field of this.indexManager.indexedFields) {
|
|
@@ -3084,7 +3132,14 @@ class Database extends EventEmitter {
|
|
|
3084
3132
|
this._indexRebuildNeeded = false
|
|
3085
3133
|
return
|
|
3086
3134
|
}
|
|
3087
|
-
|
|
3135
|
+
|
|
3136
|
+
// Read-only mode: never write/rebuild on disk. Let queries fall back to a
|
|
3137
|
+
// full streaming scan instead of throwing or rewriting the index file.
|
|
3138
|
+
if (this.opts.readOnly) {
|
|
3139
|
+
this._indexRebuildNeeded = false
|
|
3140
|
+
return
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3088
3143
|
// Check if rebuild is allowed
|
|
3089
3144
|
if (!this.opts.allowIndexRebuild) {
|
|
3090
3145
|
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
|
|
@@ -3165,9 +3220,13 @@ class Database extends EventEmitter {
|
|
|
3165
3220
|
crlfDelay: Infinity
|
|
3166
3221
|
})
|
|
3167
3222
|
|
|
3168
|
-
// Handle abort
|
|
3223
|
+
// Handle abort. Destroy the stream WITHOUT an error payload, then
|
|
3224
|
+
// close readline. Destroying with `new Error('AbortError')` makes
|
|
3225
|
+
// the stream emit an unhandled 'error' (readline is closed first, so
|
|
3226
|
+
// the for-await loop exits cleanly and never observes it), which
|
|
3227
|
+
// Node escalates to an uncaughtException and crashes the host app.
|
|
3169
3228
|
controller.signal.addEventListener('abort', () => {
|
|
3170
|
-
stream.destroy(
|
|
3229
|
+
stream.destroy();
|
|
3171
3230
|
rl.close();
|
|
3172
3231
|
});
|
|
3173
3232
|
|
|
@@ -3209,6 +3268,13 @@ class Database extends EventEmitter {
|
|
|
3209
3268
|
}
|
|
3210
3269
|
}
|
|
3211
3270
|
}
|
|
3271
|
+
|
|
3272
|
+
// If aborted during schema detection, surface a normalized timeout
|
|
3273
|
+
// error so the retry wrapper (and ultimately the caller) observes it
|
|
3274
|
+
// instead of an uncaughtException.
|
|
3275
|
+
if (controller.signal.aborted) {
|
|
3276
|
+
throw createIoTimeoutError(timeoutMs)
|
|
3277
|
+
}
|
|
3212
3278
|
stream.destroy()
|
|
3213
3279
|
}
|
|
3214
3280
|
|
|
@@ -3228,9 +3294,10 @@ class Database extends EventEmitter {
|
|
|
3228
3294
|
crlfDelay: Infinity
|
|
3229
3295
|
})
|
|
3230
3296
|
|
|
3231
|
-
// Handle abort
|
|
3297
|
+
// Handle abort. See the schema stream above - destroy WITHOUT an error
|
|
3298
|
+
// payload to avoid an unhandled 'error' becoming an uncaughtException.
|
|
3232
3299
|
controller.signal.addEventListener('abort', () => {
|
|
3233
|
-
stream.destroy(
|
|
3300
|
+
stream.destroy();
|
|
3234
3301
|
rl.close();
|
|
3235
3302
|
});
|
|
3236
3303
|
|
|
@@ -3257,10 +3324,20 @@ class Database extends EventEmitter {
|
|
|
3257
3324
|
currentOffset += Buffer.byteLength(line, 'utf8') + 1
|
|
3258
3325
|
}
|
|
3259
3326
|
|
|
3327
|
+
// If the read was aborted, do NOT treat a partial read as a successful
|
|
3328
|
+
// rebuild - surface a normalized timeout error so it can be retried.
|
|
3329
|
+
if (controller.signal.aborted) {
|
|
3330
|
+
throw createIoTimeoutError(timeoutMs)
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3260
3333
|
count += localCount;
|
|
3261
3334
|
stream.destroy()
|
|
3262
3335
|
} catch (error) {
|
|
3263
|
-
|
|
3336
|
+
// p-retry "stop" signal (may be absent when p-retry is mocked in tests)
|
|
3337
|
+
if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
|
|
3338
|
+
throw error
|
|
3339
|
+
}
|
|
3340
|
+
if (isIoTimeoutError(error)) {
|
|
3264
3341
|
if (this.opts.debugMode) {
|
|
3265
3342
|
console.log(`⚠️ Index rebuild attempt ${attempt} timed out, retrying...`);
|
|
3266
3343
|
}
|
|
@@ -4063,7 +4140,12 @@ class Database extends EventEmitter {
|
|
|
4063
4140
|
if (!this.indexManager.indexLoaded) {
|
|
4064
4141
|
await this._ensureLazyIndexLoaded()
|
|
4065
4142
|
}
|
|
4066
|
-
|
|
4143
|
+
|
|
4144
|
+
// Swap detection: if another process atomically replaced the data file,
|
|
4145
|
+
// reload offsets + index (and term mapping) cleanly so the scores below are
|
|
4146
|
+
// computed against the current file, never stale byte ranges.
|
|
4147
|
+
await this._refreshFromDiskIfChanged()
|
|
4148
|
+
|
|
4067
4149
|
// Access the index for this field
|
|
4068
4150
|
const fieldIndex = this.indexManager.index.data[fieldName]
|
|
4069
4151
|
if (!fieldIndex || Object.keys(fieldIndex).length === 0) {
|
|
@@ -4305,9 +4387,19 @@ class Database extends EventEmitter {
|
|
|
4305
4387
|
}
|
|
4306
4388
|
}
|
|
4307
4389
|
|
|
4390
|
+
// If the read was aborted, do not silently return partial scores -
|
|
4391
|
+
// surface a normalized timeout error so it can be retried.
|
|
4392
|
+
if (controller.signal.aborted) {
|
|
4393
|
+
throw createIoTimeoutError(timeoutMs)
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4308
4396
|
results.push(...rangeResults);
|
|
4309
4397
|
} catch (error) {
|
|
4310
|
-
|
|
4398
|
+
// p-retry "stop" signal (may be absent when p-retry is mocked in tests)
|
|
4399
|
+
if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
|
|
4400
|
+
throw error
|
|
4401
|
+
}
|
|
4402
|
+
if (isIoTimeoutError(error)) {
|
|
4311
4403
|
if (this.opts.debugMode) {
|
|
4312
4404
|
console.log(`⚠️ Score range read attempt ${attempt} timed out, retrying...`);
|
|
4313
4405
|
}
|
|
@@ -5391,11 +5483,26 @@ class Database extends EventEmitter {
|
|
|
5391
5483
|
return
|
|
5392
5484
|
}
|
|
5393
5485
|
|
|
5394
|
-
|
|
5395
|
-
|
|
5486
|
+
// Single-flight: N concurrent callers (find/count/score) share a single
|
|
5487
|
+
// on-disk load. The promise is cleared in a finally so a rejected load is
|
|
5488
|
+
// never left behind to poison every future attempt.
|
|
5489
|
+
if (!this._lazyIndexLoadPromise) {
|
|
5490
|
+
this._lazyIndexLoadPromise = this._doLazyIndexLoad().finally(() => {
|
|
5491
|
+
this._lazyIndexLoadPromise = null
|
|
5492
|
+
})
|
|
5396
5493
|
}
|
|
5494
|
+
return this._lazyIndexLoadPromise
|
|
5495
|
+
}
|
|
5397
5496
|
|
|
5398
|
-
|
|
5497
|
+
/**
|
|
5498
|
+
* Actually perform the lazy index load from the on-disk .idx file.
|
|
5499
|
+
* Never rejects: on failure it degrades to an empty in-memory index (queries
|
|
5500
|
+
* fall back to streaming) instead of poisoning the single-flight or surfacing
|
|
5501
|
+
* a background cold-load as an unhandled rejection.
|
|
5502
|
+
* @private
|
|
5503
|
+
*/
|
|
5504
|
+
async _doLazyIndexLoad() {
|
|
5505
|
+
try {
|
|
5399
5506
|
const indexData = await this._loadIndexDataFromFile()
|
|
5400
5507
|
if (indexData) {
|
|
5401
5508
|
this.indexManager.load(indexData)
|
|
@@ -5406,11 +5513,143 @@ class Database extends EventEmitter {
|
|
|
5406
5513
|
this.indexManager.indexLoaded = true
|
|
5407
5514
|
this.indexManager.markIndexUsed()
|
|
5408
5515
|
}
|
|
5516
|
+
} catch (error) {
|
|
5517
|
+
if (this.opts.debugMode) {
|
|
5518
|
+
console.error(`⚠️ _ensureLazyIndexLoaded: Lazy index load failed, continuing with empty index: ${error && error.message}`)
|
|
5519
|
+
}
|
|
5520
|
+
// Never leave the index flagged as not-loaded: mark it loaded (empty) so
|
|
5521
|
+
// later calls short-circuit and queries degrade to a full scan.
|
|
5522
|
+
this.indexManager.indexLoaded = true
|
|
5523
|
+
}
|
|
5524
|
+
}
|
|
5409
5525
|
|
|
5410
|
-
|
|
5411
|
-
|
|
5526
|
+
/**
|
|
5527
|
+
* Path of the "writer active" sentinel file for this data file, e.g.
|
|
5528
|
+
* `list.jdb` -> `list.updating.jdb`. While this file exists, a writer is
|
|
5529
|
+
* atomically replacing the database and readers must not refresh/repair it.
|
|
5530
|
+
* @returns {string}
|
|
5531
|
+
*/
|
|
5532
|
+
_getUpdatingSentinelPath() {
|
|
5533
|
+
return this.normalizedFile.replace(/\.jdb$/i, '.updating.jdb')
|
|
5534
|
+
}
|
|
5412
5535
|
|
|
5413
|
-
|
|
5536
|
+
/**
|
|
5537
|
+
* Whether a writer sentinel is currently present on disk.
|
|
5538
|
+
* @returns {Promise<boolean>}
|
|
5539
|
+
*/
|
|
5540
|
+
async hasUpdatingSentinel() {
|
|
5541
|
+
try {
|
|
5542
|
+
await fs.promises.access(this._getUpdatingSentinelPath())
|
|
5543
|
+
return true
|
|
5544
|
+
} catch (error) {
|
|
5545
|
+
return false
|
|
5546
|
+
}
|
|
5547
|
+
}
|
|
5548
|
+
|
|
5549
|
+
/**
|
|
5550
|
+
* Capture the current data-file fingerprint (size + mtime) for swap
|
|
5551
|
+
* detection. Best-effort: returns null when the file cannot be stat'ed.
|
|
5552
|
+
* @returns {Promise<{size: number, mtimeMs: number}|null>}
|
|
5553
|
+
*/
|
|
5554
|
+
async _statDataFingerprint() {
|
|
5555
|
+
try {
|
|
5556
|
+
const st = await fs.promises.stat(this.normalizedFile)
|
|
5557
|
+
return { size: st.size, mtimeMs: st.mtimeMs }
|
|
5558
|
+
} catch (error) {
|
|
5559
|
+
return null
|
|
5560
|
+
}
|
|
5561
|
+
}
|
|
5562
|
+
|
|
5563
|
+
/**
|
|
5564
|
+
* Cheap swap detection for the read path. The data file may be atomically
|
|
5565
|
+
* replaced by a writer (same or another process); the in-memory offsets and
|
|
5566
|
+
* index describe the previous file. When the on-disk size/mtime no longer
|
|
5567
|
+
* match the signature captured when the offsets were loaded, reload offsets
|
|
5568
|
+
* and index cleanly from the current .idx.
|
|
5569
|
+
*
|
|
5570
|
+
* Returns:
|
|
5571
|
+
* - 'ok' : nothing changed / refresh not applicable
|
|
5572
|
+
* - 'changed' : file was swapped and offsets + index were cleanly reloaded
|
|
5573
|
+
* - 'busy' : a writer sentinel is present (or the clean reload failed) -
|
|
5574
|
+
* the caller must fall back to a full streaming scan instead
|
|
5575
|
+
* of using possibly stale byte offsets on the new file
|
|
5576
|
+
* @returns {Promise<'ok'|'changed'|'busy'>}
|
|
5577
|
+
*/
|
|
5578
|
+
async _refreshFromDiskIfChanged() {
|
|
5579
|
+
// If a writer is mid-update, never refresh/repair in place.
|
|
5580
|
+
if (this.opts.updatingSentinel && await this.hasUpdatingSentinel()) {
|
|
5581
|
+
return 'busy'
|
|
5582
|
+
}
|
|
5583
|
+
|
|
5584
|
+
if (!this._offsetsFileSignature) return 'ok'
|
|
5585
|
+
if (!this.offsets || this.offsets.length === 0) return 'ok'
|
|
5586
|
+
|
|
5587
|
+
// Cheap stat with a short TTL so hot queries don't syscall every time.
|
|
5588
|
+
const now = Date.now()
|
|
5589
|
+
if (!this._dataFingerprint || !this._dataFingerprint.at || now - this._dataFingerprint.at > 250) {
|
|
5590
|
+
const st = await this._statDataFingerprint()
|
|
5591
|
+
this._dataFingerprint = { ...(st || { size: 0, mtimeMs: 0 }), at: now }
|
|
5592
|
+
}
|
|
5593
|
+
const fp = this._dataFingerprint
|
|
5594
|
+
const sig = this._offsetsFileSignature
|
|
5595
|
+
if (fp.size === sig.size && fp.mtimeMs === sig.mtimeMs) return 'ok'
|
|
5596
|
+
|
|
5597
|
+
const reloaded = await this._reloadOffsetsAndIndex()
|
|
5598
|
+
if (!reloaded) return 'busy'
|
|
5599
|
+
// Signature now describes the (already stat'ed) new file
|
|
5600
|
+
this._offsetsFileSignature = { size: fp.size, mtimeMs: fp.mtimeMs }
|
|
5601
|
+
return 'changed'
|
|
5602
|
+
}
|
|
5603
|
+
|
|
5604
|
+
/**
|
|
5605
|
+
* Cleanly reload offsets/indexOffset/index data from the on-disk .idx file
|
|
5606
|
+
* after the data file was swapped. Single-flight so N concurrent queries
|
|
5607
|
+
* share one reload.
|
|
5608
|
+
* @returns {Promise<boolean>} true when the reload produced consistent state
|
|
5609
|
+
*/
|
|
5610
|
+
async _reloadOffsetsAndIndex() {
|
|
5611
|
+
if (this._offsetsReloadPromise) {
|
|
5612
|
+
return this._offsetsReloadPromise
|
|
5613
|
+
}
|
|
5614
|
+
this._offsetsReloadPromise = (async () => {
|
|
5615
|
+
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
|
|
5616
|
+
try {
|
|
5617
|
+
const raw = await fs.promises.readFile(idxPath, 'utf8')
|
|
5618
|
+
const parsed = JSON.parse(raw)
|
|
5619
|
+
if (!parsed || !Array.isArray(parsed.offsets)) return false
|
|
5620
|
+
this.offsets = parsed.offsets
|
|
5621
|
+
this.indexOffset = typeof parsed.indexOffset === 'number' ? parsed.indexOffset : 0
|
|
5622
|
+
|
|
5623
|
+
// Reload term mapping (if any) first so term IDs/words match the new
|
|
5624
|
+
// index and the records it describes.
|
|
5625
|
+
if (parsed.termMapping && this.termManager && typeof this.termManager.loadTerms === 'function') {
|
|
5626
|
+
this.termManager.loadTerms(parsed.termMapping)
|
|
5627
|
+
}
|
|
5628
|
+
|
|
5629
|
+
// Clear the current in-memory index so IndexManager.load() does not
|
|
5630
|
+
// short-circuit (load() skips when it already sees actual data).
|
|
5631
|
+
if (this.indexManager && this.indexManager.indexedFields) {
|
|
5632
|
+
for (const field of this.indexManager.indexedFields) {
|
|
5633
|
+
if (this.indexManager.index && this.indexManager.index.data) {
|
|
5634
|
+
this.indexManager.index.data[field] = {}
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
}
|
|
5638
|
+
// Reload the in-memory index from the same (new) .idx data
|
|
5639
|
+
this.indexManager.indexLoaded = false
|
|
5640
|
+
await this._ensureLazyIndexLoaded()
|
|
5641
|
+
this.indexManager.setTotalLines(this.offsets.length)
|
|
5642
|
+
return true
|
|
5643
|
+
} catch (error) {
|
|
5644
|
+
if (this.opts.debugMode) {
|
|
5645
|
+
console.warn(`⚠️ _reloadOffsetsAndIndex: could not reload from ${idxPath}: ${error && error.message}`)
|
|
5646
|
+
}
|
|
5647
|
+
return false
|
|
5648
|
+
} finally {
|
|
5649
|
+
this._offsetsReloadPromise = null
|
|
5650
|
+
}
|
|
5651
|
+
})()
|
|
5652
|
+
return this._offsetsReloadPromise
|
|
5414
5653
|
}
|
|
5415
5654
|
|
|
5416
5655
|
/**
|
|
@@ -5775,6 +6014,10 @@ class Database extends EventEmitter {
|
|
|
5775
6014
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
5776
6015
|
}
|
|
5777
6016
|
}
|
|
6017
|
+
|
|
6018
|
+
// Swap detection: if another process replaced the data file, reload offsets
|
|
6019
|
+
// and index cleanly so the line map / ranges describe the current file.
|
|
6020
|
+
await this._refreshFromDiskIfChanged()
|
|
5778
6021
|
|
|
5779
6022
|
let count = 0
|
|
5780
6023
|
let remainingSkip = options.skip || 0
|
|
@@ -6415,6 +6658,9 @@ class Database extends EventEmitter {
|
|
|
6415
6658
|
* @private
|
|
6416
6659
|
*/
|
|
6417
6660
|
async _saveIndexDataToFile() {
|
|
6661
|
+
// Read-only mode: never write to the index file on disk
|
|
6662
|
+
if (this.opts.readOnly) return
|
|
6663
|
+
|
|
6418
6664
|
if (this.indexManager) {
|
|
6419
6665
|
try {
|
|
6420
6666
|
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
|
|
@@ -6483,7 +6729,7 @@ class Database extends EventEmitter {
|
|
|
6483
6729
|
// This ensures the database structure is complete
|
|
6484
6730
|
const originalFile = this.fileHandler.file
|
|
6485
6731
|
this.fileHandler.file = idxPath
|
|
6486
|
-
await this.fileHandler.
|
|
6732
|
+
await this.fileHandler.writeFileAtomic(JSON.stringify(indexData, null, 2))
|
|
6487
6733
|
this.fileHandler.file = originalFile
|
|
6488
6734
|
|
|
6489
6735
|
if (this.opts.debugMode) {
|
package/src/FileHandler.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'path'
|
|
|
3
3
|
import readline from 'readline'
|
|
4
4
|
import pLimit from 'p-limit'
|
|
5
5
|
import pRetry from 'p-retry'
|
|
6
|
+
import { isIoTimeoutError } from './utils/ioTimeout.mjs'
|
|
6
7
|
|
|
7
8
|
export default class FileHandler {
|
|
8
9
|
constructor(file, fileMutex = null, opts = {}) {
|
|
@@ -707,6 +708,23 @@ export default class FileHandler {
|
|
|
707
708
|
await fs.promises.rename(tmpFile, targetFile);
|
|
708
709
|
return; // Success
|
|
709
710
|
} catch (error) {
|
|
711
|
+
// ENOENT: the temp file vanished before the rename. With unique temp names
|
|
712
|
+
// (see _atomicReplaceFile) this means another concurrent writer already
|
|
713
|
+
// replaced the target (its rename won, ours was superseded) or an external
|
|
714
|
+
// cleanup removed our temp. If the target exists, the write was superseded,
|
|
715
|
+
// not an error. (CHANGELOG [2.2.5])
|
|
716
|
+
if (error.code === 'ENOENT') {
|
|
717
|
+
try {
|
|
718
|
+
await fs.promises.access(targetFile)
|
|
719
|
+
return // target already replaced by a concurrent writer - ok
|
|
720
|
+
} catch (accessError) {
|
|
721
|
+
if (attempt < maxRetries) {
|
|
722
|
+
await new Promise(resolve => setTimeout(resolve, 50 * attempt))
|
|
723
|
+
continue
|
|
724
|
+
}
|
|
725
|
+
throw error
|
|
726
|
+
}
|
|
727
|
+
}
|
|
710
728
|
if (error.code === 'EPERM' && attempt < maxRetries) {
|
|
711
729
|
// Quick delay: 50ms, 100ms, 200ms
|
|
712
730
|
const delay = 50 * attempt;
|
|
@@ -786,6 +804,71 @@ export default class FileHandler {
|
|
|
786
804
|
}
|
|
787
805
|
}
|
|
788
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Atomically replace this file's content: write to a sibling temp file, then
|
|
809
|
+
* rename over the target (with a Windows copy fallback on EPERM). Concurrent
|
|
810
|
+
* readers never observe a half-written file.
|
|
811
|
+
* @param {Buffer|string} data - Full content of the new file.
|
|
812
|
+
*/
|
|
813
|
+
async _atomicReplaceFile(data) {
|
|
814
|
+
// Use a UNIQUE temp name per write. A fixed "<file>.tmp" collides when two
|
|
815
|
+
// Database instances write the same file concurrently: each removes the
|
|
816
|
+
// other's temp before its rename, causing ENOENT. (CHANGELOG [2.2.5])
|
|
817
|
+
const tmpFile = `${this.file}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`
|
|
818
|
+
const release = this.fileMutex ? await this.fileMutex.acquire() : () => {}
|
|
819
|
+
try {
|
|
820
|
+
await fs.promises.writeFile(tmpFile, data)
|
|
821
|
+
|
|
822
|
+
// Windows: brief delay helps readers release the target handle before the
|
|
823
|
+
// rename (EPERM mitigation, mirroring _writeFileWithRetry).
|
|
824
|
+
if (process.platform === 'win32') {
|
|
825
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
await this._safeRename(tmpFile, this.file)
|
|
829
|
+
} catch (error) {
|
|
830
|
+
// Best-effort cleanup of our own temp on failure.
|
|
831
|
+
await fs.promises.unlink(tmpFile).catch(() => {})
|
|
832
|
+
throw error
|
|
833
|
+
} finally {
|
|
834
|
+
release()
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Atomically replace the whole data file with the given content.
|
|
840
|
+
*
|
|
841
|
+
* When `opts.updatingSentinel` is enabled, a `<file>.updating.jdb` sentinel is
|
|
842
|
+
* created for the duration of the swap so cross-process readers can detect an
|
|
843
|
+
* active writer and avoid touching the file mid-update.
|
|
844
|
+
*
|
|
845
|
+
* @param {Buffer|string} data - Full content of the new data file.
|
|
846
|
+
*/
|
|
847
|
+
async replaceDataFile(data) {
|
|
848
|
+
const sentinelFile = this.file.replace(/\.jdb$/i, '.updating.jdb')
|
|
849
|
+
const useSentinel = !!(this.opts && this.opts.updatingSentinel)
|
|
850
|
+
|
|
851
|
+
let releaseSentinel = null
|
|
852
|
+
if (useSentinel) {
|
|
853
|
+
// Signal "writer active" before touching the target
|
|
854
|
+
await fs.promises.writeFile(sentinelFile, String(Date.now()), 'utf8').catch(() => {})
|
|
855
|
+
releaseSentinel = () => fs.promises.unlink(sentinelFile).catch(() => {})
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
await this._atomicReplaceFile(data)
|
|
859
|
+
} finally {
|
|
860
|
+
if (releaseSentinel) await releaseSentinel()
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Atomically replace this file's content (used for the .idx.jdb index file).
|
|
866
|
+
* @param {Buffer|string} data - Full content of the new file.
|
|
867
|
+
*/
|
|
868
|
+
async writeFileAtomic(data) {
|
|
869
|
+
await this._atomicReplaceFile(data)
|
|
870
|
+
}
|
|
871
|
+
|
|
789
872
|
async writeData(data, immediate, fd) {
|
|
790
873
|
await fd.write(data)
|
|
791
874
|
}
|
|
@@ -898,7 +981,11 @@ export default class FileHandler {
|
|
|
898
981
|
const results = await this._readWithStreamingInternal(criteria, options, matchesCriteria, serializer, controller.signal);
|
|
899
982
|
return results;
|
|
900
983
|
} catch (error) {
|
|
901
|
-
|
|
984
|
+
// p-retry "stop" signal (may be absent when p-retry is mocked in tests)
|
|
985
|
+
if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
|
|
986
|
+
throw error
|
|
987
|
+
}
|
|
988
|
+
if (isIoTimeoutError(error)) {
|
|
902
989
|
if (this.opts.debugMode) {
|
|
903
990
|
console.log(`⚠️ Streaming read attempt ${attempt} timed out, retrying...`);
|
|
904
991
|
}
|