Skip to content

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 ​

ListSort
Identity inventoryBy risk (default), name, criticality, relationship counts, permission count or membership counts, in either direction
Detection alertsNewest first
River drill-down tablesFixed
Relationship tables behind a river lineHeaviest first
Security Posture findingsFixed

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:

reasonMeaning
frontier-ceilingThe level passed the size limit, 1,000,000 identities by default. Deeper levels still stream, built from the counted part.
depth-budgetMapping reached its time limit, 2 minutes by default. The levels already sent are right.
saturatedThe 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 ​

FieldTypeDescription
nextCursorstring or nullCursor for the next page. null when there are no more pages.
cappedbooleantrue when a server-side limit cut the result
capReasonstring or nullWhy the limit applied. null when capped is false.
limitintegerThe page size or limit that applied to this request

Cap reasons ​

ValueWhereMeaningWhat to do
max-rowsIdentity inventory and analysis listsThe result passed the row limit. For the inventory, that's 100,000 rows.Narrow the filter by type, domain or preset
target-list-limitAccess intelligenceEach target list stops at the limit. The counts still cover every target.None needed
hop-limitMembership chainEach level of the chain stops at the limit, in a fixed orderInvestigate a large group directly
bfs-capThe older single-response reach dataThe deeper levels are a sampleUse 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:

  • nextCursor set, capped false: more pages exist.
  • nextCursor null, capped false: there are no more rows.
  • capped true 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.
  • capped true 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:

ReasonMessage
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:

FieldTypeDescription
fromTypestringIdentity type of the source, such as User or ServicePrincipal
toTypestringIdentity type of the target
fromCriticalityintegerCriticality of the source: 1 Low, 2 Medium, 3 High, 4 Critical. Absent when none.
toCriticalityintegerCriticality of the target
fromTierinteger0 when the source is Tier 0. Absent otherwise.
toTierinteger0 when the target is Tier 0. Absent otherwise.
fromDomainstringDomain of the source
toDomainstringDomain of the target
fromAccountEnabledbooleanWhether the source account is enabled. Absent when the directory doesn't say, as for some Entra ID service principals.
toAccountEnabledbooleanSame for the target
fromDisplayNamestringDisplay name of the source when the relationship was written. The next full sync refreshes it.
toDisplayNamestringDisplay 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:

SituationWhy
An upgrade's release notes say the relationship schema changedOnly a fresh sync writes the new fields
River counts look wrong, or the first level is slow, after an upgradeOlder relationships lack the fields the river reads
The Criticality Gap is 0 for every identityOlder relationships have no toCriticality
The Tier 0 credential exposure check finds nothing in an environment that sends telemetryOlder 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 --topology clears the graph (identities, resources, policies and relationships), sync history and escape findings.
  • Add --alerts when the analytics and river caches depend on the changed fields: db-reset --topology --alerts.
  • Add --dry-run to 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: no header the server sends.