Conversation
993a1a7 to
a1865f5
Compare
a1865f5 to
ce46c19
Compare
ce46c19 to
8d5775f
Compare
The status file merges each sync run onto the previous one and never removed anything, so its size grew with the total number of frames ever downloaded through a queue. Deadline Cloud Monitor reads the whole file every ~60s per open queue view and parses it on its UI thread, at roughly 2.4ms of freeze and 1MB of memory per MB of file. Empty the task records of the least recently updated jobs once more than 100,000 records are retained. Records are what dominate size, ~230 bytes each against ~330 for a job row, and they are dropped whole per job because a half-populated tasks dict understates a job's frame count. Only jobs that finished with every record a plain success are eligible. The Monitor derives a job's partial-failure indicator from the records alone, so dropping them from a partly-failed job would repaint it as a clean success. Records of this run's download candidates are also kept, because the farm_failed guard reads them from disk to suppress a failure the API replays after a requeue. That protection covers the candidate categories only, not every job still in the checkpoint: an inactive job is re-appended to the checkpoint on every run so a later requeue can be detected, so it is inactive permanently, and protecting that set would exempt exactly the accumulated history this reclaims. The consequence is accepted rather than hidden: a job whose records were reclaimed and which is later requeued can have an already-downloaded frame recorded as a farm failure, because the guard has no on-disk record to match. The same applies to a job another machine is tracking, since one machine cannot see another's candidate set. What this does not do, deliberately: Job rows are never removed, so the file is not bounded. Rows accumulate at ~330 bytes each, roughly 33MB per 100,000 jobs. Deleting a row makes the next run rebuild it from scratch with zeroed file counts, because the preserve-counts branch needs the existing entry to be there, and nothing in this file can show that a job is finished with. last_updated moves only on a status change, so a job that finished weeks ago and is still enumerated carries an old timestamp while being actively tracked; several machines write this file with independent checkpoints, so one machine's job set proves nothing about another's. An age gate on last_updated was tried and removed for that reason: it reads "nothing changed recently" as "safe to delete". Bounding rows needs a field that advances on every sync, which would also lift the eligibility restriction that keeps failure-heavy queues large, and belongs in its own change. The record target is a reclaim budget rather than a ceiling for the same reason: records held by an ineligible job, or by a candidate this run, are kept without spending it. The retained total can therefore exceed the target, and the overshoot is logged with both figures broken out so an operator can tell protected records from unreclaimed ones. A timestamp further ahead than a five-minute skew allowance is clamped to now rather than demoted to oldest, so a machine with a fast clock does not have its newest records dropped first by every other writer. Entries and records of an unexpected JSON shape are treated as ineligible instead of raising, because the caller swallows exceptions and only logs, so a shape error here would have left the file frozen at its old content indefinitely. Kept as feat: rather than feat!: deliberately. The schema is unchanged, no CLI subcommand, argument or default changes, and no job row ever disappears. What does change is that an empty tasks dict can now mean "records reclaimed" as well as "nothing downloaded", which is called out on the schema-version constant. Signed-off-by: Phillip Krasnick <259470369+phil-IO-p@users.noreply.github.com>
8d5775f to
841d544
Compare
| assert set(tasks) == {"task-fresh-0"} | ||
| assert tasks["task-fresh-0"]["download_status"] == "downloaded" | ||
|
|
||
| def test_records_kept_on_an_untrimmable_job_still_suppress_a_stale_farm_failure(self): |
There was a problem hiding this comment.
This test guards the invariant that matters most about trimming — that a retained "downloaded" record still suppresses a stale farm_failed — but as written it cannot distinguish the untrimmable path from the live-candidate path, and it does not cover the case where the invariant actually breaks.
MOCK_JOB_ID is passed in completed={MOCK_JOB_ID}, so it lands in all_job_ids, which is exactly what _build_status_file_content hands to _trim_retained_history as candidate_job_ids. In the trim loop that means the elif job_id in candidate_job_ids branch retains it regardless of _is_history_trimmable. Stub _is_history_trimmable to return True unconditionally and this test still passes: job-new (day 9, 10 records, not a candidate) spends the whole shrunken budget, and MOCK_JOB_ID is then exempted as live rather than as untrimmable. So the assertion pins the merge guard in _build_status_file_content, not the trimming decision the test name refers to.
It cannot be fixed by just dropping the job from the categorized sets, because task_download_results is only consumed inside the for job_id in all_job_ids loop — any job receiving a farm_failed record this run is necessarily a candidate, hence necessarily exempt. Which is the real point: within a single run the invariant is unbreakable, and the risk is entirely cross-run — trim on run N while the job is not a candidate, then have it become a candidate again on run N+1 and receive a farm_failed record for a task whose "downloaded" evidence is gone. That is the regression the _trim_retained_history docstring already admits ("A reclaimed job that is later requeued can report an already-downloaded frame as a farm failure"), and it is reachable in practice: _retrieve_session_actions_for_session collects FAILED taskRun ids from every retrieved session before the checkpoint_job_session_completed_indexes filter is applied, so a re-retrieved session can re-surface an old farm failure.
Worth restructuring as two builder calls — run 1 with the job in no categorized set so its records are actually reclaimed, run 2 feeding that output back in with the job in completed plus a farm_failed task result — and asserting whatever the intended behaviour is there. Even if the answer is "the frame is reported as farm_failed", pinning it makes the accepted cost visible in the suite instead of only in a docstring; today the suite reads as though the invariant holds unconditionally.
| logger.debug( | ||
| f"Trimmed download status history: dropped {dropped_records} task records, " | ||
| f"retaining {retained_records}." | ||
| ) |
There was a problem hiding this comment.
nit: can we make this more visible to the users?
| logger.info( | ||
| f"Download status file retains {retained_records} task records, above the " | ||
| f"{_TARGET_RETAINED_TASK_RECORDS} target: {untrimmable_records} belong to jobs that " | ||
| f"are not eligible to be trimmed and {live_records} to jobs this run is still tracking." |
There was a problem hiding this comment.
Can we make this more visible to the users?
|
In instances where a task row has been reclaimed, what is the customer experience. Are those task rows stale until synced? I'm not super familiar with this functionality. |
@andychoquette Good question, and it turned up something we had missed, so thanks for asking! Short answer What a customer would see today, for a job whose per-frame detail has been reclaimed
The underlying reason is that the reader cannot currently tell "these records were reclaimed to keep the file small" apart from "this job never had records", and those two cases deserve different labels. So I am holding this as a draft rather than pushing it forward. Landing it safely needs two things together:
|
|
Closing this. Clearing a finished job's task records makes Deadline Cloud Monitor report downloaded frames as having no outputs, and deleting the whole row breaks the same way as soon as the job is requeued. Replaced by #1361: write the file compactly (40% smaller) and report its size, so any ceiling comes from real numbers instead of a guess. |
What this does
Bounds the growth of the per-queue download-status file, which until now only ever grew.
_build_status_file_contentmerges eachdeadline queue sync-outputrun onto the previous file and nothing was ever removed, so size scaled with the total number of frames ever downloaded through a queue.Once more than 100,000 task records are retained, the records of the least recently updated eligible jobs are emptied. Job rows themselves are never removed.
Counterpart change on the reader side, a 64 MiB read ceiling in Deadline Cloud Monitor.
Why the budget counts frames, not jobs
Task records dominate size. Measured on the pretty-printed file, two independent measurements agreeing within 3%:
Capping job count instead would leave size at the mercy of frames-per-job:
That last row fails worst for the large-render studios the feature exists to serve.
What is protected, and why
Two sets of records are never reclaimed.
Jobs that did not finish cleanly. A job is eligible only if every task record is a plain success, with no
failed, nofarm_failed, and noerror_code. The Monitor derives a job's partial-failure indicator from the records alone rather than from the job's counts, so dropping them from a partly-failed job would repaint it as a clean success. Job status alone is not sufficient here: adownloadedjob can legitimately hold failed tasks.This run's download candidates. The
farm_failedguard in this module reads records from disk to suppress a failure the API replays after a requeue, so emptying them for a job being actively synced would let an already-downloaded frame be recorded as a farm failure.That second protection deliberately covers the candidate categories only, not every job the checkpoint knows about. An inactive job is re-appended to the checkpoint on every run so a later requeue can be detected (
_incremental_download.py:1201), andinactiveis recomputed as "in the checkpoint but not a candidate", so a finished job is inactive permanently. Protecting that set would exempt exactly the accumulated history this change exists to reclaim, and the overshoot branch would become the steady state rather than the exception.Accepted consequences
infowith both figures broken out, so an operator can tell protected records from unreclaimed ones.farm_failedguard to match, a frame whose output was downloaded earlier can be recorded as a farm failure. The same applies to a job another machine is tracking, since one machine cannot see another's candidate set. This is the price of the change reclaiming anything at all; the alternatives were a no-op or zeroed file counts.Why job rows are never removed
Deleting a row makes the next run rebuild it from scratch with zeroed file counts, because the preserve-counts branch needs the existing entry to be there. Avoiding that requires knowing a job is finished with, and nothing in this file can show it:
last_updatedmoves only on a status change, never on a sync. A job that finished weeks ago and is still enumerated every run carries an old timestamp while being actively tracked.An age gate on
last_updatedwas tried and removed for that reason: it reads "nothing changed recently" as "safe to delete", which is the normal steady state of a healthy finished job. Bounding rows needs a field that advances on every sync. That would also lift the eligibility restriction above, so it belongs in its own change rather than bolted on here.Shared-filesystem hazards handled
This file is written by several machines to a shared location, so the retention order cannot trust its contents:
fromisoformatonly accepts a trailingZfrom Python 3.11, andrequires-pythonis>=3.9, so which records were reclaimed first would otherwise depend on the interpreter running the sync.Zis now normalised.write_download_status_fileswallows every exception and only logs, so a shape error in the reclaim pass would have left that queue's file frozen at its old content indefinitely.Why
feat:and notfeat!:Deliberate, and worth a reviewer's disagreement. The schema is unchanged, no CLI subcommand, argument or default changes, and no job row ever disappears. What does change is that an empty
tasksdict can now mean "records reclaimed" as well as "nothing downloaded", which is called out on the schema-version constant.Testing
hatch run test: 3842 passed, 22 skipped, 0 failures. Coverage 77.67% against the 69% gate; the changed module is at 95%, with the remaining uncovered lines pre-existing.hatch run fmt,hatch run lint(ruff + mypy),hatch build: all clean.write_download_status_filepath with a real read/write cycle rather than only the internal builder.