Skip to content

Collect LLDP adjacencies

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.

l2trace can collect LLDP via either collector:

WireWhat it watchesWhen 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, lldpRemSysNameUniversal — 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).

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:

OIDColumnWhat it tells us
1.0.8802.1.1.2.1.4.1.1.5lldpRemChassisIdThe peer’s chassis MAC (load-bearing)
1.0.8802.1.1.2.1.4.1.1.8lldpRemPortDescThe peer’s port description (e.g. Eth8)
1.0.8802.1.1.2.1.4.1.1.9lldpRemSysNameThe 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.

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 — only MAC_ADDRESS is ingested, matching the SNMP path’s filtering.
  • Normalizes the MAC to canonical lowercase form via the same normalize_mac helper the MAC table parser uses.
  • Prefers port-description (free-text label) over port-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.

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.upper on the existing row, INSERT the new one (in that order — the adj_no_overlap_per_source EXCLUDE 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.

  • Peer device resolution: the DeviceIdentified event (emitted by every collector that learns its own chassis_id) populates device.chassis_id. The reconciler then resolves each new adjacency row’s remote_chassis_id against 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→B row for a corresponding B→A and flag one-way LLDP / source asymmetry. MLAG peer-link adjacencies are filtered by default; see Configure MLAG groups.
  • 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.
Terminal window
# 1. Register a device with SNMP collection
make 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 landed
make 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.