Stream History: storage, rollups & retention
Stream History stores each stream in three layers in TimescaleDB. Knowing which layer answers which read — and how long each layer lives — is the difference between a query that scans a year of raw samples and one that reads a pre-computed series. This page is the map (ZAF-681, the product side of the ZAF-672 observability decision).
The three layers
| Layer | Table / view | Grain | Written by | Read for |
|---|---|---|---|---|
| Summary | channel_history, channel_history_streams | one row per session / per broadcast | the finalizer (history_sampler.rs) once a session closes | list previews, channel-page aggregates |
| Raw samples | channel_history_stats (hypertable) | one 60s sample per (session, platform) | the sampler, every 60s per open session | the detail curve of one stream |
| Rollups | channel_history_stats_5m, channel_history_stats_1h (continuous aggregates) | one bucket per (5m or 1h, session, platform) | TimescaleDB refresh policies | long / cross-session ranges |
Raw samples — the detail curve
channel_history_stats is the raw time series: viewer_count plus the
cumulative counters (messages_total, followers_total, subs_total,
donations_total_cents) and per-platform platform_metrics, sampled every 60s
while a session is live. For one stream's page this is exactly the right
resolution, so the detail readers stay on it:
db::public_stats_read::list_stream_viewer_curve— the public stream-page viewer curve for a single(session, platform).db::channel_history_reports::list_stats— the report charts for a session.db::channel_history::{session_peak_avg_viewers, stream_peak_avg_viewers, …}— per-session finalizer aggregates.
None of these span beyond a session, so none were changed.
Rollups — the pre-computed series for long ranges
The ZAF-594 public Stats app spans a year across thousands of channels, and
the ZAF-595 crawler adds tens of thousands of foreign channels
(source = 'external_crawler'). Scanning raw 60s samples over those ranges is
exactly the pattern we avoid on the Prometheus side with recording rules
(ZAF-675 §4). So two continuous aggregates
roll the raw samples up:
channel_history_stats_5m— 5-minute buckets, refreshed every 15 min (leaving the last 10 min to the raw detail curve). Multi-day / multi-week reads.channel_history_stats_1h— 1-hour buckets, refreshed hourly. Month / quarter / year reads.
Each bucket keeps viewer_sum + viewer_samples (so a read that re-aggregates
across sessions gets an exact sample-weighted average, not an average of
averages), peak_viewers / min_viewers, and the max() of each cumulative
counter (== the value at the last sample of the bucket). Both are
materialized_only — a read only ever touches pre-computed buckets, never the
raw table.
The cross-session read primitive is
db::public_stats_read::list_account_viewer_curve(account_id, platform, from, to, resolution).
It picks the 5m or 1h aggregate and returns a downsampled curve. A gated test
(apps/api/tests/tsdb_channel_history_aggregates.rs, run under
RUN_TSDB_MIGRATION_TEST) proves a long range returns downsampled buckets whose
plan scans the aggregate's materialization hypertable — not raw
channel_history_stats.
Migrations
The aggregates ship in apps/api/tsdb_migrations/20260823000010_channel_history_continuous_aggregates.{up,down}.sql.
Creating (and dropping) a continuous aggregate cannot run inside a
transaction, so the migration opts out of sqlx's transaction wrapper with a
-- no-transaction first line (sqlx 0.9). The .down.sql drops both views,
which also removes their refresh-policy jobs.
Retention
Two very different retention questions live here, and they are answered differently.
-
Aggregates: kept, no retention drop. The published privacy policy (
apps/web/content/{en,de}/legal/privacy.mdx, §"Stream history (broadcast statistics)") promises the viewer-count curve for the lifetime of the account, and — as aggregate reach statistics — retains it on an ongoing basis even after the account ends. The rollups are that long-lived pre-computed curve, so they carry no retention policy. Keeping them is what makes the lifetime promise sustainable at crawl scale. -
Raw 60s samples: dropped after 90 days. The raw hypertable carries a TimescaleDB retention policy —
add_retention_policy('channel_history_stats', INTERVAL '90 days'), migration20260824000001— that drops each chunk once its whole time range is older than 90 days. This is lawful precisely because the rollups above preserve the promised viewer-count curve:max(peaks survive),min, an exact sample-weighted average (SUM(viewer_sum)/SUM(viewer_samples)), and the cumulative counters (max()of the monotonic-in-session totals). Dropping the 60s cadence — which the privacy policy never published — therefore loses no promised information and is Art. 5(1)(c)/(e) DSGVO data-minimisation-positive. Legal & Compliance signed off on the 90-day window on ZAF-689 (founder-approved 2026-08-24), the same align-with-Legal-before-you-set-a-window gate ZAF-648/653 applied to the GDPR-bound hypertables (audit_events,platform_event_logs). The policy is table-wide, so it covers first-party andsource = 'external_crawler'(ZAF-595) rows identically — both are de-identified aggregate reach data under the same window. Raw samples are still compressed after 7 days (independent compression policy); retention then drops the chunk outright once it ages past 90 days.
The rollups are the long-lived preserved curve; the raw samples are the
short-lived (90-day) detail curve. Both migrations are reversible with no public
surface, and the one-way-door raw-retention window was set only after Legal
signed off — the same decouple-the-reversible-migration-from-the-approval-gate
discipline the audit and telemetry retention work used. The retention migration
is numbered after the rollup migration (20260823000010), so the preserving
aggregate is always materialising before any raw chunk can age out.