Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions backend/src/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import uuid
import warnings
from asyncio import Queue, Task
from collections import OrderedDict
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
Expand Down Expand Up @@ -44,11 +45,25 @@

# Per-session progress queues - keyed by session_id, cleaned up after analysis completes
_progress_queues: dict[str, asyncio.Queue[dict[str, object]]] = {}
_report_registry: dict[str, Analyzer] = {}

# Completed reports, keyed by report_id, used by the drilldown routes.
# Each stored Analyzer holds the full parsed-pcap pandas frames, so the registry
# is bounded and evicts the least-recently-used report once the cap is reached.
# Ordered oldest-to-newest; reads move the entry to the end (see _require_analyzer).
_report_registry: OrderedDict[str, Analyzer] = OrderedDict()
_executor = ThreadPoolExecutor(max_workers=4)

# Constants
PCAP_CHUNK_SIZE = 1024 * 1024 # 1MB
MAX_STORED_REPORTS = 32 # Cap on retained reports before the oldest is evicted.


def _store_analyzer(report_id: str, analyzer: Analyzer) -> None:
"""Register an analyzer for later drilldown, evicting the oldest if over cap."""
_report_registry[report_id] = analyzer
_report_registry.move_to_end(report_id)
while len(_report_registry) > MAX_STORED_REPORTS:
_report_registry.popitem(last=False)


def sanitize_for_json(obj: JSONValue) -> JSONValue:
Expand Down Expand Up @@ -129,7 +144,7 @@ def run_analysis(
)

report_id = str(uuid.uuid4())
_report_registry[report_id] = analyzer
_store_analyzer(report_id, analyzer)

_emit(session_id, "report", 80, "Generating report...")
report = Report(analyzer, report_id=report_id)
Expand Down Expand Up @@ -292,6 +307,8 @@ def _require_analyzer(report_id: str) -> Analyzer:
analyzer = _report_registry.get(report_id)
if analyzer is None:
raise HTTPException(status_code=404, detail=f"Report not found: {report_id}")
# Mark this report as recently used so active drilldowns are not evicted first.
_report_registry.move_to_end(report_id)
return analyzer


Expand Down
42 changes: 42 additions & 0 deletions backend/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@
# or if the src/app directory is added to PYTHONPATH.
# If not, we might need to adjust sys.path or use a different import strategy.
from src.app.main import (
MAX_STORED_REPORTS,
_emit,
_progress_queues,
_report_registry,
_require_analyzer,
_store_analyzer,
app,
logger,
run_analysis,
Expand All @@ -39,6 +43,44 @@ def clear_progress_queues():
_progress_queues.clear()


@pytest.fixture
def clear_report_registry():
"""Clear the report registry around a test case."""
_report_registry.clear()
yield
_report_registry.clear()


def test_store_analyzer_bounds_registry(clear_report_registry):
"""Keep the report registry capped when more reports are stored than the limit."""
total = MAX_STORED_REPORTS + 10
for i in range(total):
_store_analyzer(f"report-{i}", Mock())

# Without a bound the registry would hold every insert; it must stay capped.
assert len(_report_registry) == MAX_STORED_REPORTS

# The oldest reports are evicted first; only the most recent survive.
assert "report-0" not in _report_registry
assert f"report-{total - 1}" in _report_registry


def test_require_analyzer_keeps_recently_used(clear_report_registry):
"""Evict the least-recently-used report rather than a recently accessed one."""
for i in range(MAX_STORED_REPORTS):
_store_analyzer(f"report-{i}", Mock())

# Touch the oldest report so it counts as recently used.
_require_analyzer("report-0")

# One more insert should evict report-1 (now the oldest) and spare report-0.
_store_analyzer("report-new", Mock())

assert len(_report_registry) == MAX_STORED_REPORTS
assert "report-0" in _report_registry
assert "report-1" not in _report_registry


@pytest.mark.asyncio
async def test_emit_success():
"""Enqueue a progress event for an active session."""
Expand Down