Architecture¶
Splice turns a NETCONF device into a standard gNMI server by discovering its YANG models, building a telemetry catalog, planning efficient batched reads, polling them through a resilience layer, and answering gNMI from a TTL cache. This page is the mental model: the end-to-end pipeline, the four ways to drive it, and the design choices — especially connection resilience — that make it safe to point at fragile optical hardware.
The pipeline at a glance¶
graph LR
D["NETCONF device<br/>:830 (SSH)"] -->|"YANG discovery"| C["Catalog<br/>(module-qualified paths)"]
C --> PL["Planner<br/>(batched query tasks)"]
PL --> P["Poller<br/>(resilient, throttled)"]
P -->|"NETCONF get"| D
P --> M[("TTL cache")]
M --> G["gNMI server<br/>:9339 (gRPC)"]
G -->|"Get / Subscribe"| K["gnmic · Telegraf<br/>AI agents"]
DB[("PostgreSQL")] -.->|"persists catalog,<br/>jobs, instances"| C
The flow has two phases:
- Discovery (one-time / on setup) — connect over NETCONF (SSH, port 830), read the device's YANG library, fetch the schemas, and compile them into module-qualified catalog paths. With the CLI stack this is persisted to PostgreSQL; with the Python API it lives in an in-memory catalog.
- Runtime (continuous) — a gNMI request (or a polling job) resolves to a catalog path, the planner turns catalog entries into batched NETCONF filters, the poller executes them through the resilience layer, parsed values land in a TTL cache, and the gRPC server answers
Get/Subscribefrom that cache.
Why a cache sits between gNMI and NETCONF
gNMI clients expect low-latency reads and subscriptions; NETCONF <get> over SSH is comparatively slow and the devices throttle concurrent sessions. The poller fills the cache on its own schedule and the gRPC server reads from it, decoupling client request rate from device RPC rate.
Stage by stage¶
Discovery: YANG to catalog paths¶
Discovery walks the device's advertised YANG modules, fetches each schema, and build_catalog (in catalog.py, driven by runtime.run_build_catalog_with_paths) compiles them into a flat set of canonical paths — every leaf and keyed list, qualified by its YANG module prefix (for example /org-openroadm-device/node-id). Each catalog entry carries the filter_xml the poller will send and the originating module.
The shared discovery steps live in runtime.py and are reused by both the all-in-one setup command and the individual CLI commands:
Step (runtime.py) |
What it does |
|---|---|
run_discovery |
Enumerate YANG modules from the device, store them |
run_fetch_yang |
Download each schema to yang_dir, skipping ones already on disk |
run_build_catalog_with_paths |
Compile YANG into canonical catalog paths |
run_build_aliases |
Derive namespace-free alias paths (unique per device) |
run_init_jobs |
Create polling jobs for catalog paths at the default interval |
Fetching YANG is lockout-aware
run_fetch_yang throttles between RPCs (default 0.3s), skips files already downloaded, and fails fast on authentication errors rather than retrying — a repeated auth failure can lock the device account. It also caps consecutive failures (default 10) to avoid a reconnect storm, and reconnects once between failures when given a reconnect_fn.
Planner: batching reads to spare the device¶
Polling every catalog path with its own <get> would mean thousands of RPCs against a device that may cap concurrent sessions. The planner (planner.py) coalesces them. A QueryTask pairs a canonical_path with the filter_xml to send and an interval_sec. Three planning strategies are available:
| Function | Coalescing key | Use |
|---|---|---|
plan_queries |
none (one task per path) | Simplest, most RPCs |
plan_batched_queries |
top-level container | Fewer RPCs — one task per root subtree |
plan_module_batched_queries |
(module, top-level container) |
One task per module subtree |
When several children of one container have different intervals, the coalesced parent is polled at the fastest (minimum) of them — one task per target, never one task per (target, interval):
# plan_batched_queries: children of /interfaces with intervals 5s and 30s
# collapse to a single task fetching /interfaces every 5s, not two tasks.
group_tasks_by_interval then buckets the resulting tasks by interval so the poller can run each interval group on its own cadence.
Poller: one resilient sweep loop¶
The NetconfPoller (netconf/poller.py) runs a single daemon thread that, for each interval group whose time has come, sweeps its tasks: fetch(filter_xml) → parse(xml) → ingest(updates). Key behaviors:
- Per-task throttle — sleeps
task_throttle_secbetween tasks within a sweep so it never fires RPCs back-to-back. - Task failure breaker — after
task_failure_limitconsecutive failures a single task is dropped from rotation for the process lifetime (re-enabled on restart or task refresh).0disables this. - Circuit-breaker pauses don't count against tasks — when the underlying pool's breaker is open, the sweep pauses and any buffered per-task failures for that sweep are discarded, so a device-wide outage never wrongly disables otherwise-healthy paths.
- Sweep stats — per-interval
ok/fail/disabled/duration/paused_by_circuit_breaker, surfaced through the/healthendpoint andsplice status.
update_tasks lets the runtime swap the task list live (after jobs are re-enabled or PM filters rebuilt on drift); a refreshed list is treated as a clean slate that clears stale failure history.
Parsing: XML to keyed key-value paths¶
parse_xml_to_kv (also in poller.py) converts a NETCONF reply into {path, value, timestamp_ns, meta} records. It walks the XML, and when given list_keys it inlines list-key predicates read from each element's own children — so a reply containing many instances of the same list yields distinct, correctly attributed paths (/interfaces/interface[name=eth0]/state/mtu) instead of collapsing onto the last key seen. Values are cast to the YANG type (int*, uint*, decimal64/float, boolean) when a type_map is supplied.
Cache and gNMI server¶
Parsed updates are ingested into a TTL cache that the gRPC server (gnmi_server.py) answers from, doing path matching against the catalog. See Configuration for the cache TTL and gnmi server settings.
Connection resilience (the load-bearing design)¶
Optical and transport gear is unforgiving: many devices limit concurrent NETCONF sessions and lock the account after repeated auth failures. Every RPC path in Splice is therefore built to back off rather than hammer. The resilience lives in NetconfClientPool (netconf/client.py), which wraps a single-session NetconfClient.
flowchart TD
A["RPC call"] --> B{"Circuit breaker open?"}
B -- "yes" --> X["raise CircuitBreakerOpen<br/>(poller pauses this sweep)"]
B -- "no" --> C["run RPC"]
C -- "success" --> S["reset failure counters"]
C -- "failure" --> D{"Auth / lockout signature?"}
D -- "yes" --> E["open breaker for<br/>auth_failure_cooldown_sec (300s)<br/>— do NOT retry"]
D -- "no" --> F["close session,<br/>wait reconnect_delay_sec,<br/>retry ONCE"]
F -- "success" --> S
F -- "failure" --> G["record failure;<br/>open breaker at threshold"]
Reconnect-once¶
On an RPC failure the pool closes the dead session, waits reconnect_delay_sec (default 2.0s), reconnects, and retries the RPC exactly once. If the retry also fails the error propagates immediately — there are no retry loops that could storm the device. The reconnect sleep happens with the pool lock released, so other threads (a concurrent Set, the health endpoint) aren't blocked.
Circuit breaker¶
A circuit breaker tracks consecutive failures across all callers. After circuit_breaker_threshold consecutive failures (default 5) the pool refuses new RPCs for circuit_breaker_cooldown_sec (default 60s), raising CircuitBreakerOpen. When the cooldown expires, one attempt is allowed through to probe recovery.
Auth / session-limit detection¶
This is the critical safety valve. _is_auth_error matches not just authentication rejections but device-side access and session-limit conditions — these all share the property that retrying makes them worse (account lockout, session exhaustion). Matched signatures include vendor-specific wording such as access denied, too many sessions, session limit, permission denied, lock-denied, and license limit. Any of these immediately opens the breaker for the longer auth_failure_cooldown_sec (default 300s) and skips the retry entirely.
Tuning for your device
These thresholds are configurable in the poller section of the config. On a device with very tight session limits, raise the cooldowns and lower the threshold. See Configuration and Troubleshooting for symptoms (empty gnmic responses often mean the poller is in an auth/session-limit backoff).
Throttling layers, top to bottom¶
| Layer | Mechanism | Default |
|---|---|---|
| Discovery | run_fetch_yang inter-RPC sleep |
0.3s |
| Poller | task_throttle_sec between tasks in a sweep |
0.05s |
| Pool | reconnect_delay_sec after a failure |
2.0s |
| Pool | circuit-breaker cooldown | 60s |
| Pool | auth/session-limit cooldown | 300s |
get_status exposes the live breaker state (consecutive_failures, auth_failure_count, circuit_breaker_open, circuit_breaker_seconds_remaining) without taking the pool lock, so /health never blocks behind an in-flight poll.
Path and catalog model¶
Canonical, alias, and keyed paths¶
- Canonical paths are YANG module-qualified:
/p1:components/p1:component. They are what discovery produces and what the planner and poller use. - Alias paths strip the module prefixes for convenience:
/components/component.normalize_path(pathing.py) drops theprefix:on each segment;run_build_aliasesensures aliases are unique per device. - Keyed list paths carry their list keys as predicates, normalized by key with percent-encoded values:
/interfaces/interface[name=eth0]. Key values are URL-encoded (encode_key_value) so values containing slashes or special characters survive — note that values containing[or]are rejected as unsafe during expansion.
is_path_prefix does boundary-aware prefix matching (a descendant continues with / or [), so /system never spuriously matches /system-backup.
Expansion modes¶
Keyed lists must be expanded into concrete instances before they can be polled or matched. expander.py does this from list_keys (which leaves key a list) and list_instances (the discovered key values), taking the cartesian product across nested lists. Expansion is guarded: expand_path_guarded estimates the product size first and skips (returns a skipped result with a reason) when it would exceed max_per_base (default 10000), preventing a cartesian explosion from a deeply nested list.
When the expansion happens is controlled by gnmi.expansion_mode:
| Mode | Behaviour |
|---|---|
direct |
Use cached list instances only (default, safe) |
full |
Fresh discovery of instances on every run start |
lazy |
Like direct, but expand on first gNMI request |
full is slow on real hardware
full re-discovers every list instance at startup, which on a session-throttled device can take a long time. Prefer direct in production and re-run discovery deliberately when the device's inventory changes.
Where PM (performance-monitoring) data fits¶
Performance-monitoring counters do not map onto YANG catalog paths. Instead they are published under synthetic, instance-keyed paths:
The instance keys are discovered from the reply XML's ancestors (key leaves such as name, entity-name, index, id) and rendered as a single bracket with comma-joined, alphabetically sorted, percent-encoded values — the same canonical form a client query produces, so lookups compare equal. This keeps per-port metrics from colliding when many ports report the same counter name. PM details (filter fanout, drift detection) are covered in Configuration.
The four usage stacks¶
Splice exposes the same NETCONF/catalog/planner/poller core through four front ends.
graph TD
subgraph core["Shared core"]
DISC["discovery · catalog"]
PLN["planner · expander · pathing"]
POOL["NetconfClientPool<br/>(resilience)"]
DISC --> PLN --> POOL
end
CLI["CLI + PostgreSQL<br/>cli.py → db/"] --> core
PYAPI["Python API<br/>api/device.py → InMemoryCatalog"] --> core
MCP["MCP server<br/>mcp_server.py"] --> PYAPI
TUI["TUI<br/>tui/ (Textual)"] --> CLI
1. CLI + PostgreSQL¶
The production stack: cli.py drives a service layer that persists the catalog, jobs, list instances, and PM filters to PostgreSQL. Config is YAML, one file per device, supporting multiple devices (one gNMI server instance and port per device, sharing the database via device_id foreign keys). This is the only stack that requires a database. See the Configuration reference and Deployment guide.
2. Python API (InMemoryCatalog)¶
api/device.py exposes a Device class that runs the whole pipeline with no database and no config files — all state lives in an in-memory catalog dataclass. Ideal for scripts, notebooks, and tests:
from splice.api.device import Device
with Device("sim://openroadm") as dev:
print(dev.get("/org-openroadm-device/node-id"))
Full reference in Python API.
3. MCP server¶
mcp_server.py wraps the Python API as MCP tools over stdio for AI agents (Claude and others) — connect, query telemetry, search paths, and push config as tools. It adds session management, rate limiting, a deny-all-by-default set policy, and a structured error taxonomy. No database required. See API.
4. TUI¶
tui/ is a Textual-based interactive console (splice-tui --devices-dir devices) that sits on top of the CLI/database stack: browse catalog paths, watch live telemetry, run setup/run as managed subprocesses, and monitor health and circuit-breaker state. See the TUI Guide.
Where to go next¶
- Quickstart — point Splice at your first device.
- Configuration — every config section, including the
pollerresilience knobs quoted above. - Python API — the database-free
Deviceclass. - TUI Guide — the interactive operator console.
- Troubleshooting — diagnosing backoff, lockouts, and empty responses.