Skip to content

fix(tesseract): fold nested multi-fact join legs into one scan - #11643

Open
waralexrom wants to merge 4 commits into
masterfrom
tesseract-multi-fact-shared-base-cte
Open

fix(tesseract): fold nested multi-fact join legs into one scan#11643
waralexrom wants to merge 4 commits into
masterfrom
tesseract-multi-fact-shared-base-cte

Conversation

@waralexrom

Copy link
Copy Markdown
Member

Summary

A query whose measures sit at different depths of one join chain planned a separate leg
per measure, and every leg re-scanned the shared part of the join from scratch. A funnel
modelled as sites → carts → checkouts paid for one full scan of the base per measure,
so adding measures multiplied the cost — two measures were already timing out on Trino.

Such legs are now folded into one wherever moving a measure into the wider join tree
provably cannot change what it computes. Note that this is not the star-shaped multi-fact
case (orders and returns under customers), which stays split as before — it is the
case where legs differ only by how far down one chain they walk, and never needed to be
separate at all.

Changes

  • MultiFactJoinGroups::try_new_merging_nested folds a group into one whose join tree
    contains its own. Safe because the extra joins are LEFT — they only ever replicate a
    row, never drop one — and only for measures immune to that replication: a distinct
    aggregation qualifies, a plain count or sum does not and keeps its own leg.
  • The wider tree is rebuilt from the union of both groups' join hints before the fan-out
    question is asked. multiplicationFactor is only populated for the cubes a tree's own
    hints named (JoinGraph.ts maps it over cubesToJoin), so a cube reached in transit
    reports as unmultiplied and would otherwise let an inflating measure through. The
    rebuild produces the same joins — asserted by comparing the JoinKey.
  • MeasureKind::survives_row_multiplication, deliberately narrower than the existing
    regular_in_multiplied: a key-based count is also safe under multiplication, but only
    after switching to the distinct MultipliedCount form, and the render form is decided
    from the measure's own tree elsewhere.
  • Merging stands down for: multi-stage measures (own CTE pipeline), member expressions
    (COUNT(*) names no member, so what it counts is whatever rows the join produces),
    ungrouped queries (raw rows expose the replication), pre-aggregation queries, and
    regrouping through for_measures.
  • It also stands down when any cube read by the groups defines a pre-aggregation at all.
    A rollup is matched against one leg at a time, so a merged query needs one spanning all
    of them; a rollup outweighs the scan saved. This guard is coarse — see below.

Testing

New fixture with a three-cube chain and a seed built so right and wrong answers differ:
two carts of one site share a msid (distinct ≠ count), one cart has two checkouts
(fan-out), two carts have none (LEFT JOIN must keep them).

  • 6 integration tests on Postgres, snapshots verified by hand against the seed. The key
    one: a distinct measure returns the same numbers selected alone and selected next to a
    deeper measure, while count/sum keep their own leg and their own values.
  • 2 structural tests (scan count, per-leg rollups still matching) and 5 unit tests on the
    grouping itself — nested merges, siblings do not, plain count does not.
  • Full suite green on real Postgres: 1287 tests, no existing snapshot changed.

Follow-up

The pre-aggregation guard answers "could a rollup exist" rather than "did one match", so
the optimisation silently switches off for models with rollups on the fact cubes. Removing
it means two-pass planning in TopLevelPlanner — plan unmerged, try pre-aggregations,
replan merged on a miss — which needs QueryProperties to carry a merge flag. Left out of
this PR deliberately.

The merge also only takes effect through is_simple_query(), so a query that additionally
carries a multiplied or multi-stage measure keeps its regular legs unmerged.

waralexrom and others added 2 commits August 25, 2026 15:28
Measures were grouped by exact join tree equality, so a measure whose tree
is contained in another one's got a group - and a full re-scan of the shared
join - of its own. A funnel over several cubes paid for one scan of the base
per measure.

Such a group is now folded into the one that walks the same cube graph
further, provided every measure it carries computes the same value there. The
extra joins are LEFT, so they only replicate rows, and a distinct aggregation
is immune to that; a plain count or a sum is not and keeps its own group.

