Connections (BYO / Factories)¶
Connection hook points: WorkerConnections per-role overrides and the
PoolFactory / ConnFactory / RedisFactory type aliases. See the
Managed Identities & BYO Connections guide
for the ownership model and deployment patterns.
connections ¶
Connection hook points, bring-your-own resources or factories.
TaskQ constructs its asyncpg pools, dedicated connections, and Redis client internally from DSN strings by default. This module lets you replace any of those with either:
- a pre-constructed, caller-owned resource (TaskQ uses it but never closes it, you close it in your own lifespan), or
- a zero-arg async factory that TaskQ invokes at the right point in its lifecycle and closes the result of on teardown (TaskQ-owned).
Fields left None fall back to the existing DSN construction, so the
hook points are purely additive.
See the managed-identities deployment guide
(docs/guides/managed-identities.md); :mod:taskq.auth provides
vendor-neutral credential providers and factory builders, with
provider-specific implementations in :mod:taskq.aad, :mod:taskq.aws,
and :mod:taskq.vault.
Ownership rule¶
- Pre-constructed objects are caller-owned. TaskQ never closes
them, close them in your own
finally/ lifespan. - Factory-produced objects are TaskQ-owned. TaskQ closes them on
teardown via its :class:
~contextlib.AsyncExitStack.
Passing both a concrete resource and a factory for the same role is a
configuration error (caught in :meth:WorkerConnections.__post_init__).
__all__
module-attribute
¶
__all__ = [
"DEFAULT_MAX_CACHED_STATEMENT_LIFETIME",
"DEFAULT_STATEMENT_CACHE_SIZE",
"ConnFactory",
"PoolFactory",
"RedisFactory",
"WorkerConnections",
"bounded_lock_budget_ms",
"connection_init_hook",
"lock_budget_command_timeout_secs",
"lock_budget_pairs",
"statement_cache_kwargs",
"with_connection_init",
]
DEFAULT_MAX_CACHED_STATEMENT_LIFETIME
module-attribute
¶
WorkerConnections
dataclass
¶
WorkerConnections(
dispatcher_pool: Pool | None = None,
dispatcher_pool_factory: PoolFactory | None = None,
heartbeat_pool: Pool | None = None,
heartbeat_pool_factory: PoolFactory | None = None,
worker_pool: Pool | None = None,
worker_pool_factory: PoolFactory | None = None,
notify_conn: Connection | None = None,
notify_conn_factory: ConnFactory | None = None,
leader_conn: Connection | None = None,
leader_conn_factory: ConnFactory | None = None,
redis_client: Redis | None = None,
redis_client_factory: RedisFactory | None = None,
)
Per-role connection overrides for the worker.
Each role has a <role> (pre-constructed, caller-owned) and a
<role>_factory (zero-arg async factory, TaskQ-owned) slot.
Leave both None for DSN-based construction (the default).
Example, AAD-managed-identity worker::
from azure.identity.aio import DefaultAzureCredential
from taskq.aad import EntraIdProvider
from taskq.auth import make_pg_pool_factory
from taskq.connections import WorkerConnections
cred = DefaultAzureCredential()
provider = EntraIdProvider(cred)
connections = WorkerConnections(
dispatcher_pool_factory=make_pg_pool_factory(
settings.pg_dsn_direct, provider, max_size=settings.dispatcher_pool_size,
),
heartbeat_pool_factory=make_pg_pool_factory(
settings.pg_dsn_direct, provider,
max_size=settings.heartbeat_pool_size,
command_timeout=settings.heartbeat_command_timeout,
),
worker_pool_factory=make_pg_pool_factory(
settings.pg_dsn_pooled, provider, max_size=settings.worker_pool_size,
),
)
Example, share an app-wide pool (caller-owned)::
connections = WorkerConnections(worker_pool=app_state.pg_pool)
dispatcher_pool
class-attribute
instance-attribute
¶
Dispatcher pool (pg_dsn_direct role). Caller-owned if set.
dispatcher_pool_factory
class-attribute
instance-attribute
¶
Factory for the dispatcher pool. TaskQ-owned.
heartbeat_pool
class-attribute
instance-attribute
¶
Heartbeat pool (pg_dsn_direct, heartbeat_command_timeout). Caller-owned.
heartbeat_pool_factory
class-attribute
instance-attribute
¶
Factory for the heartbeat pool. TaskQ-owned. command_timeout is
your responsibility when overriding, set it on create_pool.
worker_pool
class-attribute
instance-attribute
¶
Worker pool (pg_dsn_pooled role). Caller-owned.
worker_pool_factory
class-attribute
instance-attribute
¶
Factory for the worker pool. TaskQ-owned.
notify_conn
class-attribute
instance-attribute
¶
Dedicated LISTEN connection. Caller-owned. TaskQ still issues LISTEN.
notify_conn_factory
class-attribute
instance-attribute
¶
Factory for the LISTEN connection. TaskQ-owned.
leader_conn
class-attribute
instance-attribute
¶
Dedicated advisory-lock connection. Caller-owned.
leader_conn_factory
class-attribute
instance-attribute
¶
Factory for the advisory-lock connection. TaskQ-owned.
redis_client
class-attribute
instance-attribute
¶
Redis client for progress fanout / rate limiting. Caller-owned.
redis_client_factory
class-attribute
instance-attribute
¶
Factory for the Redis client. TaskQ-owned.
The client a factory returns replaces the one TaskQ builds from
TASKQ_REDIS_URL — including its resilience defaults. TaskQ-built
clients set health_check_interval=30, socket_keepalive=True,
a 5s socket_timeout (pinned to redis-py 8.x's asyncio default —
the bound the fail-closed wall stands on), and a 1-attempt
exponential-backoff Retry (0.02s base, 0.1s cap — see
taskq._redis_client's RETRY_RETRIES for why one) with
retry_on_error=[ConnectionError, TimeoutError] (see
taskq._redis_client). A factory-built
client must set these kwargs itself — without them an idle-closed
broker connection surfaces as a raw ConnectionError in the
progress-stream pubsub and as the limiter's fail-closed warning noise
(see docs/guides/managed-identities.md).
__post_init__ ¶
Reject concrete + factory for the same role (configuration error).
Source code in src/taskq/connections.py
has_any ¶
True if any override (concrete or factory) is set.
Source code in src/taskq/connections.py
statement_cache_kwargs ¶
The create_pool kwargs carrying TaskQ's statement-cache tuning.
Bridges :class:~taskq.settings.TaskQSettings to
asyncpg.create_pool: pass a loaded settings instance to read the
operator-configured values (TASKQ_STATEMENT_CACHE_SIZE /
TASKQ_MAX_CACHED_STATEMENT_LIFETIME), or None for the module
constants above. Either way the result is the pair every TaskQ-built
pool passes.
When the settings declare a transaction-mode pooler
(TASKQ_PG_IS_POOLED, field pg_is_pooled) the pair is
0/0 regardless of the operator's cache tuning: a pooler that
remaps server connections between statements can split a prepared
statement's Parse from its Bind, and asyncpg's per-connection cache
has no way to notice - the only safe cache size is zero. The knob
deliberately wins over TASKQ_STATEMENT_CACHE_SIZE: a nonzero
cache under a remapping pooler is broken by definition, not a
tuning trade-off.
Call sites with a settings instance in scope resolve through this
helper and forward the two values as explicit create_pool
kwargs (statement_cache_size=kwargs["statement_cache_size"] …):
pyright strict rejects a dict[str, int] splat against
asyncpg.create_pool's typed keyword-only parameters, and the
explicit forwarding keeps the call site type-traced. Sites without a
settings instance (the testing fixtures) pass the constants directly.
Returns a fresh dict.
Source code in src/taskq/connections.py
bounded_lock_budget_ms ¶
Clamp one enqueue lock budget to fit inside its connection's bound.
Each of the enqueue path's bounded waits is enforced server-side by a
lock_timeout GUC whose expiry is the point: it produces the typed
refusal (MaxPendingLockTimeoutError, UniqueForLockTimeoutError,
IdempotencyKeyLockTimeoutError) naming which contention the caller
lost and that retrying is the right response.
A budget at or above the connection's own per-query timeout can never
deliver that. asyncpg starts its client-side timer before the
SET LOCAL lock_timeout has even executed, so it always fires
first: the caller gets a bare TimeoutError naming nothing, the
warning line never logs, the backpressure counter never moves, and
the typed refusal is unreachable code. Clamping is what makes the
configured budget mean what it says, an unclamped budget is not a
longer wait, it is no verdict at all.
command_timeout_secs of None is a connection with no
client-side bound, so the budget stands as configured; so does a
budget of 0 or less, which is the operator asking for an
unbounded wait (the lock_timeout GUC convention).
Source code in src/taskq/connections.py
lock_budget_command_timeout_secs ¶
lock_budget_command_timeout_secs(
budgets_ms: Iterable[tuple[float, float]],
*,
floor_secs: float,
) -> float
The per-query bound a TaskQ-built pool must carry to deliver its configured enqueue lock budgets.
floor_secs is the bound sized for the shipped defaults, at the
defaults each budget is delivered clamped to the floor's
:data:_LOCK_BUDGET_COMMAND_TIMEOUT_SHARE share by
:func:bounded_lock_budget_ms, so the floor is exactly what the
pre-knob pool carried and a deployment that sets nothing keeps it.
Each pair in budgets_ms is (configured_ms, default_ms) for one
knob. A budget configured ABOVE its shipped default cannot be
delivered inside the floor, the clamp would silently cap it back ,
so the bound is re-derived as configured / share: the widened
budget occupies the same share of a larger bound, the clamp no
longer bites, and the server-side lock_timeout still fires
before the client-side timer (the race the clamp exists to win).
Budgets at or below their default never move the bound, and neither
does a non-positive one: an unbounded lock wait cannot fit inside
any finite bound, and dropping the pool's per-query bound to honor
one would remove the black-hole guard every other query relies on.
Source code in src/taskq/connections.py
lock_budget_pairs ¶
lock_budget_pairs(
settings: TaskQSettings, field_names: Iterable[str]
) -> list[tuple[float, float]]
(configured, shipped default) per lock-budget knob named in
field_names, the input :func:lock_budget_command_timeout_secs
derives a TaskQ-built pool's per-query bound from.
settings is the loaded settings model the knobs are read from (the
client pool reads its enqueue knobs off TaskQSettings; the
dispatcher pool reads the admission knobs off WorkerSettings);
field_names lists that model's lock-budget field names, spelled once
by each caller so the derivation cannot drift onto a different knob.
The defaults are read off the model's field metadata, never restated.
Source code in src/taskq/connections.py
with_connection_init ¶
with_connection_init(
factory: ConnFactory,
init: Callable[[Connection], Awaitable[None]],
) -> ConnFactory
Wrap a connection factory so init runs on every connection it opens, and so the worker's per-slot pool can inherit the same hook.
init is applied to each produced connection exactly once, right
after the factory returns it, the setup= hook position in
asyncpg.connect. This is the channel through which per-connection
setup (registering type codecs, preparing statements) reaches the
connections an actor actually runs on: when the wrapped factory
provides the LOOP-scope asyncpg.Connection registration and the
worker activates its per-slot transaction pool, the worker reads the
declared hook off the registration and passes it as the slot pool's
init, so every slot connection gets the same setup. Applied
directly to one live connection instead, the same setup is invisible
to the slot pool (the driver exposes no read-back) and silently
absent above max_concurrency = 1.
Declare the hook here, not inside factory as well: the wrapper applies it, so a factory that also applies it would run it twice on the LOOP-scope connection. If init raises, the produced connection is closed (bounded) before the error propagates, a failed hook means no usable connection, matching asyncpg's own hook contract.
Source code in src/taskq/connections.py
connection_init_hook ¶
The init hook factory declares, if it declares one.
The read half of the inheritance channel, public so an application
that hand-rolls its factories can verify its declaration is visible
the way the worker sees it. Returns the hook exactly as declared (so
the slot pool installs the very callable the LOOP-scope connection
got), or None when the factory carries no declaration, a bare
closure, a raw asyncpg.connect partial, in which case nothing
about its per-connection setup is recoverable.