From 80ecd3a75d42fda94c3fdc0556f83f03a7ea7cdf Mon Sep 17 00:00:00 2001 From: krynju Date: Sat, 23 May 2026 20:29:19 +0200 Subject: [PATCH] fix(join): declare loop-spawned closure locals to avoid soft-scope race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In `_join(::Symbol, ::Any, ::DTable)` the `process_one_chunk` closure assigned to `inner_l`, `inner_r`, and `outer_l`. Those same identifiers are also assigned later in the enclosing `_join` body (the `intersect`/`Vector{UInt}()` initialization for the outer-row merge). Under Julia's soft-scope rules that turns them into captured bindings shared between the enclosing function and every concurrent `Dagger.@spawn`ed invocation of the closure. With N r-chunks the closure runs N times in parallel, all writing to the same three binding cells. The tuple destructure `inner_l, inner_r = match_inner_indices(...)` is two stores, so a context switch between them lets one task observe another task's `inner_l` paired with its own `inner_r` — mismatched lengths trigger a `BoundsError` in `build_joined_table`, and even when lengths happen to match, `find_outer_indices` runs against the wrong `inner_l` and the final merge ends up with extra rows. Declaring `local inner_l, inner_r, outer_l` in the closure body forces fresh per-invocation bindings and resolves the race. Re-enables the previously commented-out `lj10`/`ij10` `DTable`-on-`DTable` join tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/operations/join_interface.jl | 5 +++++ test/table.jl | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/operations/join_interface.jl b/src/operations/join_interface.jl index 9127cba..31c1065 100644 --- a/src/operations/join_interface.jl +++ b/src/operations/join_interface.jl @@ -158,6 +158,11 @@ function _join( process_one_chunk = (type, l, r, cmp_l, cmp_r, other_r, lookup, r_sorted, l_sorted, r_unique) -> begin + # Variables below MUST be `local`: identifiers `inner_l`, `inner_r`, `outer_l` + # are also assigned in the enclosing `_join` body, so Julia's soft-scope rules + # would otherwise capture them by reference. Multiple `Dagger.@spawn`ed + # invocations of this closure would then race on the same binding cells. + local inner_l, inner_r, outer_l inner_l, inner_r = match_inner_indices( l, r, cmp_l, cmp_r, lookup, r_sorted, l_sorted, r_unique ) diff --git a/test/table.jl b/test/table.jl index bc05262..a337183 100644 --- a/test/table.jl +++ b/test/table.jl @@ -532,7 +532,7 @@ using OnlineStats @test isequal(lj1u, lj7) @test isequal(lj1, lj8) @test isequal(lj1, lj9) - # @test isequal(lj1, lj10) + @test isequal(lj1, lj10) ij1 = innerjoin(d1, d2, on=on) ij1u = innerjoin(d1, unique(d2, r_colsymbols), on=on) @@ -557,7 +557,7 @@ using OnlineStats @test isequal(ij1u, ij7) @test isequal(ij1, ij8) @test isequal(ij1, ij9) - # @test isequal(ij1, ij10) + @test isequal(ij1, ij10) @test isequal(ij1, ij11) end end