Summary
The alert and incident data streams paginate the Microsoft Graph security API using $top + incrementing $skip while holding a fixed $filter. This collides with three hard limits of the Graph security API and leads to 400 errors, wasted/stalled polling, and a latent data-loss edge case under high volume. The current default config mostly avoids the worst symptom by accident, but the mechanism is not sound and should be reworked before we lean on these streams for agentless GA.
vulnerability is not affected — it uses a different API (Defender machines API) and already paginates correctly via @odata.nextLink.
Affected
packages/m365_defender/data_stream/alert/agent/stream/httpjson.yml.hbs ($top default batch_size=2000, interval=5m)
packages/m365_defender/data_stream/incident/agent/stream/httpjson.yml.hbs ($top default batch_size=50, interval=1m, $expand=alerts)
Background — Graph security API constraints
Per the Microsoft Graph Security API error responses / Constraints:
$top is capped at 1000. Values above are clamped, not rejected.
$skip is capped at 500 — $skip=501 returns 400 Bad Request.
- The documented way to page past these limits is to advance a
$filter on the timestamp (eventDateTime/lastUpdateDateTime), i.e. move the time boundary forward — not $skip.
Additionally, empirically, alerts_v2 does not return @odata.nextLink (verified below), so nextLink-based pagination is not available for these two streams.
The problem
Current pagination (both streams) keeps $filter fixed and grows $skip:
response.pagination:
- set: url.params.$filter = '[[.last_response.url.params.Get "$filter"]]' # unchanged
- set: url.params.$skip = '[[if (eq (len .last_response.body.value) BATCH)]][[add prevSkip BATCH]][[end]]'
Because $filter is only used as a cross-interval cursor (never advanced within a run), within-run paging is pure $skip offset paging and therefore subject to the $skip ≤ 500 cap. Behavior by batch_size:
batch_size |
Behavior |
Result |
| 2000 (current alert default) |
server clamps page to ≤1000; predicate len==2000 never true → stops after 1 page |
no $skip paging, but ≤1000 records/interval; the 2000 is dead/misleading |
| 1500 |
same as 2000 (≤1000 ≠ 1500) |
≤1000 records/interval |
| 1000 |
full page len==1000==batch → page 2 $skip=1000 > 500 → 400 |
errors every high-volume interval, stall |
≤500 (e.g. incident 50) |
real $skip paging until $skip crosses 500 → 400 on the deep page under load |
plus offset-shift risk (below) |
Consequences:
400 errors + stalled catch-up whenever a poll window has more records than the offset cap allows.
- Slow real-time ingestion — with
batch_size > 1000 the stream fetches ≤1000 records per interval, so a backlog drains at ≤1000/interval and new alerts queue behind it.
- Latent within-run data loss (edge case): the order/cursor key
lastUpdateDateTime is also the mutable field. If a record is updated mid-pagination it shifts offsets, and an item at the boundary can be skipped; if its timestamp is below the final cursor, the next interval's ge filter misses it → permanent loss. Requires concurrent updates during a multi-page $skip run (so mainly the smaller-batch incident stream).
No duplicate documents result from boundary re-fetches: both streams set a deterministic _id via fingerprint (including lastUpdateDateTime), so re-fetching the same version overwrites.
Evidence
$top is clamped, not rejected: Live API response - runs alert with $top=2000 and returns 200.
- No
@odata.nextLink: a live GET /v1.0/security/alerts_v2?$top=5 returns only @odata.context + value (5 items) and no @odata.nextLink / @odata.count, despite more alerts existing.
- Precision: returned timestamps are 7-digit sub-second (e.g.
2026-04-15T09:45:29.3666667Z).
- Docs:
$top ≤ 1000, $skip ≤ 500 (constraints); List incidents; Paging.
Proposed fix
Rework alert/incident pagination to advance $filter instead of $skip (keeping within-run pagination so backfill stays fast):
- Keep
$orderby lastUpdateDateTime asc.
- In
response.pagination, when the page is full (len == batch_size): set $filter = 'lastUpdateDateTime gt <max lastUpdateDateTime in page>' and reset $skip=0; otherwise stop (non-full page = last page).
- Use
gt at full timestamp precision (do not truncate to milliseconds — that would reintroduce ties). At 7-digit (100 ns) precision, exact ties are negligible; gt avoids the ge infinite-loop-on-identical-timestamp-page case, and the fingerprint _id absorbs any rare boundary re-fetch.
- Correct the
alert batch_size default (2000 is above the Graph cap and never triggers within-run paging); pick a value ≤ 1000.
This keeps $skip at 0 (no 400), drains a full backlog in one run (fast catch-up, decoupled from interval), and needs no @odata.nextLink.
Alternatives considered
- Follow
@odata.nextLink (like the vulnerability stream): not viable — alerts_v2 doesn't return it.
- MDE-style cross-interval
gt cursor with no within-run pagination (microsoft_defender_endpoint/data_stream/log): correct and simple, but fetches only one page per interval, so backfill/catch-up time scales with interval × backlog — poor for a high-volume stream and sensitive to user-set interval/initial_interval.
Notes / related
- Surfaced during the agentless ORR for
m365_defender; the package currently requests 4Gi for agentless (manifest.yml) citing in-memory volume. Pagination and per-page memory are being reviewed together.
- Enforcing numeric bounds on
batch_size is tracked separately in elastic/package-spec#1176.
Summary
The
alertandincidentdata streams paginate the Microsoft Graph security API using$top+ incrementing$skipwhile holding a fixed$filter. This collides with three hard limits of the Graph security API and leads to400errors, wasted/stalled polling, and a latent data-loss edge case under high volume. The current default config mostly avoids the worst symptom by accident, but the mechanism is not sound and should be reworked before we lean on these streams for agentless GA.vulnerabilityis not affected — it uses a different API (Defender machines API) and already paginates correctly via@odata.nextLink.Affected
packages/m365_defender/data_stream/alert/agent/stream/httpjson.yml.hbs($topdefaultbatch_size=2000,interval=5m)packages/m365_defender/data_stream/incident/agent/stream/httpjson.yml.hbs($topdefaultbatch_size=50,interval=1m,$expand=alerts)Background — Graph security API constraints
Per the Microsoft Graph Security API error responses / Constraints:
$topis capped at 1000. Values above are clamped, not rejected.$skipis capped at 500 —$skip=501returns400 Bad Request.$filteron the timestamp (eventDateTime/lastUpdateDateTime), i.e. move the time boundary forward — not$skip.Additionally, empirically,
alerts_v2does not return@odata.nextLink(verified below), so nextLink-based pagination is not available for these two streams.The problem
Current pagination (both streams) keeps
$filterfixed and grows$skip:Because
$filteris only used as a cross-interval cursor (never advanced within a run), within-run paging is pure$skipoffset paging and therefore subject to the$skip ≤ 500cap. Behavior bybatch_size:batch_sizelen==2000never true → stops after 1 page$skippaging, but ≤1000 records/interval; the2000is dead/misleading≤1000 ≠ 1500)len==1000==batch→ page 2$skip=1000 > 500→40050)$skippaging until$skipcrosses 500 →400on the deep page under loadConsequences:
400errors + stalled catch-up whenever a poll window has more records than the offset cap allows.batch_size > 1000the stream fetches ≤1000 records per interval, so a backlog drains at ≤1000/interval and new alerts queue behind it.lastUpdateDateTimeis also the mutable field. If a record is updated mid-pagination it shifts offsets, and an item at the boundary can be skipped; if its timestamp is below the final cursor, the next interval'sgefilter misses it → permanent loss. Requires concurrent updates during a multi-page$skiprun (so mainly the smaller-batchincidentstream).No duplicate documents result from boundary re-fetches: both streams set a deterministic
_idviafingerprint(includinglastUpdateDateTime), so re-fetching the same version overwrites.Evidence
$topis clamped, not rejected: Live API response - runsalertwith$top=2000and returns 200.@odata.nextLink: a liveGET /v1.0/security/alerts_v2?$top=5returns only@odata.context+value(5 items) and no@odata.nextLink/@odata.count, despite more alerts existing.2026-04-15T09:45:29.3666667Z).$top ≤ 1000,$skip ≤ 500(constraints); List incidents; Paging.Proposed fix
Rework
alert/incidentpagination to advance$filterinstead of$skip(keeping within-run pagination so backfill stays fast):$orderby lastUpdateDateTime asc.response.pagination, when the page is full (len == batch_size): set$filter = 'lastUpdateDateTime gt <max lastUpdateDateTime in page>'and reset$skip=0; otherwise stop (non-full page = last page).gtat full timestamp precision (do not truncate to milliseconds — that would reintroduce ties). At 7-digit (100 ns) precision, exact ties are negligible;gtavoids thegeinfinite-loop-on-identical-timestamp-page case, and the fingerprint_idabsorbs any rare boundary re-fetch.alertbatch_sizedefault (2000 is above the Graph cap and never triggers within-run paging); pick a value ≤ 1000.This keeps
$skipat 0 (no400), drains a full backlog in one run (fast catch-up, decoupled frominterval), and needs no@odata.nextLink.Alternatives considered
@odata.nextLink(like thevulnerabilitystream): not viable —alerts_v2doesn't return it.gtcursor with no within-run pagination (microsoft_defender_endpoint/data_stream/log): correct and simple, but fetches only one page per interval, so backfill/catch-up time scales withinterval× backlog — poor for a high-volume stream and sensitive to user-setinterval/initial_interval.Notes / related
m365_defender; the package currently requests4Gifor agentless (manifest.yml) citing in-memory volume. Pagination and per-page memory are being reviewed together.batch_sizeis tracked separately in elastic/package-spec#1176.