UNPKG

@deno/kv

Version:

A Deno KV client library optimized for Node.js.

401 lines (400 loc) 16.4 kB
"use strict"; // 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(); } }