Skip to content

fix(#195): stream opentofu task logs incrementally and expose log fields in the task list - #201

Open
jgruberf5 wants to merge 4 commits into
stagingfrom
fix/195-opentofu-task-logs-buffered
Open

fix(#195): stream opentofu task logs incrementally and expose log fields in the task list#201
jgruberf5 wants to merge 4 commits into
stagingfrom
fix/195-opentofu-task-logs-buffered

Conversation

@jgruberf5

Copy link
Copy Markdown
Collaborator

Summary

Two distinct defects (#195) made a running opentofu module opaque: logs_full_size stayed 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 blocking subprocess.run(capture_output=True), which returns the entire log only when the process exits. The Celery tasks accumulated that into all_logs and wrote task.logs (and therefore logs_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 optional on_output callback and, when supplied, stream stdout+stderr line-by-line through a new _stream_subprocess helper (Popen + a watchdog timer that preserves the (returncode, output) contract and TimeoutExpired semantics). When no callback is passed the classic blocking capture is kept, so existing behaviour and the runtime test suite are unchanged. A new TofuLogStreamer turns the 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, the final size, and the per-line timestamp format are all intact.

Defect 2 — list endpoint omitted log fields (root cause)

get_tasks never returned logs_full_size (it surfaced as null), while get_task did — so any client enumerating a module's tasks saw every task as empty.

Fix: the list now includes logs_full_size, computed in SQL via func.length(Task.logs)logs is a deferred Text column, so this measures the size without loading every body — and matches the detail endpoint's len(logs). logs_truncated is included as False (the list returns no body by design; fetch the detail endpoint for logs). The frontend Task type 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 reaches on_output before the process exits (a buffered implementation would deadlock and time out); stderr-merge + exit code; timeout raises with partial output; the run_* methods route through streaming only when on_output is 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 persisted logs_full_size (SQL length, exactly the detail computation) grows across lines while status is still in_progress; plus a guard that the plan task hands run_plan a callable sink.
  • test_routes_tasks.py::TestTaskListLogFields: the list exposes logs_full_size matching the detail endpoint, and reports 0 (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

…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
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

Self-review (cold, adversarial) — production code sound; one vacuous test fixed

An 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, 0906043) — the flagship streaming test proved nothing. test_delivers_each_line_before_process_finishes claimed a buffered impl "would deadlock and raise TimeoutExpired," but the child self-released after ~10s (range(200)*0.05) and the test asserted only line order — which a fully-buffered _stream_subprocess also satisfies. Reproduced: mutating _stream_subprocess to fire on_output only after proc.wait() (the exact #195 bug) left the test green.
→ Fixed: the child now blocks ~300s (>> the 8s watchdog), so a buffered impl can't self-release — it deadlocks, the watchdog kills it, and _stream_subprocess raises TimeoutExpired → the test errors. Added an explicit timing bound (< 5s) as a fast second signal. Mutation-confirmed: real code passes in ~5s; the buffered mutation now fails at the watchdog (~14s).

Held under attack (verified clean):

  • Tail-drop under throttling — every call site appends the returned output and commits task.logs after the run, so final logs/logs_full_size is always complete; begin() forces an immediate first-line flush.
  • func.length vs len() unicode — SQL LENGTH() is char-count (not bytes) on SQLite+Postgres; reproduced with accented/CJK/emoji strings — equal to Python len() every time; NULL→0 matches the detail endpoint.
  • OpenAPI/TS stalenessGET /api/tasks has no response_model (raw dict, {} schema), so adding dict keys can't change the spec; --check up to date.
  • Contract preservation — no-callback path byte-identical; watchdog is a real proc.kill(); TimeoutExpired.output still yields the partial log; deadlock/early-close/hung-child all terminate via the watchdog. (One intentional MINOR: stderr now interleaves with stdout chronologically — the downstream regex consumers are order-independent.)
  • Lock safety — the streamer's mid-run db.commit() doesn't release the workspace lock (persistent lock-row + fence token + heartbeat, designed to survive commits).
  • Mutation tests — list serializer + streamer flush both non-vacuous.

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.

@bonnyr-f5

Copy link
Copy Markdown
Collaborator

Review — round 1 @ 0906043

Reviewed under the review-discipline pipeline: invariant sweep across the whole surface, an independent cold full-diff audit with no session context, then every load-bearing claim re-verified locally before being written down here. CI gates run verbatim (results at the bottom).

Verdict: BLOCK — two blockers in the new _stream_subprocess. Defect 2 (the list endpoint) is solid and I'd take it as-is.


Defect 2 — list endpoint: verified correct

  • func.length(Task.logs)len(logs) for every value the column can hold. NULL → SQL NULL → or 0, matching the detail endpoint's len(logs) if logs else 0. PostgreSQL length(text) counts characters, not octets, so it agrees with Python len() on multibyte input too.
  • models/task.py:40 really is deferred(Column(Text)), so the comment's justification holds; add_columns adds a scalar expression and does not undefer the attribute.
  • total = query.count() is computed before .add_columns(), so pagination totals are unchanged. Both joinedloads are many-to-one → no row fan-out and no subquery nesting under limit/offset.
  • No response_model on the route, so the added keys are purely additive; frontend-v2/src/types/tasks.ts:34-35 already declares both optional; make openapi-check passes. The logs body itself is still absent from list rows.

Defect 1 — _stream_subprocess is a partial reimplementation of subprocess.run

It drops the two things subprocess.run does for correctness: closing the pipe, and killing the child on abrupt exit. The PR states it "preserves the (returncode, output) contract and TimeoutExpired semantics" — that does not hold, and both failures below were reproduced rather than reasoned about.

B-1 (blocker) · the timeout is not actually enforced — opentofu_runtime.py:88-98

The deadline is enforced only indirectly, via "kill the direct child ⇒ the pipe reaches EOF." That implication is false whenever any descendant inherited the write end of the pipe. Running the exact shape of the loop against a child that spawns a grandchild inheriting stdout:

PR _stream_subprocess: returned=False after 12.0s (deadline was 2s)
subprocess.run:        raised TimeoutExpired after 2.0s

proc.stdout is never closed on any path, and proc.wait() at :96 has no timeout, so nothing bounds the call. At APPLY_TIMEOUT the watchdog SIGKILLs tofu, the read loop does not return, TimeoutExpired is never raised, and the task never completes — while the project_modules lease is never released and its heartbeat thread keeps refreshing it, so the stale-lock sweeper cannot reclaim it either. The module ends up permanently locked.

On reachability, being exact because it cuts the other way from how it first looks: grepping for local-exec / null_resource / data "external" returns zero hits — but the repo ships zero .tf files at all. ModuleLibrary.git_source (backend/models/module.py:34) is nullable=False: every module tofu executes is fetched from external git. So that grep establishes nothing about production, and the HCL is operator-supplied.

Fix shape: close proc.stdout in a finally and proc.wait(timeout=…), or use with proc: and read against a deadline.

B-2 (blocker) · non-timeout exceptions orphan a live tofu applyopentofu_runtime.py:88-98

The try carries only finally: timer.cancel() — no proc.kill(), no pipe close. CPython's subprocess.run has except: # Including KeyboardInterruptprocess.kill(); raise, plus with Popen(...) closing the pipes on exit.

Celery delivers SoftTimeLimitExceeded by raising it in the worker's main thread — which, during a streamed run, is blocked inside for line in proc.stdout. Handlers for it already exist at opentofu_tasks.py:943 (apply) and :1216 (destroy). Before this PR that path was safe precisely because subprocess.run killed the child. Now tofu survives the worker, keeps mutating cloud state and the .tfstate under work_dir while the task is marked failed and the workspace lock is released — and a retry then runs a second concurrent tofu against the same state directory.

on_output exceptions specifically are guarded (:93-95), so the sink cannot trigger this; everything else can (soft/hard time limit, UnicodeDecodeError from text=True on non-UTF-8 provider output, OSError on the pipe).


Minor

F1 · the persisted log rewinds on the stale-plan retry path — opentofu_tasks.py:769, 788, 808

all_logs += apply_logs is at :811, but streamer.begin(all_logs) is called at :769, :788 and :808 — all inside the retry block, all before :811. The first apply's output has already been streamed and persisted; the retry's base omits it, so task.logs / logs_full_size shrink mid-run, contradicting the PR's own strict-growth invariant.

Swept all 12 begin() sites: the plan task (:449, :471), _ensure_workspace_initialized (:633), the main plan (:724) and all three destroy sites (:1084, :1106, :1124) are correctly caught up — the class is confined to those 3 retry sites. The component run reports opentofu_tasks.py:766-784, 792-803 uncovered, which is exactly why no test sees it.

Fix shape: move the append to immediately after the first apply (which also recovers the first apply's output, currently dropped — pre-existing), or have begin() reject a base shorter than what it has already persisted.

M-1 · a comment that is factually wrong, in the one unguarded branch — opentofu_runtime.py:1991

Measured locally: subprocess.run(text=True, timeout=…) populates TimeoutExpired.stdout as bytes (b'x\n'), stderr as None. Five timeout branches carry isinstance(stdout, bytes) decode guards (:1664, 1715, 1768, 1834, 1926) — the codebase already knows this. run_destroy instead asserts the opposite in a comment and has no guard, so it raises TypeError: can't concat str to bytes on the blocking path.

Pre-existing — but this PR makes run_opentofu_destroy always pass a sink, so that caller silently starts working while opentofu_engine.py:452 still crashes. Same method, two behaviours, decided by an unrelated kwarg.

M-3 · write amplification — _tofu_helpers.py:77-86

The throttle is time-only (interval=2.0) and every flush rewrites the entire accumulated log. A 90-minute apply is ≈2700 full UPDATE tasks SET logs = <N bytes>; total payload ≈ 1350·N. On PostgreSQL logs is TOASTed above ~2KB, so each UPDATE rewrites the whole TOAST chain — thousands of dead heap + TOAST tuples per task. Wants a size-aware trigger, an append (logs = logs || :chunk), or a buffer cap.

M-5 · _flush's except performs a session-wide rollback — _tofu_helpers.py:81-86

self._db.rollback() is not scoped to the log write: it discards everything pending in the task's session and expires the whole identity map, silently, with no log line. I traced what is actually dirty at every sink point in all four task functions and found no currently exploitable instancetask.command is the realistic casualty, and WorkspaceManager.mark_initialized/clear_plan both self-commit. So this is a live class with no live instance: the next pre-run mutation anyone adds inherits it. Also self._last = now is set before _flush(), so a failing flush still consumes the throttle window.

routes/tasks.py:97-105 — the "avoids loading every task's full log body" claim holds on the wire but not server-side. PostgreSQL's textlen() fast-paths to toast_raw_datum_size() only when the database encoding is single-byte; on UTF-8 it must detoast and run pg_mbstrlen_with_len, i.e. fetch and decompress every log body in the page. With limit up to 200 on a 60s poll, that is the exact cost deferred() exists to avoid. Worth softening the comment even if the cost is acceptable.

Test wiring — only run_opentofu_plan's sink is asserted. Deleting on_output= from apply or destroy leaves the whole suite green. test_timeout_kills_and_raises_with_partial_output never asserts the process actually died.

Nits

  • :72bufsize=1 is cosmetic; line_buffering on a TextIOWrapper affects writes. Streaming works because readline() returns at the first newline regardless.
  • :89assert proc.stdout is not None vanishes under python -O. No PYTHONOPTIMIZE in any image here, so currently harmless.
  • :84_kill_on_timeout sets timed_out without checking proc.poll(), so a process finishing microseconds before the deadline still reports a timeout and discards a successful returncode plus the full output.

Falsified hypotheses (so coverage is distinguishable from silence)

Two things that look like they should be bugs and are not:

  • The mid-run db.commit() does not release the workspace lock. module_lock is a fence-token row lease with a heartbeat (entity_lock.py), not pg_advisory_xact_lock and not SELECT … FOR UPDATE; a commit cannot drop it.
  • It does not race the heartbeat writer. HeartbeatRefresher uses its own SessionLocal() (entity_lock.py:442), so the streamer's commits never share a Session across threads. The sink genuinely runs on the task's own thread, as the docstring claims.

Gate results — CI targets run verbatim @ 0906043

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
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

Review — round 2 @ 27a990d7 (_stream_subprocess rewrite + self-found minors)

Reviewed with the cold-audit discipline: the rewrite was driven directly (real _stream_subprocess imported and exercised against crafted child processes), every claim executed rather than reasoned, and each new test mutation-checked (neuter the fix → test goes RED → restore).

Verdict: both round-1 blockers are genuinely fixed, and three self-found minors are now closed. No open BLOCKER/MAJOR.

Round-1 blockers — fixed and verified under attack

  • B-1 (timeout not enforced when a descendant holds the pipe): the loop is now a select on the caller's thread bounded by a monotonic deadline. Held at dur=2.00s across all four cases — grandchild inherits stdout + child exits; grandchild inherits + child stays alive; huge non-newline chunk then hang; child closes stdout early but keeps running (fd 2 still holds the pipe). Child SIGKILLed before the with-exit wait, so no ~30s block.
  • B-2 (non-timeout exception orphans a live tofu): except BaseException: proc.kill(); raise. Simulated Celery SoftTimeLimitExceeded via SIGALRM while parked in select → re-raised at 1.00s, direct child confirmed killed. Mutation-confirmed (neuter the deadline → grandchild test RED at 36s; neuter the kill → B-2 test RED rc=0).

Self-found minors — fixed this round (each mutation-verified)

  • M1 — EOF-while-child-alive left proc.wait() unbounded. When os.read returned b"" with the child still alive, the post-loop wait had no timeout (deadline blown ~30x in repro). Now proc.wait(timeout=max(0, deadline - now)), killing + raising TimeoutExpired (with partial output) on expiry — matching the in-loop path.
  • M2 — a timeout landing on a buffered partial multibyte char raised UnicodeDecodeError instead of TimeoutExpired. The decoder.decode(b"", final=True) flush ran before the timeout check, so the graceful except subprocess.TimeoutExpired branch in run_apply/run_destroy was bypassed. The kill+raise-TimeoutExpired now runs before the final flush.
  • M3 — streamed output was not newline-translated (raw bytes split on \n), diverging from the no-callback subprocess.run(text=True) path. _emit now normalizes \r\n\n then lone \r\n on the accumulated stream, holding a trailing \r across reads so a split CRLF is not doubled.

Tests

Streaming suite 9 → 13 (+4: EOF-alive timeout, partial-multibyte timeout, newline parity, CRLF-split-across-reads), all mutation-confirmed non-vacuous. Broader runtime/tasks/routes set green.

CI

P1/P2/P3 are green on 27a990d7. The only red is the two repo-wide P4 security gates (Docker Build + Scan — Trivy CRITICAL CVE-2026-56854 in helm's bundled x/crypto; Security Audit — gitpython 3.1.58). These are environmental (the advisory/vuln DBs updated after staging last ran clean on 2026-08-24; they'd be red on staging too) and are not from this PR's code. Both are fixed in #215; this branch goes green on P4 once #215 merges to staging and this branch rebases.

Awaiting further review.

https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants