Drogheda/02-Architecture-Software/code-review-scale-readiness.md

7.4 KiB

tags project author date status
project-drogheda
backend
code-review
scale
Project Drogheda Helgen CTO 2026-06-20 Review — for the "add more units" test campaign

Code Review — retranslator + monitor (scale readiness)

Review of the hardened retranslator.py, web_monitor.py, and the gps_logger.py they depend on, ahead of bringing more units online. The three 🔴 field-killers (HOL blocking, anti-jump black-hole, CGNAT identity) are already fixed — this pass is about what else bites as the fleet grows. Nothing here is on fire; the top items are the ones you'll actually hit while testing.

Severity: 🔴 blocks/badly misleads at scale · 🟠 should fix before the fleet grows · 🟡 polish.

Status (2026-06-20): R1, R2, G1, G2 are implemented and verified — smoke (10/10, no regression), an R1/R2 focused test (7/7), and a standalone index+prune test all pass. Remaining: M1 (dashboard auth — an nginx change, server-side) and the 🟡 polish items. New env knob: GPS_RETENTION_DAYS (default 30, 0 = off).

Summary

The hardened code is solid and the concurrency model is sound. The remaining risks are mostly operational at scale: a new unit that isn't in device_config.json silently won't forward (and the monitor won't tell you why); the SQLite history has no index and no retention (it will scan slowly and grow until the small disk fills); the dashboard has no auth and re-renders 500 rows every 2 s; and a couple of latent parsing/ack assumptions. No SQL injection (queries are parameterized) and no obvious XSS.

🔴 / 🟠 Issues to fix before adding units

# File · location Issue Sev
R1 retranslator.py · _process_device_telemetry (wialon_id = device_cell_id_to_wialon_id.get(cell_id)) An unconfigured cell_id is silently never forwarded. A new unit's GPTXT marks it online (so it appears active in the monitor) but with no wialon_id mapping it's never sent to Wialon — and it shows as wialon_state="idle", not a clear "unconfigured". This is the #1 thing you'll hit adding units. 🔴
G2 gps_logger.py · whole file SQLite history grows unbounded. ~288 rows/unit/day → ~144k/day at 500 units, no pruning. On the space-limited server this fills the disk over weeks. 🔴
G1 gps_logger.py · _create_table No index on gps_positions. /api/availability does WHERE received_at … GROUP BY day,device → full scan; at millions of rows the report crawls. 🟠
M1 web_monitor.py / nginx No auth on the dashboard or API. monitor.helgen.dev exposes live fleet positions + /api/status + /api/availability to anyone with the URL. Client-sensitive. 🟠
R2 retranslator.py · main() UDP loop (raw = udp_data.decode(...).strip()) One NMEA sentence per UDP datagram is assumed. If a unit ever batches sentences in one packet ($GPTXT…\r\n$GNGGA…), only the first is parsed. Latent today (firmware sends one per datagram) but a trap for the planned $PHLG injection. 🟠
R3 retranslator.py · WialonForwarder._deliver (response = s.recv(1024)) Wialon data-ack isn't validated. Only the login ack (#AL#1) is checked; the per-packet ack is read and discarded, so a rejected packet still counts as acked → monitor shows the unit healthy when Wialon isn't accepting it. 🟠

🟡 Worth doing

# File · location Suggestion
R4 retranslator.py · config load New units need a restart (device_config.json read once). Add a SIGHUP handler to reload the map live — no fleet-wide blip every time you add a test unit.
M2 web_monitor.py · renderTable() (tbody.innerHTML = rows.map(...)) Re-renders all 500 rows every 2 s, churning the DOM and dropping scroll/selection. Diff rows, or only re-render on change.
R5 retranslator.py · _build_data_packet (datetime.utcnow()) Wialon timestamp = server receive time, not the GGA fix time. Sub-second for a slow fleet, but parse the GGA UTC if you want exact fix times.
R6 retranslator.py · WialonForwarder.forget A forget() on a full worker queue is silently dropped → a socket can leak until the next send error. Bounded; low impact.
R7 retranslator.py · _deliver ack read Residual per-worker HOL: one slow ack blocks that worker's ~8 devices for up to TCP_RW_TIMEOUT (15 s). Far better than global; consider lowering the read timeout.
G3 gps_logger.py · _insert commit() per row. Fine at the 300 s throttle; revisit if the throttle drops or inserts burst.
M3 web_monitor.py · sse_events Re-parses the whole status JSON every 2 s only to validate. Trivial now; grows with the device count.

What looks good ✅

  • Concurrency model — UDP ingest fully decoupled from Wialon I/O; per-worker queues with drop-oldest backpressure; stats behind a lock; list() snapshots in the stats/cleanup threads (no "dict changed size").
  • Identity — (ip,port) transport + cell_id state is the right CGNAT-safe shape.
  • Anti-jump — time-aware + self-healing + cleared on disconnect; can't black-hole.
  • Safety — atomic status-file write (.tmp + rename); fail-safe drops; SQL writer is a bounded background thread that never blocks the loop.
  • Security hygiene — availability SQL is parameterized (?), DB opened read-only, free-text escaped (esc()), SSE degrades to "gateway down" cleanly.

Before you test more units — do these first

  1. R1 — make unconfigured units obvious. Two cheap parts: log a one-time WARNING per unknown cell_id, and surface a distinct wialon_state="unconfigured" in the monitor so a new unit that isn't forwarding is self-explanatory.
    # in _process_device_telemetry, where wialon_id is looked up:
    wialon_id = device_cell_id_to_wialon_id.get(cell_id)
    if not wialon_id:
        if cell_id not in _warned_unconfigured:      # module-level set()
            _warned_unconfigured.add(cell_id)
            log.warning(f"[CONFIG] cell_id {cell_id} sin wialon_id — "
                        f"agrégalo a device_config.json o no se reenvía.")
        # still record telemetry/odometer; just don't forward
    
  2. G2 — cap the SQLite growth. A nightly prune keeps the disk safe:
    DELETE FROM gps_positions WHERE received_at < datetime('now','-30 days');
    
    (run from the writer on a daily timer, or a cron sqlite3 one-liner + VACUUM).
  3. G1 — add the index (one line in _create_table, idempotent):
    CREATE INDEX IF NOT EXISTS idx_gps_recv_dev
      ON gps_positions(received_at, device_cell_id);
    
  4. M1 — gate the dashboard. Fastest is nginx Basic Auth on monitor.helgen.dev (one auth_basic + htpasswd), or an IP allowlist for the office/VPN.
  5. R2 — split multi-sentence datagrams (for line in raw.split('\n'): …) so the loop is robust before you wire any new sender (incl. $PHLG).

Verdict

Approve, with the R1/G1/G2 items recommended before the test campaign. The core is production-grade; the gaps are operational (config ergonomics, DB lifecycle, dashboard auth) and quick to close. R1 and G2 are the two that will actually bite you while adding units.