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

zanmato1984 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 065db5f6e6 GH-51495: [C++] Fix race in MergedGenerator that could drop 
an error and end the stream early (#51498)
065db5f6e6 is described below

commit 065db5f6e669befd11632c06bfd697b9b9f94fe2
Author: Kita Renji <[email protected]>
AuthorDate: Tue Sep 29 00:18:51 2026 +0700

    GH-51495: [C++] Fix race in MergedGenerator that could drop an error and 
end the stream early (#51498)
    
    ### Rationale for this change
    
    `MergedGenerator` could turn an error from a subscription into a normal 
end-of-stream. When an inner or outer subscription failed while no caller was 
waiting, the callback set `broken = true` under the mutex but only stored the 
error in `final_error` after releasing it. A call to `operator()` in that 
window saw `broken` with an OK `final_error` and returned `IterationEnd`, and 
the error was never delivered.
    
    In the dataset scanner this shows up as `ToTable()` returning a truncated 
table without raising when a fragment cannot be opened (#51495).
    
    ### What changes are included in this PR?
    
    - The first error is recorded in the same locked section that sets 
`broken`, via `SetFinalErrorUnlocked` (replacing `MarkFinalError`), together 
with the caller that will receive it, if one is already waiting. Otherwise the 
error goes to the next caller that asks, so a concurrent pull that sees 
`broken` also sees the error.
    - `all_finished` is removed, and the remaining futures are no longer 
completed through callbacks on it. `Future` callbacks have no guaranteed order 
(`future.h`): one added while a future is being marked finished may run 
immediately. With every terminal item chained on `all_finished`, a pull made 
during that dispatch could complete before an earlier future: the one receiving 
the error (pointed out in review), or an earlier `IterationEnd`.
      - Terminal pulls made once the generator is broken or exhausted are 
queued in `waiting_jobs`.
      - `MarkFinishedAndPurge` completes the remaining futures itself, in the 
order they were handed out: the future receiving the error first, then the 
waiting callers and terminal items, which get `IterationEnd`. Pulls that arrive 
meanwhile are queued behind them, and the queue is drained until it stays empty.
      - A caller that asks from the callbacks of the last pending future still 
gets an already-finished `IterationEnd`, as before.
    - A private, test-only static hook, 
`MergedGenerator<T>::error_signaled_hook_for_testing`, that runs right after 
the generator enters its error state and the mutex is released. It is empty by 
default and only reachable from the `MergedGeneratorErrorHookTest` fixture, 
which is a friend. Without it I couldn't find a way to test this 
deterministically, since no user code runs inside the window.
    - Tests, for both the inner and outer error paths:
      - 
`MergedGeneratorErrorHookTest.{Inner,Outer}ErrorNotLostToConcurrentPull`: 
nobody waiting; a pull from inside the hook must raise.
      - 
`MergedGeneratorErrorHookTest.{Inner,Outer}ErrorToWaiterNotOvertakenByLaterPull`:
 a caller already waiting must get the error before a later pull gets 
end-of-stream.
      - 
`MergedGeneratorErrorHookTest.{Inner,Outer}ErrorToWaiterNotOvertakenDuringCompletion`
 and `ClaimedErrorNotOvertakenDuringCompletion`: the later pull is made while 
the generator is completing its futures, with the earlier future's completion 
held up (via `TryAddCallback`, as in the review).
      - `MergedGeneratorTest.PullFromLastFutureCallbackCompletesAtOnce`.
      - `MergedGeneratorStressTest.TerminalNeverOvertakesEarlierFutures`: inner 
and outer failures on the thread pool; checks that no terminal item completes 
while an earlier future is pending and that exactly one error is delivered.
    
    ### Are these changes tested?
    
    Yes. `arrow-async-utility-test`.
    
    Latest commit (macOS arm64): all 138 tests pass. The `MergedGenerator` 
tests pass 300/300 with `--gtest_repeat=300` (Debug) and 30/30 under TSAN with 
no reports. The three `*DuringCompletion` tests fail every time on the previous 
commit, and the stress test found the `IterationEnd` ordering issue before the 
fix.
    
    Original commit (Linux):
    
    - With this PR: all 131 tests pass. The two new tests also passed 1000/1000 
with `--gtest_repeat=1000`.
    - With only the `final_error` change reverted (hook kept): both new tests 
fail every time (2000/2000 over `--gtest_repeat=1000`) with `Expected 
'_fut.status()' to fail with Invalid, but got OK`. The other 129 tests still 
pass.
    
    I also checked the fix end to end outside the test suite. I built 
`libarrow_dataset` from the 25.0.1 tag with the same header change and swapped 
it into the 25.0.1 wheel. The Python reproducer from the issue (going through a 
`PyFileSystem` wrapper, which is what made it reproducible for me) went from 
57/4500 truncated tables to 0 in about 11.6k runs.
    
    ### Are there any user-facing changes?
    
    A scan that hits a fragment error now always raises. Before, it could 
sometimes return a partial result.
    
    **This PR contains a "Critical Fix".** It fixes a bug where a dataset scan 
could return incorrect (truncated) results with no error.
    
    ### Was AI used for this PR?
    
    In accordance to the [AI generation 
guidelines](https://arrow.apache.org/docs/dev/developers/overview.html#ai-generated-code),
 please disclose below whether and how AI was used in this PR.
    
    **PR code and description written by:**
    
    - [ ] Human
    - [x] AI
    
    **Reviewed before submission by:**
    
    - [x] Human
    - [ ] AI
    - [ ] Not reviewed
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    * GitHub Issue: #51495
    
    Lead-authored-by: Panchawat <[email protected]>
    Co-authored-by: Panchawat <[email protected]>
    Signed-off-by: Rossi Sun <[email protected]>
---
 cpp/src/arrow/util/async_generator.h       | 195 ++++++++++++----
 cpp/src/arrow/util/async_generator_test.cc | 349 +++++++++++++++++++++++++++++
 2 files changed, 499 insertions(+), 45 deletions(-)

diff --git a/cpp/src/arrow/util/async_generator.h 
b/cpp/src/arrow/util/async_generator.h
index 8b2c4dc54d..1e8c72d2a5 100644
--- a/cpp/src/arrow/util/async_generator.h
+++ b/cpp/src/arrow/util/async_generator.h
@@ -1080,13 +1080,28 @@ class MergedGenerator {
       } else if (state_->broken ||
                  (!state_->first && state_->num_running_subscriptions == 0)) {
         // If we are broken or exhausted then prepare a terminal item but
-        // we won't complete it until we've finished.
-        Result<T> end_res = IterationEnd<T>();
-        if (!state_->final_error.ok()) {
-          end_res = state_->final_error;
-          state_->final_error = Status::OK();
+        // we won't complete it until we've finished, and not before any 
future we
+        // returned earlier.
+        if (!state_->final_error.ok() && !state_->error_sink.is_valid()) {
+          // Nobody has claimed the error yet, so this caller receives it.  
Nothing
+          // returned earlier is still waiting: that would have received the 
error.
+          if (state_->finishing) {
+            Status err = std::move(state_->final_error);
+            state_->final_error = Status::OK();
+            return Future<T>::MakeFinished(std::move(err));
+          }
+          state_->error_sink = Future<T>::Make();
+          return state_->error_sink;
+        }
+        if (state_->AllCompletedUnlocked(guard)) {
+          // Every future returned earlier has completed
+          return Future<T>::MakeFinished(IterationEnd<T>());
         }
-        return state_->all_finished.Then([end_res]() -> Result<T> { return 
end_res; });
+        // Queue it behind the waiting callers, MarkFinishedAndPurge completes 
them in
+        // order
+        auto terminal = Future<T>::Make();
+        state_->waiting_jobs.push_back(std::make_shared<Future<T>>(terminal));
+        return terminal;
       } else {
         // Otherwise we just queue the request and it will be completed when 
one of the
         // ongoing inner subscriptions delivers a result
@@ -1108,7 +1123,7 @@ class MergedGenerator {
     // subscription that delivered it (deliverer).
     if (delivered_job) {
       if (mark_generator_complete) {
-        state_->all_finished.MarkFinished();
+        state_->MarkFinishedAndPurge();
       } else {
         delivered_job->deliverer().AddCallback(
             InnerCallback(state_, delivered_job->index));
@@ -1149,6 +1164,14 @@ class MergedGenerator {
   }
 
  private:
+  friend class MergedGeneratorErrorHookTest;
+
+  // Test-only hook.  If set, it is called right after an error from an inner 
or outer
+  // subscription has put the generator in its errored state and the mutex has 
been
+  // released, so tests can pull from the generator at that exact point.  Only 
set it
+  // while no MergedGenerator<T> is in use.
+  static inline std::function<void()> error_signaled_hook_for_testing;
+
   struct DeliveredJob {
     explicit DeliveredJob(AsyncGenerator<T> deliverer_, Result<T> value_,
                           std::size_t index_)
@@ -1193,30 +1216,97 @@ class MergedGenerator {
       }
     }
 
-    // This function is called outside the mutex but it will only ever be
-    // called once
+    // This function is called outside the mutex, once all outstanding work is 
done, and
+    // it will only ever be called once.
+    //
+    // It completes the remaining futures in the order they were handed out: 
the one
+    // that receives the error, then the waiting callers (including terminal 
items
+    // requested since), which get the end of the stream.  Callers that ask 
while this
+    // runs are queued behind them, so the queue is drained until it stays 
empty.  This
+    // ordering cannot be left to callbacks: Future callbacks do not run in a 
guaranteed
+    // order, and one added while a future is being marked finished may run at 
once.
     void MarkFinishedAndPurge() {
-      all_finished.MarkFinished();
-      while (!waiting_jobs.empty()) {
-        waiting_jobs.front()->MarkFinished(IterationEnd<T>());
-        waiting_jobs.pop_front();
+      std::deque<std::shared_ptr<Future<T>>> to_complete;
+      Status err;
+      {
+        auto guard = mutex.Lock();
+        finishing = true;
+        if (error_sink.is_valid()) {
+          
to_complete.push_back(std::make_shared<Future<T>>(std::move(error_sink)));
+          error_sink = Future<T>();
+          err = std::move(final_error);
+          final_error = Status::OK();
+        }
+        for (auto& waiting_job : waiting_jobs) {
+          to_complete.push_back(std::move(waiting_job));
+        }
+        waiting_jobs.clear();
+        if (to_complete.empty()) {
+          // Nothing is pending
+          purged = true;
+          return;
+        }
+      }
+      auto complete = [&err](const std::shared_ptr<Future<T>>& fut) {
+        if (err.ok()) {
+          fut->MarkFinished(IterationEnd<T>());
+        } else {
+          fut->MarkFinished(std::move(err));
+          err = Status::OK();
+        }
+      };
+      while (true) {
+        std::shared_ptr<Future<T>> last;
+        if (!to_complete.empty()) {
+          last = std::move(to_complete.back());
+          to_complete.pop_back();
+        }
+        for (const auto& fut : to_complete) {
+          complete(fut);
+        }
+        to_complete.clear();
+        if (last) {
+          {
+            // If nobody asked meanwhile, `last` is the only future still 
pending, so a
+            // caller that asks once it has completed, e.g. from its 
callbacks, can be
+            // given its terminal item at once, as it would be after we are 
done
+            auto guard = mutex.Lock();
+            if (waiting_jobs.empty()) {
+              completing_last = last;
+            }
+          }
+          complete(last);
+        }
+        auto guard = mutex.Lock();
+        completing_last.reset();
+        if (waiting_jobs.empty()) {
+          purged = true;
+          return;
+        }
+        to_complete.swap(waiting_jobs);
       }
     }
 
-    // This is called outside the mutex but it is only ever called
-    // once and Future<>::AddCallback is thread-safe
-    void MarkFinalError(const Status& err, Future<T> maybe_sink) {
-      if (maybe_sink.is_valid()) {
-        // Someone is waiting for this error so lets mark it complete when
-        // all the work is done
-        all_finished.AddCallback([maybe_sink, err](const Status& status) 
mutable {
-          maybe_sink.MarkFinished(err);
-        });
-      } else {
-        // No one is waiting for this error right now so it will be delivered
-        // next.
-        final_error = err;
-      }
+    // True if every future handed out so far has completed.  Must be called 
with the
+    // mutex held, once we are broken or exhausted.
+    bool AllCompletedUnlocked(const util::Mutex::Guard& guard) {
+      return purged ||
+             (waiting_jobs.empty() && completing_last && 
completing_last->is_finished());
+    }
+
+    // Must be called with the mutex held, when the first error arrives, in 
the same
+    // locked section that sets `broken`: a concurrent caller that sees 
`broken` must
+    // also see the error, or it would get a plain end of stream and the error 
would be
+    // silently dropped (GH-51495).
+    //
+    // `sink` is the caller waiting for the item that failed, if any.  If 
there is none,
+    // the error goes to the next caller.  Either way it is delivered once all
+    // outstanding work is done, by MarkFinishedAndPurge (or directly, to a 
caller that
+    // asks after that has started).
+    void SetFinalErrorUnlocked(const util::Mutex::Guard& guard, const Status& 
err,
+                               Future<T> sink) {
+      final_error = err;
+      error_sink = std::move(sink);
     }
 
     bool IsCompleteUnlocked(const util::Mutex::Guard& guard) {
@@ -1239,12 +1329,9 @@ class MergedGenerator {
     // caller
     std::deque<std::shared_ptr<DeliveredJob>> delivered_jobs;
     // waiting_jobs is unbounded, reentrant pulls (e.g. AddReadahead) will 
provide the
-    // backpressure
+    // backpressure.  Once we are broken or exhausted it also holds the 
terminal items
+    // requested until MarkFinishedAndPurge completes them.
     std::deque<std::shared_ptr<Future<T>>> waiting_jobs;
-    // A future that will be marked complete when the terminal item has 
arrived and all
-    // outstanding futures have completed.  It is used to hold off emission of 
an error
-    // until all outstanding work is done.
-    Future<> all_finished = Future<>::Make();
     util::Mutex mutex;
     // A flag cleared when the caller firsts asks for a future.  Used to start 
polling.
     bool first;
@@ -1255,7 +1342,7 @@ class MergedGenerator {
     // are finishing up.
     bool source_exhausted;
     // The number of futures that we have requested from either the outer or 
inner
-    // subscriptions that have not yet completed.  We cannot mark all_finished 
until this
+    // subscriptions that have not yet completed.  We cannot finish until this
     // reaches 0.  This will never be greater than max_subscriptions
     int outstanding_requests;
     // The number of running subscriptions.  We ramp this up to 
`max_subscriptions` as
@@ -1264,12 +1351,28 @@ class MergedGenerator {
     // subscription is exhausted at which point this descends to 0 (and 
source_exhausted)
     // is then set to true.
     int num_running_subscriptions;
-    // If an error arrives, and the caller hasn't asked for that item, we 
store the error
-    // here.  It is analagous to delivered_jobs but for errors instead of 
finished
-    // results.
+    // The first error, until it is delivered.  It is analagous to 
delivered_jobs but for
+    // errors instead of finished results.  Guarded by `mutex`.
     Status final_error;
+    // The caller that will receive `final_error`, once one has asked for it.  
Guarded
+    // by `mutex`.
+    Future<T> error_sink;
+    // Set once MarkFinishedAndPurge has started.  Guarded by `mutex`.
+    bool finishing = false;
+    // Set once MarkFinishedAndPurge has completed every future handed out so 
far.
+    // Guarded by `mutex`.
+    bool purged = false;
+    // While MarkFinishedAndPurge completes what it knows to be the last 
pending future,
+    // that future.  Guarded by `mutex`.
+    std::shared_ptr<Future<T>> completing_last;
   };
 
+  static void RunErrorSignaledHookForTesting() {
+    if (error_signaled_hook_for_testing) {
+      error_signaled_hook_for_testing();
+    }
+  }
+
   struct InnerCallback {
     InnerCallback(std::shared_ptr<State> state, std::size_t index, bool 
recursive = false)
         : state(std::move(state)), index(index), recursive(recursive) {}
@@ -1289,7 +1392,7 @@ class MergedGenerator {
         bool pull_next_sub = false;
         bool was_broken = false;
         bool should_mark_gen_complete = false;
-        bool should_mark_final_error = false;
+        bool signaled_error = false;
         {
           auto guard = state->mutex.Lock();
           if (state->broken) {
@@ -1311,8 +1414,9 @@ class MergedGenerator {
 
             // If this is the first error then we transition the state to a 
broken state
             if (!maybe_next->ok()) {
-              should_mark_final_error = true;
+              signaled_error = true;
               state->SignalErrorUnlocked(guard);
+              state->SetFinalErrorUnlocked(guard, maybe_next->status(), 
std::move(sink));
             }
           }
 
@@ -1338,10 +1442,9 @@ class MergedGenerator {
 
         // Now we have given up the lock and we can take all the actions we 
decided we
         // need to take.
-        if (should_mark_final_error) {
-          state->MarkFinalError(maybe_next->status(), std::move(sink));
+        if (signaled_error) {
+          RunErrorSignaledHookForTesting();
         }
-
         if (should_mark_gen_complete) {
           state->MarkFinishedAndPurge();
         }
@@ -1391,7 +1494,7 @@ class MergedGenerator {
         // We have been given a new inner subscription
         bool should_continue = false;
         bool should_mark_gen_complete = false;
-        bool should_deliver_error = false;
+        bool signaled_error = false;
         bool source_exhausted = maybe_next.ok() && IsIterationEnd(*maybe_next);
         Future<T> error_sink;
         {
@@ -1399,13 +1502,15 @@ class MergedGenerator {
           if (!maybe_next.ok() || source_exhausted || state->broken) {
             // If here then we will not pull any more from the outer source
             if (!state->broken && !maybe_next.ok()) {
+              signaled_error = true;
               state->SignalErrorUnlocked(guard);
               // If here then we are the first error so we need to deliver it
-              should_deliver_error = true;
               if (!state->waiting_jobs.empty()) {
                 error_sink = std::move(*state->waiting_jobs.front());
                 state->waiting_jobs.pop_front();
               }
+              state->SetFinalErrorUnlocked(guard, maybe_next.status(),
+                                           std::move(error_sink));
             }
             if (source_exhausted) {
               state->source_exhausted = true;
@@ -1419,8 +1524,8 @@ class MergedGenerator {
             should_continue = true;
           }
         }
-        if (should_deliver_error) {
-          state->MarkFinalError(maybe_next.status(), std::move(error_sink));
+        if (signaled_error) {
+          RunErrorSignaledHookForTesting();
         }
         if (should_mark_gen_complete) {
           state->MarkFinishedAndPurge();
diff --git a/cpp/src/arrow/util/async_generator_test.cc 
b/cpp/src/arrow/util/async_generator_test.cc
index afb03b6720..9d965dbc4e 100644
--- a/cpp/src/arrow/util/async_generator_test.cc
+++ b/cpp/src/arrow/util/async_generator_test.cc
@@ -18,12 +18,15 @@
 #include <atomic>
 #include <chrono>
 #include <condition_variable>
+#include <functional>
 #include <mutex>
 #include <optional>
 #include <random>
+#include <string>
 #include <thread>
 #include <unordered_set>
 #include <utility>
+#include <vector>
 
 #include "arrow/io/slow.h"
 #include "arrow/testing/async_test_util.h"
@@ -791,6 +794,352 @@ TEST_P(MergedGeneratorTestFixture, 
DeepOuterGeneratorStackOverflow) {
 INSTANTIATE_TEST_SUITE_P(MergedGeneratorTests, MergedGeneratorTestFixture,
                          ::testing::Values(false, true));
 
+// GH-51495: when an inner or outer subscription fails while no caller is 
waiting, the
+// error must be visible to the next pull as soon as the generator is in its 
errored
+// state.  Previously the error was stored only after the internal mutex had 
been
+// released, so a pull landing in between got a plain end-of-stream and the 
error was
+// lost.  The test hook runs exactly in that window, which makes these tests
+// deterministic.
+class MergedGeneratorErrorHookTest : public ::testing::Test {
+ protected:
+  void TearDown() override {
+    MergedGenerator<TestInt>::error_signaled_hook_for_testing = nullptr;
+  }
+
+  // Pull from `merged` once, from inside the hook
+  void PullFromErrorHook(AsyncGenerator<TestInt>* merged, Future<TestInt>* 
pulled) {
+    MergedGenerator<TestInt>::error_signaled_hook_for_testing = [merged, 
pulled]() {
+      if (!pulled->is_valid()) {
+        *pulled = (*merged)();
+      }
+    };
+  }
+
+  // Fails a pending item by calling `fail` on another thread, and pulls again 
while
+  // `gen` is completing `*earlier`: `earlier`'s internal mutex is held 
meanwhile, so
+  // completing it blocks, and the later pull is made once `gen` has started 
completing
+  // its remaining futures.  If `pull_earlier_in_hook`, `*earlier` is pulled 
from the
+  // error hook, i.e. it is the caller that claims an error nobody was waiting 
for.
+  // Returns true if the later pull completed while `*earlier` was still 
pending.
+  bool LaterPullCompletesFirst(const MergedGenerator<TestInt>& gen,
+                               AsyncGenerator<TestInt>* merged, 
Future<TestInt>* earlier,
+                               std::function<void()> fail, bool 
pull_earlier_in_hook,
+                               Future<TestInt>* later) {
+    std::atomic<bool> signaled{false};
+    std::atomic<bool> holding{false};
+    MergedGenerator<TestInt>::error_signaled_hook_for_testing = [&]() {
+      if (pull_earlier_in_hook) {
+        *earlier = (*merged)();
+      }
+      signaled = true;
+      while (!holding) {
+        std::this_thread::yield();
+      }
+    };
+    std::thread failer(std::move(fail));
+    while (!signaled) {
+      std::this_thread::yield();
+    }
+    bool later_completed_first = false;
+    bool held = earlier->TryAddCallback([&]() {
+      holding = true;
+      // Relies on TryAddCallback running this factory under `earlier`'s 
internal mutex
+      const auto deadline = std::chrono::steady_clock::now() + 
std::chrono::seconds(10);
+      while (!IsFinishing(gen)) {
+        if (std::chrono::steady_clock::now() > deadline) {
+          ADD_FAILURE() << "generator never started completing its futures";
+          break;
+        }
+        std::this_thread::yield();
+      }
+      *later = (*merged)();
+      later_completed_first = later->is_finished();
+      return [](const Result<TestInt>&) {};
+    });
+    if (!held) {
+      holding = true;
+    }
+    failer.join();
+    EXPECT_TRUE(held) << "earlier future completed before the later pull";
+    return later_completed_first;
+  }
+
+  static bool IsFinishing(const MergedGenerator<TestInt>& gen) {
+    auto guard = gen.state_->mutex.Lock();
+    return gen.state_->finishing;
+  }
+
+  // Like PullFromErrorHook, but also records "terminal" in `order` when the 
pulled
+  // future completes
+  void PullFromErrorHookAndRecord(AsyncGenerator<TestInt>* merged,
+                                  Future<TestInt>* pulled,
+                                  std::vector<std::string>* order) {
+    MergedGenerator<TestInt>::error_signaled_hook_for_testing = [=]() {
+      if (!pulled->is_valid()) {
+        *pulled = (*merged)();
+        pulled->AddCallback(
+            [order](const Result<TestInt>&) { order->push_back("terminal"); });
+      }
+    };
+  }
+};
+
+TEST_F(MergedGeneratorErrorHookTest, InnerErrorNotLostToConcurrentPull) {
+  auto failing = Future<TestInt>::Make();
+  AsyncGenerator<TestInt> failing_sub = [failing]() { return failing; };
+  std::vector<AsyncGenerator<TestInt>> subs = 
{MakeVectorGenerator<TestInt>({TestInt(1)}),
+                                               failing_sub};
+  auto merged = MakeMergedGenerator(MakeVectorGenerator(std::move(subs)), 1);
+  // Delivers 1 and then subscribes to failing_sub, whose first item is 
pending.  Now
+  // there is one outstanding request and nobody waiting.
+  ASSERT_FINISHES_OK_AND_EQ(TestInt(1), merged());
+
+  Future<TestInt> pulled;
+  PullFromErrorHook(&merged, &pulled);
+  failing.MarkFinished(Status::Invalid("XYZ"));
+  ASSERT_TRUE(pulled.is_valid());
+  ASSERT_FINISHES_AND_RAISES(Invalid, pulled);
+  AssertGeneratorExhausted(merged);
+}
+
+TEST_F(MergedGeneratorErrorHookTest, OuterErrorNotLostToConcurrentPull) {
+  auto failing = Future<AsyncGenerator<TestInt>>::Make();
+  int num_pulls = 0;
+  AsyncGenerator<AsyncGenerator<TestInt>> source =
+      [&]() -> Future<AsyncGenerator<TestInt>> {
+    if (num_pulls++ == 0) {
+      return Future<AsyncGenerator<TestInt>>::MakeFinished(
+          MakeVectorGenerator<TestInt>({TestInt(1)}));
+    }
+    return failing;
+  };
+  auto merged = MakeMergedGenerator(std::move(source), 1);
+  // Delivers 1 and then pulls the next subscription from the source, which is 
pending.
+  // Now there is one outstanding request and nobody waiting.
+  ASSERT_FINISHES_OK_AND_EQ(TestInt(1), merged());
+
+  Future<TestInt> pulled;
+  PullFromErrorHook(&merged, &pulled);
+  failing.MarkFinished(Status::Invalid("XYZ"));
+  ASSERT_TRUE(pulled.is_valid());
+  ASSERT_FINISHES_AND_RAISES(Invalid, pulled);
+  AssertGeneratorExhausted(merged);
+}
+
+// When a caller is already waiting for the item that fails, it must receive 
the error
+// before any later pull receives its end-of-stream: the error callback has to 
be
+// registered in the same locked transition that marks the generator errored, 
or a
+// pull landing in between registers its terminal continuation first and 
overtakes it.
+TEST_F(MergedGeneratorErrorHookTest, 
InnerErrorToWaiterNotOvertakenByLaterPull) {
+  auto failing = Future<TestInt>::Make();
+  AsyncGenerator<TestInt> failing_sub = [failing]() { return failing; };
+  std::vector<AsyncGenerator<TestInt>> subs = {failing_sub};
+  auto merged = MakeMergedGenerator(MakeVectorGenerator(std::move(subs)), 1);
+  std::vector<std::string> order;
+  // Subscribes to failing_sub, whose first item is pending, so this caller 
waits
+  Future<TestInt> waiting = merged();
+  waiting.AddCallback([&order](const Result<TestInt>&) { 
order.push_back("error"); });
+
+  Future<TestInt> pulled;
+  PullFromErrorHookAndRecord(&merged, &pulled, &order);
+  failing.MarkFinished(Status::Invalid("XYZ"));
+  ASSERT_TRUE(pulled.is_valid());
+  ASSERT_FINISHES_AND_RAISES(Invalid, waiting);
+  ASSERT_FINISHES_OK_AND_ASSIGN(auto terminal, pulled);
+  ASSERT_TRUE(IsIterationEnd(terminal));
+  ASSERT_EQ(order, (std::vector<std::string>{"error", "terminal"}));
+}
+
+TEST_F(MergedGeneratorErrorHookTest, 
OuterErrorToWaiterNotOvertakenByLaterPull) {
+  auto failing = Future<AsyncGenerator<TestInt>>::Make();
+  AsyncGenerator<AsyncGenerator<TestInt>> source = [failing]() { return 
failing; };
+  auto merged = MakeMergedGenerator(std::move(source), 1);
+  std::vector<std::string> order;
+  // Pulls the first subscription from the source, which is pending, so this 
caller
+  // waits
+  Future<TestInt> waiting = merged();
+  waiting.AddCallback([&order](const Result<TestInt>&) { 
order.push_back("error"); });
+
+  Future<TestInt> pulled;
+  PullFromErrorHookAndRecord(&merged, &pulled, &order);
+  failing.MarkFinished(Status::Invalid("XYZ"));
+  ASSERT_TRUE(pulled.is_valid());
+  ASSERT_FINISHES_AND_RAISES(Invalid, waiting);
+  ASSERT_FINISHES_OK_AND_ASSIGN(auto terminal, pulled);
+  ASSERT_TRUE(IsIterationEnd(terminal));
+  ASSERT_EQ(order, (std::vector<std::string>{"error", "terminal"}));
+}
+
+// Callbacks on a Future do not run in a guaranteed order: one added while the 
future is
+// being marked finished may run immediately.  So a terminal item must not 
merely be
+// registered after the error, it must not be able to complete before the 
future that
+// receives the error has completed, even when it is requested while the 
generator is
+// already completing.
+TEST_F(MergedGeneratorErrorHookTest, 
InnerErrorToWaiterNotOvertakenDuringCompletion) {
+  auto failing = Future<TestInt>::Make();
+  AsyncGenerator<TestInt> failing_sub = [failing]() { return failing; };
+  std::vector<AsyncGenerator<TestInt>> subs = {failing_sub};
+  MergedGenerator<TestInt> gen(MakeVectorGenerator(std::move(subs)), 1);
+  AsyncGenerator<TestInt> merged = gen;
+  Future<TestInt> waiting = merged();
+
+  Future<TestInt> later;
+  ASSERT_FALSE(LaterPullCompletesFirst(
+      gen, &merged, &waiting,
+      [failing]() mutable { failing.MarkFinished(Status::Invalid("XYZ")); },
+      /*pull_earlier_in_hook=*/false, &later));
+  ASSERT_FINISHES_AND_RAISES(Invalid, waiting);
+  ASSERT_FINISHES_OK_AND_ASSIGN(auto terminal, later);
+  ASSERT_TRUE(IsIterationEnd(terminal));
+}
+
+TEST_F(MergedGeneratorErrorHookTest, 
OuterErrorToWaiterNotOvertakenDuringCompletion) {
+  auto failing = Future<AsyncGenerator<TestInt>>::Make();
+  AsyncGenerator<AsyncGenerator<TestInt>> source = [failing]() { return 
failing; };
+  MergedGenerator<TestInt> gen(std::move(source), 1);
+  AsyncGenerator<TestInt> merged = gen;
+  Future<TestInt> waiting = merged();
+
+  Future<TestInt> later;
+  ASSERT_FALSE(LaterPullCompletesFirst(
+      gen, &merged, &waiting,
+      [failing]() mutable { failing.MarkFinished(Status::Invalid("XYZ")); },
+      /*pull_earlier_in_hook=*/false, &later));
+  ASSERT_FINISHES_AND_RAISES(Invalid, waiting);
+  ASSERT_FINISHES_OK_AND_ASSIGN(auto terminal, later);
+  ASSERT_TRUE(IsIterationEnd(terminal));
+}
+
+TEST_F(MergedGeneratorErrorHookTest, ClaimedErrorNotOvertakenDuringCompletion) 
{
+  auto failing = Future<TestInt>::Make();
+  AsyncGenerator<TestInt> failing_sub = [failing]() { return failing; };
+  std::vector<AsyncGenerator<TestInt>> subs = 
{MakeVectorGenerator<TestInt>({TestInt(1)}),
+                                               failing_sub};
+  MergedGenerator<TestInt> gen(MakeVectorGenerator(std::move(subs)), 1);
+  AsyncGenerator<TestInt> merged = gen;
+  // Delivers 1 and then subscribes to failing_sub, whose first item is 
pending.  Now
+  // there is one outstanding request and nobody waiting, so the error goes to 
the
+  // next pull, made from the hook.
+  ASSERT_FINISHES_OK_AND_EQ(TestInt(1), merged());
+
+  Future<TestInt> claimed;
+  Future<TestInt> later;
+  ASSERT_FALSE(LaterPullCompletesFirst(
+      gen, &merged, &claimed,
+      [failing]() mutable { failing.MarkFinished(Status::Invalid("XYZ")); },
+      /*pull_earlier_in_hook=*/true, &later));
+  ASSERT_FINISHES_AND_RAISES(Invalid, claimed);
+  ASSERT_FINISHES_OK_AND_ASSIGN(auto terminal, later);
+  ASSERT_TRUE(IsIterationEnd(terminal));
+}
+
+// A caller that asks from the callbacks of the last pending future gets its 
terminal item
+// at once, so it may block on it.
+TEST(MergedGeneratorTest, PullFromLastFutureCallbackCompletesAtOnce) {
+  for (bool fail : {false, true}) {
+    ARROW_SCOPED_TRACE("fail = ", fail);
+    auto pending = Future<TestInt>::Make();
+    AsyncGenerator<TestInt> sub = [pending]() { return pending; };
+    std::vector<AsyncGenerator<TestInt>> subs = {sub};
+    auto merged = MakeMergedGenerator(MakeVectorGenerator(std::move(subs)), 1);
+    Future<TestInt> waiting = merged();
+    Future<TestInt> pulled;
+    waiting.AddCallback([&](const Result<TestInt>&) { pulled = merged(); });
+    if (fail) {
+      pending.MarkFinished(Status::Invalid("XYZ"));
+      ASSERT_FINISHES_AND_RAISES(Invalid, waiting);
+    } else {
+      pending.MarkFinished(IterationEnd<TestInt>());
+      ASSERT_FINISHES_OK_AND_ASSIGN(auto end, waiting);
+      ASSERT_TRUE(IsIterationEnd(end));
+    }
+    ASSERT_TRUE(pulled.is_valid());
+    ASSERT_TRUE(pulled.is_finished());
+    ASSERT_OK_AND_ASSIGN(auto terminal, pulled.result());
+    ASSERT_TRUE(IsIterationEnd(terminal));
+  }
+}
+
+// Stress test of the AsyncGenerator contract with inner items and errors 
arriving on
+// other threads: a terminal item (an error or the end of the stream) must not 
complete
+// while any future returned before it is still pending, and an error must 
never be
+// lost.
+TEST(MergedGeneratorStressTest, TerminalNeverOvertakesEarlierFutures) {
+  constexpr int kIterations = 300;
+  constexpr int kNumSubs = 6;
+  constexpr int kItemsPerSub = 4;
+  constexpr int kNumPulls = kNumSubs * kItemsPerSub + 8;
+  auto pool = internal::GetCpuThreadPool();
+  for (int iter = 0; iter < kIterations; iter++) {
+    const bool outer_fails = iter % 3 == 0;
+    std::vector<AsyncGenerator<TestInt>> subs;
+    for (int i = 0; i < kNumSubs; i++) {
+      auto sub = NewBackgroundAsyncVectorIt({1, 2, 3, 4}, /*sleep=*/false);
+      if (!outer_fails && i == iter % kNumSubs) {
+        sub = MakeTransferredGenerator(util::FailAt(std::move(sub), iter % 
kItemsPerSub),
+                                       pool);
+      }
+      subs.push_back(std::move(sub));
+    }
+    AsyncGenerator<AsyncGenerator<TestInt>> source = 
MakeVectorGenerator(std::move(subs));
+    if (outer_fails) {
+      source =
+          MakeTransferredGenerator(util::FailAt(std::move(source), 1 + iter % 
4), pool);
+    }
+    auto merged = MakeMergedGenerator(std::move(source), 3);
+
+    // Shared with the callbacks, which may still be running after the futures 
are
+    // marked finished
+    struct Shared {
+      std::mutex mutex;
+      std::vector<Future<TestInt>> futures;
+      int overtaken = 0;
+      int callbacks_run = 0;
+    };
+    auto shared = std::make_shared<Shared>();
+    for (int i = 0; i < kNumPulls; i++) {
+      Future<TestInt> fut = merged();
+      {
+        std::lock_guard<std::mutex> lock(shared->mutex);
+        shared->futures.push_back(fut);
+      }
+      fut.AddCallback([shared, i](const Result<TestInt>& result) {
+        std::lock_guard<std::mutex> lock(shared->mutex);
+        shared->callbacks_run++;
+        if (result.ok() && !IsIterationEnd(*result)) {
+          return;
+        }
+        for (int j = 0; j < i; j++) {
+          if (!shared->futures[j].is_finished()) {
+            shared->overtaken++;
+          }
+        }
+      });
+    }
+    std::vector<Future<TestInt>> futures;
+    {
+      std::lock_guard<std::mutex> lock(shared->mutex);
+      futures = shared->futures;
+    }
+    int num_errors = 0;
+    for (const auto& fut : futures) {
+      ASSERT_TRUE(fut.Wait(kDefaultAssertFinishesWaitSeconds));
+      if (!fut.status().ok()) {
+        ASSERT_TRUE(fut.status().IsInvalid()) << fut.status();
+        num_errors++;
+      }
+    }
+    BusyWait(kDefaultAssertFinishesWaitSeconds, [&]() {
+      std::lock_guard<std::mutex> lock(shared->mutex);
+      return shared->callbacks_run == kNumPulls;
+    });
+    std::lock_guard<std::mutex> lock(shared->mutex);
+    ASSERT_EQ(shared->callbacks_run, kNumPulls) << "iteration " << iter;
+    ASSERT_EQ(shared->overtaken, 0) << "iteration " << iter;
+    ASSERT_EQ(num_errors, 1) << "iteration " << iter;
+  }
+}
+
 class AutoStartingGeneratorTestFixture : public GeneratorTestFixture {};
 
 TEST_P(AutoStartingGeneratorTestFixture, Basic) {

Reply via email to