fix(#195): stream opentofu task logs incrementally and expose log fields in the task list - #201
fix(#195): stream opentofu task logs incrementally and expose log fields in the task list#201jgruberf5 wants to merge 4 commits into
Conversation
…lds in the task list
Two distinct defects made a running opentofu module opaque.
1. Logs buffered until completion. The runtime ran init/plan/apply/destroy
via a blocking subprocess.run(capture_output=True), returning the whole
log only when the process exited; the Celery tasks then wrote task.logs
once at the end. So logs_full_size sat at 0 for the entire run and jumped
to its final value at completion -- you could not tell working from wedged.
Root fix: OpenTofuRuntime.run_{init,plan,apply,destroy} now accept an
optional on_output callback and, when given, stream stdout+stderr
line-by-line through a new _stream_subprocess helper (Popen + watchdog
timeout, preserving the (returncode, output) contract and TimeoutExpired
semantics; blocking capture is kept when no callback is passed, so existing
behaviour and tests are unchanged). A new TofuLogStreamer turns that
callback into throttled task.logs writes, so logs_full_size grows during
the run. The task still writes the complete all_logs at completion, so the
final content and size -- and the per-line timestamp format -- are intact.
2. The task LIST endpoint omitted log fields. get_tasks never returned
logs_full_size (it surfaced as null), while get_task did -- so enumerating
a module's tasks showed every one as empty. The list now includes
logs_full_size, computed in SQL via func.length(Task.logs) (logs is a
deferred Text column, so this avoids loading every body just to measure it)
and matching the detail endpoint's len(logs); logs_truncated is included as
False since the list returns no body by design.
Tests lock: a real-subprocess handshake proving lines arrive before the
process exits; the runtime routing streaming vs blocking; TofuLogStreamer
growth/throttle/rollback; a task-level reproduce asserting the persisted
logs_full_size grows across lines while status is still in_progress; and the
list endpoint exposing logs_full_size matching the detail endpoint.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Self-review (MAJOR): test_delivers_each_line_before_process_finishes proved nothing -- the child self-released after ~10s (range(200)*0.05) and the test asserted only line ORDER, so a fully-buffered _stream_subprocess (the #195 bug) passed it. Reproduced: mutating _stream_subprocess to fire on_output only after proc.wait() left the test GREEN. Fix: the child now blocks ~300s (>> the watchdog), so a buffered impl cannot self-release -- it deadlocks, the 8s watchdog kills it, _stream_subprocess raises TimeoutExpired and the test ERRORS. Added an explicit timing bound (call returns in <5s) as a second, fast signal. Mutation-confirmed: real code passes in ~5s; the buffered mutation now FAILS at the watchdog. Production code unchanged. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Self-review (cold, adversarial) — production code sound; one vacuous test fixedAn independent cold auditor reviewed this PR, executing the code and mutation-testing every claim. No production blocker — the streaming, the throttled flush, the list-endpoint fields, and the contract preservation are all correct. One MAJOR test-vacuousness defect, now fixed. MAJOR (fixed, Held under attack (verified clean):
Full regression sweep: 82 passed (opentofu tasks/runtime, routes/tasks, + the two new files). Both #195 defects (buffered logs; list omits log fields) are correctly and now non-vacuously fixed. |
Review — round 1 @
|
| gate | result |
|---|---|
make lint-backend (ruff) |
PASS |
make openapi-check |
PASS — spec up to date (533 paths / 472 schemas) |
make test-backend-unit |
PASS — 4862 passed |
make test-backend-component |
PASS — 3197 passed |
make test-integration |
PASS — 760 passed, 207 deselected |
make typecheck-backend (mypy) |
not run — the gate covers only core/ schemas/; no changed file in this diff is in scope |
Two honest gaps in this review: mypy covers none of the changed files, and the func.length equivalence is only ever exercised on SQLite (conftest.py pins in-memory SQLite) — never on the production dialect where TOAST and encoding-dependent textlen() live.
…pe + kill the child on any abrupt exit (B-1, B-2); stop the retry log rewind (F1) bonnyr-f5 round-1 BLOCK: `_stream_subprocess` was a partial reimplementation of `subprocess.run` that dropped the two things run does for correctness — bounding the read and killing the child on abrupt exit. B-1: the deadline was enforced only via "kill the direct child => pipe EOF", which is false whenever a descendant (local-exec/null_resource/external-data) inherited the stdout write end: `for line in proc.stdout` blocked forever, `proc.wait()` had no timeout, TimeoutExpired never fired, and the task never completed — leaving project_modules permanently locked (heartbeat keeps refreshing the lease, sweeper can't reclaim). Rewritten to read the pipe with a `select` loop on the caller's thread bounded by the remaining time budget (mirrors CPython's POSIX `_communicate`), so the loop always terminates at the deadline regardless of who holds the pipe; then kill + raise TimeoutExpired with the partial output. Reading on the caller's thread (not a helper thread blocked in read()) is also what lets `with Popen` close the pipe on exit without deadlocking on that thread's buffer lock. B-2: the try had only `finally: timer.cancel()` — no kill on abrupt exit, so a SoftTimeLimitExceeded (Celery raises it in the worker thread, blocked in the read), UnicodeDecodeError, or OSError left tofu alive, still mutating cloud state + .tfstate after the task was marked failed and the lock released, and a retry ran a second concurrent tofu on the same state dir. Now wrapped in `except BaseException: proc.kill(); raise`, mirroring CPython. on_output errors stay guarded and swallowed. Uses `with subprocess.Popen(...)` so pipes always close on exit; an incremental UTF-8 decoder reassembles multibyte chars split across reads and still raises UnicodeDecodeError on genuinely invalid bytes. Returns (returncode, output) and raises TimeoutExpired(output=partial) exactly as before, so the run_* timeout branches are unchanged. The no-callback path (subprocess.run) is untouched. F1: the stale-plan retry called streamer.begin(all_logs) before the first apply's output was folded into all_logs (appended only at the end), so task.logs rewound mid-run — and that first apply's output was dropped entirely. Fold it in immediately after the first apply (recovering it), and guard the streamer so a shorter base can never shrink the persisted log below its high-water mark. Tests (mutation-confirmed): grandchild-holds-pipe raises TimeoutExpired at ~the 2s deadline, not ~30s (reverting the deadline reds it); an exception in the read path SIGKILLs the child promptly instead of leaving it to be reaped 30s later (reverting the kill reds it); and the retry path never rewinds task.logs. Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
…alive timeout, M2 multibyte-timeout, M3 newline parity) M1: a genuine pipe EOF while the child is still alive (a descendant closed the inherited stdout pipe, or the child closed its own fds then kept running) fell into a bare, UNBOUNDED proc.wait() — silently defeating the timeout the docstring promises. Bound the post-loop wait by the remaining deadline; on expiry kill the child and raise TimeoutExpired with the accumulated partial output (same shape as the in-loop timeout path). M2: the decoder.decode(b"", final=True) flush ran BEFORE the timed_out check, so a deadline landing while a partial multibyte sequence was buffered raised UnicodeDecodeError in place of TimeoutExpired — bypassing run_apply/run_destroy's graceful except subprocess.TimeoutExpired branch. Raise TimeoutExpired before the final flush. M3: the streamed decode path split only on "\n" with no universal-newline translation, so CRLF/CR persisted differently than the no-callback subprocess.run(text=True) path. Normalize CRLF/CR to LF on the accumulated stream (holding a trailing CR across reads so a split CRLF is not turned into two newlines), feeding both the delivered lines and the returned combined output. B-1 (grandchild-held-pipe timeout) and B-2 (exception-kills-child) remain intact. Adds regression tests for each minor, including a CRLF split across reads; all mutation-verified. Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
Review — round 2 @
|
Summary
Two distinct defects (#195) made a running
opentofumodule opaque:logs_full_sizestayed 0 for the whole run and the task list endpoint dropped log fields entirely.Defect 1 — logs buffered until completion (root cause)
OpenTofuRuntime.run_{init,plan,apply,destroy}executed tofu via a blockingsubprocess.run(capture_output=True), which returns the entire log only when the process exits. The Celery tasks accumulated that intoall_logsand wrotetask.logs(and thereforelogs_full_size) exactly once, at the end. Result: 12 in-flight samples at 0, then the full size at once.Fix: the
run_*methods now take an optionalon_outputcallback and, when supplied, stream stdout+stderr line-by-line through a new_stream_subprocesshelper (Popen + a watchdog timer that preserves the(returncode, output)contract andTimeoutExpiredsemantics). When no callback is passed the classic blocking capture is kept, so existing behaviour and the runtime test suite are unchanged. A newTofuLogStreamerturns the callback into throttledtask.logswrites, sologs_full_sizegrows during the run. The task still writes the completeall_logsat completion, so the final content, the final size, and the per-line timestamp format are all intact.Defect 2 — list endpoint omitted log fields (root cause)
get_tasksnever returnedlogs_full_size(it surfaced asnull), whileget_taskdid — so any client enumerating a module's tasks saw every task as empty.Fix: the list now includes
logs_full_size, computed in SQL viafunc.length(Task.logs)—logsis a deferredTextcolumn, so this measures the size without loading every body — and matches the detail endpoint'slen(logs).logs_truncatedis included asFalse(the list returns no body by design; fetch the detail endpoint for logs). The frontendTasktype already declared both fields optional, so the change is additive/backward-compatible.What the tests lock
test_opentofu_runtime_streaming.py: a real-subprocess handshake proving each line reacheson_outputbefore the process exits (a buffered implementation would deadlock and time out); stderr-merge + exit code; timeout raises with partial output; therun_*methods route through streaming only whenon_outputis given, blocking capture otherwise.test_tofu_log_streamer.py: base+buffer composition, strict growth across lines, interval throttling, first-line force-flush, and commit-failure rollback (never raises).test_opentofu_tasks.py::TestOpenTofuTaskLogStreaming: task-level reproduce — the persistedlogs_full_size(SQL length, exactly the detail computation) grows across lines whilestatusis stillin_progress; plus a guard that the plan task handsrun_plana callable sink.test_routes_tasks.py::TestTaskListLogFields: the list exposeslogs_full_sizematching the detail endpoint, and reports0(not null) for a task with no logs.Mutation-checked: disabling the flush fails the streamer + task-level reproduce tests.
Closes #195
https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4