Skip to content

Commit fe7348a

Browse files
fix(grpc): drain all remaining log lines at job completion in StreamLogs (#1508)
## Summary - `StreamLogs` did a single extra `getline` after detecting job completion, silently dropping any lines flushed between the last EOF poll and the job-complete marker - With multiple final log lines (e.g. `"Optimal solution found."` followed by `"Best objective ..."`), only the first was streamed to the client - Replace the single read with a drain loop so all buffered lines are sent before the `job_complete` sentinel ## Root cause `test_cli_lp_remote` and `test_cli_mip_remote` both parse the CLI subprocess output for solver status and objective. In remote mode the CLI streams server logs via gRPC. The race: if the solver logs two final lines in quick succession and the job is marked complete before `StreamLogs` polls again, the old code sent exactly one of those lines and dropped the rest. The probability scales with I/O load (more xdist workers = more contention = wider race window), which is why it surfaced when experimenting with higher `-n` values in pytest-xdist. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Authors: - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv) Approvers: - Alice Boucher (https://github.com/aliceb-nv) URL: #1508
1 parent 081462f commit fe7348a

3 files changed

Lines changed: 60 additions & 19 deletions

File tree

cpp/src/grpc/client/grpc_client.cpp

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,12 +281,14 @@ void grpc_client_t::start_log_streaming(const std::string& job_id)
281281
}
282282

283283
stop_logs_.store(false);
284+
log_stream_done_.store(false);
284285
log_thread_ = std::make_unique<std::thread>([this, job_id]() {
285286
stream_logs(job_id, 0, [this](const std::string& line, bool /*job_complete*/) {
286287
if (stop_logs_.load()) return false;
287288
if (config_.log_callback) { config_.log_callback(line); }
288289
return true;
289290
});
291+
log_stream_done_.store(true);
290292
});
291293
}
292294

@@ -308,6 +310,28 @@ void grpc_client_t::stop_log_streaming()
308310
if (t && t->joinable()) { t->join(); }
309311
}
310312

313+
void grpc_client_t::drain_log_streaming()
314+
{
315+
// No-op when streaming was never started (config_.stream_logs or
316+
// config_.log_callback disabled): log_stream_done_ stays false but
317+
// log_thread_ is null, so stop_log_streaming() is also a no-op.
318+
// Avoid the polling loop entirely to prevent a 5-second delay on the
319+
// common non-streaming solve path.
320+
if (!log_thread_) return;
321+
322+
// On the success path the server drains all pending log lines and then sends
323+
// a job_complete sentinel; the streaming thread exits naturally when that
324+
// sentinel arrives. Poll for natural completion (up to kDrainTimeout) so
325+
// callers receive the final log lines (e.g. "Best objective …") before we
326+
// force-cancel. Fall back to stop_log_streaming() if the deadline passes.
327+
static constexpr auto kDrainTimeout = std::chrono::seconds(5);
328+
auto deadline = std::chrono::steady_clock::now() + kDrainTimeout;
329+
while (!log_stream_done_.load() && std::chrono::steady_clock::now() < deadline) {
330+
std::this_thread::sleep_for(std::chrono::milliseconds(10));
331+
}
332+
stop_log_streaming();
333+
}
334+
311335
// =============================================================================
312336
// Proto → Client Enum Conversion
313337
// =============================================================================
@@ -848,7 +872,13 @@ remote_lp_result_t<i_t, f_t> grpc_client_t::solve_lp(
848872

849873
start_log_streaming(sub.job_id);
850874
auto poll = poll_for_completion(sub.job_id);
851-
stop_log_streaming();
875+
// On success, wait for the server to send the job_complete sentinel so all
876+
// final log lines are received before the stream is torn down.
877+
if (poll.completed) {
878+
drain_log_streaming();
879+
} else {
880+
stop_log_streaming();
881+
}
852882

853883
if (!poll.completed) { return {.error_message = poll.error_message}; }
854884

@@ -875,7 +905,11 @@ remote_mip_result_t<i_t, f_t> grpc_client_t::solve_mip(
875905

876906
start_log_streaming(sub.job_id);
877907
auto poll = poll_for_completion(sub.job_id);
878-
stop_log_streaming();
908+
if (poll.completed) {
909+
drain_log_streaming();
910+
} else {
911+
stop_log_streaming();
912+
}
879913

880914
if (!poll.completed) { return {.error_message = poll.error_message}; }
881915

cpp/src/grpc/client/grpc_client.hpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,10 @@ class grpc_client_t {
408408
// Activated when config_.stream_logs is true and config_.log_callback is set.
409409
void start_log_streaming(const std::string& job_id);
410410
void stop_log_streaming();
411+
// Graceful variant: waits up to kLogDrainTimeout for the server to send the
412+
// job_complete sentinel before force-cancelling. Use on the success path so
413+
// final log lines (e.g. "Best objective …") are not dropped.
414+
void drain_log_streaming();
411415

412416
// Shared polling loop used by solve_lp and solve_mip.
413417
struct poll_result_t {
@@ -419,6 +423,7 @@ class grpc_client_t {
419423

420424
std::unique_ptr<std::thread> log_thread_;
421425
std::atomic<bool> stop_logs_{false};
426+
std::atomic<bool> log_stream_done_{false};
422427
mutable std::mutex log_context_mutex_;
423428
// Points to the grpc::ClientContext* of the in-flight StreamLogs RPC (if
424429
// any). Typed as void* to avoid exposing grpc headers in the public API.

cpp/src/grpc/server/grpc_service_impl.cpp

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -823,31 +823,33 @@ class CuOptRemoteServiceImpl final : public cuopt::remote::CuOptRemoteService::S
823823
in.clear();
824824
}
825825

826-
// Job finished while we were caught up at EOF. The worker may still flush
827-
// the last log line after marking the job complete, so read once more
828-
// before closing the stream.
829-
std::string msg;
830-
JobStatus s = check_job_status(job_id, msg);
831-
if (s == JobStatus::COMPLETED || s == JobStatus::FAILED || s == JobStatus::CANCELLED) {
832-
// Resume offset for the final read (same tellg logic as the main loop).
826+
// Job finished while we were caught up at EOF. Drain all remaining lines
827+
// before closing: the worker may have flushed several lines (e.g. the
828+
// final solver summary) between our last getline and the job-complete
829+
// marker, and a single extra read would silently drop everything after
830+
// the first of those lines.
831+
std::string term_msg;
832+
JobStatus term_status = check_job_status(job_id, term_msg);
833+
if (term_status == JobStatus::COMPLETED || term_status == JobStatus::FAILED ||
834+
term_status == JobStatus::CANCELLED) {
833835
std::streampos read_start = in.tellg();
834836
if (read_start >= 0) { current_offset = static_cast<int64_t>(read_start); }
835837

836-
// One last getline: picks up a trailing line written after our previous
837-
// EOF poll. If nothing remains, skip straight to the sentinel below.
838-
if (std::getline(in, line)) {
839-
std::streampos read_end = in.tellg();
840-
// byte_offset on each message is where the client should resume; mirror
841-
// the main-loop fallback when tellg() is unavailable after the last line.
838+
bool drain_ok = true;
839+
while (std::getline(in, line)) {
840+
std::streampos read_end = in.tellg();
842841
int64_t next_byte_offset = current_offset + static_cast<int64_t>(line.size());
843842
if (read_end >= 0) { next_byte_offset = static_cast<int64_t>(read_end); }
844-
(void)write_log_message(line, next_byte_offset, false);
843+
if (!write_log_message(line, next_byte_offset, false)) {
844+
drain_ok = false;
845+
break;
846+
}
847+
current_offset = next_byte_offset;
845848
}
846849

847850
// Empty line + job_complete=true tells the client the log stream is done.
848-
// Use current_offset (not next_byte_offset): if we sent a final line above,
849-
// the client already got its resume point; this sentinel only marks EOF.
850-
(void)write_log_message("", current_offset);
851+
// Skip sentinel if the Write() failed mid-drain — stream is already broken.
852+
if (drain_ok) { (void)write_log_message("", current_offset); }
851853
return Status::OK;
852854
}
853855

0 commit comments

Comments
 (0)