mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-10-30 19:52:03 +01:00
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:
21 files changed
+273
-145
No files matched your search
+7
-5
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
Reference in new issue
Block a user