Skip to content

feat(backend): clean up old task history in the task manager and during the upgrade [INFP-507] - #10869

Closed
saltas888 wants to merge 41 commits into
task-history-retention-infp-507from
task-history-cleanup-infp-507
Closed

saltas888 wants to merge 41 commits into
task-history-retention-infp-507from
task-history-cleanup-infp-507

Conversation

@saltas888

@saltas888 saltas888 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Superseded by #11289. The six INFP-507 PRs are consolidated there into one commit targeting develop, with the complete design, code-flow diagrams and contract-based review guide. This PR is closed to keep one active review; its branch and previous discussions are preserved.

Stacked PR 2/6 for INFP-507 (task history and activity log retention), in GitHub stack #10873 on develop. Base: #10862. Review and merge bottom-up.

  1. feat(backend): add task manager retention settings [INFP-507] #10862: retention settings applied to Prefect
  2. This PR: task history cleanup command, and the upgrade's task history hint
  3. fix(backend): keep the Activities page fast with a year of activity log [INFP-507] #10876: Activities page queries
  4. test(backend): guard the Prefect event-type list on the full stack [INFP-507] #10877: guard for Prefect's event types on the full stack
  5. feat(backend): run the task manager background services with the retention settings [INFP-507] #10878: infrahub tasks background-services
  6. docs: document task history and activity log retention [INFP-507] #10879: documentation

Ship with #10862 in the same release. #10862 on its own switches on Prefect's hourly deletion, which works through a large backlog slowly and gives no disk space back; this PR adds the one-off cleanup and the table rewrite.

Why

The large instances known to OpsMill hold 20 to 35 GB of task history. Prefect's own cleanup took 2 h 26 min on 25 GB in the benchmark, and Postgres does not shrink its files after deletes, so the disk only comes back after a rewrite of the tables. Design doc: Task history and Activity log retention (D9 in the numbering this stack uses, D7 since the doc renumbered its decisions on 11 Oct); spec set in dev/specs/infp-507-task-history-retention/.

Goal: delete the backlog fast with SQL that follows Prefect's own rules, rewrite each table that is mostly free space, and tell operators about it during the upgrade without slowing the upgrade.

The design doc's Design changes for implementation list asked for two changes to this PR, both done:

  • 11 Oct: the one-off cleanup leaves the upgrade. The upgrade step, its renumbering, --no-task-history-cleanup and the --check text are removed, and the upgrade prints a hint instead.
  • 8 Oct: the rewrite also covers the activity log's two tables, and rewrites each table only when most of its space is free.

Closes part of INFP-507.

What changed

Behavioural changes:

  • infrahub tasks flush flow-runs [--rewrite] deletes, in the task manager, the finished top-level runs older than the task history retention with their task runs, states, logs and artifacts, one day of end times per transaction, and prints progress per day.
    • --rewrite then rewrites, with VACUUM FULL, each of flow_run, flow_run_state, task_run, task_run_state, log, artifact, events and event_resources whose live rows hold less than half of its disk space, whoever deleted the rows. The instance does not need to be stopped: tasks that write to a table wait while it is rewritten.
    • The summary gives the runs deleted, the size of the eight tables before and after, then one line per outcome: Rewrote …, Left … as they are, because their live rows hold at least half of their disk space, Did not rewrite …, which stayed locked by other sessions, Did not rewrite …, whose rewrite took longer than its time limit and was stopped.
    • Breaking: --days-to-keep and --batch-size are removed; the cutoff comes from the task manager's retention, set from task_manager.retention.task_history (changelog +flush-flow-runs.changed.md).
    • Against a task manager that does not provide the cleanup (older release), it prints a message and exits 0.
  • infrahub upgrade deletes no task history and keeps its six steps. In its task manager step it prints Task history takes 24.8 GB, with 1,234 runs older than the retention. To get the space back, run: infrahub tasks flush flow-runs --rewrite. The count stops at 100,000 (more than 100,000 runs), so it stays fast on millions of runs; with no runs older than the retention, the step only shrinks the tables. The size and the count are read within 10 s. A task manager that answers late, answers with an error (404 from a release without the route) or answers without a size gets Could not read the task history size from the task manager (<error type>). before the command, and the upgrade goes on.
  • While it waits (a cleanup runs on another task manager, the task manager lost the job, or it restarts), the command prints one line when the wait starts and when its reason changes; when it gives up after 3 hours, it names the last reason. A failed cleanup prints what it had already deleted and rewritten.
  • New task-manager routes:
    • POST /infrahub/task-history/cleanup ({"rewrite": "never" | "if_freed"}) and GET /infrahub/task-history/cleanup/{id}, a background job per task manager;
    • GET /infrahub/task-history/size, the disk space of the six task history tables with their indexes (null on SQLite), and the finished top-level runs older than the retention, counted up to 100,000 (old_runs, old_runs_limit_reached).

Implementation notes:

  • Same eligibility as Prefect 3.8.6's vacuum_old_flow_runs (top-level, terminal state, end_time before the cutoff, which is Prefect's own db_vacuum.retention_period), same order (runs first, then their logs and artifacts), runs selected with FOR UPDATE SKIP LOCKED, so it never waits on Prefect's own cleanup. Passes repeat until nothing is eligible, which catches subflows left without a parent.
  • No DDL on Prefect's database: no tables, temporary tables, indexes or extensions. Deleted IDs travel through Python in batches under the bind-parameter limit.
  • One job across task-manager replicas: a Postgres advisory lock held on a dedicated connection (a process lock on SQLite). Another replica answers 409; the CLI waits and posts again, and also waits out a task-manager restart while it follows a job. Committed days are never redone.
  • A start request that reaches a task manager already running a cleanup raises that job's rewrite mode to if_freed when it asks for it, and the job decides again before it completes; it never rewrites twice.
  • Free space, per table: its size on disk (main data and TOAST, without indexes) against the size of its live rows (per-column pg_column_size plus 28 bytes a row, so no value stored out of line is read). It measures space rather than counting deleted runs, because Prefect's own cleanup usually deletes the old records before the command runs.
  • Up to 10,000 pages a table is read whole; above that its live size is estimated from TABLESAMPLE SYSTEM over about 10,000 random pages, which is built into Postgres. On a restored copy of Fatih's 1.10 large backup (12 GB events, 12 GB event_resources), the estimate was within 2 % of the full read and took 0.2 to 0.7 s against 4 to 6 s. A fully live table measures about 0.9 of its disk size, so it is left as it is.
  • A small table usually counts as mostly free, because space is counted in whole pages, and is rewritten in milliseconds.
  • The upgrade's size comes from pg_total_relation_size, which reads no table data.
  • Each VACUUM FULL runs in autocommit with the limits of the manual cleanup of the event tables of OpsMill's production task manager on 26 Oct (changed on 27 Oct, from lock_timeout = 60s with 3 retries):
    • lock_timeout = 3s, retried up to 4 times 20 s apart, then listed as not rewritten. While a VACUUM FULL waits for its lock, every later query on the table queues behind it, so the wait stays short.
    • statement_timeout = 60s: a stopped VACUUM FULL leaves the table as it was, and the job lists it in a new timed_out field.
    • A plain VACUUM (ANALYZE) of each rewritten table, which blocks nothing.
    • Before the next table, a pause of ten times as long as the previous table was locked, up to 60 s, so Prefect catches up on the writes that waited.
    • The job goes on with the next table after one it could not rewrite.
  • The job uses its own small connection pool with a long statement timeout, separate from Prefect's API engine.
  • The request, job and size models live in prefect_server/task_history_models.py, so the CLI client imports them without loading Prefect's database layer or asyncpg (a unit test checks it).

The design doc's two suggestions are implemented: the command names each table it skipped and why, and the hint counts the old runs. The count selects the runs as the cleanup does and stops at 100,001, because an exact count would read every old run, millions on the instances this is for.

Needs approval

  • Constitution deviation: the cleanup route deletes data without authentication, against Principle VI. It follows the task manager's existing trust model: reachable only on the internal network, like Prefect's own API, and only the Infrahub CLI calls it. Its input is limited to the rewrite mode; the cutoff comes from the task manager's own settings.
  • New endpoint: backend/AGENTS.md lists new REST endpoints under "Ask first". GET /infrahub/task-history/size is a new read-only route on the task manager, not on the Infrahub API, so it is not in schema/openapi.json; like the other task manager routes, it has no authentication. The design doc's hint needs it, because the upgrade runs in the Infrahub server container, which reaches the task manager only over HTTP.

