Skip to content

fix(cli): bound how much log a single drain reads into memory [PC-4873] - #1844

Draft
robert-ursu wants to merge 6 commits into
feat/python-job-cancellationfrom
feat/bound-log-tailer-reads
Draft

robert-ursu wants to merge 6 commits into
feat/python-job-cancellationfrom
feat/bound-log-tailer-reads

Conversation

@robert-ursu

@robert-ursu robert-ursu commented Aug 4, 2026 •

Copy link
Copy Markdown
Collaborator

🅿️ Parked with #1835 — by dependency only

Nothing here is affected by the transport direction: this bounds how much JobLogTailer._drain reads into memory, which stands whether logs are pushed over HTTP or streamed over uipath-ipc. It is parked purely because JobLogTailer is introduced by #1835, so there is nothing on main for it to bound.

If the log push is re-cut over IPC, this bounding logic carries over essentially unchanged.

Last rebased 2026-08-19 and green at that point: 1457 passed, ruff/mypy clean.


Draft — third in the stack. Base is feat/python-job-cancellation (#1842). It stays stacked rather than targeting main directly: JobLogTailer is introduced by #1835, so there is nothing on main for this to bound.

Paired with UiPath/hdens#7711. Rebased onto main; replayed with no conflicts.

Summary

JobLogTailer._drain called f.read() with no limit, so a single pass pulled everything written since the last poll into memory — and the decoded str costs more again.

The 250 ms poll normally keeps that tiny. It does not when:

  • a job logs heavily between polls,
  • the tailer is starved (a stop can hold the loop for up to STOP_GRACE_SECONDS),
  • or the final drain runs after a long job.

Same read-it-all-only-to-forward-it shape that has OOMed pods on the handler side (UiPath/hdens#7711).

Each pass now reads at most LOG_READ_CHUNK_BYTES (256 KiB) and loops until caught up, so peak memory is the window, not the backlog.

Two cases only reachable once the read is bounded

  • A chunk boundary landing mid-line — trimmed back to the last newline. That is also what keeps the decode correct: \n never appears inside a multi-byte UTF-8 sequence, so cutting there can't split a character.
  • A single line longer than the window — emitted as a fragment rather than held. Buffering it defeats the point, and stalling on it would wedge the tailer permanently (the old code's cut == -1 → return becomes an infinite stall once reads are bounded).

Caveats

  • A line longer than 256 KiB is delivered in pieces; nothing downstream rejoins them.

Testing

  • uv run pytest tests/cli — 1457 passed, 0 failures. Two new tests for exactly the cases above: catch-up across multiple windows with a shrunk LOG_READ_CHUNK_BYTES, and a 500-byte line through a 64-byte window. The existing 14 tailer tests are unchanged and still pass — ordering, no-replay, partial-line hold-back, and final-drain behaviour are all preserved.
  • ruff check and ruff format --check clean; mypy --config-file pyproject.toml . — no issues in 336 source files.

Jira

PC-4873

@github-actions github-actions Bot added test:uipath-langchain Triggers tests in the uipath-langchain-python repository test:uipath-integrations labels Aug 4, 2026
@robert-ursu robert-ursu changed the title fix(cli): bound how much log a single drain reads into memory fix(cli): bound how much log a single drain reads into memory [PC-4873] Aug 4, 2026
@robert-ursu
robert-ursu force-pushed the feat/python-job-cancellation branch from b05d195 to 1df44eb Compare August 4, 2026 16:00
@robert-ursu
robert-ursu force-pushed the feat/bound-log-tailer-reads branch from 228c7c2 to 2321386 Compare August 4, 2026 16:00
robert-ursu and others added 6 commits August 19, 2026 21:43
…caller

`uipath server` used to block for a whole job and leave its outcome on disk for
the caller to find. StartJob now enqueues the work and returns; the server
pushes logs while the job runs and the terminal result when it finishes.

Behaviour is a pure function of the request: a caller that supplies
`resultCallbackSocket` gets async dispatch, one that does not gets the original
blocking call, byte for byte. No handshake, no capability gate on the dispatch
path -- an older caller cannot send the field and could not serve the callback
if it did.

  POST {callback}/api/python/jobs/{jobKey}/result
  POST {callback}/api/python/jobs/{jobKey}/logs

The readiness ACK advertises protocolVersion + capabilities so the caller knows
whether logs will be forwarded before it decides to tail the file itself.

Execution stays serialised behind the process-wide lock: a job mutates process
globals (logging handlers, OTel providers, env, cwd), so queueing changes who
waits, not how many run.

Also fixes two pre-existing defects that made the API result meaningless:
_run_command_isolated hardcoded ExitCode 0 while click RETURNS ctx.exit(N)'s
code under standalone_mode=False, so every ConsoleLogger.error path reported
success; and the HTTP body now carries exitCode, which is the field the
un-upgraded .NET handler already reads.

Notes for review:
- Server diagnostics go to a stderr handle bound at import, NOT ConsoleLogger.
  ConsoleLogger resolves sys.stdout at call time, and the runtime's interceptor
  has replaced it with a writer feeding the job's execution.log -- which the
  tailer then reads and posts back. With the callback down that is a
  self-feeding loop.
- A 4xx from the callback is REJECTED, not UNREACHABLE: the caller is up and has
  moved on, so retrying cannot help and must not trip the shutdown path.
- The log tailer holds back an unterminated tail; a handler writes the record
  and only then flushes, so a poll can otherwise split one line into two
  entries that cannot be rejoined.
- Logs are tailed from the file rather than captured via a handler: the
  runtime's log interceptor strips every handler but its own, and
  uipath-runtime ships on its own release train.

Stopping a job that is already executing is deliberately NOT in this change --
it follows on top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI runs mypy over tests as well as src, and these tests were red: the recording
fakes they pass to JobRegistry.start and JobLogTailer are not HandlerCallbacks.

Neither collaborator wants the socket transport -- they want somewhere to send a
result and somewhere to send logs -- so say that: JobReporter is a Protocol with
those two methods, and HandlerCallback satisfies it structurally. The fakes then
type-check as themselves rather than needing a cast at every call site.

Worth noting the protocol immediately found a real gap: test_server_async's
FakeCallback had no post_logs at all, so the tailer's calls into it were only
ever working by accident of it never being exercised there.

The rest is local: cast the _FakeRequest stand-ins to web.Request at the two
handle_start call sites, and narrow web.Response.text (str | None) before
json.loads and `in`.
Builds on async dispatch. Until now StopJob could only refuse a running job:
its body runs on a thread via asyncio.to_thread, which cannot be cancelled.

That reasoning missed a fact. run/debug/eval each drive their own event loop
with asyncio.run INSIDE the worker thread (cli_run.py:333, cli_debug.py:269,
cli_eval.py:531), so a job IS an event loop -- and an event loop can be
cancelled. The three call sites now go through `run_job_loop`, which publishes
that loop and its root task to a JobControl carried on a ContextVar.
asyncio.to_thread propagates contextvars, so no monkeypatching is needed, and
outside the server (uipath run on a terminal) it is asyncio.run verbatim.

Cancellation is cooperative, not a thread kill: the runtime's context managers
still unwind, so UiPathRuntimeContext.__exit__ still writes output.json and the
caller's file fallback still works.

The stop ladder:
  1. cancel the ROOT task only, then wait STOP_GRACE_SECONDS. Cancelling every
     task would land a second CancelledError inside the cleanup that writes
     output.json and abort it mid-write.
  2. if cleanup itself is stuck, sweep the loop and wait a shorter window.
  3. otherwise return False -- a job wedged in a non-cancellable C call (a
     socket read inside an LLM request) cannot be stopped, and saying so is
     better than claiming a stop that did not happen.

A stopped job reports Stopped, not Faulted. The runtime records it as
FAULTED/ERROR_CancelledError because it sees a CancelledError, which is the
wrong story for a stop the caller asked for, so the result push carries an
explicit `stopped` flag that wins over the document.

Stop is also reachable now: POST /jobs/{job_key}/stop on the HTTP transport,
which carries all current traffic and previously had no way to reach the
registry at all.

Notes for review:
- _invoke_command discriminates the two CancelledErrors via Task.cancelling():
  0 means the job's own loop was cancelled (an outcome, swallow it); >0 means
  our awaiting task was cancelled (a shutdown, re-raise).
- StopJob takes resume_version as a trailing optional parameter rather than a
  DTO: uipath-ipc ignores a surplus wire arg and defaults a missing one, so old
  and new peers interoperate both ways. A suspended job resumes under the same
  key, so a stop aimed at the previous run must not kill the resumed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… when asked

Two defects in the cancellation path, both found in review.

JobControl.cancel() documented that it must be called at most once, but
JobRegistry.stop() called it unguarded on every request. A stop followed by a
force-stop escalation is ordinary, and the second delivery lands inside the
runtime's cleanup finally blocks -- the ones that write output.json -- and
aborts them, destroying the fallback the caller relies on. Absorbing the repeat
belongs in JobControl, not in every caller.

_invoke_command treated any CancelledError with cancelling() == 0 as a
user-requested stop. cancelling() reports on the awaiting server task, so a
CancelledError the job's own code let escape was reported as exit 143 "stopped
on request" with no StopJob in sight -- a fault filed as a clean stop. Gate the
classification on the control's own cancel_requested.

Also corrects JobRegistry.stop()'s docstring, which still described the
pre-cancellation behaviour of refusing to stop executing work.
Same CI gate as the parent commit, applied to the tests this branch adds: give
the module-level events a real Event type instead of letting them infer None,
annotate the recording callback against the JobReporter protocol, and cast the
_FakeRequest stand-ins at the handle_stop call sites.
The tailer did f.read() with no limit, so one drain pulled everything written
since the last poll — and the decoded str costs more again. Normally the 250 ms
poll keeps that tiny, but a job that logs heavily between polls, a starved
tailer, or the final drain after a long run could all pull a large log in at
once. The same read-it-all-to-forward-it shape has OOMed pods on the handler
side.

Each pass now reads at most LOG_READ_CHUNK_BYTES and loops until caught up, so
peak memory is the window rather than the backlog.

Two cases only reachable once the read is bounded:
- A chunk boundary landing mid-line: trimmed back to the last newline, which is
  also what keeps the decode safe, since \n never appears inside a multi-byte
  UTF-8 sequence.
- A single line longer than the window: emitted as a fragment rather than held.
  Buffering it without bound would defeat the point, and stalling on it would
  wedge the tailer permanently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@robert-ursu
robert-ursu force-pushed the feat/python-job-cancellation branch from 1df44eb to e14f4b2 Compare August 19, 2026 19:13
@robert-ursu
robert-ursu force-pushed the feat/bound-log-tailer-reads branch from 2321386 to 31710dd Compare August 19, 2026 19:13
@github-actions

Copy link
Copy Markdown

🚨 Heads up: uipath-langchain cross-tests are FAILING 🚨

Your changes may break the uipath-langchain-python integration.

⚠️ These checks are NOT enforced by branch protection rules. Please review the failures before merging.

🔍 Inspect the failed run →

@robert-ursu
robert-ursu force-pushed the feat/python-job-cancellation branch from e14f4b2 to 50e0cc7 Compare September 24, 2026 15:26

This branch has not been deployed

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

Labels

test:uipath-integrations test:uipath-langchain Triggers tests in the uipath-langchain-python repository

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant