Skip to content

world-postgres: pollInterval is hardcoded to 500 ms, so an idle World polls Postgres concurrency × 2 times per second #4354

Description

@cwdx

Summary

@workflow/world-postgres starts graphile-worker with pollInterval: 500 hardcoded, and nothing in PostgresWorldConfig or the environment can change it. graphile-worker runs one polling loop per concurrency slot, so an idle World sends about concurrency × 2 job-fetch queries per second to Postgres, even when no work is queued. A config option (and an env var, like the existing WORKFLOW_POSTGRES_WORKER_CONCURRENCY) for the poll interval would let deployments trade pickup latency for database load.

Where it is

On main at 2694663aef5742a012cbfed30a021d67877bb82b:

  • packages/world-postgres/src/queue.ts:963-979: start() calls graphile's run({ ..., concurrency: config.queueConcurrency || 50, ..., pollInterval: 500 }). The comment on that line says graphile-worker uses LISTEN/NOTIFY when available.
  • packages/world-postgres/src/index.ts:53: WORKFLOW_POSTGRES_WORKER_CONCURRENCY (default 50) feeds queueConcurrency.
  • packages/world-postgres/src/config.ts:14: PostgresWorldConfig exposes queueConcurrency but no poll setting.

What we observed

Setup: @workflow/world-postgres@5.0.0-beta.41, graphile-worker@0.16.6, Node 24, Cloud SQL for PostgreSQL 18. Several Worlds each use their own database on one shared Postgres instance, with concurrency 10.

  • With no jobs queued (graphile_worker._private_jobs empty), each World's database received about 24 job-fetch statements per second (with j as (select ... from graphile_worker._private_jobs ... for update skip locked) update ...): 7,104 in 5 minutes, from pg_stat_statements. That matches 10 workers × 2 polls per second. LISTEN/NOTIFY does not stop the idle polling.
  • Each fetch is cheap (about 0.05 ms). The cost shows up around the statement instead. The fetch is an UPDATE, so with log_statement = mod (a common audit setting) every poll is written to the Postgres log. On our instance that took the instance from about 0.7 to a flat 3 of 4 vCPUs, and raised the daily log export about 7×. Setting log_statement = ddl took it back to about 0.45 vCPU. That confirms the cost is per statement, and not the job table.
  • Lowering concurrency reduces the rate proportionally. But concurrency also sets how many steps can run at once, and the comment at queue.ts:971 warns that a low value can deadlock parent→child polling workflows. So it is not a safe knob for this.

Proposal

  • Add pollInterval?: number to PostgresWorldConfig, read from WORKFLOW_POSTGRES_POLL_INTERVAL_MS in createWorld, and default to the current 500 ms, so behavior does not change for anyone who does not set it.
  • Optionally, document the idle query rate (concurrency × 1000 / pollInterval per second) next to WORKFLOW_POSTGRES_WORKER_CONCURRENCY, since it is not obvious that concurrency multiplies idle load.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions