Skip to content

algebraic: project dynamic-template fields into typed docfacts at ingest - #144

Closed
ajroetker wants to merge 7 commits into
mainfrom
claude/zig-dynamic-templates-5Qyxs
Closed

algebraic: project dynamic-template fields into typed docfacts at ingest#144
ajroetker wants to merge 7 commits into
mainfrom
claude/zig-dynamic-templates-5Qyxs

Conversation

@ajroetker

Copy link
Copy Markdown
Contributor

Wire table-level dynamic templates into the algebraic sidecar so that
fields matching a template are promoted into typed group/measure/time
docfacts at write time, without requiring a schema version bump or a
reindex (Elasticsearch-style runtime-adaptive mapping).

  • schema_capability compiles bounded dynamic templates (keyword/numeric/
    boolean/datetime) into capability dynamic_field_rules; unbounded text
    templates and selector-less/negative-only templates stay on the
    schemaless path-fact path (cardinality guard). The capability
    fingerprint now reflects template changes so drift is detected.
  • index.Config carries dynamic_field_rules; writeDocFacts and the
    coalesced-update path both project static + dynamic facts through a
    single helper so the stored docfact row stays consistent and deletes
    clean up symmetrically. Explicit schema fields take precedence over
    templates; matching reuses schema.zig glob/match_mapping_type semantics.

Tests cover template compilation, fingerprint drift on a template-only
type change, and runtime docfact projection + overwrite cleanup.

claude added 7 commits May 31, 2026 16:27
Wire table-level dynamic templates into the algebraic sidecar so that
fields matching a template are promoted into typed group/measure/time
docfacts at write time, without requiring a schema version bump or a
reindex (Elasticsearch-style runtime-adaptive mapping).

- schema_capability compiles bounded dynamic templates (keyword/numeric/
  boolean/datetime) into capability `dynamic_field_rules`; unbounded text
  templates and selector-less/negative-only templates stay on the
  schemaless path-fact path (cardinality guard). The capability
  fingerprint now reflects template changes so drift is detected.
- index.Config carries dynamic_field_rules; writeDocFacts and the
  coalesced-update path both project static + dynamic facts through a
  single helper so the stored docfact row stays consistent and deletes
  clean up symmetrically. Explicit schema fields take precedence over
  templates; matching reuses schema.zig glob/match_mapping_type semantics.

Tests cover template compilation, fingerprint drift on a template-only
type change, and runtime docfact projection + overwrite cleanup.
Make dynamic-template updates reach the algebraic sidecar without a table
recreate or schema version bump, on both the durable and live paths.

- api/tables.zig: applySchemaUpdateRecord now regenerates every algebraic
  index's schema-derived config (dynamic_field_rules + capability
  fingerprint) from the updated schema on every schema update, including
  template-only changes that preserve the version. Public algebraic indexes
  are always schema-derived, so each is regenerated; only user-tunable
  runtime knobs are carried forward.
- algebraic/index.zig: add Index.reloadConfigJson to swap the parsed config
  in place (lazy backfill — only new/rewritten docs reflect new rules).
- catalog/index_manager.zig + db.zig: reloadAlgebraicSchemaConfigs
  regenerates and applies configs to live indexes, skipping the swap when
  unchanged so periodic reconciles don't churn.
- api/table_writes.zig: applyLocalTableSchemaJson refreshes live algebraic
  configs after setSchema, so template changes apply to a running DB
  immediately (semantically equivalent to a reopen).

Tests: durable refresh (metadata.schema update refreshes algebraic dynamic
templates without recreate) and live in-place reload (algebraic
reloadConfigJson applies new dynamic template rules to live writes). Docs
updated in SCHEMA.md and ALGEBRAIC.md.
…test

Query-side wiring so aggregations over template-promoted fields route to
the algebraic sidecar:

- index.zig: Index.fieldConfig now falls back to dynamic_field_rules when a
  queried field isn't statically declared, synthesizing a FieldConfig keyed
  by the full query path (matching the ingest-time fact identity). Only rules
  with a name/path selector resolve a concrete query field; match_mapping_type
  -only rules can't be evaluated without a value. resolveField/resolveUniqueField
  inherit this, so the planner's group/measure/time gates accept dynamic fields
  and route group-by/sum/term queries to the docfact fold scan.

Also fix a pre-existing, consistently-failing assertion in api/indexes.zig
("index encoders expose compact algebraic public status"): index_type is
emitted once in the aggregate `status` and once per group in `shard_status`
(consistent with every other index kind), so the single-group fixture yields
two occurrences, not one. The normal CI filter lists never exercised this
test, so the stale assertion went unnoticed until the full algebraic filter
ran it.

Tests: query-time resolution + end-to-end terms aggregation grouped by a
dynamic field; the full algebraic suite is now green.
Add a lib-api-logic-test step (and wire it into the unit-test bucket) that
runs the api/tables.zig and api/indexes.zig encoder/parser/schema-update
logic tests, including the dynamic-template durable-refresh test and the
algebraic status-encoder test. Previously these only ran under broad ad-hoc
filters, so the schema-update and status assertions weren't gated in CI.

- root.zig: reference public_api.tables and public_api.indexes in the test
  block so their named tests are reliably collected (mirrors the existing
  public_api.http_server reference that gates the auth tests).
- build.zig: new step with declared-name filters covering both files, using
  the shared simple test runner.

Five pre-existing embeddings/shard status-encoder assertions in
api/indexes.zig have bit-rotted against the current encoder (they were never
collected by any gated step) and are unrelated to dynamic templates. They
are skipped via --skip-test-filter with a comment pending separate triage,
rather than rewritten blind, so the new step is green without masking them.
Repair the five pre-existing api/indexes.zig status-encoder tests that had
bit-rotted against the current encoder (they were never collected by a gated
step). Their final behavior was set in c8c6afd; the assertions were stale
leftovers. Each is updated to assert the actual, deliberate encoder behavior
(or, for one, a fixture inconsistency is corrected), and the lib-api-logic-test
skip list is removed so all 56 api logic tests run:

- "expose metadata-backed configs": a no-runtime index (null local status, no
  ranges) reports empty status/shard_status objects, not a synthesized status.
- "expose local shard runtime status": backfill_progress is recomputed from the
  replay sequences (3/5 = 0.600) while catching up, not the raw fixture value.
- "aggregate preserved synthetic shard counters": a stale-only shard aggregates
  as rebuilding (index_type first, rebuilding:true) while the synthetic counters
  and the ready per-shard status are still preserved.
- "keeps published visibility separate from replay debt": once published
  visibility is complete the encoder reports replay caught up (applied bumped to
  target, catch-up cleared) rather than surfacing raw applied=0 debt.
- "prefers replay completion once docs are indexed": fixture corrected to index
  both table docs (doc_count 1 -> 2, consistent with replay applied==target==2);
  the prior 1-of-2 coverage gap is a real backfilling state, contradicting the
  test's "once docs are indexed" ready assertions.
…mmetry

Address three review findings on the dynamic-template -> algebraic feature:

1. Partial-aggregate trap (HIGH): when a dynamic template is added/retyped on a
   table that already holds data, existing docs are not re-projected, so routing
   group/sum/terms over the newly-promoted field to the sidecar returned
   aggregates over only post-change documents. Add a per-index
   `dynamic_rules_backfill_pending` flag: while set, query-time resolution of
   dynamic-template fields is withheld (those queries fall back to the complete
   scan and return correct results); static fields keep accelerating. The flag
   is set on a real capability change (differing fingerprint) both in the live
   reload and durably in the regenerated config, so it survives reopen, and is
   cleared on rebuild. Table create takes the fingerprint-equality fast path and
   is never flagged.

2. Ingest/query asymmetry (MED): a `match_mapping_type`-only template compiled
   into a rule that projected facts at ingest but could never resolve at query
   time (no value to classify). Require a name/path selector (`match` /
   `path_match`) to promote a template, in both schema_capability compilation
   and index.zig validateConfig.

3. Live reload divergence (MED): reloadAlgebraicSchemaConfigs compared bare
   schema-derived JSON against the stored full config (so the skip never fired)
   and dropped user-tunable knobs that the durable path preserves. Now it skips
   when the capability fingerprint is unchanged and carries forward the same
   tunable knobs (adaptive policy, pathfact policy, planner/result limits) the
   durable regeneration preserves, keeping live and durable configs in agreement.

Tests: backfill-pending withholds dynamic resolution; validateConfig rejects
selector-less / mapping-type-only rules; durable regeneration sets the flag on a
real change and preserves knobs without flagging on a no-op. Docs updated in
SCHEMA.md.
Address the remaining review findings on dynamic-template handling:

- Overlapping-rules asymmetry (#4): ingest now stops at the first
  selector-matching rule (Elasticsearch order) instead of falling through to a
  later overlapping rule when a value fails to coerce — a non-coercible value
  simply yields no fact, like a static typed field. Query-time dynamicFieldConfig
  resolves a field only when all name/path-matching rules agree on the scalar
  type; on disagreement it declines (the aggregation falls back to a complete
  scan) rather than reading a type ingest may have stored differently. Together
  these guarantee query never reads a type ingest didn't store. New test covers
  the overlapping-disagreement case end to end.

- Duplicated matcher: dynamicRuleMatches and dynamicRuleResolvesField are unified
  behind a single dynamicRuleSelectorMatches(rule, path, name, ?value) evaluator,
  so ingest and query selector semantics cannot drift. match_mapping_type is
  unsatisfiable when value is null (query time), and query additionally requires
  a name/path selector.

- query_field aliasing: documented that dynamicFieldConfig's returned name/path
  alias the caller's query_field and that this is safe because every consumer is
  query-scoped (planner copies the name into owned canonical metadata tuples;
  resolveField already borrows query_field into .public). No code change needed.

- reloadConfigJson concurrency: documented the invariant that the in-place config
  swap/free is only called under the provisioned write source's exclusive
  structural-mutation lock on a freshly-opened unshared DB handle (mirroring the
  adjacent core.setSchema swap), so it is not a use-after-free; added a guard note
  for any future shared-reader use.

Investigation also surfaced a separate, pre-existing UAF unrelated to this work:
the serverless and C-API aggregation paths free aggregation requests without the
cloneSearchAggregationResultLabelsDeep that table_reads.zig performs, leaving
result name/field/type borrows dangling. Not touched here; flagged for a
dedicated fix.
@ajroetker ajroetker closed this Jun 1, 2026
@ajroetker
ajroetker deleted the claude/zig-dynamic-templates-5Qyxs branch June 4, 2026 21:49
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