Collect LLDP adjacencies
Why adjacencies matter
Section titled “Why adjacencies matter”l2trace’s recursive-CTE traceroute walks adjacency rows to traverse
between switches. Without them, every trace terminates at the ingress
device’s egress trunk port — the walker has no way to find the next
hop.
Until this collector landed, the adjacency table was populated only
by the demo seed script. Real-deployment fabrics need LLDP collection
to make trace functional.
Two wire paths, one event shape
Section titled “Two wire paths, one event shape”l2trace can collect LLDP via either collector:
| Wire | What it watches | When you’d use it |
|---|---|---|
| gNMI (streaming) | /lldp/interfaces/interface[name=*]/neighbors/neighbor[id=*]/state (OpenConfig) | Modern devices (Cisco IOS-XR, Arista EOS 4.20+, Nokia SR OS, Juniper Junos with gNMI-on). Pushes adjacency changes within seconds. |
| SNMP (polling) | lldpRemTable (IEEE 802.1AB / RFC 2922) — three columns: lldpRemChassisId, lldpRemPortDesc, lldpRemSysName | Universal — works on every managed switch. Cadence = poll interval (default 60s). |
Both paths emit the same LldpNeighborUpdate event envelope, so the
reconciler’s write path is identical. Two sources can observe the same
neighbor concurrently; the adj_no_overlap_per_source EXCLUDE
constraint keeps them in separate rows and the OPS pane surfaces
cross-source disagreement (e.g. gNMI sees a new peer, SNMP is still
showing the old one — one poll cycle behind).
How SNMP collection works
Section titled “How SNMP collection works”The SNMP collector walks lldpRemTable (the LLDP-MIB neighbor table)
on every poll cycle, alongside the FDB walk it was already doing.
Three columns get walked:
| OID | Column | What it tells us |
|---|---|---|
1.0.8802.1.1.2.1.4.1.1.5 | lldpRemChassisId | The peer’s chassis MAC (load-bearing) |
1.0.8802.1.1.2.1.4.1.1.8 | lldpRemPortDesc | The peer’s port description (e.g. Eth8) |
1.0.8802.1.1.2.1.4.1.1.9 | lldpRemSysName | The peer’s hostname |
The lldpRemTable index is <timeMark>.<localPortNum>.<remIndex>, three
sub-IDs. localPortNum is the BRIDGE-MIB bridge-port number, not an
ifIndex — the parser reuses the existing dot1dBasePortIfIndex → ifName chain (already walked for the FDB pass) to resolve it to a
human port name.
Chassis IDs come back as raw OctetString bytes — the parser only
ingests the macAddress(4) subtype (6 raw bytes), silently skipping
other subtypes (interface alias, network address, hostname, etc).
This keeps the adjacency table free of garbage rows that would
poison the traceroute join.
How gNMI collection works
Section titled “How gNMI collection works”The GnmiCollector subscribes to /lldp/interfaces/interface/neighbors
(globbed across all interfaces). Each LldpNeighborUpdate notification
delivers a value dict like:
{ "chassis-id": "00:1a:a1:11:22:33", "chassis-id-type": "MAC_ADDRESS", "port-description": "GigE0/24", "system-name": "sw-core-1"}The parser:
- Accepts both bare keys (
chassis-id) and yang-module-prefixed keys (openconfig-lldp:chassis-id) — vendor implementations differ. - Filters on
chassis-id-type— onlyMAC_ADDRESSis ingested, matching the SNMP path’s filtering. - Normalizes the MAC to canonical lowercase form via the same
normalize_machelper the MAC table parser uses. - Prefers
port-description(free-text label) overport-id(often an opaque INTERFACE_ALIAS string).
The local_port_name comes straight from the path’s [name=...] key
— no BRIDGE-MIB indirection needed, because OpenConfig keys on the
human port name directly.
The write path
Section titled “The write path”LLDP events take a different reconciler path than MAC events:
- No state machine / LiveSet — adjacency cardinality is tiny (one row per trunk per source). A direct DB query per event is cheaper than maintaining an in-memory cache.
- Bitemporal upsert in three cases:
- First-sight: no existing open row → INSERT
- Continuation: same
(local_port, remote_chassis, remote_port_descr)→ no-op - Move: peer changed → close
valid_during.upperon the existing row, INSERT the new one (in that order — theadj_no_overlap_per_sourceEXCLUDE constraint would trip otherwise)
The constraint is per-source, so gNMI + SNMP both observing the same neighbor is allowed and useful — cross-source disagreement gets surfaced via the same pattern as MAC observations.
What ships with adjacency collection
Section titled “What ships with adjacency collection”-
Peer device resolution: the
DeviceIdentifiedevent (emitted by every collector that learns its own chassis_id) populatesdevice.chassis_id. The reconciler then resolves each new adjacency row’sremote_chassis_idagainst that table, both eagerly at INSERT and via backfill when a peer joins later. See How peer resolution works. -
Bidirectional audit: the adjacency audit
- the AUDIT screen check every open
A→Brow for a correspondingB→Aand flag one-way LLDP / source asymmetry. MLAG peer-link adjacencies are filtered by default; see Configure MLAG groups.
- the AUDIT screen check every open
What you still don’t get
Section titled “What you still don’t get”- CDP / FDP: only LLDP is parsed. Cisco’s CDP and Foundry’s FDP have similar MIBs but different OIDs; could be added by following the same pattern.
Trying it
Section titled “Trying it”# 1. Register a device with SNMP collectionmake device-add HOSTNAME=sw-edge-7 IP=10.0.0.7 \ SOURCE=snmp COMMUNITY=read-only
# 2. Wait for the orchestrator to spawn the collector (~30s) and# for it to do one poll cycle (~60s default)
# 3. Verify adjacencies landedmake psql> SELECT a.local_port_id, p.name AS local_port,> a.remote_chassis_id, a.remote_port_descr, a.source> FROM adjacency a> JOIN port p ON p.id = a.local_port_id> WHERE upper_inf(a.valid_during);You should see one row per trunk port the device has LLDP neighbors on.
See also
Section titled “See also”- The parser source:
src/l2trace/collectors/snmp.py::parse_lldp_walk - The writer source:
src/l2trace/db/adjacency.py - SNMP collector configuration
- The L2 traceroute algorithm — what adjacencies actually power