Projection Indexes (experimental, analytical)

August 26, 2026 · View on GitHub

A fourth, columnar index type accelerates analytical queries — aggregates, filtered counts, group-bys, count-distinct — over homogeneous record sets. A projection index extracts the declared fields of every record under a root path into compact column-oriented leaf pages (1024 rows per leaf: frame-of-reference numerics, per-leaf string dictionaries, presence bitmaps), which the vectorized executor scans with SIMD kernels instead of walking the document tree.

Persisted leaves are stored as semantic segments — the record-key column, one body per column, one (FSST-compressed where beneficial) dictionary per string column — each its own copy-on-write page addressed from a tiny per-leaf descriptor, so a single-column update rewrites one segment page and unchanged segments are shared across revisions by reference. Bit-packed segments come to roughly 5% of the in-memory size, so the on-disk tax over the versioned document store stays ~10%. Double columns store exact values in an order-preserving encoding; value-exact consumers decline columns tainted by lossy decimal conversions (fail-closed).

For the internals, see PROJECTION_INDEX_DEEP_DIVE.md, PROJECTION_INDEX_INCREMENTAL_MAINTENANCE.md for exact row lookup and positional updates, and the storage-design notes in this directory.

Creating a projection

Create one with JSONiq — the resource must be created with a path summary (buildPathSummary(true)):

(: store a record set :)
jn:store('mydb', 'sales.jn', '[
  {"age": 30, "active": true,  "dept": "Eng",   "city": "NYC"},
  {"age": 45, "active": false, "dept": "Sales", "city": "LA"},
  {"age": 52, "active": true,  "dept": "Eng",   "city": "NYC"}
]')
(: project (age, active, dept, city) over the top-level array :)
let $doc := jn:doc('mydb', 'sales.jn')
let $stats := jn:create-projection-index($doc, '/[]',
    ('/[]/age', '/[]/active', '/[]/dept', '/[]/city'),
    ('long', 'boolean', 'string', 'string'))   (: also: 'double' / 'decimal' columns :)
return {"revision": sdb:commit($doc)}

Nested roots and nested columns are paths too — e.g. a record set under '/wrapper/records/[]' with a column '/wrapper/records/[]/address/city', or a descendant pattern '//records/[]' spanning sibling subtrees. Missing fields are tracked per row in presence bitmaps, so sparse data stays correct.

A field path may also end in an array step: '/[]/genres/[]' declares a genres column over the elements of an array-valued field (a dictionary-encoded string set per row), so membership predicates — some $g in $r.genres[] satisfies $g eq "Drama", alone or inside a larger filter — are answered from the projection instead of the tree.

Building during the load (one pass)

jn:create-projection-index walks a finished resource, so it costs a second pass over the corpus. Embedded callers can instead declare the projection up front and let the load itself produce the rows — the definition is catalogued on the still-empty resource and the load feeds the builder as it shreds, so the index is complete when the load commits:

final var projection = new ProjectionSpec("/[]",
    List.of("/[]/age", "/[]/active", "/[]/dept", "/[]/city"),
    List.of("long", "boolean", "string", "string"),
    expectedRows);   // -1 when unknown; only sizes the value-dictionary election
store.create("mydb", "sales.jn", jsonReader, projection);

The spec uses exactly the vocabulary of the query form (same root path, field paths, and type names), so the two cannot drift apart. A sequential shred feeds the builder through its own change notifications; the parallel bulk importer feeds the same builder from the rows its build workers extract, so both routes produce a byte-identical index — see BULK_IMPORT.md for the load-side contract (armed builds, refusals, cost).

Until the load's final commit the projection's metadata slot holds the stale tombstone, so an interrupted load leaves queries on the generic pipeline rather than serving them from a half-filled index. One failure completes the LOAD but not the projection: a resource-wide value dictionary that hits its byte budget mid-build abandons the projection rather than the load. That prints [proj] PROJECTION ABANDONED on stderr (unconditionally — the warning alone is invisible under the shipped log configuration), and the tombstone records the reason plus its remedy: give the loader an expected-row-count hint so the oversized column is declined up front and the rest of the projection still builds, or raise -Dsirix.projection.globalDict.budgetBytes, then rebuild with jn:create-projection-index.

Querying

There is no separate scan function — eligible queries route through the projection automatically once it is installed (compile through SirixCompileChain.createWithJsonStore(store, session) to get the analytical executor). Plain JSONiq does it:

(: full-column aggregates — served from the numeric column, no tree walk :)
let $doc := jn:doc('mydb', 'sales.jn')
return {"sum": sum(for $r in $doc[] return $r.age),
        "min": min(for $r in $doc[] return $r.age),
        "max": max(for $r in $doc[] return $r.age)}

(: filtered count — conjunctive predicate over the age + active columns :)
let $doc := jn:doc('mydb', 'sales.jn')
return count(for $r in $doc[] where $r.age > 40 and $r.active return $r)

(: single- and multi-key group-by — dictionary-encoded group columns :)
let $doc := jn:doc('mydb', 'sales.jn')
for $r in $doc[]
let $d := $r.dept, $c := $r.city
group by $d, $c
return {"dept": $d, "city": $c, "count": count($r)}

(: count-distinct — answered from the union of per-leaf dictionaries :)
let $doc := jn:doc('mydb', 'sales.jn')
return count(for $r in $doc[] let $d := $r.dept group by $d return $d)

Lifecycle, versioning, and maintenance

The index is written into the session's transaction — sdb:commit($doc) persists it, like the other index-creation functions. Projection definitions are catalogued in the resource's index set exactly like path/CAS/name indexes, so a resource can carry several projections side by side (each in its own storage sub-tree), and queries discover them through the revision-scoped catalog and page layer — after re-opening a database, analytical queries use persisted projections automatically (decoded once per revision into a bounded in-memory cache, sub-second per ~10M rows), with no re-creation call needed. Because discovery is revision-scoped, uncommitted builds are invisible to other sessions, rollbacks need no compensation, and time-travel queries only ever see projection data that was current at their revision.

Update transactions maintain projections incrementally (wired through the index-controller listener lifecycle, like the other index types): changes are attributed to their records as they happen, and at commit time only the touched leaves are patched — updated records are re-extracted in place, deleted records drop out, and new or moved records are spliced at their document predecessor/successor anchors. First and middle inserts use sparse exact locators; a proven high-key tail insert stays on the compact normal fence backbone. The same catalogued projection therefore keeps serving without a re-creation call, including record reordering, moves into or out of the projected set, replacing a record set wholesale, and descendant-pattern record sets appearing or disappearing.

Maintenance has no dirty-record cliff and never scans or rebuilds the complete projection. It updates only touched row groups, 32-physical-leaf order/fence chunks, 256-leaf Bloom chunks, bounded per-column set summaries, sparse locators, and immutable global-dictionary radix paths. An unresolvable or corrupt touched unit fails the owning transaction and requires rollback. Calling jn:create-projection-index with a different shape creates an additional projection.

Serving is memory-bounded, not all-or-nothing. Kernels that want a column's whole byte image get it eagerly while the projection's worst-case resident size fits -Dsirix.projection.eagerMaterializeBytes (default: the smaller of half the projection cache budget and a quarter of the heap); above that the same kernels read through bounded 128-leaf windows instead, and a column fill that would exceed the budget declines: the query re-enters the windowed whole-leaf route where one exists, and otherwise falls back to the record path. Declining is a routing decision, not a corruption signal — the index stays valid and keeps serving everything else.

Uncommitted state is servable too: an executor constructed over an open write transaction (new SirixVectorizedExecutor(wtx, threads)) answers unpredicated aggregates, group-bys and count-distinct from the transaction's own state — pending maintenance is applied on read (read-your-writes) and the leaves are read through the transaction log, uncached, so committed readers keep their isolated snapshots.

The full function family matches the other index types: jn:find-projection-index($doc, $rootPath, $fields) returns a projection's definition id (or -1), and jn:drop-projection-index($doc[, $idx-no]) drops one or all projections (tombstoning the stored columns so a later same-shape re-creation rebuilds instead of serving leftovers).

REST serving

Projection serving is also wired into the REST API: a resource-scoped query (GET /database/resource?query=...) is compiled with a vectorized executor bound to the request's resource and revision, so the same analytical queries are answered from the projection over HTTP. Because the analytical detection captures source paths — not resource identity — the REST layer applies a fail-closed serving gate built as an allowlist: the executor is wired only when the query provably targets the request's own resource — every jn:doc names exactly that database/resource with two string literals, every other function call is a known-safe builtin or xs:* constructor (any prefixed function, unknown name, function reference, or module import refuses), requests scoped to a nodeId subtree are excluded, and with a pinned non-latest revision only pure context-item queries qualify. Anything unprovable simply runs on the generic pipeline — the gate can cost performance, never correctness. The index-management functions (jn:create-projection-index, jn:find-projection-index, jn:drop-projection-index) work over REST like any other JSONiq query.

Current limits

Column types are long, boolean, string, double/float/decimal (stored exactly in an order-preserving encoding; value-exact consumers decline columns tainted by lossy decimal conversions), and array-element string sets. Columns are resolved by their declared path relative to the record set, so a projection may declare fields nested at different depths and on different branches below the root, and a trailing name that also occurs at some other path under the record set is not ambiguous. The column name — the trailing object-key step, or for a set column the field step before the array layer — is part of the projection's identity and must be unique within one projection; a declared path that does not sit strictly under the declared root falls back to name-only lookup and is rejected when that name also occurs elsewhere under the record set. Queries that the projection cannot serve exactly (unrepresentable values, non-covered predicates, ambiguous projection selection) fall back to the regular pipeline automatically, so results are always identical with or without the index.