brainy/src/indexes/columnStore/ColumnTailBuffer.ts
David Snelling 46583f2d9b feat: unified column store for filtering + sorting at billion scale
Adds a per-field sorted column store (Lucene doc values + roaring bitmap
architecture) that replaces the MetadataIndex sparse index internals for
both filtering and sorting. One system for all field types with exact
precision — no bucketing, no per-entity storage reads.

Column store: binary .cidx segment format, in-memory tail buffers,
LSM-style compaction, k-way merge sort, multi-value support (__words__).
All queries (filter, range, sort, filtered sort) route through the column
store when data is available, falling back to sparse index otherwise.

Key unlocks:
- find({ orderBy: 'createdAt' }) works WITHOUT a filter (previously threw)
- find({ orderBy: 'metadata.price' }) works for custom numeric fields
- Exact timestamp precision (no 1-minute bucketing)
- O(K log S) sort independent of total entity count

New files: src/indexes/columnStore/ (types, format, tail buffer, cursor,
manifest, coordinator — ~700 lines). 101 new unit tests covering binary
format round-trips, CRC validation, sort, filter, range, deletion,
multi-segment merge, persistence, and multi-value (words) fields.

Deleted: metadataIndex-automatic-bucketing.test.ts (bucketing behavior
eliminated by exact-precision column store). Sparse index write path
removed from addToIndex/removeFromIndex. Sparse index legacy code still
present as dead code pending cleanup in next commit.
2026-04-10 11:22:19 -07:00

169 lines
5 KiB
TypeScript

/**
* @module columnStore/ColumnTailBuffer
* @description Per-field in-memory write buffer that accumulates (value, entityIntId)
* entries before flushing to a sorted L0 segment on disk.
*
* Follows the MemTable pattern from GraphAdjacencyIndex's LSMTree
* (src/graph/lsm/LSMTree.ts:64-143): unsorted writes in memory,
* sort on flush, clear after flush.
*
* For multi-value fields (__words__), one entity can add multiple entries
* to the same buffer. The sorted drain merges them naturally.
*/
import { ValueType, DEFAULT_FLUSH_THRESHOLD } from './types.js'
/**
* Entry in the tail buffer: a (value, entityIntId) pair.
*
* For numeric fields, value is a number (timestamp, integer count).
* For string fields, value is a string.
* For boolean fields, value is 0 or 1 (stored as number).
*/
export interface TailEntry {
value: number | string
entityIntId: number
}
/**
* Result of draining the tail buffer: parallel sorted arrays
* ready for segment writing.
*/
export interface DrainResult {
values: (number | string)[]
entityIds: Uint32Array
pendingRemovals: Set<number>
}
/**
* In-memory write buffer for a single field's column data.
*
* Entries accumulate unsorted. On drain/flush, they are sorted by value
* (numeric comparison for numbers, lexicographic for strings). Ties are
* broken by entityIntId ascending for deterministic output.
*
* @example
* const buf = new ColumnTailBuffer('createdAt', ValueType.Number)
* buf.add(1700000001000, 42)
* buf.add(1700000000000, 10)
* const { values, entityIds } = buf.drain()
* // values: [1700000000000, 1700000001000]
* // entityIds: [10, 42]
*/
export class ColumnTailBuffer {
/** Field name this buffer is for. */
readonly fieldName: string
/** Value type determines sort comparator. */
readonly valueType: ValueType
/** Flush threshold. */
readonly threshold: number
/** Unsorted entry accumulator. */
private entries: TailEntry[] = []
/**
* Entity int IDs marked for removal (tombstoning).
* Applied during segment writes — the removal propagates to existing segments.
*/
private removals: Set<number> = new Set()
/**
* @param fieldName - Field name (e.g. 'createdAt', 'status', '__words__')
* @param valueType - How values should be sorted and encoded
* @param threshold - Number of entries before shouldFlush() returns true
*/
constructor(fieldName: string, valueType: ValueType, threshold: number = DEFAULT_FLUSH_THRESHOLD) {
this.fieldName = fieldName
this.valueType = valueType
this.threshold = threshold
}
/**
* Add a (value, entityIntId) entry to the buffer.
*
* @param value - The field value (number for numeric/boolean, string for string)
* @param entityIntId - The entity's u32 integer ID from EntityIdMapper
*/
add(value: number | string, entityIntId: number): void {
this.entries.push({ value, entityIntId })
}
/**
* Mark an entity for removal. The removal is tracked as a pending tombstone
* and applied when flushing or during segment compaction.
*
* @param entityIntId - Entity to remove
*/
remove(entityIntId: number): void {
this.removals.add(entityIntId)
// Also remove any pending entries for this entity in the buffer
this.entries = this.entries.filter(e => e.entityIntId !== entityIntId)
}
/**
* Number of entries currently in the buffer.
*/
get size(): number {
return this.entries.length
}
/**
* Whether the buffer has reached the flush threshold.
*/
shouldFlush(): boolean {
return this.entries.length >= this.threshold
}
/**
* Drain the buffer: sort entries by value, return parallel arrays,
* and clear the buffer. Pending removals are returned separately
* for the caller to apply as tombstones to existing segments.
*
* Sorting: numeric values by numeric comparison, strings lexicographically.
* Ties broken by entityIntId ascending for deterministic segment output.
*
* @returns Sorted parallel arrays and pending removals
*/
drain(): DrainResult {
// Sort entries
const sorted = this.entries.slice()
if (this.valueType === ValueType.String) {
sorted.sort((a, b) => {
const cmp = String(a.value).localeCompare(String(b.value))
return cmp !== 0 ? cmp : a.entityIntId - b.entityIntId
})
} else {
sorted.sort((a, b) => {
const va = a.value as number
const vb = b.value as number
return va !== vb ? va - vb : a.entityIntId - b.entityIntId
})
}
// Build parallel arrays
const values: (number | string)[] = new Array(sorted.length)
const entityIds = new Uint32Array(sorted.length)
for (let i = 0; i < sorted.length; i++) {
values[i] = sorted[i].value
entityIds[i] = sorted[i].entityIntId
}
const pendingRemovals = new Set(this.removals)
// Clear buffer
this.entries = []
this.removals.clear()
return { values, entityIds, pendingRemovals }
}
/**
* Clear the buffer without returning data.
*/
clear(): void {
this.entries = []
this.removals.clear()
}
}