Skip to content

Fixed GET /api/v1/fleet/commands timeout in large Fleet deployments - #44297

Merged
getvictor merged 18 commits into
mainfrom
victor/44170-list-mdm-commands-perf
Apr 30, 2026
Merged

Fixed GET /api/v1/fleet/commands timeout in large Fleet deployments#44297
getvictor merged 18 commits into
mainfrom
victor/44170-list-mdm-commands-perf

Conversation

@getvictor

@getvictor getvictor commented Apr 28, 2026

Copy link
Copy Markdown
Member

Related issue: Resolves #44170 and Resolves #44422

Pagination is now pushed into each branch of the merged query, so per-tick work scales with page size instead of total commands. The Windows side was rewritten to avoid a disjunctive join that forced a nested-loop plan. per_page is capped (default 10), page is capped, and order_key is enforced against a closed allowlist on both code paths. Cursor pagination is fixed and is the recommended way to traverse beyond the page cap.

This PR improves but does not fix the use case of fetching commands from all hosts. Deprecate usage without host_identifier: https://github.com/fleetdm/fleet/pull/44392/changes

API doc updates: #44292

Checklist for submitter

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

  • Input data is properly validated, SELECT * is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters.

  • If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes

Testing

Summary by CodeRabbit

  • New Features

    • Enforced pagination on MDM commands list: per_page defaults to 10 (max 1,000) and page is capped at 100; traversal beyond page 100 requires cursor pagination via after.
  • Bug Fixes / Performance

    • Improved MDM command listing performance and de-duplication for large queries; fixed SQL error when combining host identifier with cursor pagination.
  • Validation

    • Requests exceeding pagination caps return 400; invalid sort keys return 422.
  • Tests

    • Added tests for pagination boundaries, cursor behavior, sort-key validation, and error responses.

Reject `per_page > 1000` with a 400 to bound worst-case work on
GET /api/v1/fleet/commands and GET /api/v1/fleet/mdm/commands. Default
`per_page = 10` when omitted, matching the docs and the existing
host-identifier path. Mirrors the activity bounded-context's pattern.
The old shape ran (apple_select) UNION ALL (windows_select) and applied
ORDER BY ... LIMIT N OFFSET M to the merged result, forcing both halves
to fully materialize before pagination. At ~420k Windows commands
(10k+ hosts × ~40 commands each) this materialized half a million rows
per request and timed out at the ALB.

Push the team filter, request_type filter, cursor predicate, ORDER BY,
and an inner LIMIT (page*per_page + per_page + 1) into each branch
before the UNION ALL. With the per_page cap of 1000 from the prior
commit, each branch now returns at most ~1001 rows, the merged set is
at most ~2002, and the outer ORDER BY + LIMIT slices the requested
page in O(per_page) work.

getCombinedMDMCommandsQuery now wraps the new getMDMCommandsSubqueries
helper for the single-command lookup at getMDMCommand, which still
needs the WHERE-prefixed combined form.

Refs #44170.
The Windows sub-statement joined mdm_windows_enrollments with

    ON wmcq.enrollment_id = mwe.id OR wmcr.enrollment_id = mwe.id

The OR forced a nested-loop plan because no single index covers both
sides of the disjunction; on a 10k+ Windows-host loadtest this
contributed materially to the 90s timeout.

Split into two UNION ALL branches: one driven by command_queue
(rows pending or in flight), the other driven by command_results
(rows that produced a result and don't already appear in the queue;
the NOT EXISTS clause prevents double-counting). Each branch joins
mdm_windows_enrollments via a single equality, so the optimizer can
use the table's PRIMARY KEY (id).

Refs #44170.
Cap per_page at 1000, default to 10, push pagination into UNION
branches, and replace the OR-join on the Windows side.
@getvictor

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@getvictor

Copy link
Copy Markdown
Member Author

/agentic_review

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Apr 28, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Remediation recommended

1. Windows updated_at inconsistent🐞 Bug ≡ Correctness
Description
In getMDMCommandsSubqueries, the Windows branches set updated_at to `COALESCE(wmc.updated_at,
wmc.created_at) even when windows_mdm_command_results` is present, so completed Windows commands
may not sort/paginate by “last status update” time. This is inconsistent with the host-scoped
Windows listing and GetMDMWindowsCommandResults, both of which prefer wmcr.updated_at when
available.
Code

server/datastore/mysql/mdm.go[R90-92]

SELECT
  mwe.host_uuid,
  wmc.command_uuid,
Evidence
The unscoped Windows list query joins windows_mdm_command_results but still derives updated_at
from the command row (wmc.*). The host-scoped Windows list uses wcr.updated_at for the results
branch, and the Windows command-results API also computes updated_at as `COALESCE(wmcr.updated_at,
wmc.updated_at). Additionally, saving a Windows response upserts into windows_mdm_command_results`
without updating windows_mdm_commands, so relying on wmc.updated_at cannot reflect result
arrival time.

server/datastore/mysql/mdm.go[89-125]
server/datastore/mysql/mdm.go[384-444]
server/datastore/mysql/microsoft_mdm.go[711-723]
server/datastore/mysql/microsoft_mdm.go[884-893]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The unscoped MDM commands listing (`ListMDMCommands` path B) computes Windows `updated_at` from `windows_mdm_commands` even when a result row exists, making ordering/cursor pagination semantics inconsistent with the host-scoped listing and the Windows command-results query.
### Issue Context
- Host-scoped Windows listing uses `COALESCE(wcr.updated_at, wc.created_at)` for the results branch.
- Windows command-results query uses `COALESCE(wmcr.updated_at, wmc.updated_at)`.
- Saving Windows results upserts `windows_mdm_command_results` and does not update `windows_mdm_commands`, so `wmc.updated_at` will not reflect result arrival.
### Fix Focus Areas
- server/datastore/mysql/mdm.go[89-125]
- server/datastore/mysql/mdm.go[384-444]
- server/datastore/mysql/microsoft_mdm.go[711-723]
- server/datastore/mysql/microsoft_mdm.go[884-893]
### What to change
- In `getMDMCommandsSubqueries` Windows branch A (queue-driven, left-joined results), set `updated_at` to prefer the result timestamp when present, e.g. `COALESCE(wmcr.updated_at, wmc.updated_at, wmc.created_at)`.
- In Windows branch B (results-driven), set `updated_at` to `COALESCE(wmcr.updated_at, wmc.updated_at, wmc.created_at)` (or just `wmcr.updated_at` if guaranteed non-NULL), to align with the meaning used elsewhere.
- Ensure the projected column name remains `updated_at` so the allowlisted ordering continues to work.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Host filter breaks on UNION🐞 Bug ≡ Correctness
Description
getMDMCommandsSubqueries appends the host identifier predicate once to windowsStmt even though
it now contains a UNION ALL, so the filter only applies to the second SELECT (results-driven) and
not the first (queue-driven). Additionally, it accumulates a single params slice across both Apple
and Windows statements, which is incompatible with the list path duplicating baseParams per-branch
(would cause placeholder/arg count mismatch when hostFilter is non-empty).
Code

server/datastore/mysql/mdm.go[R70-83]

