Skip to content

[BUG] Stop the Elasticsearch async ForceFlush reporting success without waiting - #4337

Draft
thc1006 wants to merge 10 commits into
open-telemetry:mainfrom
thc1006:fix/es-forceflush-deadline
Draft

[BUG] Stop the Elasticsearch async ForceFlush reporting success without waiting#4337
thc1006 wants to merge 10 commits into
open-telemetry:mainfrom
thc1006:fix/es-forceflush-deadline

Conversation

@thc1006

@thc1006 thc1006 commented Aug 2, 2026

Copy link
Copy Markdown
Member

Fixes #4336. Fixes #4338.

Rewritten after the second review round, so that this describes the branch as it stands rather than accumulating an appendix. The change itself is unchanged in shape; five things moved, and each is named where it belongs below.

ForceFlush waited for options_.response_timeout_ rather than the time the caller gave it, and the timeout branch left the loop without subtracting what it had just spent:

while (timeout_steady > std::chrono::steady_clock::duration::zero())
{
  if (finished_session_counter_.load(...) >= running_counter) { break; }
  auto start = std::chrono::steady_clock::now();
  if (std::cv_status::no_timeout != force_flush_cv.wait_for(
                                        lk_cv, std::chrono::seconds{options_.response_timeout_}))
  {
    break;
  }
  timeout_steady -= std::chrono::steady_clock::now() - start;
}
return timeout_steady > std::chrono::steady_clock::duration::zero();

The loop condition has already established that timeout_steady is positive, so the return on that path is true whatever happened. Every flush that ran out of time reported success. The only way to get false was to be notified repeatedly without completing until the subtraction drained the budget.

That contradicts what the method promises in es_log_record_exporter.h, return true when all data are exported, and false when timeout. The Logs SDK puts the MUST prioritize honoring the timeout over finishing all calls on the processor rather than on the exporter, whose own wording is the weaker SHOULD complete or abort within some timeout, but a return value that cannot express having run out of time gives the processor nothing to honour it with.

The change

One steady_clock deadline derived from the caller's timeout, and a wait on a completion predicate. wait_until returns the predicate, so the return value is now the answer to the question the caller asked rather than a leftover duration.

The deadline is taken at function entry rather than after the mutex, so time spent waiting for the lock comes out of the caller's budget instead of being handed back as a fresh one. A plain mutex still cannot promise a bound, but acquiring it should not add a second full timeout to the one that was asked for.

A flush with no deadline of its own gets one too. The bound is response_timeout_, which is what this path waited for before the accounting was fixed and is also the value the request itself is given, so a session outstanding when the watermark is taken has at most that long before the client owes it a terminal event. The bound is unchanged from base and the answer is what changes: running out of it no longer reports success. That also keeps this independent of the curl client work, where a transfer can currently be left with no callback at all, and it leaves ForceFlush with one wait path rather than two. An earlier revision of this branch left the no-deadline form unbounded; that was worse, because a client that accepts a request and never calls back turned a wrong true into a permanent hang.

Serialising concurrent calls was a second way to miss the deadline: force_flush_m was taken unconditionally at the top, so a second caller waited out the first one's wait however short its own timeout was. Measured at 2921 ms for a ForceFlush(20ms) behind a 3 second one.

That lock is gone rather than made timed. It protected nothing: each call snapshots the session counter it waits for and waits on its own predicate, and the wait publishes no state, so two callers were already safe side by side. It was declared in the header and used in exactly one place. Dropping it changes the layout of SynchronizationData, which is declared in the installed header, but that struct is a private member and the whole block is behind ENABLE_ASYNC_EXPORT, so it carries no ABI promise. There is no ABI diff job in CI to catch or complain about it either way. I first made it a timed acquisition instead, which ThreadSanitizer could not see through, since libstdc++ routes try_lock_until to pthread_mutex_clocklock and libtsan does not intercept it. Deleting the lock was the better answer to the same problem and that revision is not in this diff.

