Skip to content

feat(admin): configurable retention policy for retained files-import archives - #1001

Open
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1785287630-import-archive-retention
Open

feat(admin): configurable retention policy for retained files-import archives#1001
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1785287630-import-archive-retention

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Summary

Closes #839 (follow-up to PR #838). Retained filesystem-import archives could grow unbounded on the /data volume; this adds an opt-in, env-driven retention policy.

  • New settings (both default 0 = retain indefinitely, preserving current behavior):
    • FILES_IMPORT_ARCHIVE_RETENTION_COUNT — keep only the newest N distinct archives
    • FILES_IMPORT_ARCHIVE_RETENTION_DAYS — delete archives older than N days
  • enforce_files_import_archive_retention(session) in admin_ops.py:
    • walks list_files_import_archives (newest first), dedupes rerun tasks sharing one input_path, deletes anything over-count or over-age
    • deletions go through the existing safe delete_files_import_archive, so archives referenced by an active files import are never removed (RuntimeError → keep and continue)
  • Enforcement runs after each successful run_files_import (appends a "Retention policy removed N older retained archive(s)." log line) and once at backend startup (best-effort, so age-based limits apply without a new import).
  • Visibility: GET /api/admin/tasks/files-import/archive-retention returns {retention_count, retention_days}; the admin UI shows an info alert next to the cumulative-usage summary whenever a non-zero policy is active.
  • Docs: docs/admin-import-export.md (new "Automatic retention policy" section) and .agents/skills/hriv-admin-operations/SKILL.md updated additively.

Testing

  • Backend: poetry run pytest — 934 passed (new unit tests for policy defaults, count/age enforcement, rerun dedupe, active-archive skip, and the new endpoint).
  • Frontend: npm test — 1323 passed (AdminPage tests for the retention alert shown/hidden).
  • npm run lint reports only the pre-existing CanvasOverlay.tsx react-refresh error; npm run format:check and npm run build pass.

Part of backlog Tier 1 (knowledge-note prioritized order).

Link to Devin session: https://app.devin.ai/sessions/414754bd88374dc693f47100ac269e78

…archives

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration
devin-ai-integration Bot requested a review from kphunter as a code owner July 29, 2026 01:25
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

Open in Devin Review

Comment thread backend/app/admin_ops.py
Comment thread backend/app/admin_ops.py
Comment on lines +698 to +704
try:
await delete_files_import_archive(session, archive_task_id)
except (LookupError, FileNotFoundError, ValueError, RuntimeError, OSError):
# In use by an active import, already gone, or not deletable —
# keep it and move on.
kept += 1
continue

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Undeletable (active/in-use) over-count archives still consume a keep slot

When delete_files_import_archive raises (e.g. RuntimeError because the archive is referenced by an active import), the loop does kept += 1 (backend/app/admin_ops.py:700-704). This means an archive that could not be deleted counts toward kept, which is a conservative choice: it avoids deleting an extra deletable archive to compensate, so the effective retained count can temporarily exceed retention_count when some archives are locked. This is reasonable behavior and matches test_enforce_retention_keeps_archives_in_active_use, but reviewers should be aware that the count limit is a soft bound under active-use contention.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread backend/app/routers/admin.py
Comment thread backend/app/main.py
Comment on lines +184 to +198
# Apply the retained files-import archive retention policy so age-based
# limits take effect even when no new import runs (no-op unless the
# FILES_IMPORT_ARCHIVE_RETENTION_* settings opt in).
try:
async with get_async_session()() as session:
await enforce_files_import_archive_retention(session)
except Exception as exc: # pragma: no cover - best effort on startup
logger.warning(
"Files-import archive retention enforcement failed: %s",
exc,
extra={
"event": "admin_task.archive_retention_failed",
"error": str(exc),
},
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Startup retention enforcement is ordered after stale-task reconciliation, preserving active-import protection

In backend/app/main.py:184-198 the startup retention pass runs after reconcile_stale_tasks (line 172-176). This ordering matters: delete_files_import_archive refuses to delete archives referenced by tasks in ACTIVE_TASK_STATUSES, and a genuinely-active sibling-pod import keeps its task active (freshness on updated_at), so startup pruning will skip it via the RuntimeError→keep path. Multi-replica races where two pods delete the same file are absorbed by the FileNotFoundError/OSError branch. This all holds up; noting it because the safety of startup pruning depends on these interactions rather than being locally obvious.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…e semantics

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review

Comment thread backend/app/admin_ops.py
Comment on lines +680 to +696
for archive in await list_files_import_archives(session):
archive_task_id = archive["archive_task_id"]
assert isinstance(archive_task_id, int)
task = await session.get(AdminTask, archive_task_id)
if task is None or not task.input_path or task.input_path in seen_paths:
continue
seen_paths.add(task.input_path)

over_count = count_limit > 0 and kept >= count_limit
created_at = archive["created_at"]
assert isinstance(created_at, datetime)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
too_old = cutoff is not None and created_at < cutoff
if not (over_count or too_old):
kept += 1
continue

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Count-based retention can evict a genuinely newer archive when an older one is rerun

enforce_files_import_archive_retention measures recency by AdminTask.id (list_files_import_archives orders by id.desc()), and dedupes reruns to the highest-id task per input_path (backend/app/admin_ops.py:680-686). This means re-running an old retained archive creates a new higher-id task that bumps that archive to the 'newest' slot, so under a count-based policy a genuinely newer distinct archive can be deleted in favor of the reused-but-older file. This matches the documented intent ('an archive that is actively being reused is treated as fresh', docs/admin-import-export.md:205-210), so it is not a bug, but operators should be aware that reruns influence count-based eviction ordering, not just age.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread backend/app/admin_ops.py
Comment on lines +698 to +705
try:
await delete_files_import_archive(session, archive_task_id)
except (LookupError, FileNotFoundError, ValueError, RuntimeError, OSError):
# In use by an active import, already gone, or not deletable —
# keep it and move on.
kept += 1
continue
deleted.append(archive_task_id)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Retention deletion leaves stale input_path on the task row

delete_files_import_archive only os.unlinks the file and never clears AdminTask.input_path (backend/app/admin_ops.py:665-670). After retention prunes an archive, the task row still points at the now-deleted file. This is safe because list_files_import_archives re-validates each path via _validate_retained_files_import_archive_path and skips missing files, so pruned archives will not reappear or be re-deleted on subsequent runs. Noted only because it is pre-existing behavior now exercised automatically rather than solely on manual delete.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread backend/app/admin_ops.py
Comment on lines +2568 to +2581
try:
pruned = await enforce_files_import_archive_retention(session)
if pruned:
await _update_task(
session, task,
log_line=(
"Retention policy removed "
f"{len(pruned)} older retained archive(s)."
),
)
except Exception:
logger.exception(
"Retention enforcement after files import failed",
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Retention runs on the import task's own session and is fully guarded

In run_files_import, retention enforcement is invoked with the same session used to mark the task completed, wrapped in its own try/except that logs and swallows any exception (backend/app/admin_ops.py:2568-2581). This correctly prevents a retention failure from flipping a successful import to failed. delete_files_import_archive performs only reads plus os.unlink (no uncommitted DB writes), and the only commit in the retention path is the optional log-line _update_task, so a no-op or failed enforcement leaves the session in a clean/read-only state before the context manager closes.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(admin): configurable retention policy for retained filesystem-import archives

0 participants