Skip to content

Commit be3aa64

Browse files
committed
Add .defer
1 parent 3bb6b46 commit be3aa64

File tree

10 files changed

+177
-32
lines changed

10 files changed

+177
-32
lines changed

CHANGELOG.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
1+
## 0.16.3
2+
3+
- Added `KV.defer(promiseToHandle, [errorHandler], [timeoutMs])` method to allow
4+
non-awaited promises to be tracked and settled during `KV.close()`.
5+
- `errorHandler` (optional): A function to handle errors that occur during
6+
promise resolution/rejection. If not provided, errors will silently ignored.
7+
- `timeoutMs` (optional): A timeout (in milliseconds) for promise resolution.
8+
If the promise doesn't settle within this time during `KV.close()`, a
9+
warning will be logged. Defaults to 5000ms.
10+
- Fix cli tool not being able to open any database after a failed open
11+
- Code refactors
12+
113
## 0.16.2
214

3-
- Fix for Node.js; use `readline` instead of prompt.
4-
-
15+
- Fix for Node.js; use `readline` instead of prompt
516

617
## 0.16.1
718

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ deno install -frA --name ckv jsr:@cross/kv/cli
136136
- `on(eventName, eventData)` - Subscribes to events like `sync`,
137137
`watchdogError`, or `closing` to get notified of specific occurrences.
138138
- `isOpen()` - Returns true if the database is open and ready for operations.
139+
- `defer(promiseToHandle, [errorHandler], [timeoutMs])` - Defers the
140+
resolution or rejection of a Promise until `.close()`
139141
- `async close()` - Closes the KV store, ensuring resources are released.
140142
141143
### Keys

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@cross/kv",
3-
"version": "0.16.2",
3+
"version": "0.16.3",
44
"exports": {
55
".": "./mod.ts",
66
"./cli": "./src/cli/mod.ts"

src/cli/commands/open.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export async function open(
3636
await container.db.open(dbPath, true);
3737
return true;
3838
} catch (e) {
39+
container.db = undefined;
3940
console.error(`Could not open database: ${e.message}`);
4041
return false;
4142
}

src/lib/cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { KVLedgerResult } from "./ledger.ts";
66
*
77
* This cache stores transaction results (`KVLedgerResult`) associated with their offsets within the ledger.
88
* It maintains a fixed maximum size and evicts the oldest entries (Least Recently Used - LRU)
9-
* when the cache becomes full. Since the ledger is append-only, expiration is not necessary.
9+
* when the cache becomes full.
1010
*
1111
* Note: The `cacheSizeBytes` property is an approximation of the cache's size and represents
1212
* the encoded size of the transaction data on disk, not the actual memory usage of the cached objects.

src/lib/kv.ts

Lines changed: 101 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export class KV extends EventEmitter {
131131
private isInTransaction: boolean = false;
132132
private watchdogTimer?: number; // Undefined if not scheduled or currently running
133133
private watchdogPromise?: Promise<void>;
134+
/** Used through .deferCompletion to make .close await the action */
135+
private promiseWatchlist: Promise<unknown>[];
134136

135137
/**
136138
* Initializes a new instance of the cross/kv main class `KV`.
@@ -142,6 +144,8 @@ export class KV extends EventEmitter {
142144
constructor(options: KVOptions = {}) {
143145
super();
144146

147+
this.promiseWatchlist = [];
148+
145149
// Validate and set options
146150
// - autoSync
147151
if (
@@ -184,6 +188,54 @@ export class KV extends EventEmitter {
184188
this.watchdogPromise = this.watchdog();
185189
}
186190
}
191+
192+
/**
193+
* Defers the resolution or rejection of a Promise until the `.close()` method is called.
194+
*
195+
* This function adds the provided promise to a `promiseWatchlist`. During the `close()` method, the database
196+
* will wait for all promises in the watchlist to settle (resolve or reject) before finalizing the closure.
197+
* If an `errorHandler` function is provided, it will be called with any errors that occur during the promise's
198+
* execution. Otherwise, errors will be silently ignored.
199+
*
200+
* @param promiseToHandle - The Promise whose resolution or rejection is to be deferred.
201+
* @param errorHandler - An optional function to handle errors that occur during the promise's execution.
202+
* @returns The original promise, allowing for chaining.
203+
*/
204+
public defer(
205+
promiseToHandle: Promise<unknown>,
206+
errorHandler?: (error: unknown) => void,
207+
): Promise<unknown> {
208+
this.promiseWatchlist.push(promiseToHandle);
209+
210+
promiseToHandle.finally(() => {
211+
this.removePromiseFromWatchlist(promiseToHandle);
212+
}).catch((error) => {
213+
if (errorHandler) {
214+
errorHandler(error); // Call the custom error handler
215+
} else {
216+
/** Silently ignore */
217+
}
218+
this.removePromiseFromWatchlist(promiseToHandle);
219+
});
220+
221+
return promiseToHandle;
222+
}
223+
224+
/**
225+
* Removes a Promise from the `promiseWatchlist`.
226+
*
227+
* This function is used internally to clean up the watchlist after a promise has been settled (resolved or rejected).
228+
* It ensures that only pending promises remain in the watchlist.
229+
*
230+
* @param promiseToRemove - The Promise to remove from the watchlist.
231+
*/
232+
private removePromiseFromWatchlist(promiseToRemove: Promise<unknown>) {
233+
const index = this.promiseWatchlist.indexOf(promiseToRemove);
234+
if (index > -1) {
235+
this.promiseWatchlist.splice(index, 1);
236+
}
237+
}
238+
187239
/**
188240
* Opens the Key-Value store based on a provided file path.
189241
* Initializes the index and data files.
@@ -795,31 +847,65 @@ export class KV extends EventEmitter {
795847
}
796848

797849
/**
798-
* Closes the database gracefully.
850+
* Closes the database gracefully, awaiting pending promises and optionally applying a timeout.
851+
*
852+
* 1. Awaits all deferred promises in the `promiseWatchlist`.
853+
* 2. Waits for any ongoing watchdog task to complete.
854+
* 3. Emits a 'closing' event to notify listeners.
855+
* 4. Closes the associated ledger.
799856
*
800-
* 1. Waits for any ongoing watchdog task to complete.
801-
* 2. Emits a 'closing' event to notify listeners.
802-
* 3. Closes the associated ledger.
857+
* @param timeoutMs (optional) - The maximum time in milliseconds to wait for promises to resolve before closing. Defaults to 5000ms.
803858
*/
804-
public async close() {
859+
public async close(timeoutMs = 5000) { // Default timeout of 5 seconds
805860
// @ts-ignore emit exists
806861
this.emit("closing");
807862

808863
// Used to stop any pending watchdog runs
809864
this.aborted = true;
810865

811-
// Await running watchdog
812-
await this.watchdogPromise;
866+
try {
867+
// Create a timeout promise
868+
let promiseTimeout;
869+
const timeoutPromise = new Promise((_, reject) => {
870+
promiseTimeout = setTimeout(
871+
() => reject(new Error("Database close timeout")),
872+
timeoutMs,
873+
);
874+
});
813875

814-
// Abort any watchdog timer
815-
clearTimeout(this.watchdogTimer!);
876+
// Race to see if promises settle before the timeout
877+
await Promise.race([
878+
Promise.allSettled(this.promiseWatchlist),
879+
timeoutPromise,
880+
]);
816881

817-
// Clear all local variables to avoid problems with unexpected usage after closing
818-
this.ledgerPath = undefined;
819-
this.ledger = undefined;
820-
this.index = new KVIndex();
821-
this.pendingTransactions = [];
822-
this.watchHandlers = [];
882+
// Clear the promise timeout on success
883+
clearTimeout(promiseTimeout);
884+
885+
// Await running watchdog if it hasn't been aborted
886+
if (this.watchdogPromise) {
887+
await this.watchdogPromise;
888+
}
889+
} catch (error) {
890+
if (error.message === "Database close timeout") {
891+
console.warn(
892+
"Database close timed out. Some promises may not have resolved:",
893+
this.promiseWatchlist,
894+
);
895+
} else {
896+
console.error("Error during database close:", error);
897+
}
898+
} finally {
899+
// Clear watchdog timer regardless of errors
900+
clearTimeout(this.watchdogTimer!);
901+
902+
// Reset internal state
903+
this.ledgerPath = undefined;
904+
this.ledger = undefined;
905+
this.index = new KVIndex();
906+
this.pendingTransactions = [];
907+
this.watchHandlers = [];
908+
}
823909
}
824910

825911
/**

src/lib/ledger.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
LEDGER_CURRENT_VERSION,
1212
LEDGER_FILE_ID,
1313
LEDGER_MAX_READ_FAILURES,
14+
LEDGER_PREFETCH_BYTES,
1415
LOCK_BYTE_OFFSET,
1516
LOCK_DEFAULT_INITIAL_RETRY_INTERVAL_MS,
1617
LOCK_DEFAULT_MAX_RETRIES,
@@ -78,7 +79,7 @@ export class KVLedger {
7879
constructor(filePath: string, maxCacheSizeMBytes: number) {
7980
this.dataPath = toNormalizedAbsolutePath(filePath);
8081
this.cache = new KVLedgerCache(maxCacheSizeMBytes * 1024 * 1024);
81-
this.prefetch = new KVPrefetcher();
82+
this.prefetch = new KVPrefetcher(LEDGER_PREFETCH_BYTES);
8283
}
8384

8485
/**

src/lib/prefetcher.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,55 @@
11
import { readAtPosition } from "./utils/file.ts";
22
import type { FileHandle } from "node:fs/promises";
3-
import { LEDGER_PREFETCH_BYTES } from "./constants.ts";
43

4+
/**
5+
* Manages prefetching data from files for efficient sequential reading.
6+
*
7+
* This class optimizes reading by fetching chunks of data larger than the requested amount,
8+
* reducing the number of file reads needed for sequential access.
9+
*/
510
export class KVPrefetcher {
611
private cache?: Uint8Array;
712
private currentChunkStart: number;
813
private currentChunkEnd: number;
14+
private prefetchBytes: number;
915

10-
constructor() {
16+
constructor(prefetchBytes: number) {
1117
this.currentChunkStart = 0;
1218
this.currentChunkEnd = 0;
19+
this.prefetchBytes = prefetchBytes;
1320
}
1421

22+
/**
23+
* Fetches a chunk of data from the file.
24+
*
25+
* @param fd The file descriptor or handle.
26+
* @param startPosition The position to start reading from.
27+
* @param length The desired length of the chunk.
28+
*/
1529
private async fetchChunk(
1630
fd: Deno.FsFile | FileHandle,
1731
startPosition: number,
1832
length: number,
1933
): Promise<void> {
2034
const chunk = await readAtPosition(
2135
fd,
22-
length > LEDGER_PREFETCH_BYTES ? length : LEDGER_PREFETCH_BYTES,
36+
length > this.prefetchBytes ? length : this.prefetchBytes,
2337
startPosition,
2438
);
2539
this.cache = chunk;
2640
this.currentChunkStart = startPosition;
2741
this.currentChunkEnd = startPosition + chunk.length;
2842
}
2943

44+
/**
45+
* Reads data from the file, using the cache if possible.
46+
*
47+
* @param fd The file descriptor or handle.
48+
* @param length The amount of data to read.
49+
* @param position The position to start reading from.
50+
* @returns The requested data.
51+
* @throws {Error} If data fetching fails.
52+
*/
3053
public async read(
3154
fd: Deno.FsFile | FileHandle,
3255
length: number,
@@ -52,6 +75,9 @@ export class KVPrefetcher {
5275
);
5376
}
5477

78+
/**
79+
* Clears the cached data.
80+
*/
5581
public clear() {
5682
this.cache = undefined;
5783
}

src/lib/utils/file.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,7 @@ export async function rawOpen(
4848
if (CurrentRuntime === Runtime.Deno) {
4949
return await Deno.open(filename, { read: true, write: write });
5050
} else {
51-
const mode = write ? "r+" : "r";
52-
return await open(filename, mode);
51+
return await open(filename, write ? "r+" : "r");
5352
}
5453
}
5554

@@ -58,25 +57,23 @@ export async function readAtPosition(
5857
length: number,
5958
position: number,
6059
): Promise<Uint8Array> {
60+
const buffer = new Uint8Array(length);
61+
let bytesRead: number | null;
6162
// Deno
6263
if (CurrentRuntime === Runtime.Deno) {
6364
await (fd as Deno.FsFile).seek(position, Deno.SeekMode.Start);
64-
const buffer = new Uint8Array(length);
65-
const bytesRead = await (fd as Deno.FsFile).read(buffer);
66-
return buffer.subarray(0, bytesRead ?? 0);
65+
bytesRead = await (fd as Deno.FsFile).read(buffer);
6766
// Node or Bun
6867
} else {
69-
// @ts-ignore cross-runtime
70-
const buffer = Buffer.alloc(length);
7168
const readResult = await fd.read(
7269
buffer,
7370
0,
7471
length,
7572
position,
7673
) as FileReadResult<Uint8Array>;
77-
const bytesRead = readResult.bytesRead as number;
78-
return new Uint8Array(buffer.buffer, 0, bytesRead);
74+
bytesRead = readResult.bytesRead;
7975
}
76+
return new Uint8Array(buffer.buffer, 0, bytesRead ?? 0);
8077
}
8178

8279
/**

test/kv.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,3 +871,24 @@ test("KV Options: throws on invalid disableIndex type", () => {
871871
"Invalid option: disableIndex must be a boolean",
872872
);
873873
});
874+
875+
test("KV: defer function - promise rejection is handled", async () => {
876+
const tempFilePrefix = await tempfile();
877+
const kvStore = new KV();
878+
879+
const deferredPromise = new Promise<void>((_, reject) => {
880+
setTimeout(() => {
881+
reject(new Error("Test error"));
882+
}, 500);
883+
});
884+
885+
const then = Date.now();
886+
887+
await kvStore.open(tempFilePrefix);
888+
889+
kvStore.defer(deferredPromise);
890+
891+
await kvStore.close();
892+
893+
assertEquals(Date.now() - then >= 500, true);
894+
});

0 commit comments

Comments
 (0)