[BUG] End the Elasticsearch exporter's wait on a read or write error - #4331
[BUG] End the Elasticsearch exporter's wait on a read or write error#4331thc1006 wants to merge 9 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4331 +/- ##
=======================================
Coverage 82.61% 82.61%
=======================================
Files 511 511
Lines 20132 20132
=======================================
Hits 16631 16631
Misses 3501 3501
🚀 New features to boost your workflow:
|
69473bc to
26ea36f
Compare
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.
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.
26ea36f to
4dcfc60
Compare
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.
c5e8c69 to
a8bcd0b
Compare
2073625 to
206c68f
Compare
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.
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.
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.
|
Context that saves working it out, since this one, #4297 and #4337 all sit on 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. |
acc15e1 to
85ec7ba
Compare
d8826b4 to
9cf9661
Compare
9ab554d to
090a25d
Compare
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>
090a25d to
f1ec07c
Compare
…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>
Fixes #4330, from @lalitb's review of #4298.
ReadErrorandWriteErrorleftcompletion_atPending. If either was the last callback, the predicate the synchronousExport()waits on could never become true and the export never returned. Both now recordCompletionState::Failurelike 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 isWarningandExport()'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
mainthey were debug, andOtlpHttpClientstill logs the same two states at debug behindconsole_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 responseanderror writing request. If a client emitsReadErroron a recoverable partial read and then delivers a good response, this reports failure, andIoErrorBeforeAResponseKeepsTheFailurepins 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:
SendFailedandResponseare 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 beforeExport()reaches the wait. Each case therefore also covers a completion recorded ahead of the waiter, the notification the barecv_.wait()before #4298 would have missed: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.
AWinningTerminalErrorIsReportedExactlyOncewalks the nine error level terminal states and holds that each fails the export and prints its own line exactly once.NoTerminalErrorAfterAResponseIsReportedwalks the same nine on the losing side and holds that none of them prints anything.OnlyTheFirstTerminalFailureIsReportedholds 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_testsregisters 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'sSetUprather than at the top of each body, becauseGTEST_SKIPreturns 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.coverageconfiguresall-options-abiv2-preview, which turns onENABLE_ASYNC_EXPORT, soExport()takes the asynchronous branch and the synchronousResponseHandleris never instantiated. gcov emits no line records for it at all: in that build the coverage data fores_log_record_exporter.ccstarts at line 265, whereAsyncResponseHandlerbegins, 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=ONand ran the exporter tests:[ PASSED ] 18 tests.../ci/do_ci.sh formatexits 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.AReadErrorAfterTheWaiterParksWakesItandAResponseAfterTheWaiterParksWakesItkeep 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. ReducingwaitForResponse()to reading the current state fails them at 0 ms, 2 of 2. The export runs on a detached thread holding ashared_ptrto everything it touches, because that wait has no deadline: written withstd::asyncthe 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 reportTIMEOUT=30.0, read back fromctest --show-only=json-v1.Two things a reviewer may want to know rather than discover. compiling a case out would leave
gtest_add_testsregistering a phantom that CTest then reports as a pass, and the skip sits inSetUpbecauseGTEST_SKIPreturns 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 isbazel.with_async_export.testand the sanitizer, legacy, noexcept and nortti entry points, compile these cases and skip them. Plainbazel.testuses the non async options, so they run there and in the CMake sync jobs.Then removed the
recordCompletioncall fromReadErroralone 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": falseparse, and the per operation shape #4297 now requires, which is anindexacknowledgement carrying a string_indexand an integer status. It did not carry_indexwhen 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 toexporters/elasticsearch/CMakeLists.txtthat 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
recordCompletionalways 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.