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 2 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.
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