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.
169 lines
4.9 KiB
TypeScript
169 lines
4.9 KiB
TypeScript
/**
|
|
* @module tail-buffer.test
|
|
* @description Tests for the per-field in-memory write buffer.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import { ColumnTailBuffer } from '../../../../src/indexes/columnStore/ColumnTailBuffer.js'
|
|
import { ValueType, DEFAULT_FLUSH_THRESHOLD } from '../../../../src/indexes/columnStore/types.js'
|
|
|
|
describe('ColumnTailBuffer', () => {
|
|
describe('numeric values', () => {
|
|
it('drain returns entries sorted by value', () => {
|
|
const buf = new ColumnTailBuffer('createdAt', ValueType.Number)
|
|
buf.add(300, 3)
|
|
buf.add(100, 1)
|
|
buf.add(200, 2)
|
|
|
|
const { values, entityIds } = buf.drain()
|
|
expect(values).toEqual([100, 200, 300])
|
|
expect(Array.from(entityIds)).toEqual([1, 2, 3])
|
|
})
|
|
|
|
it('breaks ties by entityIntId ascending', () => {
|
|
const buf = new ColumnTailBuffer('ts', ValueType.Number)
|
|
buf.add(100, 30)
|
|
buf.add(100, 10)
|
|
buf.add(100, 20)
|
|
|
|
const { entityIds } = buf.drain()
|
|
expect(Array.from(entityIds)).toEqual([10, 20, 30])
|
|
})
|
|
|
|
it('handles negative values', () => {
|
|
const buf = new ColumnTailBuffer('offset', ValueType.Number)
|
|
buf.add(-10, 1)
|
|
buf.add(5, 2)
|
|
buf.add(-20, 3)
|
|
|
|
const { values } = buf.drain()
|
|
expect(values).toEqual([-20, -10, 5])
|
|
})
|
|
})
|
|
|
|
describe('string values', () => {
|
|
it('drain returns entries sorted lexicographically', () => {
|
|
const buf = new ColumnTailBuffer('status', ValueType.String)
|
|
buf.add('pending', 3)
|
|
buf.add('active', 1)
|
|
buf.add('inactive', 2)
|
|
|
|
const { values, entityIds } = buf.drain()
|
|
expect(values).toEqual(['active', 'inactive', 'pending'])
|
|
expect(Array.from(entityIds)).toEqual([1, 2, 3])
|
|
})
|
|
})
|
|
|
|
describe('boolean values', () => {
|
|
it('sorts false (0) before true (1)', () => {
|
|
const buf = new ColumnTailBuffer('isActive', ValueType.Boolean)
|
|
buf.add(1, 1)
|
|
buf.add(0, 2)
|
|
buf.add(1, 3)
|
|
buf.add(0, 4)
|
|
|
|
const { values, entityIds } = buf.drain()
|
|
expect(values).toEqual([0, 0, 1, 1])
|
|
expect(Array.from(entityIds)).toEqual([2, 4, 1, 3])
|
|
})
|
|
})
|
|
|
|
describe('removal', () => {
|
|
it('removes pending entries from buffer', () => {
|
|
const buf = new ColumnTailBuffer('x', ValueType.Number)
|
|
buf.add(100, 1)
|
|
buf.add(200, 2)
|
|
buf.add(300, 3)
|
|
|
|
buf.remove(2)
|
|
|
|
const { values, entityIds, pendingRemovals } = buf.drain()
|
|
expect(values).toEqual([100, 300])
|
|
expect(Array.from(entityIds)).toEqual([1, 3])
|
|
expect(pendingRemovals.has(2)).toBe(true)
|
|
})
|
|
|
|
it('tracks pending removals for existing segments', () => {
|
|
const buf = new ColumnTailBuffer('x', ValueType.Number)
|
|
buf.remove(42)
|
|
buf.remove(99)
|
|
|
|
const { pendingRemovals } = buf.drain()
|
|
expect(pendingRemovals.has(42)).toBe(true)
|
|
expect(pendingRemovals.has(99)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('threshold', () => {
|
|
it('shouldFlush returns false below threshold', () => {
|
|
const buf = new ColumnTailBuffer('x', ValueType.Number, 3)
|
|
buf.add(1, 1)
|
|
buf.add(2, 2)
|
|
expect(buf.shouldFlush()).toBe(false)
|
|
})
|
|
|
|
it('shouldFlush returns true at threshold', () => {
|
|
const buf = new ColumnTailBuffer('x', ValueType.Number, 3)
|
|
buf.add(1, 1)
|
|
buf.add(2, 2)
|
|
buf.add(3, 3)
|
|
expect(buf.shouldFlush()).toBe(true)
|
|
})
|
|
|
|
it('uses default threshold when not specified', () => {
|
|
const buf = new ColumnTailBuffer('x', ValueType.Number)
|
|
expect(buf.threshold).toBe(DEFAULT_FLUSH_THRESHOLD)
|
|
})
|
|
})
|
|
|
|
describe('drain clears buffer', () => {
|
|
it('buffer is empty after drain', () => {
|
|
const buf = new ColumnTailBuffer('x', ValueType.Number)
|
|
buf.add(1, 1)
|
|
buf.add(2, 2)
|
|
expect(buf.size).toBe(2)
|
|
|
|
buf.drain()
|
|
expect(buf.size).toBe(0)
|
|
})
|
|
|
|
it('removals are cleared after drain', () => {
|
|
const buf = new ColumnTailBuffer('x', ValueType.Number)
|
|
buf.remove(42)
|
|
buf.drain()
|
|
|
|
const { pendingRemovals } = buf.drain()
|
|
expect(pendingRemovals.size).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('clear', () => {
|
|
it('clears entries and removals without returning data', () => {
|
|
const buf = new ColumnTailBuffer('x', ValueType.Number)
|
|
buf.add(1, 1)
|
|
buf.remove(99)
|
|
|
|
buf.clear()
|
|
expect(buf.size).toBe(0)
|
|
|
|
const { values, pendingRemovals } = buf.drain()
|
|
expect(values).toEqual([])
|
|
expect(pendingRemovals.size).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('multi-value (words)', () => {
|
|
it('supports multiple entries with the same entityIntId', () => {
|
|
const buf = new ColumnTailBuffer('__words__', ValueType.String)
|
|
buf.add('machine', 5)
|
|
buf.add('learning', 5)
|
|
buf.add('neural', 10)
|
|
buf.add('algorithm', 5)
|
|
|
|
const { values, entityIds } = buf.drain()
|
|
// Sorted lexicographically
|
|
expect(values).toEqual(['algorithm', 'learning', 'machine', 'neural'])
|
|
expect(Array.from(entityIds)).toEqual([5, 5, 5, 10])
|
|
})
|
|
})
|
|
})
|