Conversation
…ations, verbatim) These files are live on the production box but were not yet pushed: the site_links endpoint-resolution overlay (device/interface ids + confidence/method/evidence) and the rf_link_state/samples/daily_stats tables fed by the LibreNMS puller. Carried unchanged so the branch matches the deployed schema; drop this commit if a local copy has since been committed.
… for outages - GET /api/site-links - the raw site_links registry (full row incl. the resolved-endpoint overlay and endpoint_confidence/method/evidence); geo/backhauls stays the coordinate-only feed for the map. Filters: ?site_id= (either end), ?media_type=, ?confidence=, ?resolved=1. - GET /api/rf-link-state - the materialised per-device RF health rows (LibreNMS-sourced, ~5-min cadence). ?since= for cheap deltas keyed on synced_at; ?device_id= for one device. baseline_* columns omitted from the resource until the baseline stage lands. - GET /api/outages gains ?from=/?to= (started_at range) and ?per_page= (1-1000, switches to cursorPaginate with an id tiebreak - the table is 2.4M+ rows). Without ?per_page the response is byte-identical to the old capped list, so existing callers are untouched. Feature tests for all three; endpoints are generic (no operator-specific assumptions) so they can go upstream as-is.
Sweep every RouterOS PPPoE concentrator's /ppp/active into a pppoe_sessions table on a 5-minute staggered cadence (isolated Horizon queue, sharded by crc32(device_id), per-device transaction with failure isolation), exposing GET /api/pppoe-sessions (Sanctum-auth, read-only, device/site join, cursor pagination, ?since/?device_id/?username/?q filters). The customer data plane — who is online, on which concentrator, since when — answerable without touching a router. Reuses the existing RouterOsConnection client + shared-credential resolution; SNMP-polled concentrators are out of scope in V1 (RouterOS publishes no active-PPPoE table over SNMP). Deployed and proven on prod: 9,786 sessions from 1,198 concentrators.
…git) rf_link_samples was created RANGE-partitioned by its migration but never added to ManageHistoryPartitions::TABLES, so when the migration's hand-seeded partitions ran out on 2026-08-07 every RF sample insert failed silently - five days of history (Aug 8-10 before the live hotfix) were lost while rf_link_state kept updating and every dashboard looked healthy. This commits the fix that has been running on the box since Aug 11: rf_link_samples joins the managed-tables list, and a new guard logs a warning on every run for any RANGE-partitioned table in the schema that this class does not manage, so the next partitioned table added without a TABLES entry announces itself instead of silently dropping data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
coreyhauer
left a comment
There was a problem hiding this comment.
Solid architecture — the shard/stagger design, the isolated pppoe queue, and the per-device transactional replace are the right shape, and the RouterOS-API-only scope call is correct (there is no /ppp/active over SNMP). But a deep review turned up 16 confirmed issues; the ones below need fixing before this merges. One I've already handled: the rf_link_samples partition-management fix that was running only on the box is now committed onto this branch (3dfc921) — that closes the loop on the Aug 8–10 history loss and makes the migration docblock's claim true.
Must fix — data integrity
1. persist() deletes before the empty-result guard (SweepPppoeSessions.php ~line 170). A read that succeeds at transport but yields zero usable rows (API group loses the ppp read policy; rows whose name is empty get continued away) runs the DELETE, inserts nothing, logs nothing — a healthy concentrator with hundreds of customers online reports 0 sessions, indistinguishable from truly empty. This is the exact failure the class docblock says it prevents. Move the guard above the delete, and log a warning when a previously-populated device returns empty.
2. Delete+reinsert breaks the API's own cursor pagination (PppoeSessionController.php ~line 75). Every sweep gives all rows new autoincrement ids, but the endpoint paginates by id cursor — a client walking ~13k rows across 26 pages silently loses every concentrator swept mid-walk, a different random subset each poll, with 200s all the way. Recommendation: upsert on a natural key (device_id + username/caller-id), delete only rows absent from the new read — like the sibling PollInterfaces/PollSensors do. That stabilizes ids for the cursor and removes ~7.5M dead tuples/day of churn.
3. The ?since= delta contract doesn't hold (~line 51). swept_at is stamped at shard start but rows commit up to a shard-duration later across concurrent staggered shards, so a poller that advances its cursor past a faster shard's stamp permanently misses the slower shard's rows. And disconnects emit no signal at all — a ?since= mirror shows a departed customer online forever. Either stamp at commit time and document the overlap window clients must re-read, or add a tombstone/ended_at model. (Also note: since every active row's swept_at is re-bumped each sweep, the current "delta" returns essentially the full table every 5 minutes anyway.)
Must fix — silent operational traps
4. WithoutOverlapping(...)->dontRelease() silently drops canary runs (SweepPppoeSessionsBatchJob.php ~line 52). A manual --device=X run hashes onto the same shard key the scheduled sweep holds; the job is deleted on pickup with no log, while the command prints success. Give manual runs their own lock key (or run them inline), and log when a job is dropped.
5. Producer/consumer queue-name split (config/horizon.php ~line 323). The supervisor hardcodes ['pppoe'] while jobs read config('mymate.pppoe.queue') — set MYMATE_PPPOE_QUEUE and every sweep orphans silently (~27k dead jobs/day in Redis, no Horizon failures, table just freezes). Make the supervisor read the same config key, or drop the env var.
6. No reaper for devices that leave the fleet (PppoeSweepDispatcher.php ~line 102). Fleet membership is a dispatch-time name-ILIKE; rename a concentrator (or unmonitor it, tighten the filter, disable the feature) and its last sessions are served as "currently online" forever — the endpoint has no freshness filter. Add a stale-row reaper (e.g. delete rows whose swept_at is older than N sweep intervals) and/or a default freshness filter on the endpoint.
Should fix — API robustness
7. parseSince() (~line 86): any all-digit value is treated as epoch-milliseconds, so epoch-seconds (1755000000) or ISO-basic (20260813) resolve to ~1970 and silently defeat the delta; and the createFromTimestampMs call sits outside the try/catch, so an out-of-range numeric 500s instead of 422ing. Accept both epoch scales (disambiguate by magnitude) and move the call inside the catch.
8. Unvalidated query params → 500s: ?username[]=x, ?q[]=x, ?since[]=x all reach the query builder as arrays (PDO binding mismatch / array-to-string). Same hole in SiteLinkController (?media_type=/?confidence=). RfLinkStateController already does this right with a validator returning 422 — copy that pattern.
9. Outages ?to= excludes the named day (OutageController.php ~line 59): ?to=2026-08-13 parses to midnight, so started_at <= midnight drops everything that happened on the 13th. Use endOfDay() for date-only input (or document that to is exclusive).
Worth a pass (confirmed, lower severity)
Unescaped ILIKE metacharacters in ?q= (?q=% matches everything); unbounded get() on /api/rf-link-state (~16k rows) and /api/site-links; RouterOsDuration duplicates CaptureDeviceFacts::parseRouterOsUptime; the crc32-shard loop now has a 6th copy — worth extracting; --dry-run ignores --device/--limit; check swept_at column type vs session TZ (timestamptz vs timestamp skew is deployment-dependent); the horizon env var is read twice.
Happy to talk through any of these. The upsert-on-natural-key change (#2) is the one that reshapes the most code, and it also simplifies #1 and #3 — I'd start there.
What
Adds a PPPoE active-session collector so MyMate can answer who is online, on which concentrator, since when — the customer data plane — without anyone touching a router.
A scheduled command sweeps every RouterOS PPPoE concentrator's
/ppp/activeinto a newpppoe_sessionstable on a 5-minute staggered cadence, and a read endpoint serves it.How
SweepPppoeSessions(Action) — connects via the existingRouterOsConnectionclient + shared-credential resolution (RouterOsTarget::fromDevice, the device'scredential_id), reads/ppp/active/print, parsesname→username,address,caller-id,uptime(RouterOsDuration), and wholesale-replaces that device's rows in one transaction. Any per-device failure logs and skips — never fails the batch, never leaves partial rows.PppoeSweepDispatcher+SweepPppoeSessionsBatchJob— shards the fleet bycrc32(device_id) % 96(same key as the poll dispatcher) onto an isolatedpppoeHorizon queue (supervisor-pppoe), dispatched with queue-side delay staggered acrossmymate.pppoe.stagger_seconds(240s) so ~2,300 concentrators sweep as a trickle, never a thundering herd — and can only ever delay themselves, never polling/ping/discovery.mymate:pppoe:sweepcommand with--limit N/--device ID/--now/--dry-runfor canary runs; scheduledeveryFiveMinutes()inroutes/console.php.GET /api/pppoe-sessions—auth:sanctum+RestrictWritesToAdmins, cursor-paginated,?since=(ISO8601 or epoch-ms) /?device_id=/?username=/?q=filters,device_name+site_idjoin. Matches thesite-links/rf-link-state/outagesenvelope.V1 is RouterOS-only; SNMP-polled concentrators are excluded (RouterOS publishes no active-PPPoE table over SNMP).
Config
All
env()-backed undermymate.pppoe.*:enabled,name_filter(%pppoe%),queue,shards(96),stagger_seconds(240),timeout(5s),job_timeout(300s),max_sessions_per_device(4000). Horizonsupervisor-pppoeprocesses: 10 prod / 1 local.Verified on production
Deployed and run: 9,786 sessions from 1,198 authenticating concentrators; endpoint 401s unauthed, returns joined rows with cursor pagination and working
?q=filter.