Skip to content

Commit 069dfde

Browse files
committed
[BUGFIX] Bound the flush, and say nothing on the way to an outcome
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>
1 parent 80b050f commit 069dfde

4 files changed

Lines changed: 195 additions & 72 deletions

File tree

‎exporters/elasticsearch/CMakeLists.txt‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ if(BUILD_TESTING)
5656
TEST_PREFIX exporter.
5757
TEST_LIST es_log_record_exporter_test)
5858

59-
# AnIndefiniteFlushReturnsOnceEverythingIsFinished exercises the branch that
60-
# waits without a deadline, so a regression there does not fail, it stops.
61-
# CTest's default bound is 25 minutes, which is a long time to spend learning
62-
# that.
63-
set_tests_properties(${es_log_record_exporter_test} PROPERTIES TIMEOUT 30)
59+
# AnIndefiniteFlushParksUntilTheOutcomeArrives really does park on the
60+
# condition variable, so a regression there does not fail, it stalls until the
61+
# exporter's own bound expires. CTest's default is 25 minutes, which is a long
62+
# time to spend learning that. This is deliberately well above the bounds the
63+
# cases use themselves, so that one of them expiring reports which case failed
64+
# rather than being killed from the outside.
65+
set_tests_properties(${es_log_record_exporter_test} PROPERTIES TIMEOUT 60)
6466
endif() # BUILD_TESTING

‎exporters/elasticsearch/include/opentelemetry/exporters/elasticsearch/es_log_record_exporter.h‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,12 @@ class ElasticsearchLogRecordExporter final : public opentelemetry::sdk::logs::Lo
170170
std::condition_variable force_flush_cv;
171171
std::mutex force_flush_cv_m;
172172

173-
// Raised once per ForceFlush(), under that mutex and immediately after the call has taken
174-
// its watermark. Nothing in the exporter reads it. A case that has to start an export after
175-
// a flush has snapshotted, and before it gives up, waits on this: the alternative is a sleep,
176-
// which leaves the order to the scheduler, and the order that does not reproduce the defect
177-
// is the one that would report a pass.
173+
// Test synchronization only, and not exporter state: nothing here reads it and no behaviour
174+
// depends on it. Raised once per ForceFlush(), under that mutex and immediately after the
175+
// call has taken its watermark. A case that has to start an export after a flush has
176+
// snapshotted, and before it gives up, waits on this. The alternative is a sleep, which
177+
// leaves the order to the scheduler, and the order that does not reproduce the defect is the
178+
// one that would report a pass.
178179
std::uint64_t watermarks_taken{0};
179180
};
180181
nostd::shared_ptr<SynchronizationData> synchronization_data_;

‎exporters/elasticsearch/src/es_log_record_exporter.cc‎

Lines changed: 46 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -338,34 +338,31 @@ class AsyncResponseHandler : public http_client::EventHandler
338338
const char *failure = nullptr;
339339
switch (state)
340340
{
341+
// The states a session passes through on its way to an outcome. Nothing is reported for
342+
// them, and in particular nothing is logged: the log handler is replaceable, this session is
343+
// registered before the request is handed to the client, and none of these events has
344+
// retired it. A handler that flushed from here would wait for the session whose call stack
345+
// it is standing in. Every other report in this file retires first for that reason, and
346+
// there is nothing to retire on the way to an outcome.
347+
case http_client::SessionState::Created:
348+
case http_client::SessionState::Connecting:
349+
case http_client::SessionState::Connected:
350+
case http_client::SessionState::Sending:
351+
// The body arrives through OnResponse(), which is what reports the outcome.
352+
case http_client::SessionState::Response:
353+
break;
341354
case http_client::SessionState::CreateFailed:
342355
failure = "[ES Log Exporter] Create request to elasticsearch failed";
343356
break;
344-
case http_client::SessionState::Created:
345-
OTEL_INTERNAL_LOG_DEBUG("[ES Log Exporter] Session created");
346-
break;
347357
case http_client::SessionState::Destroyed:
348358
failure = "[ES Log Exporter] Session to elasticsearch destroyed before a response";
349359
break;
350-
case http_client::SessionState::Connecting:
351-
OTEL_INTERNAL_LOG_DEBUG("[ES Log Exporter] Connecting to elasticsearch");
352-
break;
353360
case http_client::SessionState::ConnectFailed:
354361
failure = "[ES Log Exporter] Connection to elasticsearch failed";
355362
break;
356-
case http_client::SessionState::Connected:
357-
OTEL_INTERNAL_LOG_DEBUG("[ES Log Exporter] Connected to elasticsearch");
358-
break;
359-
case http_client::SessionState::Sending:
360-
OTEL_INTERNAL_LOG_DEBUG("[ES Log Exporter] Sending request to elasticsearch");
361-
break;
362363
case http_client::SessionState::SendFailed:
363364
failure = "[ES Log Exporter] Request failed to be sent to elasticsearch";
364365
break;
365-
case http_client::SessionState::Response:
366-
// The body arrives through OnResponse(), which is what reports the outcome.
367-
OTEL_INTERNAL_LOG_DEBUG("[ES Log Exporter] Response received from elasticsearch");
368-
break;
369366
case http_client::SessionState::SSLHandshakeFailed:
370367
failure = "[ES Log Exporter] SSL handshake to elasticsearch failed";
371368
break;
@@ -482,10 +479,9 @@ sdk::common::ExportResult ElasticsearchLogRecordExporter::Export(
482479
//
483480
// That is the whole of what the ordering buys, and it is worth being exact about the rest.
484481
// It does not make a log handler safe to re-enter the exporter from in general: one that
485-
// calls ForceFlush() without a deadline from a progress event blocks the Export() that has
486-
// not handed its request to the client yet, and one that flushes from any callback the HTTP
487-
// client dispatches can wait on work only that client thread can advance. Neither is new
488-
// here and neither is fixed here.
482+
// flushes from any callback the HTTP client dispatches can still wait on work that only the
483+
// client thread it is standing on can advance. That is
484+
// https://github.com/open-telemetry/opentelemetry-cpp/issues/4435, and it is not fixed here.
489485
if (result != opentelemetry::sdk::common::ExportResult::kSuccess)
490486
{
491487
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] ERROR: Export "
@@ -500,37 +496,19 @@ sdk::common::ExportResult ElasticsearchLogRecordExporter::Export(
500496
return true;
501497
};
502498

503-
// A return added between here and SendRequest() would otherwise strand a waiter on a session
504-
// that can never finish. Reporting through the same completion leaves a session one way out,
505-
// and says so in the log rather than dropping the batch silently.
506-
struct GiveUpGuard
507-
{
508-
Completion *report = nullptr;
509-
510-
GiveUpGuard() = default;
511-
GiveUpGuard(const GiveUpGuard &) = delete;
512-
GiveUpGuard &operator=(const GiveUpGuard &) = delete;
513-
GiveUpGuard(GiveUpGuard &&) = delete;
514-
GiveUpGuard &operator=(GiveUpGuard &&) = delete;
515-
516-
~GiveUpGuard()
517-
{
518-
if (report != nullptr)
519-
{
520-
(*report)(opentelemetry::sdk::common::ExportResult::kFailure);
521-
}
522-
}
523-
} guard;
524-
guard.report = &complete;
499+
// Two ways out from here, and each one is spelled out below: the shutdown refusal retires by
500+
// hand, and the handler takes the session over once SendRequest() has it. A return added
501+
// between them has to do one or the other, or it strands a waiter on a session that can never
502+
// finish.
525503
#endif
526504

527505
// Return failure if this exporter has been shutdown
528506
if (isShutdown())
529507
{
530508
#ifdef ENABLE_ASYNC_EXPORT
531-
// Retired before anything replaceable runs, and reported here rather than through the guard,
532-
// so a log handler that flushes does not wait for this Export() and one refusal reads as one.
533-
guard.report = nullptr;
509+
// Retired before anything replaceable runs, and reported by the line below rather than
510+
// through the completion, so a log handler that flushes does not wait for this Export() and
511+
// one refusal reads as one.
534512
retire();
535513
#endif
536514
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Exporting "
@@ -573,10 +551,11 @@ sdk::common::ExportResult ElasticsearchLogRecordExporter::Export(
573551

574552
#ifdef ENABLE_ASYNC_EXPORT
575553
// Send the request
576-
auto handler = std::make_shared<AsyncResponseHandler>(session, Completion(complete),
577-
options_.console_debug_);
554+
// The handler reports this session from here on, so the completion goes to it rather than
555+
// being copied to it.
556+
auto handler =
557+
std::make_shared<AsyncResponseHandler>(session, std::move(complete), options_.console_debug_);
578558
session->SendRequest(handler);
579-
guard.report = nullptr; // the handler reports this session from here on
580559
return sdk::common::ExportResult::kSuccess;
581560
#else
582561
// Send the request
@@ -627,6 +606,25 @@ bool ElasticsearchLogRecordExporter::ForceFlush(std::chrono::microseconds timeou
627606
timeout = opentelemetry::common::DurationUtil::AdjustWaitForTimeout(
628607
timeout, std::chrono::microseconds::zero());
629608

609+
// A caller that asks for no deadline still gets one, because a flush that cannot end is worse
610+
// than one that reports it did not finish, and the client is not obliged to call back at all.
611+
// response_timeout_ is the bound this wait already had before the accounting was fixed, and it
612+
// is the same value the request itself is given, so a session outstanding when the watermark is
613+
// taken has at most that long before the client owes it a terminal event. The bound is
614+
// unchanged; what changes is that running out of it no longer reports success.
615+
if (timeout <= std::chrono::microseconds::zero())
616+
{
617+
timeout = std::chrono::seconds{options_.response_timeout_};
618+
}
619+
620+
// Taken before the lock rather than after it, so the time spent waiting for the mutex comes out
621+
// of the caller's budget instead of being handed back as a fresh one. A plain mutex still
622+
// cannot promise a bound, but acquiring it should not add a second full timeout to the one that
623+
// was asked for.
624+
const std::chrono::steady_clock::time_point deadline =
625+
std::chrono::steady_clock::now() +
626+
std::chrono::duration_cast<std::chrono::steady_clock::duration>(timeout);
627+
630628
std::unique_lock<std::mutex> lock(synchronization_data_->force_flush_cv_m);
631629

632630
// The snapshot is the next id, not a count: a session started after it takes a larger id and
@@ -639,20 +637,9 @@ bool ElasticsearchLogRecordExporter::ForceFlush(std::chrono::microseconds timeou
639637
return running.empty() || *running.begin() >= watermark;
640638
};
641639

642-
if (timeout <= std::chrono::microseconds::zero())
643-
{
644-
// wait() only returns once the predicate holds, so the flush has completed.
645-
synchronization_data_->force_flush_cv.wait(lock, flushed);
646-
return true;
647-
}
648-
649640
// One deadline for the call, so a wakeup that is not a completion resumes against what is left
650641
// rather than starting the wait again. wait_until() returns the predicate, so a flush that ran
651642
// out of time cannot report success.
652-
const std::chrono::steady_clock::time_point deadline =
653-
std::chrono::steady_clock::now() +
654-
std::chrono::duration_cast<std::chrono::steady_clock::duration>(timeout);
655-
656643
return synchronization_data_->force_flush_cv.wait_until(lock, deadline, flushed);
657644
#else
658645
return true;

0 commit comments

Comments
 (0)