feat(llo/v31): skip observation/aggregation for recently-reported channels - #263
Draft
cawthorne wants to merge 6 commits into
Draft
feat(llo/v31): skip observation/aggregation for recently-reported channels#263cawthorne wants to merge 6 commits into
cawthorne wants to merge 6 commits into
Conversation
…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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an offchain config field
DefaultMinObservationIntervalNanosecondsthat skips observation and aggregation for channels that are not yet due to report — the same idea as the existingDefaultMinReportIntervalNanosecondsreport-skip, but applied earlier in the pipeline so the work is never done rather than done and discarded.Motivation
DefaultMinReportIntervalNanosecondsskips report generation when a channel's last report was too recent. But the channel's streams are still observed (a data-source fetch, via the blob pump) and aggregated every round, even when the channel is guaranteed not to report.Design
Per-channel observation schedule
Each channel carries an observation schedule in the hot state: the timestamp at which it next becomes due. It advances by the interval from its own previous slot, and only on a round that actually reported.
Advancing from the reporting round instead would make the interval a fixed delay, so any latency in a cycle would be added to every later cycle and the cadence would creep. Advancing from the schedule makes latency a one-time phase offset: a channel configured to report every
Tkeeps reporting everyT.This 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.Withholding, and the lead it produces
Stream values are gathered asynchronously and arrive a round after a channel's streams enter the pump's input, so the first due round after a skip window has no aggregates. Reporting anyway would emit nil values and advance
validAfterover a round that carried nothing, so instead the channel withholds its report. Neither the watermark nor the schedule advances, so it stays due and reports once the values land.That delay then becomes the offset between schedule and watermark — and that offset is exactly the lead the pump needs. A channel becomes due for observation far enough ahead of when it may report that its values are always gathered in time. The lead matches whatever the data source actually needs and is not configured anywhere.
What the skip does not apply to
history_backfill channels are exempt: their watermark is a history timestamp
rather than a report time, so a report cadence means nothing for them.
Channels reading
History(...)are not exempt, deliberately. The skip makes achannel's report cadence the sampling rate for its windows. That lowers their
resolution but does not make them wrong — records carry their own
observedAtNanosecondsand TWAP integrates over real time — and it cannot stallsilently, 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. The degenerate case costs reports, not correctness.
What it does change is how a window must be sized, now documented on the config
field and beside the warmup section in the calculated engine:
so an interval at or below one second changes nothing about achievable quality
and makes depth go further, since depth stops being spent on records that
collapse into the same bucket. Above a second, the observed bucket count falls
to about
window/intervaland interior gaps becomeinterval-1buckets.Avg,Median,EMA,SMA,WMA,Delta,PctChange,Spread,Variance,Stddev,Last) reads the record seriesdirectly with no bucketing, so its wall-clock meaning tracks the sampling rate
at any interval.
This is not a new class of concern:
checkTWAPalready rejectsminSamples > depthat config time, and becauseMcounts distinct seconds, anyworking TWAP config is already sized against the sampling rate. A node logs once
per channel when a history-reading channel is first skipped, carrying the
interval.
Carry-forward
A pair belonging only to a skipped channel keeps its carried value. Carry-forward
is the last-known-good store behind
TimestampedStreamValuemonotonicity and thefallback for a transient aggregation failure, so a channel must not come out of a
skip window worse off than it went in. The pass is driven from the live
definitions rather than the carry map, so a pair whose last channel was removed is
still reclaimed; and it is restricted to pairs the round never visited, so the
deliberate drops in the main loop keep their decision. Carried values never reach
history — a window records a gap rather than a repeat, so a carried value is not
weighted twice and a stalled feed stays visible to TWAP's gap checks.
Promotion
Promotion replaces
validAfterwholesale from the predecessor's retirement report so the handover is gapless, and the observation schedule is cleared to match. A slot inherited from staging could otherwise leave a channel not due on the promotion round, and a channel that is not aggregated has no values to report — reopening the gap, up to a full interval wide, in the one path built to avoid it. An unset schedule means due, so every channel aggregates immediately and rebuilds its slot from its first report.Channels that have never reported
A channel with no schedule entry is due. A newly effective channel therefore aggregates from its first round and builds its initial aggregates and history windows immediately. It is still not reportable that round, since
validAfterequals the observation timestamp — pre-existing behaviour.Changes
Config (
llo/protocol/)llo_offchain_config.proto/.pb.go: new field 4defaultMinObservationIntervalNanosecondsoffchain_config.go: wired throughDecode/Encode; validation — must be 0 for protocol v0; for v1+, 0 disables the skip and it must not exceedDefaultMinReportIntervalNanoseconds. Setting it equal is fine.plugin_codecs.proto/.pb.go:LLOHotStateProtogainsobservationDueNanoseconds(field 5), sorted ascending by channelID like its neighbours for deterministic serializationPlugin (
llo/dev/v31/)plugin.go:isObservationDueis a schedule lookup;nextObservationDueowns the fixed-rate advance and skips missed slots rather than firing every round to catch up after a channel has been unable to report.Observationapplies the one-round-lagging advance the hot state has not yet recorded, and publishes the pump's input every round so a later cycle can never gather a stale stream set.statetransition.go: computes the schedule alongsidevalidAfter, and derives the due set once so aggregation and calculated-stream evaluation cannot disagree about which channels a round covers.reports.go: a due channel with a missing stream aggregate is not reportable while the interval is configured.kv.go: schedule persisted in the hot state;readValidAfterOnlyreads what Observation needs without decoding carry-forward aggregates.Behaviour when disabled
With the interval unset (
0, every deployment today) nothing changes: every channel is due, the withholding rule does not apply, and the observed set is identical — including history_backfill channels, which are exempt from the schedule entirely since their watermark is a history timestamp rather than a report time.Trade-off
The data source is gathered only on the rounds a channel is actually due rather than on every round, so the saving scales with how much slower a channel's report cadence is than the round rate. The cost is that a channel's report lands one gather later than the moment it becomes due — a constant phase offset per channel, not a growing one, and not something operators need to compensate for in config.
Tests
Test_ObservationIntervalSkip_observableStreams— due/not-due filtering, shared streams, unscheduled channels, disabled modeTest_ObservationIntervalSkip_ObservationAdvancesLaggingSchedule— Observation and StateTransition agree despite the hot state lagging a roundTest_nextObservationDue— fixed-rate advance, no drift over 100 late cycles, catch-up after an outageTest_ObservationIntervalSkip_WithholdsReportUntilValuesArrive— no nil reports; rule is gated on the intervalTest_ObservationIntervalSkip_CarryForwardSurvivesSkip— a skipped channel's carried value survives, is not published as this round's aggregate, and is still reclaimed once no live channel declares the pairTest_History_SurvivesObservationSkip— a history window gains one record per aggregated cycle, none on skipped rounds, and stays readable across a skip windowTest_ObservationIntervalSkip_aggregate,Test_ObservationIntervalSkip_FullRound— unit and end-to-end skip/report cycleNotes for review
LLOHotStateProtois 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.plugin_codecs.pb.gowas 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.