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.
Summary
@workflow/world-postgresstarts graphile-worker withpollInterval: 500hardcoded, and nothing inPostgresWorldConfigor the environment can change it. graphile-worker runs one polling loop per concurrency slot, so an idle World sends aboutconcurrency × 2job-fetch queries per second to Postgres, even when no work is queued. A config option (and an env var, like the existingWORKFLOW_POSTGRES_WORKER_CONCURRENCY) for the poll interval would let deployments trade pickup latency for database load.Where it is
On
mainat2694663aef5742a012cbfed30a021d67877bb82b:packages/world-postgres/src/queue.ts:963-979:start()calls graphile'srun({ ..., 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) feedsqueueConcurrency.packages/world-postgres/src/config.ts:14:PostgresWorldConfigexposesqueueConcurrencybut 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.graphile_worker._private_jobsempty), 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, frompg_stat_statements. That matches 10 workers × 2 polls per second. LISTEN/NOTIFY does not stop the idle polling.UPDATE, so withlog_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×. Settinglog_statement = ddltook it back to about 0.45 vCPU. That confirms the cost is per statement, and not the job table.queue.ts:971warns that a low value can deadlock parent→child polling workflows. So it is not a safe knob for this.Proposal
pollInterval?: numbertoPostgresWorldConfig, read fromWORKFLOW_POSTGRES_POLL_INTERVAL_MSincreateWorld, and default to the current 500 ms, so behavior does not change for anyone who does not set it.concurrency × 1000 / pollIntervalper second) next toWORKFLOW_POSTGRES_WORKER_CONCURRENCY, since it is not obvious that concurrency multiplies idle load.