Skip to content

[BUG] End the Elasticsearch exporter's wait on a read or write error - #4331

Open
thc1006 wants to merge 9 commits into
open-telemetry:mainfrom
thc1006:bugfix/es-readerror-writeerror-completion-4330
Open

[BUG] End the Elasticsearch exporter's wait on a read or write error#4331
thc1006 wants to merge 9 commits into
open-telemetry:mainfrom
thc1006:bugfix/es-readerror-writeerror-completion-4330

Conversation

@thc1006

@thc1006 thc1006 commented Aug 2, 2026

Copy link
Copy Markdown
Member

Fixes #4330, from @lalitb's review of #4298.

ReadError and WriteError left completion_ at Pending. If either was the last callback, the predicate the synchronous Export() waits on could never become true and the export never returned. Both now record CompletionState::Failure like the other terminal states.

They are logged at error level, but only when they are the outcome. Recording is first writer wins, so either can arrive after a response has already succeeded, and in that case Export() still reports success. Logging unconditionally would announce a failure the caller was never told about, and logging at debug would hide the one case where the I/O error is the result, since the default log level is Warning and Export()'s failure return logs nothing of its own.

recordCompletion() therefore returns whether it was the write that decided the outcome, and the log follows that.

Two things in here are decisions rather than repairs, and I would rather put them in front of you than have them found.

The level moved from debug to error for these two. On main they were debug, and OtlpHttpClient still logs the same two states at debug behind console_debug_ and does not treat them as terminal at all. So this diverges from the sibling exporter in both level and terminality.

Treating them as terminal is a semantic call about an enum that does not define itself. The header says only error reading response and error writing request. If a client emits ReadError on a recoverable partial read and then delivers a good response, this reports failure, and IoErrorBeforeAResponseKeepsTheFailure pins that. #4330 asks for exactly this, so I have followed it, but say the word if you would rather the two exporters stayed aligned and the hang were closed some other way. The same rule applies to every error level terminal state rather than only these two: the line is printed by the call that decided the outcome, so none of them can describe a failure the caller was never told about.

Worth knowing for the risk: the in-tree curl client does not emit either state, so they can only arrive from a consumer that supplies its own HTTP client through the factory. The two new completion transitions are reachable only that way. The winner only logging rule, though, applies to the seven error states that were already terminal, so a client that reports more than one terminal event for one request now leaves one diagnostic instead of two. The bundled curl client can still do that: a setup failure dispatches ConnectFailed and then CreateFailed, which is the part of #4360 that is still open. For the consumer that supplies its own client, the export previously hung. (An earlier revision of this description tried to make that point by listing everything curl does dispatch, and got the list wrong: SendFailed and Response are dispatched too. The narrower claim is the one this change relies on.)

Tests

The cases lalitb asked for, driven by a fake HTTP client that delivers its callbacks from inside SendRequest(), which runs before Export() reaches the wait. Each case therefore also covers a completion recorded ahead of the waiter, the notification the bare cv_.wait() before #4298 would have missed:

  • a response,
  • a read error,
  • a write error,
  • a session destroyed while pending,
  • a session destroyed after a response, which stays successful because the first outcome recorded is the one reported,
  • a read or write error after a response, which stays successful for the same reason,
  • a read or write error before a response, which stays failed, which is the case where the error line is the only diagnostic the caller gets.

Two more that widen the net past the two states being fixed:

The cases pin results and, for the terminal errors, the diagnostic that goes with them. AWinningTerminalErrorIsReportedExactlyOnce walks the nine error level terminal states and holds that each fails the export and prints its own line exactly once. NoTerminalErrorAfterAResponseIsReported walks the same nine on the losing side and holds that none of them prints anything. OnlyTheFirstTerminalFailureIsReported holds that when two failures arrive, the one that decided the outcome is the one described.

They skip at runtime rather than compiling out when async export is enabled, because gtest_add_tests registers from the source: a case missing from the binary is still handed to CTest, and a gtest filter that matches nothing exits zero, so it would report a pass without running. The skip lives in the fixture's SetUp rather than at the top of each body, because GTEST_SKIP returns and a skip inside the body leaves everything after it unreachable, which MSVC reports as C4702 and the maintainer mode jobs turn into an error.

What the coverage job measures here

None of this. code.coverage configures all-options-abiv2-preview, which turns on ENABLE_ASYNC_EXPORT, so Export() takes the asynchronous branch and the synchronous ResponseHandler is never instantiated. gcov emits no line records for it at all: in that build the coverage data for es_log_record_exporter.cc starts at line 265, where AsyncResponseHandler begins, and every line this change touches is below that. So a green Codecov result is not evidence that these cases ran. They run in the synchronous jobs, which is where the code they change is the code that executes.

Verification

Built with WITH_ELASTICSEARCH=ON and ran the exporter tests: [ PASSED ] 18 tests.. ./ci/do_ci.sh format exits 0 with no diff.

Two of those come from the half of the wait a notification is for. Every other fake here delivers its callbacks from inside SendRequest(), including the two threads of the racing case, which are joined before it returns, so the export always reached its wait with the outcome already recorded. AReadErrorAfterTheWaiterParksWakesIt and AResponseAfterTheWaiterParksWakesIt keep the handler, return, show the export still in flight, and only then deliver a callback.

Measured both ways. Deleting both cv_.notify_all() calls used to leave all sixteen cases green; it now fails those two and the other sixteen still pass, 2 of 2 runs, each failing on its own ten second wait rather than hanging. Reducing waitForResponse() to reading the current state fails them at 0 ms, 2 of 2. The export runs on a detached thread holding a shared_ptr to everything it touches, because that wait has no deadline: written with std::async the same mutation took the whole binary down at exit 124 instead of failing the cases.

The cases carry a 30 second CTest timeout. They exist to catch a wait that never returns, and without a bound a regression would stall the job rather than fail it. Verified through ctest --show-only=json-v1: all twenty one registered Elasticsearch cases report TIMEOUT=30.0, read back from ctest --show-only=json-v1.

Two things a reviewer may want to know rather than discover. compiling a case out would leave gtest_add_tests registering a phantom that CTest then reports as a pass, and the skip sits in SetUp because GTEST_SKIP returns and one at the top of a body would leave the rest unreachable, which MSVC reports under maintainer mode. And the Bazel jobs that pass --copt=-DENABLE_ASYNC_EXPORT, which is bazel.with_async_export.test and the sanitizer, legacy, noexcept and nortti entry points, compile these cases and skip them. Plain bazel.test uses the non async options, so they run there and in the CMake sync jobs.

Then removed the recordCompletion call from ReadError alone and reran that one case under a 20 second cap: it timed out rather than failing, which is the hang this closes, and confirms the case is not passing for some other reason.

The response body the success cases use satisfies the current substring check, a top level "errors": false parse, and the per operation shape #4297 now requires, which is an index acknowledgement carrying a string _index and an integer status. It did not carry _index when this was first written, so on the current #4297 every success case here would have been read as a failure once the two met.

Landing next to the other Elasticsearch changes

This test file is also touched by #4297 and #4337, and all three add a fake HTTP client to it, so any two of them conflict there. #4337 adds the same set_tests_properties(... TIMEOUT 30) line to exporters/elasticsearch/CMakeLists.txt that this one does. Whichever lands first, I rebase the others onto it and drop the duplicate rather than carry a second copy. Of the three this has the smallest production diff, 32 lines, if you want an order.

The half I had left open is closed. The error line is printed only by the call that decided the outcome, and that is now held from both sides. It needed holding: leaving the recording in place but making recordCompletion always answer that this call did not decide the outcome kept all twelve of the earlier cases green while the exporter stopped describing any failure at all, measured 12 of 12 both ways. The same change fails two of the cases now.

@thc1006
thc1006 requested a review from a team as a code owner August 2, 2026 17:50
@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 82.61%. Comparing base (60c3d11) to head (5820fbf).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #4331   +/-   ##
=======================================
  Coverage   82.61%   82.61%           
=======================================
  Files         511      511           
  Lines       20132    20132           
=======================================
  Hits        16631    16631           
  Misses       3501     3501           
Files with missing lines Coverage Δ
...orters/elasticsearch/src/es_log_record_exporter.cc 12.22% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thc1006
thc1006 force-pushed the bugfix/es-readerror-writeerror-completion-4330 branch from 69473bc to 26ea36f Compare August 2, 2026 18:51
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 2, 2026
The helper tests call IsBulkResponseSuccessful() directly, so they cannot
show that the status and the body reach it. A handler that stored a fixed
status, or an Export() that never asked for one, passes all of them.

Three cases through the exporter with a fake HTTP client: an accepted bulk
response, the rejected item from open-telemetry#4295 whose shard counter still reads
"failed" : 0, and a 500 carrying a body the parser would otherwise accept.

The fake client is the same one open-telemetry#4331 adds to this file. Whichever lands
first, the other drops the duplicate when it rebases.
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 2, 2026
The helper tests call IsBulkResponseSuccessful() directly, so they cannot
show that the status and the body reach it. A handler that stored a fixed
status, or an Export() that never asked for one, passes all of them.

Three cases through the exporter with a fake HTTP client: an accepted bulk
response, the rejected item from open-telemetry#4295 whose shard counter still reads
"failed" : 0, and a 500 carrying a body the parser would otherwise accept.

The fake client is the same one open-telemetry#4331 adds to this file. Whichever lands
first, the other drops the duplicate when it rebases.
@thc1006
thc1006 force-pushed the bugfix/es-readerror-writeerror-completion-4330 branch from 26ea36f to 4dcfc60 Compare August 2, 2026 19:13
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 2, 2026
The helper tests call IsBulkResponseSuccessful() directly, so they cannot
show that the status and the body reach it. A handler that stored a fixed
status, or an Export() that never asked for one, passes all of them.

Three cases through the exporter with a fake HTTP client: an accepted bulk
response, the rejected item from open-telemetry#4295 whose shard counter still reads
"failed" : 0, and a 500 carrying a body the parser would otherwise accept.

The fake client is the same one open-telemetry#4331 adds to this file. Whichever lands
first, the other drops the duplicate when it rebases.
@thc1006
thc1006 force-pushed the bugfix/es-readerror-writeerror-completion-4330 branch from c5e8c69 to a8bcd0b Compare August 2, 2026 19:18
@thc1006
thc1006 force-pushed the bugfix/es-readerror-writeerror-completion-4330 branch from 2073625 to 206c68f Compare August 2, 2026 20:06
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 2, 2026
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.
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 3, 2026
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.
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 3, 2026
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.
@thc1006

thc1006 commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Context that saves working it out, since this one, #4297 and #4337 all sit on exporters/elasticsearch/src/es_log_record_exporter.cc and its test file.

Any two of the three conflict there, so whichever lands first the other two need a rebase. I will do those rebases, and there is no reason to take more than one of them.

Measured sizes, if it helps to pick a starting point. This one is 23 lines of production code and ends a wait that otherwise never returns. #4297 is 211 lines and stops a batch Elasticsearch rejected from reading as written. #4337 is 181 lines and the largest of the three. The test files are bigger than the production diffs in all three because each carries its own fake HTTP client; the second and third to land drop that copy when they rebase.

They fix separate things and none of them depends on the others, so the order is whatever suits you.

@thc1006
thc1006 force-pushed the bugfix/es-readerror-writeerror-completion-4330 branch 2 times, most recently from acc15e1 to 85ec7ba Compare August 5, 2026 03:01
@thc1006
thc1006 force-pushed the bugfix/es-readerror-writeerror-completion-4330 branch 2 times, most recently from d8826b4 to 9cf9661 Compare August 13, 2026 16:21
@thc1006
thc1006 marked this pull request as draft August 13, 2026 17:45
@thc1006
thc1006 force-pushed the bugfix/es-readerror-writeerror-completion-4330 branch from 9ab554d to 090a25d Compare August 14, 2026 05:49
ReadError and WriteError logged a line and recorded nothing, so a synchronous
Export() blocked in waitForResponse() with no completion to wake it and no
deadline of its own. Both are terminal for the session, so record a failure on
each.

recordCompletion() returns whether this call is the one that decided the
outcome. A read or write error can arrive after a response has already
succeeded, and the log line then describes a failure the caller was never told
about, so only the writer that won reports.

The accepted body the fixtures use now carries a per item status. Elasticsearch
always reports one for a bulk operation, so the fixture was not a response the
server could actually send.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
recordCompletion() reports whether this event decided the outcome, and nine
error states log only when it did. The cases only ever checked the losing side,
so the rule was half pinned: making the function still record the outcome but
always answer no keeps every case green while the exporter stops describing any
failure it reports to its caller. Measured, 12 of 12 both ways.

Three cases close it. Each of the nine error states reports itself exactly once
and carries its own message. Two failures in a row report only the one that
decided the outcome, which is not synthetic: open-telemetry#4360 records the shared curl
client reporting more than one terminal event for one request. And the losing
side now runs over the same table rather than three of the nine.

The same mutation fails two of them now.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Every case here delivers its callbacks one after another from inside
SendRequest(), so they cover a completion recorded before the waiter
arrives but never two callbacks arriving at once, which is the shape
the shared curl client can produce. This releases a response and a
read error together and holds that whichever wins, the result and the
diagnostic agree: a success reports nothing, a failure reports once and
says what failed.

Verified live rather than assumed. Removing the lock recordCompletion
takes makes ThreadSanitizer report a data race on completion_, reached
through this case, 3 of 3; with the lock in place the whole file is
clean 5 of 5.

The helpers added alongside the earlier cases sat at namespace scope,
where clang-tidy flags both of them under misc-use-internal-linkage.
Measured against the CI header filters: 2 warnings without the
anonymous namespace, 0 with it.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The comments carried the reasoning that found the bug as well as the rule the
code follows. The rule is what a reader needs; the rest belongs in the pull
request. Each block now states its constraint and stops, and the two member
comments in the installed headers follow the one line trailing form the file
already uses next to them.

No code changes.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Every fake here delivered its callbacks from inside SendRequest(), including
the two threads of the racing case, which are joined before it returns. The
export therefore reached its wait with the outcome already recorded, and the
notification was never what ended it. Measured: deleting both cv_.notify_all()
calls left all 16 cases green.

Two cases hold the other half. The session keeps the handler and returns, the
export is shown to be still waiting, and only then does a callback arrive.

Measured both ways. Without the notifications the two new cases fail and the
other 16 still pass, 2 of 2 runs, each failing on its own ten second wait.
With waitForResponse reduced to reading the current state they fail at 0 ms,
2 of 2. Clean, 18 pass, 3 of 3.

The export runs on a detached thread holding a shared_ptr to everything it
touches. waitForResponse waits without a deadline, so a broken wake-up never
returns; a joining future would take the whole binary down with it rather than
fail these cases, which is what it did when they were written with std::async:
exit 124, twice, with no case after them running.

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

The sentence about the return value went in as a second comment stacked under the
block that was already there, which leaves one declaration carrying two doc comments
and Doxygen reading only the last of them. It belongs in the block.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the bugfix/es-readerror-writeerror-completion-4330 branch from 090a25d to f1ec07c Compare August 15, 2026 19:29
…the log assertions

Three things the cases were getting away with.

The deferred helper published the handler into a slot beside the promise and signalled
with the promise. EXPECT_EQ is not fatal, so when the five second handoff wait timed out
the helper carried on and read that slot while the exporter's thread could still be
writing it. A wait that times out has not observed the promise becoming ready, so those
two accesses have nothing ordering them and the failure path was itself undefined. The
handler now travels in the promise and is taken from the future, and each precondition
reports and returns instead of leaving the rest of the helper to run on state it just
said was wrong.

The cases that read the exporter's error lines now sit on their own fixture. Below error
level OTEL_INTERNAL_LOG_ERROR expands to nothing rather than being filtered, so with
-DOTEL_INTERNAL_LOG_LEVEL=0 the ones expecting the winner to report failed on correct
code and the ones expecting silence from the loser passed without testing anything.
Measured: with the fixture, twelve pass and those six skip; move one back and it fails
expecting one line and finding none.

The accepted bulk body now names an index. open-telemetry#4297 requires a string _index in every index
acknowledgement, so without it every success case here would be read as a failure once
these two meet, which is the opposite of what this file's comment promised.

The two deferred cases are renamed for what they hold. The session publishes the handler
before SendRequest() returns, so they cover a callback delivered after the handoff while
the export is still running, not a waiter proven to be inside cv_.wait().

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

Every other terminal state describes the outcome only when it is the event that decided
it. Destroyed did not: it wrote a debug line and then recorded the failure, so a session
that ended without a response left the caller with kFailure and, at the default warning
level, nothing saying why. It is the one state that could decide the result silently.

It now follows the same rule as the rest, an error when it records the failure and a
debug line when an earlier outcome already won, and it joins the table the terminal
cases iterate so the winner and loser halves are held for it too.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 marked this pull request as ready for review August 16, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Elasticsearch exporter can still block on ReadError or WriteError

1 participant