+	// The Windows sub-statement is itself a UNION ALL of two branches: one
+	// driven by windows_mdm_command_queue (any command pending or in
+	// flight), the other driven by windows_mdm_command_results (any
+	// command that produced a result). Branch B's NOT EXISTS clause
+	// excludes (command_uuid, enrollment_id) pairs already covered by
+	// branch A so the union does not double-count a single command/host
+	// pair. This shape replaces the previous single SELECT that joined
+	// mdm_windows_enrollments with `wmcq.enrollment_id = mwe.id OR
+	// wmcr.enrollment_id = mwe.id`; the OR forced a nested-loop plan
+	// because no single index covers both sides of the disjunction.
+	windowsStmt = `
SELECT
 mwe.host_uuid,
 wmc.command_uuid,
Evidence
The Windows subquery is a SELECT ... WHERE TRUE UNION ALL SELECT ... WHERE NOT EXISTS (...) block,
but whereFilterHostsByIdentifier is implemented as a simple string append of AND ? IN (...), so
it attaches to the trailing WHERE clause only. Because getMDMCommandsSubqueries reuses a single
params slice while mutating both statements, the returned params correspond to the combined
(Apple+Windows) placeholder order rather than each statement independently, which conflicts with
ListMDMCommands' per-branch execution model.

server/datastore/mysql/mdm.go[42-121]
server/datastore/mysql/mysql.go[1103-1112]
server/datastore/mysql/mdm.go[170-183]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`getMDMCommandsSubqueries` returns Apple/Windows statements “separately”, but host filtering and params are currently built as if the statements will be executed as one combined UNION. With the Windows statement now being an internal UNION ALL, appending the host predicate once filters only the second SELECT, and the shared params slice can’t be safely reused per-statement.
### Issue Context
This helper is used by both the list path (per-branch pagination) and the legacy combined query builder. The list path duplicates params per branch.
### Fix Focus Areas
- server/datastore/mysql/mdm.go[42-121]
- server/datastore/mysql/mysql.go[1103-1112]
### Suggested fix approach
- Change `getMDMCommandsSubqueries` to return **separate params** for Apple vs Windows (e.g., `appleParams`, `windowsParams`).
- For Windows, either:
- apply `whereFilterHostsByIdentifier` to **each UNION branch** before concatenation, or
- build the Windows union from two already-filtered SELECTs.
- Update `getCombinedMDMCommandsQuery` to concatenate `(appleStmt, windowsStmt)` and then concatenate `(appleParams, windowsParams)` in the correct order.
- Update `ListMDMCommands` to use the per-statement params directly (no duplicating a combined param list).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Unbounded page enables huge scans🐞 Bug ➹ Performance
Description
GET /mdm/commands caps per_page but does not validate page, and the new per-branch pagination
computes an inner LIMIT proportional to per_page*(page+1), allowing extremely expensive queries
for large page values. This can reintroduce timeouts (and resource exhaustion) even with
per_page capped.
Code

server/datastore/mysql/mdm.go[R148-160]

+	// Per-branch pagination: each branch carries the team filter, the
+	// request_type filter, the cursor predicate (if any), the ORDER BY,
+	// and an inner LIMIT large enough to cover the requested page if all
+	// matching rows came from this branch alone (page*per_page + per_page +
+	// 1). Without this, the UNION ALL would materialize every command on
+	// both sides before pagination, which times out at scale (#44170).
+	innerOpts := listOpts.ListOptions
+	// Inner LIMIT = page*per_page + per_page; appendListOptionsWithCursorToSQL
+	// adds +1 because IncludeMetadata is true. Inner Page=0 suppresses the
+	// inner OFFSET; the outer wrapper handles offset slicing.
+	innerOpts.PerPage = innerOpts.PerPage*innerOpts.Page + innerOpts.PerPage
+	innerOpts.Page = 0
+	innerOpts.IncludeMetadata = true
Evidence
listOptionsFromRequest only rejects negative page values and does not enforce an upper bound.
The endpoint validation caps only PerPage, and pagination SQL generation multiplies `per_page *
page to compute OFFSET; ListMDMCommands now also multiplies PerPage*Page` to expand the inner
LIMIT, amplifying the amount of work for large page numbers.

server/datastore/mysql/mdm.go[148-160]
server/service/mdm.go[1058-1063]
server/service/transport.go[51-66]
server/platform/mysql/list_options.go[218-227]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Clients can request arbitrarily large `page` values, which forces large OFFSETs and (in this PR) large per-branch inner LIMITs. This can still time out the endpoint at scale despite the `per_page` cap.
### Issue Context
`listMDMCommandsRequest.DecodeBody` currently validates only `per_page` but not `page`.
### Fix Focus Areas
- server/service/mdm.go[1028-1064]
- server/service/transport.go[51-66]
- server/datastore/mysql/mdm.go[148-160]
### Suggested fix approach
- In `listMDMCommandsRequest.DecodeBody`, reject `req.ListOptions.Page` above a reasonable maximum (or require cursor-based pagination for deep paging).
- Optionally add a safe-guard in the datastore to cap `innerOpts.PerPage` to a maximum computed limit (and/or detect overflow) and return a clear bad request error.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Drop SQL implementation details (UNION ALL, OR-join, indexable
branches) in favor of the user-facing symptom (slow load times,
timeouts) and the only behavior change that matters to a Fleet
operator (the 1000 per_page cap and the default of 10).
@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Reworks MDM command listing: builds Apple and Windows branches separately, pushes ORDER BY/LIMIT into each UNION branch, and splits Windows into queue-driven and results-driven branches with deduplication via NOT EXISTS. Validates and defaults pagination/order options (Default per_page=10, Max per_page=1000, Max page=100), rejects PerPage==0, and enforces an order_key allowlist. Hostname is projected in SQL for host-scoped queries. Adjusts orbit/consumer call to request a single AccountConfiguration result. Adds/updates integration and datastore tests covering pagination, ordering, and cursor behavior.

Possibly related PRs

  • Fix bug in MDM command listing #32992: Alters server/datastore/mysql/mdm.go MDM command-listing SQL and team-filter application, overlapping with combined-query and team-filter refactoring in this change.
  • Improve filtering on commands endpoints #44426: Modifies MDM command listing SQL and request validation for order/sort keys and cursor pagination, directly related to the order_key allowlist and pagination caps implemented here.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main objective: fixing timeout issues in the GET /api/v1/fleet/commands endpoint for large Fleet deployments.
Linked Issues check ✅ Passed All code changes directly address the linked issues: #44170 (preventing O(total_commands) work via inner LIMIT, Windows OR-join refactor, nano_view_queue inlining) and #44422 (fixing column aliasing for single-host path with order_key=command_uuid/hostname).
Out of Scope Changes check ✅ Passed All changes are in scope: pagination constants, datastore query refactoring, test additions, endpoint validation, and helper method cleanup directly support the performance and bug-fix objectives without unrelated modifications.
Description check ✅ Passed The PR description includes all required sections from the template: related issue numbers, completed checklist items covering security validation, SQL injection prevention, testing coverage, and manual QA.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch victor/44170-list-mdm-commands-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/datastore/mysql/mdm.go (1)

50-68: ⚠️ Potential issue | 🟠 Major

Inline nano_view_queue here instead of wrapping it.

The Apple branch still reads from nano_view_queue, which #44170 identifies as the remaining materialization hotspot because the view bakes in its own ORDER BY. Putting the new per-branch LIMIT outside that view won't recover the intended index-driven plan, so large Apple fleets can still pay the full scan/materialization cost.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/datastore/mysql/mdm.go` around lines 50 - 68, The appleStmt query
currently selects from the materialized view "nano_view_queue" which contains
its own ORDER BY and forces view materialization; inline the view's underlying
SELECT (replace the FROM nano_view_queue nvq with the view's SELECT
projection/joins/filters) so there is no baked-in ORDER BY inside the referenced
table expression, ensure any ORDER BY and LIMIT are applied outside this in the
per-branch query, and keep the existing column aliases (nvq.id as host_uuid,
nvq.command_uuid, etc.) so the appleStmt, its callers, and the host join
(h.uuid) still match the symbols used in the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/datastore/mysql/mdm.go`:
- Around line 162-167: The pagination helpers use
appendListOptionsWithCursorToSQL with user-controlled listOpts, allowing a
malicious order_key to inject SQL; replace both calls (the one inside
paginateBranch and the corresponding outer merge call) with
appendListOptionsWithCursorToSQLSecure and pass an explicit allowlist of allowed
order columns (e.g., the canonical host columns used elsewhere in this file).
Keep addRequestTypeFilter and ds.whereFilterHostsByTeams calls as-is, but ensure
you pass the same params/innerOpts variables to the secure helper and return the
wrapped SQL and params unchanged.

In `@server/service/mdm.go`:
- Around line 1058-1062: The per_page cap (req.ListOptions.PerPage vs
maxMDMCommandsPerPage) doesn't prevent large page values from making the
datastore compute branch limits as page*per_page + per_page; add validation for
req.ListOptions.Page (e.g., enforce a maxPage or compute maxPage =
floor(maxRowsAllowed / req.ListOptions.PerPage)) and return a
fleet.BadRequestError with a clear message when page is too large, or
alternatively require cursor-only pagination for deep traversal; apply the same
check where similar pagination is handled (the block around lines 1078-1080) so
oversized page values are rejected consistently.

---

Outside diff comments:
In `@server/datastore/mysql/mdm.go`:
- Around line 50-68: The appleStmt query currently selects from the materialized
view "nano_view_queue" which contains its own ORDER BY and forces view
materialization; inline the view's underlying SELECT (replace the FROM
nano_view_queue nvq with the view's SELECT projection/joins/filters) so there is
no baked-in ORDER BY inside the referenced table expression, ensure any ORDER BY
and LIMIT are applied outside this in the per-branch query, and keep the
existing column aliases (nvq.id as host_uuid, nvq.command_uuid, etc.) so the
appleStmt, its callers, and the host join (h.uuid) still match the symbols used
in the code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 41a70c4f-ed18-451c-a6ca-f3d8058414db

📥 Commits

Reviewing files that changed from the base of the PR and between b3172f3 and 9de81a6.

📒 Files selected for processing (4)
  • changes/44170-list-mdm-commands-perf
  • server/datastore/mysql/mdm.go
  • server/service/integration_mdm_test.go
  • server/service/mdm.go

Comment thread server/datastore/mysql/mdm.go Outdated
Comment thread server/service/mdm.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves performance and reliability of the MDM command listing endpoints (GET /api/v1/fleet/commands and deprecated /mdm/commands) in large deployments by bounding pagination and restructuring the underlying MySQL query to avoid expensive UNION materialization and non-indexable joins.

Changes:

  • Add endpoint-level pagination defaults/caps (per_page defaults to 10, max 1000) with validation and integration test coverage.
  • Refactor the datastore query to push pagination into each UNION branch and replace the Windows OR enrollment join with two UNION-able, index-friendly branches.
  • Add a changelog entry describing the performance fix.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.

File Description
server/service/mdm.go Enforces per-page default and maximum for the commands list endpoint.
server/service/integration_mdm_test.go Adds integration assertions for per_page cap behavior.
server/datastore/mysql/mdm.go Reworks combined Apple/Windows commands listing query to paginate per-branch and avoid OR joins.
changes/44170-list-mdm-commands-perf Documents the performance fix in the changelog.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/datastore/mysql/mdm.go Outdated
Comment thread server/datastore/mysql/mdm.go Outdated
Comment thread server/datastore/mysql/mdm.go Outdated
Fixes from bot reviews on PR #44297:

- SQL injection (CodeRabbit, critical): a crafted order_key reached
  ORDER BY through appendListOptionsWithCursorToSQL, which has no
  allowlist. Switch both the inner-branch and outer-merge calls to the
  Secure variant with an explicit allowlist of supported columns
  (host_uuid, command_uuid, status, updated_at, request_type, hostname).
  The helper's InvalidOrderKeyError is a 422 validation error.

- Page cap (CodeRabbit + Qodo, perf): the per_page cap from the prior
  commit didn't bound the page parameter, so ?page=500000&per_page=1000
  still asked each branch for ~500M rows. Cap page at 100 (max
  in-bounds offset 100*1000 = 100,000) and recommend cursor pagination
  for deeper traversal.

- Cursor + page interplay (Copilot): zero outerOpts.Page when After is
  set so the outer query does not OFFSET past per-branch cursor
  results.

- Datastore-layer defaults (Copilot): default OrderKey=updated_at and
  PerPage=10 inside ListMDMCommands path B for callers that bypass the
  endpoint, matching path A's hygiene.

- Drop dead host_filter parameter (Copilot + Qodo): the parameter was
  always "" in practice, and the existing implementation had a latent
  UNION-ALL-only-second-branch-filtered bug if it ever became
  non-empty. Path A handles host scoping; remove the dead parameter.
@codecov

codecov Bot commented Apr 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.68085% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.78%. Comparing base (39429c2) to head (1a91690).
⚠️ Report is 36 commits behind head on main.

Files with missing lines Patch % Lines
server/datastore/mysql/mdm.go 94.04% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #44297      +/-   ##
==========================================
+ Coverage   66.75%   66.78%   +0.02%     
==========================================
  Files        2633     2634       +1     
  Lines      211720   211858     +138     
  Branches     9505     9538      +33     
==========================================
+ Hits       141338   141480     +142     
+ Misses      57541    57534       -7     
- Partials    12841    12844       +3     
Flag Coverage Δ
backend 68.55% <94.68%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The helper had two callers (getMDMCommandsSubqueries and
getCombinedMDMCommandsQuery), both of which lost their hostFilter
parameter in the prior commit. The full golangci-lint suite (unused
linter) flagged it; the incremental lint missed it because the function
was already on main and only its callers changed.

Also drop the dangling reference to it in two doc comments inside
mdm.go that contrasted the old approach.
@getvictor

Copy link
Copy Markdown
Member Author

@JordanMontgomery FYI. I'm making optimizations to the /command endpoint. Using the secure method.

The unscoped path (path B) already validates order_key against an
allowlist via appendListOptionsWithCursorToSQLSecure, but the
host-scoped path (listMDMCommandsByHostIdentifier, path A) was still
using the non-secure helper. That left the same SQL injection /
information-disclosure surface CodeRabbit flagged on path B, just
under a different query string.

Lift the allowlist to a package var (mdmCommandsOrderAllowlist) and
have both paths reference it. Drop hostname from the allowlist:
path A doesn't project hostname in SQL (it's merged from byUUID after
the SELECT), so allowing it would have errored on path A, and a
single-host scope makes hostname sort meaningless anyway.

Allowed order_keys: host_uuid, command_uuid, status, updated_at,
request_type. Other values get a 422 from either path.
Path A's apple and windows branches now JOIN hosts and project
h.hostname, mirroring what path B's subqueries already do. The
mdmCommandsOrderAllowlist gets hostname back, so order_key=hostname
works on both paths with honest semantics (the SQL actually orders by
hostname, which matters for cursor pagination correctness on the
unscoped path).

The byUUID merge in path A no longer needs to set Hostname (SQL
provides it); team_id still merges from byUUID since it's not in any
projection.

Also extends the integration test to assert order_key=hostname returns
200 on both the unscoped and host-scoped paths.

The hosts JOIN is keyed on hosts.uuid (idx_hosts_uuid), which combined
with the existing host_identifier filter is a primary-key seek per
host — negligible cost.
@getvictor

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@getvictor

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@getvictor

Copy link
Copy Markdown
Member Author

/agentic_review

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Apr 29, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit aac5461

@getvictor

Copy link
Copy Markdown
Member Author

@claude review once

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
server/datastore/mysql/mdm.go (2)

53-71: ⚠️ Potential issue | 🟠 Major

Inline the Apple branch instead of reading nano_view_queue.

The all-hosts path still pulls Apple commands from nano_view_queue, so the optimizer can still inherit the view’s baked-in join/order work before your per-branch LIMIT runs. That leaves the Apple side of GET /api/v1/fleet/commands exposed to the same O(total_commands) behavior this PR is trying to remove.


486-500: ⚠️ Potential issue | 🟠 Major

Keep list params separate from count params.

appendListOptionsWithCursorToSQLSecure appends the cursor value to params. On host-scoped requests that filter to pending commands and use after, countStmt is executed with that extra bind even though the count SQL has no cursor placeholder, so this path will fail at runtime.

🛠️ Minimal fix
-	var listStmt, countStmt string
-	var params []any
+	var listStmt, countStmt string
+	var params []any
 	// Wrap in `SELECT * FROM (...) u WHERE TRUE` so the cursor and ORDER BY
 	// predicates resolve against the unambiguous `u` projection — the inner
 	// branches join multiple tables that all expose `command_uuid` / `updated_at`.
 	// `WHERE TRUE` is required because the cursor helper picks AND vs WHERE by
 	// substring-matching "where", picks AND from the inner branches, and would
@@
 	case len(winUUIDs) > 0:
 		listStmt = `SELECT * FROM (` + winStmt + `) u WHERE TRUE`
 		countStmt = `SELECT COUNT(1) FROM (` + winStmt + `) u`
 		params = winParams
 	}
+	countParams := append([]any{}, params...)
@@
-	listStmt, params, err = appendListOptionsWithCursorToSQLSecure(listStmt, params, &listOpts.ListOptions, mdmCommandsOrderAllowlist)
+	listParams, params, err := appendListOptionsWithCursorToSQLSecure(listStmt, params, &listOpts.ListOptions, mdmCommandsOrderAllowlist)
 	if err != nil {
 		return nil, nil, nil, ctxerr.Wrap(ctx, err, "list commands pagination")
 	}
+	listStmt = listParams
@@
-	if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, listStmt, params...); err != nil {
+	if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, listStmt, params...); err != nil {
 		return nil, nil, nil, ctxerr.Wrap(ctx, err, "list commands")
 	}
@@
-		if err := sqlx.GetContext(ctx, ds.reader(ctx), &total, countStmt, params...); err != nil {
+		if err := sqlx.GetContext(ctx, ds.reader(ctx), &total, countStmt, countParams...); err != nil {
 			return nil, nil, nil, ctxerr.Wrap(ctx, err, "count commands")
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/datastore/mysql/mdm.go` around lines 486 - 500, The code reuses the
same params slice for both listStmt and countStmt;
appendListOptionsWithCursorToSQLSecure appends a cursor to params which causes
countStmt to be executed with an extra bind and fail. Fix by preserving a
separate params slice for the count before appending the cursor: copy the
original params (e.g., paramsForCount := append([]interface{}(nil), params...))
then call appendListOptionsWithCursorToSQLSecure to get the params for list
(leave paramsForCount unchanged) and use paramsForCount with countStmt; update
references around appendListOptionsWithCursorToSQLSecure, listStmt, countStmt,
params and listOpts.ListOptions accordingly.
🧹 Nitpick comments (1)
server/datastore/mysql/mdm_test.go (1)

770-789: Exercise the Windows results-backed branch in this pagination setup too.

This fixture only creates queued Windows commands, so the all-hosts pagination assertions never hit the results-backed Windows UNION branch or its dedupe path. A regression there would still pass this test. Consider marking one Windows command as responded before the page/cursor assertions and verifying its UUID still appears exactly once across the walk.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/datastore/mysql/mdm_test.go` around lines 770 - 789, The test
currently only enqueues Windows commands (via
ds.MDMWindowsInsertCommandForHosts) so the results-backed Windows branch isn't
exercised; update the fixture to mark one of the inserted Windows commands as
responded before the pagination/cursor assertions by taking one cmdUUID from
allUUIDs (or capture the cmdUUID when creating Windows commands) and inserting a
corresponding result for that UUID (e.g., call the datastore helper that records
Windows command results such as ds.MDMWindowsInsertCommandResult or the existing
test helper that writes a command response for winDeviceID and the chosen
cmdUUID), then proceed with the pagination walk and assert the chosen UUID still
appears exactly once.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@server/datastore/mysql/mdm.go`:
- Around line 486-500: The code reuses the same params slice for both listStmt
and countStmt; appendListOptionsWithCursorToSQLSecure appends a cursor to params
which causes countStmt to be executed with an extra bind and fail. Fix by
preserving a separate params slice for the count before appending the cursor:
copy the original params (e.g., paramsForCount := append([]interface{}(nil),
params...)) then call appendListOptionsWithCursorToSQLSecure to get the params
for list (leave paramsForCount unchanged) and use paramsForCount with countStmt;
update references around appendListOptionsWithCursorToSQLSecure, listStmt,
countStmt, params and listOpts.ListOptions accordingly.

