Skip to content

Queue Sizing ​

How to size worker pools and connection pools for your workload.

Worker Count Formula ​

The worker count sets:

  • The maximum number of concurrent handlers.
  • The required connection pool size.
  • Database concurrency under load.

Starting formula:

text
workers = target_concurrent_handlers

# Default (txSlots = workers):
pool MaxConns = workers + 6

# With reduced txSlots:
pool MaxConns = txSlots + 6

For I/O-bound handlers (HTTP calls, external APIs), the worker count can be high (16–64+). Those handlers spend most of their time waiting. They do not use CPU or database connections for that time.

For CPU-bound handlers, set workers near the number of CPU cores. More workers do not help. They add context-switching overhead.

For handlers that use the database heavily (many queries in the handler body), balance the worker count against the database max_connections budget.

Queue Isolation ​

Separate queues stop one workload from starving another. A slow bulk job that takes 5 minutes cannot block a claim of a priority job.

go
cyclone.WithWorkerQueues(map[string]int{
    "priority": 8,  // user-facing; must respond quickly
    "default":  4,  // normal background work
    "bulk":     2,  // slow batch jobs; few workers to limit DB pressure
})
// Default txSlots=14, so Pool MaxConns >= 14 + 6 = 20

Long-Running vs Short-Running Jobs ​

Short jobs (< 1 second):

  • High churn on cyclone_steps (many claim and complete cycles per second).
  • Autovacuum must keep up. Apply the custom storage parameters.
  • The default 30s lease TTL is enough.
  • LISTEN/NOTIFY is necessary for sub-second latency.

Long jobs (minutes to hours):

  • Fewer claim and complete cycles per hour.
  • leaseTTL still sizes crash detection, not handler length. A per-step heartbeat renews every leaseTTL/3 for the whole handler. The default 30s holds the lease of an hour-long handler. It still returns a crashed worker's steps within about half a minute. Raise it only to cut heartbeat write volume. Accept the slower reclaim:
    go
    cyclone.WithLeaseTTL(2 * time.Minute) // fewer renewals; up to ~2min to notice a crash
  • Write handlers that save progress and that check ctx.Done().
  • The pool does not hold a connection during handler body execution. It holds connections only at claim and at ctx.CompletionTx() completion.

Wakeup Latency and the Polling Floor ​

Two mechanisms deliver claimable work. Only one of them is a guarantee.

  • LISTEN/NOTIFY is a latency hint. Cyclone publishes a wakeup after the transaction that created the work commits. Cyclone never publishes inside that transaction. Publication is best-effort by design.
  • Polling is the correctness floor. WithPollInterval(min, max) makes delivery certain. max (default 1s) bounds how long committed work can sit unclaimed when a wakeup does not arrive.

A wakeup is lost in more cases than a crash. Every row below happens on a healthy process. max is the recovery bound for all of them:

What happensEffect on one wakeupRecovered by
Receiving wake channel already fulldropped. The channels have depth 1. A queue with a wake already pending needs no second onepolling, within max
Publication fails (database unreachable, pool closed)dropped and logged. With WithNotifyCoalesce set, one background retry happens firstpolling, within max
Caller's context cancelled before publicationdropped. With WithNotifyCoalesce set, the timer buffers it and publishes itpolling, within max
Process crashes between commit and publicationdroppedpolling, within max
Pool closed before Shutdown on a producer-only clientdropped, loggedpolling, within max
Coalesce buffer overflows (WithNotifyCoalesce only)dropped. Cyclone counts it against the cap and logs it at error level on the next flushpolling, within max
Coalesce batch cap reached (WithNotifyCoalesce only)not dropped. The remainder goes out on the next flushnext flush

The first four rows also apply at the default WithNotifyCoalesce(0). None of them requires a crash or a coalesce window.

For cyclone.Wait, the equivalent floor is its own fixed 1s poll. WithPollInterval does not change that poll.

Size max as the worst-case added latency for one job when the notify path does not deliver. Pick it from the cost of a delayed job:

go
// Latency-sensitive user-facing work: tighten the floor, accept more polling.
cyclone.WithPollInterval(100*time.Millisecond, 500*time.Millisecond)

// Background work where a second or two is invisible: the default is fine.
cyclone.WithPollInterval(100*time.Millisecond, 1*time.Second)

// Bulk queues on a busy database: relax it to cut idle claim queries.
cyclone.WithPollInterval(1*time.Second, 10*time.Second)

A tighter max costs idle claim queries on every worker. It trades database load against tail latency. Do not tighten it to hide a notify problem. Fix the notify path. The floor only fires on the losses above.

If you set WithNotifyCoalesce, watch for the overflow log line (cyclone notify coalesce buffer overflow). A non-zero drop count means the buffer caps are binding. Those wakeups then wait out max. Reduce the window, reduce the publication rate per client, or raise max on purpose. See Client Options.

Connection Pool Sizing ​

Each Client.Start holds connections for:

  • txSlots connections at peak (completion transactions).
  • A shared reserve of six connections. The reserve covers the claim loops, the recovery sweep, and the schedule, wait, and deadline scanners. It also covers the LISTEN connection (held for the whole Start lifetime) and headroom for ad-hoc GetRun and Cancel reads.

The listener is inside that reserve, not on top of it. The arithmetic below already includes it.

text
Required MaxConns per client = txSlots + 6

For multiple clients in the same process (for example, one client for enqueue only and one worker client):

  • Worker client: MaxConns = txSlots + 6
  • Producer-only client: configure WithWorkers(1), WithTxSlots(1) for a validated minimum of 7

WithTxSlots: Decouple Concurrency from Pool ​

If your handlers are CPU-bound and complete quickly (little time in ctx.CompletionTx()), many workers can share fewer txSlots:

go
client, _ := cyclone.NewClient(pool, mux,
    cyclone.WithWorkers(20),      // 20 goroutines running handlers
    cyclone.WithTxSlots(5),       // but at most 5 completion txs at once
    // Pool: MaxConns >= 5 + 6 = 11 (instead of the default 20 + 6 = 26)
)

Use this when:

  • Handlers are short-lived and CPU-bound.
  • ctx.CompletionTx() calls are quick (small INSERTs, not large scans).
  • You need to keep database connections free.

Do not cut txSlots too far. If workers often wait for a slot, completion becomes a bottleneck.

Priority Tuning ​

Within a queue, a worker claims higher WithPriority values first. Use priority so time-critical work does not wait behind bulk work on the same queue.

go
// User-initiated export: high priority
cyclone.Enqueue(ctx, client, exportStep, in, cyclone.WithPriority(10))

// Scheduled nightly export: normal priority
cyclone.Enqueue(ctx, client, exportStep, in) // priority = 0

// Cleanup: low priority
cyclone.Enqueue(ctx, client, cleanupStep, in, cyclone.WithPriority(-5))

Priority matters only within a queue. For a strong isolation guarantee, use separate queues.

Benchmarking Your Workload ​

Execute the included benchmarks against your real Postgres instance:

sh
export CYCLONETEST_DSN='postgres://user:pass@host/db?sslmode=disable'
go test -tags benchmark -run '^$' -bench=. -benchtime=30s .

Key benchmark scenarios:

  • BenchmarkSustainedThroughput: enqueue → claim → complete cycle throughput.
  • BenchmarkLongRunningSoak: sustained execution with heartbeat renewals.
  • BenchmarkLargeDAGSubmit: submit time for many-node workflows.

Interpret the results: ns/op ÷ 1e9 = seconds per operation. 1 ÷ (ns/op × 1e-9) = ops/sec.

Cyclone stores each run in Postgres.