Known limitations

  • Behind a load balancer that spreads each request across task-manager replicas, a status read can reach a replica that does not know a job that already completed; the client then starts a new job, which with --rewrite measures each table again and rewrites those still mostly free. With a Kubernetes Service and keep-alive connections, reads stay on one pod.
  • Artifact collections pointing at a deleted artifact are reconciled by Prefect's own hourly service, not by this job.
  • How long a rewrite of the activity log tables takes after a long activity log retention was lowered was not measured (design doc, "Cleanup step for existing instances: timings and costs").

How to review

  1. backend/infrahub/prefect_server/task_history.py (job, lock, deletes, per-table rewrite, routes).
  2. backend/infrahub/task_manager/flow_run/cleanup.py (client loop and size read), then cli/tasks.py and cli/upgrade.py.
  3. Tests: tests/component/task_manager/test_task_history_cleanup.py runs Prefect's vacuum_old_flow_runs on one copy of a seeded database and the new cleanup on another, and compares every remaining run, task run, state, log and artifact ID (plus a concurrent run of both), and checks the per-table rewrite decision; test_task_history_routes.py; tests/integration_docker/test_task_history_cleanup.py on Postgres through the real command, including the sampled measurement and a locked table; unit tests for the client, the CLI and the upgrade's hint.

How to test

uv run pytest backend/tests/unit/task_manager/flow_run/test_cleanup.py backend/tests/unit/cli/test_tasks_flush_flow_runs.py backend/tests/unit/cli/test_upgrade_task_history.py -n 0
uv run pytest backend/tests/component/task_manager/test_task_history_cleanup.py backend/tests/component/task_manager/test_task_history_routes.py -n 0
uv run pytest backend/tests/integration_docker/test_task_history_cleanup.py -n 0

Impact & rollout

  • Backward compatibility: flush flow-runs options removed (breaking, changelog). The infrahub-taskmanager flush flow-runs maintenance tool passes both options, and fails on this release without opsmill/infrahub-backup#179, which runs the command without a number of days from 1.13 on; the changelog points the tool's users to it. Deletion is irreversible; set a longer task_history before upgrading to keep older runs.
  • Performance: in the benchmark, the command took about 8.5 min on 25 GB and about 2 h 20 min on 100 GB of task history, almost all of it the deletes; the upgrade itself takes no longer than before. A rewrite needs free disk for a copy of the rows a table keeps (under 0.5 GB for 25 GB of task history, 2.2 GB for 100 GB). Tasks that write to a table waited up to 7.4 s with the instance running on 25 GB, and the slowest table took about 20 s on 100 GB.
  • Deployment notes: the Helm chart's upgrade hook needs no extra option. Existing instances with a lot of task history run the command once after the upgrade, the same on Compose and Helm (PR 6 documents it).

Validated locally on 27 Oct, after the change to the rewrite limits and the rebase onto develop: unit and component tests (576 passed), the integration-docker tests against an image built from the rebased stack (6 passed, including a rewrite stopped at its time limit), this PR alone without the PRs above it (169 tests passed, ruff), mypy and the generated docs.

Validated locally on 11 Oct: unit, component and integration-docker tests (the integration tests against an image built from this branch, 5 passed); the free-space sampling against a restored copy of Fatih's 1.10 large backup; quickstart Part 1 steps 5 and 6 on a dev stack with 30,000 old runs (results in #10879); /pre-ci checks (ruff, mypy, generated docs, test shards, changelog style). Earlier, on 4 to 8 Oct: the Compose upgrade guide run end to end with 120,305 old runs, a Postgres 14 and 18 probe of the advisory lock, concurrency and the lock timeout, and cubic review (three passes). Release evidence on restored 25 GB and 100 GB backups comes from opsmill/infrahub-private-tests, run separately.

Checklist

  • Tests added/updated
  • Changelog entry added
  • External docs updated (PR 6 of this stack)
  • Internal .md docs updated (PR 6 of this stack)
  • I have reviewed AI generated content

🤖 Generated with Claude Code

@saltas888 saltas888 added type/feature New feature or request group/backend API server, GraphQL, task worker (Prefect), database, Python code labels Oct 4, 2026
@github-actions github-actions Bot added type/documentation Improvements or additions to documentation type/spec A specification for an upcoming change to the project labels Oct 4, 2026

@cubic-dev-ai cubic-dev-ai 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.

2 issues found across 26 files

Confidence score: 4/5

  • test_cleanup.py changes both the day and run count, so it doesn’t verify that a count-only increase reports progress. Add a poll with the same day and a higher deleted_runs count.
  • task-manager-api.md says rewritten can be true even when no table was rewritten, including when all tables are skipped for lock timeouts. Clarify that it indicates the rewrite pass completed.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="backend/tests/unit/task_manager/flow_run/test_cleanup.py">

<violation number="1" location="backend/tests/unit/task_manager/flow_run/test_cleanup.py:84">
P2: This poll changes both the day and run count, so it does not verify that a count-only increase reports progress. Add a poll that keeps the same day while increasing `deleted_runs`, and assert that notification.</violation>
</file>

<file name="dev/specs/infp-507-task-history-retention/contracts/task-manager-api.md">

<violation number="1" location="dev/specs/infp-507-task-history-retention/contracts/task-manager-api.md:32">
P2: `rewritten` can be true even when no table was rewritten: the job sets it after the rewrite pass returns, including when every table is skipped for lock timeouts. Document it as indicating that the rewrite pass completed, and let `not_rewritten` identify skipped tables.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread changelog/+flush-flow-runs.changed.md Resolved
@codspeed

codspeed Bot commented Oct 4, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 13 untouched benchmarks


Comparing task-history-cleanup-infp-507 (5e2d507) with task-history-retention-infp-507 (ff6a8ce)

Open in CodSpeed

saltas888 and others added 7 commits October 28, 2026 10:54
…routes

POST /infrahub/task-history/cleanup starts a background job in the task
manager that deletes the finished top-level runs older than Prefect's
flow-run vacuum retention, one day of end times per transaction: the runs
first (DELETE ... RETURNING id, selected FOR UPDATE SKIP LOCKED), then the
logs and artifacts of those runs, batched under the bind-parameter limit.
Further passes catch subflows whose parent was deleted. No table, temporary
table or index is created in Prefect's database.

On Postgres the job holds an advisory lock on a connection of its own (409
from another replica), and with rewrite=true runs VACUUM FULL on the six
task history tables when the deletes freed more than half of the runs,
with a 60s lock timeout and 3 retries per table. SQLite uses a process
lock and never rewrites. GET /infrahub/task-history/cleanup/{id} reports
the job. Stale artifact collections are left to Prefect's daily orphan
cleanup, as after Prefect's own vacuum.

Component tests compare the end state with Prefect's vacuum_old_flow_runs
on a copy of the same database, run both concurrently, and cover the
routes. The contract now spells out the rewrite, size and error fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cleanup request's rewrite becomes a mode instead of a boolean:
"never" (default), "if_freed" (rewrite only when the runs left are fewer
than half of the runs counted at the start, so Prefect's own deletes during
the job count too) and "always". The job reports the requested mode in
"rewrite" and the outcome in a new "rewritten" field; SQLite never rewrites.

A condition on the cleanup's own deletes skipped the rewrite exactly when
the disk had to come back: after a Helm rollout or after lowering the
retention, Prefect's hourly cleanup deletes the old runs first and a later
maintenance run of flush flow-runs --rewrite deleted too little to rewrite.
The command's rewrite option will send "always" and the upgrade "if_freed".

The seeded task history scenario moves into the seed helpers so that a
Postgres test can reuse it. The spec set is updated for the new modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p job

infrahub tasks flush flow-runs now starts the task manager's task history
cleanup and polls it to its end, printing a line each time its day or its
number of deleted runs changes, then a summary: runs deleted and the
cutoff, the table sizes before and after on Postgres, and whether the
tables were rewritten. --rewrite asks for the rewrite "always", and the
command asks for "never" without it. --days-to-keep and --batch-size are
gone: the task manager reads its own retention. Exit code 0 on success and
when the task manager has no cleanup route (an older release), 1 when the
cleanup fails.

A cleanup running on another replica (409) or unknown to the replica that
answers (404 while polling) is started again after a 2s wait; committed
days stay deleted. The client gives up with exit code 1 after 3 hours
without an answer that shows a cleanup.

