Ring buffer overwrite policies for telemetry backlogs

Every bounded buffer eventually meets a producer that will not stop, and the policy it applies at that moment decides what a week-long outage costs. This guide works through the four policies a telemetry spool can implement, the failure each one produces, and how to combine them per message class so that the answer is right for events and right for positions at the same time. It sits under store-and-forward buffering in the Bandwidth & Async Sync Optimization guide.

The four policies

Drop newest refuses the incoming record and keeps what is already stored. It preserves the beginning of an incident and loses the end, which is right for a diagnostic stream where the first occurrence explains the fault and the thousandth adds nothing.

Drop oldest — the classic ring buffer — overwrites the head to make room. It keeps the buffer current at the cost of a hole in the history, which is right for position telemetry where the most recent location is the operationally useful one and last Tuesday’s is not.

Backpressure refuses to accept the record and signals the producer to stop. It is correct only when the producer can do something with the refusal. A UART reader cannot; a batch importer can.

Decimate keeps everything but at reduced resolution: when the buffer passes a threshold, discard every second record from the oldest region rather than dropping a contiguous span. It preserves the shape of a track across the whole outage at progressively coarser fidelity, which for a position stream is frequently better than either drop policy — a day at 60-second resolution beats twelve hours at 1-second resolution and twelve hours of nothing.

What survives a 40-hour outage under each policy A buffer sized for 24 hours meeting a 40 hour outage, shown four ways. Drop newest retains the first 24 hours in full and nothing after. Drop oldest retains the last 24 hours in full and nothing before. Backpressure retains the first 24 hours and stops the producer, which for a sensor reader means the readings are lost anyway. Decimate retains all 40 hours, at full resolution for the most recent six and progressively coarser resolution — 5 second, then 30 second, then 120 second spacing — going back. A 24-hour buffer meets a 40-hour outage drop newestdrop oldestbackpressuredecimate lost lost producer stopped — readings never taken 120 s30 s5 sfull 1 s outage beginslink returns · 40 h later Only the last row still answers "where was the asset on Tuesday afternoon?" — at a resolution that is enough to answer it.
Three of these policies choose which half of the outage to lose. The fourth chooses how much detail to lose across all of it, which for position history is nearly always the better trade.

Implementing decimation

Decimation is the least familiar of the four and the most useful for position streams, so it is worth writing out. The mechanism is a set of resolution bands: the buffer is divided by age, and each older band is thinned to a coarser spacing when space is needed.

# decimate.py — age-banded thinning for a position spool.
# Runs on the maintenance task when a partition crosses its high-water mark.
# Preserves the first and last record of every thinned run so the track's
# shape and its endpoints survive at every resolution.
BANDS = (
    # (max_age_seconds, target_spacing_seconds)
    (6 * 3600, 1),        # last 6 h: keep everything
    (24 * 3600, 5),       # 6–24 h: one every 5 s
    (72 * 3600, 30),      # 1–3 days: one every 30 s
    (float("inf"), 120),  # older: one every 2 minutes
)


def target_spacing(age_s: float) -> int:
    for max_age, spacing in BANDS:
        if age_s <= max_age:
            return spacing
    return BANDS[-1][1]


def decimate(records, now_s: float):
    """records: iterable of (offset, timestamp_s, kind, payload), oldest first.
    Yields the offsets to drop. Never drops an event record, and never drops
    two adjacent records — the track must keep its vertices."""
    keep_time = None
    prev_kept = True
    for offset, ts, kind, _payload in records:
        if kind != KIND_POSITION:
            keep_time = None                   # events always survive
            prev_kept = True
            continue
        spacing = target_spacing(now_s - ts)
        if keep_time is None or (ts - keep_time) >= spacing:
            keep_time = ts
            prev_kept = True
            continue
        if not prev_kept:
            # Refuse to drop two in a row: preserves direction changes even
            # when the spacing rule would remove them.
            keep_time = ts
            prev_kept = True
            continue
        prev_kept = False
        yield offset

The “never drop two adjacent records” rule is the detail that makes decimated tracks usable. A pure time-spacing rule removes every point between kept samples, which flattens a corner taken between two samples into a straight line. Keeping every second point at worst preserves the direction changes, so the decimated track still looks like the route rather than like a polygon of chords.

Constraint validation

Constraint Expected impact Mitigation built into the design
RAM A policy that needs the whole buffer in memory would not fit Decimation streams the partition, holding one record and two timestamps
CPU A thinning pass competes with ingestion Triggered at a high-water mark, run on the maintenance task, never inline with append
Flash Rewriting to compact costs writes One sequential rewrite per pass, reclaiming a large fraction — not a rewrite per dropped record
Correctness Events must never be lost to a position policy Reserved partitions plus an explicit kind check in the decimator
Auditability Silent loss is the real hazard Every dropped record increments a per-policy counter that is exported and synced

Gotchas and edge cases

  • A dropped record must be counted, always. The single most damaging property of any of these policies is silence. Export counters per partition and per policy, and sync them as telemetry in their own right — a consumer that knows 4 100 positions were decimated can interpret the gap; one that sees only the surviving records cannot.
  • Decimation is not idempotent across passes. Running it repeatedly thins the same data further as it ages, which is intended, but a bug that re-runs it in a loop empties the partition. Gate the pass on the high-water mark and record the last pass time.
  • Drop-oldest with variable-size records needs care. Reclaiming “enough bytes” can drop a partial record if the loop stops mid-record. Always drop whole records and recompute the freed space from actual record boundaries.
  • Backpressure that cannot be honoured is worse than dropping. A producer told to stop, which cannot stop, will either block — stalling the acquisition path — or ignore the signal, in which case the buffer overflows anyway with the added latency of the failed negotiation.
  • Events need their own reservation, not just their own policy. A never-drop policy on a shared partition means the partition fills with events and then refuses positions. The reservation described in the parent guide is what makes a never-drop policy safe.
Policy by message class on a mixed workload A matrix of four message classes against their correct policy and reasoning. Alarms and geofence events use never drop with a reserved partition, because a single missing record changes the conclusion. Position telemetry uses decimate, because shape at reduced resolution beats a contiguous hole. Periodic health samples use drop oldest, because only the recent trend is used. Debug traces use drop newest with a small partition, because the first occurrence of a fault explains it and the rest is repetition. One buffer, four classes, four different right answers alarms, geofence crossings never drop · reserved partition one missing record changes the conclusion position telemetry decimate by age band shape at lower resolution beats a contiguous hole periodic health samples drop oldest only the recent trend is ever read debug traces drop newest · small partition the first occurrence explains the fault
Choosing one policy for the whole device is the mistake. The classes disagree about which end of the history matters, and the buffer can accommodate all of them.

Deciding which policy a stream needs

Two questions settle it. If exactly one record of this stream survived the outage, which one would you want? If the answer is “the first”, the policy is drop newest. If it is “the last”, drop oldest. If the answer is “I would want a sample from throughout”, the policy is decimate. If the answer is “all of them, this is not negotiable”, the stream needs a reservation and a never-drop policy — and a rate low enough that the reservation can hold it.

What does the consumer do with a gap? A consumer that treats a gap as “no data” behaves differently from one that treats it as “not reported”. Position history is usually the first; a state stream — where a missing record means the state is unknown rather than unchanged — is the second, and gaps in it are far more damaging. State streams belong in the never-drop class even when their volume makes that uncomfortable, because the alternative is a consumer confidently reporting a state the device never sent.

Making the loss visible upstream

A policy that drops records correctly and says nothing about it has solved half the problem. The consumer receives a clean sequence with an unmarked hole, and every analysis over that sequence is quietly wrong in a way that looks like a real absence of activity.

The fix is a gap record: a small synthetic message emitted into the same stream at the point where records were removed, carrying the count dropped, the time span they covered and the policy that removed them. It costs a few dozen bytes per gap and it converts an invisible hole into a described one. A consumer that sees dropped: 4 118 positions, 06:12–14:40, policy=decimate can render the period correctly, exclude it from a utilisation calculation, or ask for a resend if the data is recoverable.

Emit the gap record at drop time rather than at drain time, so that its position in the stream is where the data was, not where the device happened to notice. Give it the same durability treatment as an event — a gap record lost to a power cut leaves exactly the hole it was meant to describe.

The same stream with and without gap records Two received sequences over the same outage. Without gap records the consumer sees a run of positions, a silent eight-hour absence, and a resumption, which is indistinguishable from an asset that was switched off. With gap records the same sequence carries a synthetic marker recording 4 118 positions dropped between 06:12 and 14:40 by the decimation policy, which the consumer can render, exclude or query. A hole and a described hole are different data without nothing — was the asset off, or did the device drop it? with gap: 4 118 positions · 06:12–14:40 · policy=decimate Forty bytes turns an unanswerable question into a rendered band on a timeline.
The consumer cannot infer the second row from the first. Whatever the policy discarded, the fact that it discarded it has to travel.

One last consideration is what the policy does to a fleet-wide analysis. If every device decimates independently, a query over the whole fleet meets tracks at different resolutions depending on which devices had outages — and an aggregate computed naively over that mixture is weighted toward the devices that stayed connected. Recording the resolution alongside the data lets the analysis normalise; without it, the bias is invisible and systematic, and it favours exactly the devices whose behaviour was least interesting.