Download packages/minidb/src/write-path.ts from SaylorTwift/kimi-code: direct link, hf CLI and curl.
- Browser
- Download file 38 kB
-
https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/minidb/src/write-path.ts
- Command line
-
hf download hf://SaylorTwift/kimi-code/packages/minidb/src/write-path.ts
-
curl -L -o write-path.ts https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/minidb/src/write-path.ts
38 kB
| // src/write-path.ts | |
| // | |
| // MiniDb's write path as a facet: the public write ops (set/del/batch/expire) | |
| // and their whole commit machinery — prepare (validate/encode/canonical/ | |
| // tokenize), apply (store + derived indexes), the WAL commit bodies with | |
| // flush-group rollback, eviction, the WAL-pointer publish, and the recovered | |
| // op/frame apply used by open-time recovery, catch-up, and the generation | |
| // load's WAL-delta replay. | |
| // | |
| // The collaborators are injected, never the MiniDb class itself: the Store / | |
| // WAL and index managers (lazy getters or stable references), the | |
| // WalGroupTracker / MemoryGuard / TextRegistry / GenerationBuilder facets | |
| // (the genBuild mutation queue is fed through the shared object the builder | |
| // publishes), the write-op gate, the unique-write serializer, and the | |
| // owner's lifecycle/compaction callbacks. | |
| import { backupInProgressError } from './backup.js'; | |
| import { encodeFrame, encodeBatchOps, scanBatchOpRefs, HEADER_SIZE, TYPE_SET, TYPE_DEL, TYPE_BATCH } from './codec.js'; | |
| import type { BatchOp as EncodedBatchOp, FrameRef } from './codec.js'; | |
| import { frameToOps } from './recovery.js'; | |
| import type { ValueMode, RecoveredOp } from './recovery.js'; | |
| import { yieldToLoop } from './text-index/tokenize.js'; | |
| import { toBuf, toKStr, normDt, MAX_KEY_LEN } from './value-codec.js'; | |
| import type { Store, StoreRecord, ValueLoc } from './store.js'; | |
| import type { WAL } from './wal.js'; | |
| import type { IndexManager } from './index-manager.js'; | |
| import type { DtIndex } from './dt-index.js'; | |
| import type { CompoundIndexManager } from './compound-index.js'; | |
| import type { OpTracker } from './op-tracker.js'; | |
| import type { WalGroupTracker } from './wal-group.js'; | |
| import type { MemoryGuard } from './memory-guard.js'; | |
| import type { TextRegistry } from './text-registry.js'; | |
| import type { GenerationBuilder } from './generation-builder.js'; | |
| import type { TextIndex } from './text-index/index.js'; | |
| import type { SetOptions, BatchInputOp, PreparedOp, ValueCodecName } from './types.js'; | |
| /** The stats counters the write path touches (a structural view of MiniDb's | |
| * stats object). */ | |
| export interface WritePathStats { | |
| evictions: number; | |
| compactionRotationPauseMs: number; | |
| } | |
| /** The owner-injected surface the write path needs (see the header). */ | |
| export interface WritePathDeps<V> { | |
| store: () => Store; | |
| wal: () => WAL; | |
| valueMode: () => ValueMode; | |
| codecName: () => ValueCodecName; | |
| /** The compaction rotation critical section (null outside it). */ | |
| rotateLock: () => Promise<void> | null; | |
| dt: DtIndex; | |
| indexes: IndexManager; | |
| compound: CompoundIndexManager; | |
| textRegistry: TextRegistry<V>; | |
| walGroups: WalGroupTracker; | |
| memoryGuard: MemoryGuard<V>; | |
| generationBuilder: GenerationBuilder<V>; | |
| writeOps: OpTracker; | |
| serializeUniqueWrites: <T>(fn: () => Promise<T>) => Promise<T>; | |
| stats: WritePathStats; | |
| encode: (v: V) => Buffer; | |
| decode: (b: Buffer | undefined) => V | undefined; | |
| indexable: (v: unknown) => v is Record<string, unknown>; | |
| ensureOpen: () => void; | |
| ensureWritable: () => void; | |
| maybeAutoCompact: () => void; | |
| } | |
| export class WritePath<V> { | |
| /** Scratch out-param for applyOp's pre-state capture. Live only within the | |
| * synchronous apply section of a commit body (shared safely because | |
| * nothing awaits while it is read); callers lift the reference into a | |
| * local before any await. Avoids one small allocation per write op. */ | |
| private readonly applyBox: { prev: StoreRecord | undefined } = { prev: undefined }; | |
| constructor(private readonly deps: WritePathDeps<V>) {} | |
| /** Park a write op while a compaction rotation is in flight, accounting the | |
| * wait so compactionRotationPauseMs reflects the writer-visible pause | |
| * (as opposed to compactionRotationDurationMs, the rotation's wall time). */ | |
| private async awaitRotation(): Promise<void> { | |
| const rl = this.deps.rotateLock(); | |
| if (!rl) return; | |
| const t0 = performance.now(); | |
| await rl; | |
| this.deps.stats.compactionRotationPauseMs += performance.now() - t0; | |
| } | |
| private hasUniqueIndexes(): boolean { | |
| // Staged included: while a unique create is in its persist window the | |
| // staged index is fully built and writes must already be checked against | |
| // it (and serialized via serializeUniqueWrites) — see IndexManager.staged. | |
| return this.deps.indexes.hasUnique(); | |
| } | |
| /** | |
| * Run a write-op commit body, transparently retrying once when the commit | |
| * raced a compaction rotation: an op that passed the _rotateLock gate check | |
| * just before it was set can hit the freshly-sealed old WAL (code | |
| * 'WAL_SEALED') between the gate and its append, or — one step later in the | |
| * rotation — the already-closed but not-yet-replaced old WAL (the untyped | |
| * 'WAL is closed'; only retried while a rotation is actually in flight, so a | |
| * write after db.close() still fails). The op rolls its in-memory side | |
| * effects back on a failed append, so re-running the (idempotent) commit | |
| * body against the post-rotation WAL is safe. | |
| */ | |
| private async retryOnWalSeal(commit: () => Promise<void>): Promise<void> { | |
| try { | |
| await commit(); | |
| } catch (e) { | |
| const sealed = (e as { code?: string }).code === 'WAL_SEALED'; | |
| const closedMidRotation = | |
| this.deps.rotateLock() !== null && e instanceof Error && e.message === 'WAL is closed'; | |
| if (!sealed && !closedMidRotation) throw e; | |
| await this.awaitRotation(); | |
| await commit(); | |
| } | |
| } | |
| async evictKey(pk: string): Promise<void> { | |
| const bytes = this.deps.store().recordBytes(pk); | |
| if (!bytes) return; | |
| const op = this.prepareDel(Buffer.from(pk, 'binary')); | |
| // Committed through retryOnWalSeal like any other write: an evict that | |
| // passed the writer gate just before a compaction rotation can land its | |
| // DEL on the freshly-sealed (or just-closed, soon-to-be-replaced) old WAL, | |
| // and the user write that triggered the eviction must never see that race. | |
| // A failed attempt restores the victim via restoreKey, so re-running the | |
| // idempotent DEL body against the post-rotation WAL is safe. | |
| const commit = async (): Promise<void> => { | |
| const recoveryGate = this.deps.walGroups.walRecoveryGate(); | |
| if (recoveryGate) await recoveryGate; | |
| const wal = this.deps.wal(); | |
| const appended = wal.appendLoc(encodeFrame({ type: TYPE_DEL, key: op.key })); | |
| const group = this.deps.walGroups.groupFor(wal, appended.batchId); | |
| const applied = this.applyBox; | |
| let prev: StoreRecord | undefined; | |
| let seq: number | undefined; | |
| try { | |
| this.applyOp(op, applied); | |
| prev = applied.prev; | |
| seq = this.deps.store().map.get(op.pk)?.seq; | |
| } catch (err) { | |
| // See set() for this defensive path (applyOp's must-not-throw contract). | |
| void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection | |
| if (group) { | |
| wal.poisonPending(err); | |
| this.deps.walGroups.groupNoteKey(group, op.pk, applied.prev); | |
| this.deps.walGroups.rollbackGroup(group, wal, appended.batchId); | |
| this.deps.walGroups.kickWalRecovery(wal); | |
| } else { | |
| this.restoreGroupKey(op.pk, applied.prev); | |
| } | |
| throw this.deps.walGroups.markAmbiguous(err); | |
| } | |
| this.deps.walGroups.groupNoteKey(group, op.pk, prev); | |
| try { | |
| await appended.done; | |
| this.deps.stats.evictions++; | |
| } catch (e) { | |
| if (group) this.deps.walGroups.rollbackGroup(group, wal, appended.batchId); | |
| else this.restoreKey(op.pk, prev, seq); | |
| this.deps.walGroups.kickWalRecovery(wal); | |
| throw this.deps.walGroups.markAmbiguous(e); | |
| } | |
| this.deps.walGroups.settleGroup(group, wal, appended.batchId); | |
| }; | |
| await this.retryOnWalSeal(commit); | |
| } | |
| private checkKey(key: string | Buffer): void { | |
| const len = typeof key === 'string' ? key.length : Buffer.from(key).length; | |
| if (len > MAX_KEY_LEN) throw new RangeError(`key too long (>${MAX_KEY_LEN})`); | |
| if ((typeof key === 'string' && key.length === 0) || (Buffer.isBuffer(key) && key.length === 0)) { | |
| throw new RangeError('key must be non-empty'); | |
| } | |
| } | |
| /** Swap a record this op just wrote over to its disk-backed WAL pointer. | |
| * Must only run after the WAL frame's `done` resolved: appendLoc's offset | |
| * is a prediction and the bytes are not in db.wal until the queued writev | |
| * lands, so publishing the pointer earlier let synchronous disk readers | |
| * (compaction's snapshot phase, get) read past the end of the file. | |
| * Skipped when the WAL was rotated by a compaction meanwhile (the pointer | |
| * would reference the old file's offsets) or when the record was | |
| * overwritten/deleted since; the record then keeps its in-memory ref — | |
| * correct, just held in RAM until the next snapshot. */ | |
| private publishWalRef( | |
| pk: string, | |
| wal: WAL, | |
| seq: number | undefined, | |
| loc: ValueLoc, | |
| expireAt: number, | |
| dt: Record<string, number> | null, | |
| ): void { | |
| if (this.deps.wal() !== wal || seq === undefined) return; | |
| const cur = this.deps.store().map.get(pk); | |
| if (!cur || cur.seq !== seq) return; | |
| this.deps.store().setRef(pk, { kind: 'disk', loc }, expireAt, dt); | |
| } | |
| async set(key: string | Buffer, value: V, { ttl, dt }: SetOptions = {}): Promise<void> { | |
| this.deps.ensureOpen(); | |
| this.deps.ensureWritable(); | |
| this.checkKey(key); | |
| if (!this.deps.writeOps.enter()) throw backupInProgressError(); | |
| try { | |
| await this.awaitRotation(); | |
| // Validation before side effects (stage 11): prepare (key/ttl checks, | |
| // encode + canonical, tokenize + custom-tokenizer validation) and the | |
| // unique check run BEFORE ensureMemoryFor can evict anything, so a | |
| // rejected write leaves the database untouched — no eviction, no WAL, no | |
| // memory change (review #6). The whole pipeline runs inside the | |
| // unique-write chain when a unique index exists: check-then-commit stays | |
| // atomic for the chain's whole lifetime, so a WAL-seal retry needs no | |
| // re-check (every violation-creating writer is serialized out). | |
| const run = async (): Promise<void> => { | |
| const op = this.prepareSet(key, value, { ttl, dt }); | |
| if (this.deps.indexes.size && this.deps.indexable(op.canonical)) this.deps.indexes.checkUnique(op.pk, op.canonical); | |
| await this.deps.memoryGuard.ensureMemoryFor([op]); | |
| await this.retryOnWalSeal(() => this.commitSetOp(op)); | |
| }; | |
| if (this.hasUniqueIndexes()) await this.deps.serializeUniqueWrites(run); | |
| else await run(); | |
| } finally { | |
| this.deps.writeOps.leave(); | |
| } | |
| } | |
| /** The set() commit body: append the frame and apply the prepared op, | |
| * rolling back (per-op or group) when the WAL write fails. */ | |
| private async commitSetOp(op: PreparedOp<V>): Promise<void> { | |
| // Queue behind any in-place WAL recovery: a write issued after a | |
| // failure waits for the truncate + poison-clear instead of hitting the | |
| // still-poisoned WAL. Null (and zero-cost) when no recovery is running. | |
| const recoveryGate = this.deps.walGroups.walRecoveryGate(); | |
| if (recoveryGate) await recoveryGate; | |
| const frame = encodeFrame({ type: TYPE_SET, key: op.key, value: op.value, meta: op.meta, expireAt: op.expireAt }); | |
| const wal = this.deps.wal(); | |
| const appended = wal.appendLoc(frame); | |
| // Apply in the SAME synchronous tick as the WAL append, so a concurrent | |
| // compaction always snapshots the post-write state. In valueMode 'disk' | |
| // the record first holds an in-memory ref: the frame's bytes are not in | |
| // db.wal yet (appendLoc's offset is only a prediction), so a disk | |
| // pointer published now could point past the end of the file. The | |
| // pointer is published once `done` resolves (see publishWalRef). If the | |
| // WAL write ultimately fails, the whole flush group rolls back to the | |
| // pre-group records so in-memory state never diverges from what is | |
| // durable (and from what a reopen replays after the in-place recovery | |
| // truncated the failed tail). | |
| const group = this.deps.walGroups.groupFor(wal, appended.batchId); | |
| const applied = this.applyBox; | |
| let prev: StoreRecord | undefined; | |
| let seq: number | undefined; | |
| try { | |
| this.applyOp(op, applied); | |
| // Lift the pre-state reference out of the shared scratch before any | |
| // await lets a later op overwrite it. | |
| prev = applied.prev; | |
| seq = this.deps.store().map.get(op.pk)?.seq; | |
| } catch (err) { | |
| // applyOp violated its must-not-throw contract (see its doc — stage 11 | |
| // makes it structural; this try is the defensive layer). An enqueued | |
| // frame (batchId >= 0) is un-acked and must never reach disk: poison | |
| // the WAL exactly like a write failure and roll the group back. A | |
| // never-enqueued frame (batchId < 0, e.g. a seal race) poisons | |
| // nothing — only the partial in-memory mutation needs undoing. | |
| void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection | |
| if (group) { | |
| wal.poisonPending(err); | |
| this.deps.walGroups.groupNoteKey(group, op.pk, applied.prev); | |
| this.deps.walGroups.rollbackGroup(group, wal, appended.batchId); | |
| this.deps.walGroups.kickWalRecovery(wal); | |
| } else { | |
| this.restoreGroupKey(op.pk, applied.prev); | |
| } | |
| throw this.deps.walGroups.markAmbiguous(err); | |
| } | |
| this.deps.walGroups.groupNoteKey(group, op.pk, prev); | |
| try { | |
| await appended.done; | |
| } catch (e) { | |
| if (group) this.deps.walGroups.rollbackGroup(group, wal, appended.batchId); | |
| else this.restoreKey(op.pk, prev, seq); | |
| this.deps.walGroups.kickWalRecovery(wal); | |
| throw this.deps.walGroups.markAmbiguous(e); | |
| } | |
| this.deps.walGroups.settleGroup(group, wal, appended.batchId); | |
| if (this.deps.valueMode() === 'disk') { | |
| this.publishWalRef( | |
| op.pk, | |
| wal, | |
| seq, | |
| { file: 'wal', off: appended.offset + HEADER_SIZE + op.key.length, len: op.value!.length }, | |
| op.expireAt, | |
| op.dtNorm, | |
| ); | |
| } | |
| this.deps.maybeAutoCompact(); | |
| } | |
| async del(key: string | Buffer): Promise<boolean> { | |
| this.deps.ensureOpen(); | |
| this.deps.ensureWritable(); | |
| if (!this.deps.writeOps.enter()) throw backupInProgressError(); | |
| try { | |
| await this.awaitRotation(); | |
| const existed = this.deps.store().has(toKStr(key)); | |
| if (!existed) return false; | |
| const op = this.prepareDel(key); | |
| await this.deps.memoryGuard.ensureMemoryFor([op]); | |
| const commit = async (): Promise<void> => { | |
| const recoveryGate = this.deps.walGroups.walRecoveryGate(); | |
| if (recoveryGate) await recoveryGate; | |
| const wal = this.deps.wal(); | |
| const appended = wal.appendLoc(encodeFrame({ type: TYPE_DEL, key: op.key })); | |
| const group = this.deps.walGroups.groupFor(wal, appended.batchId); | |
| const applied = this.applyBox; | |
| let prev: StoreRecord | undefined; | |
| let seq: number | undefined; | |
| try { | |
| this.applyOp(op, applied); | |
| prev = applied.prev; | |
| seq = this.deps.store().map.get(op.pk)?.seq; | |
| } catch (err) { | |
| // See set() for this defensive path (applyOp's must-not-throw contract). | |
| void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection | |
| if (group) { | |
| wal.poisonPending(err); | |
| this.deps.walGroups.groupNoteKey(group, op.pk, applied.prev); | |
| this.deps.walGroups.rollbackGroup(group, wal, appended.batchId); | |
| this.deps.walGroups.kickWalRecovery(wal); | |
| } else { | |
| this.restoreGroupKey(op.pk, applied.prev); | |
| } | |
| throw this.deps.walGroups.markAmbiguous(err); | |
| } | |
| this.deps.walGroups.groupNoteKey(group, op.pk, prev); | |
| try { | |
| await appended.done; | |
| } catch (e) { | |
| if (group) this.deps.walGroups.rollbackGroup(group, wal, appended.batchId); | |
| else this.restoreKey(op.pk, prev, seq); | |
| this.deps.walGroups.kickWalRecovery(wal); | |
| throw this.deps.walGroups.markAmbiguous(e); | |
| } | |
| this.deps.walGroups.settleGroup(group, wal, appended.batchId); | |
| this.deps.maybeAutoCompact(); | |
| }; | |
| await this.retryOnWalSeal(commit); | |
| return true; | |
| } finally { | |
| this.deps.writeOps.leave(); | |
| } | |
| } | |
| /** Atomically apply a batch of operations (all-or-nothing). */ | |
| async batch(ops: readonly BatchInputOp<V>[]): Promise<void> { | |
| this.deps.ensureOpen(); | |
| this.deps.ensureWritable(); | |
| if (!this.deps.writeOps.enter()) throw backupInProgressError(); | |
| try { | |
| await this.awaitRotation(); | |
| if (!ops || ops.length === 0) return; | |
| // Same stage-11 ordering as set(): every fallible validation (per-op | |
| // prepare, then the whole-batch unique check against canonical docs) | |
| // precedes ensureMemoryFor's evictions, so a rejected batch has zero | |
| // side effects; the pipeline holds the unique-write chain end to end, so | |
| // a WAL-seal retry of the commit needs no re-check. | |
| const run = async (): Promise<void> => { | |
| const prepared = ops.map((o) => this.prepareOp(o)); | |
| if (this.deps.indexes.size) { | |
| this.deps.indexes.checkUniqueBatch( | |
| prepared.map((o) => ({ | |
| pk: o.pk, | |
| op: o.type === TYPE_DEL ? ('del' as const) : ('set' as const), | |
| doc: o.canonical, | |
| })), | |
| ); | |
| } | |
| await this.deps.memoryGuard.ensureMemoryFor(prepared); | |
| await this.retryOnWalSeal(() => this.commitBatchOps(prepared)); | |
| }; | |
| if (this.hasUniqueIndexes()) await this.deps.serializeUniqueWrites(run); | |
| else await run(); | |
| } finally { | |
| this.deps.writeOps.leave(); | |
| } | |
| } | |
| /** The batch() commit body: append one BATCH frame and apply every prepared | |
| * op, rolling the whole batch back when the WAL write fails. */ | |
| private async commitBatchOps(prepared: readonly PreparedOp<V>[]): Promise<void> { | |
| const recoveryGate = this.deps.walGroups.walRecoveryGate(); | |
| if (recoveryGate) await recoveryGate; | |
| const body = encodeBatchOps( | |
| prepared.map<EncodedBatchOp>((op) => ({ type: op.type, key: op.key, value: op.value, meta: op.meta, expireAt: op.expireAt })), | |
| ); | |
| const frame = encodeFrame({ type: TYPE_BATCH, key: Buffer.alloc(0), value: body }); | |
| const wal = this.deps.wal(); | |
| const appended = wal.appendLoc(frame); | |
| const group = this.deps.walGroups.groupFor(wal, appended.batchId); | |
| // Capture each key's pre-batch record (first applyOp per key) so the whole | |
| // batch can be rolled back if the WAL write fails, preserving atomicity. | |
| const prevs = new Map<string, StoreRecord | undefined>(); | |
| const applied = this.applyBox; | |
| let cur: PreparedOp<V> | null = null; | |
| try { | |
| for (const op of prepared) { | |
| cur = op; | |
| this.applyOp(op, applied); | |
| if (!prevs.has(op.pk)) prevs.set(op.pk, applied.prev); | |
| } | |
| } catch (err) { | |
| // See set() for this defensive path (applyOp's must-not-throw | |
| // contract); the op that threw mid-apply has its pre-state in `applied`. | |
| if (cur && !prevs.has(cur.pk)) prevs.set(cur.pk, applied.prev); | |
| void appended.done.catch(() => {}); // this batch throws here; swallow the frame's rejection | |
| if (group) { | |
| wal.poisonPending(err); | |
| for (const [pk, p] of prevs) this.deps.walGroups.groupNoteKey(group, pk, p); | |
| this.deps.walGroups.rollbackGroup(group, wal, appended.batchId); | |
| this.deps.walGroups.kickWalRecovery(wal); | |
| } else { | |
| for (const [pk, p] of prevs) this.restoreGroupKey(pk, p); | |
| } | |
| throw this.deps.walGroups.markAmbiguous(err); | |
| } | |
| for (const [pk, p] of prevs) this.deps.walGroups.groupNoteKey(group, pk, p); | |
| // Seq identity of each record as this batch left it (undefined where the | |
| // batch's last op deleted the key): guards both the per-op rollback | |
| // (frames that never entered a group, e.g. a seal race) and the WAL | |
| // pointer publish against interleaved same-key commits. | |
| const seqs = new Map<string, number | undefined>(); | |
| for (const pk of prevs.keys()) seqs.set(pk, this.deps.store().map.get(pk)?.seq); | |
| // In valueMode 'disk' the applied records hold in-memory refs for now | |
| // (see set()); their WAL pointers are published after `done` resolves. | |
| // Only the LAST set per key may publish — an earlier op's frame range | |
| // holds a superseded value. | |
| const lastSet = new Map<string, { op: PreparedOp<V>; loc: ValueLoc; seq: number | undefined }>(); | |
| if (this.deps.valueMode() === 'disk') { | |
| const bodyOff = appended.offset + HEADER_SIZE; | |
| const opRefs = scanBatchOpRefs(body, 0); | |
| for (let i = 0; i < prepared.length; i++) { | |
| const op = prepared[i]!; | |
| const ref = opRefs[i]; | |
| if (op.type === TYPE_SET && ref) { | |
| lastSet.set(op.pk, { op, loc: { file: 'wal', off: bodyOff + ref.valueOff, len: ref.valLen }, seq: seqs.get(op.pk) }); | |
| } | |
| } | |
| } | |
| try { | |
| await appended.done; | |
| } catch (e) { | |
| if (group) this.deps.walGroups.rollbackGroup(group, wal, appended.batchId); | |
| else for (const [pk, prev] of prevs) this.restoreKey(pk, prev, seqs.get(pk)); | |
| this.deps.walGroups.kickWalRecovery(wal); | |
| throw this.deps.walGroups.markAmbiguous(e); | |
| } | |
| this.deps.walGroups.settleGroup(group, wal, appended.batchId); | |
| for (const [pk, { op, loc, seq }] of lastSet) { | |
| this.publishWalRef(pk, wal, seq, loc, op.expireAt, op.dtNorm); | |
| } | |
| this.deps.maybeAutoCompact(); | |
| } | |
| private prepareOp(o: BatchInputOp<V>): PreparedOp<V> { | |
| if (o.op === 'set') return this.prepareSet(o.key, o.value, { ttl: o.ttl, dt: o.dt }); | |
| if (o.op === 'del') return this.prepareDel(o.key); | |
| throw new TypeError(`unknown batch op: ${(o as { op: string }).op}`); | |
| } | |
| private prepareSet(key: string | Buffer, value: V, { ttl, dt }: SetOptions = {}): PreparedOp<V> { | |
| this.checkKey(key); | |
| const pk = toKStr(key); | |
| const dtNorm = normDt(dt); | |
| // A TTL is encoded as an int64 in the frame, so it must be a finite integer | |
| // of milliseconds. A fractional TTL is floored; a non-finite one | |
| // (NaN / ±Infinity) is rejected up front instead of exploding inside the | |
| // frame encoder with an opaque "cannot convert to BigInt" error. ttl 0 (or | |
| // omitted) keeps the existing "no expiry" semantics. | |
| if (ttl !== undefined && !Number.isFinite(ttl)) throw new RangeError('ttl must be a finite number of milliseconds'); | |
| const expireAt = ttl ? Date.now() + Math.floor(ttl) : 0; | |
| const vbuf = this.deps.encode(value); | |
| // Canonical value (stage 11): the json codec re-parses the encoded bytes | |
| // ONCE, so every downstream consumer sees exactly the persisted value | |
| // (review #5). The decode is infallible here — it re-parses what | |
| // JSON.stringify just produced. Buffer/string codecs have no canonical | |
| // concept and keep the value as passed (their paths never feed indexes). | |
| const canonical = this.deps.codecName() === 'json' ? (this.deps.decode(vbuf) as V) : value; | |
| // Tokenize at the prepare boundary (stage 11): a throwing custom | |
| // tokenizer — or one producing an overlong term — rejects the write here, | |
| // before the store/delta/buildQueue can be polluted (reviews #24/#27). | |
| let textTokens: Map<TextIndex, readonly string[] | null> | null = null; | |
| if (this.deps.textRegistry.text.size) { | |
| textTokens = new Map(); | |
| for (const ti of this.deps.textRegistry.text.values()) { | |
| textTokens.set(ti, this.deps.indexable(canonical) ? ti.prepareAdd(canonical) : null); | |
| } | |
| } | |
| const meta = dtNorm ? Buffer.from(JSON.stringify({ dt: dtNorm })) : null; | |
| return { type: TYPE_SET, key: toBuf(key), value: vbuf, meta, expireAt, dtNorm, pk, canonical, textTokens }; | |
| } | |
| private prepareDel(key: string | Buffer): PreparedOp<V> { | |
| this.checkKey(key); | |
| return { | |
| type: TYPE_DEL, | |
| key: toBuf(key), | |
| value: null, | |
| meta: null, | |
| expireAt: 0, | |
| dtNorm: null, | |
| pk: toKStr(key), | |
| canonical: undefined, | |
| textTokens: null, | |
| }; | |
| } | |
| /** Apply a prepared op to the store + derived indexes, writing the key's | |
| * pre-op logical record into `out.prev` so the caller can roll back (or | |
| * poison + group-rollback) on failure. `out.prev` is assigned before any | |
| * mutation, so it is valid even when the apply throws. | |
| * | |
| * CONTRACT: applyOp must not throw. Stage 11 makes this structural: every | |
| * fallible input validation lives in the prepare phase (key/ttl checks, | |
| * encoding, the canonical decode, tokenization + custom-tokenizer output | |
| * validation) and unique checks run before ensureMemoryFor, so the body | |
| * below is pure assignment against pre-validated data. The ONE remaining | |
| * fallible branch is a text index registered between prepare and apply | |
| * (a createTextIndex racing this write — see the comment inline); the | |
| * commit bodies' defensive try (stage 7) stays as the backstop for it and | |
| * for catastrophic store I/O. */ | |
| private applyOp(op: PreparedOp<V>, out: { prev: StoreRecord | undefined }): void { | |
| const oldBuf = this.deps.store().get(op.pk); | |
| out.prev = oldBuf !== undefined ? this.deps.store().map.get(op.pk) : undefined; | |
| const oldDoc = oldBuf !== undefined ? this.deps.decode(oldBuf) : undefined; | |
| if (op.type === TYPE_SET) { | |
| // Always applied as an in-memory ref; in valueMode 'disk' the caller | |
| // swaps in the WAL pointer via publishWalRef() once the frame's bytes | |
| // are durably in db.wal. | |
| this.deps.store().set(op.key, op.value!, op.expireAt, op.dtNorm); | |
| this.deps.dt.set(op.pk, op.dtNorm); | |
| this.deps.compound.add(op.pk, op.canonical, op.dtNorm); | |
| if (this.deps.indexes.size) { | |
| if (this.deps.indexable(oldDoc)) this.deps.indexes.remove(op.pk, oldDoc); | |
| if (this.deps.indexable(op.canonical)) this.deps.indexes.add(op.pk, op.canonical); | |
| } | |
| for (const ti of this.deps.textRegistry.text.values()) { | |
| const tokens = op.textTokens?.get(ti); | |
| if (tokens !== undefined) { | |
| // Pre-tokenized and validated at the prepare boundary (null = the | |
| // canonical doc is not indexable → drop the key from this index). | |
| if (tokens === null) ti.remove(op.pk); | |
| else ti.addPrepared(op.pk, tokens); | |
| } else if (this.deps.indexable(op.canonical)) { | |
| // An index registered AFTER this op was prepared (createTextIndex | |
| // registered it mid-write), or replaced by a same-name drop+create | |
| // since: it has no prepared tokens, so tokenize here. A throwing | |
| // tokenizer in this narrow race is covered by the commit body's | |
| // defensive try (stage 7), exactly as before stage 11. | |
| ti.add(op.pk, op.canonical); | |
| } else { | |
| ti.remove(op.pk); | |
| } | |
| } | |
| } else if (op.type === TYPE_DEL) { | |
| const existed = this.deps.store().del(op.key); | |
| if (existed) { | |
| this.deps.memoryGuard.access.delete(op.pk); | |
| this.deps.dt.del(op.pk); | |
| this.deps.compound.remove(op.pk); | |
| if (this.deps.indexes.size && this.deps.indexable(oldDoc)) this.deps.indexes.remove(op.pk, oldDoc); | |
| for (const ti of this.deps.textRegistry.text.values()) ti.remove(op.pk); | |
| } | |
| } | |
| // Stage 5: feed the in-flight generation build (if any) so its detached | |
| // states converge on the exact sealed checkpoint — see genBuild. Infallible | |
| // (a bare array push + counter), preserving this method's must-not-throw | |
| // contract. | |
| const gb = this.deps.generationBuilder.genBuild; | |
| if (gb) { | |
| gb.queue.push({ | |
| type: op.type, | |
| pk: op.pk, | |
| value: op.value, | |
| expireAt: op.expireAt, | |
| dtNorm: op.dtNorm, | |
| canonical: op.canonical, | |
| }); | |
| gb.bytes += (op.value ? op.value.length : 0) + 64; | |
| } | |
| if (op.type === TYPE_SET) this.deps.memoryGuard.touchAccess(op.pk); | |
| } | |
| /** Roll a key back to its pre-op record across the store and every derived | |
| * index. Used when a WAL write fails after applyOp already mutated state. | |
| * `appliedSeq` is the store record's seq captured right after THIS attempt's | |
| * own apply (undefined when the op left the key absent, i.e. a DEL). The | |
| * restore is skipped when the key's current state no longer matches it — | |
| * the same seq-identity guard publishWalRef uses — because a later same-key | |
| * op committed (or an expiry reaped the key) meanwhile, and rolling back | |
| * over it would wipe state that is already durable. This per-op path covers | |
| * frames that never entered a flush group (batchId < 0: a seal/rotation | |
| * race) and cross-group interleaves with retryOnWalSeal retries; grouped | |
| * failures roll back via rollbackGroup instead. */ | |
| private restoreKey(pk: string, prev: StoreRecord | undefined, appliedSeq: number | undefined): void { | |
| const cur = this.deps.store().map.get(pk); | |
| if (appliedSeq === undefined ? cur !== undefined : cur?.seq !== appliedSeq) return; | |
| this.restoreGroupKey(pk, prev); | |
| } | |
| /** The unguarded restore core behind restoreKey and the flush-group | |
| * rollback: put the key back to `prev` across the store and every derived | |
| * index (TTL/access/dt/secondary/compound/text). */ | |
| restoreGroupKey(pk: string, prev: StoreRecord | undefined): void { | |
| // A rollback rewinds the store OUTSIDE applyOp's op stream, so an | |
| // in-flight generation build can no longer prove its image equals the | |
| // checkpoint replay: abort it (expected churn, never an error). | |
| const gb = this.deps.generationBuilder.genBuild; | |
| if (gb) gb.aborted = true; | |
| if (this.deps.indexes.size) this.deps.indexes.remove(pk, undefined); | |
| for (const ti of this.deps.textRegistry.text.values()) ti.remove(pk); | |
| this.deps.dt.del(pk); | |
| this.deps.compound.remove(pk); | |
| if (prev === undefined) { | |
| this.deps.store().del(pk); | |
| this.deps.memoryGuard.access.delete(pk); | |
| return; | |
| } | |
| this.deps.store().setRef(pk, prev.ref, prev.expireAt, prev.dt); | |
| this.deps.memoryGuard.touchAccess(pk); | |
| const doc = this.deps.decode(this.deps.store().get(pk)); | |
| this.deps.dt.set(pk, prev.dt); | |
| this.deps.compound.add(pk, doc, prev.dt); | |
| if (this.deps.indexable(doc)) this.deps.indexes.add(pk, doc); | |
| for (const ti of this.deps.textRegistry.text.values()) { | |
| if (this.deps.indexable(doc)) ti.add(pk, doc); | |
| } | |
| } | |
| /** Apply one recovered WAL frame during catchUpFromWal: the same ops | |
| * open-time recovery derives from it (frameToOps), plus the incremental | |
| * derived-index maintenance applyOp performs on the write path — minus | |
| * unique checks: the writer already validated, and intermediate frame | |
| * states must apply literally (LWW). Cooperative: yields between primitive | |
| * ops when the caller's slicer (walApplySlicer budgets) fires — a BATCH | |
| * frame unrolls into thousands of ops, so per-op yielding is what bounds a | |
| * catch-up slice on the host's event loop. */ | |
| async applyRecoveredFrameAsync(f: FrameRef, fd: number, slice: () => boolean): Promise<void> { | |
| for (const op of frameToOps(f, 'wal', fd, this.deps.valueMode())) { | |
| this.applyRecoveredOp(op); | |
| if (slice()) await yieldToLoop(); | |
| } | |
| } | |
| applyRecoveredOp(op: RecoveredOp): void { | |
| const pk = toKStr(op.key); | |
| // Old doc for derived-index removal; decoded before the overwrite, like | |
| // applyOp. This get also lazy-reaps an expired old record, whose onExpire | |
| // hook then removes its derived entries for us. | |
| const oldDoc = this.deps.indexes.size ? this.deps.decode(this.deps.store().get(pk)) : undefined; | |
| if (op.type === TYPE_DEL) { | |
| if (!this.deps.store().del(pk)) return; | |
| this.deps.memoryGuard.access.delete(pk); | |
| this.deps.dt.del(pk); | |
| this.deps.compound.remove(pk); | |
| if (this.deps.indexes.size && this.deps.indexable(oldDoc)) this.deps.indexes.remove(pk, oldDoc); | |
| for (const ti of this.deps.textRegistry.text.values()) ti.remove(pk); | |
| return; | |
| } | |
| this.deps.store().setRef(op.key, op.ref!, op.expireAt, op.dt); | |
| // Re-read through the store: a TTL too short to survive the few | |
| // microseconds since the replay-time expiry check was already reaped | |
| // here, with onExpire dropping derived state — exactly what a fresh | |
| // reopen leaves for the key. Otherwise dt/compound/secondary/text indexes | |
| // would be resurrected for a key the store no longer holds. | |
| const buf = this.deps.store().get(pk); | |
| if (buf === undefined) return; | |
| this.deps.dt.set(pk, op.dt); | |
| // Values are only decoded when a value-derived index exists (all of them | |
| // require the json codec): with none, recovery never copies them either. | |
| if (this.deps.indexes.size || this.deps.textRegistry.text.size || this.deps.compound.size) { | |
| const doc = this.deps.decode(buf)!; | |
| this.deps.compound.add(pk, doc, op.dt); | |
| if (this.deps.indexes.size) { | |
| if (this.deps.indexable(oldDoc)) this.deps.indexes.remove(pk, oldDoc); | |
| if (this.deps.indexable(doc)) this.deps.indexes.add(pk, doc); | |
| } | |
| for (const ti of this.deps.textRegistry.text.values()) { | |
| if (this.deps.indexable(doc)) ti.add(pk, doc); | |
| else ti.remove(pk); | |
| } | |
| } | |
| this.deps.memoryGuard.touchAccess(pk); | |
| } | |
| async expire(key: string | Buffer, ttlMs: number): Promise<boolean> { | |
| this.deps.ensureOpen(); | |
| this.deps.ensureWritable(); | |
| if (!this.deps.writeOps.enter()) throw backupInProgressError(); | |
| try { | |
| await this.awaitRotation(); | |
| const k = toKStr(key); | |
| const cur = this.deps.store().getRecord(k); | |
| if (cur === undefined) return false; | |
| // Same validation as set(): the TTL is stored as an int64, so it must be a | |
| // finite integer of milliseconds (fractional values are floored). | |
| if (!Number.isFinite(ttlMs)) throw new RangeError('ttl must be a finite number of milliseconds'); | |
| const expireAt = Date.now() + Math.floor(ttlMs); | |
| const curValue = this.deps.store().get(k); | |
| if (curValue === undefined) return false; | |
| const meta = cur.dt ? Buffer.from(JSON.stringify({ dt: cur.dt })) : null; | |
| const keyBuf = toBuf(key); | |
| const frame = encodeFrame({ type: TYPE_SET, key: keyBuf, value: curValue, meta, expireAt }); | |
| const commit = async (): Promise<void> => { | |
| const recoveryGate = this.deps.walGroups.walRecoveryGate(); | |
| if (recoveryGate) await recoveryGate; | |
| const wal = this.deps.wal(); | |
| const appended = wal.appendLoc(frame); | |
| const group = this.deps.walGroups.groupFor(wal, appended.batchId); | |
| // In-memory ref first (see set()); the disk pointer is published once the | |
| // frame's bytes are durably in db.wal. prev/seq are captured per attempt | |
| // (as in set()): a rotation retry can find a different record in place, | |
| // and restoreKey's seq guard then leaves that newer durable state alone. | |
| const prev = this.deps.store().map.get(k); | |
| let seq: number | undefined; | |
| try { | |
| this.deps.store().set(k, curValue, expireAt, cur.dt); | |
| // Stage 5: expire() rewrites the TTL without going through applyOp, | |
| // so the generation build's queue needs this store-only entry — the | |
| // value is unchanged and value-derived indexes need no re-feed. | |
| const gb = this.deps.generationBuilder.genBuild; | |
| if (gb) { | |
| gb.queue.push({ | |
| type: TYPE_SET, | |
| pk: k, | |
| value: curValue, | |
| expireAt, | |
| dtNorm: cur.dt, | |
| canonical: undefined, | |
| storeOnly: true, | |
| }); | |
| gb.bytes += curValue.length + 64; | |
| } | |
| seq = this.deps.store().map.get(k)?.seq; | |
| } catch (err) { | |
| // The in-memory mutation failed: an enqueued frame poisons the WAL | |
| // exactly like a write failure and rolls the group back; a | |
| // never-enqueued one only needs the per-op undo (see set()). | |
| void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection | |
| if (group) { | |
| wal.poisonPending(err); | |
| this.deps.walGroups.groupNoteKey(group, k, prev); | |
| this.deps.walGroups.rollbackGroup(group, wal, appended.batchId); | |
| this.deps.walGroups.kickWalRecovery(wal); | |
| } else { | |
| this.restoreGroupKey(k, prev); | |
| } | |
| throw this.deps.walGroups.markAmbiguous(err); | |
| } | |
| this.deps.walGroups.groupNoteKey(group, k, prev); | |
| try { | |
| await appended.done; | |
| } catch (e) { | |
| if (group) this.deps.walGroups.rollbackGroup(group, wal, appended.batchId); | |
| else this.restoreKey(k, prev, seq); | |
| this.deps.walGroups.kickWalRecovery(wal); | |
| throw this.deps.walGroups.markAmbiguous(e); | |
| } | |
| this.deps.walGroups.settleGroup(group, wal, appended.batchId); | |
| if (this.deps.valueMode() === 'disk') { | |
| this.publishWalRef( | |
| k, | |
| wal, | |
| seq, | |
| { file: 'wal', off: appended.offset + HEADER_SIZE + keyBuf.length, len: curValue.length }, | |
| expireAt, | |
| cur.dt, | |
| ); | |
| } | |
| this.deps.maybeAutoCompact(); | |
| }; | |
| await this.retryOnWalSeal(commit); | |
| return true; | |
| } finally { | |
| this.deps.writeOps.leave(); | |
| } | |
| } | |
| } | |