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.
The resolution order
Section titled “The resolution order”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:
| Tier | Source of the value | Where it lives |
|---|---|---|
| 1 | Per-row override | device_collector.extras['poll_interval_seconds'] |
| 2 | Per-source profile override | profile.{snmp,gnmi,ssh}_interval_seconds |
| 3 | Profile default | profile.default_poll_interval_seconds |
| 4 | Collector built-in | Collector.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_secondsCOALESCE 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.
Why ::float and not ::int
Section titled “Why ::float and not ::int”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.
Collectors stay ignorant of profiles
Section titled “Collectors stay ignorant of profiles”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 deviceSET collection_profile_id = cp.idFROM collection_profile cpWHERE device.hostname = :hostname AND cp.name = :profile_nameThe 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.
What happens when a profile is deleted
Section titled “What happens when a profile is deleted”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.
See also
Section titled “See also”- Assign collection profiles — the operator playbook for creating profiles and binding them to devices
- Data model — the
collection_profileanddevice_collectortable definitions behind the resolution order