mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-26 11:33:12 +02:00
c9b247c553
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.
524 lines
16 KiB
Python
524 lines
16 KiB
Python
"""Deterministic seed data for the Playwright end-to-end test stack.
|
|
|
|
Runs inside the e2e collector container (which has the app and the Postgres
|
|
driver installed) against the throwaway e2e database:
|
|
|
|
docker compose -f e2e/docker-compose.test.yml exec -T collector \
|
|
python /seed_data.py
|
|
|
|
Idempotent: clears previously seeded rows and recreates them with fixed public
|
|
keys and recent timestamps, so every run yields the same dataset. The e2e
|
|
stack uses its own ephemeral Postgres instance - this never touches the local
|
|
development database.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import delete
|
|
from sqlalchemy.exc import OperationalError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from meshcore_hub.common.config import get_common_settings
|
|
from meshcore_hub.common.database import (
|
|
create_database_engine,
|
|
create_session_factory,
|
|
)
|
|
from meshcore_hub.common.models import (
|
|
Advertisement,
|
|
Channel,
|
|
EventObserver,
|
|
Message,
|
|
Node,
|
|
NodeTag,
|
|
PacketPathHop,
|
|
RawPacket,
|
|
Route,
|
|
RouteNode,
|
|
RouteObserver,
|
|
RouteRecentMatch,
|
|
RouteResult,
|
|
RouteResultHistory,
|
|
UserProfile,
|
|
UserProfileNode,
|
|
)
|
|
|
|
NOW = datetime.now(timezone.utc)
|
|
|
|
ALPHA = ("a1fa" + "0" * 64)[:64]
|
|
BRAVO = ("b2b0" + "0" * 64)[:64]
|
|
CHARLIE = ("c3c0" + "0" * 64)[:64]
|
|
DELTA = ("d4d0" + "0" * 64)[:64]
|
|
NORTH_1 = ("aa01" + "0" * 64)[:64]
|
|
NORTH_2 = ("aa02" + "0" * 64)[:64]
|
|
SOUTH_1 = ("bb01" + "0" * 64)[:64]
|
|
SOUTH_2 = ("bb02" + "0" * 64)[:64]
|
|
|
|
PATH_HOPS = [ALPHA[:4].upper(), BRAVO[:4].upper(), CHARLIE[:4].upper()]
|
|
|
|
|
|
def _hash(seed: str) -> str:
|
|
return (seed + "0" * 32)[:32]
|
|
|
|
|
|
def _event_hash(counter: int) -> str:
|
|
return f"{counter:032x}"
|
|
|
|
|
|
def _ago(minutes: float = 0.0, hours: float = 0.0, days: float = 0.0) -> datetime:
|
|
return NOW - timedelta(minutes=minutes, hours=hours, days=days)
|
|
|
|
|
|
def clear(session: Session) -> None:
|
|
for model in (
|
|
RouteRecentMatch,
|
|
RouteResultHistory,
|
|
RouteResult,
|
|
RouteObserver,
|
|
RouteNode,
|
|
Route,
|
|
PacketPathHop,
|
|
RawPacket,
|
|
EventObserver,
|
|
Advertisement,
|
|
Message,
|
|
UserProfileNode,
|
|
UserProfile,
|
|
NodeTag,
|
|
Node,
|
|
Channel,
|
|
):
|
|
session.execute(delete(model))
|
|
|
|
|
|
def seed_nodes(session: Session) -> dict[str, Node]:
|
|
content_specs = [
|
|
(ALPHA, "Alpha Node", "chat", 51.5074, -0.1278),
|
|
(BRAVO, "Bravo Node", "repeater", 52.4862, -1.8904),
|
|
(CHARLIE, "Charlie Node", "room", 53.4808, -2.2426),
|
|
(DELTA, "Delta Node", "chat", None, None),
|
|
]
|
|
observer_specs = [
|
|
(NORTH_1, "North Observer 1", "North", 51.6, -0.2),
|
|
(NORTH_2, "North Observer 2", "North", 51.7, -0.3),
|
|
(SOUTH_1, "South Observer 1", "South", 52.5, -1.9),
|
|
(SOUTH_2, "South Observer 2", "South", 52.6, -2.0),
|
|
]
|
|
|
|
nodes: dict[str, Node] = {}
|
|
for i, (pk, name, adv_type, lat, lon) in enumerate(content_specs):
|
|
node = Node(
|
|
public_key=pk,
|
|
name=name,
|
|
adv_type=adv_type,
|
|
lat=lat,
|
|
lon=lon,
|
|
first_seen=_ago(days=30),
|
|
last_seen=_ago(minutes=i + 1),
|
|
)
|
|
session.add(node)
|
|
nodes[pk] = node
|
|
|
|
for i, (pk, name, _area, lat, lon) in enumerate(observer_specs):
|
|
node = Node(
|
|
public_key=pk,
|
|
name=name,
|
|
adv_type="repeater",
|
|
lat=lat,
|
|
lon=lon,
|
|
is_observer=True,
|
|
first_seen=_ago(days=30),
|
|
last_seen=_ago(minutes=i + 1),
|
|
)
|
|
session.add(node)
|
|
nodes[pk] = node
|
|
|
|
session.flush()
|
|
for pk, _name, area, _lat, _lon in observer_specs:
|
|
session.add(NodeTag(node_id=nodes[pk].id, key="area", value=area))
|
|
for pk in nodes:
|
|
session.add(NodeTag(node_id=nodes[pk].id, key="name", value=nodes[pk].name))
|
|
session.flush()
|
|
return nodes
|
|
|
|
|
|
def seed_advertisements(
|
|
session: Session, nodes: dict[str, Node]
|
|
) -> dict[str, tuple[str, float]]:
|
|
specs = [
|
|
(ALPHA, NORTH_1, "ad01", "flood", 30.0),
|
|
(BRAVO, SOUTH_1, "ad02", "flood", 45.0),
|
|
(CHARLIE, NORTH_2, "ad03", "direct", 60.0),
|
|
(DELTA, SOUTH_2, "ad04", "transport_flood", 90.0),
|
|
(ALPHA, SOUTH_1, "ad05", "flood", 120.0),
|
|
(BRAVO, NORTH_1, "ad06", "flood", 150.0),
|
|
]
|
|
events: dict[str, tuple[str, float]] = {}
|
|
for i, (node_pk, observer_pk, seed, route_type, minutes) in enumerate(specs):
|
|
packet_hash = _hash(seed)
|
|
event_hash = _event_hash(i + 1)
|
|
events[packet_hash] = (event_hash, minutes)
|
|
node = nodes[node_pk]
|
|
received_at = _ago(minutes=minutes)
|
|
session.add(
|
|
Advertisement(
|
|
observer_node_id=nodes[observer_pk].id,
|
|
node_id=node.id,
|
|
public_key=node_pk,
|
|
name=node.name,
|
|
adv_type=node.adv_type,
|
|
received_at=received_at,
|
|
event_hash=event_hash,
|
|
packet_hash=packet_hash,
|
|
route_type=route_type,
|
|
advert_timestamp=received_at,
|
|
)
|
|
)
|
|
session.add(
|
|
EventObserver(
|
|
event_type="advertisement",
|
|
event_hash=event_hash,
|
|
observer_node_id=nodes[observer_pk].id,
|
|
snr=7.5,
|
|
path_len=2,
|
|
observed_at=received_at,
|
|
)
|
|
)
|
|
session.flush()
|
|
return events
|
|
|
|
|
|
def seed_messages(
|
|
session: Session, nodes: dict[str, Node], custom_channel_idx: int
|
|
) -> dict[str, tuple[str, float]]:
|
|
specs = [
|
|
("channel", NORTH_1, "ce01", 10.0, "Hello from the e2e mesh", 17, None),
|
|
("channel", SOUTH_1, "ce02", 20.0, "Channel check from the south", 17, None),
|
|
(
|
|
"channel",
|
|
NORTH_2,
|
|
"ce03",
|
|
25.0,
|
|
"Ops channel traffic",
|
|
custom_channel_idx,
|
|
None,
|
|
),
|
|
(
|
|
"contact",
|
|
SOUTH_2,
|
|
"ce04",
|
|
15.0,
|
|
"Direct hello over the mesh",
|
|
None,
|
|
ALPHA[:12],
|
|
),
|
|
]
|
|
events: dict[str, tuple[str, float]] = {}
|
|
for i, (mtype, observer_pk, seed, minutes, text, channel_idx, prefix) in enumerate(
|
|
specs
|
|
):
|
|
packet_hash = _hash(seed)
|
|
event_hash = _event_hash(100 + i)
|
|
events[packet_hash] = (event_hash, minutes)
|
|
received_at = _ago(minutes=minutes)
|
|
session.add(
|
|
Message(
|
|
observer_node_id=nodes[observer_pk].id,
|
|
message_type=mtype,
|
|
pubkey_prefix=prefix,
|
|
channel_idx=channel_idx,
|
|
text=text,
|
|
path_len=2,
|
|
snr=6.5,
|
|
received_at=received_at,
|
|
event_hash=event_hash,
|
|
packet_hash=packet_hash,
|
|
)
|
|
)
|
|
session.add(
|
|
EventObserver(
|
|
event_type="message",
|
|
event_hash=event_hash,
|
|
observer_node_id=nodes[observer_pk].id,
|
|
snr=6.5,
|
|
path_len=2,
|
|
observed_at=received_at,
|
|
)
|
|
)
|
|
session.flush()
|
|
return events
|
|
|
|
|
|
def seed_raw_packets(
|
|
session: Session,
|
|
nodes: dict[str, Node],
|
|
advert_events: dict[str, tuple[str, float]],
|
|
message_events: dict[str, tuple[str, float]],
|
|
custom_channel_idx: int,
|
|
) -> str:
|
|
channel_indices = {
|
|
_hash("ce01"): 17,
|
|
_hash("ce02"): 17,
|
|
_hash("ce03"): custom_channel_idx,
|
|
}
|
|
events = {
|
|
**{h: (e, m, "advertisement") for h, (e, m) in advert_events.items()},
|
|
**{
|
|
h: (e, m, "contact_msg_recv" if h == _hash("ce04") else "channel_msg_recv")
|
|
for h, (e, m) in message_events.items()
|
|
},
|
|
}
|
|
|
|
first_raw_packet_id = ""
|
|
for packet_hash, (event_hash, minutes, event_type) in sorted(events.items()):
|
|
for j, observer_pk in enumerate((NORTH_1, SOUTH_1)):
|
|
received_at = _ago(minutes=minutes) + timedelta(seconds=j * 3)
|
|
raw = RawPacket(
|
|
observer_node_id=nodes[observer_pk].id,
|
|
packet_hash=packet_hash,
|
|
event_hash=event_hash,
|
|
raw_hex=(packet_hash * 4)[:128],
|
|
packet_type=5,
|
|
payload_type=4 if event_type == "advertisement" else 5,
|
|
event_type=event_type,
|
|
channel_idx=channel_indices.get(packet_hash),
|
|
source_pubkey_prefix=ALPHA[:12],
|
|
route_type="flood",
|
|
path_len=3,
|
|
path_hash_bytes=2,
|
|
snr=8.5 - j * 2.25,
|
|
decoded={"e2e": True, "packet_hash": packet_hash},
|
|
received_at=received_at,
|
|
)
|
|
session.add(raw)
|
|
session.flush()
|
|
if not first_raw_packet_id:
|
|
first_raw_packet_id = raw.id
|
|
for position, node_hash in enumerate(PATH_HOPS):
|
|
session.add(
|
|
PacketPathHop(
|
|
raw_packet_id=raw.id,
|
|
position=position,
|
|
node_hash=node_hash,
|
|
packet_hash=packet_hash,
|
|
event_hash=event_hash,
|
|
received_at=received_at,
|
|
observer_node_id=nodes[observer_pk].id,
|
|
)
|
|
)
|
|
session.flush()
|
|
return first_raw_packet_id
|
|
|
|
|
|
def seed_channels(session: Session) -> int:
|
|
keys = [
|
|
("E2E General", "00112233445566778899aabbccddeeff" * 2),
|
|
("E2E Ops", "ffeeddccbbaa99887766554433221100" * 2),
|
|
]
|
|
for name, key_hex in keys:
|
|
session.add(
|
|
Channel(
|
|
name=name,
|
|
key_hex=key_hex,
|
|
channel_hash=Channel.compute_channel_hash(key_hex),
|
|
visibility="community",
|
|
enabled=True,
|
|
)
|
|
)
|
|
session.flush()
|
|
general_hash = Channel.compute_channel_hash(keys[0][1])
|
|
return int(general_hash, 16)
|
|
|
|
|
|
def seed_routes(
|
|
session: Session, nodes: dict[str, Node], first_raw_packet_id: str
|
|
) -> None:
|
|
route = Route(
|
|
from_label="Alpha Site",
|
|
to_label="Bravo Site",
|
|
description="Synthetic e2e route",
|
|
visibility="community",
|
|
match_width=2,
|
|
window_hours=12,
|
|
packet_count_threshold=3,
|
|
clear_threshold=6,
|
|
max_hop_span=8,
|
|
enabled=True,
|
|
reversible=True,
|
|
)
|
|
session.add(route)
|
|
session.flush()
|
|
|
|
for position, pk in enumerate((ALPHA, BRAVO)):
|
|
session.add(
|
|
RouteNode(
|
|
route_id=route.id,
|
|
node_id=nodes[pk].id,
|
|
position=position,
|
|
expected_hash=pk[:4].upper(),
|
|
)
|
|
)
|
|
session.add(RouteObserver(route_id=route.id, node_id=nodes[NORTH_1].id))
|
|
|
|
session.add(
|
|
RouteResult(
|
|
route_id=route.id,
|
|
state="healthy",
|
|
quality="clear",
|
|
matched_count=7,
|
|
threshold=3,
|
|
effective_clear=6,
|
|
evaluated_at=_ago(minutes=5),
|
|
quality_avg="clear",
|
|
)
|
|
)
|
|
|
|
history = [
|
|
("clear", 7),
|
|
("clear", 6),
|
|
("marginal", 4),
|
|
("clear", 5),
|
|
("marginal", 3),
|
|
("failing", 1),
|
|
("clear", 6),
|
|
]
|
|
for day_offset, (quality, matched) in enumerate(history):
|
|
session.add(
|
|
RouteResultHistory(
|
|
route_id=route.id,
|
|
date=(NOW - timedelta(days=day_offset)).date(),
|
|
quality=quality,
|
|
state="unhealthy" if quality == "failing" else "healthy",
|
|
matched_count=matched,
|
|
evaluated_at=_ago(days=day_offset),
|
|
)
|
|
)
|
|
|
|
if first_raw_packet_id:
|
|
session.add(
|
|
RouteRecentMatch(
|
|
route_id=route.id,
|
|
raw_packet_id=first_raw_packet_id,
|
|
first_position=0,
|
|
last_position=1,
|
|
)
|
|
)
|
|
session.flush()
|
|
|
|
# A second route owned by the e2e operator session (pw-operator).
|
|
# Used by the "mine" filter test: operator-owned routes stay visible
|
|
# when ?mine=true is active, while the legacy NULL-created_by route above
|
|
# disappears.
|
|
op_route = Route(
|
|
from_label="Op North",
|
|
to_label="Op South",
|
|
description="Operator-owned e2e route",
|
|
visibility="operator",
|
|
match_width=1,
|
|
window_hours=12,
|
|
packet_count_threshold=3,
|
|
clear_threshold=6,
|
|
max_hop_span=8,
|
|
enabled=True,
|
|
reversible=False,
|
|
created_by="pw-operator",
|
|
)
|
|
session.add(op_route)
|
|
session.flush()
|
|
for position, pk in enumerate((CHARLIE, DELTA)):
|
|
session.add(
|
|
RouteNode(
|
|
route_id=op_route.id,
|
|
node_id=nodes[pk].id,
|
|
position=position,
|
|
expected_hash=pk[:4].upper(),
|
|
)
|
|
)
|
|
session.add(RouteObserver(route_id=op_route.id, node_id=nodes[NORTH_2].id))
|
|
session.flush()
|
|
|
|
|
|
def seed_profiles(session: Session, nodes: dict[str, Node]) -> None:
|
|
specs = [
|
|
(
|
|
"pw-admin",
|
|
"PW Admin",
|
|
"E2EADM",
|
|
"admin,member",
|
|
"Playwright admin user",
|
|
"https://example.com/pw-admin",
|
|
),
|
|
(
|
|
"pw-member",
|
|
"PW Member",
|
|
"E2EMBR",
|
|
"member",
|
|
"Playwright member user",
|
|
None,
|
|
),
|
|
("op-north", "Op North", "OPN1", "operator,member", "North operator", None),
|
|
("mem-south", "Mem South", "MEMS1", "member", "South member", None),
|
|
]
|
|
profiles: dict[str, UserProfile] = {}
|
|
for user_id, name, callsign, roles, description, url in specs:
|
|
profile = UserProfile(
|
|
user_id=user_id,
|
|
name=name,
|
|
callsign=callsign,
|
|
roles=roles,
|
|
description=description,
|
|
url=url,
|
|
)
|
|
session.add(profile)
|
|
profiles[user_id] = profile
|
|
session.flush()
|
|
|
|
session.add(
|
|
UserProfileNode(
|
|
user_profile_id=profiles["op-north"].id, node_id=nodes[ALPHA].id
|
|
)
|
|
)
|
|
session.add(
|
|
UserProfileNode(
|
|
user_profile_id=profiles["mem-south"].id, node_id=nodes[BRAVO].id
|
|
)
|
|
)
|
|
session.flush()
|
|
|
|
|
|
def main() -> None:
|
|
settings = get_common_settings()
|
|
engine = create_database_engine(
|
|
settings.effective_database_url,
|
|
schema=settings.effective_database_schema,
|
|
)
|
|
session_factory = create_session_factory(engine)
|
|
|
|
last_error: OperationalError | None = None
|
|
for _ in range(15):
|
|
try:
|
|
with session_factory() as session:
|
|
clear(session)
|
|
nodes = seed_nodes(session)
|
|
custom_channel_idx = seed_channels(session)
|
|
advert_events = seed_advertisements(session, nodes)
|
|
message_events = seed_messages(session, nodes, custom_channel_idx)
|
|
first_raw_packet_id = seed_raw_packets(
|
|
session, nodes, advert_events, message_events, custom_channel_idx
|
|
)
|
|
seed_routes(session, nodes, first_raw_packet_id)
|
|
seed_profiles(session, nodes)
|
|
session.commit()
|
|
print("e2e seed data written")
|
|
return
|
|
except OperationalError as exc:
|
|
last_error = exc
|
|
time.sleep(2)
|
|
raise RuntimeError(f"database not reachable after retries: {last_error}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|