@deno/kv
Version:
A Deno KV client library optimized for Node.js.
401 lines (400 loc) • 16.4 kB
JavaScript
;
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
Object.defineProperty(exports, "__esModule", { value: true });
exports.makeInMemoryService = void 0;
const assert_instance_of_js_1 = require("./deps/deno.land/std@0.208.0/assert/assert_instance_of.js");
const red_black_tree_js_1 = require("./deps/deno.land/std@0.208.0/data_structures/red_black_tree.js");
const bytes_js_1 = require("./bytes.js");
const kv_key_js_1 = require("./kv_key.js");
const kv_u64_js_1 = require("./kv_u64.js");
const kv_util_js_1 = require("./kv_util.js");
const unraw_watch_stream_js_1 = require("./unraw_watch_stream.js");
const check_js_1 = require("./check.js");
/**
* Return a new KvService that creates ephemeral in-memory KV instances.
*/
function makeInMemoryService(opts = {}) {
(0, check_js_1.checkRecord)("opts", opts);
(0, check_js_1.checkOptionalBoolean)("opts.debug", opts.debug);
(0, check_js_1.checkOptionalNumber)("opts.maxQueueAttempts", opts.maxQueueAttempts);
const { debug = false, maxQueueAttempts = 10 } = opts;
const instances = new Map();
return {
openKv: (url = "") => {
let kv = instances.get(url);
if (!kv) {
if (debug)
console.log(`makeInMemoryService: new kv(${url})`);
kv = new InMemoryKv(debug, maxQueueAttempts);
instances.set(url, kv);
}
return Promise.resolve(kv);
},
};
}
exports.makeInMemoryService = makeInMemoryService;
const keyRow = (keyBytes) => [keyBytes, undefined, "", undefined];
const copyValueIfNecessary = (v) => v instanceof kv_u64_js_1._KvU64 ? v : structuredClone(v);
class InMemoryKv extends kv_util_js_1.BaseKv {
constructor(debug, maxQueueAttempts) {
super({ debug });
Object.defineProperty(this, "rows", {
enumerable: true,
configurable: true,
writable: true,
value: new red_black_tree_js_1.RedBlackTree((a, b) => (0, bytes_js_1.compareBytes)(a[0], b[0]))
}); // keep sorted by keyBytes
Object.defineProperty(this, "queue", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
Object.defineProperty(this, "watches", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
Object.defineProperty(this, "expirer", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "queueWorker", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "maxQueueAttempts", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "version", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "nextQueueItemId", {
enumerable: true,
configurable: true,
writable: true,
value: 1
});
this.expirer = new kv_util_js_1.Expirer(debug, () => this.expire());
this.queueWorker = new kv_util_js_1.QueueWorker((queueHandler) => this.runWorker(queueHandler));
this.maxQueueAttempts = maxQueueAttempts;
}
get_(key, _consistency) {
return Promise.resolve(this.getOne(key));
}
getMany_(keys, _consistency) {
return Promise.resolve(keys.map((v) => this.getOne(v)));
}
async *listStream(outCursor, selector, { batchSize, consistency: _, cursor: cursorOpt, limit, reverse = false } = {}) {
let yielded = 0;
if (typeof limit === "number" && yielded >= limit)
return;
const cursor = typeof cursorOpt === "string"
? (0, kv_util_js_1.unpackCursor)(cursorOpt)
: undefined;
let lastYieldedKeyBytes = cursor?.lastYieldedKeyBytes;
let pass = 0;
const prefixBytes = "prefix" in selector
? (0, kv_key_js_1.packKey)(selector.prefix)
: undefined;
while (true) {
pass++;
let start;
let end;
if ("prefix" in selector) {
start = "start" in selector ? (0, kv_key_js_1.packKey)(selector.start) : prefixBytes;
end = "end" in selector
? (0, kv_key_js_1.packKey)(selector.end)
: new Uint8Array([...prefixBytes, 0xff]);
}
else {
start = (0, kv_key_js_1.packKey)(selector.start);
end = (0, kv_key_js_1.packKey)(selector.end);
}
if (reverse) {
end = lastYieldedKeyBytes ?? end;
}
else {
start = lastYieldedKeyBytes ?? start;
}
if (start === undefined || end === undefined)
throw new Error();
if (start.length === 0)
start = new Uint8Array([0]);
const batchLimit = Math.min(batchSize ?? 100, 500, limit ?? Number.MAX_SAFE_INTEGER) +
(lastYieldedKeyBytes ? 1 : 0);
const rows = this.listRows({ start, end, reverse, batchLimit });
let entries = 0;
for (const [keyBytes, value, versionstamp] of rows) {
if (entries++ === 0 &&
(lastYieldedKeyBytes && (0, bytes_js_1.equalBytes)(lastYieldedKeyBytes, keyBytes) ||
prefixBytes && (0, bytes_js_1.equalBytes)(prefixBytes, keyBytes)))
continue;
const key = (0, kv_key_js_1.unpackKey)(keyBytes);
lastYieldedKeyBytes = keyBytes;
outCursor.set((0, kv_util_js_1.packCursor)({ lastYieldedKeyBytes })); // cursor needs to be set before yield
yield { key, value: copyValueIfNecessary(value), versionstamp };
yielded++;
// console.log({ yielded, entries, limit });
if (typeof limit === "number" && yielded >= limit)
return;
}
if (entries < batchLimit)
return;
}
}
listenQueue_(handler) {
return this.queueWorker.listen(handler);
}
async commit(checks, mutations, enqueues, additionalWork) {
const { rows, queue, watches } = this;
await Promise.resolve();
for (const { key, versionstamp } of checks) {
if (!(versionstamp === null ||
typeof versionstamp === "string" && (0, kv_util_js_1.isValidVersionstamp)(versionstamp)))
throw new Error(`Bad 'versionstamp': ${versionstamp}`);
const existing = rows.find(keyRow((0, kv_key_js_1.packKey)(key)));
if (versionstamp === null && existing)
return { ok: false };
if (typeof versionstamp === "string" && (existing ?? [])[2] !== versionstamp)
return { ok: false };
}
let minExpires;
let minEnqueued;
const newVersionstamp = (0, kv_util_js_1.packVersionstamp)(++this.version);
for (const { value, opts = {} } of enqueues) {
const { delay = 0, keysIfUndelivered = [] } = opts;
const enqueued = Date.now();
const available = enqueued + delay;
const id = this.nextQueueItemId++;
queue.set(id, {
id,
value: copyValueIfNecessary(value),
enqueued,
available,
failures: 0,
keysIfUndelivered,
locked: false,
});
minEnqueued = Math.min(enqueued, minEnqueued ?? Number.MAX_SAFE_INTEGER);
}
const watchIds = new Set();
for (const mutation of mutations) {
const { key } = mutation;
const keyBytes = (0, kv_key_js_1.packKey)(key);
if (mutation.type === "set") {
const { value, expireIn } = mutation;
const expires = typeof expireIn === "number"
? Date.now() + Math.round(expireIn)
: undefined;
if (expires !== undefined) {
minExpires = Math.min(expires, minExpires ?? Number.MAX_SAFE_INTEGER);
}
rows.remove(keyRow(keyBytes));
rows.insert([
keyBytes,
copyValueIfNecessary(value),
newVersionstamp,
expires,
]);
}
else if (mutation.type === "delete") {
rows.remove(keyRow(keyBytes));
}
else if (mutation.type === "sum" || mutation.type === "min" ||
mutation.type === "max") {
const existing = rows.find(keyRow(keyBytes));
if (!existing) {
rows.insert([keyBytes, mutation.value, newVersionstamp, undefined]);
}
else {
const existingValue = existing[1];
(0, assert_instance_of_js_1.assertInstanceOf)(existingValue, kv_u64_js_1._KvU64, `Can only '${mutation.type}' on KvU64`);
const result = mutation.type === "min"
? existingValue.min(mutation.value)
: mutation.type === "max"
? existingValue.max(mutation.value)
: existingValue.sum(mutation.value);
rows.remove(keyRow(keyBytes));
rows.insert([keyBytes, result, newVersionstamp, undefined]);
}
}
else {
throw new Error(`commit(${JSON.stringify({ checks, mutations, enqueues }, kv_util_js_1.replacer)}) not implemented`);
}
for (const [watchId, watch] of watches) {
if (watchIds.has(watchId))
continue;
if (watch.keysAsBytes.some((v) => (0, bytes_js_1.equalBytes)(v, keyBytes))) {
watchIds.add(watchId);
}
}
}
if (additionalWork)
additionalWork();
if (minExpires !== undefined)
this.expirer.rescheduleExpirer(minExpires);
if (minEnqueued !== undefined)
this.queueWorker.rescheduleWorker();
for (const watchId of watchIds) {
const watch = watches.get(watchId);
if (watch) {
watch.onEntries(watch.keysAsBytes.map((v) => this.getOne((0, kv_key_js_1.unpackKey)(v))));
}
}
return { ok: true, versionstamp: newVersionstamp };
}
watch_(keys, raw) {
const { watches, debug } = this;
const keysAsBytes = keys.map(kv_key_js_1.packKey);
const watchId = [...watches.keys()].reduce((a, b) => Math.max(a, b), 0) + 1;
let onCancel;
const rawStream = new ReadableStream({
start(controller) {
if (debug)
console.log(`watch: ${watchId} start`);
const tryClose = () => {
try {
controller.close();
}
catch { /* noop */ }
};
onCancel = tryClose;
watches.set(watchId, {
keysAsBytes,
onEntries: (entries) => controller.enqueue(entries),
onFinalize: tryClose,
});
},
cancel() {
watches.delete(watchId);
if (debug)
console.log(`watch: ${watchId} cancel`);
},
});
return raw ? rawStream : (0, unraw_watch_stream_js_1.makeUnrawWatchStream)(rawStream, onCancel);
}
close_() {
this.expirer.finalize();
this.queueWorker.finalize();
this.watches.forEach((v) => v.onFinalize());
}
//
getOne(key) {
const row = this.rows.find(keyRow((0, kv_key_js_1.packKey)(key)));
return row
? { key, value: copyValueIfNecessary(row[1]), versionstamp: row[2] }
: { key, value: null, versionstamp: null };
}
listRows({ start, end, reverse, batchLimit }) {
const { rows } = this;
const rt = [];
for (const row of reverse ? rows.rnlValues() : rows.lnrValues()) { // TODO use rbt to find start node faster
const keyBytes = row[0];
if (rt.length >= batchLimit)
break;
if (reverse) {
if ((0, bytes_js_1.compareBytes)(keyBytes, start) < 0)
break;
if ((0, bytes_js_1.compareBytes)(keyBytes, end) < 0)
rt.push(row);
}
else {
if ((0, bytes_js_1.compareBytes)(keyBytes, end) >= 0)
break;
if ((0, bytes_js_1.compareBytes)(keyBytes, start) >= 0)
rt.push(row);
}
}
return rt;
}
expire() {
const { rows } = this;
const now = Date.now();
const remove = [];
let minExpires;
for (const row of rows) {
const expires = row[3];
if (expires !== undefined && expires <= now) {
remove.push(row);
}
minExpires = expires === undefined
? minExpires
: Math.min(expires, minExpires ?? Number.MAX_SAFE_INTEGER);
}
remove.forEach((v) => rows.remove(v));
return minExpires;
}
async runWorker(queueHandler) {
const { debug, queue } = this;
if (!queueHandler) {
if (debug)
console.log(`runWorker: no queueHandler`);
return;
}
const time = Date.now();
const candidateIds = [...queue.values()].filter((v) => v.available <= time && !v.locked).map((v) => v.id);
if (candidateIds.length === 0) {
const nextAvailableItem = [...queue.values()].filter((v) => !v.locked).sort((a, b) => a.available - b.available)[0];
if (nextAvailableItem) {
const nextAvailableIn = nextAvailableItem.available - Date.now();
if (debug) {
console.log(`runWorker: no work (nextAvailableIn=${nextAvailableIn}ms)`);
}
this.queueWorker.rescheduleWorker(nextAvailableIn);
}
else {
if (debug) {
console.log("runWorker: no work");
}
}
return;
}
const id = Math.min(...candidateIds);
const queueItem = queue.get(id);
queueItem.locked = true;
const { value, failures, keysIfUndelivered } = queueItem;
const deleteQueueItem = () => queue.delete(id);
try {
if (debug)
console.log(`runWorker: dispatching ${id}: ${value}`);
await Promise.resolve(queueHandler(value));
if (debug)
console.log(`runWorker: ${id} succeeded, clearing`);
deleteQueueItem();
}
catch (e) {
const totalFailures = failures + 1;
if (debug) {
console.log(`runWorker: ${id} failed (totalFailures=${totalFailures}): ${e.stack || e}`);
}
if (totalFailures >= this.maxQueueAttempts) {
let atomic = this.atomic(deleteQueueItem);
for (const key of keysIfUndelivered)
atomic = atomic.set(key, value);
await atomic.commit();
if (debug) {
console.log(`runWorker: give up on ${id}, keys=${keysIfUndelivered.length}`);
}
}
else {
const available = Date.now() + 1000 * totalFailures;
queueItem.failures = totalFailures;
queueItem.available = available;
queueItem.locked = false;
}
}
this.queueWorker.rescheduleWorker();
}
}