Skip to content

Projection index storage redesign, vectorized serving generalization, and write-path speedups - #1131

Merged
JohannesLichtenberger merged 31 commits into
mainfrom
claude/projection-index-storage-redesign-i3849v
Jul 21, 2026
Merged

Projection index storage redesign, vectorized serving generalization, and write-path speedups#1131
JohannesLichtenberger merged 31 commits into
mainfrom
claude/projection-index-storage-redesign-i3849v

Conversation

@JohannesLichtenberger

@JohannesLichtenberger JohannesLichtenberger commented Jul 21, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Redesigns the projection-index storage layer and generalizes the vectorized serving layer on top of it, then closes with write-path performance work and a brackit dependency bump. 30 commits, staged with a design doc first and per-phase review rounds.

Storage redesign (P1–P8, docs/PROJECTION_INDEX_STORAGE_REDESIGN.md)

  • New PROJECTION_SEGMENT_PAGE (pinned page-kind id 18) with a HOT side-map commit chain; leaf descriptors (PIXD) + per-segment codecs replace the legacy chunked-values API (now removed).
  • Column-boundary chunking (per lessons from Parquet/DuckDB/ClickHouse), descriptor-tier row counts (zero-hydrate), streaming builder + per-segment maintenance, v1 migration and orphan recovery.
  • NUMERIC_DOUBLE columns via an order-preserving transform, ALP encoding for double BODY streams, FSST-compressed dictionary segments (symbol table parsed once per segment).
  • Docs: DISK_FORMAT.md projection section, KNOWN_LIMITATIONS.md, a technical deep dive with reading guide and glossary.

Serving generalization (projection fast paths in sirix-query)

  • Per-group aggregates: 1–5 group keys (string and/or integral numeric), count/sum/min/max/avg, first-appearance emission order, exact-sum-or-decline overflow policy.
  • Sorted scans: multi-key with per-key direction, stable document-order tiebreaks, lazy per-pull materialization, heap top-K when a sole-consumer fn:subsequence bounds the scan, provably-non-raising let bindings (numeric-proof gated).
  • Covered-row materialization incl. computed +,-,* record entries; computed aggregates over +,-,*,idiv,mod trees with exact arithmetic (overflow / zero divisor declines to the interpreter's promoting/raising semantics).
  • OR predicate trees for all shapes via a shared AND/OR mask evaluator with per-leaf zone downgrades.
  • Safety policy throughout: the generic brackit pipeline is always compiled alongside as runtime fallback — the fast path may only change cost, never answers. Differential suites assert parity including error outcomes, with served/declined counter proofs.
  • Hardening from multiple adversarial review rounds: extraction-time lossy-value poisoning (wrapped longs, double-typed cells), kind-gated predicate conversion, thread-confined record cursors, executor-boundary postfix-program validation, per-leaf dict-id/kind-drift guards.

Write path

  • Async bulk imports on the default backend: the default jn:load/jn:store path on 64-bit Linux/macOS (store default MEMORY_MAPPED) silently ran ~21 synchronous serialize+harden stalls per 2M-row import because the async pre-flush was gated to FILE_CHANNEL. KEEP_OPEN_ASYNC_FLUSH is now allowed on MEMORY_MAPPED too (both backends append through the same FileChannelWriter; reads of flushed offsets are ordered by the flush-permit handoff), the import gate consults the resource's actual storage type, and the background flush is parallelized (double-buffered 128-page windows pre-serialized on a small dedicated pool, -Dsirix.asyncFlush.parallelism). Pages whose serialization spills overlong records (>150 KB) into OverflowPages are exempted and promoted to the final recursive commit — flushing them froze NULL overflow keys into durable bytes (silent data loss, caught in adversarial review and regression-tested). Semantics: one import revision instead of parser-progress checkpoints; -Dsirix.import.asyncFlush=false restores sync mode. Measured on a 4-core box (median of 3, identical binaries): 2M-row shred 18.1s → 15.6s (~14%); 100M rows 371.5s vs 428.6s pre-optimization (~13%) — wider hosts recover more of the former ~24% commit share.
  • Sticky-winner codec election for the KEYVALUELEAFPAGE body bake-off: only probe pages (first 8 per thread, then every Nth, -Dsirix.codecBakeoff.probeInterval, default 16) run all three codecs; pages in between encode with the elected winner. The emitted codec byte keeps the format self-describing; probeInterval=1 restores exhaustive pick-smallest exactly.
  • Per-IndexType most-recent page-container slots in NodeStorageEngineWriter: interleaved index streams during shredding no longer thrash the shared two-slot LRU into access-ordered HashMap probes.

Dependency

  • io.sirix:brackit 1.0-alpha8 → 1.0-alpha9-SNAPSHOT, which carries the upstream DerefExpr sequence-dispatch fix (Fix DerefExpr sequence dispatch and iterator sharing brackit#109): (pipe).field over a parenthesized pipeline previously evaluated to empty without running the pipe. The temporary sirix-side port (SirixDerefExpr) is removed now that the fix ships upstream.

Related issue

No single tracking issue — this is the follow-on to the projection-index foundation merged in #1116, guided by docs/PROJECTION_INDEX_STORAGE_REDESIGN.md (first commit of this branch). The brackit half of the dependency change is sirixdb/brackit#109 (merged; deployed to the central snapshots repository by brackit CI).

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that changes existing behavior)
  • Performance improvement
  • Documentation only

Checklist

  • ./gradlew build passes locally (JDK 25) — full build is delegated to CI: the local environment cannot fetch the Gradle 9.4.1 wrapper distribution, so verification ran targeted suites on JDK 25 instead (core page/keyed-trie/async-commit/shredder tests, JsonIntegrationTest, ProjectionIndexCatalogServingTest, kernel parity + differential serving suites, sync-vs-async import content-parity incl. an overlong-record epoch-boundary fixture — all green). CI resolves 1.0-alpha9-SNAPSHOT from the central snapshots repository.
  • Added or updated tests covering the change
  • For behavioral changes to the storage engine: the relevant invariants in docs/formal-verification.md are still upheld — page/container lifecycle (§5) is unchanged (per-type slots are stamped only at the existing post-CoW site and invalidated at snapshot rotation, commit reset, and close; flush-window copies are closed on every path including failures), and crash recovery (§7) is unaffected (codec election never changes the wire format; async page appends are never referenced until the final commit publishes).
  • Updated documentation (README / docs) where relevant
  • No unrelated formatting churn (run Spotless / the pre-commit hook)

Notes for reviewers

  • Serving is decline-by-default. Every fast path double-checks its preconditions at executor boundaries (program validation, column-kind gates, integrality/provenance bits) and falls back to the generic pipeline on any doubt. The differential tests assert byte-identical answers including raised errors (e.g. FOAR0001 for idiv/mod by zero, overflow promotion to decimal).
  • Async import semantics (deliberate, documented in CHANGELOG.md): default Linux/macOS bulk imports now mint ONE revision and nothing is durable until the final commit — the sync mode's parser-progress checkpoint revisions (and their crash-survivability) are available via -Dsirix.import.asyncFlush=false. The rollback + -Dsirix.commit.preallocated=true offset-reuse interaction is documented in KNOWN_LIMITATIONS.md.
  • Determinism caveat (sticky codec election): with probeInterval > 1 the codec chosen for a page depends on per-thread serialization history, so stored bytes are not a pure function of page content (content round-trips identically; sizes may differ by a few bytes run to run). Golden-file byte comparisons must pin -Dsirix.codecBakeoff.probeInterval=1.
  • Known limitations (documented in KNOWN_LIMITATIONS.md / the deep dive): cross-field OR predicates are not yet annotated for serving by brackit's detection (single-field OR trees are); very large stores (100M-record scale) currently decline group-aggregate serving — under investigation, generic fallback semantics unaffected.

claude added 29 commits July 20, 2026 17:39
Documents the planned task #57 resolution: lift projection chunk bytes out
of HOT slot values into dedicated CoW-versioned chunk pages keyed from a
per-leaf ChunkDirectory, keeping HOT slots tiny. Pins down the as-built
interim layout (composite-key 4 KB chunking, slot-0 metadata, PIXC/PIX1/PIXM
encodings), the redesign's write/read paths and required commit-walk work,
a catalog of invariants to preserve and new hazards (commit-order reference
resolution, GC reachability, cache epoch gating), migration via metadata
version bump, and the test plan.
…ckDB/ClickHouse

Replace byte-offset chunk pages with semantic segments behind the per-leaf
directory: record-key segment, one body segment per column (presence bitmap
at the head), separate dictionary segments for string columns, and
per-segment stats/flags mirrored into the directory entry for I/O pruning.

Documents the byte-shift cascade defect of byte-offset chunking over
bit-packed encodings, the Parquet/DuckDB/ClickHouse design rules that
transfer (and the update stories that deliberately don't), the deliberate
1024-row group divergence, new hazards (fragmentation tax, stats mirror vs
segment truth, dict/id cache coherence), and corrects the earlier draft's
conflation of per-column HOT slots with per-column segment pages.
…e, phased plan

Reviewed the design against the as-built code from four angles (page/commit
machinery, query/serving, build/maintenance, float+FSST integration) and
corrected every finding:

- OverflowPage is a working template for reference-bearing values; adopt
  its side-map design (refs outside slot bytes, offset identity, write-at-
  commit key resolution) instead of parsing directories in the commit walk.
  The hook list is now precise: HOTLeafPage.commit override, writer branch,
  HOT serializer refs section (sparse-emit correct), fragment-merge carry,
  PageReference.getPage guard - not combineRecordPages.
- Sirix has no page reclamation; the GC hazard is reframed as append-only
  growth and the migration age-out claim removed.
- Slot values become a LeafDescriptor (PIXD) carrying the raw-form header,
  per-segment byteLen + XXH3-64 contentHash (no-op carry-forward test and
  integrity check in one), and per-column stats/flags.
- Corrected the build-path description (heap-buffered, slot 0 first,
  putFromSegment is dead code) and scoped update-containment honestly:
  single-segment rewrites hold for in-place updates only; deletes/appends
  re-encode the whole leaf; rebuilds become share-friendly.
- New hazards from review: live zero-row leaf vs tombstone, sparse
  dirty-entry emit, dict/id cache coherence (Handle.canonicalDicts),
  kernel heap-identity constraint (+4.5% MemorySegment regression).
- NUMERIC_DOUBLE via ALP and FSST dictionaries promoted from open questions
  to in-scope (sections 2.6/2.7): order-preserving transform-domain storage
  so predicate kernels and zone maps work unchanged, NOT_VALUE_EXACT
  provenance decoupled from long integrality, FSST confined to the
  persisted DICT segment (kernels compare raw bytes pervasively).
- Detailed implementation plan P0-P8 with per-phase files, tests, exit
  criteria, and a risk register.
…ps, roadmap)

New section 8 grounds the OLAP competitiveness intent in the repo's own
benchmarks: the 100M DuckDB head-to-head (ahead on 3/9 shapes, 1.1-2.5x on
all but count-distinct) and the 10M ClickHouse results; analyzes the scale
inversion on dictionary shapes (per-leaf amortization at 97k leaves) and why
DICT segments are the prerequisite for fixing it; taxonomizes the gaps
(coverage cliff, amortization, type coverage, build cost, native caveats)
and maps each to a phase, a follow-up, or explicit out-of-scope; documents
the versioned-analytics structural advantage; sequences the post-redesign
roadmap (R1 store-level canonical dictionaries, R2 kernel breadth incl.
temporal-analytical kernels, R3 morsel-style parallelism); and sets
measurable pass/fail bars per milestone in 8.7. P8 gains the benchmark exit
artifact (re-run DuckDB protocol, first ClickHouse run at 100M). Sections
renumbered 9-11.
ProjectionSegmentPage (PageKind 18, OverflowPage-shaped: opaque bytes,
offset identity, leaf of the commit recursion) plus the HOT side-map
commit chain from the storage redesign doc (section 2.4):

- HOTLeafPage gains a commit override descending into side-map refs,
  deep-copied refs in copy() (CoW discipline), removePageReference,
  sorted-key helpers, and split-time ref routing: all four split
  variants route side-map entries to the page that received their
  owning slot (owner-slot residency, correct for non-contiguous
  disc-bit partitions).
- HOT_LEAF_PAGE serialization writes a trailing segment-refs section
  behind a new envelope flag bit; every fragment carries the complete
  map so the newest fragment is authoritative under fragment merge.
  Unresolved refs at serialize time fail loudly. New flags-aware
  envelope helpers keep the strict path for other page kinds.
- NodeStorageEngineWriter.commit writes in-memory segment pages and
  assigns their offset keys inside the commit descent (overflow
  discipline); resolved refs carried forward are no-ops.
- StorageEngineReader.readProjectionSegmentPage default + reader
  implementation with reference swizzling; writer delegates with
  in-memory-first resolution.
- ProjectionIndexHOTStorage segment API (putSegmentPage,
  removeSegmentPage, getSegmentPageBytes, readSegmentPageBytes) using
  the (ownerSlotKey << 8 | segmentId) side-map convention.
- ProjectionSegmentPageCommitTest: commit + cold-reopen round trip,
  deep split cascades with early-attached refs, second-commit sparse
  fragment merge (replace/carry/remove + time-travel isolation),
  rollback never writes.

Projection, HOT, and page suites green (2706 tests).
20+ candidates from four finder angles, verified and applied:

- Side-map key convention promoted to a single contract on HOTLeafPage
  (segmentRefKey/segmentRefOwnerSlot) with loud validation: segmentId
  range-checked at every call site (was: put only — remove/get/read
  silently aliased out-of-range ids onto other segments) and ownerSlotKey
  checked against the <<8>>8 round-trip (silent collision + sign-extended
  mis-routing after splits for |key| >= 2^55).
- putSegmentPage now requires the owner slot to exist on the target leaf:
  a ref attached without its owning slot is permanently orphaned after
  the next split (routing is by owner-slot residency) — durably committed
  but unreachable, with no error.
- removeSegmentPage probes read-only first: the unconditional
  prepareLeafOfTree CoW'd unchanged leaves into the TIL (spurious
  fragments per commit) and created a root leaf on an empty trie.
- Loud backstops on every uninstrumented leaf-rebuild path that harvests
  (key,value) pairs and discards the source leaf: subtree rebuild +
  strand migration + leaf-scoped rebuild (AbstractHOTIndexWriter),
  incremental split (HOTIncrementalInsert), phase-4 subtree merge and
  hoist-and-reroute (HOTTrieWriter, hot.strict.binna-gated). All verified
  unreachable for projection trees today; if wiring ever changes they now
  fail attributably instead of silently dropping committed segments.
- splitTo mid-loop put failure now aborts the split cleanly (target
  reset, source untouched) instead of truncating the source and dropping
  the failed entry from BOTH halves.
- Segment-page deserialization validates the stored length (corrupt
  offsets fail as a clean error, not NegativeArraySizeException/OOM);
  MAX_SEGMENT_BYTES enforced symmetrically at construction. Reader-side
  resolution type-checks the loaded page (bare offset refs carry no kind
  tag) instead of blind-casting.
- Forwarding reader base class delegates readProjectionSegmentPage (the
  throwing interface default would surface at the first committed read
  through any wrapper).
- Duplicate writer commit branches merged; KEY_BUFFER reused on the
  static read path (HFT: no per-call byte[8]); explicit imports per
  CLAUDE.md; zero-copy no-mutate contracts documented.

Projection + HOT + page suites green (2706 tests).
Segment-directory codec layer from the storage redesign doc (section 2.3):

- LeafDescriptor: the tiny HOT slot value — raw-form header (rowCount,
  columnCount, kinds, record-key fences) plus fixed 30-byte entries per
  segment carrying exact byteLen, XXH3-64 contentHash (write-path no-op
  comparator and the segments' only checksum), and the per-column
  stats/flags mirror. Positional allocation-free readers for the scan
  path; structural validation fails loudly; 84-column cap enforced
  (8-bit segmentId space).
- ProjectionIndexSegmentCodec: splits a raw leaf into semantic segments
  (KEYS with fences + delta/FOR keys; per-column BODY with flag truth at
  the head, zone maps, marker-byte presence, FOR/verbatim/packed-id
  values; per-string-column DICT), each self-describing (PIXS magic +
  version + kind). assembleRaw reconstructs the raw scan form
  byte-identically, verifying every segment's length + hash against the
  descriptor at fill time. Empty leaves emit KEYS + BODYs (flag truth),
  no DICTs. Encoding primitives shared with ProjectionIndexLeafCodec
  (widened to package-private, no logic change) - same wire streams,
  regrouped.
- bodySegmentFlags: column-scoped provenance primitive reading segment
  truth, per the mirror discipline.

15 new tests: byte-identity round trips (bench-shaped 1024-row, empty,
single-row, word boundaries, non-ascending + extreme values, 48-64-bit
width sweeps, UTF-8 and multi-KB dictionaries, real-empty-string vs
phantom), descriptor mirror equals segment truth, deterministic
hashes (no-op comparator contract), corrupted/truncated/missing segment
fail-loud, column cap.
Applied all 12 verified findings from the two-angle P2 review:

- Deduplicated every shared wire authority into ProjectionIndexLeafCodec:
  string-dict encode split into encodeDictEntries/encodeDictIds halves
  (used by both the monolithic and segmented codecs), shared decode
  helpers (decodeForBitPackedColumn, decodeBooleanWords,
  decodeDictEntries, decodePackedIds, decodePresenceInto) replacing the
  copy-pasted arms in decode() and assembleRaw, and LeafDescriptor now
  uses the codec's LE getters. One authority per encoding - drift can no
  longer break the segmented-vs-monolithic byte-identity guarantee.
- Empty-leaf descriptor mirror writes the min>max sentinel pair instead
  of a fabricated [0,0] range that would defeat descriptor-only pruning
  (and read as 'possibly contains 0').
- Live-empty-leaf representation reconciled with the design doc: the doc
  now specifies KEYS + per-column BODY flag-truth carriers (5.1-7) with
  no DICTs, not segCount=0.
- MAX_COLUMNS derived from HOTLeafPage.MAX_SEGMENT_ID and a shared
  SEGMENTS_PER_COLUMN constant (single authority for the 8-bit id-space
  invariant); bodySegmentId/dictSegmentId range-check the column so ids
  can never silently wrap past 255 onto another column's segment.
- LeafDescriptor.serialize validates segCount range, parallel-array
  lengths, and non-negative byteLens (a corrupt length is refused at
  write time instead of poisoning every later assembly).
- encode(null) returns null (absent-leaf symmetry with the monolithic
  codec); bodySegmentFlags guards the truncated-6-byte-segment case
  (IllegalStateException, not AIOOBE); EncodedLeaf aliasing contract
  documented (HFT, no defensive copies).

Projection suite green (171 tests).
The segment-directory storage layer (redesign doc sections 2.3/3/4),
added alongside the legacy chunked paths until the builder/maintenance/
catalog callers migrate (P4/P5) - the layouts never share a store:

- putLeaf: encode via the segment codec, carry forward every segment
  whose (byteLen, XXH3) matches the prior descriptor entry (CoW share by
  reference, no page write), write changed/new ProjectionSegmentPages,
  drop refs of vanished segments (real deletes), store the descriptor as
  the slot value (plain leafIndex keys).
- tombstoneLeaf (zero-length slot + ref removal) vs live empty leaf
  (rowCount-0 descriptor) - distinct states, both tested.
- getLeaf/readLeaf/readAllLeaves: assemble the byte-identical raw form
  through the side map, tombstone-skipping, ordered enumeration across
  split topologies; mixed-layout slots fail loudly.
- putBlob/getBlob/readBlob: slot-0 metadata path - tiny PIXB marker
  (byteLen + XXH3) in the slot, whole payload as one segment page,
  verified on every read (metadata can reach MBs once per-leaf fences
  scale; tested at 1.5 MB).
- segmentPageOffset diagnostic: exposes the durable offset key so tests
  and P8 measurements can prove sharing, not just byte-equality.

Storage-level containment proof (the SLIDING_SNAPSHOT claim, doc 2.5):
a single-column edit across two commits shares KEYS + every other BODY +
DICT by identical disk offsets and re-writes exactly one BODY page.
Shrink-grow-shrink never resurrects stale segments; time travel serves
each revision's own bytes.

Projection suite green (178 tests).
Applied all 12 verified findings from the two-angle P3 review:

- tombstoneLeaf handles blob slots: the PIXB marker failed the descriptor
  check, so the blob's MB-scale segment ref was never removed - a
  permanent storage leak carried into every future fragment. Also
  no-ops on truly absent slots (a tombstone insert CoW'd unchanged
  leaves and grew the trie for nothing).
- putLeaf validates the side-map key precondition BEFORE writing the
  descriptor slot: failing mid-attach used to leave a poisoned
  descriptor that a same-trx retry would carry-forward against
  (hashes match) and skip attaching every segment.
- putBlob gained the carry-forward no-op: an unchanged blob (the
  steady-state metadata case) previously rewrote the full payload page
  every commit, defeating CoW sharing for the largest payload in the
  index. Regression test proves sharing by disk offset and the
  tombstone ref removal.
- readAllLeaves fails as loudly as the point reads on mixed-layout
  slots (silent skipping masked exactly the corruption readLeaf
  catches) and peeks the magic from the slice before copying (no heap
  copy for blob/tombstone slots).
- updateOrSplitInsert diagnostic is layout-agnostic (raw key + legacy
  composite interpretation) instead of mis-decoding descriptor slot
  keys as chunk coordinates.
- Write path stops re-hashing carried-forward segments (reads the
  hashes encode() already stored in the new descriptor); blob marker
  written via the shared LE writers; navigation preamble and
  descriptor-guard deduplicated into navigateToSlotLeaf and
  isLiveDescriptor.

Projection + HOT + page suites green (2729 tests).
The persisted projection format flips as one unit — builder, incremental
maintenance, catalog reads, creation/drop functions, and the bench setup
all move to descriptors + segment pages; ProjectionIndexMetadata.VERSION
bumps to 2 so pre-descriptor (chunked) stores degrade to rebuild-on-use
per the migration plan (no dual-format reads, no value sniffing).

- ProjectionIndexBuilder: truly streaming build - each leaf is written
  (putLeaf) the moment the builder emits it, one leaf in memory instead
  of buffering every encoded leaf (~240 MB at 100 M rows); fences
  accumulate as two longs per leaf; orphaned slots above the new leaf
  count are tombstoned (real deletes); metadata blob written last.
- ProjectionIndexChangeListener: touched-leaf reads via getLeaf (raw
  form, no decode step), writeLeaf via putLeaf - the hash carry-forward
  makes untouched-column sharing automatic and full rebuilds
  share-friendly; metadata refresh and the corruption valve write the
  slot-0 blob; legacy/corrupt slot-0 payloads degrade to the ladder
  instead of throwing mid-commit.
- ProjectionIndexCatalog: probe via readBlob (legacy stores fall to
  UNUSABLE -> rebuild), hydrate via readAllLeaves (already raw; serial
  cursor safe for wtx reads), truncated-store check counts leaves.
- CreateProjectionIndex.hydrateLegacy reads the new layout and treats
  pre-break stores as rebuild-fresh; DropProjectionIndex tombstones via
  the blob path.
- ScaleBenchProjectionSetup persists via putEncodedLeaf (new split of
  putLeaf exposing encoded sizes without a second codec pass) + metadata
  blob; probe reads blob + raw leaves.

sirix-core projection suite and all sirix-query projection suites green
on the new format (incl. ProjectionIndexStressTest soak and the
TypedGroupByDifferentialTest byte-identity battery).
All 6 findings from the P4 review, verified and applied:

- The v1->v2 migration path actually works now: a rebuild over a legacy
  (chunked) store RESETS the definition's sub-tree
  (ProjectionIndexPage.resetProjectionIndexTree + storage.resetTree) -
  selectively clearing is impossible because composite chunk keys would
  poison descriptor enumeration with mixed-layout errors forever,
  permanently un-serving the index while metadata looked valid.
  Regression test pins reset + fresh build + time-travel to the legacy
  revision.
- priorLeafCount recovers the pre-invalidation leaf count by probing
  live descriptor slots when metadata is a stale tombstone (the
  tombstone carries leafCount=0, so re-creates after invalidate/drop
  left live orphan descriptors above the new count forever).
- readAllLeaves enforces the slot-contiguity invariant (5.1-11) loudly -
  positional hydration against metadata fences would silently mislabel
  every leaf after a gap - and restores parallel hydrate: phase-2
  assembly fans out across the common pool resolving segment pages by
  durable offsets through throwaway references (no page instances or
  cursors shared between threads); uncommitted reads and small stores
  stay serial. The catalog passes its parallelHydrate flag through
  again.
- Bench probe reads the metadata blob first and unconditionally: a
  catalogued EMPTY projection (leafCount=0) now hydrates instead of
  being rebuilt every run - the rebuild used to clobber the catalogued
  definition's metadata with the bench shape, turning the def UNUSABLE.
- ProjectionPersistForceRebuildTest migrated to the descriptor layout
  (it silently exercised the rebuild path instead of hydration).
- Metadata VERSION javadoc updated - v1 databases exist and are exactly
  what the migration gate recovers; the degrade path is load-bearing.

Core + query projection suites green; full sirix-core + sirix-query
suite run green in background.
… bumps

Floating-point projection columns land end-to-end (redesign doc 2.6),
decoupled from the long-integrality machinery:

- COLUMN_KIND_NUMERIC_DOUBLE stores the sortable-bits involution of the
  double (negatives flip their low 63 bits), making cells
  order-isomorphic to signed longs - zone maps, zoneSkip, the numeric
  predicate kernels (with plan-time-transformed literals), FOR
  bit-packing, and both codecs work unchanged via kind fall-throughs.
  ProjectionDoubleEncoding pins the transform with 70k-case
  order-isomorphism/involution/sentinel property tests.
- Extraction converts exactly (Double/Float/Integer widen losslessly;
  Long round-trip-checked; Big* via exact BigDecimal compare) and
  reuses the shared provenance bit with kind-dependent semantics: for
  double columns COLUMN_FLAG_NON_INTEGRAL means 'a stored cell is NOT
  value-exact', so numericColumnIsIntegral doubles as the provably-
  value-exact gate with zero format changes. Non-finite values are
  defensively unrepresentable (no collision with zone-map sentinels).
- jn:create-projection-index accepts double/float/decimal; the type
  mapping routes DEC/DBL/FLO to NUMERIC_DOUBLE (previously silently
  truncated to longs on non-user paths, rejected on user paths).
- conjunctiveAggregateNumericDouble kernel (mask flow shared, two-op
  decode per matching row) wired through the executor at all three
  aggregate call sites, surfacing xs:double via Dbl; per-thread
  partials merge in ascending chunk order for run-to-run-deterministic
  double summation. Fail-closed gates preserved: not-value-exact or
  sparse-unclean columns decline to the generic pipeline (parity tests
  hold either way, by construction).
- Metadata VERSION stays 1 per project convention (no deployed
  databases): the v1->v2 layout migration gate is structural (blob
  marker check + resetTree), not version-based; doc section 6 updated.

Core + query projection suites green (incl. new double end-to-end
function tests and the repurposed type-acceptance test).
…scape

- DICT segment payloads gain a mode byte: RAW (byte-compatible with the
  monolithic codec's dictionary half) or FSST - a per-segment symbol
  table followed by per-entry FSST streams, taken only when the
  dictionary passes FSSTCompressor's existing gates AND measurably
  compresses (isCompressionBeneficial). FSST lives in the persisted form
  only: decode restores plain UTF-8 dictionary bytes, so the raw scan
  form and every kernel comparing dictionary bytes raw are untouched
  (doc section 2.7). Training input is the dictionary in interning order
  - deterministic, so identical re-encodes hash identically and the
  carry-forward no-op contract (5.2-n) holds, pinned by test.
- Tests: high-cardinality URL dictionary round-trips byte-identically
  and shrinks >2x; escape-heavy (0xFF-laden) adversarial input takes the
  RAW fallback and round-trips; small dictionaries stay RAW; stale
  pre-FSST size assertion updated.
- FOR numeric decode rejects width bytes above 64 as reserved escapes,
  making future numeric encodings (ALP for double columns) additive
  without version machinery - old readers fail attributably instead of
  misparsing packed bits.

Core + query projection suites green.
Predicate extraction assumed long columns: OP_NUM_CMP emitted the raw
long literal and OP_FP_CMP rewrote double thresholds into long space -
both would compare untransformed literals against the order-preserving
transformed cells of a NUMERIC_DOUBLE column and silently match the
wrong rows (the kind fall-through in evalNumericBytes made the kernels
reachable). Extraction now branches on the column kind: double columns
transform the literal (ProjectionDoubleEncoding) and keep the operator
(the transform is order-isomorphic, no threshold rewrite), gate on
provably-value-exact (a lossy Big*->double cell differs from the source
the interpreted pipeline compares exactly), and decline NaN literals
and integer literals beyond 2^53. Regression test drives both literal
shapes through filtered aggregates with the interpreted pipeline as
oracle.
Align the DICT segment codec with KeyValueLeafPage's per-page FSST
discipline: parse the symbol table once and run every entry through the
parsed-symbol encode/decode overloads. The byte[]-table overloads
re-parse the table per entry - O(dictSize x tableLen) pure waste on both
the build and hydrate paths. Wire format unchanged (round-trip and
determinism tests pin byte-identity).
- DISK_FORMAT.md gains the projection on-disk format (previously
  undocumented outside class javadoc, a gap the redesign doc called
  out): descriptor/blob slot values, side-map refs section, segment
  pages and ids, per-kind segment wire forms, double-transform and
  legacy-store detection.
- KNOWN_LIMITATIONS.md: double-column support entry (value-exactness
  fail-closed gate, ALP as a reserved escape follow-up) and the
  legacy-store structural-migration entry.
- README projection section: segment-directory storage blurb, double/
  decimal column types.
Records what landed on this branch (P0-P4, P5 parity, P6 sans ALP
compression, P7, P8 docs), what is deferred with rationale (P5b columnar
restructure, ALP behind the reserved escapes, bench-machine scale
gates), and renumbers the open questions accordingly.
…ly double aggregates

From the P6 adversarial review:

- CRITICAL: OP_DEC_CMP predicates on double columns compared the
  collapsed long-space arm (x > 2.5 => L >= 3) against transformed
  cells, silently matching every row ('price > 2.5' is the most natural
  literal shape - fractional decimals compile to DecCmp). XQuery
  promotes xs:decimal to xs:double against double values, so the
  transformed PROMOTED literal with the original operator is exact
  parity. Regression test pins the fixed sum (12.125) and count.
- Long.MAX_VALUE exactness edge: its doubleValue rounds up to 2^63 and
  the narrowing cast saturates back, so the round-trip check falsely
  certified it value-exact (stored double off by one on the very path
  whose gate promises exactness). Explicitly lossy now.
- Double-column aggregate serving restricted to count: plain JSON
  decimals shred as BigDecimal and the fallback accumulates
  decimal-exactly (Dec results), so double-kernel sum/avg/min/max
  cannot guarantee digit-and-type parity - the differential oracle
  forbids serving them. Predicates and counts serve; the lift path
  (pure-double-source provenance bit, additive) is documented in the
  redesign doc (11-8) and KNOWN_LIMITATIONS.
- doubleStatsToSequence relocated after longStatsToSequence (javadoc
  was misattached), FQN and import-group conventions fixed, stale
  'v2 metadata' comment reworded.
The golden format test exists to force conscious acknowledgment of on-disk
format additions; the projection segment page (PageKind 18, redesign doc
§2.3) is such an addition.
End-to-end walkthrough of the projection index on the segment-directory
layout: logical model, page architecture, wire formats (PIXD/PIXS/PIXB/PIXM)
with a worked example encoded byte-by-byte, commit chain, hash-based no-op
sharing, read path and serving gates, incremental maintenance ladder,
double-column transform, FSST dictionaries, migration, and performance
positioning — with diagrams throughout.
Make the document approachable for readers who are not storage engineers:
a per-audience reading table, a primer on the five foundational ideas
(columnar layout, dictionary encoding, zone maps, FOR bit-packing, CoW
versioning), and a glossary of the SirixDB-specific vocabulary.
The segment-directory layout (descriptors + segment pages) is the only
production format; the chunk machinery had zero production callers. Gone:
CHUNK_SIZE and its class-load guard, chunk put/get/readAll and the depth-2
parallel scan, LeafChunkAccumulator, composite chunk keys, the dead off-heap
putFromSegment path and serializeIntoSegment, and the two test classes whose
subject was that machinery (surviving invariants are covered by
ProjectionIndexDescriptorStorageTest, plus a new empty-store test).

Migration tests fabricate the pre-redesign layout byte-for-byte through a
package-private writeSlotValue seam instead of the removed API.

ProjectionIndexHOTStorage: 2072 -> 866 lines; class javadoc now documents
the segment-directory contract instead of the interim chunked one.
COLUMN_FLAG_PURE_DOUBLE_SOURCE (flags bit2, additive): set at extraction only
when every present double cell came from a Double source — Integer/Long/Big*
clear it (the fallback types those Dec), Float clears it (the fallback types
Flt and accumulates in float arithmetic). Carried through the PIX1 tail, BODY
segments, and the descriptor mirror; probed AND-across-leaves with a
fail-closed malformed-payload contract.

Under the bit the executor serves sum/avg/min/max over double projection
columns; without it, count-only as before. Serving arithmetic reproduces the
interpreter bit for bit — adversarial review drove three corrections:
- sum/avg use a SEED-FIRST document-order fold: a zero-seeded fold absorbs a
  lone -0.0 (0.0 + -0.0 == +0.0), and delegation to brackit's aggregator was
  empirically refuted (its batched double fast path never engages for the
  document pipeline's non-Dbl items); [1e16, 1, 1] pins the association order.
- min/max use Double.compare total order in the kernel (-0.0 < 0.0), matching
  the interpreter's comparator where IEEE < / > treats them equal.
- purity is checked before any leaf work (impure columns decline at metadata
  cost; count paths skip the probe entirely), and served results are per-func
  cached ahead of the MixedAgg cache so a hydration-race MixedAgg can never
  shadow served digits.

Also review-driven: resolve-once DoubleAggServing record replaces the acc[4]
purity smuggling; reconstruct takes the persisted flags byte verbatim instead
of three exploded boolean[]s; probeDoublePureSource keeps all payload reads
inside its fail-closed try; null-func guard.

New ProjectionDoubleAggregateServingTest pins serving on pure columns,
count-only declines for decimal- and integer-fed columns, ill-conditioned
sums, and every ±0.0 edge — each against the interpreter's serialized output.
ProjectionAlpEncoding: per leaf-column vector, deterministic two-stage decimal
scale-pair selection (32-cell sampled shortlist over the 190 (e,f) candidates,
full-vector verification), digits FOR-packed via the shared codec primitives,
verbatim exception list (capped at rowCount/8), and a strictly-smaller-than-
plain-FOR profitability bar — price-like decimal data drops from ~60-bit
transform patterns to ~10-bit digits; non-decimal data falls back to the plain
FOR form byte-identically to before.

Losslessness is a per-cell proof: every value is verified bit-exact at encode
time against the exact decode expression (digits * 10^f) / 10^e — the trailing
correctly-rounded DIVISION is load-bearing (a reciprocal multiply misses about
half of all k/10^n decimals; the ALP test caught this immediately), and -0.0
routes to the exception list since its digits decode to +0.0.

Wire safety: escape 65 is only ever written by the double encoder; 66-255 stay
loudly-rejected reserved escapes; an escape byte inside an ALP digits stream is
corruption (decodePlainForBitPacked), pinned by a nested-escape test; corrupt
scale pairs, exception counts, and rows all reject. Adversarial review passed
with two test-coverage gaps, both closed (escape-taken assertion in the -0.0
test; nested-escape rejection case).

Determinism end to end keeps the descriptor-hash no-op carry-forward sharing
ALP segments across revisions.
sumLiveDescriptorRows walks the projection trie reading only the ~30-byte
PIXD slot values — no segment pages — with the same contiguity (5.1-11) and
metadata-leafCount truncation checks as a full hydrate, so a descriptor-tier
answer can never disagree with what hydration would count. The catalog
serves unpredicated record counts through it (countRowsFromDescriptors, own
DESCRIPTOR_STATS cache keyed like DATA, enumerated by invalidateUnder and
clearCache), and executeAggregate carries a defensive field-less count
branch. No current query shape reaches the executor with a field-less count
(bare array counts are not pipeline-intercepted), so the catalog API is the
load-bearing surface — pinned by a test proving count parity with the
hydrate tier while the DATA cache stays empty.
One cache not two (segment-lazy Handle v2 inside DATA, not a sibling
column cache); Caffeine fixed-at-insert weights force pessimistic
full-projected-size weighing (the win is cold I/O, not resident bytes);
segment-scoped kernels migrate one at a time behind the differential
suite, numeric/boolean predicates first (string predicates wait for the
R1 canonical-dictionary layout).
…dups

Serving (projection-index fast paths, always with the generic pipeline
compiled alongside as runtime fallback — serving changes cost, never
answers):
- Per-group aggregates: 1-5 group keys (string and/or integral numeric),
  count/sum/min/max/avg, first-appearance emission order, composite
  missing-key components, exact-sum-or-decline overflow policy
- Sorted scans: 1-N order keys with per-key direction, stable
  document-order tiebreaks, lazy per-pull record materialization,
  heap-based top-K when a sole-consumer fn:subsequence bounds the scan,
  provably-non-raising let bindings (numeric-proof gated)
- Covered-row materialization: record-constructor returns incl. computed
  +,-,* entries, interpreter-parity empty-sequence semantics for missing
  fields, eager-materialization row cap
- Computed aggregates: sum/avg/min/max/count over +,-,*,idiv,mod trees of
  covered numeric fields with exact arithmetic (overflow/zero-divisor
  declines to the interpreter's promoting/raising semantics)
- OR predicate trees for all of the above via a shared AND/OR mask
  evaluator (per-leaf zone downgrades, missing=>false leaf algebra)

Correctness hardening from adversarial review rounds:
- Extraction-time value-exactness: out-of-range integers and double-typed
  cells in long columns poison the integrality bit (wrapped longs and
  double-space folds can no longer serve)
- Kind-gated predicate conversion (string-EQ/bool-EBV/numeric arms decline
  on column-kind mismatch instead of scanning unrelated encodings)
- Thread-confined sorted-scan record cursors (per-thread read trx)
- Executor-boundary postfix-program validation, per-leaf dict-id and
  kind-drift guards, packed-key bounds, MAX_GROUP_COLUMNS cap
- Fixed Brackit DerefExpr sequence dispatch via SirixDerefExpr port:
  (pipe).field over parenthesized pipelines evaluated to empty without
  running the pipe; fresh iterator per iterate() call

Write path:
- Sticky-winner codec election in the page-body bake-off (probe all
  codecs every Nth page, run only the elected winner in between;
  ~10% shred speedup, storage unchanged, probeInterval=1 restores
  exhaustive pick-smallest)
- Per-IndexType most-recent page-container slots (interleaved index
  streams no longer thrash the shared LRU pair into HashMap lookups)

Tests: differential serving suites (parity incl. error outcomes, served/
declined counter proofs), randomized kernel parity vs naive oracles,
composition probes for empty-vs-null semantics, overflow/lossy-value/
type-mismatch regression fixtures.
… per-IndexType container slots

- Bump io.sirix:brackit to 1.0-alpha9-SNAPSHOT (main + tests artifacts),
  which carries the upstream DerefExpr sequence-dispatch fix
  (sirixdb/brackit#109): (pipe).field over parenthesized pipelines now
  evaluates the pipe and iterates freshly per pass.
- Drop the temporary SirixDerefExpr port and its translator override —
  redundant now that the fix ships in brackit itself.
- Sticky-winner codec election for the KEYVALUELEAFPAGE body bake-off:
  only probe pages (first 8 per thread, then every Nth,
  -Dsirix.codecBakeoff.probeInterval, default 16) run all three codecs
  and (re-)elect a winner; pages in between encode with the elected
  codec alone (~3x less encode work). The codec byte keeps the format
  self-describing; probeInterval=1 restores exhaustive pick-smallest
  for byte-identical golden files. Both body writers (template-dedup
  and inline) now share one emit helper instead of two copies.
- Per-IndexType most-recent page-container slots in the storage engine
  writer: interleaved index streams during shredding no longer thrash
  the shared two-slot LRU into access-ordered HashMap probes. Slots are
  stamped only at the post-CoW site (never frozen), and invalidated at
  snapshot rotation, commit reset, and close.
@JohannesLichtenberger JohannesLichtenberger changed the title Add projection-index storage redesign doc with corner-case catalog Projection index storage redesign, vectorized serving generalization, and write-path speedups Jul 21, 2026
claude added 2 commits July 21, 2026 19:51
The default jn:load/jn:store path on 64-bit Linux/macOS (BasicJsonDBStore
defaults to MEMORY_MAPPED) silently fell back to SYNCHRONOUS intermediate
auto-commits: the async pre-flush was gated to FILE_CHANNEL, so a 2M-row
import stalled the insert thread ~21 times for full serialize+harden
cycles (~24% of wall time profiled). Both backends append through the
same FileChannelWriter, and a write transaction's storage reads are
ordered after the background flush via the flush-permit handoff, so the
gate was validation conservatism, not a capability boundary.

- Allow KEEP_OPEN_ASYNC_FLUSH on MEMORY_MAPPED (KEEP_OPEN_ASYNC_COMMIT
  stays FILE_CHANNEL-only until mid-transaction revision publication is
  validated against remapping mapped readers); document the ordering
  argument at the guard.
- beginImportTrx decides from the RESOURCE's configured storage type,
  not the store default — per-call overrides to non-capable backends
  must fall back to sync, never fail the import.
- Parallelize the background snapshot flush: double-buffered sliding
  windows (128 pages) pre-serialize deep copies on a dedicated
  bounded pool (-Dsirix.asyncFlush.parallelism, default 2 — full fan-out
  halves insert throughput via memory-bandwidth contention) while the
  flush thread appends the previous window's cached bytes in snapshot
  order. A single-threaded flush could not keep pace with rotations,
  turning the permit backpressure into a near-synchronous stall.
- Exempt pages with unresolved overflow references from the background
  flush: their durable image needs OverflowPage disk keys that only the
  recursive final commit assigns — flushing froze NULL overflow keys
  into the stored bytes and silently lost overlong records (>150 KB
  values). Such pages are promoted back into the live TIL via a new
  side-channel sentinel and committed with real keys at the end
  (regression-tested with a 200 KB value crossing an epoch boundary).
- Review hardening: parallel-stream leaves capture the first failure
  instead of throwing (an exceptional root releases joiners while
  sibling leaves still run — the join must fence every leaf before
  cleanup scans the windows), close-on-throw for copies that never
  reach a window, append-pass close ordering, and a high clamp on the
  parallelism property (an oversized value must degrade, not fail
  class-init for every write transaction).

Semantics: async imports mint ONE revision (no parser-progress
checkpoints) and nothing is durable until the final commit; sync mode
remains available via -Dsirix.import.asyncFlush=false. The rollback +
preallocated-commit offset-reuse interaction is documented in
KNOWN_LIMITATIONS.

Measured (4-core box, 2M-row / ~22M-node pure-write shred, median of 3,
identical binaries): sync 18.1s -> async 15.6s (~14% faster; the flush
competes for cores here — wider hosts recover more of the former ~24%
commit share). 100M rows: 371.5s vs 428.6s pre-optimization (~13%).
…ex-storage-redesign-i3849v

# Conflicts:
#	bundles/sirix-core/src/main/java/io/sirix/index/projection/ProjectionIndexByteScan.java
#	docs/PROJECTION_INDEX_STORAGE_REDESIGN.md
@JohannesLichtenberger
JohannesLichtenberger merged commit 752882b into main Jul 21, 2026
7 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants