Ctrl K
ComunidadGuías…

tests/test_log_retention.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Split retention: logs and job rows prune on independent clocks (2026-07-12).

Measured on the live broker: 2,875 job rows = 6.4 MB, but 2,605 log files =
2.0 GB (~0.7 GB/month). A row is ~2 KB and feeds the ETA estimator's per-project
p50/p90 — cheap, and more useful with age. A log is ~800 KB and is
write-once-read-maybe. One shared clock forced a false choice between keeping
8 GB/yr of logs and discarding the cheap history the estimator runs on, which is
exactly why retention was left off. Two clocks remove the choice.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta

from fastapi.testclient import TestClient
from sqlalchemy import update

from jobd.broker.sweeper import prune_old_logs
from jobd.db import Job


def _finished_job(app, client, *, age_days: int, log_text: str = "hello") -> int:
    jid = client.post(
        "/submit", json={"cmd": ["true"], "cwd": "/tmp", "project": "project-a"}
    ).json()["id"]
    (app.state.shared["logs_dir"] / f"{jid}.log").write_text(log_text)
    with app.state.engine.begin() as conn:
        conn.execute(
            update(Job)
            .where(Job.id == jid)
            .values(
                state="completed",
                exit_code=0,
                finished_at=datetime.now(UTC).replace(tzinfo=None) - timedelta(days=age_days),
            )
        )
    return jid


def test_old_log_is_unlinked_but_the_row_survives(app, monkeypatch):
    """The whole point: reclaim the 2 GB of logs, keep the 6 MB of history."""
    monkeypatch.setenv("JOBD_LOG_RETENTION_DAYS", "60")
    client = TestClient(app)
    logs_dir = app.state.shared["logs_dir"]
    old = _finished_job(app, client, age_days=90)

    assert prune_old_logs(app.state.SessionLocal, logs_dir) == 1

    assert not (logs_dir / f"{old}.log").exists()  # log gone
    got = client.get(f"/jobs/{old}")
    assert got.status_code == 200  # row survives
    assert got.json()["state"] == "completed"  # ...with its history intact


def test_recent_log_is_kept(app, monkeypatch):
    monkeypatch.setenv("JOBD_LOG_RETENTION_DAYS", "60")
    client = TestClient(app)
    logs_dir = app.state.shared["logs_dir"]
    recent = _finished_job(app, client, age_days=5)

    assert prune_old_logs(app.state.SessionLocal, logs_dir) == 0
    assert (logs_dir / f"{recent}.log").exists()


def test_zero_disables_log_retention(app, monkeypatch):
    monkeypatch.setenv("JOBD_LOG_RETENTION_DAYS", "0")
    client = TestClient(app)
    logs_dir = app.state.shared["logs_dir"]
    old = _finished_job(app, client, age_days=999)

    assert prune_old_logs(app.state.SessionLocal, logs_dir) == 0
    assert (logs_dir / f"{old}.log").exists()


def test_pruned_job_is_not_rescanned(app, monkeypatch):
    """log_pruned_at makes the scan shrink monotonically — otherwise every sweep
    would re-stat every historical log forever."""
    monkeypatch.setenv("JOBD_LOG_RETENTION_DAYS", "60")
    client = TestClient(app)
    logs_dir = app.state.shared["logs_dir"]
    _finished_job(app, client, age_days=90)

    assert prune_old_logs(app.state.SessionLocal, logs_dir) == 1
    # Second pass finds no candidates at all (not merely "nothing to unlink").
    assert prune_old_logs(app.state.SessionLocal, logs_dir) == 0


def test_output_reports_pruned_not_empty(app, monkeypatch):
    """A pruned log and a never-written one both leave no file — but reporting a
    pruned log as empty output makes a job that emitted megabytes look like it
    produced nothing."""
    monkeypatch.setenv("JOBD_LOG_RETENTION_DAYS", "60")
    client = TestClient(app)
    logs_dir = app.state.shared["logs_dir"]

    pruned = _finished_job(app, client, age_days=90, log_text="lots of output")
    never = _finished_job(app, client, age_days=1)
    (logs_dir / f"{never}.log").unlink()  # worker never captured anything

    prune_old_logs(app.state.SessionLocal, logs_dir)

    a = client.get(f"/jobs/{pruned}/output").json()
    assert a["size_bytes"] == 0 and a["pruned"] is True and a["pruned_at"]

    b = client.get(f"/jobs/{never}/output").json()
    assert b["size_bytes"] == 0 and b["pruned"] is False


def test_row_retention_stays_off_by_default(app, monkeypatch):
    """Rows are ~7 MB/yr and feed the estimator, so JOBD_JOB_RETENTION_DAYS
    remains opt-in even though logs now prune by default."""
    monkeypatch.delenv("JOBD_JOB_RETENTION_DAYS", raising=False)
    monkeypatch.setenv("JOBD_LOG_RETENTION_DAYS", "60")
    client = TestClient(app)
    old = _finished_job(app, client, age_days=999)

    app.state.sweep_once()

    assert client.get(f"/jobs/{old}").status_code == 200  # row kept
    assert not (app.state.shared["logs_dir"] / f"{old}.log").exists()  # log pruned