[Ideas] Devices list — perf fix + scale plan (LATERAL latest-metrics, cap lift, server-side pagination) #742
Replies: 3 comments
|
Thanks @bdunncompany — went through all three against the code and the analysis holds end to end. Splitting into three independent PRs is the right call; taking them one at a time. PR 1 — LATERAL + LIMIT 1 (perf). Green light. Confirmed PR 2 — cap lift. Already shipped — PR 3 — server-side pagination. Green light on the architecture, and you were right to settle the four design questions before code rather than in review. Where I've landed on all four:
Two repo-shape constraints to fold in:
Keep PR 1 and PR 3 independent — PR 1 can land first and makes every PR 3 page fast. PR welcome on both. |
|
Quick status checkpoint for anyone landing here:
|
|
Closing — all three PRs from the plan have landed:
The Devices list now runs on keyset cursor pagination end-to-end. Thanks for the detailed perf writeup and scale plan. |
Uh oh!
There was an error while loading. Please reload this page.
Devices list: perf fix + scale plan (short + long-term)
I've been digging into
GET /devicesand want to surface three connected issues that I think are worth resolving together, even though the fixes split cleanly into three independent PRs.What I was looking at
The visible symptom was the Devices page being slow on a 70-device fleet — about 6–8 seconds to render. Profiling pointed at one query, not the UI.
The latest-metrics lookup in
routes/devices/core.tsuses aGROUP BY MAX(timestamp)+ self-join againstdevice_metrics. To produce 70 rows of "latest sample" it reads every metric row for every device in the page to compute the max timestamp. LiveEXPLAIN ANALYZEon production data with ~8,993 rows per device:Both run against the same
(device_id, timestamp)primary key. The old plan reads the full per-device history (8,981 × 70 ≈ 628k row fetches) and then re-joins to pick the row atmax(timestamp). The LATERAL form does one backward index seek per device —LIMIT 1stops the scan after a single tuple. The new shape's cost is constant per device regardless of metric retention depth; the old shape gets linearly worse as history grows.I also tested
DISTINCT ON (device_id) … ORDER BY device_id, timestamp DESC. It was actually slower than the existing query (~2,267 ms), because the planner sorted the full set on disk before picking one per device — the optimizer didn't find the index-scan-backward path that LATERAL forces. So LATERAL isn't just "an option," it's the one that gets the right plan.GROUP BY MAX+ self-join (current)DISTINCT ON … ORDER BY(tested)LATERAL + LIMIT 1(proposed)That's roughly 400× on this dataset. While reading the code path for that fix two related structural issues jumped out.
Issue 2: 100-device cap
GET /devicesis capped atlimit=100(viagetPagination's defaultmaxLimit). The webDevicesPagecurrently fetches once with no explicit limit, so a partner-scope user sees at most 100 devices regardless of how many are actually enrolled. Once past ~100 endpoints, the list silently truncates — there's no "next page" UI because pagination happens client-side over whatever the single fetch returned.For very small fleets this isn't visible. The day you hit 101 it is.
The quick fix is small: raise the
/devicescap (other routes keep the default 100) and have the web fetch ask for it. The natural ceiling is around 500 devices (~1 MB of JSON, well within what the page renders fast). Two ways to do it:core.ts, one query param inDevicesPage.tsx. Three changed lines total.BREEZE_DEVICES_LIST_MAX=500, default 500): same diff plus the env-var plumbing. Lets a self-hoster with 800 endpoints raise it without a code change.I lean toward the env-var path because the alternative just moves the cliff from 100 to 500, but I'd take your read.
Mobile note:
apps/mobile/src/services/api.ts:436also hits/devices, but it uses the default page size (no explicit limit param). The cap change doesn't affect mobile unless someone separately bumps that call site, which is out of scope for this fix.Adjacent to but separate from this: Discussion #684 (per-page-size selector, merged via #705) already gave users 10–200 per-page UI control. Path A makes that selector actually work above 100. Path B is what makes it work above 500.
This unblocks 100–500 device fleets (or higher with the env-var path) immediately with negligible risk and no UX changes.
Issue 3: the structural problem the cap lift doesn't solve
For fleets above 500 (MSPs running 1000–3000+ endpoints across many orgs), lifting the cap doesn't help — the architecture itself is wrong for that size. Current shape:
page,limit, basic filters (siteId,status,osType,orgId,searchon hostname) and returns{ data, pagination: { page, limit, total } }. So the bones are there.sort+sortDirparameters (currently hardcodedlastSeenAt DESC), and multi-value or composite filters that match the UI's filter set.DeviceListneeds to stop doingsortedDevices.slice()and become a controlled component that requests a new page on every sort/filter/page/search change.DevicesPage(or a wrapper) owns the (page, pageSize, sort, filters) state and refetches on change. Search needs debouncing.The design target I'd argue for: no user-visible cap on total fleet size. A user with 10,000 endpoints should be able to see, sort, filter, and act on every one of them — just not in a single HTTP round trip. The way you make that work without unbounded memory or query cost is cursor-based pagination with an internal per-page safety cap that the user doesn't have to think about. Walking the cursor chain is "give me everything"; the per-page max only exists to keep any one response bounded.
What I'd propose
A coherent design across four sub-decisions. None of these are dogmatic — laying them out together because they hang together.
(last_seen_at DESC, id). Stable under churn (every agent check-in bumpslast_seen_at, which would shift rows between pages under offset pagination), constant per-page cost, scales to any fleet size. Trade-off: no random-access "jump to page 47," only forward/back. For an RMM device list I think that's correct — users search/filter to narrow, they don't navigate by page number.includeTotal=true, off by default. A separatecount(*)isn't free; let the Devices page opt in for "showing 1–25 of 312," let scripts/bulk consumers walk the cursor without paying. Skips the "approximate count" path entirely, which always feels wrong in an admin UI.listDevicesSchemawith first-class params (status, osType, role, orgIds[], siteIds[], groupIds[], search); keepFilterConditionGroupas the escape hatch. Matches the pattern already in the codebase. Common path stays fast and predictable; advanced filter stays for the long tail.Why these four together: cursor + optional-total + first-class-filters + 1000-cap form a design where the user sees "no cap on total fleet size" while every individual request is bounded for safety. A user with 10,000 endpoints walks the cursor chain to access all of them; the UI shows "showing 1–25 of 312" when it wants; scripts get fast responses without paying for counts they won't read; and no caller can blow up the server with a
limit=1000000request.Where I'm least confident
last_seen_atcan update between your "next page" request and the server's read of it; the cursor might briefly skip or duplicate a row at the boundary. Theidtiebreaker helps but doesn't fully eliminate the case. Mitigation: stabilize on a less-volatile sort key (e.g.,enrolled_at) for the cursor and sort client-side within the page, or accept the rare boundary anomaly as the cost. Want your read.Happy to make any of these into a real spec doc before writing code — they're at the level of "broad intent" not "API contract" yet.
TimescaleDB compatibility
apps/api/migrations/optional/includes TimescaleDB hypertable migrations fordevice_metrics.LATERAL + LIMIT 1against the chunk-aware index is the recommended shape for "latest per series" queries on Timescale and gets even better there than on stock PG (chunk pruning skips old chunks entirely). I tested on stock PG; I haven't bench-tested with the Timescale hypertable. No reason to expect regression — flagging in case you have a Timescale env handy to spot-check.Proposed PRs
If this framing makes sense to you, I'd split into three independent PRs you can approve separately:
routes/devices/core.ts), no schema change, no API contract change. Identical output payload. Has a unit-test update in the same PR that pins the new query shape and asserts response mapping. Tested live in production (the perf numbers above are from this exact change running against real data)./devicesmaxLimitto 500 (or env-var driven if you prefer) + web fetch asks for it. Two files. Tactical band-aid that's still useful on its own because most early adopters are well under 500 devices.PR 1 and PR 2 are independent — either can land alone without affecting the other. Happy to open both as soon as you greenlight. PR 3 I'd prefer to do as a design doc / spec first so we're not negotiating cursor vs offset in review comments.
All reactions