Skip to content

feat(llo/v31): skip observation/aggregation for recently-reported channels - #263

Draft
cawthorne wants to merge 7 commits into
smartcontractkit:masterfrom
cawthorne:feat/observation-interval-skip
Draft

feat(llo/v31): skip observation/aggregation for recently-reported channels#263
cawthorne wants to merge 7 commits into
smartcontractkit:masterfrom
cawthorne:feat/observation-interval-skip

Conversation

@cawthorne

@cawthorne cawthorne commented Aug 28, 2026

Copy link
Copy Markdown

Summary

Adds an offchain config field DefaultMinObservationIntervalNanoseconds. It is the same idea as the existing DefaultMinReportIntervalNanoseconds, moved earlier in the pipeline: instead of doing the work and discarding the report, a channel that cannot report yet is not observed or aggregated at all.

Today a channel's streams are fetched (a data-source call, via the blob pump) and aggregated every round even when DefaultMinReportIntervalNanoseconds guarantees it will not report. The saving scales with how much slower a channel's cadence is than the round rate.

Unset (0) everywhere today, and unset means no change: every channel is due and the observed set is byte-identical.

How it works

Each channel carries an observation schedule in the hot state — the timestamp at which it next becomes due. The schedule advances by the interval from its own previous slot, and only on a round that actually reported.

That is fixed-rate rather than fixed-delay, and the distinction matters: advancing from the round that reported would add any latency in a cycle to every later cycle, so the cadence would creep. Advancing from the slot makes latency a one-time phase offset — a channel configured to report every T keeps reporting every T.

The schedule is deliberately not validAfter. That watermark is a report boundary, emitted in the report and defining the window (validAfter, observationTimestamp] that consecutive reports must tile exactly; anchoring it to a schedule would leave gaps between reports.

One consequence needs handling. Stream values are gathered asynchronously: they arrive a round after a channel's streams enter the pump's input. So on the first round after a skip window, a channel is due but its values have not landed yet. Reporting then would emit nils and advance validAfter across a round that carried nothing, so the channel instead withholds — it produces no report, and neither its watermark nor its schedule advances. It stays due and reports as soon as the values arrive.

The one-off delay that creates becomes the standing offset between schedule and watermark, which is exactly the lead the pump needs: from then on a channel becomes due for observation far enough ahead of when it may report that its values are always ready. The lead is whatever the data source actually needs and is not configured anywhere.

Details worth a reviewer's attention

Shared streams. A stream is observed if any channel needing it is due, and aggregated once for every due channel declaring the same pair. Pairs wanted only by skipped channels are not aggregated. A channel sharing a stream with a more frequently due one therefore finds its values already gathered when it comes due, and skips the withheld round.

History sampling. history_backfill channels are exempt from the schedule — their watermark is a history timestamp, so a report cadence means nothing for them. Channels reading History(...) are not exempt, deliberately: the skip makes their report cadence the sampling rate for their windows. That lowers resolution without making results wrong (records carry their own observedAtNanoseconds, TWAP integrates over real time), and it cannot stall silently — an unreadable window leaves the channel unreportable, which stops its schedule advancing, so it reverts to observing every round. It does change how a window must be sized; the config field documents this per window function. A node logs once per channel the first time a history-reading channel is skipped.

Carry-forward. A pair belonging only to a skipped channel keeps its carried value, so a channel does not come out of a skip window worse off than it went in. Carried values never reach history: a window records a gap rather than a repeat, so a stalled feed stays visible to TWAP's gap checks.

Promotion clears the schedule, matching how it replaces validAfter wholesale from the predecessor's retirement report. A slot inherited from staging could otherwise leave a channel not due on the promotion round, reopening — up to a full interval wide — the very gap that handover exists to close.

A channel with no schedule entry is due, so a newly effective channel aggregates from its first round and builds its initial history immediately.

Changes

Area
llo/protocol/llo_offchain_config.proto new field 4 defaultMinObservationIntervalNanoseconds
llo/protocol/offchain_config.go encode/decode plus validation: must be 0 for protocol v0; for v1+, 0 disables it and it must not exceed DefaultMinReportIntervalNanoseconds (equal is fine)
llo/protocol/plugin_codecs.proto LLOHotStateProto gains observationDueNanoseconds (field 5), sorted by channelID like its neighbours
llo/dev/v31/plugin.go isObservationDue (schedule lookup) and nextObservationDue (fixed-rate advance, skipping missed slots rather than firing every round to catch up). Observation applies the advance the one-round-lagging hot state has not recorded yet
llo/dev/v31/statetransition.go computes the schedule alongside validAfter, and derives the due set once so aggregation and calculated-stream evaluation cannot disagree
llo/dev/v31/reports.go the withholding rule
llo/dev/v31/kv.go schedule persisted in hot state; readHotStateForObservation reads what Observation needs without decoding carry-forward aggregates

