From b0bb77637d797f9442c5aa4a484c45affdf697ab Mon Sep 17 00:00:00 2001 From: Keijo Kapp Date: Fri, 10 May 2024 12:54:09 +0300 Subject: [PATCH] Added support for including keyspace ends in the ranges --- lib/database.ts | 23 ++- lib/directory.ts | 2 +- lib/subspace.ts | 93 ++++++++++-- lib/transaction.ts | 310 ++++++++++++++++++++++----------------- scripts/bindingtester.ts | 2 +- 5 files changed, 276 insertions(+), 154 deletions(-) diff --git a/lib/database.ts b/lib/database.ts index 41afec6..f24b24f 100644 --- a/lib/database.ts +++ b/lib/database.ts @@ -22,6 +22,15 @@ export default class Database(prefix, keyXf, valueXf) } + /** + * Switch to a new mode of handling ranges. + * + * @see Subspace.noDefaultPrefix + */ + noDefaultPrefix() { + return new Database(this._db, this.subspace.noDefaultPrefix()) + } + setNativeOptions(opts: DatabaseOptions) { eachOption(databaseOptionData, opts, (code, val) => this._db.setOption(code, val)) } @@ -108,7 +117,7 @@ export default class Database tn.clear(key)) } - clearRange(start: KeyIn, end?: KeyIn) { + clearRange(start?: KeyIn, end?: KeyIn) { return this.doOneshot(tn => tn.clearRange(start, end)) } @@ -144,21 +153,21 @@ export default class Database, - end?: KeyIn | KeySelector, + start?: KeyIn | KeySelector, + end?: KeyIn | KeySelector, opts?: RangeOptions) { - return this.doTransaction(async tn => tn.snapshot().getRangeAll(start, end, opts)) + return this.doTransaction(tn => tn.snapshot().getRangeAll(start, end, opts)) } getRangeAllStartsWith(prefix: KeyIn | KeySelector, opts?: RangeOptions) { - return this.getRangeAll(prefix, undefined, opts) + return this.doTransaction(tn => tn.snapshot().getRangeAllStartsWith(prefix, opts)) } - getEstimatedRangeSizeBytes(start: KeyIn, end: KeyIn): Promise { + getEstimatedRangeSizeBytes(start?: KeyIn, end?: KeyIn): Promise { return this.doTransaction(tn => tn.getEstimatedRangeSizeBytes(start, end)) } - getRangeSplitPoints(start: KeyIn, end: KeyIn, chunkSize: number): Promise { + getRangeSplitPoints(start: KeyIn | undefined, end: KeyIn | undefined, chunkSize: number): Promise { return this.doTransaction(tn => tn.getRangeSplitPoints(start, end, chunkSize)) } diff --git a/lib/directory.ts b/lib/directory.ts index 92d7465..0f4bd79 100644 --- a/lib/directory.ts +++ b/lib/directory.ts @@ -917,7 +917,7 @@ export class DirectoryLayer { private async* _subdirNamesAndNodes(txn: TxnAny, node: NodeSubspace) { // TODO: This could work using async iterators to improve performance of searches on very large directories. - for await (const [key, prefix] of txn.at(node).getRange(SUBDIRS_KEY)) { + for await (const [key, prefix] of txn.at(node).getRangeStartsWith(SUBDIRS_KEY)) { yield [key[1], this._nodeWithPrefix(prefix)] as [Buffer, NodeSubspace] } } diff --git a/lib/subspace.ts b/lib/subspace.ts index eb52e0c..4a1d185 100644 --- a/lib/subspace.ts +++ b/lib/subspace.ts @@ -5,7 +5,10 @@ import { Transformer, prefixTransformer, defaultTransformer, defaultGetRange } from "./transformer" import { NativeValue } from "./native" -import { asBuf, concat2, startsWith } from "./util" +import { + asBuf, concat2, startsWith, strInc +} from "./util" +import { UnboundStamp } from './versionstamp.js' const EMPTY_BUF = Buffer.alloc(0) @@ -26,7 +29,14 @@ export default class Subspace // This is cached from _prefix + keyXf. - constructor(rawPrefix: string | Buffer | null, keyXf?: Transformer, valueXf?: Transformer) { + _noDefaultPrefix: boolean + + constructor( + rawPrefix: string | Buffer | null, + keyXf?: Transformer, + valueXf?: Transformer, + noDefaultPrefix: boolean = false + ) { this.prefix = rawPrefix != null ? Buffer.from(rawPrefix) : EMPTY_BUF // Ugh typing this is a mess. Usually this will be fine since if you say new @@ -35,6 +45,24 @@ export default class Subspace) this._bakedKeyXf = rawPrefix ? prefixTransformer(rawPrefix, this.keyXf) : this.keyXf + + this._noDefaultPrefix = noDefaultPrefix + } + + /** + * Switch to a new mode of handling ranges. By default, the range operations (`getRange` family + * and `clearRange`) treat calls with missing end key as operations on prefix ranges. That means + * that a call like `tn.at('a').getRange('x')` acts on prefix `ax`, ie key range `[ax, ay)`. In + * the new mode, the missing end key defaults to a subspace end (inclusive), ie that call would + * act on a range `[ax, b)`. This enabled specifying key ranges not possible before. + * + * To specifiy range as a prefix, use `StartsWith` version of those methods (eg + * `getRangeAllStartsWith`). + * + * @see Subspace.packRange + */ + noDefaultPrefix() { + return new Subspace(this.prefix, this.keyXf, this.valueXf, true) } // All these template parameters make me question my life choices, but this is @@ -48,21 +76,21 @@ export default class Subspace = this.keyXf, valueXf: Transformer = this.valueXf) { const _prefix = prefix == null ? null : this.keyXf.pack(prefix) - return new Subspace(concatPrefix(this.prefix, _prefix), keyXf, valueXf) + return new Subspace(concatPrefix(this.prefix, _prefix), keyXf, valueXf, this._noDefaultPrefix) } /** At a child prefix thats specified without reference to the key transformer */ atRaw(prefix: Buffer) { - return new Subspace(concatPrefix(this.prefix, prefix), this.keyXf, this.valueXf) + return new Subspace(concatPrefix(this.prefix, prefix), this.keyXf, this.valueXf, this._noDefaultPrefix) } withKeyEncoding(keyXf: Transformer): Subspace { - return new Subspace(this.prefix, keyXf, this.valueXf) + return new Subspace(this.prefix, keyXf, this.valueXf, this._noDefaultPrefix) } withValueEncoding(valXf: Transformer): Subspace { - return new Subspace(this.prefix, this.keyXf, valXf) + return new Subspace(this.prefix, this.keyXf, valXf, this._noDefaultPrefix) } // GetSubspace implementation @@ -75,17 +103,62 @@ export default class Subspace - // Copied out from scope for convenience, since these are so heavily used. Not - // sure if this is a good idea. - private _keyEncoding: Transformer - private _valueEncoding: Transformer - private _ctx: TxnCtx /** @@ -144,8 +139,6 @@ export default class Transaction tn.setOption(code, val)) @@ -266,20 +259,20 @@ export default class Transaction): void get(key: KeyIn, cb?: Callback) { - const keyBuf = this._keyEncoding.pack(key) + const keyBuf = this.subspace.packKey(key) return cb ? this._tn.get(keyBuf, this.isSnapshot, (err, val) => { - cb(err, val == null ? undefined : this._valueEncoding.unpack(val)) + cb(err, val == null ? undefined : this.subspace.unpackValue(val)) }) : this._tn.get(keyBuf, this.isSnapshot) - .then(val => val == null ? undefined : this._valueEncoding.unpack(val)) + .then(val => val == null ? undefined : this.subspace.unpackValue(val)) } /** Checks if the key exists in the database. This is just a shorthand for * tn.get() !== undefined. */ exists(key: KeyIn): Promise { - const keyBuf = this._keyEncoding.pack(key) + const keyBuf = this.subspace.packKey(key) return this._tn.get(keyBuf, this.isSnapshot).then(val => val != undefined) } @@ -302,22 +295,22 @@ export default class Transaction | KeyIn): Promise { const sel = keySelector.from(_sel) - return this._tn.getKey(this._keyEncoding.pack(sel.key), sel.orEqual, sel.offset, this.isSnapshot) + return this._tn.getKey(this.subspace.packKey(sel.key), sel.orEqual, sel.offset, this.isSnapshot) .then(key => ( (key.length === 0 || !this.subspace.contains(key)) ? undefined - : this._keyEncoding.unpack(key) + : this.subspace.unpackKey(key) )) } /** Set the specified key/value pair in the database */ set(key: KeyIn, val: ValIn) { - this._tn.set(this._keyEncoding.pack(key), this._valueEncoding.pack(val)) + this._tn.set(this.subspace.packKey(key), this.subspace.packValue(val)) } /** Remove the value for the specified key */ clear(key: KeyIn) { - const pack = this._keyEncoding.pack(key) + const pack = this.subspace.packKey(key) this._tn.clear(pack) } @@ -330,8 +323,8 @@ export default class Transaction> { return this.getRangeNative( - keySelector.toNative(start, this._keyEncoding), - end != null ? keySelector.toNative(end, this._keyEncoding) : null, + keySelector(this.subspace.packKey(start.key), start.orEqual, start.offset), + end != null ? keySelector(this.subspace.packKey(end.key), end.orEqual, end.offset) : null, limit, targetBytes, streamingMode, iter, reverse) .then(r => ({more: r.more, results: this._encodeRangeResult(r.results)})) } - getEstimatedRangeSizeBytes(start: KeyIn, end: KeyIn): Promise { - return this._tn.getEstimatedRangeSizeBytes( - this._keyEncoding.pack(start), - this._keyEncoding.pack(end) - ) + getEstimatedRangeSizeBytes(start?: KeyIn, end?: KeyIn): Promise { + const range = this.subspace.packRange(start, end, true) + + return this._tn.getEstimatedRangeSizeBytes(range.begin, range.end) } - getRangeSplitPoints(start: KeyIn, end: KeyIn, chunkSize: number): Promise { - return this._tn.getRangeSplitPoints( - this._keyEncoding.pack(start), - this._keyEncoding.pack(end), - chunkSize - ).then(results => ( - results.map(r => this._keyEncoding.unpack(r)) + getRangeSplitPoints(start: KeyIn | undefined, end: KeyIn | undefined, chunkSize: number): Promise { + const range = this.subspace.packRange(start, end, true) + + return this._tn.getRangeSplitPoints(range.begin, range.end, chunkSize).then(results => ( + results.map(r => this.subspace.unpackKey(r)) )) } + async *getRangeBatchNative( + start: KeySelector, + end: KeySelector, + { + limit = 0, + reverse = false, + streamingMode = StreamingMode.Iterator + }: RangeOptions = {} + ) { + let iter = 0 + + while (1) { + const { results, more } = await this.getRangeNative( + start, + end, + limit, + 0, + streamingMode, + ++iter, + reverse + ) + + if (results.length) { + if (!reverse) start = keySelector.firstGreaterThan(results[results.length-1][0]) + else end = keySelector.firstGreaterOrEqual(results[results.length-1][0]) + } + + // This destructively consumes results. + yield this._encodeRangeResult(results) + if (!more) break + + if (limit) { + limit -= results.length + if (limit <= 0) break + } + } + } + /** * This method is functionally the same as *getRange*, but values are returned * in the batches they're delivered in from the database. This method is @@ -393,54 +421,37 @@ export default class Transaction, // Consider also supporting string / buffers for these. - _end?: KeyIn | KeySelector, // If not specified, start is used as a prefix. + getRangeBatch( + start?: KeyIn | KeySelector, + end?: KeyIn | KeySelector, opts: RangeOptions = {}) { - - // This is a bit of a dog's breakfast. We're trying to handle a lot of different cases here: - // - The start and end parameters can be specified as keys or as selectors - // - The end parameter can be missing / null, and if it is we want to "do the right thing" here - // - Which normally means searching between [start, strInc(start)] - // - But with tuple encoding this means between [start + '\x00', start + '\xff'] - - let start: KeySelector, end: KeySelector - const startSelEnc = keySelector.from(_start) - - if (_end == null) { - const range = this.subspace.packRange(startSelEnc.key) - start = keySelector(range.begin, startSelEnc.orEqual, startSelEnc.offset) - end = keySelector.firstGreaterOrEqual(range.end) - } else { - start = keySelector.toNative(startSelEnc, this._keyEncoding) - end = keySelector.toNative(keySelector.from(_end), this._keyEncoding) - } - - let limit = opts.limit || 0 - const streamingMode = opts.streamingMode == null ? StreamingMode.Iterator : opts.streamingMode - - let iter = 0 - while (1) { - const {results, more} = await this.getRangeNative(start, end, - limit, 0, streamingMode, ++iter, opts.reverse || false) - - if (results.length) { - if (!opts.reverse) start = keySelector.firstGreaterThan(results[results.length-1][0]) - else end = keySelector.firstGreaterOrEqual(results[results.length-1][0]) - } - - // This destructively consumes results. - yield this._encodeRangeResult(results) - if (!more) break - - if (limit) { - limit -= results.length - if (limit <= 0) break - } - } + const startSelector = keySelector.from(start) + const endSelector = keySelector.from(end) + const range = this.subspace.packRange(startSelector.key, endSelector.key) + + return this.getRangeBatchNative( + keySelector(range.begin, startSelector.orEqual, startSelector.offset), + keySelector(range.end, startSelector.orEqual, startSelector.offset), + opts + ) } - // TODO: getRangeBatchStartsWith + /** + * This method is similar to *getRangeBatch*, but performs a query + * on a key range specified by `prefix` instead of start and end. + * + * @see Transaction.getRangeBatch + */ + getRangeBatchStartsWith(prefix: KeyIn | KeySelector, opts?: RangeOptions) { + const prefixSelector = keySelector.from(prefix) + const range = this.subspace.packRangeStartsWith(prefixSelector.key) + + return this.getRangeBatchNative( + keySelector(range.begin, prefixSelector.orEqual, prefixSelector.offset), + keySelector.firstGreaterOrEqual(range.end), + opts + ) + } /** * Get all key value pairs within the specified range. This method returns an @@ -483,15 +494,27 @@ export default class Transaction, // Consider also supporting string / buffers for these. - end?: KeyIn | KeySelector, + start?: KeyIn | KeySelector, + end?: KeyIn | KeySelector, opts?: RangeOptions) { for await (const batch of this.getRangeBatch(start, end, opts)) { for (const pair of batch) yield pair } } - // TODO: getRangeStartsWtih + /** + * This method is similar to *getRange*, but performs a query + * on a key range specified by `prefix` instead of start and end. + * + * @see Transaction.getRange + */ + async *getRangeStartsWith( + prefix: KeyIn | KeySelector, + opts: RangeOptions = {}) { + for await (const batch of this.getRangeBatchStartsWith(prefix, opts)) { + for (const pair of batch) yield pair + } + } /** * Same as getRange, but prefetches and returns all values in an array rather @@ -503,71 +526,97 @@ export default class Transaction, - end?: KeyIn | KeySelector, // if undefined, start is used as a prefix. - opts: RangeOptions = {}) { - const childOpts: RangeOptions = {...opts} - if (childOpts.streamingMode == null) childOpts.streamingMode = StreamingMode.WantAll + start?: KeyIn | KeySelector, + end?: KeyIn | KeySelector, + opts?: RangeOptions) { + const childOpts: RangeOptions = opts?.streamingMode == null + ? { ...opts, streamingMode: StreamingMode.WantAll } + : opts const result: [KeyOut, ValOut][] = [] + for await (const batch of this.getRangeBatch(start, end, childOpts)) { - result.push.apply(result, batch) + result.push(...batch) } + return result } - getRangeAllStartsWith(prefix: KeyIn | KeySelector, opts?: RangeOptions) { - return this.getRangeAll(prefix, undefined, opts) + /** + * This method is similar to *getRangeAll*, but performs a query + * on a key range specified by `prefix` instead of start and end. + * + * @see Transaction.getRangeAll + */ + async getRangeAllStartsWith(prefix: KeyIn | KeySelector, opts?: RangeOptions) { + const childOpts: RangeOptions = opts?.streamingMode == null + ? { ...opts, streamingMode: StreamingMode.WantAll } + : opts + + const result: [KeyOut, ValOut][] = [] + + for await (const batch of this.getRangeBatchStartsWith(prefix, childOpts)) { + result.push(...batch) + } + + return result } /** * Removes all key value pairs from the database in between start and end. * - * End parameter is optional. If not specified, this removes all keys with - * *start* as a prefix. + * @param start Start of the range. If unspecified, the start of the keyspace is assumed. + * @param end End of the range. If unspecified, the inclusive end of the keyspace is assumed. */ - clearRange(_start: KeyIn, _end?: KeyIn) { - let start: NativeValue, end: NativeValue - // const _start = this._keyEncoding.pack(start) - - if (_end == null) { - const range = this.subspace.packRange(_start) - start = range.begin - end = range.end - } else { - start = this._keyEncoding.pack(_start) - end = this._keyEncoding.pack(_end) - } - // const _end = end == null ? strInc(_start) : this._keyEncoding.pack(end) - this._tn.clearRange(start, end) + clearRange(start?: KeyIn, end?: KeyIn) { + const range = this.subspace.packRange(start, end) + + this._tn.clearRange(range.begin, range.end) } - /** An alias for unary clearRange */ + /** + * This method is similar to *clearRange*, but performs the operation + * on a key range specified by `prefix` instead of start and end. + * + * @see Transaction.clearRange + */ clearRangeStartsWith(prefix: KeyIn) { - this.clearRange(prefix) + const range = this.subspace.packRangeStartsWith(prefix) + + this._tn.clearRange(range.begin, range.end) } watch(key: KeyIn, opts?: WatchOptions): Watch { const throwAll = opts && opts.throwAllErrors - const watch = this._tn.watch(this._keyEncoding.pack(key), !throwAll) + const watch = this._tn.watch(this.subspace.packKey(key), !throwAll) // Suppress the global unhandledRejection handler when a watch errors watch.promise.catch(doNothing) return watch } - addReadConflictRange(start: KeyIn, end: KeyIn) { - this._tn.addReadConflictRange(this._keyEncoding.pack(start), this._keyEncoding.pack(end)) + addReadConflictRange(start?: KeyIn, end?: KeyIn) { + const range = this.subspace.packRange(start, end, true) + this._tn.addReadConflictRange(range.begin, range.end) + } + addReadConflictRangeStartsWith(prefix: KeyIn) { + const range = this.subspace.packRangeStartsWith(prefix) + this._tn.addReadConflictRange(range.begin, range.end) } addReadConflictKey(key: KeyIn) { - const keyBuf = this._keyEncoding.pack(key) + const keyBuf = this.subspace.packKey(key) this._tn.addReadConflictRange(keyBuf, strNext(keyBuf)) } - addWriteConflictRange(start: KeyIn, end: KeyIn) { - this._tn.addWriteConflictRange(this._keyEncoding.pack(start), this._keyEncoding.pack(end)) + addWriteConflictRange(start?: KeyIn, end?: KeyIn) { + const range = this.subspace.packRange(start, end, true) + this._tn.addWriteConflictRange(range.begin, range.end) + } + addWriteConflictRangeStartsWith(prefix: KeyIn) { + const range = this.subspace.packRangeStartsWith(prefix) + this._tn.addWriteConflictRange(range.begin, range.end) } addWriteConflictKey(key: KeyIn) { - const keyBuf = this._keyEncoding.pack(key) + const keyBuf = this.subspace.packKey(key) this._tn.addWriteConflictRange(keyBuf, strNext(keyBuf)) } @@ -608,7 +657,7 @@ export default class Transaction(item: T, transformer: Transformer, code: Buffer | null) { @@ -698,19 +747,15 @@ export default class Transaction { - const val = await this._tn.get(this._keyEncoding.pack(key), this.isSnapshot) + const val = await this._tn.get(this.subspace.packKey(key), this.isSnapshot) if (val == null) { - return null; + return null } return val.length <= 10 @@ -770,7 +810,7 @@ export default class Transaction { }, async DIRECTORY_RANGE() { const keyTuple = await popNValues() - const {begin, end} = getCurrentSubspace().withKeyEncoding(tupleStrict).packRange(keyTuple) + const {begin, end} = getCurrentSubspace().withKeyEncoding(tupleStrict).packRangeStartsWith(keyTuple) pushValue(begin); pushValue(end) }, async DIRECTORY_CONTAINS() {