[BUG] Stop the Elasticsearch async ForceFlush reporting success without waiting - #4337
Open
thc1006 wants to merge 1 commit into
Open
[BUG] Stop the Elasticsearch async ForceFlush reporting success without waiting#4337thc1006 wants to merge 1 commit into
thc1006 wants to merge 1 commit into
Conversation
thc1006
force-pushed
the
fix/es-forceflush-deadline
branch
4 times, most recently
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 82.11% 82.65% +0.55%
==========================================
Files 494 494
Lines 19670 19706 +36
==========================================
+ Hits 16151 16287 +136
+ Misses 3519 3419 -100
🚀 New features to boost your workflow:
|
This was referenced Aug 3, 2026
thc1006
force-pushed
the
fix/es-forceflush-deadline
branch
3 times, most recently
from
August 5, 2026 03:01
6085fad to
703f2b0
Compare
…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>
thc1006
force-pushed
the
fix/es-forceflush-deadline
branch
from
August 11, 2026 04:11
fac1be4 to
648de6e
Compare
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. Fixes #4338.
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 every terminal event called the result callback directly with no guard, whileReadError,WriteErrorandDestroyedfell through adefaultlabel 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 oneCompleteOnce, a compare-exchange that reports at most once and keeps the first verdict. The switch lists every state with nodefault, 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
OtlpHttpClientkeepsrunning_sessions_and waits for it to empty,SimpleSpanProcessorin opentelemetry-java keeps aSet<CompletableResultCode> pendingExportsand returnsofAllof it, and opentelemetry-go's batch processor uses aWaitGroup. 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.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 what keeps this one to the sessions the caller asked about.That argument holds only while the ids keep increasing, so they are
uint64_trather thansize_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:
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 ofExport(), with a scope guard that releases the id if the call gives up before a handler takes it over. Nothing between the two returns today andExport()isnoexcept, butCreateSession()can return null without being checked, 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 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 returntruefor a batch that was rejected. That is #3075 rather than something this changes; the header's@returnsays 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:
OnResponseand the terminalOnEventstates each ahead of their logging, and the handler destructor ahead ofFinishSession(). That order is deliberate, since the log handler is replaceable and one that callsForceFlush()would otherwise wait on the session its own call is still holding. The@returnsays that too now. It used to claim the session had ended, which the code never promised.Shutdown(timeout)still ignores its timeout, callsCancelAllSessions()andFinishAllSessions()in order, and returnstrueunconditionally. 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 inExport()and the cancellation inShutdown()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 afterShutdown()has returnedtrue. The window is the same onmain, and registering the session id earlier does not widen it, since nothing moved between the check andCreateSession(). 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.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.