Tests

Test
..._observableStreams due/not-due filtering, unscheduled channels, disabled mode
..._SharedStreams, ..._SharedStreamServesEveryDueChannel a stream updates if any channel needing it is due
..._ObservationAdvancesLaggingSchedule Observation and StateTransition agree despite the hot state lagging
Test_nextObservationDue fixed-rate advance, no drift over 100 late cycles, catch-up after an outage
..._WithholdsReportUntilValuesArrive no nil reports; gated on the interval
..._CarryForwardSurvivesSkip carried value survives, is not published as this round's aggregate, and is still reclaimed once no live channel declares the pair
Test_History_SurvivesObservationSkip one record per aggregated cycle, none on skipped rounds, readable across a skip window
Test_StateTransition_PromotionClearsObservationSchedule fails on the unfixed code with a staging slot carried into production
..._aggregate, ..._FullRound unit and end-to-end skip/report cycle

Notes for review

  • LLOHotStateProto is replicated state. The new field is additive, but writing it changes the serialized record, so rollout must be gated on the agreed offchain config like the rest of the feature.
  • With the interval enabled, a channel whose stream has no data stops reporting rather than emitting nils, and its watermark freezes. Better than publishing nils, but it is a real behaviour change gated on the feature being on.
  • plugin_codecs.pb.go was regenerated with protoc-gen-go v1.36.11 / protoc v7.35.0 against the committed v1.36.12 / v7.35.1, so the version header moves. Worth regenerating with the pinned toolchain before merge.

…bservation/aggregation for recently-reported channels

Add a new offchain config field that skips observation and aggregation for
channels whose last report is too recent, mirroring the existing
DefaultMinReportIntervalNanoseconds report-skip but applied earlier in the
pipeline to avoid unnecessary work on channels that won't report this round.

- New proto field 4: defaultMinObservationIntervalNanoseconds
- Validation: must be 0 for protocol v0; must not exceed
  DefaultMinReportIntervalNanoseconds for v1+ (0 = disabled)
- Observation: skips gathering stream values for not-due channels
- Aggregation: skips aggregating streams exclusive to not-due channels
- Calculated streams: only evaluated for due channels
- Shared streams are still observed/aggregated via due channels
- New channels are always due (no validAfter yet)
- ValidAfter watermark advanced for reported channels before skip check,
  matching the one-round lag in persisted hot state
The blob pump serves a round from the snapshot gathered under the previous
round's stream set, so a channel whose streams first entered the input on the
round it became due was aggregated with no values at all. With the observation
interval equal to the report interval the channel was due only on rounds it
reported, so it was never due twice running and never recovered: every cycle
produced a report with nil values (or a codec error) while validAfter advanced
regardless, since isReportable does not require aggregates.

Observation now applies the due check at now+lead, where lead is one round
estimated from the gap to the previous round's agreed observation timestamp and
capped at the interval. A channel's streams therefore enter the pump's input the
round before the channel is due, and the snapshot the due round consumes already
carries them. The lead also absorbs clock skew, so nodes whose clock trails the
agreed median still observe what the median round will aggregate. Degenerate
inputs (empty hot state, watermark ahead of now) widen the observed set rather
than narrowing it.

- SetInput now runs every round while Take stays gated on len(streams)>0, so the
  pump's input can never go stale and a round that needs no values no longer
  consumes and discards the snapshot gathered for the next one.
- observableStreams exempts backfill channels from the due check instead of
  dropping them, restoring the observed set for hosts that have not configured
  the interval and matching the documented behaviour.
- The due filter now lives in one place: StateTransition computes it once and
  feeds both aggregate and ProcessCalculatedStreams, and aggregate's signature
  reverts to its original shape. observableDefinitions retains tombstoned and
  backfill channels so the set handed to ProcessCalculatedStreams no longer
  depends on whether the interval is configured.
- isObservationDue's contract is corrected: a missing watermark means due on the
  Observation side only. StateTransition seeds a watermark for every newly
  effective channel, so a new channel is not aggregated until the interval
  elapses, which costs it nothing because it was not reportable in that window.
