Table of Contents
In petabyte-scale observability platforms (Thanos, Cortex, VictoriaMetrics, Grafana Mimir), telemetry pipelines process millions of metric streams continuously.
Operating production time-series systems introduces two major data engineering hurdles: Out-of-Order (OOO) Metric Ingestion and High-Volume Historical Storage Costs.
When mobile client devices or edge IoT nodes reconnect after a network partition, they transmit historical metrics out of timestamp order.
Simultaneously, storing raw 1-second metric resolution across years of historical data inflates cloud storage costs into millions of dollars while crippling dashboard query rendering.
To resolve these challenges, modern TSDB platforms implement Out-of-Order Head Buffers and Automated Downsampling Rollups.
This article details OOO skip-list buffering, background chunk merging, multi-resolution downsampling (1s → 5m → 1h), rollup aggregation functions (min, max, sum, count), and automated retention tiering.
Out-of-Order Ingestion & Downsampling Architecture
How time-series databases handle late-arriving metrics and execute automated multi-tier downsampling rollups:
Core Downsampling & OOO Mechanics
- The Out-of-Order (OOO) Ingestion Problem:
- Standard Gorilla compression algorithms rely on strictly monotonically increasing timestamps ($t_n > t_{n-1}$).
- If a late sample arrives with timestamp $t_{\text{late}} < t_{\text{last}}$, attempting to append it to a Gorilla bit stream corrupts the compressed block.
- Prometheus / VictoriaMetrics OOO Head Buffer:
- Modern TSDBs allocate a dedicated Out-of-Order (OOO) Head Buffer alongside the main Head Block.
- Late-arriving metrics are stored in a memory-sorted Skip-List or B-Tree structure indexed by timestamp.
- During background block compaction, the engine reads both the in-order Gorilla chunks and the sorted OOO buffer, merging them into a unified, ordered disk block directory.
- Automated Downsampling Rollups:
- The Resolution Problem: Rendering a 1-year dashboard graph over raw 1-second metrics requires reading over 31 million data points per time series. Most pixels on a computer monitor are less than 2000 pixels wide!
- Multi-Tier Resolution Rollups:
- Raw Tier (1-Second Scrapes): Kept for 14 days for real-time debugging and root cause investigation.
- 5-Minute Downsampled Tier: After 14 days, the downsampler aggregates 300 raw samples into 5-minute bucket summaries storing statistical aggregates: $$\langle \text{min}, \text{max}, \text{sum}, \text{count} \rangle$$
- 1-Hour Downsampled Tier: After 90 days, data is aggregated into 1-hour rollup buckets for long-term historical trends.
- Query Resolution Pushdown:
- When a user queries a 1-year time range (
http_requests_total[1y]), the query engine automatically routes the request to the 1-hour downsampled tier, reading $99.9%$ fewer data points and returning instant query graphs!
- When a user queries a 1-year time range (
Python Implementation: OOO Ingestion & Downsampling Engine
Here is a production-grade Python implementation of an Out-of-Order Metric Ingestion Buffer and Multi-Tier Downsampling Rollup Engine:
import math
from typing import Dict, List, Tuple
from pydantic import BaseModel
class RawSample(BaseModel):
timestamp: int
value: float
class DownsampledBucket(BaseModel):
bucket_start_time: int
min_val: float
max_val: float
sum_val: float
count_val: int
@property
def avg_val(self) -> float:
return self.sum_val / self.count_val if self.count_val > 0 else 0.0
class OutOfOrderTSDBEngine:
"""
Simulates Out-of-Order (OOO) Metric Ingestion & Multi-Tier Downsampling Rollups.
"""
def __init__(self, raw_retention_sec: int = 86400):
self.in_order_stream: List[RawSample] = []
self.ooo_buffer: List[RawSample] = [] # Sorted late-arriving samples
self.downsampled_5m_buckets: List[DownsampledBucket] = []
def ingest_metric(self, timestamp: int, value: float):
sample = RawSample(timestamp=timestamp, value=value)
# Check if Out-of-Order (timestamp < last in-order timestamp)
if self.in_order_stream and timestamp < self.in_order_stream[-1].timestamp:
print(f" ⚠️ [OOO LATE SAMPLE] Timestamp {timestamp} < Last {self.in_order_stream[-1].timestamp}! Routing to OOO Buffer.")
self.ooo_buffer.append(sample)
self.ooo_buffer.sort(key=lambda s: s.timestamp) # Sort OOO skip list
else:
self.in_order_stream.append(sample)
print(f" 📥 [In-Order Ingest] Timestamp {timestamp} -> Value: {value:.2f}")
def merge_ooo_buffer_and_compact((self) -> List[RawSample]:
"""Merges OOO Buffer with In-Order Stream into unified timeline."""
print(f"\n🧹 [Merging OOO Buffer] Merging {len(self.ooo_buffer)} late samples into main stream...")
combined = self.in_order_stream + self.ooo_buffer
combined.sort(key=lambda s: s.timestamp)
self.in_order_stream = combined
self.ooo_buffer.clear()
print(f" ✅ [Merge Complete] Unified stream contains {len(self.in_order_stream)} total samples.")
return self.in_order_stream
def run_5m_downsampling_job(self, bucket_size_sec: int = 300):
"""Downsamples raw 1-second metrics into 5-minute aggregate buckets."""
print(f"\n🗜️ [Downsampling Job] Aggregating raw stream into {bucket_size_sec}s (5-Minute) Rollup Buckets...")
if not self.in_order_stream:
return
buckets: Dict[int, List[float]] = {}
for sample in self.in_order_stream:
# Align timestamp to 5-minute bucket boundary
b_start = (sample.timestamp // bucket_size_sec) * bucket_size_sec
buckets.setdefault(b_start, []).append(sample.value)
self.downsampled_5m_buckets.clear()
for b_start, vals in sorted(buckets.items()):
b = DownsampledBucket(
bucket_start_time=b_start,
min_val=min(vals),
max_val=max(vals),
sum_val=sum(vals),
count_val=len(vals)
)
self.downsampled_5m_buckets.append(b)
print(f" • 5m Bucket [{b_start}] -> Min: {b.min_val:.1f} | Max: {b.max_val:.1f} | Avg: {b.avg_val:.1f} | Count: {b.count_val}")
print(f" 🎉 Downsampled {len(self.in_order_stream)} raw samples into {len(self.downsampled_5m_buckets)} rollup buckets ($95\\%$ storage reduction!)")
# Demonstration Execution
if __name__ == "__main__":
engine = OutOfOrderTSDBEngine()
print("🚀 Demonstrating Out-of-Order Ingestion & Multi-Tier Downsampling Engine...")
print("=" * 75)
base_time = 1700000000
# 1. Ingest In-Order Samples (1s intervals)
for i in range(10):
engine.ingest_metric(base_time + i, 50.0 + i)
# 2. Ingest Late Out-of-Order Samples (Reconnected IoT node)
engine.ingest_metric(base_time + 2, 99.9) # Late sample!
engine.ingest_metric(base_time + 4, 88.8) # Late sample!
# 3. Merge OOO Buffer
engine.merge_ooo_buffer_and_compact()
# 4. Execute 5-Minute Downsampling Rollup Job
engine.run_5m_downsampling_job(bucket_size_sec=5) # 5s buckets for demo
Downsampling & OOO Gotchas & Best Practices
When managing long-term metric retention:
Use Downsampled Rollups for Long-Range Dashboard Queries: Configure Grafana datasources to read from 5-minute or 1-hour downsampled Thanos/VictoriaMetrics tiers when rendering multi-month graphs. It accelerates dashboard load times from 30 seconds to under 200 milliseconds.
Cap Maximum Out-of-Order Time Windows: Allowing unbounded OOO ingestion (e.g. metrics delayed by 30 days) forces the storage engine to re-rewrite historical compacted block files on disk. Limit OOO window acceptance to 2 hours (out_of_order_time_window: 2h).
Real-World Enterprise Impact
Downsampling and OOO ingestion engines (in Thanos, VictoriaMetrics, and Grafana Mimir) report:
- Over $95%$ Reduction in Long-Term Cloud Storage Costs: Downsampling 1-second raw metrics into 5-minute rollups slashes S3 storage volumes.
- $50\times$ Faster Multi-Month Dashboard Rendering: Querying pre-aggregated downsampled buckets eliminates reading billions of raw historical data points.

Discussion & Comments