Skip to content

keel update spends ~466 MB of database backups before the step most likely to fail #676

Description

@eaitbrahim

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:

  1. read the superseded wheel set (must stay first — it globs Release/*.whl before this release's wheels land)
  2. download all five wheels
  3. back up every database
  4. 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

  • A failed download leaves no .bak-before-* file on disk, pinned by a test.
  • Backups still exist and open as consistent SQLite snapshots before anything is installed — the existing pin unchanged.
  • The reordering's argument is written where the old comment was, not just in this issue.
  • Mutation-verified.

Related

The sibling issue adding retries to _download_file.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions