Topograph consists of five major components:
- API Server
- Node Observer
- Node Data Broker
- Provider
- Engine
Providers differ per environment. The canonical topology.Graph is stable. Engines only translate; they never discover.
Three consequences follow, and they are the reason the component split is shaped the way it is:
- Adding an environment means adding a provider. A new CSP, a new fabric-management tool, or a new source of accelerator-domain identity is a new package under
pkg/providers/, registered inpkg/registry/registry.go. It changes no engine and does not change the graph. - Adding an output format means adding an engine. A new workload manager or file format is a new package under
pkg/engines/, registered in the same file. Every provider's output is already translatable into it, because every provider produces the same graph. - Reading the fabric inside an engine is a design error, and so is emitting scheduler-specific output from a provider. Either one couples one environment to one scheduler and destroys the "any provider with any engine" property that the split buys.
topology.Graph (in pkg/topology/) has three fields:
| Field | Meaning |
|---|---|
Tiers *Vertex |
Root of the switch hierarchy. Leaf vertices are compute nodes; interior vertices are switches. |
Domains DomainMap |
Accelerator/block domains mapped to their hosts. This is the source for topology/block output. |
Instances map[string]Instance |
Optional per-instance metadata keyed by instance ID. Engines that do not need instance-oriented output ignore it. |
Vertex is deliberately small: Name (the compute node name), ID (the CSP instance ID for a node, or the switch ID), and Vertices (its children). Because every provider returns this shape and every engine consumes it, a change to Vertex ripples through all of them, which is why pkg/topology/ changes are discussed in an issue before they are written.
Providers implement one method, defined in pkg/providers/providers.go:
type Provider interface {
GenerateTopologyConfig(ctx context.Context, pageSize *int, instances []topology.ComputeInstances) (*topology.Graph, *httperr.Error)
}Most providers assemble a topology.ClusterTopology and let it produce the graph. For each instance they populate:
InstanceTopology.FabricTiers, closest-first, so index 0 is the switch nearest the compute node. The slice length is that instance's fabric depth. There is no fixed depth, and instances in one cluster may differ.InstanceTopology.XclrDomainID, the accelerator domain, when the environment has one.InstanceTopology.XclrSubDomainID, an optional sub-domain nested inside that domain.
ClusterTopology.ToGraph then builds the vertex forest and the domain map. Requested instances for which the provider returned no topology are collected under a single no-topology vertex, and each one raises the topograph_missing_topology gauge described under Metrics.
Network-fabric discovery and accelerator-domain discovery may be composed independently through pkg/accelerator. Providers that use this facility stay responsible for combining both dimensions into one graph; the engine never sees the two sources separately. Other providers may obtain both dimensions directly from their native topology source.
The return type is *httperr.Error, not error. The status code it carries becomes the status the API server reports for the whole request, so a provider chooses it deliberately. A plain error at this boundary throws that information away and is not accepted.
Engines implement two methods, defined in pkg/engines/engines.go: ResolveComputeInstances, which produces the instance-to-node mapping the provider needs, and GenerateOutput, which takes the finished *topology.Graph and returns bytes. GenerateOutput receives a graph and engine parameters. It receives no provider handle and no fabric source, which is the invariant enforced in code.
The API Server receives topology generation requests and returns results asynchronously. Requests are aggregated over a configurable delay window so that a burst of node changes (common during cluster scaling events) produces a single topology update rather than a storm.
The Node Observer is used in Kubernetes deployments. It is a controller that monitors configured node and pod changes, and it also watches the Topograph API pod. When the node-data-broker is enabled, it additionally watches the broker DaemonSet's lifecycle, desired replica count, and readiness. Relevant events enqueue one cluster-wide topology key in a rate-limited work queue, coalescing bursts into idempotent reconciliation. Reconciliation waits for the broker DaemonSet's ready replica count to match its desired count before asking the API Server to generate a new topology configuration, and it requeues failed requests.
The Node Data Broker is also used when Topograph is deployed in a Kubernetes cluster. It collects relevant node attributes and stores them as node annotations.
The Provider interfaces with CSPs or on-premises tools to retrieve topology-related data from the cluster and converts it into an internal representation. Providers may compose network-fabric discovery with an independent accelerator-domain source; for example, the InfiniBand provider can combine ibnetdiscover with nvidia-smi, a Kubernetes Node label, or no accelerator source.
The Engine translates this internal representation into the format expected by the workload manager.
- The API Server listens on the port and notifies the Provider about incoming requests. In Kubernetes, the incoming requests are sent by the Node Observer, which watches selected node/pod status and API-server readiness.
- The Provider receives notifications and invokes CSP API to retrieve topology-related information.
- The Engine converts the topology information into the format expected by the user cluster (e.g., SLURM or Kubernetes).
Topograph is a Go module, github.com/dsx-ai-factory/topograph. Its exported API is documented as godoc and browsable on pkg.go.dev:
The packages an external caller is most likely to import are pkg/topology (the canonical Graph, Vertex, DomainMap, and the topology constants), pkg/providers (the Provider interface and the registry types), and pkg/engines (the Engine interface). Packages under internal/ are not importable from outside the module by design.
The canonical types carry godoc comments: Graph, Vertex, FabricTier, DomainMap, and BlockVertex each document what they mean and how they are ordered, and those comments are what pkg.go.dev renders. Coverage is not yet uniform across the whole exported surface; several exported helpers in pkg/topology and pkg/providers still have none. Exported types and functions you add or change should carry a doc comment, because that comment is the published reference for them.
docs/ is the source of truth for public documentation. It is published through Fern to https://docs.nvidia.com/topograph. docs/index.yml drives the sidebar and fern/docs.yml declares the site and its versions; fern/ holds site configuration and theme assets only, never page content.
The site is versioned per release, and readers select a version from the site's version picker:
.github/workflows/publish-fern-docs.ymlpublishes on avX.Y.Ztag push, or on manual dispatch for a chosen tag.- Each published version serves content frozen at its tag. The workflow extracts
docs/fromrefs/tags/vX.Y.Zrather than frommain, so the pages under a version match the source of that release. There is no "docs built from main" entry. - On a release tag the workflow generates
fern/versions/vX.Y.Z.ymlfrom that tag'sdocs/index.yml, inserts the version intofern/docs.ymlsorted by semver descending, prunes the registry to the three most recent versions, and opens a PR so the registry change lands onmain. - The newest version is displayed with a "Latest" prefix at publish time. That stamp is applied to the published site only and is not persisted into
fern/docs.yml. - Pre-release tags such as
v0.5.0-rc1publish, but do not register a new version.
CI (.github/workflows/fern-docs-ci.yml) fails if any docs/**/*.md outside docs/design/ is missing from docs/index.yml. docs/design/ is a drafting area for work in progress and is not published.
This section covers running Topograph as a service. It describes what the code actually exposes; the request and configuration schemas themselves are in Config and API, and the label keys are in Node Labels and Annotations.
The API Server serves everything on one port (http.port in the config, 49021 in the sample config and in the Helm chart's service.port):
| Endpoint | Method | Purpose |
|---|---|---|
/healthz |
GET | Liveness and readiness. Returns 200 with body OK. |
/v1/generate |
POST | Submit a topology request. Returns 202 and the request ID immediately. |
/v1/topology?uid=<id> |
GET | Fetch a result: 200 when complete, 202 while in progress, 404 for an unknown or evicted ID, or the status the request failed with. |
/v1/lookup |
POST | Takes the same body as /v1/generate and returns the stored result for that body's hash, without queueing new work. |
/metrics |
GET | Prometheus exposition. |
/healthz answers as soon as the HTTP server is listening. It does not test provider reachability, credentials, or the last generation result, so a healthy pod tells you nothing about whether topology generation is working. Use the metrics and logs below for that.
The Helm chart wires /healthz into both livenessProbe and readinessProbe on the API Server Deployment. The node-data-broker DaemonSet serves its own /healthz on nodeDataBroker.port (8080 by default) and uses it for its startup, liveness, and readiness probes. The broker applies its node annotations first and only then starts that endpoint, so a Ready broker pod means the annotations for that node have been written.
pkg/metrics/ registers five Prometheus collectors, all under the topograph subsystem:
| Metric | Type | Labels | When it is emitted |
|---|---|---|---|
topograph_http_request_duration_seconds |
histogram | method, path, proto, from, status |
Every HTTP request, from the logging middleware. Default buckets. |
topograph_request_duration_seconds |
histogram | provider, engine, status |
Once per topology-generation attempt, and once for each request rejected while being read or validated. Buckets run from 1s to 30s; anything slower falls into +Inf. |
topograph_version |
gauge | version |
Set to 1 on each HTTP request. Use it to confirm which build is running. |
topograph_missing_topology |
gauge | provider, node |
Set to 1 for each requested node the provider returned no topology for. |
topograph_validation_error_total |
counter | type |
Registered, but nothing in the tree increments it today, so no series appears. |
topograph_request_duration_seconds is the one to alert on. Split it by status: a rising non-200 rate names the provider and the engine involved. Because the API Server retries a failed generation, one logical request can produce several observations.
Two caveats on topograph_missing_topology: it is only ever set, never cleared, so an existing series means "this node was missing at some point since the process started", not "this node is missing now". Restarting the API Server resets it. The same nodes appear in the generated topology under the no-topology vertex, which is the authoritative current view.
The chart ships an opt-in ServiceMonitor (serviceMonitor.enabled, scraping /metrics on the http port every 15s by default) and an opt-in NetworkPolicy that admits the scraper's namespace when networkPolicy.metricsScraperNamespace is set.
requestAggregationDelay is required in the API Server config and has no built-in default; both config/topograph-config.yaml and the chart's config.requestAggregationDelay use 15s. The API Server hashes each request body and holds it in a trailing-delay queue: submitting a request starts a timer for that hash, and submitting an identical request before the timer fires cancels and restarts it. Generation begins only when the delay elapses with no newer identical request.
When updates seem slow, this is usually why:
- The floor on end-to-end latency is
requestAggregationDelaymeasured from the last identical request, plus the provider's own query time. With15s, a result is never available sooner than 15 seconds after node churn stops. - Continuous churn starves the queue. If events arrive faster than the delay, the timer keeps restarting and nothing is produced. The signature in the logs is many
Submit request; delay processing by <delay>lines with no matchingProcessing request ID <hash>line. - The request hash covers only the provider name and parameters and the engine name and parameters. Requests that agree on those collapse into one, even when their node lists or credentials differ. The Node Observer always sends the same body (the provider and engine from its own config), which is what makes coalescing effective in Kubernetes.
- Results are kept in an LRU of the last 100 request IDs. Polling an older ID returns
404.
| Engine | Where the output goes |
|---|---|
slurm |
Writes the Slurm topology config to the path in the topologyConfigPath engine parameter and returns OK. With no path set it returns the generated text as the response body, so GET /v1/topology hands you the file. With reconfigure: true it then runs scontrol reconfigure. |
k8s |
Writes node labels directly through the Kubernetes API: fabric.topograph.run/tier-N closest-first, accelerator.topograph.run/domain, and accelerator.topograph.run/sub-domain. The fabricLabels parameter overrides the fabric tier keys and acceleratorLabel overrides the accelerator domain key; the sub-domain key is always accelerator.topograph.run/sub-domain. Returns OK. A failed label write surfaces as 502. |
nfd |
Creates or updates NFD NodeFeature and NodeFeatureGroup objects in the namespace named by the NFD_NAMESPACE environment variable, which is required; the Helm chart sets it from nfdNamespace and rejects an env.NFD_NAMESPACE override. Returns OK nodeFeatures=<n> nodeFeatureGroups=<m>. With cleanup enabled it refuses to apply an empty result rather than deleting the existing topology. |
slinky |
Writes the Slurm topology into the ConfigMap named by topologyConfigmapName in namespace, under the key given by topologyConfigPath. configUpdateMode: none skips the ConfigMap write. With useDynamicNodes: true, it also reconciles the topology.slinky.slurm.net/spec annotation on Kubernetes nodes, including when the ConfigMap write is skipped. |
graph |
Returns instance-oriented JSON as the response body, or writes it to topologyConfigPath and returns OK. |
An engine that returns OK has already applied its side effect. The body is a completion marker, not the topology.
In a Kubernetes deployment, work through these in order. Each step names the log line or signal that confirms or rules it out.
-
Is the Node Observer past its startup gate? On start it polls the API Server's
/healthzevery 2 seconds for up to 1 minute. It logsWaiting for topograph to start at <url>until it succeeds, thenTopograph API is ready at <url>. If the API never answers, it returnstopograph API not ready at <url> after <elapsed>and the process exits non-zero, so the pod restarts. An observer in CrashLoopBackOff with that message points at the API Server Service or pod, not at the provider. -
Is the node-data-broker DaemonSet ready? When
NODE_DATA_BROKER_NAMEandNODE_DATA_BROKER_NAMESPACEare set, the observer will not request a topology until the DaemonSet'sstatus.numberReadyequalsstatus.desiredNumberScheduled. While it waits it requeues every 10 seconds and logs, at-v=2,Waiting for the node-data-broker DaemonSet to become ready before topology generation. A DaemonSet with zero desired replicas logsnode-data-broker DaemonSet <ns>/<name> has 0 desired replicas; check its node selector, affinity, and tolerations, which names the fix. A broker pod that never becomes Ready failed to collect or write its node annotations, and its own logs carry the provider error. -
Does anything trigger the observer at all? It watches only what its config selects:
trigger.nodeSelector,trigger.podSelector, andapiServer.podSelector. Node adds and deletes, trigger-pod readiness transitions, API Server pod readiness and container restarts, and broker DaemonSet readiness or desired-count changes each enqueue one cluster-wide key. If no selector matches anything, nothing is ever enqueued and the topology never regenerates. At-v=4every accepted event logs anInformer added/updated/deleted ...line, so an empty log at that verbosity means the selectors are the problem. -
Are generation requests failing to reach the API Server? A failed POST logs
Topology reconciliation failed: failed to send topology generation request: <err>and is requeued through an exponential rate limiter seeded withretryDelay(5 minutes by default). Note that the observer only ever sees the202handshake:/v1/generatereturns before the provider runs, so a provider failure never appears in observer logs. -
Is generation itself failing? That is visible only on the API Server. The queue logs
HTTP <code>: <message>when a request completes with an error, andtopograph_request_duration_secondscarries the same status alongside the provider and engine labels. Missing or expired credentials, upstream API quota, and unreachable fabric tooling all land here. -
Are only some nodes missing? Check
topograph_missing_topologyand theno-topologyvertex in the generated output. Those are nodes the request asked about that the provider returned no topology for, which usually means the instance is outside the queried scope (region, cluster, or selector) rather than that the provider failed.
The provider interface returns *httperr.Error rather than error so that the status the provider chose propagates intact. That code becomes three things: the HTTP status stored with the queued request and returned by GET /v1/topology?uid=<id>, the status label on topograph_request_duration_seconds, and the input to the retry decision.
The API Server makes up to five generation attempts: one initial attempt and four retries. It retries only status codes 408, 429, 500, 502, 503, and 504, waiting 2s, 4s, 8s, and 16s between attempts. Other status codes fail immediately. Before each retry, it logs Attempt <n> failed with error: <err>. Retrying in <wait>.
These generation retries do not honor Retry-After because the API Server does not receive the upstream response headers. Provider HTTP calls made through internal/httpreq.DoRequestWithRetries do honor Retry-After, given as seconds or an HTTP date, up to a maximum of 5 minutes. Without a valid header, provider HTTP retries wait 500ms, 1s, 2s, and 4s. If the provider still fails, the API Server may retry the whole generation, so the two retry loops can compound.
What the statuses mean in practice:
| Status | Typical cause |
|---|---|
400 |
Malformed request body, an unknown provider or engine name, or invalid provider/engine parameters. Rejected before any upstream call, and not retried. |
401 |
Provider authentication failed. In-tree examples: the NetQ login, OCI API authentication, and Nebius SDK construction. Not retried; fix the credentials. |
500 |
A local failure, such as writing the output file or generating the config. Retried. |
502 |
An upstream call failed. A transport-level failure in internal/httpreq maps to 502, as does a failed Kubernetes write in the k8s and nfd engines. Retried. |
404 |
From /v1/topology when the request ID is unknown or has been evicted from the 100-entry result LRU, and from /v1/lookup when the request body's hash is unknown or evicted. |
Because a request that exhausts its retries stores the final error against its hash, GET /v1/topology?uid=<id> (or a /v1/lookup with the same body) returns the provider's own message. That message, not the status alone, is what identifies the failing call.