feat: give the output of a job a limit, and keep both of its ends - #15
feat: give the output of a job a limit, and keep both of its ends#15stephenc wants to merge 3 commits into
Conversation
A job wrote 386MB of standard output in a review, and nothing stopped it. qex is made to be started and left, and that is the moment when nobody sees a disk fill. The same disk holds `status.json`, so output with no limit can destroy the record of each job on the machine. The new field `[logs] max_bytes` is the space that one stream of one job can use. The default is 32MB for `stdout.log` and 32MB for `stderr.log`. Use "0" for no limit. The limit operates WHILE THE JOB WRITES. The supervisor gives the job a pipe and writes the file itself, so a job that writes 400MB never puts 400MB on the disk. A limit that corrects the file afterwards lets the disk fill first, which is the fault itself. qex keeps the first part of the output and the last part. The head holds the start-up and the configuration. The tail holds the failure. A reader who loses one end loses the reason to open the file. Between the two ends, qex writes a line that says how many bytes and how many lines went. The tail stays on the disk and not in the memory. The supervisor is in the cgroup of the job, so a tail in the memory of the supervisor would count against the memory claim of the job and could stop it. qex writes the tail to a circular file of a fixed size beside the log file, and it uses one buffer of 64KB. `qex status` and `qex logs` say what went, and `status.json` holds the count in the new field `logs_dropped`. A reader thus never takes a part of the output for the whole output. A job never fails because of this limit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
A review of this branch found two faults that removed data, and four smaller faults. 1. A tail with no line end went completely. qex removes the incomplete first line of the tail, so that a reader never meets one half of a line. That rule removed the WHOLE tail when the tail held no line end at all: a job that wrote one enormous line kept its head only. One JSON document, one base64 block, and a progress display that uses `\r` (dd, curl, docker, apt) all give output of that form. qex now keeps the last part and says that it starts in the middle of a line. 2. A second attempt removed output that fits in the limit. The file went back to one quarter of the limit at the first byte of the second attempt, and the note said that the output had reached the limit, which was not true. qex now removes nothing before the output passes the limit. A job that writes less than `max_bytes` thus keeps every byte in one piece, and the mark between two attempts stays. 3. A machine that refuses the file for the tail made qex write the note one time for each 64KB, and the file grew with the output that the limit had to stop. The record also said that nothing went. A state flag now replaces the test of the ring, and the count says that the output is not complete. 4. The supervisor left the file of the tail behind when the output did not close, and the record said nothing. It now removes that file and puts the cause in the record, where `qex status` shows it. 5. The 30 second limit covered the copy of the tail as well as the wait for the end of the output. A limit of some gigabytes needs more than 30 seconds for local work, and the supervisor cut the log file of a job that did nothing wrong. The two events are now separate: the wait for the end of the output keeps its short limit, and the copy that follows has its own. 6. The documentation now says what the JOB sees. Its output is a pipe and no longer a regular file, so `lseek` gives ESPIPE, `stat` gives a FIFO, and two children that write more than 4096 bytes together can mix in the middle of a line. `isatty` does not change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
|
Review addressed in BLOCKING 1 — a tail with no line end went completely. Measured with BLOCKING 2 — a retry removed output that fits in the limit. Measured with While writing the test for this I found one more fault of my own in the same place: after the cut back to the head budget, 3 — a tail file that the machine refuses. A new 4 — the escape hatch left an orphan and reported nothing. On a timeout the supervisor now removes 5 — the 30 second budget covered the merge. 7 — documentation. New tests: |
A second review of this branch found one fault that loses data in silence, and four smaller faults. 1. `qex logs --follow` lost each line after the limit. The log file of a job BECOMES SHORTER when the output passes `[logs] max_bytes`, and no file did that before this limit existed. The position of the follower was then after the end of the file, it read nothing more, and the command gave the code 0 and no word. The reader saw the output stop in the middle, with no reason to doubt it, in the command that an agent uses to watch a job. The follower now watches the length of the file, says that qex removed the middle, and continues at the new end. It also reads the record when the job stops, so a job that passes the limit and stops between two reads still gives the reader a word. 2. A line end that is far into the tail cost almost all of the tail. qex removes the incomplete first line, so that a reader never meets one half of a line. With one line end deep in the tail, that rule kept 13 bytes of a tail of 46KB. qex now removes that fragment only when it is a small part of the tail, and in each other case it keeps the fragment and says that the text starts in the middle of a line. A measure with a progress display and a failure at the end now keeps 47104 bytes in place of 57. 3. A record said that the files were complete when qex could not complete them. The new field `logs_dropped.incomplete` says that a process of the job held the output open, that the counts are the counts that arrived, and that the file can be missing more. `qex status` and `qex logs` say it as well. 4. A tail that starts in the middle of a line now always says so. The warning went to the reader only when the circular file returned to its start, and a reader thus met a fragment of a line with no warning. 5. The documentation said that a job that writes less than `max_bytes` keeps every byte. The true point is `max_bytes` less the room that qex keeps for its notes, and the text now says that, with the reason: qex writes the file while the job runs, so at that moment nobody knows how much output follows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
|
Second review addressed in BLOCKING — The new length becomes the new reference — without that, the test would find the file short at every turn and the merged tail (which is smaller than the pre-cut length) would never reach the reader. A job can also pass the limit and stop between two reads of the loop, and then there is no short file to find. For that case the loop reads New e2e test Late line end cost almost the whole tail.
A tail that did not wrap started mid-line with no warning. The test is no longer The cliff below
Disk after the large-write experiments: 356G free on the state volume, and every experiment directory was removed. |
Closes part of #10 (the item "a limit on the output of a job").
What it does
A new
[logs]section withmax_bytes. It is the space that ONE STREAM of ONEJOB can use, and it accepts the size syntax of the other fields
(
max_bytes = "64MB")."0","none"and"unlimited"remove the limit.A job that writes more keeps the first part of its output and the last part,
with a line between them:
qex status,qex logsandqex logs --followall say what went, andstatus.jsonholds the numbers in the new fieldlogs_dropped. A job neverfails because of the limit.
What I decided, and why
The supervisor writes the file through a pipe. It does not correct the file
afterwards. The requirement is that the disk cannot fill, so the limit must
operate while the job writes. A correction afterwards lets the disk fill first,
which is the fault itself. The supervisor thus gives the job
Stdio::piped()and copies each stream in a thread of its own.
The default is 32MB for each stream. No limit is not an acceptable default,
because that is the fault. A real build or test run writes a few megabytes, so
32MB never touches an honest job. It is also small enough that a day of runaway
jobs costs some gigabytes and not some hundreds of gigabytes. A user who needs
more writes one line.
The tail stays on the disk, and not in the memory. The supervisor puts
itself in the cgroup of the job, so memory that the supervisor holds counts
against the memory claim of the job. A tail of 24MB in memory would stop a job
that operates at its claim, and
qex statuswould show an out-of-memory eventthat the job did not cause. qex writes the overflow into a circular file of a
fixed size (
stdout.log.tail): the writer returns to the start when it reachesthe end, so that file never grows, and this module holds one buffer of 64KB.
The file goes when the job stops, and
qex cleandeletes the directory anyway.The parts of the limit: head = max/4, tail = the rest, less 2KB for the two
notes. The head file and the circular file are the only files, so the disk
that one stream uses stays below
max_bytesAT EACH MOMENT, and not at the endonly. The tail is three times the head, because the head needs the start of the
output only and the failure is at the end.
A note goes into the file when the limit is reached, and not at the end
only. A reader who opens the file while the job operates would otherwise see
a file that stops at the head and reads as complete.
qex logs --followgivesthat line as it arrives. The follower then sees no new line until the job
stops, and the last part arrives then. That is documented.
The limit belongs to the stream of the job, and not to one attempt. A job
with
--retriesadds to the same file. When a later attempt goes above thehead budget, qex counts and removes what is above it, so three attempts do not
hold three times the limit.
A fault of this code never fails the job. A write that the disk refuses
goes into
supervisor.logand the copy continues to read the pipe, so the jobnever blocks. If a process that left the process group holds a pipe open, the
supervisor waits 30 seconds, writes the record, and says that the last part of
a log file can be missing. A record that arrives is worth more than a wait with
no end.
The refusal of a limit below 16KB. A limit of a few hundred bytes cannot
hold a head, a tail and the note, so the configuration refuses it and names the
remedy (
"0"for no limit).What I measured
With
max_bytes = "64KB", a job that writesseq 1 500000(about 3.4MB, 50times the limit):
stdout.logis 63891 bytes. It holds the line1and the line500000, andnot the line
250000.logs_droppedsays 1.2MB and 189773 lines for the 200000-line variant that Iran by hand; the job exits with its own code and the state is
completed.the_disk_stays_below_the_limit_while_the_job_writesmeasuresevery file of the directory after each 100 lines, so a limit that operates
only at the end fails it.
What I tested
New unit tests in
src/logcap.rs(both ends stay, the disk stays below thelimit during the run, the temporary file goes, output below the limit is
untouched, no limit keeps everything, a reader sees the limit before the job
stops, a second attempt shares the limit, one write larger than the tail keeps
its end, the parts fit the limit) and in
src/config.rs(a default limitexists, the sizes and the words parse, a limit that is too small is refused).
New end-to-end test
a_job_that_writes_more_than_the_limit_keeps_the_head_and_the_tail.Documentation:
docs/reference.md,qex help config,qex help output, thestatusJSON Schema (logs_dropped), and a line inqex config show.Cargo.tomlmoves to 0.8.0.🤖 Generated with Claude Code
https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9