M9: benchmarks, chunked sorted map and JSON clone for the in-memory driver
Profiling showed document updates dominated by O(n) sorted-array splices; the chunked map makes inserts O(log n + chunk). Baselines recorded in DESIGN.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
+20
-31
@@ -1,11 +1,11 @@
|
||||
import { BaseKVDriver, type BatchOp, type ScanOptions } from './driver.js'
|
||||
import { SortedArrayMap, type SortedMap } from './sorted.js'
|
||||
import { ChunkedSortedMap, type SortedMap } from './sorted.js'
|
||||
|
||||
export interface InMemoryKVDriverOptions {
|
||||
/**
|
||||
* Copy values on write and read so callers can't mutate stored state through a shared
|
||||
* reference (matching serialising drivers). Default `true`. Disable for speed when values
|
||||
* are treated as immutable.
|
||||
* Deep-copy values on write and read so callers can't mutate stored state through a shared
|
||||
* reference (matching serialising drivers). Values must be JSON-compatible. Default `true`.
|
||||
* Disable for speed when values are treated as immutable.
|
||||
*/
|
||||
clone?: boolean
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export interface InMemoryKVDriverOptions {
|
||||
* `setIfAbsent`, atomic `batch` and `deleteRange`.
|
||||
*/
|
||||
export class InMemoryKVDriver extends BaseKVDriver {
|
||||
readonly #data: SortedMap<unknown> = new SortedArrayMap()
|
||||
readonly #data: SortedMap<unknown> = new ChunkedSortedMap()
|
||||
readonly #clone: (v: unknown) => unknown
|
||||
|
||||
constructor(opts: InMemoryKVDriverOptions = {}) {
|
||||
@@ -59,37 +59,17 @@ export class InMemoryKVDriver extends BaseKVDriver {
|
||||
}
|
||||
|
||||
override async deleteRange(gte: string, lt: string): Promise<void> {
|
||||
const data = this.#data
|
||||
const start = data.lowerBound(gte)
|
||||
const doomed: string[] = []
|
||||
for (let i = start; ; i++) {
|
||||
const key = data.keyAt(i)
|
||||
if (key === undefined || key >= lt) break
|
||||
doomed.push(key)
|
||||
}
|
||||
for (const key of doomed) data.delete(key)
|
||||
const doomed = [...this.#data.keys({ gte, lt })]
|
||||
for (const key of doomed) this.#data.delete(key)
|
||||
}
|
||||
|
||||
async *scan(opts: ScanOptions): AsyncGenerator<[string, unknown]> {
|
||||
const data = this.#data
|
||||
const { gte, lt, reverse = false, values = false } = opts
|
||||
let remaining = opts.limit ?? Infinity
|
||||
if (remaining <= 0) return
|
||||
// `last` is the last key yielded; if the map changes while we're suspended, re-seek from it.
|
||||
let last: string | undefined
|
||||
let version = data.version
|
||||
let i = reverse ? (lt === undefined ? data.size : data.lowerBound(lt)) - 1 : gte === undefined ? 0 : data.lowerBound(gte)
|
||||
for (;;) {
|
||||
if (data.version !== version) {
|
||||
version = data.version
|
||||
if (last !== undefined) i = reverse ? data.lowerBound(last) - 1 : data.lowerBound(last, true)
|
||||
}
|
||||
const key = data.keyAt(i)
|
||||
if (key === undefined || i < 0) return
|
||||
if (reverse ? gte !== undefined && key < gte : lt !== undefined && key >= lt) return
|
||||
last = key
|
||||
i += reverse ? -1 : 1
|
||||
yield [key, values ? this.#clone(data.get(key)) : undefined]
|
||||
// The key iterator re-seeks by itself if the map changes while we're suspended.
|
||||
for (const key of this.#data.keys({ gte, lt, reverse })) {
|
||||
yield [key, values ? this.#clone(this.#data.get(key)) : undefined]
|
||||
if (--remaining <= 0) return
|
||||
}
|
||||
}
|
||||
@@ -99,6 +79,15 @@ function identity(v: unknown): unknown {
|
||||
return v
|
||||
}
|
||||
|
||||
/** Deep copy of a JSON-compatible value (much faster than `structuredClone`). */
|
||||
function cloneValue(v: unknown): unknown {
|
||||
return v === null || typeof v !== 'object' ? v : structuredClone(v)
|
||||
if (v === null || typeof v !== 'object') return v
|
||||
if (Array.isArray(v)) {
|
||||
const out = new Array<unknown>(v.length)
|
||||
for (let i = 0; i < v.length; i++) out[i] = cloneValue(v[i])
|
||||
return out
|
||||
}
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const k in v) out[k] = cloneValue((v as Record<string, unknown>)[k])
|
||||
return out
|
||||
}
|
||||
|
||||
+115
-34
@@ -1,33 +1,37 @@
|
||||
/**
|
||||
* Ordered string-keyed map used by the in-memory driver. Kept behind an interface so the
|
||||
* sorted-array implementation can be swapped for a B-tree without touching the driver.
|
||||
* implementation can be swapped (e.g. for a B-tree) without touching the driver.
|
||||
*/
|
||||
export interface SortedMap<V> {
|
||||
readonly size: number
|
||||
/** Incremented on every structural change (insert or delete), for iterator revalidation. */
|
||||
readonly version: number
|
||||
get(key: string): V | undefined
|
||||
has(key: string): boolean
|
||||
set(key: string, value: V): void
|
||||
/** Inserts many entries; may be faster than repeated `set`. */
|
||||
setMany(entries: Iterable<[string, V]>): void
|
||||
delete(key: string): boolean
|
||||
/** Index of the first key `>= key` (or `> key` when `exclusive`). */
|
||||
lowerBound(key: string, exclusive?: boolean): number
|
||||
keyAt(index: number): string | undefined
|
||||
/**
|
||||
* Keys in `[gte, lt)` in order (descending when `reverse`). The iterator stays valid across
|
||||
* mutations between steps: it resumes after (before) the last key it returned.
|
||||
*/
|
||||
keys(range: { gte?: string; lt?: string; reverse?: boolean }): IterableIterator<string>
|
||||
}
|
||||
|
||||
export class SortedArrayMap<V> implements SortedMap<V> {
|
||||
#keys: string[] = []
|
||||
#values = new Map<string, V>()
|
||||
|
||||
/**
|
||||
* Sorted chunks of at most `maxChunk` keys (split in half on overflow) plus a `Map` for values.
|
||||
* Inserts and deletes cost O(log n + maxChunk); iteration is O(1) per step while the map
|
||||
* is unchanged and re-seeks in O(log n) after a mutation.
|
||||
*/
|
||||
export class ChunkedSortedMap<V> implements SortedMap<V> {
|
||||
constructor(readonly maxChunk = 1024) {}
|
||||
|
||||
#chunks: string[][] = [[]]
|
||||
readonly #values = new Map<string, V>()
|
||||
#version = 0
|
||||
|
||||
get size(): number {
|
||||
return this.#keys.length
|
||||
}
|
||||
|
||||
get version(): number {
|
||||
return this.#version
|
||||
return this.#values.size
|
||||
}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
@@ -39,10 +43,7 @@ export class SortedArrayMap<V> implements SortedMap<V> {
|
||||
}
|
||||
|
||||
set(key: string, value: V): void {
|
||||
if (!this.#values.has(key)) {
|
||||
this.#keys.splice(this.lowerBound(key), 0, key)
|
||||
this.#version++
|
||||
}
|
||||
if (!this.#values.has(key)) this.#insert(key)
|
||||
this.#values.set(key, value)
|
||||
}
|
||||
|
||||
@@ -53,14 +54,13 @@ export class SortedArrayMap<V> implements SortedMap<V> {
|
||||
this.#values.set(key, value)
|
||||
}
|
||||
if (fresh.length === 0) return
|
||||
this.#version++
|
||||
if (fresh.length < 32) {
|
||||
for (const key of fresh) this.#keys.splice(this.lowerBound(key), 0, key)
|
||||
if (fresh.length < 64 || fresh.length * 8 < this.#values.size) {
|
||||
for (const key of fresh) this.#insert(key)
|
||||
return
|
||||
}
|
||||
// Bulk path: sort the new keys and merge, O(n + k log k) instead of O(n·k).
|
||||
// Bulk path: merge and re-chunk, O(n + k log k).
|
||||
fresh.sort(cmp)
|
||||
const old = this.#keys
|
||||
const old = this.#chunks.flat()
|
||||
const merged = new Array<string>(old.length + fresh.length)
|
||||
let i = 0
|
||||
let j = 0
|
||||
@@ -68,32 +68,113 @@ export class SortedArrayMap<V> implements SortedMap<V> {
|
||||
while (i < old.length && j < fresh.length) merged[k++] = old[i]! < fresh[j]! ? old[i++]! : fresh[j++]!
|
||||
while (i < old.length) merged[k++] = old[i++]!
|
||||
while (j < fresh.length) merged[k++] = fresh[j++]!
|
||||
this.#keys = merged
|
||||
const half = Math.max(1, this.maxChunk >>> 1)
|
||||
this.#chunks = []
|
||||
for (let s = 0; s < merged.length; s += half) this.#chunks.push(merged.slice(s, s + half))
|
||||
this.#version++
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
if (!this.#values.delete(key)) return false
|
||||
this.#keys.splice(this.lowerBound(key), 1)
|
||||
const c = this.#chunkFor(key)
|
||||
const chunk = this.#chunks[c]!
|
||||
chunk.splice(lowerBound(chunk, key), 1)
|
||||
if (chunk.length === 0 && this.#chunks.length > 1) this.#chunks.splice(c, 1)
|
||||
this.#version++
|
||||
return true
|
||||
}
|
||||
|
||||
lowerBound(key: string, exclusive = false): number {
|
||||
const keys = this.#keys
|
||||
*keys(range: { gte?: string; lt?: string; reverse?: boolean }): IterableIterator<string> {
|
||||
const { gte, lt, reverse = false } = range
|
||||
let version = this.#version
|
||||
let c: number
|
||||
let i: number
|
||||
// Position of the first candidate.
|
||||
if (reverse) {
|
||||
;[c, i] = lt === undefined ? this.#end() : this.#seek(lt, false)
|
||||
;[c, i] = this.#prev(c, i)
|
||||
} else {
|
||||
;[c, i] = gte === undefined ? [0, 0] : this.#seek(gte, false)
|
||||
;[c, i] = this.#normalize(c, i)
|
||||
}
|
||||
for (;;) {
|
||||
const key = this.#chunks[c]?.[i]
|
||||
if (key === undefined || c < 0) return
|
||||
if (reverse ? gte !== undefined && key < gte : lt !== undefined && key >= lt) return
|
||||
yield key
|
||||
if (this.#version !== version) {
|
||||
version = this.#version
|
||||
;[c, i] = this.#seek(key, !reverse)
|
||||
if (reverse) [c, i] = this.#prev(c, i)
|
||||
else [c, i] = this.#normalize(c, i)
|
||||
} else if (reverse) [c, i] = this.#prev(c, i)
|
||||
else [c, i] = this.#normalize(c, i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
#insert(key: string): void {
|
||||
const c = this.#chunkFor(key)
|
||||
const chunk = this.#chunks[c]!
|
||||
chunk.splice(lowerBound(chunk, key), 0, key)
|
||||
if (chunk.length > this.maxChunk) this.#chunks.splice(c + 1, 0, chunk.splice(this.maxChunk >>> 1))
|
||||
this.#version++
|
||||
}
|
||||
|
||||
/** Index of the last chunk whose first key is <= key (0 if none). */
|
||||
#chunkFor(key: string): number {
|
||||
const chunks = this.#chunks
|
||||
let lo = 0
|
||||
let hi = keys.length
|
||||
let hi = chunks.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1
|
||||
const k = keys[mid]!
|
||||
if (k < key || (exclusive && k === key)) lo = mid + 1
|
||||
else hi = mid
|
||||
const mid = (lo + hi + 1) >>> 1
|
||||
if (chunks[mid]![0]! <= key) lo = mid
|
||||
else hi = mid - 1
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
keyAt(index: number): string | undefined {
|
||||
return this.#keys[index]
|
||||
/** Position of the first key `>= key` (`> key` when exclusive); may point one past a chunk's end. */
|
||||
#seek(key: string, exclusive: boolean): [number, number] {
|
||||
const c = this.#chunkFor(key)
|
||||
const chunk = this.#chunks[c]!
|
||||
let i = lowerBound(chunk, key)
|
||||
if (exclusive && chunk[i] === key) i++
|
||||
return [c, i]
|
||||
}
|
||||
|
||||
#end(): [number, number] {
|
||||
const c = this.#chunks.length - 1
|
||||
return [c, this.#chunks[c]!.length]
|
||||
}
|
||||
|
||||
/** Moves a position that is past its chunk's end to the start of the next chunk. */
|
||||
#normalize(c: number, i: number): [number, number] {
|
||||
while (c < this.#chunks.length && i >= this.#chunks[c]!.length) {
|
||||
c++
|
||||
i = 0
|
||||
}
|
||||
return [c, i]
|
||||
}
|
||||
|
||||
#prev(c: number, i: number): [number, number] {
|
||||
i--
|
||||
while (i < 0 && c > 0) {
|
||||
c--
|
||||
i = this.#chunks[c]!.length - 1
|
||||
}
|
||||
return i < 0 ? [-1, -1] : [c, i]
|
||||
}
|
||||
}
|
||||
|
||||
function lowerBound(arr: string[], key: string): number {
|
||||
let lo = 0
|
||||
let hi = arr.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1
|
||||
if (arr[mid]! < key) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
function cmp(a: string, b: string): number {
|
||||
|
||||
Reference in New Issue
Block a user