Skip to content

fix: reproduce function registry deterministically in SessionStateBuilder::new_from_existing#23850

Open
tohuya6 wants to merge 1 commit into
apache:mainfrom
tohuya6:fix-23697-sessionstatebuilder-determinism
Open

fix: reproduce function registry deterministically in SessionStateBuilder::new_from_existing#23850
tohuya6 wants to merge 1 commit into
apache:mainfrom
tohuya6:fix-23697-sessionstatebuilder-determinism

Conversation

@tohuya6

@tohuya6 tohuya6 commented Jul 23, 2026

Copy link
Copy Markdown

Which issue does this PR close?

Rationale for this change

SessionState registers every UDF under its canonical name and each of its aliases, so a single Arc is reachable from multiple keys in the scalar, aggregate, window, and higher-order registries. SessionStateBuilder::new_from_existing flattened those registries with HashMap::into_values(), which yields the shared Arcs duplicated and in non-deterministic iteration order.

On build() the functions are re-registered last-writer-wins, so which function ends up owning a contested alias depended on HashMap ordering. A user's alias override of a builtin (for example a custom to_char) could silently revert — and do so differently from run to run.

What changes are included in this PR?

Reconstruct a faithful registration order instead of relying on HashMap iteration order. Because the registry was produced by some real registration sequence, an order that reproduces it always exists.

  • Add the AliasedRegistryEntry trait and a deterministic_registration_order helper. The helper dedups entries by Arc identity and topologically sorts them under a single rule — a key's owner must register after every other function that also claims that key by name or alias — breaking ties by canonical name.
  • Use it for the scalar / aggregate / window / higher-order registries in new_from_existing. Any valid order reproduces the exact map, so the roundtrip is now both deterministic and override-preserving.

Are these changes tested?

Yes. New regression tests cover:

Are there any user-facing changes?

No API changes. This fixes a correctness bug: alias overrides now survive SessionStateBuilder::new_from_existing deterministically.

…lder::new_from_existing

`SessionState` registers every UDF under its canonical name and each of its
aliases, so a single `Arc` is reachable from multiple keys in the scalar,
aggregate, window, and higher-order registries. `new_from_existing` flattened
those registries with `HashMap::into_values()`, which yields the shared `Arc`s
duplicated and in non-deterministic iteration order. On `build()` the functions
are re-registered last-writer-wins, so which one ends up owning a contested alias
depended on HashMap ordering: a user's alias override of a builtin (for example a
custom `to_char`) could silently revert, and do so differently from run to run.

Reconstruct a faithful registration order instead. Because the registry was
produced by some real registration sequence, an order that reproduces it always
exists. `deterministic_registration_order` dedups entries by `Arc` identity and
topologically sorts them under a single rule -- a key's owner must register after
every other function that also claims that key by name or alias -- breaking ties
by canonical name. Any valid order reproduces the exact map, so the roundtrip is
now both deterministic and override-preserving.

- add the `AliasedRegistryEntry` trait and `deterministic_registration_order`
  helper, and use them for the scalar / aggregate / window / higher-order
  registries in `new_from_existing`
- add regression tests: an alias override survives the roundtrip on every run, the
  alias-chain counterexample from apache#21262 roundtrips exactly, and a unit test that
  the helper's reconstructed order replays to the input map

Closes apache#23697.
@github-actions github-actions Bot added the core Core DataFusion crate label Jul 23, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.54248% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.75%. Comparing base (60cdf8a) to head (827181b).
⚠️ Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/core/src/execution/session_state.rs 89.54% 10 Missing and 6 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23850      +/-   ##
==========================================
- Coverage   80.77%   80.75%   -0.02%     
==========================================
  Files        1089     1089              
  Lines      369082   369309     +227     
  Branches   369082   369309     +227     
==========================================
+ Hits       298134   298250     +116     
- Misses      53196    53297     +101     
- Partials    17752    17762      +10     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tohuya6

tohuya6 commented Jul 24, 2026

Copy link
Copy Markdown
Author

@Jefffrey @alamb, ready for review

@neilconway

Copy link
Copy Markdown
Contributor

I wonder if it would be net simpler to split the registry data into two fields. For example:

  1. scalar_functions_snapshot, which is an optional hashmap that comes via new_with_existing
  2. scalar_functions_extra, which is the user-supplied Vec with additional registrations

Then build() would install the snapshot as-is and then apply the vec of additional registrations on top.

@alamb alamb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @tohuya6 -- I agree with @neilconway that this feels overly complicated for the problem it is trying to solve. I have some thoughts -- let us know

/// order dependence surfaces.
#[test]
fn test_from_existing_preserves_alias_override() -> Result<()> {
for _ in 0..64 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to do this 64 times?

query_planner: Some(existing.query_planner),
catalog_list: Some(existing.catalog_list),
table_functions: Some(existing.table_functions),
scalar_functions: Some(existing.scalar_functions.into_values().collect_vec()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about potentially aligning builder to use a HashMap too (rather than a Vec) so that we could just copy the existing state to the builder?

}
}

/// Flatten a function registry into its distinct functions, ordered so that

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the problem is the non determinism of reregistration, perhaps we could do something simpler like sort the values by alias name so the result is deterministic?

I haven't read this implementation carefully but it seems pretty complicated semantically ti try and reverse engineer the aliasing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

non-deterministic roundtripping through sessionstatebuilder

4 participants