infrahub upgrade gets step 6/7 "Task history cleanup" after the task
manager step, with the rewrite "if_freed", and --no-task-history-cleanup to
leave it out on an instance that still serves (the Helm upgrade hook). A
failed cleanup cancels the upgrade, as a failed migration does.

Unit tests drive the client, the command and the upgrade step against a
scripted task manager on an httpx mock transport; an integration-docker
test seeds Postgres through the task manager container and runs the
command with --rewrite against the full stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The committed days stay deleted and a failed rewrite leaves its table intact,
so the upgrade reports the failure with the command that finishes the cleanup
and continues to the branch rebase instead of stopping with exit code 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ger restart

The cleanup client now orders the rewrite modes never < if_freed < always.
When the job it followed was started by another caller with a weaker mode,
it posts again with its own once that job completes, so
`infrahub tasks flush flow-runs --rewrite` no longer exits 0 without a
rewrite after adopting an interrupted upgrade's if_freed job.

A transport error while the client follows a job (a task manager restart
that outlasts Prefect's client retries) is treated like a 404: wait and
post again inside the same give-up window. A transport error on the first
request still propagates.

`flush flow-runs` now handles the same failures as the upgrade step
(TaskHistoryCleanupError and httpx errors, PrefectHTTPStatusError
included): one error line and exit 1 instead of a traceback. Both share
the handling through clean_task_history, which returns the failure.

The route test that held the cleanup lock in a try/finally now takes it
from a yield fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Holds an ACCESS SHARE lock on the log table from another psql session in
the task-manager-db container, runs the cleanup in the task manager
container with a 1 s lock timeout and no retries, and asserts that only
log is reported not rewritten while the five other tables get a new
relfilenode. The lock holder is terminated on fixture teardown, and the
test runs last in the class so a leaked lock cannot stall the flush test.
The in-container script's exit code is now checked.

Without the lock the test fails with `[] != ['log']`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saltas888 and others added 12 commits October 28, 2026 10:55
…ewrite decision

The job started with no decision, so the loop's first hold of the start lock
decides the rewrite instead of a separate call before it; the job still ends
in the same hold as its last check of the mode.

The comment on the maintenance statement timeout now says what a timeout of
None does: Prefect turns it into the API's 10-second timeout, and the engine
cache then hands back the API's own engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…deletes of a task history cleanup

A cleanup whose cutoff falls at noon deletes a run that ended at 06:00 that
day and keeps one that ended at 18:00. With the cap on the day's end
removed, this test fails as the 18:00 run is deleted, and no other test
noticed.

The comparison with Prefect's vacuum now seeds three old runs that ended on
one day and runs with one run per statement as well, so a day's log and
artifact deletes span several statements. With the batching loop replaced
by a single statement over the first batch, the one-run case fails on the
logs and artifacts left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d one committed

While the task manager answered that a cleanup runs elsewhere, that it no
longer knows the cleanup, or could not be reached, the client waited up to
three hours without a word, and its give-up error merged the three
conditions. It now reports each wait through a second callback when the
wait starts, when its reason changes, and when it starts again after a job
showed, and the give-up error names the reason seen last. The flush command
and the upgrade print one line per reported wait.

A job that ends failed now raises with the job as it ended, and both print
a second line with what it had committed: the runs it deleted, and the
rewrite when that had finished. The job records no table of a rewrite cut
short, so none is named then.

The client builds its request with the condition it reuses, and the upgrade
test of an unexpected answer pins the whole line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… --check`

The check said nothing about the new step, which deletes finished task runs
irreversibly. It now prints a "Task history" section before "Branches": the
upgrade deletes the finished task runs older than the task manager's task
history retention, rewrites the tables if the deletes free more than half
of their runs, and --no-task-history-cleanup leaves this out. It does not
query the task manager. The check has no test of its own to extend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…leanup

A script run in the task manager container builds three advisory locks on a
key of the test's own and checks, in order, that the first acquires, the
second is refused, the second acquires once the first releases, and a third
acquires once both released. The key is not the cleanup's, so a cleanup that
another test runs on the shared stack never holds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e is free

The upgrade's cleanup decided the `if_freed` rewrite against the runs it
counted when it started. On the Compose upgrade path, `docker compose run`
starts the task manager fresh, and Prefect's flow-run vacuum deletes the old
runs at startup, before the cleanup starts. The cleanup then counted only
the runs left, deleted none and never rewrote: with 120,305 old runs the
tables stayed at 166.5 MB, so the disk space never came back.

`if_freed` now rewrites when the live rows of `flow_run` hold less than half
of its size on disk, whoever deleted the runs and when. The size on disk is
the main data and the TOAST table, without indexes, free-space map and
visibility map; the live size sums `pg_column_size` of each column plus 28
bytes a row, so no value stored out of line is read. It runs on the
maintenance pool, as it scans the whole table. `flow_run` stands in for the
six tables, as the other five lose their rows with their runs. The job no
longer counts runs: `delete` returns nothing, `rewrite` takes no count, and
`TaskHistoryTables.count_runs` is gone. A decision taken again after a
stronger start measures the space again.

The upgrade docstring, the `--check` line, the `if_freed` docstring, the
changelog fragment and the spec documents state the new rule. The rewrite
tests script the measured space through the recording rewriter, including
a cleanup that deleted nothing from a mostly free table, and a mostly live
table raised to `always` after the decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n Postgres

A script run in the task manager container inserts 2,000 finished runs that
ended 35 days ago and 20 recent ones after them, and plain SQL deletes the
old ones before any cleanup starts, as Prefect's vacuum does when the task
manager starts. A cleanup started through the route with `if_freed` then
deletes no run and rewrites all six tables (each gets a new file), and the
tables are smaller after it than before. The recent runs keep the freed
pages from ending the table, where a vacuum could cut them off before the
cleanup measures. Against an image of the earlier cleanup, which counted
runs, the test fails on the job reporting `rewritten` False.

A second test inserts 500 recent runs, compacts the runs table with
`VACUUM FULL`, and checks that an `if_freed` cleanup with no run to delete
leaves every table's file as it was. The scripts use only Prefect,
SQLAlchemy and httpx, so they run in an image of any version of the
cleanup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… each freed table

Follows the design doc's changes of 8 and 11 Oct 2026. The upgrade no
longer runs the one-off task history cleanup: its task manager step prints
how much disk space the task history takes and the command that returns it,
read from a new GET /infrahub/task-history/size route with a 10 s bound, and
the command alone when the task manager answers late, with an error or
without a size. The "Task history cleanup" step, the 1/7 to 7/7 numbering,
--no-task-history-cleanup and the --check text are gone.

`infrahub tasks flush flow-runs --rewrite` now asks for `if_freed`, the
only rewrite mode left besides `never`. The job rewrites each of the six
task history tables and events, event_resources whose live rows hold less
than half of its disk space, measured from a random sample of pages above
10,000 pages, and records and prints the tables it rewrote, left because
they are mostly live, and could not lock.

The spec set, contracts and changelog fragments follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hint now says the size could not be read, with the error type, before
the command, so the failure it swallows is reported. Its comments no longer
name the upgrade, and the --rewrite help starts with "After the deletes".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hint

The design doc suggests that the hint report how many old runs are left,
so an operator can wait until the hourly cleanup has worked through the
backlog, when the cleanup step is mostly the rewrite. The size route now
also answers the finished top-level runs older than the task history
retention, counted up to 100,000, and whether there are more, so the count
stays fast on millions of runs. The hint reads, for example, "Task history
takes 24.8 GB, with 1,234 runs older than the retention."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…upports the new flush flow-runs

The tool passes --days-to-keep and --batch-size, which this change removes;
opsmill/infrahub-backup#179 makes it use the task manager's retention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…production cleanup

Each VACUUM FULL of the rewrite now follows the limits of the manual
cleanup of the event tables of OpsMill's production task manager:

- lock_timeout of 3 s, retried up to 4 times 20 s apart, instead of 60 s
  with 3 retries: while a rewrite waits for its lock, every later query
  on the table queues behind it;
- statement_timeout of 60 s: a stopped rewrite leaves the table as it
  was, and the job lists it in a new timed_out field that the command
  prints;
- a plain VACUUM (ANALYZE) of each rewritten table;
- before the next table, a pause of ten times as long as the previous
  table was locked, up to 60 s, so Prefect catches up on the writes that
  waited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saltas888
saltas888 force-pushed the task-history-cleanup-infp-507 branch from 77aae1f to 7aefafe Compare October 28, 2026 11:01
claude added 15 commits October 28, 2026 11:23
…pgrade hint

The spec set still gave the old 60 s lock timeout with 3 retries, left out the
timed-out tables, said that other queries never queue behind a rewrite waiting
for its lock, and gave the upgrade hint without the count of old runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
…tgreSQL

On a SQLite task manager database the cleanup has no rewriter, so --rewrite
deletes the old runs and rewrites nothing. The upgrade also prints its task
history hint only on PostgreSQL, so the retention fragment says so too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
…inished

The job recorded the rewrite outcomes only after the last table, so a
cleanup that failed partway through its rewrite named none of the tables
it had already rewritten in autocommit. Each table now joins its list as
soon as its rewrite ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
…its SQLSTATE

The rewrite told a lock timeout from a statement timeout by the asyncpg
exception that SQLAlchemy chains as __cause__, which SQLAlchemy does not
document. It now compares the SQLSTATE in the documented .sqlstate of the
adapter's error, and the backend no longer imports asyncpg directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
…fails

A rewrite that the database rejected, for example because the disk had no
room for the copy, failed the whole cleanup and left the tables after it
untried. The server rolls back only that table's copy, so the table is now
listed as failed and the job goes on; a lost connection or a client error
still fails the job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
…its lock

The advisory lock lived on a session that sat idle for the whole cleanup, so
an idle timeout, a terminated session or a failover could drop it while the
job ran and let another replica start a second cleanup. The session now
checks every 30 s that it still holds the lock, and a job that cannot
confirm it stops its current step and fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
…saction

A cleanup deleted a whole day of runs, with their task runs, logs and
artifacts, in one transaction, which held its locks and blocked vacuum for
as long as the day took, showed no progress within the day and rolled the
whole day back on a failure. Each transaction now deletes at most 1,000
runs of the day, the earliest ended first, with their logs and artifacts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
…eted runs grows

Within a day, a cleanup now commits its deletes in several transactions, so
the count of deleted runs can grow while the day stays the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
…nother session

Only the runs themselves were selected with SKIP LOCKED; their task runs,
states, logs, artifacts and subflows could keep a delete waiting until the
24 h maintenance timeout, holding the cleanup lock all along. Each delete
transaction now waits at most 60 s for a lock, and the job then fails with
an error that names the day. The comment on the maintenance timeout now
gives the reason for its 24 h value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
…tch keeps old task runs

With PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED set to a value without
flow_runs, Prefect deletes no old task runs and the task history retention is
not applied, yet the command still deleted them and the upgrade hint still
advised it. The task manager now refuses the cleanup with the reason, the
command prints it and exits 1, and the hint says why old runs stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
An answer to the start or to a read of the cleanup that held no cleanup,
such as a sign-in page or a state from another release, ended the command
with a pydantic traceback. The client now raises its own error with the
status and each validation error, which the command prints on one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
… cleanup is followed

Behind an ingress or a service mesh, a restarting task manager answers 502,
503 or 504 instead of failing the connection, and the task manager answers
503 while its database starts, so the command failed where it would have
waited for a transport error. Once the task manager has answered, these
answers now lead to the same wait and new start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
…mmary

When the task manager lost the cleanup the command followed, for example in
a restart, the command started a new one and reported only that one's
count, so the progress fell and the summary left out the runs already
deleted. The client now adds the last-seen count of each lost cleanup, and
the summary says 'at least' when it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
The data model said that only a lost connection or a client error fails the
job during the rewrite, but a job that cannot confirm its cleanup lock, or a
server error outside a table's rewrite, fails it too. The free-disk edge case
now also says what happens when the write-ahead log has no room either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
… answer

The check of the cleanup lock was bounded by cancelling its query, and
asyncpg waits without a limit for the server's answer to a cancelled query.
A lock session that stopped answering, through a stopped server or a lost
network, therefore kept the job running, and another replica could start a
second cleanup once the server ended that session. A check without an
answer within the interval now closes the lock's connection at once, so the
job stops and fails. Releasing the lock waits for a check in progress
instead of cancelling it.

The integration test of a lost lock now checks every 3 s instead of every
second, so a slow answer on a busy runner is not taken for a lost lock, and
a new one silences the lock's connection through a proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwJNFw8rccyxc4Wp9eZFbH
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

group/backend API server, GraphQL, task worker (Prefect), database, Python code type/feature New feature or request type/spec A specification for an upcoming change to the project

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants