-
Notifications
You must be signed in to change notification settings - Fork 36
[Backup] Log backup rotation failures instead of silently swallowing them #305
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import logging | ||
| import os | ||
| import pytest | ||
| from termstory.backup import backup_db, restore_db | ||
|
|
@@ -84,3 +85,46 @@ def now(cls): | |
| import glob | ||
| remaining = glob.glob(os.path.join(backup_dir, "termstory_backup_*.db")) | ||
| assert len(remaining) == 10 | ||
|
|
||
|
|
||
| def test_backup_survives_rotation_failure(tmp_path, monkeypatch, caplog): | ||
| db_file = tmp_path / "test_rotation_failure.db" | ||
| db_path = str(db_file) | ||
| monkeypatch.setenv("DB_PATH", db_path) | ||
| monkeypatch.setattr("termstory.config.get_db_path", lambda: db_path) | ||
| monkeypatch.setattr("termstory.backup.get_db_path", lambda: db_path) | ||
|
|
||
| db = Database(db_path) | ||
| db.init_db() | ||
|
|
||
| class MockDatetime: | ||
| counter = 0 | ||
| @classmethod | ||
| def now(cls): | ||
| cls.counter += 1 | ||
| from datetime import datetime as dt | ||
| return dt(2026, 6, 18, 19, 0, cls.counter) | ||
|
|
||
| monkeypatch.setattr("termstory.backup.datetime", MockDatetime) | ||
|
|
||
| # Fill past the rotation threshold so cleanup runs and hits the failing os.remove. | ||
| for _ in range(11): | ||
| backup_db() | ||
|
|
||
| def boom(_path): | ||
| raise PermissionError("cannot delete backup") | ||
|
|
||
| monkeypatch.setattr("termstory.backup.os.remove", boom) | ||
|
|
||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional stronger assertion: After the failing rotation, also check that cleanup did not complete, so the test isn’t only checking that a new backup file exists: from termstory.backup import _get_backup_dir
import glob
remaining = glob.glob(os.path.join(_get_backup_dir(), "termstory_backup_*.db"))
assert len(remaining) > 10 # rotation did not finish deletingNice-to-have, not blocking. |
||
| # Rotation now fails, but the backup itself must still succeed and return a path. | ||
| with caplog.at_level(logging.ERROR, logger="termstory.backup"): | ||
| backup_path = backup_db() | ||
|
|
||
| assert os.path.isfile(backup_path) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Assert that the failure is actually logged. Right now this only proves the backup still returns a path — a regression back to Also, the PR body currently claims this test verifies errors are “caught and logged”; that claim is only true if we lock logging in here. import logging
# ... setup / MockDatetime / 11 seed backups / monkeypatch os.remove ...
with caplog.at_level(logging.ERROR, logger="termstory.backup"):
backup_path = backup_db()
assert os.path.isfile(backup_path)
assert any(
"Failed to rotate old backups" in r.message
for r in caplog.records
)(Add |
||
|
|
||
| # The rotation failure must be logged, not silently swallowed. A regression | ||
| # back to `except OSError: pass` would drop this record and fail here. | ||
| assert any( | ||
| "Failed to rotate old backups" in record.message | ||
| for record in caplog.records | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good.
OSErrorcorrectly coversPermissionError/FileNotFoundError/ common FS failures, andlogger.exceptionsurfaces a traceback without failing the backup.One intentional behavior change to be aware of: non-
OSErrorexceptions in this block (e.g. unexpected bugs) now propagate instead of being swallowed. That’s the right tradeoff — just noting it so reviewers don’t treat this as a pure observability-only diff.