Skip to content

[core] Add log rotation for job driver logs - #65006

Open
odncode wants to merge 7 commits into
ray-project:masterfrom
odncode:core/job-driver-log-rotation
Open

[core] Add log rotation for job driver logs#65006
odncode wants to merge 7 commits into
ray-project:masterfrom
odncode:core/job-driver-log-rotation

Conversation

@odncode

@odncode odncode commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #64528

Why

Job driver logs (job-driver-{submission_id}.log) are written via a plain append-mode file handle with no rotation, unlike other Ray log files which respect RAY_ROTATION_MAX_BYTES/RAY_ROTATION_BACKUP_COUNT. Long-running jobs can grow this file unbounded.

Why not the existing C++ pipe-logging mechanism

I initially looked at reusing the existing StreamRedirector mechanism from #49733 and related PRs, since @Kunchd pointed at it as prior art. That mechanism redirects a process's own stdout/stderr at its own startup, and every existing caller uses it that way. JobSupervisor's situation is different: it spawns the job driver as an arbitrary shell command via subprocess.Popen(shell=True), and there is no hook to inject a redirect call into a process whose contents Ray does not control before it starts. Full investigation notes are on the issue.

What this does instead

Copytruncate semantics as suggested by @nadongjun , the same approach used by logrotate --copytruncate. JobSupervisor periodically checks the driver log file size, and if it exceeds the configured threshold, JobLogStorageClient copies the current content to a backup file and truncates the original in place. Because the file is opened with O_APPEND, the subprocess's next write lands cleanly at the new end of file with no gap or corruption, verified locally against a real subprocess.

Also fixes a related bug this surfaced: file_tail_iterator (used for live log streaming via ray job logs -f) held a persistent read position that would go stale after an external truncation, returning corrupted output. Added a check to detect when the file has shrunk below the reader's position and seek back to start.

Testing

  • Added test_recovers_after_external_truncate to test_utils.py, passing locally, covering the file_tail_iterator fix directly.
  • Added test_job_driver_log_rotation to test_job_manager.py, a full integration test against a real cluster and subprocess confirming rotation triggers correctly and no log lines are lost, duplicated, or corrupted across the rotation boundary. I wasn't able to run this one locally due to a pre-existing, seemingly unrelated environment issue in my checkout (a stale compiled protobuf mismatch in custom_types.py that also blocks the file's existing tests, unrelated to this change), so I am relying on CI for this one.

Job driver logs (job-driver-{submission_id}.log) were written via a
plain append-mode file handle with no rotation, unlike other Ray log
files which respect RAY_ROTATION_MAX_BYTES/RAY_ROTATION_BACKUP_COUNT.
Long-running jobs could grow this file unbounded.

The driver subprocess holds the log file open directly via
Popen(stdout=...), so the existing C++ pipe-logging mechanism used
elsewhere in Ray (which redirects a process's own stdout/stderr at
its own startup) does not apply here, since Ray does not control the
entrypoint of the driver subprocess. Instead this uses copytruncate
semantics: JobSupervisor periodically checks the log file size and,
if over the threshold, JobLogStorageClient copies current content to
a backup file and truncates the original in place. Because the file
is opened with O_APPEND, the subprocess's next write lands cleanly
at the new end of file.

file_tail_iterator, used for live log streaming, held a persistent
read position that would go stale after an external truncation,
returning corrupted output. This adds a check to detect when the
file has shrunk below the reader's position and seek back to start.

Fixes ray-project#64528

See ray-project#64528 for full investigation notes.

Signed-off-by: odncode <nnajiodera2@gmail.com>
@odncode
odncode requested a review from a team as a code owner July 25, 2026 18:11

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements log rotation for Ray job driver logs using copytruncate semantics, adding a periodic background task to monitor log sizes and updating the file tail iterator to handle truncated files. The review feedback is highly constructive, pointing out critical issues such as a blocking synchronous I/O call in the asyncio event loop, a potential race condition when truncating files, and a flaky test due to the timing of the rotation check. It also suggests fixing a minor typo introduced in a test comment.

Comment thread python/ray/dashboard/modules/job/job_supervisor.py Outdated
Comment thread python/ray/dashboard/modules/job/tests/test_job_manager.py
Comment thread python/ray/dashboard/modules/job/job_log_storage_client.py Outdated
Comment thread python/ray/dashboard/modules/job/tests/test_utils.py Outdated
Comment thread python/ray/dashboard/modules/job/job_log_storage_client.py Outdated
Comment thread python/ray/dashboard/modules/job/job_log_storage_client.py
@ray-gardener ray-gardener Bot added core Issues that should be addressed in Ray Core community-contribution Contributed by the community labels Jul 25, 2026
Fixes several issues raised by gemini-code-assist and cursor bugbot on
the initial PR:

- rotate_log_file does synchronous file copy/truncate that could block
  the JobSupervisor event loop for a long time on large log files.
  Moved the call into the default executor via loop.run_in_executor so
  subprocess polling, stop handling, and status updates are not
  delayed while rotation runs.

- get_logs and get_last_n_log_lines only read the active log file, so
  after a rotation they silently lost all output written before it.
  Added _get_rotated_backup_paths to enumerate rotated .1/.2/... files
  oldest first, and updated both methods to include backup content
  before the active file's content.

- rotate_log_file's final truncate could raise FileNotFoundError if the
  log file was removed between the earlier os.path.exists check and
  the truncate itself. Wrapped in a try/except to handle that race.

- test_job_driver_log_rotation would not reliably trigger rotation:
  the test entrypoint runs for about 4 seconds but
  LOG_ROTATION_CHECK_PERIOD_S defaults to 5 seconds, so the rotation
  task would get cancelled before its first check ever fired. Added
  RAY_JOB_LOG_ROTATION_CHECK_PERIOD_S as an env override (matching the
  existing pattern for RAY_ROTATION_MAX_BYTES/BACKUP_COUNT) and set it
  to 0.5s in the test via call_ray_start's env parameter. Note this
  needed an env var rather than monkeypatching the class attribute,
  since JobSupervisor runs as a separate Ray actor process and would
  not see an in-process monkeypatch.

- Fixed a typo in a test comment (test_utils.py).

Also added TestJobLogStorageClientRotation, a set of local unit tests
covering _get_rotated_backup_paths ordering and get_logs's backup
concatenation behavior directly against tmp_path, without needing a
running Ray cluster.

Signed-off-by: odncode <nnajiodera2@gmail.com>
Comment thread python/ray/dashboard/modules/job/utils.py Outdated
Two follow-ups from cursor bugbot's second review pass on the log
rotation PR:

- file_tail_iterator's truncation check only ran after readline()
  returned EOF, comparing the reader's position against the file's
  current size at that moment. Cursor correctly pointed out this
  misses a real case: if the file is truncated and then grows back
  past the reader's old position before the check runs, the size
  comparison comes back clean and the iterator resumes from a stale
  position instead of detecting the rotation, silently skipping or
  garbling content.

  Fixed by moving the check to run before every read, not only on
  EOF (a stale offset can otherwise land on a real byte boundary in
  new post-rotation content and return what looks like a normal
  line), and by tracking the largest file size observed across
  checks rather than only the reader's current position, the same
  approach log_monitor.py's LogFileInfo.reopen_if_necessary uses for
  the same problem.

  This remains a size-polling based detection, so it shares
  log_monitor.py's own inherent limitation: a truncate followed by
  regrowth past the previous size within a single write, entirely
  between two checks, can still go undetected. Documented this
  directly in the function's docstring rather than leaving it
  implicit. In practice this requires the file to shrink and then
  outgrow its previous size faster than we can observe it, which real
  incrementally-written subprocess output does not do.

- test_job_driver_log_rotation used a 1000 byte rotation threshold
  against ~4200 bytes of total test output with backup_count=1. This
  produced multiple rotation events during the test, and since each
  rotation overwrites the single backup file, most of the job's early
  output was legitimately gone by the time the job finished, not lost
  to a bug, but to backup_count being too small for what the test was
  asserting. Raised the threshold to 2200 bytes so exactly one
  rotation fires, matching what the test actually verifies: a single
  rotation boundary is handled cleanly, not that history survives an
  arbitrary number of rapid rotations.

Signed-off-by: odncode <nnajiodera2@gmail.com>
Comment thread python/ray/dashboard/modules/job/utils.py
Cursor bugbot correctly flagged that file_tail_iterator's truncation
check compared f.tell() (a text-mode stream position) directly against
os.path.getsize() (a real byte count). Per Python's own documentation,
TextIOWrapper.tell() returns an opaque, implementation-defined number
that does not generally represent a byte offset. Comparing it directly
to a byte count can misfire, most likely with multi-byte characters or
non-Unix newline translation, causing the truncation check to
falsely trigger (or fail to trigger) and either spuriously reset a
reader with no rotation, or miss a real one.

Fixed by tracking bytes consumed ourselves (bytes_read), computed from
len(line.encode(file_encoding)) on lines we have actually decoded,
rather than relying on the stream's own opaque position. The encoding
used to open the file is now pinned explicitly so this byte count
always matches what was actually decoded from disk.

f.seek(0) itself remains correct and unaffected: Python's docs confirm
seeking to exactly offset 0 is always well-defined on a text stream,
unlike arbitrary offsets, so no change was needed there.

Verified no other f.tell() usage in this file has the same issue;
fast_tail_last_n_lines's tell() call operates on a file opened in
binary mode ("rb"), where tell() is a genuine byte offset.

Signed-off-by: odncode <nnajiodera2@gmail.com>
Comment thread python/ray/dashboard/modules/job/job_log_storage_client.py
Comment thread python/ray/dashboard/modules/job/job_log_storage_client.py
Comment thread python/ray/dashboard/modules/job/job_supervisor.py
Cursor bugbot correctly flagged that get_last_n_log_lines treated
active_line_count == 0 the same as "nothing to read at all", returning
early without checking rotated backups. This conflated two different
situations: a job that genuinely has not produced output yet, and an
active file that is empty specifically because rotation just fired and
truncated it, with the real content sitting in a .1 backup. In the
second case, a failed job's error message could show no logs at all
even though the actual output still exists on disk.

Fixed by only treating an empty active file as "nothing to read" when
there are also no backup files present. When backups exist, the
existing backup-then-active-file logic below already handles this
correctly (remaining_lines becomes the full num_log_lines requested,
since active_line_count is 0).

Also removed a redundant duplicate call to
_get_rotated_backup_paths, previously called once in the new
guard and again to build backup_paths a few lines later; now
computed once and reused.

Added three tests to TestJobLogStorageClientRotation covering: empty
active file with a real backup (the regression case), empty active
file with no backup at all (the genuine no-output case, to make sure
we did not overcorrect), and active file alone already sufficient (to
confirm backups are not consulted unnecessarily).

Signed-off-by: odncode <nnajiodera2@gmail.com>
Comment thread python/ray/dashboard/modules/job/job_log_storage_client.py
Comment thread python/ray/dashboard/modules/job/job_supervisor.py
Addresses two Cursor review comments on commit 7453aed:

1. Rotation could race with the failure-path log read.
   Task.cancel() on the log rotation task does not stop or wait for
   an in-flight rotate_log_file call already running in an executor,
   since cancelling the asyncio task only cancels the asyncio-level
   future it is awaiting, not the underlying thread. Verified this
   empirically before writing a fix. The fix keeps a reference to
   the raw concurrent.futures.Future for an in-flight rotation and
   genuinely waits for it, with a bounded timeout, before the
   failure path reads the log file.

2. get_last_n_log_lines did a synchronous full-file line count scan
   directly in an async def with no executor offload, blocking the
   JobSupervisor actor's event loop on large files, same category of
   issue as the original rotate_log_file blocking fix. The method is
   now a thin async wrapper around a synchronous helper that can run
   inline (default, unchanged behavior) or on a supplied executor.
   JobSupervisor now passes a dedicated executor for this on the
   failure path.

Both fixes verified locally: existing test_utils.py suite (32 tests)
passes unchanged, two new tests added confirming the executor path
is genuinely used and the no-executor default is unchanged. The
rotation-wait fix could not get local unit coverage since
JobSupervisor's constructor requires a real GcsClient connection,
consistent with every existing test in test_job_manager.py, so it
relies on hand-tracing plus standalone asyncio repro scripts and the
existing test_job_driver_log_rotation integration test in CI.

Signed-off-by: odncode <nnajiodera2@gmail.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit e729e60. Configure here.

Comment thread python/ray/dashboard/modules/job/job_supervisor.py
Addresses Cursor comment on commit e729e60: 'Rotation wait can cancel
job completion.'

_wait_for_rotation_to_finish read self._log_rotation_future fresh
after log_rotation_task.cancel() had already been called.
_rotate_driver_log_if_needed clears that attribute to None in a
finally block as part of its own cancellation cleanup, and that
finally can run synchronously enough that the attribute is already
None by the time _wait_for_rotation_to_finish reads it, so the
method silently treated a genuinely in-flight rotation as nothing to
wait for and returned immediately, defeating the entire point of the
prior fix.

Verified this specific race empirically with a standalone repro
script before writing the fix, since Cursor's stated mechanism
(asyncio.wrap_future chaining a cancel back to the source future)
did not reproduce, but a related and real race did, once the repro
matched the real code's finally-block cleanup shape exactly, not a
simplified version.

Fix: run() now captures self._log_rotation_future into a local
variable before calling log_rotation_task.cancel(), and passes that
captured reference into _wait_for_rotation_to_finish, which no
longer reads self._log_rotation_future itself. The captured
reference is unaffected by the coroutine's own cleanup clearing the
instance attribute afterward. Verified the fix closes the gap with a
second repro matching this exact sequence.

No new local test added, same constraint as the original method,
JobSupervisor's constructor requires a real GcsClient connection.
Existing 32-test test_utils.py suite passes unchanged.

Signed-off-by: odncode <nnajiodera2@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution Contributed by the community core Issues that should be addressed in Ray Core

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Core] Add log rotation support for job driver logs.

2 participants