This is an automated email from the ASF dual-hosted git repository.

kou pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git


The following commit(s) were added to refs/heads/main by this push:
     new 2db03408aae GH-51663: [C++][Gandiva] Race condition with Gandiva 
expressions (#51665)
2db03408aae is described below

commit 2db03408aaefad7edaf48831b37ed6047a80bce4
Author: Logan Riggs <[email protected]>
AuthorDate: Fri Oct 23 06:53:02 2026 -0700

    GH-51663: [C++][Gandiva] Race condition with Gandiva expressions (#51665)
    
    ### Rationale for this change
    
    I originally identified [this issue in 
arrow-java](https://github.com/apache/arrow-java/issues/601) and fixed it on 
the arrow-java side with a coarse grained lock on the Projector and Filter Make 
methods. That fixed the issue but adds some undesirable overhead in certain 
multi threaded workflows.
    This issue captures the deeper problem that is occurring in the C++ side of 
Gandiva and was not touched by my previous fix though it is prevented from 
happening. I have a proposed solution for this and believe it is a better 
overall solution.
    
    `Projector::Make()` and `Filter::Make()` read the shared expression cache 
once to decide the `is_cached` status and then call `SetLLVMObjectCache()`. 
That performs its own second, unsynchronized read of the same key before 
pre-loading a cached object into the LLJIT.
    
    Those two reads could disagree. If another thread compiling the identical 
(schema, expressions, selection vector mode, configuration) tuple inserted 
between them, the first thread would:
    
    1. take the `is_cached == false` path, generating `expr_0_0` into its IR 
module, and
    2. also see a hit on the second read and `addObjectFile()` a cached object 
that defines `expr_0_0` as well.
    
    Both then call `JITDylib::define` for the same symbol in the same 
`JITDylib`, and ORC's duplicate-symbol detection fires:
    
    ```text
    CodeGenError in Gandiva: Failed to add IR module to LLJIT:
    In gdv_module_..., duplicate definition of symbol 'expr_0_0'
    ```
    
    ### What changes are included in this PR?
    
    Thread the single cache-lookup result (`prev_cached_obj`) that already 
determined `is_cached` directly into `SetLLVMObjectCache()`, instead of letting 
it perform an independent second lookup.
    
    `Engine::SetLLVMObjectCache()` and `LLVMGenerator::SetLLVMObjectCache()` 
now take the resolved `std::shared_ptr<llvm::MemoryBuffer>` rather than a 
`GandivaObjectCache&`. `Engine::SetLLVMObjectCache()` passes a copy of the 
buffer to `addObjectFile()`, as the old `GandivaObjectCache::getObject()` did, 
because the shared cache still owns the original buffer.
    
    No new locking — this removes the window rather than serializing around it.
    
    ### Are these changes tested?
    
    This adds a new deterministic regression test, 
`cpp/src/gandiva/tests/cache_race_test.cc`, which is part of 
`gandiva-projector-test`.
    
    The test doesn't depend on thread timing. It registers an 
`ExportedFuncsBase` hook that inserts an entry for the key being built into the 
shared cache during `Engine::Init()`. That is after `Make()`'s first cache 
lookup and before the (pre-fix) second lookup in `SetLLVMObjectCache()`, so the 
race window is reproduced on every run. It covers both `Projector::Make()` and 
`Filter::Make()`, and checks that the built projector/filter evaluates 
correctly.
    
    Verified the tests fail with the duplicate-symbol error without this fix.
    
    Verified internally with our product stress tests after removing the 
arrow-java side of the fix. Once this change is in C++ I can make the java 
change to remove the synchronization.
    
    ### Are there any user-facing changes?
    
    **This PR includes breaking changes to public APIs.**
    
    Engine::SetLLVMObjectCache and LLVMGenerator::SetLLVMObjectCache are 
GANDIVA_EXPORT so this changes an exported signature. In-tree there are no 
other callers, and the Gandiva Java side JNI only uses 
Projector::Make/Filter::Make, which are unchanged.
    
    ### Was AI used for this PR?
    
    AI was used to diagnose the problem, write code and tests over several 
iterations before being reviewed by two human developers. A human ran several 
manual and regression tests against the change. One human reviewed and heavily 
edited the PR description and also wrote some himself.
    
    **PR code and description written by:**
    
    - [X] Human
    - [X] AI
    
    **Reviewed before submission by:**
    
    - [X] Human
    - [X] AI
    - [ ] Not reviewed
    
    * GitHub Issue: #51663
    
    Authored-by: [email protected] <[email protected]>
    Signed-off-by: Sutou Kouhei <[email protected]>
---
 cpp/src/gandiva/engine.cc                |  11 +-
 cpp/src/gandiva/engine.h                 |   8 +-
 cpp/src/gandiva/filter.cc                |   7 +-
 cpp/src/gandiva/llvm_generator.cc        |   5 +-
 cpp/src/gandiva/llvm_generator.h         |   7 +-
 cpp/src/gandiva/projector.cc             |   7 +-
 cpp/src/gandiva/tests/CMakeLists.txt     |   1 +
 cpp/src/gandiva/tests/cache_race_test.cc | 212 +++++++++++++++++++++++++++++++
 8 files changed, 245 insertions(+), 13 deletions(-)

diff --git a/cpp/src/gandiva/engine.cc b/cpp/src/gandiva/engine.cc
index 205d2f866b2..e208bb3dc0e 100644
--- a/cpp/src/gandiva/engine.cc
+++ b/cpp/src/gandiva/engine.cc
@@ -304,9 +304,14 @@ void RemoveBuildTargetAttributes(llvm::Module& module) {
 
 }  // namespace
 
-Status Engine::SetLLVMObjectCache(GandivaObjectCache& object_cache) {
-  auto cached_buffer = object_cache.getObject(nullptr);
-  if (cached_buffer) {
+Status Engine::SetLLVMObjectCache(
+    const std::shared_ptr<llvm::MemoryBuffer>& prev_cached_obj) {
+  if (prev_cached_obj) {
+    // Copy rather than hand over the cache's own buffer -- addObjectFile takes
+    // ownership and may free it, but the shared cache map holds a reference 
to the
+    // same object too.
+    auto cached_buffer = llvm::MemoryBuffer::getMemBufferCopy(
+        prev_cached_obj->getBuffer(), prev_cached_obj->getBufferIdentifier());
     auto error = lljit_->addObjectFile(std::move(cached_buffer));
     if (error) {
       return Status::CodeGenError("Failed to add cached object file to LLJIT: 
",
diff --git a/cpp/src/gandiva/engine.h b/cpp/src/gandiva/engine.h
index 20165787cb6..23fc366fe15 100644
--- a/cpp/src/gandiva/engine.h
+++ b/cpp/src/gandiva/engine.h
@@ -74,8 +74,12 @@ class GANDIVA_EXPORT Engine {
   /// Optimise and compile the module.
   Status FinalizeModule();
 
-  /// Set LLVM ObjectCache.
-  Status SetLLVMObjectCache(GandivaObjectCache& object_cache);
+  /// Pre-load the LLJIT with an already-compiled object for the current 
module, if
+  /// one was found by the caller's own cache lookup. Takes the lookup result 
directly
+  /// rather than re-querying the cache, so this can never disagree with the 
\p cached
+  /// flag passed to \p Make -- avoiding the load being based on a newer cache 
state
+  /// than the one that decided whether Make() would still build (and load) 
fresh IR.
+  Status SetLLVMObjectCache(const std::shared_ptr<llvm::MemoryBuffer>& 
prev_cached_obj);
 
   /// Get the compiled function corresponding to the irfunction.
   Result<void*> CompiledFunction(const std::string& function);
diff --git a/cpp/src/gandiva/filter.cc b/cpp/src/gandiva/filter.cc
index 8a270cfdc06..815516b6969 100644
--- a/cpp/src/gandiva/filter.cc
+++ b/cpp/src/gandiva/filter.cc
@@ -76,8 +76,11 @@ Status Filter::Make(SchemaPtr schema, ConditionPtr condition,
     ARROW_RETURN_NOT_OK(expr_validator.Validate(condition));
   }
 
-  // Set the object cache for LLVM
-  ARROW_RETURN_NOT_OK(llvm_gen->SetLLVMObjectCache(obj_cache));
+  // Pre-load the already-compiled object from the cache lookup above, if 
there was
+  // one. Reuses prev_cached_obj instead of letting SetLLVMObjectCache 
re-query the
+  // shared cache, so this can never disagree with the is_cached snapshot that
+  // decided whether Build() below still compiles.
+  ARROW_RETURN_NOT_OK(llvm_gen->SetLLVMObjectCache(prev_cached_obj));
 
   ARROW_RETURN_NOT_OK(llvm_gen->Build({condition}, 
SelectionVector::Mode::MODE_NONE));
 
diff --git a/cpp/src/gandiva/llvm_generator.cc 
b/cpp/src/gandiva/llvm_generator.cc
index 7caafa58369..7aa91c4dd6b 100644
--- a/cpp/src/gandiva/llvm_generator.cc
+++ b/cpp/src/gandiva/llvm_generator.cc
@@ -64,8 +64,9 @@ LLVMGenerator::GetCache() {
   return shared_cache;
 }
 
-Status LLVMGenerator::SetLLVMObjectCache(GandivaObjectCache& object_cache) {
-  return engine_->SetLLVMObjectCache(object_cache);
+Status LLVMGenerator::SetLLVMObjectCache(
+    const std::shared_ptr<llvm::MemoryBuffer>& prev_cached_obj) {
+  return engine_->SetLLVMObjectCache(prev_cached_obj);
 }
 
 Status LLVMGenerator::Add(const ExpressionPtr expr, const FieldDescriptorPtr 
output) {
diff --git a/cpp/src/gandiva/llvm_generator.h b/cpp/src/gandiva/llvm_generator.h
index c6e4e821dfe..0c796b4b2a2 100644
--- a/cpp/src/gandiva/llvm_generator.h
+++ b/cpp/src/gandiva/llvm_generator.h
@@ -58,8 +58,11 @@ class GANDIVA_EXPORT LLVMGenerator {
   static std::shared_ptr<Cache<ExpressionCacheKey, 
std::shared_ptr<llvm::MemoryBuffer>>>
   GetCache();
 
-  /// \brief Set LLVM ObjectCache.
-  Status SetLLVMObjectCache(GandivaObjectCache& object_cache);
+  /// \brief Pre-load the engine with an already-compiled object for the 
current
+  /// module, if the caller's own cache lookup found one. See the equivalent 
Engine
+  /// method for why this takes the lookup result directly instead of 
re-querying
+  /// the cache.
+  Status SetLLVMObjectCache(const std::shared_ptr<llvm::MemoryBuffer>& 
prev_cached_obj);
 
   /// \brief Build the code for the expression trees for default mode with a 
LLVM
   /// ObjectCache. Each element in the vector represents an expression tree
diff --git a/cpp/src/gandiva/projector.cc b/cpp/src/gandiva/projector.cc
index ec0302146ff..b6c11ce0477 100644
--- a/cpp/src/gandiva/projector.cc
+++ b/cpp/src/gandiva/projector.cc
@@ -94,8 +94,11 @@ Status Projector::Make(SchemaPtr schema, const 
ExpressionVector& exprs,
     }
   }
 
-  // Set the object cache for LLVM
-  ARROW_RETURN_NOT_OK(llvm_gen->SetLLVMObjectCache(obj_cache));
+  // Pre-load the already-compiled object from the cache lookup above, if 
there was
+  // one. Reuses prev_cached_obj instead of letting SetLLVMObjectCache 
re-query the
+  // shared cache, so this can never disagree with the is_cached snapshot that
+  // decided whether Build() below still compiles.
+  ARROW_RETURN_NOT_OK(llvm_gen->SetLLVMObjectCache(prev_cached_obj));
 
   ARROW_RETURN_NOT_OK(llvm_gen->Build(exprs, selection_vector_mode));
 
diff --git a/cpp/src/gandiva/tests/CMakeLists.txt 
b/cpp/src/gandiva/tests/CMakeLists.txt
index be635e16d3a..7746645ad9a 100644
--- a/cpp/src/gandiva/tests/CMakeLists.txt
+++ b/cpp/src/gandiva/tests/CMakeLists.txt
@@ -19,6 +19,7 @@ add_gandiva_test(projector-test
                  SOURCES
                  binary_test.cc
                  boolean_expr_test.cc
+                 cache_race_test.cc
                  date_time_test.cc
                  decimal_alignment_test.cc
                  decimal_single_test.cc
diff --git a/cpp/src/gandiva/tests/cache_race_test.cc 
b/cpp/src/gandiva/tests/cache_race_test.cc
new file mode 100644
index 00000000000..ae7677105ec
--- /dev/null
+++ b/cpp/src/gandiva/tests/cache_race_test.cc
@@ -0,0 +1,212 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Deterministic regression test for GH-51663.
+//
+// Projector::Make() and Filter::Make() read the shared expression cache once 
to decide
+// `is_cached`. Before the fix, SetLLVMObjectCache() performed a *second*, 
independent
+// read of the same key. A thread that saw a miss on the first read and a hit 
on the
+// second one would both pre-load the cached object (defining e.g. "expr_0_0" 
in its
+// JITDylib) and add its own compiled IR module defining the same symbol, so 
LLVM ORC's
+// duplicate-symbol detection fired. (Originally reported against the Java 
bindings as
+// apache/arrow-java#601.)
+
+#include <gtest/gtest.h>
+
+#include <atomic>
+#include <memory>
+#include <utility>
+#include <vector>
+
+#include "arrow/memory_pool.h"
+#include "gandiva/cache.h"
+#include "gandiva/configuration.h"
+#include "gandiva/exported_funcs.h"
+#include "gandiva/exported_funcs_registry.h"
+#include "gandiva/expression_cache_key.h"
+#include "gandiva/filter.h"
+#include "gandiva/llvm_generator.h"
+#include "gandiva/projector.h"
+#include "gandiva/tests/test_util.h"
+#include "gandiva/tree_expr_builder.h"
+
+namespace gandiva {
+
+using arrow::boolean;
+using arrow::int32;
+
+namespace {
+
+using ObjectCacheType = Cache<ExpressionCacheKey, 
std::shared_ptr<llvm::MemoryBuffer>>;
+
+struct PendingInjection {
+  std::shared_ptr<ObjectCacheType> cache;
+  std::shared_ptr<ExpressionCacheKey> key;
+  std::shared_ptr<llvm::MemoryBuffer> object;
+  std::atomic<bool> armed{false};
+};
+
+PendingInjection* GetPendingInjection() {
+  static PendingInjection instance;
+  return &instance;
+}
+
+// Registered once; fires only while armed.
+class CacheRaceInjector : public ExportedFuncsBase {
+ public:
+  arrow::Status AddMappings(Engine* engine) const override {
+    auto* pending = GetPendingInjection();
+    // exchange() disarms, so this injects exactly once per arming even though 
the
+    // registry keeps calling us on every subsequent Engine::Init().
+    if (pending->armed.exchange(false)) {
+      pending->cache->PutObjectCode(*pending->key, pending->object);
+    }
+    return arrow::Status::OK();
+  }
+};
+
+REGISTER_EXPORTED_FUNCS(CacheRaceInjector);
+
+// Arms the injection that will run inside the next Make()'s engine 
construction.
+void ArmInjection(std::shared_ptr<ObjectCacheType> cache,
+                  std::shared_ptr<ExpressionCacheKey> key,
+                  std::shared_ptr<llvm::MemoryBuffer> object) {
+  auto* pending = GetPendingInjection();
+  pending->cache = std::move(cache);
+  pending->key = std::move(key);
+  pending->object = std::move(object);
+  pending->armed.store(true);
+}
+
+}  // namespace
+
+class TestCacheRace : public ::testing::Test {
+ public:
+  void SetUp() { pool_ = arrow::default_memory_pool(); }
+
+  void TearDown() { GetPendingInjection()->armed.store(false); }
+
+ protected:
+  arrow::MemoryPool* pool_;
+};
+
+TEST_F(TestCacheRace, TestProjectorCacheInsertedDuringMake) {
+  auto field0 = field("f0", int32());
+  auto field1 = field("f1", int32());
+  auto schema = arrow::schema({field0, field1});
+  auto field_sum = field("add", int32());
+  auto sum_expr = TreeExprBuilder::MakeExpression("add", {field0, field1}, 
field_sum);
+  ExpressionVector exprs = {sum_expr};
+
+  // Two separately built configurations with identical content: same hash, 
different
+  // keys.
+  auto config_a = ConfigurationBuilder().build();
+  auto config_b = ConfigurationBuilder().build();
+
+  // Build once under config_a so the expression is cached.
+  std::shared_ptr<Projector> warm_projector;
+  ASSERT_OK(Projector::Make(schema, exprs, config_a, &warm_projector));
+
+  auto cache = LLVMGenerator::GetCache();
+  ExpressionCacheKey key_a(schema, config_a, exprs, 
SelectionVector::Mode::MODE_NONE);
+  auto cached_object = cache->GetObjectCode(key_a);
+  ASSERT_NE(cached_object, nullptr) << "expected the first Make() to populate 
the cache";
+
+  // config_b has never been built, so Make()'s first read is a guaranteed 
miss.
+  auto key_b = std::make_shared<ExpressionCacheKey>(schema, config_b, exprs,
+                                                    
SelectionVector::Mode::MODE_NONE);
+  ASSERT_EQ(cache->GetObjectCode(*key_b), nullptr) << "key_b should start 
uncached";
+
+  // Fires inside Engine::Init(), i.e. after the first read and before the 
(pre-fix)
+  // second.
+  ArmInjection(cache, key_b, cached_object);
+
+  std::shared_ptr<Projector> projector;
+  auto status = Projector::Make(schema, exprs, config_b, &projector);
+
+  ASSERT_OK(status) << "a cache entry appearing mid-Make() must not produce a 
duplicate "
+                       "symbol -- see GH-51663";
+  ASSERT_NE(projector, nullptr);
+  EXPECT_FALSE(GetPendingInjection()->armed.load())
+      << "injection never fired; the window this test targets was not reached";
+
+  // The projector must also be usable: a silently wrong module is as bad as 
an error.
+  int num_records = 4;
+  auto array0 = MakeArrowArrayInt32({1, 2, 3, 4}, {true, true, true, true});
+  auto array1 = MakeArrowArrayInt32({11, 13, 15, 17}, {true, true, true, 
true});
+  auto exp_sum = MakeArrowArrayInt32({12, 15, 18, 21}, {true, true, true, 
true});
+  auto in_batch = arrow::RecordBatch::Make(schema, num_records, {array0, 
array1});
+
+  arrow::ArrayVector outputs;
+  ASSERT_OK(projector->Evaluate(*in_batch, pool_, &outputs));
+  EXPECT_ARROW_ARRAY_EQUALS(exp_sum, outputs.at(0));
+}
+
+TEST_F(TestCacheRace, TestFilterCacheInsertedDuringMake) {
+  auto field0 = field("g0", int32());
+  auto field1 = field("g1", int32());
+  auto schema = arrow::schema({field0, field1});
+
+  // g0 + g1 < 10
+  auto node_f0 = TreeExprBuilder::MakeField(field0);
+  auto node_f1 = TreeExprBuilder::MakeField(field1);
+  auto sum_func = TreeExprBuilder::MakeFunction("add", {node_f0, node_f1}, 
int32());
+  auto literal_10 = TreeExprBuilder::MakeLiteral(static_cast<int32_t>(10));
+  auto less_than_10 =
+      TreeExprBuilder::MakeFunction("less_than", {sum_func, literal_10}, 
boolean());
+  auto condition = TreeExprBuilder::MakeCondition(less_than_10);
+
+  auto config_a = ConfigurationBuilder().build();
+  auto config_b = ConfigurationBuilder().build();
+
+  std::shared_ptr<Filter> warm_filter;
+  ASSERT_OK(Filter::Make(schema, condition, config_a, &warm_filter));
+
+  auto cache = LLVMGenerator::GetCache();
+  // Filter::Make uses the Condition overload of the key.
+  Condition condition_for_key = *(condition.get());
+  ExpressionCacheKey key_a(schema, config_a, condition_for_key);
+  auto cached_object = cache->GetObjectCode(key_a);
+  ASSERT_NE(cached_object, nullptr) << "expected the first Make() to populate 
the cache";
+
+  auto key_b = std::make_shared<ExpressionCacheKey>(schema, config_b, 
condition_for_key);
+  ASSERT_EQ(cache->GetObjectCode(*key_b), nullptr) << "key_b should start 
uncached";
+
+  ArmInjection(cache, key_b, cached_object);
+
+  std::shared_ptr<Filter> filter;
+  auto status = Filter::Make(schema, condition, config_b, &filter);
+
+  ASSERT_OK(status) << "a cache entry appearing mid-Make() must not produce a 
duplicate "
+                       "symbol -- see GH-51663";
+  ASSERT_NE(filter, nullptr);
+  EXPECT_FALSE(GetPendingInjection()->armed.load())
+      << "injection never fired; the window this test targets was not reached";
+
+  int num_records = 5;
+  auto array0 = MakeArrowArrayInt32({1, 2, 3, 4, 6}, {true, true, true, false, 
true});
+  auto array1 = MakeArrowArrayInt32({5, 9, 6, 17, 3}, {true, true, false, 
true, true});
+  auto exp = MakeArrowArrayUint16({0, 4});
+  auto in_batch = arrow::RecordBatch::Make(schema, num_records, {array0, 
array1});
+
+  std::shared_ptr<SelectionVector> selection_vector;
+  ASSERT_OK(SelectionVector::MakeInt16(num_records, pool_, &selection_vector));
+  ASSERT_OK(filter->Evaluate(*in_batch, selection_vector));
+  EXPECT_ARROW_ARRAY_EQUALS(exp, selection_vector->ToArray());
+}
+
+}  // namespace gandiva

Reply via email to