The wider tree is rebuilt from the union of both groups' join hints before
that question is asked: a tree only records multiplication for the cubes its
own hints named, so a cube reached through it in transit would otherwise
report as unmultiplied and let an inflating measure through.

Only the query's own grouping merges. Regrouping a measure subset does not:
a pre-aggregation is matched against one leg at a time, and collapsing legs
would cost the query its rollups. Ungrouped queries are excluded as well,
since they expose the replicated rows directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three holes in the merge, all of them cases where a group was folded in that
should have stayed on its own tree.

A member expression names no member to anchor it to a cube, so what it reads
is decided by the join it lands on - `COUNT(*)` over a wider tree counts the
fanned-out rows. The multiplication collector reports such a measure as
unmultiplied by construction, so the fan-out check waved it through; a leaf
that is not a plain measure now declines the merge outright.

A dimension-only member expression over several cubes makes the collector
refuse a tree that multiplies any of them. The tree passed here is a merge
candidate rather than the one the query renders, so that refusal was turning
a plannable query into an error. It now declines the merge instead.

Folding groups also costs the query its rollups: a pre-aggregation is matched
against one group at a time, and a merged query needs one spanning all of
them. The merge now stands down whenever any cube it reads defines a
pre-aggregation at all - coarse, but a rollup outweighs the scan saved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the rust Pull requests that update Rust code label Aug 25, 2026
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 5m 36s —— View job


Review: sound optimization, well-guarded — 1 medium correctness, 1 medium perf, 3 low. No blockers.

Full review

Todo

  • Gather context / read diff (git diff 6f3e66d..HEAD)
  • Review multi_fact_join_groups.rs merge logic
  • Trace the safety argument end-to-end (is_multipliedmultiplicationFactor → render form)
  • Review guards (query_properties, query_tools, measure_kinds)
  • Review tests, fixtures, seed data and snapshot values
  • Post inline comments + summary

What I verified

I traced the safety argument rather than taking the PR description at face value, and it holds:

  • Every cube join is LEFTphysical_plan_builder/processors/logical_join.rs:67 and :106 only ever call left_join_cube / left_join_source. So the "extra joins replicate, never drop" premise is enforced by construction, not by convention.
  • The render form really is decided from the measure's own tree, not the merged one: full_key_aggregate_measures (query_properties.rs:601-604) resolves the join per single measure via compute_join_multi_fact_groups_with_measures, which routes through for_measuresbuild(..., merge_nested = false). This is exactly why survives_row_multiplication must be narrower than regular_in_multiplied, and the code gets that right — a Count that would need switching to MultipliedCount is correctly refused.
  • The multiplicationFactor gap is genuinely closed. JoinTree::is_multiplied falls back to false for a cube absent from the map (join_tree.rs:87-92), which is the trap the PR describes. Rebuilding from the union of hints puts the moving measure's cube into cubesToJoin, and the key != other.key bail means a rebuild that produced different joins is skipped rather than trusted. Since the factor per cube is derived from the join edges and the edges are asserted identical, the rebuild only adds entries — it cannot flip an existing one, so the target group's own measures are unaffected.
  • Chained merges stay honest: hints/measures are both accumulated into the target, so a group that already absorbed another is re-validated with the full measure set on the next pass, and its absorbed hints are part of every subsequent rebuild.
  • Termination: each pass removes exactly one group, so the loop is bounded by group count. Iteration is over key_order, so merge selection is deterministic.
  • Member expressions stand down via item.measure.as_measure() failing — collect_multiplied_measures returns the expression symbol itself for the dimension-only case (multiplied_measures_collector.rs:124-162) and through the visitor otherwise. Covered by test_nested_trees_count_star_expression_keeps_its_own_scan.
  • Cache safety: JoinTreeCache is keyed by JoinHints, so the speculative rebuilds add entries under their own union key and cannot poison the entry for either original group's hints.
  • Snapshot values check out by hand against the seed: US carts {m1, m1, m2} → 2 distinct; US checkouts {x1, x2} (cart 1) ∪ {x1} (cart 3) → 2; DE cart 4 → 1 cart, 0 checkouts, which is the LEFT JOIN-preserves-the-row case. The seed is deliberately built so distinct ≠ count and so fan-out and no-fan-out both occur — good test design.

