At-Scale Reads
How GraphnAI keeps large reads fast in big environments: relationship fields that save lookups, cursor pagination, and reach data streamed level by level. For administrators and API clients.
Requires role: Viewer (topology and reach views), Operator (inventory, detection alert and analysis views), access to the server host (the reset procedure) Related: Identity Inventory, Detection Alerts, Graph Explorer, Bridge Configuration
Overview
GraphnAI handles environments with hundreds of thousands of identities and tens of millions of relationships. At that size, three kinds of read need care: a walk out from an identity with hundreds of thousands of relationships, a long list read page by page, and a large count or grouping. Three things keep them fast:
- Relationship fields that save lookups. Each relationship carries the type, criticality, tier, domain, account state and display name of both its ends. Counting and grouping read the relationships alone, without fetching each identity.
- Cursor pagination. Large lists page with signed cursors. A cursor seeks straight to the next page, so page 10,000 costs the same as page 1.
- Streamed reach data. Reach graphs receive one JSON object per line as each level finishes. The first level arrives in seconds, and deeper levels follow.
The Identity Inventory and Detection Alerts pages stream their lists. Cursors serve API clients and the drill-down tables.
Prerequisites
- Role: Viewer for the topology and reach views. Operator or Admin for the inventory, detection alert and analysis views. Access to the server host for the reset procedure.
- Relationship fields: A sync writes the relationship fields. Relationships written by an older version may lack them, which slows reach reads. See Schema Changes and Wipe-and-Resync.
- No configuration: The cursor signing key comes from the install key that GraphnAI creates on its host at first start. See Cursor key changes.
Cursor Pagination
How it works
A paginated list returns an opaque string, the cursor, in nextCursor. Sending it back with the next request returns the next page. When nextCursor is null, there are no more rows.
A cursor is opaque. Its structure isn't documented and can change between releases. Don't parse it, build one, or keep it long term. Treat each cursor as a token for the next request.
Lists that page with cursors
| List | Sort |
|---|---|
| Identity inventory | By risk (default), name, criticality, relationship counts, permission count or membership counts, in either direction |
| Detection alerts | Newest first |
| River drill-down tables | Fixed |
| Relationship tables behind a river line | Heaviest first |
| Security Posture findings | Fixed |
Replay protection
The sort field and direction are signed into the cursor. A cursor from a list sorted by risk is refused when it's sent with a sort by name. A cursor from one list is refused on another. Filters such as type and domain aren't part of the cursor, so keep them the same from page to page.
Tampered cursor behavior
Cursors are signed with HMAC-SHA256. A cursor changed by even one character is refused with an "invalid cursor" error. The error doesn't say which part failed, so probing the check teaches nothing. When a client gets it, it starts again from the first page.
Cursor key changes
The cursor signing key comes from the install key, which GraphnAI creates on its host at first start and keeps in the server's data folder. You don't set it or rotate it. The key can be replaced, for example when you restore a different data volume. Every cursor signed with the old key then stops working, and every user is signed out. A client that was part way through a list gets an "invalid cursor" error on its next page, and starts again from the first page. There's no grace period.
A server restart with the same install key doesn't affect cursors.
NDJSON River Streaming
Overview
Reach data streams level by level as newline-delimited JSON (NDJSON). Each line of the response is one complete JSON object, called a frame. The connection stays open until every level is done or the client disconnects.
Each user can hold 4 reach streams open at once, and the server 32. Past that, the server refuses a new stream and asks the client to retry shortly.
Frame protocol
Every frame has a kind field. There are five kinds.
segment: One bucket per level
Sent once for each combination of level, source type, relationship type and target type. It carries the data.
json
{
"kind": "segment",
"hop": 1,
"fromType": "ServicePrincipal",
"edgeType": "HasPermission",
"toType": "Computer",
"count": 245678,
"criticalCount": 12,
"highCount": 3245,
"mediumCount": 142112,
"tier0Count": 0
}count is exact at every level.
hop-progress: A level still being counted
Sent while a level with a large number of identities is being counted. processed and frontierTotal say how far through the level's identities the count has got.
json
{"kind":"hop-progress","hop":2,"direction":"outbound","processed":250000,"frontierTotal":501459}capped: A limit was reached, or the map is complete
Sent at most once per level and direction. reason says why:
reason | Meaning |
|---|---|
frontier-ceiling | The level passed the size limit, 1,000,000 identities by default. Deeper levels still stream, built from the counted part. |
depth-budget | Mapping reached its time limit, 2 minutes by default. The levels already sent are right. |
saturated | The identity reaches at least 90% of the directory by default, so mapping stopped. This means the map is complete. |
json
{"kind":"capped","hop":2,"direction":"outbound","reason":"frontier-ceiling","limit":1000000,"totalTargets":1431207,"processed":1000000}error: A level failed
Sent when a level's query fails. The connection then closes, and no data follows.
json
{
"kind": "error",
"hop": 3,
"error": "hop processing failed"
}complete: A clean end of stream
Sent as the last frame when every level finished without an error.
json
{
"kind": "complete",
"lastHop": 4
}A stream that ends without a complete frame was cut short. The client disconnected, the server restarted, or the network dropped. The product shows "Loading stopped early, so the map is incomplete. Reload to try again." in that case.
Cancellation
When the client closes the connection, for example by leaving the page, the server stops the running query at its next batch and starts no new ones. An abandoned stream doesn't keep using the database.
Proxy and buffering considerations
Some reverse proxies hold the whole response before they pass it on, which defeats streaming. The server asks nginx not to buffer the stream. Configure any other proxy in front of GraphnAI to pass the stream through unbuffered. The product's reach graphs need the stream.
Server settings
An administrator of a very large environment can tune the stream through server settings: the size limit for one level (1,000,000 identities by default), the time limit for a whole stream (2 minutes by default), the share of the directory at which a reach counts as complete (90% by default), and how much work runs at once. They change server efficiency and when the partial notices appear.
Capped Response Envelope
Large lists add the same cap fields to every response, whether or not a cap was reached.
Envelope fields
| Field | Type | Description |
|---|---|---|
nextCursor | string or null | Cursor for the next page. null when there are no more pages. |
capped | boolean | true when a server-side limit cut the result |
capReason | string or null | Why the limit applied. null when capped is false. |
limit | integer | The page size or limit that applied to this request |
Cap reasons
| Value | Where | Meaning | What to do |
|---|---|---|---|
max-rows | Identity inventory and analysis lists | The result passed the row limit. For the inventory, that's 100,000 rows. | Narrow the filter by type, domain or preset |
target-list-limit | Access intelligence | Each target list stops at the limit. The counts still cover every target. | None needed |
hop-limit | Membership chain | Each level of the chain stops at the limit, in a fixed order | Investigate a large group directly |
bfs-cap | The older single-response reach data | The deeper levels are a sample | Use the streamed reach data for exact counts |
Streamed reach data reports its limits in capped frames. See Frame protocol.
Understanding capped and nextCursor
The two fields are separate signals:
nextCursorset,cappedfalse: more pages exist.nextCursornull,cappedfalse: there are no more rows.cappedtrue on the inventory: the result passed 100,000 rows. Every page says so, and pages stop at row 100,000. Narrow the filter to reach the rest.cappedtrue on an analysis list: more rows exist past this page.
Cap messages in the product
When a list or map reaches a cap, an amber line appears above it:
| Reason | Message |
|---|---|
max-rows | "Showing the first N. Narrow the filter to see the rest." |
target-list-limit | "Showing the first N targets in each list. The counts cover all of them." |
frontier-ceiling | "Counts cover N of M identities at this level. Deeper levels are partial." |
depth-budget | "Mapped N identities before the time limit. Deeper levels weren't expanded." |
bfs-cap | "Showing a partial map. Deeper levels are sampled." |
Schema Changes and Wipe-and-Resync
Relationship fields that save lookups
Each relationship carries twelve fields copied from its two ends when a sync writes it:
| Field | Type | Description |
|---|---|---|
fromType | string | Identity type of the source, such as User or ServicePrincipal |
toType | string | Identity type of the target |
fromCriticality | integer | Criticality of the source: 1 Low, 2 Medium, 3 High, 4 Critical. Absent when none. |
toCriticality | integer | Criticality of the target |
fromTier | integer | 0 when the source is Tier 0. Absent otherwise. |
toTier | integer | 0 when the target is Tier 0. Absent otherwise. |
fromDomain | string | Domain of the source |
toDomain | string | Domain of the target |
fromAccountEnabled | boolean | Whether the source account is enabled. Absent when the directory doesn't say, as for some Entra ID service principals. |
toAccountEnabled | boolean | Same for the target |
fromDisplayName | string | Display name of the source when the relationship was written. The next full sync refreshes it. |
toDisplayName | string | Display name of the target |
With these fields, reach graphs, blast radius graphs and the Tier 0 credential exposure check read relationships without fetching each identity. Relationships from sessions and privileges carry them too.
The maxTargetCrit field
On inventory entries, maxTargetCrit is the highest criticality among the targets an identity reaches, as an integer from 0 to 4. It's 0 when none of its targets has a criticality. The Criticality Gap score in over-permission analytics counts those targets as 0 too.
When to reset and resync
GraphnAI doesn't migrate relationships from one schema to the next. When an upgrade changes the relationship fields, reset the graph and run a full sync, so every relationship is written again from the directory. Run it in these situations:
| Situation | Why |
|---|---|
| An upgrade's release notes say the relationship schema changed | Only a fresh sync writes the new fields |
| River counts look wrong, or the first level is slow, after an upgrade | Older relationships lack the fields the river reads |
| The Criticality Gap is 0 for every identity | Older relationships have no toCriticality |
| The Tier 0 credential exposure check finds nothing in an environment that sends telemetry | Older relationships have no account state or display names |
Pick the narrowest reset that covers the change. On the server host, run the db-reset tool:
db-reset --topologyclears the graph (identities, resources, policies and relationships), sync history and escape findings.- Add
--alertswhen the analytics and river caches depend on the changed fields:db-reset --topology --alerts. - Add
--dry-runto print the plan without clearing anything.
Avoid db-reset --all for this. It also clears users, sign-in settings, bridge configurations, telemetry and investigations.
Then run a full sync from each bridge's page. On an environment of about 900,000 identities and 10 million relationships, a full sync takes several hours. Until it finishes, the product shows partial data, so wait before you rely on it.
Troubleshooting
A list reports "invalid cursor"
- Cause: The sort or direction changed since the cursor was issued, the cursor came from another list, or the install key was replaced. On a river drill-down table, a sync that changed relationships also ends the cursor.
- Fix: Start again from the first page. Don't save cursors to use later.
A reach graph shows nothing for a large identity
- Cause: The relationships were written before an upgrade that changed their fields, or a river index is missing from the database.
- Fix: Restart the server, which creates missing indexes when it starts. If the graph is still empty, reset and resync. See When to reset and resync.
The Criticality Gap is 0 for every identity
- Cause: The relationships have no
toCriticality, because they were written before the field existed. - Fix: Reset and resync. Until then, every target counts as 0, so every gap is 0.
maxTargetCrit is 0
- Cause: None of the identity's targets has a criticality.
- Fix: None needed. If you meant every identity to get at least Low, check the rules on Admin > Classification Rules, on the Criticality tab.
A cap message appears on a list you expected to be complete
- Cause: The request reached a server-side limit, for example a broad filter in an environment of 500,000 or more identities, or a reach from an identity with very many relationships.
- Fix:
- For the inventory, narrow by type, domain or preset.
- For a reach graph in Graph Explorer, pick one direction in the toolbar in place of both.
- For detection alerts, filter by severity or date.
Reach graphs load all at once, or not at all, behind a proxy
- Cause: A proxy between the browser and GraphnAI holds the stream until it ends.
- Fix: Configure the proxy to pass the reach stream through unbuffered, for example by honoring the
X-Accel-Buffering: noheader the server sends.
Related Documentation
- Identity Inventory: the inventory list, which the product streams
- Detection Alerts: the alert list, its paging and filters
- Graph Explorer: the river view, which reads the stream
- Over-Permission Analytics: the Criticality Gap score, which reads the relationship fields
- Asset Criticality: how criticality rules set the criticality the relationships carry
- Bridge Configuration: running a full sync after a reset