perf(routes): cut evaluator scan cost via window/retention/index/sweep

The route evaluator's fetch_candidate_paths triggered a 10-30s full-table
scan of packet_path_hops per route per sweep, flooding the slow-query
log and blocking route saves on the same scan. EXPLAIN analysis (Merge
Join vs forced Nested Loop, across query rewrites, ANALYZE, CTE
MATERIALIZED) confirmed both candidate plans cost ~10-15s at 6.4M rows;
no SQL rewrite or stats change could avoid the scan. The fix applies
four orthogonal data-volume pressures:

- ROUTE_EVALUATOR_INTERVAL_SECONDS default 60->300 (5x less frequent)
- Remove synchronous _reevaluate_route on POST/PUT; route_result and
  recent_matches now refresh on the next background sweep instead of
  inline (kills save latency)
- Route.window_hours default 48->6, max 12 (bounds the candidate set);
  migration clamps pre-existing routes >12 to 12
- Covering index ix_packet_path_hops_raw_packet_id_position INCLUDE
  (node_hash, packet_hash, event_hash, received_at, observer_node_id)
  so the Merge Join outer scan becomes index-only
- RAW_PACKET_RETENTION_DAYS default 7->2 (hops cascade-delete with raw
  packets; smaller table cuts the dominant outer scan)

Migration a59611449e2a clamps window_hours and rebuilds the index
CONCURRENTLY on PostgreSQL (SQLite is a no-op; no INCLUDE support).

DESTRUCTIVE on upgrade: the retention default change purges ~5 days of
raw packets + cascaded hops on the next cleanup run. See
docs/upgrading.md for the full change notes and the new save/sweep
contract.
This commit is contained in:
Louis King committed 2026-07-25 13:15:51 +01:00
1 parent 41dd92e6e7
commit c9b247c553
21 files changed
+273 -145

No files matched your search

+7 -5
View File
@@ -273,11 +273,13 @@ DATA_RETENTION_INTERVAL_HOURS=24
# setting FEATURE_PACKETS=true enables both capture and the Packets page.
# RAW_PACKET_CAPTURE_ENABLED=false
# Days to retain raw packets before cleanup. Defaults to 7, independent of
# Days to retain raw packets before cleanup. Defaults to 2, independent of
# DATA_RETENTION_DAYS. The raw_packets table grows fastest of all; lower this on
# busy meshes or constrained storage. Retention runs regardless of capture being
# enabled, so disabling capture lets existing rows drain.
# RAW_PACKET_RETENTION_DAYS=7
# enabled, so disabling capture lets existing rows drain. Bounds the
# packet_path_hops table size (hops cascade-delete with their raw packet) and
# therefore the cost of the route evaluator's full-scan query.
# RAW_PACKET_RETENTION_DAYS=2
# -------------------------
# Observer Ingestion Filter
@@ -617,11 +619,11 @@ SYSTEM_MAINTENANCE=false
# FEATURE_SPAM_DETECTION=true
# Routes page (route health monitoring) is ON by default.
# FEATURE_ROUTES=true
# Route evaluator interval in seconds (0 disables, default 60). Each tick
# Route evaluator interval in seconds (0 disables, default 300). Each tick
# writes the current snapshot to route_results, upserts today's per-day
# bucket into route_result_history, refreshes the rolling quality_avg
# column, and rewrites recent_matches_json.
# ROUTE_EVALUATOR_INTERVAL_SECONDS=60
# ROUTE_EVALUATOR_INTERVAL_SECONDS=300
# Slower backfill sweep (seconds, 0 disables, default 3600) that recomputes
# the full raw_packet_retention_days window into route_result_history so
# late-arriving packets and route-config changes propagate into historical
@@ -0,0 +1,103 @@
"""route evaluator performance remediation
Revision ID: a59611449e2a
Revises: da69304d8106
Create Date: 2026-07-25 12:00:00.000000+00:00
Two performance-focused changes backing the route-evaluator remediation:
1. **Clamp ``routes.window_hours`` to the new maximum (12).** The schema
validation now caps the evaluation window at 12h (default 6h) so the
candidate set fed to ``fetch_candidate_paths`` stays bounded. Any
pre-existing route configured above 12h is clamped down to 12h to
remain valid under the new constraint. The clamp is one-way: original
values are not recoverable on downgrade. Clamped routes are printed to
stdout during upgrade as an audit trail.
2. **Covering index on ``packet_path_hops``.** Recreate
``ix_packet_path_hops_raw_packet_id_position`` with INCLUDE columns
(``node_hash``, ``packet_hash``, ``event_hash``, ``received_at``,
``observer_node_id``) on PostgreSQL so the route evaluator's outer
Merge Join scan becomes an index-only scan instead of heap-fetching
every row. SQLite does not support INCLUDE; its existing plain index
is left untouched (SQLite is dev/test-scale only).
Note: the index rebuild runs CONCURRENTLY on PostgreSQL (no write
blocking) and therefore uses an autocommit block.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a59611449e2a"
down_revision: Union[str, None] = "da69304d8106"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[Sequence[str], None] = None
_WINDOW_HOURS_MAX = 12
_INDEX_NAME = "ix_packet_path_hops_raw_packet_id_position"
_TABLE = "packet_path_hops"
_INCLUDE_COLS = [
"node_hash",
"packet_hash",
"event_hash",
"received_at",
"observer_node_id",
]
def upgrade() -> None:
bind = op.get_bind()
# --- 1. Audit + clamp routes.window_hours to the new max ---
clamped = bind.execute(
sa.text(
"SELECT id, window_hours FROM routes "
"WHERE window_hours > :max ORDER BY window_hours DESC"
).bindparams(max=_WINDOW_HOURS_MAX)
).fetchall()
if clamped:
print(
f" [route perf] clamping {len(clamped)} route(s) "
f"with window_hours > {_WINDOW_HOURS_MAX} -> {_WINDOW_HOURS_MAX}:"
)
for row in clamped:
print(f" {row.id}: {row.window_hours}h -> {_WINDOW_HOURS_MAX}h")
op.execute(
sa.text(
"UPDATE routes SET window_hours = :max WHERE window_hours > :max"
).bindparams(max=_WINDOW_HOURS_MAX)
)
# --- 2. Covering index on packet_path_hops (PostgreSQL only) ---
# SQLite (and other backends) do not support INCLUDE; their existing
# plain index is retained unchanged.
if bind.dialect.name == "postgresql":
with op.get_context().autocommit_block():
op.drop_index(_INDEX_NAME, table_name=_TABLE)
op.create_index(
_INDEX_NAME,
_TABLE,
["raw_packet_id", "position"],
postgresql_include=_INCLUDE_COLS,
postgresql_concurrently=True,
)
def downgrade() -> None:
# Restore the plain (non-covering) index on PostgreSQL. The
# window_hours clamp is one-way: original values are not recoverable.
bind = op.get_bind()
if bind.dialect.name == "postgresql":
with op.get_context().autocommit_block():
op.drop_index(_INDEX_NAME, table_name=_TABLE)
op.create_index(
_INDEX_NAME,
_TABLE,
["raw_packet_id", "position"],
postgresql_concurrently=True,
)
+1 -1
View File
@@ -257,7 +257,7 @@ services:
- SPAM_WEIGHT_NAME=${SPAM_WEIGHT_NAME:-0.25}
- SPAM_RESCORE_INTERVAL_SECONDS=${SPAM_RESCORE_INTERVAL_SECONDS:-120}
# Route health monitoring (evaluator + history backfill cadence; 0 disables each).
- ROUTE_EVALUATOR_INTERVAL_SECONDS=${ROUTE_EVALUATOR_INTERVAL_SECONDS:-60}
- ROUTE_EVALUATOR_INTERVAL_SECONDS=${ROUTE_EVALUATOR_INTERVAL_SECONDS:-300}
- ROUTE_HISTORY_BACKFILL_INTERVAL_SECONDS=${ROUTE_HISTORY_BACKFILL_INTERVAL_SECONDS:-3600}
command: ["collector"]
healthcheck:
+2 -2
View File
@@ -91,7 +91,7 @@ The collector subscribes to MQTT events and persists them to the database. For p
| Variable | Default | Description |
| --- | --- | --- |
| `CHANNEL_REFRESH_INTERVAL_SECONDS` | `300` | Seconds between channel-key refresh from the database (minimum `10`) |
| `ROUTE_EVALUATOR_INTERVAL_SECONDS` | `60` | Seconds between route health evaluations. `0` disables the background evaluator (route cards then stay `unknown`). See [routes.md](routes.md) |
| `ROUTE_EVALUATOR_INTERVAL_SECONDS` | `300` | Seconds between route health evaluations. `0` disables the background evaluator (route cards then stay `unknown`). See [routes.md](routes.md) |
| `ROUTE_HISTORY_BACKFILL_INTERVAL_SECONDS` | `3600` | Seconds between route-health history backfill sweeps (recomputes completed-day buckets for the retention window). `0` disables the backfill; the dashboard strip and `/routes/{id}/history` then only reflect the live 60 s evaluator sweeps. See [routes.md](routes.md) |
### Observer Ingestion Filters
@@ -167,7 +167,7 @@ The collector automatically cleans up old event data and inactive nodes. Retenti
| `NODE_CLEANUP_ENABLED` | `true` | Enable removal of inactive nodes (nodes with `last_seen=NULL` are never removed) |
| `NODE_CLEANUP_DAYS` | `30` | Remove nodes not seen for this many days |
| `RAW_PACKET_CAPTURE_ENABLED` | `false` | Capture raw packets into `raw_packets`. In Compose, derived from `FEATURE_PACKETS` — see [letsmesh.md → Raw Packet Capture](letsmesh.md#raw-packet-capture) |
| `RAW_PACKET_RETENTION_DAYS` | `7` | Days to retain raw packets (independent of `DATA_RETENTION_DAYS`) |
| `RAW_PACKET_RETENTION_DAYS` | `2` | Days to retain raw packets (independent of `DATA_RETENTION_DAYS`). Bounds the `packet_path_hops` table size (hops cascade-delete with their raw packet) and therefore the route evaluator's query cost |
## Spam Detection
+6 -2
View File
@@ -17,7 +17,7 @@ Each route carries these knobs:
| Field | Default | Description |
| --- | --- | --- |
| `match_width` | `1` | Path-hash prefix width in bytes (1/2/3). Higher widths disambiguate nodes that share a short public-key prefix. |
| `window_hours` | `48` | Rolling lookback window for the live status card. |
| `window_hours` | `6` | Rolling lookback window for the live status card (1–12 hours). |
| `packet_count_threshold` | `5` | Distinct matching packets at/above which the route is `healthy`. "Distinct" is per underlying event, not per transmission — see [How health is evaluated](#how-health-is-evaluated) above. |
| `clear_threshold` | _(3× threshold)_ | Comfort bar for the `clear`/`marginal` split. Omit/null to use three times the threshold. |
| `max_hop_span` | `8` | Caps the position gap between the first and last matched node, to reject matches that wander too far. |
@@ -38,7 +38,11 @@ By default every observer's receptions contribute to every route. A route may in
### Background evaluator
The collector runs a background thread that re-evaluates every enabled route on a fixed cadence and upserts the result into `route_results` (one row per route, holding the latest `state`, `quality`, `matched_count`, and the threshold snapshots used). The cadence is controlled by `ROUTE_EVALUATOR_INTERVAL_SECONDS` (default `60`); set it to `0` to disable the evaluator, in which case route cards remain at `unknown` until it is re-enabled. The per-route history strip and the `/api/v1/routes/{id}/history` endpoint evaluate on demand over the raw `packet_path_hops` table and are not dependent on the background evaluator.
The collector runs a background thread that re-evaluates every enabled route on a fixed cadence and upserts the result into `route_results` (one row per route, holding the latest `state`, `quality`, `matched_count`, and the threshold snapshots used). The cadence is controlled by `ROUTE_EVALUATOR_INTERVAL_SECONDS` (default `300`); set it to `0` to disable the evaluator, in which case route cards remain at `unknown` until it is re-enabled. The per-route history strip and the `/api/v1/routes/{id}/history` endpoint evaluate on demand over the raw `packet_path_hops` table and are not dependent on the background evaluator.
Routes are **not** re-evaluated synchronously on create/update — the write handler returns immediately with the route's direct fields and whatever `route_result` snapshot the last sweep wrote (or `null` for a brand-new route). The next sweep (within `ROUTE_EVALUATOR_INTERVAL_SECONDS`) refreshes `state`, `quality`, `matched_count`, `quality_avg`, and the recent-matches card. Keep this in mind when changing `window_hours` / thresholds: the card reflects the new config on the next sweep, not on save.
The route evaluation window (`window_hours`) defaults to **6 hours** and is capped at **12 hours** (`ge=1, le=12`). A tight window bounds the candidate set fed to the route evaluator's `packet_path_hops` scan; raise it only if you accept the additional scan cost, and never above the configured `RAW_PACKET_RETENTION_DAYS` (the window would otherwise extend past purged data and falsely report `no_coverage`).
## Visibility
+17 -1
View File
@@ -2,6 +2,22 @@
This guide covers upgrading from a previous MeshCore Hub release to the current version. Check the relevant version section below before upgrading.
## Unreleased — Route evaluator performance remediation
The background route evaluator was triggering a multi-second `packet_path_hops` scan per route per sweep, flooding the slow-query log and blocking route saves on the same scan. This release reduces load across four orthogonal levers. **Two are destructive / behaviour-changing — read before upgrading.**
**Defaults changed (review before upgrading):**
- `ROUTE_EVALUATOR_INTERVAL_SECONDS`: `60` → **`300`** (5× less frequent sweeps).
- `RAW_PACKET_RETENTION_DAYS`: `7` → **`2`** (⚠️ **destructive** — on the next cleanup run, raw packets and their cascaded `packet_path_hops` rows older than 2 days are permanently purged; 5 days of historical packet/hop data is lost). Raise the env var if you need to keep longer history. Must stay ≥ your largest route `window_hours` (in days) so route windows don't extend past purged data.
- `Route.window_hours`: default `48` → **`6`**, max `720` → **`12`** (⚠️ pre-existing routes with `window_hours > 12` are **clamped to 12** by the migration; clamped route IDs are printed during upgrade).
**Behaviour change — no synchronous re-evaluation on save:** creating or updating a route no longer runs the evaluator inline. The write returns immediately with the route's direct fields and whatever `route_result` snapshot the last sweep wrote (`null` for a brand-new route). `state` / `quality` / `matched_count` / `quality_avg` / the recent-matches card refresh on the next sweep (within `ROUTE_EVALUATOR_INTERVAL_SECONDS`). This keeps saves fast; the trade-off is up-to-5-min staleness after a config change.
**Database migration required** (revision `a59611449e2a`): clamps `routes.window_hours` to 12 and rebuilds `ix_packet_path_hops_raw_packet_id_position` as a covering index (`INCLUDE node_hash, packet_hash, event_hash, received_at, observer_node_id`) on PostgreSQL so the evaluator's outer scan becomes index-only. The index rebuild runs `CONCURRENTLY` (no write blocking). SQLite is unchanged (no `INCLUDE` support). The `window_hours` clamp is one-way (original values not recoverable on downgrade).
**Performance rationale:** EXPLAIN analysis showed both candidate query plans (Merge Join full scan and forced Nested Loop) cost ~10–15 s at 6.4 M `packet_path_hops` rows; no SQL rewrite, stats change, or CTE trick could avoid the scan. The fix is purely data-volume pressure: smaller table (retention), smaller candidate set (window cap), index-only scan (covering index), and less frequent sweeps. See `docs/routes.md` for the updated evaluator contract.
## v0.17.0
### React Web UI
@@ -40,7 +56,7 @@ This creates seven tables — `routes`, `route_nodes`, `route_observers`, `route
| Variable | Default | Description |
| ------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `FEATURE_ROUTES` | `true` | Show the `/routes` page and nav entry. On by default. |
| `ROUTE_EVALUATOR_INTERVAL_SECONDS` | `60` | Collector background evaluator cadence in seconds. `0` disables the evaluator (cards stay `unknown`). |
| `ROUTE_EVALUATOR_INTERVAL_SECONDS` | `300` | Collector background evaluator cadence in seconds. `0` disables the evaluator (cards stay `unknown`). |
| `ROUTE_HISTORY_BACKFILL_INTERVAL_SECONDS` | `3600` | Collector hourly history backfill cadence (recomputes completed-day buckets). `0` disables the backfill; strips reflect only live sweeps. |
### Observer Ingestion Filters (allow/deny remote observers)
+2 -2
View File
@@ -341,7 +341,7 @@ def seed_routes(
description="Synthetic e2e route",
visibility="community",
match_width=2,
window_hours=48,
window_hours=12,
packet_count_threshold=3,
clear_threshold=6,
max_hop_span=8,
@@ -417,7 +417,7 @@ def seed_routes(
description="Operator-owned e2e route",
visibility="operator",
match_width=1,
window_hours=24,
window_hours=12,
packet_count_threshold=3,
clear_threshold=6,
max_hop_span=8,
+2 -2
View File
@@ -48,7 +48,7 @@ test.describe.serial("routes (admin)", () => {
await page.getByTestId("node-search-result").first().click();
await expect(modal.getByText(/North Observer/).first()).toBeVisible();
await page.getByTestId("route-window").fill("72");
await page.getByTestId("route-window").fill("12");
await page.getByTestId("route-threshold").fill("4");
await page.getByTestId("route-clear-threshold").fill("8");
await page.getByTestId("route-max-span").fill("6");
@@ -81,7 +81,7 @@ test.describe.serial("routes (admin)", () => {
await expect(modal.getByText("Alpha Node")).toBeVisible();
await expect(modal.getByText("Bravo Node")).toBeVisible();
await expect(modal.getByText(/North Observer/).first()).toBeVisible();
await expect(page.getByTestId("route-window")).toHaveValue("72");
await expect(page.getByTestId("route-window")).toHaveValue("12");
await expect(page.getByTestId("route-threshold")).toHaveValue("4");
await expect(page.getByTestId("route-clear-threshold")).toHaveValue("8");
await expect(page.getByTestId("route-max-span")).toHaveValue("6");
-49
View File
@@ -18,14 +18,10 @@ from meshcore_hub.api.channel_visibility import (
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.api.profile_utils import get_or_create_profile
from meshcore_hub.collector.routes import (
compute_persisted_quality_avg,
derive_expected_hash,
evaluate_route,
preview_route,
read_route_history_from_db,
recent_matches,
upsert_route_recent_matches,
upsert_route_result,
)
from meshcore_hub.common.config import get_collector_settings
from meshcore_hub.common.models.node import Node
@@ -267,49 +263,6 @@ def _sync_observers(
session.add(RouteObserver(route_id=route.id, node_id=node.id))
def _reevaluate_route(session: DbSession, route: Route) -> None:
"""Synchronously evaluate *route* and persist every derived field.
The background evaluator (collector.route_evaluator) writes
``RouteResult`` on a schedule (default 60s). Without this synchronous
re-eval, the route's ``packet_count_threshold`` / ``clear_threshold``
changes take up to that interval to surface in the UI — the list card
displays ``route_result.threshold`` / ``effective_clear``, not the
route's just-updated direct fields, so it shows the stale snapshot
until the next evaluator cycle. Running the eval inline on every
create/update keeps the post-mutation GET consistent with the new
config at the cost of one bounded DB scan per write.
Refreshes the current snapshot, the persisted top-3 recent matches
(so the detail page is fresh), and the rolling ``quality_avg`` (so
the list/detail badge updates immediately when the snapshot tier
changes).
"""
if not route.enabled:
return
now = datetime.now(timezone.utc)
since = now - timedelta(hours=route.window_hours)
state, quality, matched_count = evaluate_route(session, route, since)
matches = recent_matches(session, route, limit=3, now=now)
upsert_route_recent_matches(session, route.id, matches, limit=3)
quality_avg = compute_persisted_quality_avg(
session, route, today_quality=quality, now=now
)
upsert_route_result(
session,
route,
state,
quality,
matched_count,
quality_avg=quality_avg,
)
session.commit()
session.refresh(route)
@router.get("", response_model=RouteList)
@cached("routes", key_builder=_routes_key_builder)
def list_routes(
@@ -400,7 +353,6 @@ def create_route(
_sync_observers(session, route, observer_nodes)
session.commit()
session.refresh(route)
_reevaluate_route(session, route)
invalidate_routes(request)
owner = _resolve_owner(session, route.created_by)
return _route_to_read(route, quality_avg=None, owner=owner)
@@ -730,7 +682,6 @@ def update_route(
session.commit()
session.refresh(route)
_reevaluate_route(session, route)
invalidate_routes(request)
owner = _resolve_owner(session, route.created_by)
return _route_to_read(route, owner=owner)
+2 -2
View File
@@ -804,7 +804,7 @@ def _import_routes(
route.description = value.get("description")
route.visibility = visibility
route.match_width = match_width
route.window_hours = value.get("window_hours", 48)
route.window_hours = value.get("window_hours", 6)
route.packet_count_threshold = value.get(
"packet_count_threshold", 5
)
@@ -827,7 +827,7 @@ def _import_routes(
description=value.get("description"),
visibility=visibility,
match_width=match_width,
window_hours=value.get("window_hours", 48),
window_hours=value.get("window_hours", 6),
packet_count_threshold=value.get("packet_count_threshold", 5),
clear_threshold=value.get("clear_threshold"),
max_hop_span=value.get("max_hop_span", 8),
@@ -2,7 +2,7 @@
Two-cadence sweep:
* :func:`run_evaluation` runs on the short (default 60s) tick. For every
* :func:`run_evaluation` runs on the short (default 300s) tick. For every
enabled route it computes the current rolling-window snapshot via
:func:`evaluate_route`, captures the top-3 recent matches, refreshes
the rolling 7-day ``quality_avg``, and upserts everything into the
+6 -4
View File
@@ -253,10 +253,12 @@ class CollectorSettings(CommonSettings):
description="Capture every inbound packets-feed packet into raw_packets",
)
raw_packet_retention_days: Optional[int] = Field(
default=7,
default=2,
description=(
"Days to retain raw packets before cleanup (default 7, independent of "
"DATA_RETENTION_DAYS)"
"Days to retain raw packets before cleanup (default 2, independent of "
"DATA_RETENTION_DAYS). Bounds the packet_path_hops table size (hops "
"cascade-delete with their raw packet) and therefore the cost of the "
"route evaluator's full-scan query."
),
ge=1,
)
@@ -326,7 +328,7 @@ class CollectorSettings(CommonSettings):
ge=0,
)
route_evaluator_interval_seconds: int = Field(
default=60,
default=300,
description=(
"Route evaluator interval in seconds. Each tick writes the current "
"snapshot to route_results, upserts today's per-day bucket into "
@@ -78,6 +78,13 @@ class PacketPathHop(Base, UUIDMixin, TimestampMixin):
"ix_packet_path_hops_raw_packet_id_position",
"raw_packet_id",
"position",
postgresql_include=[
"node_hash",
"packet_hash",
"event_hash",
"received_at",
"observer_node_id",
],
),
Index(
"ix_packet_path_hops_received_at",
+1 -1
View File
@@ -70,7 +70,7 @@ class Route(Base, UUIDMixin, TimestampMixin):
)
window_hours: Mapped[int] = mapped_column(
Integer,
default=48,
default=6,
nullable=False,
)
packet_count_threshold: Mapped[int] = mapped_column(
+3 -3
View File
@@ -64,7 +64,7 @@ class RouteCreate(BaseModel):
default=1, ge=1, le=3, description="Hash prefix width (1-3 bytes)"
)
window_hours: int = Field(
default=48, ge=1, le=720, description="Evaluation window in hours"
default=6, ge=1, le=12, description="Evaluation window in hours"
)
packet_count_threshold: int = Field(
default=5, ge=1, le=10000, description="Minimum distinct packets for healthy"
@@ -114,7 +114,7 @@ class RouteUpdate(BaseModel):
description: Optional[str] = None
visibility: Optional[Literal["community", "member", "operator", "admin"]] = None
match_width: Optional[int] = Field(default=None, ge=1, le=3)
window_hours: Optional[int] = Field(default=None, ge=1, le=720)
window_hours: Optional[int] = Field(default=None, ge=1, le=12)
packet_count_threshold: Optional[int] = Field(default=None, ge=1, le=10000)
clear_threshold: Optional[int] = None
max_hop_span: Optional[int] = None
@@ -255,7 +255,7 @@ class RoutePreviewRequest(BaseModel):
..., description="Ordered path node public keys"
)
match_width: int = Field(default=1, ge=1, le=3)
window_hours: int = Field(default=48, ge=1, le=720)
window_hours: int = Field(default=6, ge=1, le=12)
packet_count_threshold: int = Field(default=5, ge=1, le=10000)
clear_threshold: Optional[int] = None
max_hop_span: Optional[int] = Field(default=8)
@@ -25,7 +25,7 @@ const ROUTES = {
enabled: true,
reversible: false,
match_width: 60,
window_hours: 24,
window_hours: 6,
quality_avg: "clear",
route_result: { quality: "clear", state: "healthy" },
route_nodes: [],
@@ -721,7 +721,7 @@ function RouteModal({
const [pathQuery, setPathQuery] = useState("");
const [obsQuery, setObsQuery] = useState("");
const [windowHours, setWindowHours] = useState(
String(route?.window_hours || 48),
String(route?.window_hours || 6),
);
const [threshold, setThreshold] = useState(
String(route?.packet_count_threshold || 5),
@@ -1041,7 +1041,7 @@ function RouteModal({
value={windowHours}
onChange={(e) => setWindowHours(e.target.value)}
min={1}
max={720}
max={12}
/>
</div>
<div>
@@ -1448,7 +1448,7 @@ export function RoutesPage() {
description: values.description.trim() || null,
visibility: values.visibility,
match_width: values.match_width || 1,
window_hours: parseInt(values.window_hours, 10) || 48,
window_hours: parseInt(values.window_hours, 10) || 6,
packet_count_threshold: parseInt(values.packet_count_threshold, 10) || 5,
max_hop_span: values.max_hop_span
? parseInt(values.max_hop_span, 10)
+16
View File
@@ -1449,6 +1449,22 @@ def _make_reception_for_route(
class TestRoutesOverview:
"""Tests for GET /dashboard/routes-overview."""
@pytest.fixture(autouse=True)
def _high_raw_packet_retention(self, monkeypatch):
"""Bump raw-packet retention to 30d for this class.
These tests exercise the endpoint's ``days`` handling (default,
custom, include_today). They need a retention window wide enough
that ``days`` is not clamped — the clamp itself is covered
separately by ``test_days_clamped_to_retention``, which reads the
(patched) retention dynamically and therefore still works.
"""
from meshcore_hub.common.config import CollectorSettings
monkeypatch.setattr(
CollectorSettings, "effective_raw_packet_retention_days", 30
)
def test_empty(self, client_no_auth):
"""No routes seeded → empty routes array and by_state."""
resp = client_no_auth.get("/api/v1/dashboard/routes-overview")
+85 -58
View File
@@ -627,13 +627,18 @@ class TestRouteQualityAvg:
assert resp.status_code == 201
assert resp.json()["quality_avg"] is None
def test_computed_on_update_response(self, client_no_auth, api_db_session):
"""Update handler recomputes the average inline.
def test_update_does_not_compute_quality_avg_inline(
self, client_no_auth, api_db_session
):
"""Update handler does NOT recompute ``quality_avg`` synchronously.
``_reevaluate_route`` runs ``compute_persisted_quality_avg`` which
reads ``route_result_history`` and returns ``None`` when no rows
exist yet (a fresh route with no hourly backfill under its belt).
Seeding history before the PUT exercises the populated path.
Previously a PUT ran ``_reevaluate_route`` which called
``compute_persisted_quality_avg`` inline. That synchronous eval was
removed (it duplicated the background evaluator's work and blocked
the request on a full ``packet_path_hops`` scan). Now the PUT
response carries whatever ``route_result`` already existed —
``None`` for a route with no prior sweep — and the rolling
``quality_avg`` is refreshed only by the background evaluator.
"""
from meshcore_hub.common.models.route_result_history import (
RouteResultHistory,
@@ -641,7 +646,9 @@ class TestRouteQualityAvg:
from datetime import date
route = self._make_route(api_db_session, enabled=True, label="UpdateMe")
# Seed 7 days of failing history so the average resolves to failing.
# Seed 7 days of failing history. Under the old inline-eval path
# this would have resolved quality_avg to "failing" on the PUT;
# with eval removed it must stay None (no route_result row).
today = date.today()
for i in range(1, 8):
api_db_session.add(
@@ -661,7 +668,7 @@ class TestRouteQualityAvg:
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
assert resp.json()["quality_avg"] == "failing"
assert resp.json()["quality_avg"] is None
class TestUpdateRoute:
@@ -720,19 +727,20 @@ class TestUpdateRoute:
public_keys = [rn["public_key"] for rn in data["route_nodes"]]
assert new_node.public_key in public_keys
def test_update_threshold_immediately_reflects_in_route_result(
def test_update_does_not_synchronously_refresh_route_result(
self, client_no_auth, api_db_session
):
"""Regression: PUT-changed threshold must surface in route_result now.
"""PUT no longer refreshes ``route_result`` inline.
Before this fix, ``route_result`` (written by a background
evaluator on a 30-60s schedule) kept the OLD threshold until the
next evaluator cycle. The routes list card displays
``route_result.threshold`` / ``effective_clear``, so the UI showed
stale values for ~30s after a PUT even though the server returned
``x-cache: MISS`` with the route's direct fields updated. The
PUT handler now runs ``_reevaluate_route`` synchronously after
commit so the very next GET sees a fresh ``route_result``.
The synchronous ``_reevaluate_route`` call was removed because it
blocked the request on a full ``packet_path_hops`` scan (duplicating
the background evaluator's work). As a result, the PUT response
surfaces the route's direct fields (new threshold/clear) but the
embedded ``route_result`` snapshot retains whatever the last
background sweep wrote — it is refreshed on the next evaluator
tick (default every 300s), not on every write. This test pins
that contract: a stale seeded ``route_result`` survives a PUT
unchanged.
"""
from meshcore_hub.common.models.route_result import RouteResult
@@ -755,10 +763,10 @@ class TestUpdateRoute:
expected_hash=n.public_key[:2].upper(),
)
)
# Seed a stale RouteResult snapshot from a hypothetical prior
# evaluator run using the OLD config (threshold=6, clear=12).
# Without synchronous re-eval, this is what the PUT response
# would continue to return until the next background sweep.
# Seed a RouteResult snapshot from a hypothetical prior evaluator
# run using the OLD config (threshold=6, clear=12). Without
# synchronous re-eval, this stale snapshot is what the PUT
# response continues to return until the next background sweep.
api_db_session.add(
RouteResult(
route_id=route.id,
@@ -782,23 +790,28 @@ class TestUpdateRoute:
# The route's direct fields reflect the new config...
assert data["packet_count_threshold"] == 3
assert data["clear_threshold"] == 6
# ...AND route_result must reflect them too, not the stale
# snapshot from the seeded prior evaluation.
# ...but route_result retains the STALE snapshot until the next
# background evaluator sweep refreshes it.
assert data["route_result"] is not None
assert (
data["route_result"]["threshold"] == 3
), "route_result.threshold should reflect the new packet_count_threshold"
data["route_result"]["threshold"] == 6
), "route_result.threshold stays stale until the next sweep"
assert (
data["route_result"]["effective_clear"] == 6
), "route_result.effective_clear should reflect the new clear_threshold"
data["route_result"]["effective_clear"] == 12
), "route_result.effective_clear stays stale until the next sweep"
def test_disabled_route_does_not_trigger_evaluation(
def test_update_does_not_invoke_evaluator(
self, client_no_auth, api_db_session, monkeypatch
):
"""Disabled routes short-circuit ``_reevaluate_route`` (no point
evaluating a route that won't be displayed as active). Guards
against unnecessary DB scans on bulk config changes."""
from meshcore_hub.api.routes import routes as routes_module
"""PUT must not synchronously invoke the route evaluator.
Regression guard: the synchronous ``_reevaluate_route`` call was
removed to keep writes fast (it triggered a full
``packet_path_hops`` scan per mutation). This spies on the
evaluator entry points to ensure no one re-wires the write path
back to inline evaluation.
"""
import meshcore_hub.api.routes.routes as routes_module
called = {"count": 0}
@@ -806,14 +819,21 @@ class TestUpdateRoute:
called["count"] += 1
return ("healthy", "clear", 0)
monkeypatch.setattr(routes_module, "evaluate_route", _spy_evaluate)
# recent_matches is the only collector symbol the routes API still
# imports; evaluate_route is not imported by the module anymore,
# so spying on it via the collector namespace catches any future
# re-import that re-adds inline evaluation.
import meshcore_hub.collector.routes as collector_routes
monkeypatch.setattr(collector_routes, "evaluate_route", _spy_evaluate)
monkeypatch.setattr(routes_module, "recent_matches", _spy_evaluate)
nodes = _sample_nodes(api_db_session, 2)
route = Route(
from_label="Off",
to_label="Line",
from_label="No",
to_label="Eval",
packet_count_threshold=3,
enabled=False,
enabled=True,
)
api_db_session.add(route)
api_db_session.flush()
@@ -830,13 +850,11 @@ class TestUpdateRoute:
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"description": "still off"},
json={"description": "still no inline eval"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
assert (
called["count"] == 0
), "evaluate_route must not be called for disabled routes"
assert called["count"] == 0, "PUT must not invoke the evaluator"
class TestDeleteRoute:
@@ -869,7 +887,7 @@ class TestPreview:
json={
"node_public_keys": [n.public_key for n in nodes],
"match_width": 1,
"window_hours": 24,
"window_hours": 12,
"packet_count_threshold": 3,
},
)
@@ -946,11 +964,13 @@ class TestRouteHistory:
)
api_db_session.commit()
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history?days=7")
# days=1 stays within the default raw-packet retention window, so
# it is not clamped; include_today=True adds one extra entry.
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history?days=1")
assert resp.status_code == 200
data = resp.json()
# days=7 + include_today=True → 8 entries
assert len(data["data"]) == 8
# days=1 + include_today=True → 2 entries
assert len(data["data"]) == 2
def test_not_found(self, client_no_auth):
resp = client_no_auth.get("/api/v1/routes/nonexistent/history")
@@ -1115,7 +1135,7 @@ class TestUpdateRouteFields:
"description": "updated desc",
"visibility": "member",
"match_width": 2,
"window_hours": 48,
"window_hours": 12,
"packet_count_threshold": 5,
"clear_threshold": 8,
"max_hop_span": 4,
@@ -1130,7 +1150,7 @@ class TestUpdateRouteFields:
assert data["description"] == "updated desc"
assert data["visibility"] == "member"
assert data["match_width"] == 2
assert data["window_hours"] == 48
assert data["window_hours"] == 12
assert data["packet_count_threshold"] == 5
assert data["clear_threshold"] == 8
assert data["max_hop_span"] == 4
@@ -1337,7 +1357,7 @@ class TestPreviewGuards:
"node_public_keys": [n.public_key for n in nodes],
"observer_public_keys": [obs.public_key],
"match_width": 1,
"window_hours": 24,
"window_hours": 12,
"packet_count_threshold": 3,
},
)
@@ -1347,7 +1367,7 @@ class TestPreviewGuards:
class TestPrecomputedRecentMatches:
"""``route_recent_matches`` table → detail-page read path.
The 60s evaluator sweep persists top-3 matches as normalized rows
The evaluator sweep (default every 300s) persists top-3 matches as normalized rows
(``route_id``, ``raw_packet_id``, ``first_position``, ``last_position``).
The detail endpoint JOINs through ``raw_packets`` and slices the
packet's hops on read, so the JSON shape stays identical to the
@@ -1429,9 +1449,18 @@ class TestPrecomputedRecentMatches:
# Live path also slices the matched subpath.
assert [h["node_hash"] for h in match["hops"]] == ["AA", "BB"]
def test_put_persists_normalized_matches(self, client_no_auth, api_db_session):
"""PUT triggers ``_reevaluate_route`` which writes through the
normalized table — the subsequent GET reads from there."""
def test_put_does_not_persist_normalized_matches(
self, client_no_auth, api_db_session
):
"""PUT does NOT write ``route_recent_matches``.
The synchronous ``_reevaluate_route`` that used to persist matches
on every write was removed (it blocked the request on a full
``packet_path_hops`` scan). The normalized table is now written
exclusively by the background evaluator sweep. Until that sweep
runs, the detail page falls back to the live compute path (see
``test_detail_falls_back_to_live_when_table_empty``).
"""
from sqlalchemy import select
from meshcore_hub.common.models import RouteRecentMatch
@@ -1449,13 +1478,13 @@ class TestPrecomputedRecentMatches:
resp = client_no_auth.put(
f"/api/v1/routes/{route.id}",
json={"description": "trigger reeval"},
json={"description": "no inline eval"},
headers=ADMIN_HEADERS,
)
assert resp.status_code == 200
# The synchronous re-evaluation on PUT should have written a
# match row through ``upsert_route_recent_matches``.
# PUT must not have written any match rows — only the background
# evaluator sweep populates this table.
rows = (
api_db_session.execute(
select(RouteRecentMatch).where(RouteRecentMatch.route_id == route.id)
@@ -1463,9 +1492,7 @@ class TestPrecomputedRecentMatches:
.scalars()
.all()
)
assert len(rows) == 1
assert rows[0].first_position == 1
assert rows[0].last_position == 2
assert rows == []
class TestRouteOperatorPermissions:
+2 -2
View File
@@ -495,7 +495,7 @@ class TestImportRoutes:
" match_width: 1\n"
" visibility: community\n"
" description: a route\n"
" window_hours: 48\n"
" window_hours: 12\n"
" packet_count_threshold: 5\n"
" clear_threshold: 8\n"
" max_hop_span: 3\n"
@@ -516,7 +516,7 @@ class TestImportRoutes:
assert route.to_label == "Beta"
assert route.visibility == "community"
assert route.match_width == 1
assert route.window_hours == 48
assert route.window_hours == 12
assert route.packet_count_threshold == 5
assert route.clear_threshold == 8
assert route.max_hop_span == 3
+6 -6
View File
@@ -53,12 +53,12 @@ class TestCollectorSettings:
assert settings.database_url == "postgresql://user@host/db"
assert settings.effective_database_url == "postgresql://user@host/db"
def test_raw_packet_retention_defaults_to_7(self) -> None:
"""Unset raw_packet_retention_days defaults to 7, independent of global."""
def test_raw_packet_retention_defaults_to_2(self) -> None:
"""Unset raw_packet_retention_days defaults to 2, independent of global."""
settings = CollectorSettings(_env_file=None, data_retention_days=12)
assert settings.raw_packet_retention_days == 7
assert settings.effective_raw_packet_retention_days == 7
assert settings.raw_packet_retention_days == 2
assert settings.effective_raw_packet_retention_days == 2
def test_raw_packet_retention_explicit_override(self) -> None:
"""An explicit raw_packet_retention_days wins over the global value."""
@@ -119,10 +119,10 @@ class TestCollectorSettings:
assert settings.channel_refresh_interval_seconds == 60
def test_route_evaluator_interval_seconds_default(self) -> None:
"""Route evaluator interval defaults to 60."""
"""Route evaluator interval defaults to 300."""
settings = CollectorSettings(_env_file=None)
assert settings.route_evaluator_interval_seconds == 60
assert settings.route_evaluator_interval_seconds == 300
def test_route_evaluator_interval_seconds_custom(self) -> None:
"""Route evaluator interval can be overridden (0 disables)."""