Findings

# Severity Location Issue
1 Medium multi_fact_join_groups.rs:408 resolve(&hints)? in a speculative merge probe propagates its error and fails a query that plans fine on master. Should skip the pair, matching the deliberate let Ok(…) else treatment three lines below.
2 Medium (perf) multi_fact_join_groups.rs:328 any_cube_has_pre_aggregations is evaluated before anything checks a merge is possible, so every ordinary single-group query pays one native-bridge call per joined cube for a result that gets discarded.
3 Low measure_kinds/mod.rs:323 min/max satisfy the predicate's own stated criterion (idempotent under replication, same SQL) but are excluded because is_distinct() covers only the two count-distinct types. Conservative, not wrong — but a common measure class left on the table.
4 Low query_tools.rs:31 Doc claims "keeps walking the same path"; the check is edge-set containment, so a sibling branch also qualifies. Still safe (added edges are LEFT), but the comment overstates the invariant.
5 Low (test) nested_join_trees.rs:32 base_scan_count counts AS "sites" — couples six assertions to alias rendering. Plus two gaps: no merging query with a filter/time dimension on the deeper cube, and the pre_aggregation_query guard is untested (ungrouped is covered).

Security: nothing relevant — no new input paths, no user-controlled SQL, no auth surface. The added SQL text is entirely planner-generated from already-validated members.

Praise

The doc comments are the strongest part of this PR — merge_nested_groups and group_survives_join explain why each condition exists, including the non-obvious multiplicationFactor-over-cubesToJoin trap, and survives_row_multiplication explicitly names its relationship to the neighbouring regular_in_multiplied so the next reader doesn't "simplify" one into the other. The key != other.key assertion on the rebuilt tree is the right instinct: it converts an assumption into a checked invariant with a safe fallback. The PR description's honesty about the coarse pre-aggregation guard and the is_simple_query()-only reach is also appreciated.

Notes on scope decisions I agree with

  • Not merging in for_measures is correct — regrouping a subset after the plan exists would cost rollups.
  • Keeping the star-shaped multi-fact case split is right and clearly explained; test_sibling_trees_do_not_merge pins it.
  • Deferring the two-pass TopLevelPlanner work is the right call for this PR's size.
• branch `tesseract-multi-fact-shared-base-cte`

Comment thread rust/cube/cubesqlplanner/cubesqlplanner/src/planner/multi_fact_join_groups.rs Outdated
Comment thread rust/cube/cubesqlplanner/cubesqlplanner/src/planner/multi_fact_join_groups.rs Outdated
Comment thread rust/cube/cubesqlplanner/cubesqlplanner/src/planner/query_tools.rs

/// How many times the query reads the cube at the root of the join. One read
/// per group is exactly the duplication that folding nested groups removes.
fn base_scan_count(sql: &str) -> usize {

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.

Counting AS "sites" occurrences is a reasonable proxy but couples these assertions to alias rendering — a change to how the base cube is aliased silently turns all six assert_eq!(base_scan_count(...), n) into vacuous 0 == n failures (loud, so acceptable) or, worse, into passes if the alias appears elsewhere. If there is an existing helper that counts logical scans / join groups in the plan, prefer it; otherwise consider asserting on num_groups() from MultiFactJoinGroups for the structural part and keeping the SQL check only where the rendered shape is the point.

Two coverage gaps worth a test each, since they are the guards most likely to be broken by future edits:

  • a merging query that also carries a filter or time dimension on the deeper cube (confirms base hints/filters don't change the merge decision or the values),
  • the pre_aggregation_query branch in query_properties.rs (ungrouped is covered by test_nested_trees_ungrouped_keeps_separate_scans, pre_aggregation_query is not).

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.42%. Comparing base (3b0c5b7) to head (9b9d000).
⚠️ Report is 3 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11643       +/-   ##
===========================================
+ Coverage   59.36%   84.42%   +25.05%     
===========================================
  Files         229      260       +31     
  Lines       18241    84473    +66232     
  Branches     3679        0     -3679     
===========================================
+ Hits        10829    71313    +60484     
- Misses       6861    13160     +6299     
+ Partials      551        0      -551     
Flag Coverage Δ
cube-backend ?
cubesql 84.42% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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.

…cheaply

Resolving the union of two groups' join hints asks the join graph a
hypothetical question. A hint set it refuses was propagating out and failing a
query that plans fine without the merge; it now declines the candidate, the
way an unmergeable measure already did.

The pre-aggregation guard also ran before anything checked whether a merge was
possible at all, so every ordinary single-group query paid a bridge call per
cube in its join to compute an answer nothing read. The nested-pair test is
pure comparison, so it goes first.

Covers the two guards that had no test of their own: a merge alongside a
filter on either cube, and the pre-aggregation build path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 1m 38s —— View job


Re-review of 902fa44: both medium findings fixed and correctly tested. No blockers; 2 low items remain (min/max, alias-coupled assertion).

Full re-review

Todo

  • Read new commit 902fa44 diff (git show 902fa44)
  • Verify findings 1 (probe error) & 2 (pre-agg gating) are actually fixed
  • Check the new tests, snapshot values against the seed
  • Re-check remaining findings (3, 4, 5) against the new diff
  • Resolve stale review threads
  • Post updated summary

What changed since my last review

902fa44 addresses three of the five findings.

Finding 1 — probe error propagation: fixed (multi_fact_join_groups.rs:426). resolve(&hints)? is now let Ok((key, tree)) = resolve(&hints) else { continue }, with a comment stating the reason. This closes the only path by which the optimization could turn a query that plans on master into a failing one. The remaining ? in group_survives_join is has_multi_stage_members, which walks the measure symbol independently of the candidate tree — not a probe, so propagating is right there.

Finding 2 — guard ordering: fixed, and with the better fix. The new has_nested_pair (:344) is a pure in-memory is_nested_in scan and runs before any_cube_has_pre_aggregations, so the sibling multi-fact case (orders/returns) also skips the bridge calls — not just the single-group case my groups.len() > 1 suggestion would have covered. is_nested_in requires strictly fewer joins, so the self-pair can't false-positive and no i != j guard is needed.

Finding 4 — doc comment: fixed (query_tools.rs:26-31). Now says "containment of the edge set, not of a path" and explicitly notes other may extend along a sibling branch, with the reason that is still safe.

Finding 5 — the two coverage gaps are closed. test_nested_trees_merge_with_filter_on_shallower_cube, test_nested_trees_filter_on_deeper_cube_leaves_one_tree, and test_nested_trees_pre_aggregation_query_keeps_separate_scans land exactly the cases I flagged. The base_scan_count alias-coupling half of that comment is unaddressed — left unresolved as a nit, not a request.

New snapshots verified by hand

Both re-derived from the seed rather than trusted:

  • filter carts.msid = 'm1' → carts 1, 2 (both site 1, US). carts.unique_msid = |{m1}| = 1 ✓. Their checkouts are 1 (x1) and 2 (x2) → 2 ✓. Site 2 (cart 3, m2) and site 3 (cart 4, m3) are filtered out, so a single US row ✓. Note this is the interesting direction: the filtered cube is the shallower one, the trees stay nested, and the merge still fires (base_scan_count == 1) — while count-style measures would still have split.
  • filter checkouts.msid = 'x1' → checkouts 1 (cart 1, site 1) and 3 (cart 3, site 2), both US. carts distinct = |{m1, m2}| = 2 ✓, checkouts distinct = |{x1}| = 1 ✓. base_scan_count == 1 here is not a merge — the filter pulls checkouts into the base hints so both measures resolve to one tree from the start. The doc comment on the test says precisely that, which is worth keeping; a future reader could easily misread the 1 as evidence the merge fired.
  • pre_aggregation_query: true → asserts 2, pinning the guard in query_properties.rs. Good choice to make this a plain #[test] (no PG needed) since only the plan shape matters.

Remaining findings

# Severity Location Status
3 Low measure_kinds/mod.rs:323 Open — min/max are idempotent under replication and render identically, but is_distinct() excludes them. Conservative in the safe direction; fine as a follow-up.
5 Low (test) nested_join_trees.rs:32 Partially addressed — coverage gaps closed; base_scan_count still counts AS "sites", coupling seven assertions to alias rendering.

Findings 1, 2 and 4 resolved on their threads.

Verification I could not run

cargo check / cargo test are not in this job's allowlist, so I reviewed the new code by reading it. The changes are small and locally type-obvious (an if let ... else { continue } inside the labeled loop; a &[GroupBuild]bool helper whose immutable borrow ends before merge_nested_groups takes &mut), but CI is the authority on that. The PR states 1287 tests green on real Postgres at b2ddcb2; the three new tests here are additive and the two new snapshots are the only new expected output.

Verdict

The two things I'd have wanted changed before merge are changed, and the fix for finding 2 is better than what I proposed. The safety argument I traced at b2ddcb2 is untouched by this commit — same LEFT-join premise, same per-measure render-form resolution, same key != other.key rebuild assertion. Ship-ready from my side; the two low items are follow-up material.

• branch `tesseract-multi-fact-shared-base-cte`

A minimum or a maximum does not move when a value it has already seen arrives
again, so replication by the wider join tree leaves it where it was — the same
grounds a distinct count merges on. Both were being turned down.

The question belongs on the aggregation type, next to the two predicates that
were already there and neither of which answers it: `is_additive` covers `sum`,
which counts every row it is given, and leaves out `countDistinct`;
`is_distinct` leaves out `min` and `max`. `is_duplicate_insensitive` names the
property the merge actually depends on.

Nothing changes for how a multiplied `min`/`max` is classified — that is still
decided from the measure's own tree, where the measure stays regular; this only
lets it move into a tree that fans it out without changing its value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 2m 20s —— View job


Re-review of 9b9d000: min/max merging is sound and correctly tested. All findings from my earlier passes are now addressed. No blockers.

Full re-review

Todo

  • Read the new commit (git diff 902fa44..HEAD)
  • Verify the min/max safety argument end-to-end
  • Check is_duplicate_insensitive against the full AggregationType enum
  • Re-derive the three new snapshots from the seed by hand
  • Re-check the linchpin assumption (rebuild can't flip an existing multiplicationFactor)
  • Resolve the now-addressed thread, post summary

What changed since 902fa44

9b9d000 implements finding 3 — the last open non-nit item — and does it better than my suggestion did.

Rather than inlining a matches! over four variants at the MeasureKind level, it introduces AggregationType::is_duplicate_insensitive() (aggregation_type.rs:54) and has survives_row_multiplication call it. That's the right home for the property: it's a fact about the aggregation function, not about the measure kind, and it now sits next to is_additive/is_distinct where the next reader will find it. The doc comment explicitly contrasts it with is_additive"sum is additive but sensitive to repeats, countDistinct is insensitive to them but not additive" — which pre-empts exactly the conflation someone would otherwise make. test_is_duplicate_insensitive pins all seven enum variants, so a new aggregation type can't silently default into the safe set.

The doc on survives_row_multiplication was also corrected, and the correction is substantive rather than cosmetic: it no longer claims to be "strictly narrower" than regular_in_multiplied (which is now false in both directions) and names the asymmetry in each direction.

Safety verified, not assumed

MIN/MAX are genuinely immune here, for the right reason. Two things both have to hold, and both do:

  1. The value doesn't move under replication. The merge only ever adds LEFT joins, so rows are replicated, never added or dropped. MIN/MAX over a multiset are unchanged by repeating an element. Note this argument needs the "never added" half too — a RIGHT/FULL join could introduce a NULL-extended row on the measure's own cube and would move a MIN. logical_join.rs only ever emits left_join_cube/left_join_source, so that's structural.
  2. The rendered SQL is identical either way. into_multiplied leaves Aggregated(_) untouched (measure_kinds/mod.rs:268), so there is no "switch to the distinct form" step that a Count needs and that the render-form resolution — done per measure against its own tree — would not know to perform. This is exactly why Count must stay refused while Min/Max may pass, and the code draws the line in the right place.

The fixture exercises the interesting direction. sites --one_to_many--> carts means carts is not multiplied in the narrow sites→carts tree, so carts.max_value classifies as a regular measure; adding checkouts makes carts multiplied in the merged tree. So the test genuinely covers "measure rendered by the narrow tree's classification, executed over the wider tree" — the case the predicate exists for, not a vacuous one.

Re-confirmed the linchpin. JoinGraph.ts:205 computes findMultiplicationFactorFor(cube, join.joins) — a function of the join edges only, with cubesToJoin deciding merely which cubes get an entry. Since the rebuild asserts key == other.key (same root, same edges), it can only add entries and provably cannot flip one, so the target group's own measures keep their classification. That closes the one way this could have gone wrong quietly.

New snapshots re-derived from the seed

All three checked by hand rather than accepted:

  • min_max_share_one_base_scan — US = sites 1+2 → carts 1 (10), 2 (20), 3 (30): max 30, min 10 ✓. DE = site 3 → cart 4 (40): 40/40 ✓. checkouts.unique_msid: US = {x1, x2} ∪ {x1} = 2 ✓, DE = 0 ✓ (cart 4 has no checkouts — the LEFT JOIN-preserves-the-row case).
  • min_max_alone — identical min/max columns to the merged query. This is the assertion that matters: the numbers are byte-identical with and without the fan-out present, which is the whole claim. ✓
  • avg_keeps_its_own_scan — US = avg(10,20,30) = 20, DE = 40 ✓, with base_scan_count == 2. Good negative control: avg is the one addition to the fixture that must not merge, and if is_duplicate_insensitive ever wrongly admitted it, both the scan count and (given cart 1's two checkouts) the US value would change — US would become avg(10,10,20,30) = 17.5. The seed makes the wrong answer visibly wrong, which is the point of a negative test.

Findings

Nothing new. Status of the five findings across the three review passes:

# Severity Status
1 Medium Fixed in 902fa44 — probe no longer propagates its error
2 Medium (perf) Fixed in 902fa44has_nested_pair gates the bridge calls
3 Low Fixed in 9b9d000min/max now merge; thread resolved
4 Low Fixed in 902fa44 — doc corrected to edge-set containment
5 Low (test) Coverage gaps closed in 902fa44; the base_scan_count-counts-AS "sites" nit remains, deliberately left as a nit

Follow-up worth considering (not for this PR)

regular_in_multiplied still turns down a multiplied Min/Max, sending it to a multiplied subquery with its own keys CTE. By the same argument this commit just made, that subquery is unnecessary — MIN/MAX over a fanned-out join equals the value over the un-fanned one, with no form switch. Widening regular_in_multiplied the same way would remove a whole CTE from a common class of query, but it would move many existing snapshots and deserves its own PR. The new doc comment already flags the asymmetry, which is the right amount of signal to leave behind.

Verification I could not run

cargo check / cargo test are outside this job's allowlist, so the new code was reviewed by reading. It is small and locally type-obvious (one new matches! predicate, one call-site swap, three additive tests, three new snapshot files), and CI is the authority. The two fixture YAMLs gained the same three measures, so no existing test in either file changes shape.

Verdict

Ship-ready. The commit closes the last substantive item, the property it relies on is now named and unit-pinned rather than implied, and the tests include a negative control that would actually fail if the predicate were wrong. Only the alias-coupled base_scan_count nit remains open, and that is follow-up material at most.

• branch `tesseract-multi-fact-shared-base-cte`

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

Labels

rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants