Repository navigation
fix(cache): scalar-UDF-only entries never bake a result cache (#81) - #112
Conversation
…rst) A make_pandas_udf scalar UDF used without any accompanying expensive op serializes as `op: ScalarUDF` (classify_build → worthy=True) but its live node is classed after the user function (`plusone`), so _is_worthy_expr's `"UDF" in type(node).__name__` substring check misses it and returns False. The two "change one, change both" worthiness predicates fall out of lockstep, so rewrite_for_build bakes no result cache and every read recomputes the UDF. Two failing tests pin the regression: the predicate-level lockstep, and the net effect (the entry must bake a snapshot cached_result_expr dereferences). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_is_worthy_expr tested `"UDF" in type(node).__name__`, but a make_pandas_udf scalar UDF node is classed after the user function (e.g. `plusone`), so the leaf-name substring missed it and the live predicate returned False — while classify_build saw `op: ScalarUDF` in the YAML and returned worthy=True. The two "change one, change both" predicates fell out of lockstep, so rewrite_for_build baked no result cache and every read recomputed the UDF. Walk type(node).__mro__ for "UDF" instead: that matches the base op name the serializer emits (ScalarUDF / AggUDF / …), restoring the lockstep by construction. The _EXPENSIVE_OPS leaf-name check is unchanged. Net effect: a scalar-UDF-only entry now bakes a CachedNode and reads dereference a materialised single-backend snapshot. The test asserts the lockstep and that net effect. (xorq's make_pandas_udf mints a fresh class per reconstruction, so the snapshot's structural key drifts across processes — the build bake is not reused cross-process and reads self-heal. That deeper key-stability gap is tracked separately.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up found while building this fix — UDF snapshot keys are non-deterministicVerified empirically: the MRO predicate fix here is correct and necessary, but it surfaces a separate, deeper bug that this PR does not fully resolve.
In-process the lru-memoised plan keeps one snapshot warm, so repeated reads in a live companion do hit — a real improvement over recompute-every-read. The cross-process reuse + phantom-path gap is rooted in xorq's UDF node hashing; a proper fix (e.g. keying the result cache on a stable recipe-source digest rather than xorq's structural key) is bigger than this lockstep fix and should be tracked separately. This PR's test asserts only what is true post-fix: predicates agree, the build bakes a CachedNode, and a read dereferences a materialised single-backend snapshot. |
|
Filed the cross-process / phantom-path follow-up described above as #117. |
Fixes #81.
Problem
The two worthiness predicates #73 requires to stay in lockstep ("change one, change both") disagree for an entry whose only expensive op is a scalar UDF.
source_cache._is_worthy_expr(src/tallyman_xorq/source_cache.py:75-78) walks the live graph and testsname in _EXPENSIVE_OPS or "UDF" in name, wherename = type(node).__name__. Amake_pandas_udfscalar UDF node is classed after the user function (e.g.plusone), so the substring check is False — even though the node's MRO is[plusone, ScalarUDF, Value, Node, …].result_cache.classify_build(src/tallyman_xorq/result_cache.py:78-84) greps the serialized YAML, where the same node isop: ScalarUDF(contains "UDF"), so it returnsworthy=True.Net effect:
rewrite_for_buildbakes no top-level result cache (it gates on_is_worthy_expr), but at readcache_worthyis True so_resolve_result_plantakes the expensive branch, finds noCachedNode, and falls back toworthy-recompute-fallback— a full UDF recompute over the whole DAG on every viewer / diff /from_catalogread. No crash; the result is silently never cached. Agg UDFs (carry anAggregate) and vectorized UDFs (live class name contains "UDF") are unaffected; the gap is specific to scalar UDFs used without any accompanying expensive op.Change
_is_worthy_exprdetects UDFs by ancestry, not leaf-name substring: it checks whether any class intype(node).__mro__has "UDF" in its name. That mirrorsclassify_build's YAML rule exactly (the serializer emits the base-class op name,ScalarUDF), so the two predicates are back in lockstep by construction. The_EXPENSIVE_OPSleaf-name check is unchanged — those ops serialize under their own leaf names.Status — TDD split
Per the repo's test-vs-fix rule, the first commit is the two failing tests (watched red on CI); the fix follows in the next commit (CI green):
test_scalar_udf_is_worthy_in_lockstep_with_classify_build— the predicate-level regression.test_scalar_udf_only_entry_bakes_result_cache— the net effect (entry must bake a snapshotcached_result_exprdereferences).Context
Surfaced during review of #74. The root shared-tmp/predicate-duplication is documented in
result_cache.py; this restores the invariant the gap violated.🤖 Generated with Claude Code