Add age-based cleanup for uploaded files#188
Conversation
New FILE_RETENTION_DAYS env var (default 0 = disabled) enables periodic deletion of uploaded files older than N days. Cleanup runs once every 24 hours as a background task. File age is determined from the YYYYMMDD_HHMMSS timestamp prefix that _save_upload() prepends to every filename. Files without a recognized prefix are never deleted. Empty per-user directories are removed after cleanup. The feature is opt-in with a conservative default so existing deployments are unaffected. Fixes #151
Review by KaiOverall: Clean PR with two findings worth addressing. Warning
file_retention_days=int(os.environ.get("FILE_RETENTION_DAYS", "0")),
raw = os.environ.get("FILE_RETENTION_DAYS", "0")
try:
file_retention_days = int(raw)
except ValueError:
raise ValueError(f"FILE_RETENTION_DAYS must be an integer, got: {raw!r}")SuggestionCleanup task is never explicitly cancelled on shutdown ( cleanup_task = asyncio.create_task(_file_cleanup_loop(...))
app.bot_data["cleanup_task"] = cleanup_taskThe comment says "task self-cancels on loop shutdown" — that's not accurate. asyncio doesn't automatically cancel tasks on loop close; it just destroys them with a async def _on_stop(app):
task = app.bot_data.get("cleanup_task")
if task:
task.cancel()
await asyncio.gather(task, return_exceptions=True)Clean
|
Non-integer values now produce a clear SystemExit message instead of an unguarded ValueError traceback. Matches the existing validation pattern for WEBHOOK_PORT, CLAUDE_IDLE_TIMEOUT, etc.
Review by KaiOverall: Prior warning resolved. One new minor finding. Resolved from prior reviewThe SuggestionUnhandled exception from for path in files_dir.rglob("*"):
Wrapping the cleanup body in a broad while True:
if not files_dir.is_dir():
await asyncio.sleep(_CLEANUP_INTERVAL)
continue
try:
# ... cutoff / rglob / rmdir block ...
except Exception:
logging.exception("File cleanup error (will retry in %ds)", _CLEANUP_INTERVAL)
await asyncio.sleep(_CLEANUP_INTERVAL)Clean
|
…rrors An unhandled PermissionError from rglob or iterdir would kill the cleanup task permanently. Now logs the error and retries next cycle, matching the webhook health monitor pattern.
Review by KaiReviewBoth prior findings have been addressed: the One new finding: SuggestionThe The second review's specific concern was that a A test along these lines would close the gap: @pytest.mark.asyncio
async def test_rglob_exception_does_not_kill_loop(self, tmp_path, monkeypatch):
"""PermissionError from rglob is logged and the loop continues."""
monkeypatch.setattr("kai.main.DATA_DIR", tmp_path)
monkeypatch.setattr("kai.main._CLEANUP_STARTUP_DELAY", 0)
monkeypatch.setattr("kai.main._CLEANUP_INTERVAL", 0)
(tmp_path / "files").mkdir()
call_count = 0
async def mock_sleep(duration):
nonlocal call_count
call_count += 1
if call_count > 2:
raise asyncio.CancelledError
with (
patch("kai.main.asyncio.sleep", side_effect=mock_sleep),
patch("pathlib.Path.rglob", side_effect=PermissionError("denied")),
patch("kai.main.logging.exception") as mock_log,
):
try:
await _file_cleanup_loop(30)
except asyncio.CancelledError:
pass
# Loop ran twice (not terminated after first exception)
assert call_count == 3
mock_log.assert_called()Clean
|
Verifies that a PermissionError from rglob does not kill the cleanup task. The loop runs twice (call_count == 3 including startup delay), proving the exception was caught and the loop continued.
Review by KaiReviewOverall: Clean PR. One new finding, one compatibility note. Suggestion
The test correctly verifies the loop survives ( Fix: add Warning (verify)
Clean
|
Review by KaiReviewAll four prior findings have been addressed. The PR is clean — no new bugs, security issues, or style violations. Verified clean:
The two open items from prior reviews (task cancellation on shutdown, This PR is ready to merge. |
Summary
Add age-based cleanup for uploaded files. New
FILE_RETENTION_DAYSenv var (default 0 = disabled) enables periodic deletion of files older than N days. Cleanup runs once every 24 hours as a background task.How it works
YYYYMMDD_HHMMSStimestamp prefix that_save_upload()prepends to every filenameConfiguration
Changes
src/kai/config.pyfile_retention_daysfield, parsed fromFILE_RETENTION_DAYSsrc/kai/main.py_file_age()timestamp parser,_file_cleanup_loop()background task.env.exampleFILE_RETENTION_DAYSentry with commentTest plan
test_parses_valid_timestamp- extracts datetime from filename prefixtest_returns_none_for_no_prefix- non-timestamped files return Nonetest_returns_none_for_malformed_timestamp- invalid dates return Nonetest_returns_none_for_partial_match- incomplete patterns return Nonetest_deletes_old_files- files past retention cutoff are deletedtest_preserves_files_without_timestamp- non-timestamped files survivetest_removes_empty_user_directories- empty per-user dirs cleaned uptest_leaves_nonempty_user_directories- non-empty dirs left intacttest_handles_missing_files_directory- no crash when files/ doesn't existtest_handles_unlink_oserror- permission errors counted, not propagatedmake checkcleanFixes #151