Removing it also means ForceFlush and Shutdown are no longer serialised against each other, which the stable Logs SDK requires them to survive, so there are two cases for the overlap: one where the shutdown settles the outstanding session and the flush is woken by that rather than by its own bound, and one where the shutdown settles nothing and the flush ends anyway. Specification issue open-telemetry/opentelemetry-specification#5248 is asking whether the exporter should carry that requirement at all, but it has not changed the current stable text.

Retirement happens under the mutex the waiter holds. It used to be an atomic counter incremented outside it, which does not lose the state but does lose the notification when it lands between the waiter's predicate check and its park. The counter itself is gone, replaced further down by ids in an ordered set, and this paragraph is about the mutex rather than about what it protects.

AdjustWaitForTimeout already maps microseconds::max() and anything that would overflow now() + timeout to zero, so zero is the sentinel for a caller that asked for no deadline.

The flush still covers the sessions that were running when it was entered, which is what the counter snapshot already meant. I asked in #4336 whether it should instead keep waiting while new exports arrive, and left the existing behaviour until you have a view.

The three the first revision left open

The first revision fixed the deadline and named three other ways this function reports success without having waited. They are the same defect from the caller's side, and two of them make the deadline fix meaningless on its own, so they are here rather than in follow-ups.

A session could be counted twice, or not at all. OnResponse and every terminal event called the result callback directly with no guard, while ReadError, WriteError and Destroyed fell through a default label and called nothing. One session could therefore finish twice, which lets the total overshoot and stay overshot for the life of the exporter, or never finish, which leaves an undeadlined flush waiting forever. Every path now goes through one CompleteOnce, a compare-exchange that reports at most once and keeps the first verdict. The switch lists every state with no default, so a state added upstream fails to compile rather than going uncounted, and the destructor reports a failure for a handler torn down without an outcome.

A completion satisfied any waiter. Both counters were monotonic totals with no session identity. A flush entering with two sessions outstanding waits for two completions; a third session started afterwards and completed, one of the original two completed, the count reached two, and the flush reported success with the other original still running. Sessions now carry an id and the running ones live in an ordered set. The snapshot is the next id to be issued and ids are issued in order, so the smallest one still running decides.

Why a set of what is running rather than a completed-sequence frontier, since both are correct. Tracking what is outstanding and waiting for it to drain is what the neighbours do: this repository's own OtlpHttpClient keeps running_sessions_ and waits for it to empty, SimpleSpanProcessor in opentelemetry-java keeps a Set<CompletableResultCode> pendingExports and returns ofAll of it, and opentelemetry-go's batch processor uses a WaitGroup. A frontier answers a stronger question, whether a contiguous prefix is complete, which is what write-ahead logs and replication need; the extra strength is what costs the unbounded buffer, because one stalled session holds back the prefix even though every completion behind it is individually known. Measured over the same traces, one stalled session with a million completions after it leaves 1000000 entries in the frontier's buffer and 2 in this one. BatchLogRecordProcessor here does use a sequence, and correctly so: its work is drained in order by one worker, where a monotone acknowledgement is exactly the right model.

Two of those neighbours also over-wait: running_sessions_.empty() and a WaitGroup both include sessions started after the call. The watermark is what keeps this one to the sessions the caller asked about.

That argument holds only while the ids keep increasing, so they are uint64_t rather than size_t. A 32 bit counter reaches its end in days at a rate this exporter is built to sustain, and the watermark taken after that point is small enough for a session that is still running to satisfy it, which is the failure that matters rather than the harmless one.

The predicate was checked against an independent oracle over 20000 random schedules, 54 million evaluations, with sessions starting, completing out of order, and flushes taking snapshots in between:

predicate duplicate completions reported flushed too early reported not flushed when it was
the counter this replaces no 9000364 0
this one no 0 0
the counter this replaces yes 22488236 0
this one yes 0 0

The counter's column is what makes the zero meaningful: the same check finds the defect it replaces. The right hand column is the other half, that this does not over-wait either, and the duplicate rows are the exactly-once fix and the tracker covering each other.

A batch already inside Export() was not waited for. The session was registered after the request had been created and the whole batch serialised into its body, so a flush asked during that window snapshotted past a batch whose records had already been handed over. The Logs SDK draws the line at records received prior to the call, so they belong to it. Registration moves to the top of Export().

An earlier revision guarded that registration with a scope guard that released the id if the call gave up before a handler took it over. The guard could not reach its active destructor: the shutdown return retired by hand and disarmed it, the successful path disarmed it after SendRequest, and there was no other return between them. It is gone, the handler takes the completion by move rather than by copy, and the invariant it was watching is stated where the two exits are. Giving it something reachable to do would have meant adding null checks for an injected HttpClient, CreateSession() and CreateRequest(). Those dereferences really are unguarded today, but that is a different defect from this one and it wants its own case rather than a ride on this one.

What the diagnostics do and do not buy

Retiring an export before its terminal diagnostic keeps a flushing log handler from waiting on the session that is reporting itself, and AFlushFromInsideTheLogHandlerDoesNotWaitForItsOwnSession holds that. It is not general re-entrancy safety and the code now says so. A handler that flushes from any callback the HTTP client dispatches can wait on work only that client thread advances. That one is not introduced here and not fixed here, and #4435 has the detail.

The states a session passes through on the way to an outcome report nothing, and in particular log nothing. An earlier revision of this branch logged five of them at debug level, which the synchronous ResponseHandler has always done and the asynchronous one never has: on 60c3d11e its OnEvent is a default: break; with no progress logging at all. The session is registered before the request is handed to the client and none of those events retires it, so a handler that flushed from one waited for the export whose call stack it was standing in. Every other report in this file already retires before it logs, which is what made the omission visible: the progress cases were the one place that broke the file's own rule. The switch is still exhaustive, so a new state is still a compile error. I said in #4435 that the debug line was inherited from main; that was wrong and the issue now carries the correction.

One deliberate difference from the synchronous handler on base, worth naming rather than leaving for a reviewer to find. There, ReadError and WriteError are logged at debug and do not call recordCompletion, so a session that ends on either is never counted as finished. Here they are terminal and report a failure through CompleteOnce, because an undeadlined flush that never counts them waits for a session that has already ended. The bundled curl client does not currently produce either state, so the case that exercises it uses an injected client. #4331 is the pull request that brings the synchronous side into line; this one does not touch it.

Tests

The cases use a fake HTTP client injected through the public constructor. The wait itself is described by:

  • a session that never calls back makes the flush report failure, at the caller's deadline rather than at response_timeout_,
  • nothing in flight returns at once,
  • a session that finished before the flush was asked for returns at once, which is the predicate being true on first evaluation,
  • two exports where only one finishes is not a success,
  • a completion delivered from another thread after the waiter has parked wakes it,
  • a second caller arriving while the first is parked returns on its own deadline,
  • a flush with no deadline returns once everything has finished, and gives up at response_timeout_ when nothing does,
  • a failed export still finishes its session, so the flush completes even though the export did not,
  • a flush parked when Shutdown arrives is woken by it, and ends on its own bound when the shutdown settles nothing.

Three of those fail if the change is reverted: the two that measure a flush which cannot complete, and the concurrent one, which on the old code queues behind the first caller. The others pass either way, and I would rather name that than let a list of cases look like a list of guards. They exercise paths that return before any wait, or that the old polling loop happened to get right.

Two more were added after the first review pointed at what the list above did not hold, and both were confirmed by mutating the predicate rather than by reading it. Each of these left the suite green:

if (timeout <= 0) { return flushed(); }                  // the no deadline branch
return running.empty();                                  // the whole watermark
return running.empty() || *running.begin() > watermark;  // the boundary

The first survived because the case named for the indefinite wait never parks: the fake answers from inside SendRequest(), so the predicate already holds when ForceFlush() is called. AnIndefiniteFlushParksUntilTheOutcomeArrives waits with nothing answering, checks it has not returned, and then answers. The other two survived because nothing held the opposite boundary from the substitution cases: those say a newer completion cannot finish an older flush, and ANewerSessionDoesNotHoldAnOlderFlushOpen says a newer export still running cannot keep that flush open. All three mutations are caught now.

Four cases used a 50 or 100 millisecond sleep to put an export, a completion or a second caller after the flush's snapshot. That leaves the order to the scheduler, and in the other order the newer work is inside the snapshot, where the counting model these cases exist to rule out reports a pass too. So ForceFlush() raises a counter under the mutex it already holds, immediately after reading its watermark, and the cases wait on that. It is test synchronization and nothing in the exporter reads it, which is what the header says about it.

The helper those cases wait on had the same bound as the whole CTest case, so a watermark that never arrived killed the process before the case reached its own assertion and its cleanup. Five seconds for the helper now, sixty for CTest, so an expiry names the case that failed rather than being killed from outside. The CMake comment named a case that never parks and now names the one that does.

The exactly-once cases read the completion count while the handler they retained is still alive, so its destructor had not run and a second report from there would have landed after the check. Making the destructor bypass CompleteOnce() left the suite green; the handler is released before a second read now, and three cases catch it.

The cases build in every configuration and skip in SetUp where the wait does not exist. An earlier revision compiled them out instead, which was wrong in a way worth naming: gtest_add_tests registers from the source, so they all stayed registered with CTest in the synchronous jobs, and a gtest filter matching nothing exits zero, so each reported a pass without running. Skipping in SetUp rather than at the top of each body also keeps GTEST_SKIP, which returns, from leaving the rest of a body unreachable, which MSVC reports as C4702. The helper they wait on needed a stub in the synchronous build for the same reason, inline so an unused static function does not trip the maintainer mode ratchet.

Verification

All of this is on 52470cfd, rebased onto 70fdb766.

[ PASSED ] 31 tests. with -DOTELCPP_WITH_ELASTICSEARCH=ON -DOTELCPP_WITH_ASYNC_EXPORT_PREVIEW=ON -DOTELCPP_MAINTAINER_MODE=ON, no warning in the build. The synchronous configuration registers the same 31 with CTest, runs 2 and skips 29 in SetUp, also with no warning. clang-format leaves the three files unchanged.

Both sanitizers, on the same 31:

build result sanitizer reports instrumented
AddressSanitizer, detect_leaks=1 31 passed 0 34 asan_ symbols in the binary
ThreadSanitizer 31 passed 0 162 tsan_ symbols in the binary

The symbol counts are there because a sanitizer that reports nothing and a sanitizer that was not linked in look identical from the outside.

Restoring the previous ForceFlush and rebuilding the same tests fails the ones that should discriminate:

[  FAILED  ] ElasticsearchForceFlushTests.ReportsFailureWhenTheFlushDoesNotComplete (2000 ms)
[  FAILED  ] ElasticsearchForceFlushTests.PartialCompletionIsNotSuccess (2000 ms)
[  FAILED  ] ElasticsearchForceFlushTests.AConcurrentFlushKeepsItsOwnDeadline (4000 ms)
[  PASSED  ] 5 tests.
[  FAILED  ] 3 tests, listed below:

The 2000 ms is the whole response_timeout_ being spent against a 20 ms caller deadline, which is the second half of the defect.

Each of the three original defects is pinned the same way: moving the registration back below the request build turns AnExportAlreadyUnderWayIsSomethingToWaitFor red, restoring the counter comparison turns the two substitution cases red, and removing the compare-exchange from CompleteOnce() turns eight of the nine completion cases red. That last number is the reason those cases count the callback through a log handler rather than through ForceFlush(): sessions are identified rather than counted, so a repeated completion erases an id that has already gone, and every flush-based assertion passed with the guard removed.

The five things that moved in the second round are pinned the same way. A progress line restored fails EverySessionStateIsClassifiedAndReportsAtMostOnce, the bound removed fails ANoDeadlineFlushGivesUpAtTheResponseTimeout, and the unbounded wait restored hangs AParkedFlushEndsEvenIfTheShutdownSettlesNothing at exit 124.

include-what-you-use and clang-tidy were measured against main rather than in isolation, and over the test target so that the test file is compiled rather than only the library. Across all three cmake option presets the workflow builds, include-what-you-use reports the same blocks and no include changes on either tree. The async-only includes sit behind the ENABLE_ASYNC_EXPORT guard, because the presets that build without it ask for them to go while the ones that build it ask for them.

The clang-tidy limit has moved three times since this branch was opened, 133 to 113 to 83 on all-options-abiv1-preview, and each cut sets the limit to what main then reports, so there is no headroom for a branch to spend. This one spends none. Comparing its report against main's from the same day row by row, every difference is a line that shifted because the branch adds lines above it, or a row in a file the branch does not touch. Nothing in the files it does touch is new.

What it still does not fix

true means every export the call snapshotted has reported a terminal outcome, not that their batches reached Elasticsearch. A failed export reports through the internal log, so a flush can return true for a batch that was rejected. That is #3075 rather than something this changes; the header's @return says so instead of promising that all data are exported.

It is also not a statement about the transport. Every completion path publishes the outcome first: OnResponse and the terminal OnEvent states each ahead of their logging, and the handler destructor ahead of FinishSession(). That order is deliberate, since the log handler is replaceable and one that calls ForceFlush() would otherwise wait on the session its own call is still holding. The @return says that too now. It used to claim the session had ended, which the code never promised.

Shutdown(timeout) still ignores its timeout, calls CancelAllSessions() and FinishAllSessions() in order, and returns true unconditionally. Separate, and not something to read this pull request as having fixed.

Its narrower cousin is worth naming too, since this changes the code around it. is_shutdown_ is atomic, but the check in Export() and the cancellation in Shutdown() are not one step: an export that has already passed the check and is still building its session can hand a request to the client after Shutdown() has returned true. The window is the same on main, and registering the session id earlier does not widen it, since nothing moved between the check and CreateSession(). Closing it needs an admission gate the two share rather than a flag each reads on its own, which is a larger change than this one and not what the flush accounting here is about.

Landing next to the other Elasticsearch changes

exporters/elasticsearch/test/es_log_record_exporter_test.cc is also touched by #4297 and #4331, and all three add a fake HTTP client to it, so any two of them conflict there. #4331 adds a set_tests_properties(... TIMEOUT ...) line to exporters/elasticsearch/CMakeLists.txt that this one also adds, with a different bound. Whichever lands first, I rebase the rest onto it and reconcile the two.

@thc1006
thc1006 requested a review from a team as a code owner August 2, 2026 19:35
@thc1006
thc1006 force-pushed the fix/es-forceflush-deadline branch 4 times, most recently from c8c0e4b to bd0c772 Compare August 2, 2026 21:01
@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.80%. Comparing base (70fdb76) to head (52470cf).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4337      +/-   ##
==========================================
+ Coverage   83.25%   83.80%   +0.55%     
==========================================
  Files         521      521              
  Lines       20384    20405      +21     
==========================================
+ Hits        16969    17098     +129     
+ Misses       3415     3307     -108     
Files with missing lines Coverage Δ
...y/exporters/elasticsearch/es_log_record_exporter.h 100.00% <ø> (ø)
...orters/elasticsearch/src/es_log_record_exporter.cc 96.72% <100.00%> (+84.50%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thc1006 thc1006 changed the title [BUG] Hold the Elasticsearch ForceFlush to the caller's timeout [BUG] Stop the Elasticsearch async ForceFlush reporting success without waiting Aug 3, 2026
@thc1006
thc1006 force-pushed the fix/es-forceflush-deadline branch 3 times, most recently from 6085fad to 703f2b0 Compare August 5, 2026 03:01
@thc1006
thc1006 force-pushed the fix/es-forceflush-deadline branch from fac1be4 to 648de6e Compare August 11, 2026 04:11
@thc1006
thc1006 marked this pull request as draft August 13, 2026 17:45
@thc1006
thc1006 force-pushed the fix/es-forceflush-deadline branch from 477d4ee to dbfdde9 Compare August 14, 2026 05:49
@thc1006
thc1006 force-pushed the fix/es-forceflush-deadline branch from a572304 to 80b050f Compare August 15, 2026 08:48
@thc1006
thc1006 force-pushed the fix/es-forceflush-deadline branch from 069dfde to 0eb3c55 Compare August 25, 2026 14:20
…ut waiting

ForceFlush() returned true straight away when async export was enabled, so a
caller had no way to know whether anything had been delivered.

It now waits on the sessions that were in flight when it was called. Sessions
are identified rather than counted: each export takes the next id and joins a
set, and the wait ends when no id below the entry watermark is left, so a
completion cannot satisfy a flush that started after it. Counting alone lets a
later export stand in for an earlier one. The wait uses one deadline for the
call, so a wakeup that is not a completion resumes against what is left instead
of restarting the wait, and the result is the predicate rather than the leftover
duration.

The ids are uint64_t rather than size_t. The wait compares them by order, which
only holds while they keep increasing, and a 32 bit counter reaches its end in
days at a rate this exporter is meant to sustain; past that the next watermark
is small enough for a still running session to satisfy it.

AsyncResponseHandler reports at most once, through a compare and exchange. The
HTTP client can deliver both a response and a terminal session event for one
request, and the exporter counts one finished session per export.

An export registers its session before anything that can return, so a flush
asked from the moment the records arrive waits for them, and a guard reports
through the same completion on every early exit. Without it a return added later
would strand a waiter on a session that can never finish.

What a true return means is documented on the declaration, and it is weaker than
"the session ended". Both completion paths publish the outcome before the
session is torn down: OnResponse calls CompleteOnce() ahead of its logging, and
the handler destructor calls it ahead of FinishSession(). So a true return means
every snapshotted export has reported an outcome, and transport cleanup may
still be running.

Reported in open-telemetry#4336

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The export is registered before the shutdown check, and the guard that
retires it could only run after the refusal had already been described. The
log handler is replaceable, so a handler that calls ForceFlush() from that
error waited for the Export() that was calling it. The same path also
described one refusal twice, once itself and once through the guard.

Retiring is now separate from reporting. The refusal retires, disarms the
guard, and says so once. The completion still answers true, because changing
what it answers would change what the response handler reads.

Measured with a handler that calls ForceFlush(20ms) from the shutdown error.
Before: the flush returns false after waiting out its full 20 ms, 3 of 3.
After: it returns true in 0 ms, 3 of 3, and one refusal reads as one line.
The file holds at 25 passing, 3 of 3.

Three statements alongside it claimed more than they hold. An array cannot
notice that an enum grew, so the comment now names the switch without a
default under -Wswitch as what catches a new state. The curl completion
lambda tests the response first and the abort in an else, so the comment
that called it two independent ifs is out of date. And the fixture put a
fresh default log handler back rather than the one it found.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
… it hid

Three of the cases here needed an export to start after a flush had taken its
watermark, and said so with a sleep. That leaves the order to the scheduler,
and the order a delayed runner picks is the one where the newer export is
inside the snapshot, where even the counting model these cases exist to rule
out reports a pass. A sleep that is too short is not flaky here, it is green
for the wrong reason.

So the flush raises a counter under the mutex it already holds, immediately
after reading its watermark, and the cases wait on that. Nothing in the
exporter reads it. The wait is bounded and the count only goes up, so a wait
that expires means the flush never got there rather than that the case missed
it.

Two boundaries had no case at all, and mutating the predicate showed it. All
three of these left the suite green at 25 of 25:

    if (timeout <= 0) { return flushed(); }          the indefinite wait
    return running.empty();                          the whole watermark
    return running.empty() || *running.begin() > watermark;

The first is the no-deadline branch, which the case named for it never
reaches: the fake answers from inside SendRequest(), so the session is gone
and the predicate holds before ForceFlush() is called. The other two are the
opposite boundary from the substitution cases: those hold that a newer
completion cannot finish an older flush, and nothing held that a newer export
still running cannot keep that flush open.

AnIndefiniteFlushParksUntilTheOutcomeArrives waits on the indefinite branch
with nothing answering, checks it has not returned, then answers.
ANewerSessionDoesNotHoldAnOlderFlushOpen starts a second export after the
watermark, answers only the first, and requires the flush to return.

Neither asserts anything fatal while its waiter thread is running. A fatal
assertion there returns from the body with the thread still joinable, which
ends the process: the first version of these two aborted the whole binary
rather than reporting one case.

All 27 cases pass, three runs out of three.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The retained handler is alive when these cases read the completion count, so its destructor has not run. A regression that reports a second result from there lands after the check: making the destructor bypass CompleteOnce left the suite green at 25 of 25. Letting the handler go before a second read makes the callback and the destructor together exactly one, and three cases now catch that mutation.

The response-then-teardown case said both orderings and had one. The other order is its own case now, which is also where the contract goes: a read or write error settles the export, and a response after it is ignored rather than replacing the verdict. EventHandler does not say whether either state can be followed by a response, so this is the choice this exporter makes, written where a change to it is visible.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Four cases needed an export, a completion or a second caller to arrive after a flush had taken its watermark, and said so with a 50 or 100 millisecond sleep. That leaves the order to the scheduler. In the other order the newer work is inside the snapshot, and then the counting model these cases exist to rule out reports a pass too, so a runner slow enough to invert them does not make the case flaky, it makes it green for the wrong reason.

They wait on the watermark count now. Each records whether the wait held and checks it after the join, because a fatal assertion with one of those threads still running would end the process rather than report the case. One sleep is left, the millisecond poll inside that wait.

All 28 cases pass, three runs out of three.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Retiring before the terminal diagnostic keeps a flushing log handler from waiting on the session that is reporting itself. It is not general re-entrancy safety, and the comment now says so: a handler calling the unbounded ForceFlush from a progress event blocks an Export that has not handed its request over yet, and one flushing from any client callback can wait on work only that client thread advances. Neither is introduced here and neither is fixed here, they are open telemetry-cpp#4435.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The cases that use it are compiled in both on purpose: a case removed from the binary stays registered with CTest and reports a pass without running, so they skip in SetUp instead. Putting the helper they call behind ENABLE_ASYNC_EXPORT broke that, and six call sites failed to compile in a synchronous build.

It has a stub there now, inline so that an unused static function does not trip the maintainer mode warning ratchet. Synchronous with maintainer mode builds clean and skips what it should, asynchronous passes 28 of 28, and asynchronous with maintainer mode builds with no warnings.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Eight things from review, each measured rather than accepted on description.

The async handler logged five progress states that the synchronous one has
always logged and the async one never did. The session is registered before the
request is handed to the client, and none of those events retires it, so a log
handler calling ForceFlush() from one waited for the export whose call stack it
was standing in. Every other report in this file already retires before it logs,
which is what made the omission visible: the progress cases were the one place
that broke the file's own rule. They report nothing now, and the exhaustive
switch stays so a new state is still a compile error.

A flush with no deadline of its own waited without one. The bound this path had
before the accounting was fixed is response_timeout_, which is also the value
the request itself is given, so a session outstanding when the watermark is
taken has at most that long before the client owes it a terminal event. The
bound is unchanged; what changes is that running out of it no longer reports
success. That also keeps this independent of the curl client work, where a
transfer can be left with no callback at all.

The deadline was built after the mutex was taken, so time spent waiting for the
lock came out of nobody's budget and the caller was handed a fresh one. It is
taken at entry now, and ForceFlush has a single wait path rather than two.

The GiveUpGuard could not reach its active destructor: the shutdown return
disarmed it and retired by hand, the successful path disarmed it after
SendRequest, and there was no other return between them. It is gone, and the
handler takes the completion by move rather than by copy. The invariant it was
guarding is stated where the two exits are.

WaitForWatermarks had the same thirty second bound as the whole CTest case, so a
watermark that never arrived killed the process before the case reached its own
assertion. Five seconds for the helper, sixty for CTest. The CMake comment named
a case that never parks; it names the one that does.

Concurrency between ForceFlush and Shutdown is a MUST in the stable Logs SDK
specification and this exporter had no case for it. Two now: one where the
shutdown settles the outstanding session and the flush is woken by it rather
than by its own bound, and one where the shutdown settles nothing and the flush
ends anyway.

watermarks_taken is labelled as test synchronization rather than left looking
like exporter state.

Verified by putting each defect back: a progress line restored fails
EverySessionStateIsClassifiedAndReportsAtMostOnce, the bound removed fails
ANoDeadlineFlushGivesUpAtTheResponseTimeout, and the unbounded wait restored
hangs AParkedFlushEndsEvenIfTheShutdownSettlesNothing at exit 124. All 31 cases
pass in the async build, under AddressSanitizer and under ThreadSanitizer, with
no sanitizer report and the same count in each.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Three comments described the change relative to the previous shape: a bound that
is "unchanged", a lock that was taken out, and an ordering that "no longer" comes
from the curl client. A reader of the file has no previous shape to compare
against, and the rationale for the change is in this PR.

Each now states the current fact: what response_timeout_ bounds, that nothing
serialises ForceFlush against Shutdown, and that the curl client does not produce
the ordering the case pins while another client may.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Review on this repository asks for comments about the current state of the code,
with the rationale for a change left to the pull request and the git history, and
for the contract to sit on the declaration it belongs to. Thirteen blocks here
were carrying an argument instead: what the alternative would have been, what a
previous revision did, why another design was rejected.

Each one keeps what a refactor would otherwise miss. The mutex that guards
next_session_id and running_sessions, the ordering the ids have to preserve and
why they are uint64_t, the two exits Export() is allowed to take, why the switch
carries no default label, and that a state on the way to an outcome must not log
while the session is still registered. The reasoning that led there is in the
pull request, where it can be argued with.

Added comment lines against added code: the header goes from 13 per 15 to 5 per
15, the source from 63 per 114 to 46 per 114. All 31 cases pass in the
asynchronous build with no warning under OTELCPP_MAINTAINER_MODE.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the fix/es-forceflush-deadline branch from 0eb3c55 to 52470cf Compare August 31, 2026 09:50
@thc1006

thc1006 commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

The one red is Bazel asan config, and it is not from this diff. BasicCurlHttpTests.ElegantQuitQuick in //ext/test/http:curl_http_test missed its 20 ms wall clock budget at curl_http_test.cc:940. That is the flake #4342 described and #4488 fixes; #4342 is closed, the flake is not.

This branch changes five files, all under exporters/elasticsearch/, so rather than argue it from the diff:

$ bazel query "somepath(//ext/test/http:curl_http_test, //exporters/elasticsearch/...)"
INFO: Empty results

The same job is green on main at 70fdb766, the commit this is rebased onto.

I cannot re-run a job here, so it will clear on the next push or on a maintainer re-run.

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

Labels

None yet

Projects

None yet

1 participant