Skip to content

Collection cadence and the resolution order

Not every device wants the same polling cadence. A core switch carrying thousands of MACs through fast-aging forwarding tables earns an aggressive gNMI subscription; a closet of access switches with stable desktop populations is fine on a lazy SNMP poll. Breitbart 2004 §VI.B (Inventory Maintenance) makes the point directly — the cost of keeping an inventory current is proportional to churn, so the refresh rate should track the device’s role, not a global constant.

l2trace expresses that policy with collection profiles: a named bundle of intervals (gateway, desktop, core, …) that a device can opt into. But a profile is only half the story. The interesting part is how a profile, a per-device override, and a collector’s built-in default collapse into one number at poll time — and why that collapse lives in a single SQL expression rather than scattered across the codebase.

For one (device, source) pair, the effective poll interval is decided by a four-tier precedence. Each tier is consulted only if the one above it produced nothing:

TierSource of the valueWhere it lives
1Per-row overridedevice_collector.extras['poll_interval_seconds']
2Per-source profile overrideprofile.{snmp,gnmi,ssh}_interval_seconds
3Profile defaultprofile.default_poll_interval_seconds
4Collector built-inCollector.DEFAULT_POLL_INTERVAL_SECONDS (no row injected)

Tier 1 is the escape hatch: an operator who wants this one switch on a 0.25-second gNMI heartbeat sets it directly on the collector row and never touches a profile. Tier 2 lets a profile say “gNMI on these devices streams fast, but their SNMP backstop can be lazy.” Tier 3 is the profile’s catch-all when it has no opinion about a specific source. Tier 4 is the floor: if a device is assigned no profile and carries no override, the resolution function returns nothing and the collector falls back to the default baked into its own class (60.0 seconds for SNMP and SSH today).

The whole thing is one COALESCE in list_enabled_collectors:

COALESCE(
(dc.extras->>'poll_interval_seconds')::float,
CASE dc.source::text
WHEN 'snmp' THEN cp.snmp_interval_seconds::float
WHEN 'gnmi' THEN cp.gnmi_interval_seconds::float
WHEN 'ssh' THEN cp.ssh_interval_seconds::float
ELSE NULL
END,
cp.default_poll_interval_seconds::float
) AS effective_poll_interval_seconds

COALESCE returns its first non-NULL argument, which is exactly the tier semantics: try the override, then the per-source profile column, then the profile default. The LEFT JOIN collection_profile means a device with no profile contributes NULL for every cp.* column, so the COALESCE falls through to NULL overall — tier 4, handled by the collector. There is no fourth branch in the SQL because the absence of a value is the signal.

Why resolve it in SQL and not Python? Because the alternative is reading the profile, the per-source columns, and the override into application objects and re-implementing COALESCE by hand — three places to get the precedence wrong, and a second query per device on the orchestrator’s hot path. One expression in the query the orchestrator already runs is both faster and harder to get subtly wrong.

The cast chain says ::float on every branch, and that is not cosmetic. It fixed a real bug found in testing.

The per-source and default columns are declared INT in the schema — whole seconds, which is all a profile ever needs. But tier 1 reads from extras, a jsonb column, and JSON has no integer/float distinction the way Postgres does. A sub-second gNMI heartbeat of 0.25 is stored as the JSON number 0.25; even a value an operator means as a whole number can serialize as 1.0. Pull it back out with ->> and you get the text "1.0", and PostgreSQL will not cast the string "1.0" straight to int:

ERROR: invalid input syntax for type integer: "1.0"

"1.0"::int is a hard error; "1.0"::float is 1.0, and 0.25::float survives as 0.25. So the override branch must be ::float to accept sub-second and decimal values at all. Once one branch of a COALESCE is float, the others have to type-unify to the same type, which is why the two profile branches and the default also carry ::float — the INT columns widen to FLOAT cleanly, the override survives intact, and the result column is effective_poll_interval_seconds float. The EnabledCollector dataclass types the field float | None for the same reason: the whole point of the override is to carry sub-second gNMI intervals that an int would have silently truncated or rejected.

The resolution happens entirely in the data layer. By the time the orchestrator has a row, the answer is already computed — collectors never learn that profiles exist. The orchestrator threads the resolved value into extras just before it builds the CollectorConfig:

effective_extras = dict(ec.extras)
if ec.effective_poll_interval_seconds is not None:
effective_extras["poll_interval_seconds"] = (
ec.effective_poll_interval_seconds
)
cfg = CollectorConfig(..., extras=effective_extras)

When the resolution returned NULL (tier 4), nothing is injected and the collector reaches for its own DEFAULT_POLL_INTERVAL_SECONDS. When it returned a value (tiers 1–3), it lands in extras['poll_interval_seconds'] — the exact key a collector already reads. The override and the profile result therefore arrive through the same channel, and the collector cannot tell which tier produced the number it’s running on. That is the design goal: a collector is a thing that talks to one device on one interval, and the policy machinery that chose the interval is somebody else’s concern.

Note the assignment is a deliberate no-op in the override case. If the operator set extras['poll_interval_seconds'] directly (tier 1), the SQL already returned that same value, so re-writing it into extras changes nothing. The branch is written defensively so the two paths can never disagree.

The UPDATE-FROM defense in profile assignment

Section titled “The UPDATE-FROM defense in profile assignment”

There is a second, quieter design decision worth surfacing, because it’s the kind of thing that looks like a stylistic preference until it eats a production assignment.

Binding a profile to a device is assign_profile_to_device, and it uses an UPDATE ... FROM join rather than the more obvious correlated subquery:

UPDATE device
SET collection_profile_id = cp.id
FROM collection_profile cp
WHERE device.hostname = :hostname
AND cp.name = :profile_name

The tempting alternative is SET collection_profile_id = (SELECT id FROM collection_profile WHERE name = :profile_name). It works — until the operator fat-fingers the profile name. A subquery that matches no rows returns NULL, and SET collection_profile_id = NULL is a perfectly valid UPDATE. So a typo doesn’t fail; it silently clears the device’s existing profile assignment. The device you meant to move to core instead loses whatever profile it had and falls all the way back to tier-4 defaults, quietly, with no error.

The UPDATE ... FROM form makes a typo a no-op instead. The join only produces a row when both the device hostname and the profile name match; a misspelled profile name means the join is empty, zero rows are updated, and the function returns False so the caller can tell the operator “no such profile” rather than corrupting state. The destructive failure mode is structurally impossible. This is the same instinct as the COALESCE-on-upsert pattern elsewhere in the device layer: make the mistake path a no-op, never a silent overwrite.

The foreign key from device.collection_profile_id to collection_profile is ON DELETE SET NULL. Deleting a profile that devices are still assigned to doesn’t fail and doesn’t cascade-delete the devices — it sets their collection_profile_id back to NULL, dropping them to tier-4 defaults. That’s the safe default: removing a policy should never remove the devices the policy governed, and a device with no profile is a well-defined state (it just polls at the collector baseline). Re-assigning the orphaned devices to a new profile is a follow-up the operator can take at leisure; in the meantime nothing stops collecting.

  • Assign collection profiles — the operator playbook for creating profiles and binding them to devices
  • Data model — the collection_profile and device_collector table definitions behind the resolution order