Troubleshooting Stale Continuous Aggregates in Production

A continuous aggregate is stale when its newest materialized bucket lags the raw hypertable by more than your freshness budget allows — and the single fastest way to resolve it is to walk a fixed decision path from “is the refresh job even running?” down to “trigger a bounded manual refresh.” In high-throughput IoT telemetry pipelines, these rollups power real-time dashboards, threshold alerting, and downstream feature stores, so a lagging watermark surfaces as silent data degradation and missed SLA windows rather than an obvious error. This page gives you the metrics to gather, a symptom-to-fix matrix, and the exact system-view queries to confirm recovery. It sits under incremental vs full refresh strategies, part of the broader continuous aggregate refresh lifecycle.

Decision path for diagnosing a stale continuous aggregate Start from a stale aggregate. First ask whether the refresh job is failing; if yes, inspect job_errors and job_stats and clear lock or memory pressure. If the job is healthy, ask whether start_offset is too large or chunks are misaligned; if yes, tighten start_offset and align chunk_time_interval. Otherwise trigger a targeted refresh_continuous_aggregate over the affected range. Aggregate is stale yes yes no no Refresh jobfailing? start_offset too largeor chunks misaligned? Inspect job_errors / job_stats;clear lock or memory pressure Tighten start_offset;align chunk_time_interval Trigger a targetedrefresh_continuous_aggregate

Input profiling: what to gather before you touch anything

Staleness is a symptom with several distinct root causes, and reaching for a full recompute before you have measured the lag will usually make things worse. Collect these five inputs first:

  • Freshness budget — the maximum acceptable lag between now() and the newest materialized bucket (for most IoT dashboards this is one to five minutes).
  • Observed lagnow() - last_successful_finish for the aggregate’s refresh job, plus the timestamp of the newest bucket actually present in the materialization hypertable.
  • Job healthlast_run_status, total_failures, and next_start from timescaledb_information.job_stats.
  • Invalidation backlog — how many raw rows have been modified behind the watermark but not yet re-aggregated (high on fleets with clock skew or late-arriving telemetry).
  • Resource contention signals — concurrent VACUUM, compression, and retention jobs competing with the refresh worker for CPU and I/O.

Baseline environment assumptions for every query below:

The symptom-to-fix decision matrix

Every stale aggregate maps to one of four root causes. Match the observed inputs to a row, apply the fix, then jump to verification.

Symptom Likely root cause Diagnostic view Fix
last_run_status = 'Failed' / 'TimedOut' Lock contention or memory pressure job_stats, job_errors Raise maintenance_work_mem; stagger competing jobs
Job runs green but lag grows Invalidation log accumulating faster than the engine drains it continuous_aggs_materialization_invalidation_log Widen schedule_interval headroom or shrink bucket span
Newest bucket never finalizes end_offset too small; in-flight bucket skipped continuous_aggregates Increase end_offset past the late-arrival window
A historical range is wrong Chunks dropped or backfilled behind the watermark chunks, drop_chunks history Bounded manual refresh_continuous_aggregate over the range

The primary staleness signal is a missed or delayed policy execution. This query joins the aggregate to its refresh job and flags anything whose last success predates your freshness budget:

sql
SELECT
    ca.view_name,
    js.last_run_status,
    js.last_successful_finish,
    js.next_start,
    CASE
        WHEN js.last_successful_finish < now() - INTERVAL '2 hours' THEN 'STALE'
        ELSE 'HEALTHY'
    END AS aggregate_status
FROM timescaledb_information.continuous_aggregates ca
JOIN timescaledb_information.jobs j
  ON j.hypertable_name = ca.materialization_hypertable_name
 AND j.proc_name = 'policy_refresh_continuous_aggregate'
JOIN timescaledb_information.job_stats js ON js.job_id = j.job_id;

When last_successful_finish lags far behind now(), the invalidation log is usually accumulating faster than the incremental engine can process it. TimescaleDB tracks every modified row in the source hypertable via that internal log, and each aggregate keeps a materialization watermark marking the boundary between summarized data and pending updates. High churn on recent partitions — common on IoT edge gateways with clock skew or late-arriving telemetry — forces repeated aggregation passes over the same buckets. Deciding whether to catch up incrementally or recompute the range wholesale is exactly the tradeoff covered in incremental refresh performance tuning for large datasets.

Automated, idempotent remediation in Python

For a self-healing check, wrap the diagnostic in an idempotent workflow that only refreshes when the watermark actually lags, with exponential backoff. This uses psycopg v3 and runs the refresh in autocommit because refresh_continuous_aggregate() cannot execute inside a transaction block:

python
import psycopg
from psycopg.rows import dict_row
import logging
import time

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

def check_and_refresh_aggregate(dsn: str, agg_name: str, max_lag_hours: float = 2.0):
    """Idempotent continuous aggregate health check and conditional refresh."""
    query = """
        SELECT js.last_successful_finish,
               (js.last_successful_finish < now() - make_interval(hours => %s)) AS is_stale
        FROM timescaledb_information.continuous_aggregates ca
        JOIN timescaledb_information.jobs j
          ON j.hypertable_name = ca.materialization_hypertable_name
         AND j.proc_name = 'policy_refresh_continuous_aggregate'
        JOIN timescaledb_information.job_stats js ON js.job_id = j.job_id
        WHERE ca.view_name = %s;
    """

    # refresh_continuous_aggregate() cannot run inside a transaction block.
    with psycopg.connect(dsn, row_factory=dict_row, autocommit=True) as conn:
        with conn.cursor() as cur:
            cur.execute(query, (max_lag_hours, agg_name))
            result = cur.fetchone()

            if not result:
                logging.warning(f"Aggregate '{agg_name}' not found.")
                return

            if result['is_stale']:
                logging.info(f"Triggering refresh for '{agg_name}' due to watermark lag.")
                cur.execute("CALL refresh_continuous_aggregate(%s, NULL, NULL);", (agg_name,))
                logging.info(f"Refresh initiated for '{agg_name}'.")
            else:
                logging.info(f"Aggregate '{agg_name}' is within acceptable lag threshold.")

if __name__ == "__main__":
    DSN = "postgresql://tsdb_user:password@host:5432/telemetry_db"
    for attempt in range(3):  # exponential backoff wrapper for production
        try:
            check_and_refresh_aggregate(DSN, "iot_sensor_1min_rollup", max_lag_hours=1.5)
            break
        except Exception as e:
            wait = 2 ** attempt
            logging.warning(f"Attempt {attempt + 1} failed: {e}. Retrying in {wait}s...")
            time.sleep(wait)

Passing NULL, NULL recomputes the whole range, which is safe as a recovery hammer but expensive; for steady-state catch-up, prefer a bounded window. When failures come from concurrent job contention rather than lag, route the recovery through the patterns in error handling and retry mechanisms instead of blindly retrying the same call.

Worked example: a 40,000-device fleet drifting behind

Consider a fleet of 40,000 sensors emitting one reading every 10 seconds — roughly 4,000 rows/sec, or 345 million rows/day — feeding a iot_sensor_1min_rollup aggregate with a one-minute time_bucket. The refresh policy runs every 60 seconds with start_offset = '1 hour' and end_offset = '2 minutes'. Dashboards begin showing data 18 minutes behind reality.

Why a healthy refresh job still falls behind The raw hypertable time axis runs toward now. A nightly backfill re-inserts corrected readings behind the materialization watermark, reopening an earlier range of buckets. Each 60-second refresh pass must now re-aggregate both that reopened range and the one-hour start_offset window near now. Because the combined span no longer fits inside the 60-second schedule interval, the watermark falls further behind every cycle and observed lag grows to 18 minutes. nightly backfill re-inserts corrected rows behind the watermark reopened range(behind watermark) start_offset window= 1 hour watermark now raw hypertable time Every 60-second pass must re-aggregate this entire span overruns the 60s budget → lag grows to 18 min

Working the matrix:

  1. Job healthlast_run_status = 'Success', so this is not a crash. Rule out row 1.
  2. Observed lag vs budget — freshness budget is 2 minutes; observed lag is 18 minutes and growing linearly. A growing green-job lag points at row 2: the invalidation backlog.
  3. Backlog check — a nightly backfill job re-inserted 6 hours of corrected readings behind the watermark. Each 60-second run now re-aggregates the entire 1-hour start_offset window plus the reopened backfill range, so a single pass no longer finishes inside its 60-second schedule_interval.
  4. Fix — run one bounded manual refresh over just the backfilled range to clear the backlog in a maintenance window, then let the policy resume steady-state:
sql
CALL refresh_continuous_aggregate(
    'iot_sensor_1min_rollup',
    now() - INTERVAL '7 hours',
    now() - INTERVAL '2 minutes'
);

After the manual pass drains the reopened range, the 60-second policy catches up within two cycles and lag returns under the 2-minute budget. The lesson: the aggregate was never broken — a backfill reopened buckets faster than the incremental engine could close them.

Edge cases and when to deviate

  • Aggressive retention drops the source first. An over-eager drop_chunks call can invalidate a materialized range and force full recomputation. Keep the retention drop_after interval longer than the aggregate’s refresh window — align it using TTL policy mapping and enforcement.
  • Catalog bloat starves compaction. Bloat in _timescaledb_catalog.continuous_aggs_materialization_invalidation_log degrades log compaction and risks transaction-ID wraparound. Set tighter autovacuum_vacuum_scale_factor on these internal tables, and coordinate with chunk compression scheduling automation so compression and vacuum do not collide.
  • Chunk misalignment multiplies passes. If chunk_time_interval is far smaller than the bucket-times-offset span, the refresh scans many chunks per run — revisit time-based chunk partitioning strategies.
  • Worker starvation, not lag. If job_stats shows the job perpetually Scheduled and never Running, the background worker pool is exhausted; this is a queue problem covered in asynchronous execution and queue management, not a watermark problem.
  • end_offset too tight. A very small end_offset never finalizes the in-flight bucket, so the freshest minute looks permanently missing even though every job succeeds.

Verification: confirm the watermark advanced

Re-run the diagnostic and confirm aggregate_status returns HEALTHY, then watch for any refresh still holding locks or running long:

sql
-- Any refresh still executing, and how long it has run.
SELECT pid, state, now() - query_start AS runtime, query
FROM pg_stat_activity
WHERE query ILIKE '%refresh_continuous_aggregate%'
  AND state <> 'idle';

-- Confirm the newest materialized bucket is within the freshness budget.
SELECT view_name,
       now() - max_bucket AS lag
FROM (
    SELECT ca.view_name,
           (SELECT max(bucket) FROM iot_sensor_1min_rollup) AS max_bucket
    FROM timescaledb_information.continuous_aggregates ca
    WHERE ca.view_name = 'iot_sensor_1min_rollup'
) s;

Establish standing alerts on job_stats failure rates and on the computed lag above so the next drift is caught before a dashboard does. Stale continuous aggregates are rarely a database defect — they are almost always a symptom of misaligned refresh cadences, unbounded invalidation logs, or resource contention. Combine deterministic SQL diagnostics with idempotent automation and you can hold sub-minute freshness across petabyte-scale IoT deployments.

← Back to Incremental vs Full Refresh Strategies · ← Continuous Aggregate Creation & Refresh Management