---

Nitpick comments:
In `@server/datastore/mysql/mdm_test.go`:
- Around line 770-789: The test currently only enqueues Windows commands (via
ds.MDMWindowsInsertCommandForHosts) so the results-backed Windows branch isn't
exercised; update the fixture to mark one of the inserted Windows commands as
responded before the pagination/cursor assertions by taking one cmdUUID from
allUUIDs (or capture the cmdUUID when creating Windows commands) and inserting a
corresponding result for that UUID (e.g., call the datastore helper that records
Windows command results such as ds.MDMWindowsInsertCommandResult or the existing
test helper that writes a command response for winDeviceID and the chosen
cmdUUID), then proceed with the pagination walk and assert the chosen UUID still
appears exactly once.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ac06ba82-362c-4fb0-98f3-aa3f6ac47652

📥 Commits

Reviewing files that changed from the base of the PR and between 875feb8 and aac5461.

📒 Files selected for processing (10)
  • changes/44170-list-mdm-commands-perf
  • changes/44422-list-mdm-commands-host-identifier-after
  • ee/server/service/orbit.go
  • server/datastore/mysql/mdm.go
  • server/datastore/mysql/mdm_test.go
  • server/datastore/mysql/mysql.go
  • server/fleet/mdm.go
  • server/service/integration_mdm_test.go
  • server/service/mdm.go
  • server/service/orbit.go
💤 Files with no reviewable changes (1)
  • server/datastore/mysql/mysql.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 10 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/datastore/mysql/mdm.go
Comment thread server/datastore/mysql/mdm.go
Comment thread server/datastore/mysql/mdm.go
@getvictor
getvictor marked this pull request as ready for review April 29, 2026 22:02
@getvictor
getvictor requested a review from a team as a code owner April 29, 2026 22:02

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

h.team_id,
nvq.name
FROM
nano_view_queue nvq

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#44170
Lists
nano_view_queue is a VIEW with ORDER BY baked in (migration 20260409153713_AddNameToNanoCommands). MySQL re-materializes the view (3-way join over nano_enrollment_queue, nano_commands, nano_command_results with ORDER BY q.priority DESC, q.created_at) on every list query. No index can help because the ordering is post-join.

Is that still a problem here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but I didn't want to expand the scope beyond Windows. It affects the now-deprecated code path. I filed a separate issue: #44509

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh gotcha! Sounds great!

@ksykulev ksykulev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks good. One last question.

WHERE NOT EXISTS (
SELECT 1 FROM windows_mdm_command_queue wmcq2
WHERE wmcq2.command_uuid = wmc.command_uuid AND wmcq2.enrollment_id = mwe.id
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is covered by the index correct? Does the optimizer pivot to start from enrollment id?
PRIMARY KEY (enrollment_id,command_uuid), <--

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, covered by the index. The optimizer pivots enrollment_id into the leading position automatically.

@getvictor
getvictor requested a review from ksykulev April 30, 2026 20:01
@getvictor

Copy link
Copy Markdown
Member Author

@ksykulev Ready for re-review

@getvictor
getvictor merged commit 2723c13 into main Apr 30, 2026
56 checks passed
@getvictor
getvictor deleted the victor/44170-list-mdm-commands-perf branch April 30, 2026 20:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants