Skip to content

fix: recover join leaves poisoned by internal __bsl_jk_ temporaries - #304

Draft
hussainsultan wants to merge 5 commits into
mainfrom
fix/join-leaf-recovery-collisions
Draft

fix: recover join leaves poisoned by internal __bsl_jk_ temporaries#304
hussainsultan wants to merge 5 commits into
mainfrom
fix/join-leaf-recovery-collisions

Conversation

@hussainsultan

@hussainsultan hussainsultan commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

The failure

A 6-table banking model (accounts + customers/bureau via join_one, transactions/balances/statements via join_many) over Snowflake-backed into_backend sources failed every read-back after cataloging:

Round-trip could not recover the left join table 'accounts': its dimension 'customer_id' does not resolve against the recovered table.

The model built cleanly and cataloged cleanly — it was only unusable at first from_tagged. Rebuilding the same model with globally unique physical column names round-tripped fine, which pointed at collisions.

Root cause (two ingredients)

  1. Seamed leaves force whole-projection recovery. When a join leaf is an into_backend seam, its expression holds more than one relation (RemoteTable placeholder + payload), so _reconstruct_table cannot walk to a single base relation and keeps the lowered leaf projection as the model's table.
  2. BSL's own lowering renamed the keys in that projection. With join-key names colliding across legs (customer_id in 3 tables, account_id in 4), SemanticJoinOp.to_untagged renames left predicate columns to __bsl_jk_<name> temporaries (the _RenamedResolver workaround for ibis ambiguous-deref). The recovered leaf then has __bsl_jk_customer_id but no customer_id, and _validate_join_leaf (correctly) raises.

Plain leaves (deferred reads, memtables, single DatabaseTables) recover via their base relation and never see the temporaries — which is why simple repros pass and only seamed + colliding models fail.

The fix

_strip_internal_join_temps inverts the reserved temporaries on the recovered leaf table, restoring the schema the model was authored against. Conservative by construction:

  • only exact-prefix __bsl_jk_<name> columns whose original name is free are inverted;
  • the __bsl_jk_<name>_N overflow spelling (a user column literally named __bsl_jk_<name> existed) is left alone rather than risk corrupting that user column.

Verified against the original failing field artifact: the broken catalog entry (91 dimensions, 75 measures) now fully recovers via .ls.builder and its queries lower.

Tests

test_xorq_join_leaf_recovery.py builds the collision model over memtable into_backend seams:

  • round-trip recovery exposes all leaf dims/measures (fails on main, passes here);
  • cross-leg query lowers against original names + one in-budget execution;
  • user look-alike __bsl_jk_x column survives untouched.

Full suite: 1748 passed, 1 skipped, 11 xfailed, 4 xpassed.

Not addressed here (xorq-side): in-process into_backend seams only support a bounded number of reads per plan — cross-leg aggregates over seams can silently return empty/NaN or raise "Maximum number of readers". That predates this fix and reproduces on the never-serialized model too.

🤖 Generated with Claude Code

Second commit: leaf recovery preserves authored deferred shaping

155dcaf generalizes the fix from inverting one rename artifact to the underlying principle: recovery must not discard what the author wrote.

  • _reconstruct_table previously walked every leaf to its bare base relation, so a model built dimensional-modeling style — conformed dimension/fact views shaped as deferred xorq expressions (.mutate(is_open=close_date.isnull())), thin semantic layer of names + simple reductions on top — failed round-trip at the first derived column. That forced all derivation logic into measure/dimension lambdas, the one layer with no authoring-time validation.
  • Now a leaf that is a pure per-row chain over exactly one relation (no Aggregate, no JoinChain) is returned as-is: the chain IS the model's table. Query entries still dig under their aggregates; memtable leaves keep their conversion; preserved chains still get __bsl_jk_ temporaries inverted.
  • _validate_join_leaf now quotes the underlying exception — a measure using an API this ibis lacks (the Column.filter field incident: AttributeError: 'StringColumn' object has no attribute 'filter' surfaced as a round-trip failure) names the real error and the fix direction instead of unconditionally blaming pre-aggregation.
  • Known gap, marked strict-xfail: a lowered query entry over a shaped view still base-walks — query-time injected mutates and authored shaping are indistinguishable in the lowered tree; needs a lowering-time base-boundary marker (follow-up).

Verified: shaped single-table, shaped star joins, and full xorq buildload_expr.ls.builder disk round-trips all recover and compute correct values. Suite: 1749 passed, 12 xfailed.

hussainsultan and others added 3 commits August 23, 2026 12:43
When a join leaf is an into_backend seam (RemoteTable placeholder + payload
relation), round-trip leaf recovery cannot walk to a single base relation
and keeps the lowered leaf projection as the model's table. If join-key
names collide across the joined tables, SemanticJoinOp.to_untagged has
renamed the left predicate columns to __bsl_jk_<name> temporaries inside
that projection (the _RenamedResolver ibis workaround), so the declared
dimensions no longer resolve and from_tagged raises "Round-trip could not
recover the left join table ...".

Observed in the field: a 6-table banking model over Snowflake-backed
into_backend sources with customer_id/account_id shared across legs failed
every read-back with this error, while the same model built with globally
unique physical column names round-tripped fine.

Fix: _strip_internal_join_temps inverts the reserved temporaries on the
recovered leaf table, restoring the schema the model was authored against.
Only exact-prefix temporaries whose original name is free are inverted;
the __bsl_jk_<name>_N overflow spelling (a user column literally named
__bsl_jk_<name> existed) is left alone to avoid corrupting that column.

Regression tests build the collision model over memtable into_backend
seams: round-trip recovery, plan lowering against original names, one
in-budget execution, and the user look-alike column guard. (Cross-leg
EXECUTION through in-process seams is not asserted: such seams only
support a bounded number of reads per plan — an xorq-side limitation,
unchanged by this fix.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iews)

Leaf recovery re-derived each model table by walking the lowered
expression to its bare base relation, discarding everything the author
put between the base and the model: mutates, selects, renames. A model
built the dimensional-modeling way — shape conformed dimension / fact
views as deferred xorq expressions first (validated at authoring), then
a thin semantic layer of names and simple reductions on top — failed
round-trip at the first derived column ("'Table' object has no attribute
'is_open'"), forcing all derivation logic into measure/dimension lambdas,
the one layer with no authoring-time validation.

_reconstruct_table now returns the leaf chain AS-IS when it is a pure
per-row chain over exactly one relation (no Aggregate, no JoinChain):
the chain IS the model's table. Lowered query entries still fall through
to the base walk (digging under the aggregate is what recovery is for
there), memtable leaves keep their from_ibis conversion, and preserved
chains still get __bsl_jk_ temporaries inverted at the call site.

Also: _validate_join_leaf now quotes the underlying exception instead of
unconditionally blaming pre-aggregation lowering — a measure written
with an API this ibis runtime lacks (the Column.filter misdiagnosis:
"AttributeError: 'StringColumn' object has no attribute 'filter'"
surfaced as a round-trip failure) now names the real error and the fix
direction (.sum(where=...) forms).

Known gap, marked xfail: a lowered QUERY entry over a shaped view still
base-walks — BSL's query-time injected mutates and authored shaping are
indistinguishable in the lowered tree; needs a lowering-time boundary
marker. MODEL entries — what the doctrine catalogs — are covered.

Suite: 1749 passed, 12 xfailed (the malloy-reader collection error and
two import-graph failures in the primary checkout come from an
in-progress malloy merge there, unrelated to this change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
serialize_dimensions/serialize_measures/serialize_calc_measures each wrap
their whole per-field loop in one @safe, so a single unserializable entry
(an untrusted callable, an unencodable constant) turned the entire
collection into a Failure. _extract_semantic_table defaulted that away
with .value_or({}), so to_tagged() returned successfully with every
dimension or measure on that table gone and no error — surfacing later,
if at all, as a confusing "unknown dimension" error on whatever query
happened to reference a dropped field.

Fix: raise the per-entry error the loop already constructs (naming the
offending field) instead of swallowing it.

Also adds regression coverage for tagging an already-aggregated join
query (to_tagged(aggregate_cache_storage=...) explicitly supports this):
a plain join_one + aggregate round-trips correctly, while a join_many
fan-out leg under an aggregated query still cannot be recovered — pre-agg
compilation rewrites the join into a decomposed tree with no JoinChain
matching the original leaves, so leaf recovery can't isolate each side.
_validate_join_leaf already catches this and raises loudly rather than
returning wrong numbers; the new test pins that the failure stays loud
rather than regressing into a silent-wrong one. Fully supporting that
case needs tagging join legs at multiple points in the tree, which is
out of scope here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
hussainsultan and others added 2 commits August 23, 2026 17:51
… guesses (#305)

The last of the leaf-recovery family. to_tagged serialized ONE lowered
expression plus metadata, and recovery re-derived each model's table from
the lowered plan (join splitting + base-relation walking) — guessing where
lowering ends and authored expression begins. Every failure in this family
was that guess going wrong: seam projections with __bsl_jk_ temporaries,
shaped views discarded, aggregate-grain views unrecoverable, query entries
losing their shaped base.

to_tagged now stamps each leaf model's AUTHORED table expression with a
marker tag inside the lowered payload (__bsl_leaf__, metadata
{"leaf": <model name>}): _collect_leaf_tables maps leaf SemanticTableOps to
their table ops (descending join wrappers' _source_join), and
_mark_leaf_tables wraps every structural occurrence via a node-equality
rewrite — so markers land wherever lowering placed the leaf: under rename
projections, under pre-aggregation legs, under a query's Aggregate. Markers
are hashing tags: payload-light, profiles ride along, they serialize
through xorq's YAML build path unchanged.

Recovery (_find_marked_leaf) returns the marked subtree verbatim before any
heuristic runs. This makes first-class, catalog-round-trippable:

- shaped deferred views (star-schema doctrine),
- to_semantic_table(table.group_by(...).aggregate(...)) — aggregate-grain
  fact models (dimensional-modeling aggregate fact tables),
- query entries over shaped bases (the previously strict-xfail gap — that
  test now passes and is promoted to a regular test).

Compatibility: payloads without markers (pre-change, or a leaf whose table
op was rewritten by lowering so no node matches) fall back to the existing
heuristics unchanged — covered by an explicit strip-the-markers test. Old
readers ignore the inner tags (their walks pass through Tag nodes).

Note on the rewrite: plain-callable replacers in ibis's replace() must
recreate nodes from _kwargs themselves for child substitutions to
propagate (Pattern/Mapping replacers get this for free) — the replacer
here does; to_tagged's pre-existing replace_read_parquet callable does
not and likely no-ops on nested rewrites (upstream xorq note, untouched).

Tests: 11 recovery tests pass incl. grouped-grain in-memory + full disk
round-trip; serialization battery 115 passed; full suite in a fresh env:
1307 passed, 6 failed — all 6 reproduce on the base commit (optional-dep
environment failures: langgraph/mcp/flavor-routing), zero regressions.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
CI lint was failing on `ruff format --check .` for a line that
exceeded the formatter's preferred line length.
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.

1 participant