open-brainy/src/indexes/columnStore/ColumnSegmentFormat.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

548 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @module columnStore/ColumnSegmentFormat
* @description Binary reader/writer for `.cidx` column segment files.
*
* Segment layout (little-endian throughout):
* ```
* ┌─────────────────────────────────────────────┐
* │ HEADER (64 bytes) │
* ├─────────────────────────────────────────────┤
* │ VALUES (count × 8 bytes for numeric, │
* │ variable for strings) │
* ├─────────────────────────────────────────────┤
* │ ENTITY IDS (count × 4 bytes, u32 LE) │
* ├─────────────────────────────────────────────┤
* │ TOMBSTONES (serialized roaring bitmap) │
* ├─────────────────────────────────────────────┤
* │ FOOTER (16 bytes) │
* └─────────────────────────────────────────────┘
* ```
*
* Both TypeScript and Rust read/write this exact byte layout.
* Cross-language format tests validate compatibility.
*/
import { createHash } from 'node:crypto'
import {
CIDX_MAGIC,
CIDX_VERSION,
HEADER_SIZE,
FOOTER_SIZE,
MAX_FIELD_NAME_LENGTH,
ValueType,
type SegmentHeader,
type SegmentFooter
} from './types.js'
import { RoaringBitmap32 } from '../../utils/roaring/index.js'
// ---------------------------------------------------------------------------
// CRC32 — use a simple table-based implementation (no external dep)
// ---------------------------------------------------------------------------
const CRC32_TABLE = new Uint32Array(256)
for (let i = 0; i < 256; i++) {
let c = i
for (let j = 0; j < 8; j++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)
}
CRC32_TABLE[i] = c
}
/**
* Compute CRC32 of a buffer.
*
* @param data - Input bytes
* @returns CRC32 checksum as unsigned 32-bit integer
*/
export function crc32(data: Uint8Array): number {
let crc = 0xFFFFFFFF
for (let i = 0; i < data.length; i++) {
crc = CRC32_TABLE[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8)
}
return (crc ^ 0xFFFFFFFF) >>> 0
}
// ---------------------------------------------------------------------------
// Header read / write
// ---------------------------------------------------------------------------
/**
* Write a 64-byte segment header into a buffer.
*
* @param header - Parsed header fields
* @returns 64-byte Buffer
*/
export function writeHeader(header: SegmentHeader): Buffer {
const buf = Buffer.alloc(HEADER_SIZE)
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
// [0..4) magic
view.setUint32(0, header.magic, true)
// [4..6) version
view.setUint16(4, header.version, true)
// [6..8) fieldNameLength
const nameBytes = Buffer.from(header.fieldName, 'utf-8')
const nameLen = Math.min(nameBytes.length, MAX_FIELD_NAME_LENGTH)
view.setUint16(6, nameLen, true)
// [8..40) fieldName (zero-padded)
nameBytes.copy(buf, 8, 0, nameLen)
// [40..41) valueType
buf[40] = header.valueType
// [41..42) level
buf[41] = header.level
// [42..43) codec
buf[42] = header.codec
// [43..44) flags
buf[43] = header.flags
// [44..48) reserved
// [48..56) count as u64
view.setBigUint64(48, BigInt(header.count), true)
// [56..64) reserved
return buf
}
/**
* Parse a 64-byte segment header from a buffer.
*
* @param buf - At least 64 bytes
* @returns Parsed SegmentHeader
* @throws If magic or version don't match
*/
export function readHeader(buf: Buffer | Uint8Array): SegmentHeader {
if (buf.length < HEADER_SIZE) {
throw new Error(`Buffer too small for header: ${buf.length} < ${HEADER_SIZE}`)
}
const view = new DataView(
buf instanceof Buffer ? buf.buffer : buf.buffer,
buf instanceof Buffer ? buf.byteOffset : buf.byteOffset,
HEADER_SIZE
)
const magic = view.getUint32(0, true)
if (magic !== CIDX_MAGIC) {
throw new Error(`Invalid segment magic: 0x${magic.toString(16)} (expected 0x${CIDX_MAGIC.toString(16)})`)
}
const version = view.getUint16(4, true)
if (version !== CIDX_VERSION) {
throw new Error(`Unsupported segment version: ${version} (expected ${CIDX_VERSION})`)
}
const fieldNameLength = view.getUint16(6, true)
const fieldName = Buffer.from(buf.slice(8, 8 + fieldNameLength)).toString('utf-8')
return {
magic,
version,
fieldName,
fieldNameLength,
valueType: buf[40] as ValueType,
level: buf[41],
codec: buf[42],
flags: buf[43],
count: Number(view.getBigUint64(48, true))
}
}
// ---------------------------------------------------------------------------
// Footer read / write
// ---------------------------------------------------------------------------
/**
* Write a 16-byte footer.
*
* @param tombstoneLength - Byte length of the serialized tombstone bitmap
* @param checksum - CRC32 of header + values + entityIds + tombstones
* @returns 16-byte Buffer
*/
export function writeFooter(tombstoneLength: number, checksum: number): Buffer {
const buf = Buffer.alloc(FOOTER_SIZE)
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
view.setBigUint64(0, BigInt(tombstoneLength), true)
view.setUint32(8, checksum, true)
// [12..16) pad
return buf
}
/**
* Parse a 16-byte footer.
*
* @param buf - Exactly 16 bytes
* @returns Parsed footer
*/
export function readFooter(buf: Buffer | Uint8Array): SegmentFooter {
if (buf.length < FOOTER_SIZE) {
throw new Error(`Buffer too small for footer: ${buf.length} < ${FOOTER_SIZE}`)
}
const view = new DataView(
buf instanceof Buffer ? buf.buffer : buf.buffer,
buf instanceof Buffer ? buf.byteOffset : buf.byteOffset,
FOOTER_SIZE
)
return {
tombstoneLength: Number(view.getBigUint64(0, true)),
crc32: view.getUint32(8, true)
}
}
// ---------------------------------------------------------------------------
// Value column encoding
// ---------------------------------------------------------------------------
/**
* Encode a sorted array of numeric values into a packed i64 LE buffer.
*
* @param values - Sorted numbers (integers or timestamps)
* @returns Packed buffer (values.length × 8 bytes)
*/
export function encodeNumericValues(values: number[]): Buffer {
const buf = Buffer.alloc(values.length * 8)
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
for (let i = 0; i < values.length; i++) {
view.setBigInt64(i * 8, BigInt(Math.round(values[i])), true)
}
return buf
}
/**
* Decode a packed i64 LE buffer into a number array.
*
* @param buf - Packed buffer
* @param count - Number of values
* @returns Array of numbers
*/
export function decodeNumericValues(buf: Buffer | Uint8Array, count: number): number[] {
const view = new DataView(
buf instanceof Buffer ? buf.buffer : buf.buffer,
buf instanceof Buffer ? buf.byteOffset : buf.byteOffset,
count * 8
)
const result = new Array<number>(count)
for (let i = 0; i < count; i++) {
result[i] = Number(view.getBigInt64(i * 8, true))
}
return result
}
/**
* Encode a sorted array of float values into a packed f64 LE buffer.
*
* @param values - Sorted floats
* @returns Packed buffer (values.length × 8 bytes)
*/
export function encodeFloatValues(values: number[]): Buffer {
const buf = Buffer.alloc(values.length * 8)
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
for (let i = 0; i < values.length; i++) {
view.setFloat64(i * 8, values[i], true)
}
return buf
}
/**
* Decode a packed f64 LE buffer into a number array.
*
* @param buf - Packed buffer
* @param count - Number of values
* @returns Array of numbers
*/
export function decodeFloatValues(buf: Buffer | Uint8Array, count: number): number[] {
const view = new DataView(
buf instanceof Buffer ? buf.buffer : buf.buffer,
buf instanceof Buffer ? buf.byteOffset : buf.byteOffset,
count * 8
)
const result = new Array<number>(count)
for (let i = 0; i < count; i++) {
result[i] = view.getFloat64(i * 8, true)
}
return result
}
/**
* Encode sorted string values as length-prefixed UTF-8.
*
* Format per string: u32 LE length + UTF-8 bytes (no padding, no null terminator).
*
* @param values - Sorted strings
* @returns Packed buffer
*/
export function encodeStringValues(values: string[]): Buffer {
// First pass: calculate total size
const encoded = values.map(v => Buffer.from(v, 'utf-8'))
const totalSize = encoded.reduce((sum, b) => sum + 4 + b.length, 0)
const buf = Buffer.alloc(totalSize)
let offset = 0
for (const bytes of encoded) {
buf.writeUInt32LE(bytes.length, offset)
offset += 4
bytes.copy(buf, offset)
offset += bytes.length
}
return buf
}
/**
* Decode length-prefixed UTF-8 strings.
*
* @param buf - Packed buffer
* @param count - Number of strings
* @returns Array of strings
*/
export function decodeStringValues(buf: Buffer | Uint8Array, count: number): string[] {
const result = new Array<string>(count)
let offset = 0
const b = buf instanceof Buffer ? buf : Buffer.from(buf)
for (let i = 0; i < count; i++) {
const len = b.readUInt32LE(offset)
offset += 4
result[i] = b.toString('utf-8', offset, offset + len)
offset += len
}
return result
}
/**
* Encode entity int IDs as packed u32 LE.
*
* @param ids - Entity integer IDs (parallel to value column)
* @returns Packed buffer (ids.length × 4 bytes)
*/
export function encodeEntityIds(ids: Uint32Array | number[]): Buffer {
const arr = ids instanceof Uint32Array ? ids : new Uint32Array(ids)
return Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength)
}
/**
* Decode packed u32 LE entity IDs.
*
* @param buf - Packed buffer
* @param count - Number of IDs
* @returns Uint32Array
*/
export function decodeEntityIds(buf: Buffer | Uint8Array, count: number): Uint32Array {
const result = new Uint32Array(count)
const view = new DataView(
buf instanceof Buffer ? buf.buffer : buf.buffer,
buf instanceof Buffer ? buf.byteOffset : buf.byteOffset,
count * 4
)
for (let i = 0; i < count; i++) {
result[i] = view.getUint32(i * 4, true)
}
return result
}
// ---------------------------------------------------------------------------
// Full segment write / read
// ---------------------------------------------------------------------------
/**
* Encode values based on ValueType.
*
* @param values - Sorted values
* @param valueType - How to encode
* @returns Packed buffer
*/
export function encodeValues(values: (number | string | boolean)[], valueType: ValueType): Buffer {
switch (valueType) {
case ValueType.Number:
case ValueType.Boolean:
return encodeNumericValues(values as number[])
case ValueType.Float:
return encodeFloatValues(values as number[])
case ValueType.String:
return encodeStringValues(values as string[])
default:
throw new Error(`Unknown ValueType: ${valueType}`)
}
}
/**
* Decode values based on ValueType.
*
* @param buf - Packed value buffer
* @param count - Number of values
* @param valueType - How to decode
* @returns Array of values
*/
export function decodeValues(buf: Buffer | Uint8Array, count: number, valueType: ValueType): (number | string)[] {
switch (valueType) {
case ValueType.Number:
case ValueType.Boolean:
return decodeNumericValues(buf, count)
case ValueType.Float:
return decodeFloatValues(buf, count)
case ValueType.String:
return decodeStringValues(buf, count)
default:
throw new Error(`Unknown ValueType: ${valueType}`)
}
}
/**
* Compute the byte size of the values column for a given count and type.
* For strings this is not knowable without the data, so returns -1.
*
* @param count - Number of entries
* @param valueType - Value encoding type
* @returns Byte size, or -1 for variable-length (strings)
*/
export function valuesColumnSize(count: number, valueType: ValueType): number {
switch (valueType) {
case ValueType.Number:
case ValueType.Boolean:
case ValueType.Float:
return count * 8
case ValueType.String:
return -1 // variable length
default:
return -1
}
}
/**
* Write a complete `.cidx` segment file to a Buffer.
*
* @param header - Segment header (magic and version are set automatically)
* @param values - Sorted value array
* @param entityIds - Parallel entity int ID array
* @param tombstones - Optional tombstone bitmap (positions that are logically deleted)
* @returns Complete segment as a Buffer
*/
export function writeSegmentToBuffer(
header: Omit<SegmentHeader, 'magic' | 'version'>,
values: (number | string | boolean)[],
entityIds: Uint32Array | number[],
tombstones?: RoaringBitmap32
): Buffer {
if (values.length !== (entityIds instanceof Uint32Array ? entityIds.length : entityIds.length)) {
throw new Error(`Values (${values.length}) and entityIds (${entityIds instanceof Uint32Array ? entityIds.length : entityIds.length}) must have the same length`)
}
const fullHeader: SegmentHeader = {
...header,
magic: CIDX_MAGIC,
version: CIDX_VERSION,
count: values.length
}
// Encode sections
const headerBuf = writeHeader(fullHeader)
const valuesBuf = encodeValues(values, header.valueType)
const idsBuf = encodeEntityIds(entityIds)
// Serialize tombstones
let tombstoneBuf: Buffer
if (tombstones && tombstones.size > 0) {
const serialized = tombstones.serialize(true) // portable serialization
tombstoneBuf = Buffer.from(serialized)
} else {
tombstoneBuf = Buffer.alloc(0)
}
// CRC32 covers header + values + entityIds + tombstones
const crcInput = Buffer.concat([headerBuf, valuesBuf, idsBuf, tombstoneBuf])
const checksum = crc32(crcInput)
const footerBuf = writeFooter(tombstoneBuf.length, checksum)
return Buffer.concat([headerBuf, valuesBuf, idsBuf, tombstoneBuf, footerBuf])
}
/**
* Parsed segment data from a `.cidx` file.
*/
export interface ParsedSegment {
header: SegmentHeader
values: (number | string)[]
entityIds: Uint32Array
tombstones: RoaringBitmap32
}
/**
* Read a complete `.cidx` segment from a Buffer.
*
* @param buf - Complete segment buffer
* @param validateCrc - Whether to validate the CRC32 checksum (default: true)
* @returns Parsed segment data
* @throws If magic/version mismatch, buffer too small, or CRC mismatch
*/
export function readSegmentFromBuffer(buf: Buffer | Uint8Array, validateCrc = true): ParsedSegment {
if (buf.length < HEADER_SIZE + FOOTER_SIZE) {
throw new Error(`Buffer too small for segment: ${buf.length} < ${HEADER_SIZE + FOOTER_SIZE}`)
}
// 1. Parse header
const header = readHeader(buf)
// 2. Parse footer (last 16 bytes)
const footerStart = buf.length - FOOTER_SIZE
const footer = readFooter(buf.slice(footerStart))
// 3. Calculate section offsets
const valuesStart = HEADER_SIZE
const idsSize = header.count * 4
const tombstoneSize = footer.tombstoneLength
// For fixed-size values, we know exact offsets
const fixedValuesSize = valuesColumnSize(header.count, header.valueType)
let valuesEnd: number
if (fixedValuesSize >= 0) {
valuesEnd = valuesStart + fixedValuesSize
} else {
// String values: entityIds start = footerStart - tombstoneSize - idsSize
valuesEnd = footerStart - tombstoneSize - idsSize
}
const idsStart = valuesEnd
const idsEnd = idsStart + idsSize
const tombstoneStart = idsEnd
const tombstoneEnd = tombstoneStart + tombstoneSize
// 4. Validate CRC
if (validateCrc) {
const crcInput = buf.slice(0, tombstoneEnd)
const computed = crc32(crcInput instanceof Buffer ? crcInput : Buffer.from(crcInput))
if (computed !== footer.crc32) {
throw new Error(`CRC32 mismatch: computed 0x${computed.toString(16)} != stored 0x${footer.crc32.toString(16)}`)
}
}
// 5. Decode values
const valuesBuf = buf.slice(valuesStart, valuesEnd)
const values = decodeValues(
valuesBuf instanceof Buffer ? valuesBuf : Buffer.from(valuesBuf),
header.count,
header.valueType
)
// 6. Decode entity IDs
const idsBuf = buf.slice(idsStart, idsEnd)
const entityIds = decodeEntityIds(
idsBuf instanceof Buffer ? idsBuf : Buffer.from(idsBuf),
header.count
)
// 7. Decode tombstones
let tombstones: RoaringBitmap32
if (tombstoneSize > 0) {
const tombstoneBuf = buf.slice(tombstoneStart, tombstoneEnd)
tombstones = RoaringBitmap32.deserialize(
tombstoneBuf instanceof Buffer ? tombstoneBuf : Buffer.from(tombstoneBuf),
true // portable format
)
} else {
tombstones = new RoaringBitmap32()
}
return { header, values, entityIds, tombstones }
}