fix: recover join leaves poisoned by internal __bsl_jk_ temporaries - #304
Draft
hussainsultan wants to merge 5 commits into
Draft
fix: recover join leaves poisoned by internal __bsl_jk_ temporaries#304hussainsultan wants to merge 5 commits into
hussainsultan wants to merge 5 commits into
Conversation
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>
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The failure
A 6-table banking model (accounts + customers/bureau via
join_one, transactions/balances/statements viajoin_many) over Snowflake-backedinto_backendsources failed every read-back after cataloging: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)
into_backendseam, its expression holds more than one relation (RemoteTable placeholder + payload), so_reconstruct_tablecannot walk to a single base relation and keeps the lowered leaf projection as the model's table.customer_idin 3 tables,account_idin 4),SemanticJoinOp.to_untaggedrenames left predicate columns to__bsl_jk_<name>temporaries (the_RenamedResolverworkaround for ibis ambiguous-deref). The recovered leaf then has__bsl_jk_customer_idbut nocustomer_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_tempsinverts the reserved temporaries on the recovered leaf table, restoring the schema the model was authored against. Conservative by construction:__bsl_jk_<name>columns whose original name is free are inverted;__bsl_jk_<name>_Noverflow 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.builderand its queries lower.Tests
test_xorq_join_leaf_recovery.pybuilds the collision model over memtableinto_backendseams:__bsl_jk_xcolumn survives untouched.Full suite: 1748 passed, 1 skipped, 11 xfailed, 4 xpassed.
Not addressed here (xorq-side): in-process
into_backendseams 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
155dcafgeneralizes the fix from inverting one rename artifact to the underlying principle: recovery must not discard what the author wrote._reconstruct_tablepreviously 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.Aggregate, noJoinChain) 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_leafnow quotes the underlying exception — a measure using an API this ibis lacks (theColumn.filterfield 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.Verified: shaped single-table, shaped star joins, and full
xorq build→load_expr→.ls.builderdisk round-trips all recover and compute correct values. Suite: 1749 passed, 12 xfailed.