run_update backs up every database before it downloads anything:
# BACKUPS FIRST -- before any download, before any install: if anything after
# this point half-happens, the databases' pre-update state exists on disk.
The instinct is right and the ordering is not. On 2026-09-01 a transient stall on the first of five wheels threw away work that had already completed:
backed up keel-live.db -> keel-live.db.bak-before-0.13.2-20260901-083521
backed up keel-paperhourly.db -> keel-paperhourly.db.bak-before-0.13.2-20260901-083521
backed up keel.db -> keel.db.bak-before-0.13.2-20260901-083521
downloading keel_core-0.13.2-py3-none-any.whl
update did not complete: ... The read operation timed out
That is ~466 MB of sqlite3.backup() (156 + 131 + 180 MB) spent before the cheapest, most failure-prone step ran. Every retry pays it again, and the backups are timestamped and never deleted by design — so each failed attempt leaves another full set behind. ~/keel currently holds 7.0 GB of them, going back to 0.9.1.
Why moving downloads first keeps the guarantee
The comment's guarantee is "if anything after this point half-happens, the databases' pre-update state exists on disk", and downloads cannot make anything half-happen to a database: _download_file writes only into Release/. The only steps that touch a database are _uv_install (replaces the running binary) and _migrate_db, and both would still run after the backups.
So the ordering becomes:
- read the superseded wheel set (must stay first — it globs
Release/*.whl before this release's wheels land)
- download all five wheels
- back up every database
- install → migrate → verify
A failed download then costs nothing: no backup taken, no disk consumed, nothing to clean up but the partial wheel the existing handler already removes.
Watch out for
UpdateResult.backups is returned by the download-failure handler today. After the move it is empty there, and that is the point — but it must be () and not stale.
tests/commands/test_update.py's _FakeOps.install asserts len(baks) >= 2 before any install. That pin is about install, not download, and must keep passing — it is the guarantee that actually matters.
- Its
download() records the backups present at download time into events, but nothing asserts on them. After this change that tuple is empty; either assert the new meaning or stop recording it.
Acceptance
Related
The sibling issue adding retries to _download_file.
run_updatebacks up every database before it downloads anything:The instinct is right and the ordering is not. On 2026-09-01 a transient stall on the first of five wheels threw away work that had already completed:
That is ~466 MB of
sqlite3.backup()(156 + 131 + 180 MB) spent before the cheapest, most failure-prone step ran. Every retry pays it again, and the backups are timestamped and never deleted by design — so each failed attempt leaves another full set behind.~/keelcurrently holds 7.0 GB of them, going back to 0.9.1.Why moving downloads first keeps the guarantee
The comment's guarantee is "if anything after this point half-happens, the databases' pre-update state exists on disk", and downloads cannot make anything half-happen to a database:
_download_filewrites only intoRelease/. The only steps that touch a database are_uv_install(replaces the running binary) and_migrate_db, and both would still run after the backups.So the ordering becomes:
Release/*.whlbefore this release's wheels land)A failed download then costs nothing: no backup taken, no disk consumed, nothing to clean up but the partial wheel the existing handler already removes.
Watch out for
UpdateResult.backupsis returned by the download-failure handler today. After the move it is empty there, and that is the point — but it must be()and not stale.tests/commands/test_update.py's_FakeOps.installassertslen(baks) >= 2before any install. That pin is about install, not download, and must keep passing — it is the guarantee that actually matters.download()records the backups present at download time intoevents, but nothing asserts on them. After this change that tuple is empty; either assert the new meaning or stop recording it.Acceptance
.bak-before-*file on disk, pinned by a test.Related
The sibling issue adding retries to
_download_file.