Understanding TimescaleDB Background Workers and Jobs

Understanding TimescaleDB Background Workers and Jobs

You set up compression, retention, and continuous aggregates months ago, and everything worked. Then one morning your dashboard shows uncompressed chunks piling up, your continuous aggregates are hours stale, and retention hasn't dropped a chunk in days. Nothing changed in your application code. No errors in the PostgreSQL log that explain it. The culprit is almost always the same: background worker exhaustion.

TimescaleDB automates its core maintenance through scheduled jobs that compete for a fixed pool of PostgreSQL background workers. When the number of jobs outgrows that pool, tasks silently queue, fall behind, and eventually stop running altogether. Understanding this system is the difference between automation that works and automation that quietly degrades.

How Background Workers Manage Jobs

Every TimescaleDB automation policy creates a scheduled job. When you call add_compression_policy(), add_retention_policy(), or add_continuous_aggregate_policy(), TimescaleDB registers a job in its internal scheduler. The scheduler assigns each job to a background worker -- a PostgreSQL process that runs independently of any client connection.

Each job type performs a specific operation:

Jobs run on their configured schedule (e.g., every 12 hours for compression, every 15 minutes for aggregate refresh). Each running job occupies one background worker for its entire duration.

-- List all scheduled jobs with their type and schedule
SELECT
    job_id,
    proc_name,
    hypertable_name,
    schedule_interval,
    scheduled AS is_active
FROM timescaledb_information.jobs
WHERE scheduled = true
ORDER BY proc_name, hypertable_name;

The max_background_workers Limit

The maximum number of concurrent TimescaleDB jobs is controlled by two PostgreSQL settings:

-- Check current worker configuration
SELECT name, setting, unit
FROM pg_settings
WHERE name IN (
    'timescaledb.max_background_workers',
    'max_worker_processes',
    'max_parallel_workers'
)
ORDER BY name;

timescaledb.max_background_workers sets the ceiling for TimescaleDB's scheduler. The default is typically 8 or 16 depending on version and platform. max_worker_processes is PostgreSQL's global limit for all background workers -- TimescaleDB, logical replication, parallel queries, and other extensions all share this pool.

The default of 16 seems generous until you count your actual jobs.

Worker Exhaustion: What Happens When Jobs Outgrow Workers

Worker exhaustion is a silent failure mode. Jobs don't throw errors -- they queue. The scheduler waits for a free worker, and if one never opens within the schedule interval, the next invocation stacks behind the current one. Over hours, a backlog forms that compounds with every cycle.

In a controlled experiment, we created 8 hypertables, each with a compression policy, a retention policy, and a continuous aggregate refresh policy. That alone produced 24 new jobs. Combined with the existing system jobs, the total reached 172 scheduled jobs: 79 compression, 83 retention, 8 continuous aggregate refreshes, and 2 internal maintenance tasks. A default max_background_workers of 16 is nowhere near sufficient for that workload.

-- Count total jobs by type
SELECT
    proc_name,
    count(*) AS job_count
FROM timescaledb_information.jobs
WHERE scheduled = true
GROUP BY proc_name
ORDER BY job_count DESC;

The real danger emerges when a job's duration exceeds its schedule interval. If a compression job takes 15 minutes but is scheduled every 10, it permanently occupies a worker slot and can never catch up. Each cycle adds another queued invocation, starving other job types of workers.

Diagnosing Worker Saturation

Start by comparing your job count to your worker limit:

-- Worker utilization check
WITH worker_config AS (
    SELECT current_setting('timescaledb.max_background_workers')::int AS max_workers
),
active_jobs AS (
    SELECT count(*) AS total_scheduled_jobs
    FROM timescaledb_information.jobs
    WHERE scheduled = true
)
SELECT
    wc.max_workers,
    aj.total_scheduled_jobs,
    CASE
        WHEN aj.total_scheduled_jobs <= wc.max_workers THEN 'OK'
        WHEN aj.total_scheduled_jobs <= wc.max_workers * 1.5 THEN 'WARNING -- approaching saturation'
        ELSE 'CRITICAL -- worker exhaustion likely'
    END AS worker_status
FROM worker_config wc, active_jobs aj;

Then check for jobs that are failing or have never run:

-- Identify failing or stalled jobs
SELECT
    j.job_id,
    j.proc_name,
    j.hypertable_name,
    js.total_failures,
    js.consecutive_failures,
    js.last_run_status,
    js.last_run_duration,
    CASE
        WHEN js.total_failures > 0 THEN 'FAILING'
        WHEN js.total_runs = 0 THEN 'NEVER RUN -- likely queued'
        ELSE 'OK'
    END AS health_status
FROM timescaledb_information.jobs j
JOIN timescaledb_information.job_stats js ON j.job_id = js.job_id
ORDER BY js.total_failures DESC, j.proc_name;

Common failure signatures:

Sizing Workers: The Formula

The calculation is straightforward:

total_policies + 2 (TimescaleDB internal jobs) = minimum max_background_workers

Use this query to compute the exact number:

-- Calculate recommended worker count
WITH policy_count AS (
    SELECT
        count(*) FILTER (WHERE proc_name = 'policy_compression') AS compression_jobs,
        count(*) FILTER (WHERE proc_name = 'policy_retention') AS retention_jobs,
        count(*) FILTER (WHERE proc_name = 'policy_refresh_continuous_aggregate') AS cagg_jobs,
        count(*) FILTER (WHERE proc_name NOT IN (
            'policy_compression', 'policy_retention',
            'policy_refresh_continuous_aggregate'
        )) AS other_jobs
    FROM timescaledb_information.jobs
    WHERE scheduled = true
)
SELECT
    compression_jobs + retention_jobs + cagg_jobs + other_jobs AS total_policies,
    compression_jobs + retention_jobs + cagg_jobs + other_jobs + 2 AS recommended_workers,
    'ALTER SYSTEM SET timescaledb.max_background_workers = ' ||
        (compression_jobs + retention_jobs + cagg_jobs + other_jobs + 2) AS sql_to_run
FROM policy_count;

Apply the change:

ALTER SYSTEM SET timescaledb.max_background_workers = 28;
ALTER SYSTEM SET max_worker_processes = 32;
-- Requires a full PostgreSQL restart -- pg_reload_conf() is NOT sufficient

Over-provisioning is cheap. Each idle worker consumes roughly 5-10 MB of memory and zero CPU. Setting max_background_workers to 32 or 64 on a server running 20 jobs carries no measurable performance penalty.

How myDBA.dev Catches Worker Exhaustion Early

myDBA.dev Jobs tab showing failure warnings and overdue job alerts

myDBA.dev Health tab showing Background Worker Slots critical at 850% utilization

Worker saturation develops gradually and rarely produces obvious errors in PostgreSQL logs. myDBA.dev's TimescaleDB Jobs tab tracks every scheduled job with its last run duration, failure count, and next scheduled start -- surfacing the queue depth that is invisible from standard views. Health Check T4 fires when any job accumulates failures, and T7 warns when active job count approaches the configured worker limit, catching saturation before jobs start falling behind.

Prevention Checklist