[BUG] Stop the Elasticsearch async ForceFlush reporting success without waiting - #4337
Open
thc1006 wants to merge 22 commits into
Open
[BUG] Stop the Elasticsearch async ForceFlush reporting success without waiting#4337thc1006 wants to merge 22 commits into
thc1006 wants to merge 22 commits into
Conversation
The wait ran 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. Since the loop condition had already established that the remaining budget was positive, the return was true on that path whatever had happened, so every flush that ran out of time reported success. It now derives one steady_clock deadline from the caller's timeout and waits on a completion predicate, so the return value is that predicate. The completion counter is published under the mutex the waiter holds. It was an atomic incremented outside it, which does not lose the state but does lose the notification when it lands between the predicate check and the park. Reported in open-telemetry#4336.
thc1006
force-pushed
the
fix/es-forceflush-deadline
branch
3 times, most recently
from
August 2, 2026 21:00
46a015c to
c8c0e4b
Compare
The fake session kept the handler so one test could complete a session after the waiter parked, but a handler owns its session, so that closed a reference cycle and every case leaked one handler and one session. The script now receives the shared_ptr and the one test that needs the handler holds it itself, so nothing the fakes own points back at them. Found by LeakSanitizer in the Bazel asan job, which builds with ENABLE_ASYNC_EXPORT and therefore runs these cases.
thc1006
force-pushed
the
fix/es-forceflush-deadline
branch
from
August 2, 2026 21:01
c8c0e4b to
bd0c772
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4337 +/- ##
==========================================
+ Coverage 81.00% 81.43% +0.43%
==========================================
Files 448 450 +2
Lines 19121 19221 +100
==========================================
+ Hits 15488 15650 +162
+ Misses 3633 3571 -62
🚀 New features to boost your workflow:
|
Measured with lcov rather than guessed: the branch taken when the caller passes no deadline was the only part of the change with no test, and the async result callback's error log went with it.
It protected nothing. Each call snapshots the session counter it is waiting for and waits on its own predicate, and the wait publishes no state, so two callers were already safe side by side. What the lock did do was make a second caller queue behind the first one's wait, however short its own timeout, which the previous commit worked around with a timed acquisition. Removing it is the simpler answer to that, and it also drops a try_lock_until that ThreadSanitizer cannot see through: libstdc++ routes it to pthread_mutex_clocklock, which libtsan does not intercept, so every matching unlock was reported as unlocking an unheld mutex. force_flush_m was declared in the header and used in exactly one place.
The first caller waited three seconds so the suite spent three seconds on one case. It only has to outlast the bound the second caller is measured against, so 1500ms against a 700ms bound keeps a queued second caller well outside the bound while costing half the wall clock.
ExportWith was copied in from a sibling branch and never called here, and kAcceptedBody was only ever referenced from the async cases. Both sit in an anonymous namespace, so a synchronous maintainer-mode build fails on -Wunused-function and -Wunused-const-variable with -Werror. The whole fixture serves the ForceFlush cases, which exist only in an async build, so it now lives inside the same guard.
The flush fixture and its cases are behind ENABLE_ASYNC_EXPORT, so in a build without it these five includes have no user and the abiv1 include-what-you-use job fails at warning_limit 0. They move behind the same guard, which is how the exporter source already handles its own.
gtest_add_tests registers from the source, so the eight cases were registered with CTest in every build while the binary only contained them under ENABLE_ASYNC_EXPORT. A filter that matches nothing exits zero, so all eight reported a pass in the synchronous jobs without running. They now build everywhere and skip in SetUp, which also keeps GTEST_SKIP out of the case bodies where its return leaves the rest unreachable.
…dline # Conflicts: # CHANGELOG.md
clang-tidy's misc-use-internal-linkage counts a file scope test fixture, which took both presets one over their limit.
AnIndefiniteFlushReturnsOnceEverythingIsFinished exercises the branch that waits with no deadline. If its precondition ever stops holding the case does not fail, it stops, and CTest's default bound is 25 minutes. Thirty seconds is well clear of the 1.6 second suite.
The handler reported the outcome from OnResponse and from every terminal event with no guard, and the exporter counts one finished session per report. Two terminal callbacks for one request therefore counted twice, and because the counters are monotonic and compared with >=, the overshoot was permanent: every later ForceFlush returned a session early. In the other direction ReadError, WriteError and Destroyed fell into a default label and reported nothing, so those sessions were never counted as finished at all. Every outcome now goes through CompleteOnce, the switch lists all fifteen states so -Wswitch reports a new one, and the destructor reports a failure if nothing else did. The error line follows the guard, so an event that arrives after a response has already been reported stays quiet. Built on the ForceFlush fix, because the counter is only observable through ForceFlush and the one on main reports success on every timeout. Reported in open-telemetry#4338.
Reported by include-what-you-use on open-telemetry#4331, which has the same construct. Adding it here rather than waiting for the same red run.
Matches the ForceFlush fixture: clang-tidy's misc-use-internal-linkage counts a file scope test fixture against the preset limits.
ForceFlush() snapshotted the number of sessions started and waited for the number finished to reach it. Both are monotonic totals with no session identity, so any completion satisfied any waiter: a batch exported after the snapshot could finish and be counted as the batch the caller was waiting on, reporting success with that export still in flight. Sessions now carry an id, and the ones running are held in an ordered set rather than counted. The snapshot is the next id to be handed out, and ids are issued in order, so the smallest one still running decides: once it is at or past the snapshot, everything the call was waiting for has gone. A session started later takes a larger id and cannot stand in for an earlier one, and a completion delivered twice erases an id that has already gone. Both members live under force_flush_cv_m, the mutex the wait already uses, so a session starting or finishing cannot interleave with a waiter taking its snapshot or evaluating its predicate. The constructor no longer zeroes the counters, which the members now do themselves. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The exactly-once cases asserted through ForceFlush(), which stopped meaning anything once sessions were identified rather than counted: a repeated completion erases an id that has already gone, so the flush reports the same thing whether the callback ran once or three times. Removing the compare-exchange from CompleteOnce() left all five of them green. The callback logs one line per invocation, at error level for a failure and at debug level for a success, so a log handler counts it directly. With that, removing the compare-exchange turns eight cases red. The cases now cover every SessionState in one table, both its classification and that a repeat does not report again; the terminal orderings a session can produce; a response followed by each teardown event; and the two races the inline scripts cannot reach, two terminal events and a response against a terminal event, each delivered from its own thread. ForceFlush() gains the substitution with the count actually reaching the snapshot: two exports outstanding when the flush is asked, a third started after it, and two completions arriving from the third and one of the originals. The export the caller waited on never finished. The .cc no longer names std::set, which include-what-you-use asks it to drop in the configurations that build without async export. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The session was registered after the request had been created and the whole batch serialised into its body. A ForceFlush() asked while that was happening snapshotted past the batch and reported it as already flushed, although the records had been handed to Export() before the call was made. 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(), after the shutdown check and before anything that can take time. A scope guard releases the id if the call gives up before handing the session to a handler. Nothing between the two returns today and Export() is noexcept, but CreateSession() can return null without being checked, so the check that eventually adds that early return would otherwise leave a waiter blocked on a session that can never finish. ForceFlush's contract in the header now says what true means: the sessions the call snapshotted have settled, not that their batches reached Elasticsearch. A failed export settles its session and reports through the internal log, which is open-telemetry#3075 rather than something this change makes true. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Three of the four ways this function reported success without having waited are not the deadline, so an entry that only names the timeout would leave them out of the release notes. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Half this file only exists under ENABLE_ASYNC_EXPORT, so the tool reaches opposite conclusions about the same includes: the presets that build without async export ask for <set> and nostd/shared_ptr.h to go, and the ones that build it ask for them. Those two are guarded, and <map> takes the keep pragma the file already uses one line below, since only the asynchronous build sees it as redundant. Verified against all three presets the workflow builds, all-options-abiv1, all-options-abiv1-preview and all-options-abiv2-preview, and against the previous head of this branch so that the findings were known to be mine. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The completion cases counted the callback but not what it reported, so a CompleteOnce() that guarded correctly and then passed the wrong result would have left every one of them green. The counter now separates successes from failures, and the two ordering cases say which one has to survive: a response followed by a teardown keeps the response's verdict, a cancellation followed by a late response keeps the cancellation's. Inverting the verdict turns both red. The comments around the registration guard, the flush snapshot and the synchronization members restated in prose what the lines below them already say. What is left is the part that is not visible from the code. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The registration guard released the id and said nothing. A call that gave up between registering and dispatching would therefore have unblocked the flush while leaving no trace of the batch it dropped, which is the one case the guard exists for. It now reports through the same completion the handler uses, so a session has one way out and the give-up path names itself in the log. Verified by adding the kind of early return the guard is for: the flush is not stranded and the log carries "ERROR: Export 1 trace span(s) error: 1". The case that pins what true means says so in its name and points at open-telemetry#3075, along with the reason it is not settled here: OTLP HTTP's ForceFlush reports the same way today, and changing one of the two alone would leave them disagreeing about the same word. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The local include-what-you-use and clang-tidy runs built the exporter library, which does not compile the test file, so three findings reached CI instead. The test drops an include the log handler header already provides, takes a reference where it was copying a shared_ptr, and the scope guard declares the four special members that a user-declared destructor calls for. The guard holds a pointer to the completion now rather than a shared_ptr, so the .cc no longer names one and the include goes with it. The two async include guards were the same condition twice and are one block. Checked by building the test target against all three cmake option presets the workflows use, and against main for a baseline: include-what-you-use reports the same two blocks and no include changes on either, and clang-tidy reports the same eighteen warnings on either, with nothing on the branch that is not also on main. Comments in the completion cases now say what the code does rather than what it used to do, which is the review note on open-telemetry#4327 applied here as well. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4336.
ForceFlushwaited foroptions_.response_timeout_rather than the time the caller gave it, and the timeout branch left the loop without subtracting what it had just spent:The loop condition has already established that
timeout_steadyis positive, so the return on that path istruewhatever happened. Every flush that ran out of time reported success. The only way to getfalsewas 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, and it works against the spec requirement that a processorMUST prioritize honoring the timeout over finishing all calls.The change
One
steady_clockdeadline derived from the caller's timeout, and a wait on a completion predicate.wait_untilreturns the predicate, so the return value is now the answer to the question the caller asked rather than a leftover duration.Serialising concurrent calls was a second way to miss the deadline:
force_flush_mwas 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 aForceFlush(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 behindENABLE_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++ routestry_lock_untiltopthread_mutex_clocklockand libtsan does not intercept it. Deleting the lock was the better answer to the same problem and that revision is not in this diff.The completion counter moves under the mutex the waiter holds. It was an atomic 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.
AdjustWaitForTimeoutalready mapsmicroseconds::max()and anything that would overflownow() + timeoutto zero, so zero is the sentinel for a caller that asked for no deadline, and that branch waits until the flush actually completes.Two notes on the edges of that:
trueafter oneresponse_timeout_. For the bundled curl client the wait is bounded anyway, becauseExport()setsCURLOPT_TIMEOUT_MSfromresponse_timeout_and every session therefore terminates and dispatches. An HTTP client injected through the factory that accepts a request and never calls back would block, where it used to be told the flush had succeeded. Blocking on an unbounded wait is whatBatchLogRecordProcessor::ForceFlushdoes, so this matches the SDK, but say so if you would rather it stayed bounded and returnedfalse.Tests
Eight cases, with a fake HTTP client injected through the public constructor:
response_timeout_,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 other five pass either way, and I would rather name that than let eight cases look like eight guards. They exercise paths that return before any wait, or that the old polling loop happened to get right.
The counter moving under
force_flush_cv_mhas no case of its own. It closes a window between the waiter's predicate check and its park, and the fake completes from a thread that sleeps first, so the window is never hit.Verification
[ PASSED ] 22 tests.built withWITH_ELASTICSEARCH=ON -DWITH_ASYNC_EXPORT_PREVIEW=ON, and the same binary in the synchronous configuration, where the cases that describe a wait skip inSetUp. Both build with no warnings underOTELCPP_MAINTAINER_MODE=ON.Restoring the previous
ForceFlushand rebuilding the same tests fails the ones that should discriminate:The 2000 ms is the whole
response_timeout_being spent against a 20 ms caller deadline, which is the second half of the defect../ci/do_ci.sh formatexits 0 with no diff. All 22 pass under AddressSanitizer with leak detection on and under ThreadSanitizer with no warnings, which the two cases that deliver a response and a terminal event from separate threads are the reason to check rather than assume.include-what-you-use and clang-tidy were measured against
mainrather 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; onall-options-abiv2-previewclang-tidy reports the same eighteen warnings on either, with nothing on this branch that is not also onmain. The async-only includes sit behind theENABLE_ASYNC_EXPORTguard, because the presets that build without it ask for them to go while the ones that build it ask for them.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.
OnResponseand everyterminal event called the result callback directly with no guard, while
ReadError,WriteErrorandDestroyedfell through adefaultlabel andcalled 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 firstverdict. The switch lists every state with no
default, so a state addedupstream 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
OtlpHttpClientkeepsrunning_sessions_and waits for it to empty,SimpleSpanProcessorinopentelemetry-java keeps a
Set<CompletableResultCode> pendingExportsandreturns
ofAllof it, and opentelemetry-go's batch processor uses aWaitGroup. A frontier answers a stronger question, whether a contiguousprefix 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.
BatchLogRecordProcessorhere 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 aWaitGroupboth include sessions started after the call. The watermark is whatkeeps this one to the sessions the caller asked about.
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:
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 wasregistered 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(), with a scope guard that releases the id if the call gives upbefore a handler takes it over. Nothing between the two returns today and
Export()isnoexcept, butCreateSession()can return null without beingchecked, and the check that eventually adds that early return would otherwise
leave a waiter blocked on a session that can never finish.
What it still does not fix
truemeans the sessions the call snapshotted have settled, not that theirbatches reached Elasticsearch. A failed export settles its session and reports
through the internal log, so a flush can return
truefor a batch that wasrejected. That is
#3075 rather
than something this changes; the header's
@returnnow says so instead ofpromising that all data are exported.
Shutdown(timeout)still ignores its timeout, callsCancelAllSessions()andFinishAllSessions()in order, and returnstrueunconditionally. Separate, andnot something to read this pull request as having fixed.
The cases build in every configuration and skip in
SetUpwhere the wait does not exist. An earlier revision compiled them out instead, which was wrong in a way worth naming:gtest_add_testsregisters from the source, so all eight stayed registered with CTest in the synchronous jobs, and a gtest filter matching nothing exits zero, so each reported a pass without running. Skipping inSetUprather than at the top of each body also keepsGTEST_SKIP, which returns, from leaving the rest of a body unreachable, which MSVC reports as C4702.Each of the three defects is pinned by reverting it: moving the registration back below the request build turns
AnExportAlreadyUnderWayIsSomethingToWaitForred, restoring the counter comparison turns the two substitution cases red, and removing the compare-exchange fromCompleteOnce()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 throughForceFlush(): 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 cases also carry a thirty second CTest timeout.
AnIndefiniteFlushReturnsOnceEverythingIsFinishedexercises the branch that waits with no deadline, so if its precondition ever stops holding the case does not fail, it stops, and CTest's own default bound is twenty five minutes. The slowest case takes 1.5 seconds today, and that 1.5 seconds is a deliberate wait rather than compute, so it does not stretch with runner load.Landing next to the other Elasticsearch changes
exporters/elasticsearch/test/es_log_record_exporter_test.ccis 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 the sameset_tests_properties(... TIMEOUT 30)line toexporters/elasticsearch/CMakeLists.txtthat this one does. Whichever lands first, I rebase the rest onto it and drop the duplicate.