@cawthorne cawthorne closed this Aug 29, 2026
@cawthorne cawthorne reopened this Aug 29, 2026
…After

Each channel now carries its own observation schedule in the hot state,
advanced by the interval from its own previous slot and only on a round that
reported. Deriving the skip from validAfter instead made the interval a fixed
delay measured from whichever round actually reported, so latency in one cycle
was added to every later cycle and the cadence crept. It also overloaded
validAfter, which is a report boundary emitted in the report and defines the
window (validAfter, observationTimestamp] that consecutive reports must tile
exactly.

With a schedule, latency in a cycle becomes a constant phase offset instead: a
channel configured to report every T keeps reporting every T.

That offset is also what gives the blob pump its lead. Values are gathered a
round after a channel's streams enter the pump's input, so the first due round
after a skip window has none and the channel withholds its report rather than
emitting nils. Once schedule and watermark differ by that delay, a channel
becomes due for observation far enough ahead of when it may report that its
values are always ready, so the lead matches whatever the data source needs and
is not configured anywhere.

A channel with no schedule entry is due, so a newly effective one aggregates
from its first round and builds its initial aggregates and history. It is still
not reportable that round, since validAfter equals the observation timestamp.

- LLOHotStateProto gains observationDueNanoseconds (field 5), sorted ascending
  by channelID like its neighbours for deterministic serialization
- nextObservationDue owns the fixed-rate advance, and skips missed slots rather
  than firing every round to catch up after a channel has been unable to report
- gofmt factory.go and plugin_test.go
A pair belonging only to a channel the schedule skipped was never visited by
aggregate, so its carry-forward entry was dropped. Carry-forward is the
last-known-good store behind TimestampedStreamValue monotonicity and behind
surviving a transient aggregation failure, so dropping it let a channel adopt an
older value coming out of a skip window than it held going in, and left it no
fallback on the round it returned.

Such pairs now keep their carried value. The pass is driven from the live
definitions rather than from the carry map, so a pair whose last channel has been
removed is still reclaimed by not being written into the new hot record, and it
is restricted to pairs this round never visited, so the deliberate drops in the
main loop keep their decision.

Carried values are still kept out of history: appendHistory is not called for
them and its strictly-newer guard would reject them anyway. A window must record
a gap rather than a repeat, both so a carried value is not weighted twice and so
that a stalled feed stays visible to TWAP's gap checks.

Channels reading stream history are deliberately not exempt from the skip. The
skip makes their report cadence the sampling rate, which lowers window resolution
without making it wrong - records carry their own observation timestamp and TWAP
integrates over real time - and it cannot stall silently, because an unreadable
window leaves the channel unreportable, which also stops its schedule advancing,
so it reverts to observing every round until the window is satisfied.

What that does change is how a window should be sized, so it is now documented
where it will be read: on the config field and beside the warmup section in the
calculated engine. TWAP buckets its window by the second and keeps the newest
record per bucket, so an interval at or below a second leaves it unchanged and
makes depth go further; above a second the observed bucket count falls to about
window/interval. Every other window function reads the record series directly, so
it tracks the sampling rate at any interval. A node also logs once per channel
when a history-reading channel is first skipped.
Promotion replaces validAfter wholesale from the predecessor's retirement report
so the handover is gapless, but the observation schedule was carried over from
staging. A slot inherited that way can leave a channel not due on the promotion
round; a channel that is not aggregated has no values, and so cannot report -
reopening the gap up to a full interval wide, in the one path built to avoid it.

An unset schedule means due, so clearing it lets every channel aggregate
immediately and rebuild its slot from its first report, matching what validAfter
already does.
The interval tests framed due-ness as validAfter+interval, which is how an
earlier iteration derived it and is the conflation nextObservationDue
warns against. The schedule is its own map and due-ness is now >= a
stored slot, so name the map for what it is and state the comparisons the
code actually makes. Rename readValidAfterOnly to
readHotStateForObservation: it reads the schedule, observation timestamp
and reportability flags, and Observation never reads the validAfter it
was named for.
…terval

A stream is gathered if any channel needing it is due, and aggregated
once for every due channel declaring the same pair. Pairs wanted only by
skipped channels are not aggregated, even when the underlying stream was
gathered for someone else.

Also pins the consequence that follows: a channel sharing a stream with a
more frequently due one finds its values already gathered on the round it
comes due, so it reports without the round it would otherwise withhold.
Only a stream no due channel carries costs that round.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant