Skip to content

Testing

taskq.testing is the test-support namespace: deterministic fakes, pytest fixtures, behavioral assertions, settings factories, and chaos tools. Every symbol lives under taskq.testing, never in taskq.backend, so production code never pulls in test doubles.

from taskq.testing import FakeClock, InMemoryBackend

The Testing guide walks these tools in context; this page is the namespace index: every public name, the module that defines it, and the rendered reference for the core surfaces.

FakeClock: one class, four import paths

FakeClock's canonical home is taskq.clock — a production module that imports only datetime. That placement (issue #625) is what lets import taskq re-export FakeClock without pulling in the test-double backend under taskq.testing. taskq.testing.clock is a backcompat shim re-exporting the same class object, and the taskq package and the taskq.testing namespace re-export it in turn. All four paths resolve to one class object (is-identical, not copies):

from taskq import FakeClock
from taskq.clock import FakeClock as canonical
from taskq.testing import FakeClock as namespaced
from taskq.testing.clock import FakeClock as shimmed

assert FakeClock is canonical is namespaced is shimmed

Prefer from taskq.clock import FakeClock (or the shorter from taskq import FakeClock) in new code; taskq.testing.clock exists for backcompat and keeps working.

Namespace surface

taskq.testing.__all__ is the package contract: 38 names, each re-exported from the module that defines it:

Home module Names
taskq.testing.in_memory InMemoryBackend
taskq.testing.clock FakeClock (shim; canonical home taskq.clock)
taskq.testing.actor EmptyPayload, FakeBackend, StubActorConfig, as_backend, default_actor_config
taskq.testing.assertions assert_attempt, assert_has_event, assert_has_otel_event, assert_has_span, assert_job_status, assert_job_terminal, assert_transition_sequence, parse_detail, pg_now, wait_for, wait_for_job_status, wait_for_leader
taskq.testing.health unique_health_sock_path
taskq.testing.job_context JobContext
taskq.testing.jobs error_info, make_enqueue_args, make_job_row
taskq.testing.pg DEFAULT_ACTORS, JobTriple, create_pending_job, create_running_job, create_worker, create_workered_running_job, get_job_triple, reset_schema, seed_actors, setup_running_job, truncate_schema
taskq.testing.settings make_integration_settings, make_integration_settings_dict
taskq.testing.spy WarningSpy

All 38 render under the package directive below. Three more surfaces are also not in the package __all__ and have their own sections: the pytest fixtures (taskq.testing.fixtures — keeps pytest/asyncpg off the package import path), the OTel test utilities (taskq.testing.otel — requires the [otel] extra), and the asyncpg chaos tools (taskq.testing.asyncpg_chaos). The defining modules also carry public module-level helpers that are not re-exported through the package; those are indexed below.

Module-level publics outside the package contract

These names are public in their defining modules (most in the module's own __all__) but deliberately not re-exported from taskq.testing — import them from the defining module:

Home module Names
taskq.testing.in_memory BACKEND_PROTOCOL_VERSION, PassthroughPayload, StubFn, decode_batch_cursor, decode_cursor, encode_batch_cursor, encode_cursor, wait_for_batch
taskq.testing.assertions plain_cli_output (strip ANSI/collapse whitespace for CLI-output assertions), wait_for_condition (poll a predicate with test-failure semantics)
taskq.testing.jobs enqueue_and_dispatch_memory (enqueue + dispatch against an InMemoryBackend), enqueue_and_dispatch_pg (same against a PG backend)
taskq.testing.pg ROW_VISIT_COUNTER_ROLE (role the row-visit probe runs as), RowVisitCounter, install_row_visit_counter, read_row_visits
taskq.testing.settings shorten_chaos_settings (context manager: shorten heartbeat/lease timings for chaos tests)

Package surface (fakes, assertions, settings factories)

testing

Test-only helpers for TaskQ: FakeClock, InMemoryBackend, and stub utilities.

Every symbol here lives in taskq.testing, never in taskq.backend, so production code does not pull in test-only helpers.

run_until_drained, tick_cancel_polling, and register_cancel_event are methods on the re-exported :class:InMemoryBackend class. The runner logic lives in :mod:taskq.testing._runner.

Pytest fixtures are NOT re-exported here, they live in :mod:taskq.testing.fixtures and are imported by :mod:tests.conftest directly. This avoids importing pytest / asyncpg at the taskq.testing top level.

JobContext is re-exported for convenience; the eventual public JobContext will replace this test-scoped version.

OTel test utilities (ListSpanExporter, setup_tracer, setup_meter) are NOT re-exported here, import them from taskq.testing.otel directly. They require the [otel] extra (opentelemetry-sdk).

DEFAULT_ACTORS module-attribute

DEFAULT_ACTORS: tuple[str, ...] = (
    "actor_a",
    "actor_b",
    "actor_c",
    "A",
    "C",
    "X",
    "test_actor",
    "_progress_redis_hundred",
    "_progress_redis_single",
    "_progress_redis_three",
)

create_worker module-attribute

create_worker = _create_worker

__all__ module-attribute

__all__ = [
    "DEFAULT_ACTORS",
    "EmptyPayload",
    "FakeBackend",
    "FakeClock",
    "InMemoryBackend",
    "JobContext",
    "JobTriple",
    "StubActorConfig",
    "WarningSpy",
    "as_backend",
    "assert_attempt",
    "assert_has_event",
    "assert_has_otel_event",
    "assert_has_span",
    "assert_job_status",
    "assert_job_terminal",
    "assert_transition_sequence",
    "create_pending_job",
    "create_running_job",
    "create_worker",
    "create_workered_running_job",
    "default_actor_config",
    "error_info",
    "get_job_triple",
    "make_enqueue_args",
    "make_integration_settings",
    "make_integration_settings_dict",
    "make_job_row",
    "parse_detail",
    "pg_now",
    "reset_schema",
    "seed_actors",
    "setup_running_job",
    "truncate_schema",
    "unique_health_sock_path",
    "wait_for",
    "wait_for_job_status",
    "wait_for_leader",
]

EmptyPayload

Bases: BaseModel

FakeBackend

FakeBackend(
    *,
    mark_snoozed_return: Literal[
        "scheduled", "failed", "noop"
    ] = "scheduled",
    mark_retry_after_return: Literal[
        "scheduled",
        "failed:DeadlineExceeded",
        "failed:MaxAttemptsExceeded",
        "noop",
    ] = "scheduled",
    mark_interrupted_return: Literal[
        "pending",
        "scheduled",
        "failed:DeadlineExceeded",
        "noop",
    ] = "pending",
)

Minimal backend recording method calls for assertions.

The terminal writes model the real backends' fencing, not just the happy path: Postgres and the in-memory twin both fence on (status='running', locked_by_worker, attempt), so once one terminal write has moved a job row out of running every later terminal write for that job matches nothing and reports False. The double keeps the terminal-state half of that contract, the first mark_succeeded/mark_cancelled for a job id lands, any subsequent one returns False, so a test exercising a cancel/success race cannot pass vacuously against a backend that let both writes land. The worker/attempt conjuncts are subsumed by first-writer-wins: the landing write IS the holder's, and on the real backends a repeated write fails the status conjunct even from the same worker and attempt.

Source code in src/taskq/testing/actor.py
def __init__(
    self,
    *,
    mark_snoozed_return: Literal["scheduled", "failed", "noop"] = "scheduled",
    mark_retry_after_return: Literal[
        "scheduled", "failed:DeadlineExceeded", "failed:MaxAttemptsExceeded", "noop"
    ] = "scheduled",
    mark_interrupted_return: Literal[
        "pending", "scheduled", "failed:DeadlineExceeded", "noop"
    ] = "pending",
) -> None:
    self.mark_succeeded_calls: list[
        tuple[UUID, UUID, dict[str, object] | None, bytes | None]
    ] = []
    self.mark_cancelled_calls: list[dict[str, object]] = []
    self.mark_snoozed_calls: list[dict[str, object]] = []
    self.mark_retry_after_calls: list[dict[str, object]] = []
    self.mark_interrupted_calls: list[dict[str, object]] = []
    self.mark_failed_or_retry_calls: list[dict[str, object]] = []
    # The fence's terminal-state half: job ids a terminal write
    # already moved out of 'running', mapped to the outcome that won.
    self._terminal_outcomes: dict[UUID, str] = {}
    self._mark_snoozed_return: Literal["scheduled", "failed", "noop"] = mark_snoozed_return
    self._mark_retry_after_return: Literal[
        "scheduled", "failed:DeadlineExceeded", "failed:MaxAttemptsExceeded", "noop"
    ] = mark_retry_after_return
    self._mark_interrupted_return: Literal[
        "pending", "scheduled", "failed:DeadlineExceeded", "noop"
    ] = mark_interrupted_return

BACKEND_PROTOCOL_VERSION class-attribute instance-attribute

BACKEND_PROTOCOL_VERSION: int = BACKEND_PROTOCOL_VERSION

supports_transactional_simulation class-attribute instance-attribute

supports_transactional_simulation: bool = False

mark_succeeded_calls instance-attribute

mark_succeeded_calls: list[
    tuple[
        UUID, UUID, dict[str, object] | None, bytes | None
    ]
] = []

mark_cancelled_calls instance-attribute

mark_cancelled_calls: list[dict[str, object]] = []

mark_snoozed_calls instance-attribute

mark_snoozed_calls: list[dict[str, object]] = []

mark_retry_after_calls instance-attribute

mark_retry_after_calls: list[dict[str, object]] = []

mark_interrupted_calls instance-attribute

mark_interrupted_calls: list[dict[str, object]] = []

mark_failed_or_retry_calls instance-attribute

mark_failed_or_retry_calls: list[dict[str, object]] = []

enqueue async

enqueue(args: EnqueueArgs) -> JobRow
Source code in src/taskq/testing/actor.py
async def enqueue(self, args: EnqueueArgs) -> JobRow:
    raise NotImplementedError

enqueue_with_conn async

enqueue_with_conn(
    conn: object, args: EnqueueArgs
) -> JobRow
Source code in src/taskq/testing/actor.py
async def enqueue_with_conn(self, conn: object, args: EnqueueArgs) -> JobRow:
    raise NotImplementedError

dispatch_batch async

dispatch_batch(
    worker_id: UUID,
    queues: list[str],
    limit: int,
    lock_lease: timedelta,
) -> list[JobRow]
Source code in src/taskq/testing/actor.py
async def dispatch_batch(
    self, worker_id: UUID, queues: list[str], limit: int, lock_lease: timedelta
) -> list[JobRow]:
    raise NotImplementedError

heartbeat_jobs async

heartbeat_jobs(
    worker_id: UUID, lock_lease: timedelta
) -> int
Source code in src/taskq/testing/actor.py
async def heartbeat_jobs(self, worker_id: UUID, lock_lease: timedelta) -> int:
    return 0

extend_reservation_leases async

extend_reservation_leases(
    worker_id: UUID, lock_lease: timedelta
) -> int
Source code in src/taskq/testing/actor.py
async def extend_reservation_leases(self, worker_id: UUID, lock_lease: timedelta) -> int:
    return 0

mark_succeeded async

mark_succeeded(
    job_id: UUID,
    worker_id: UUID,
    result: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    fallback_result_ttl: timedelta | None = None,
    *,
    result_bytes: bytes | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool
Source code in src/taskq/testing/actor.py
async def mark_succeeded(
    self,
    job_id: UUID,
    worker_id: UUID,
    result: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    fallback_result_ttl: timedelta | None = None,
    *,
    result_bytes: bytes | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool:
    self.mark_succeeded_calls.append((job_id, worker_id, result, result_bytes))
    return self._land_terminal_write(job_id, "succeeded")

mark_succeeded_with_conn async

mark_succeeded_with_conn(
    conn: object,
    job_id: UUID,
    worker_id: UUID,
    result: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    fallback_result_ttl: timedelta | None = None,
    *,
    result_bytes: bytes | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool
Source code in src/taskq/testing/actor.py
async def mark_succeeded_with_conn(
    self,
    conn: object,
    job_id: UUID,
    worker_id: UUID,
    result: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    fallback_result_ttl: timedelta | None = None,
    *,
    result_bytes: bytes | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool:
    return await self.mark_succeeded(
        job_id,
        worker_id,
        result,
        progress_seq,
        progress_state,
        fallback_result_ttl,
        result_bytes=result_bytes,
        attempt=attempt,
        claim_epoch=claim_epoch,
    )

mark_failed_or_retry async

mark_failed_or_retry(
    job_id: UUID,
    worker_id: UUID,
    error_info: ErrorInfo,
    retry_delay: timedelta | None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    *,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> JobRow
Source code in src/taskq/testing/actor.py
async def mark_failed_or_retry(
    self,
    job_id: UUID,
    worker_id: UUID,
    error_info: ErrorInfo,
    retry_delay: timedelta | None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    *,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> JobRow:
    self.mark_failed_or_retry_calls.append(
        {
            "job_id": job_id,
            "worker_id": worker_id,
            "error_info": error_info,
            "retry_delay": retry_delay,
        }
    )
    return _make_job_row()

mark_cancelled async

mark_cancelled(
    job_id: UUID,
    worker_id: UUID,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    *,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool
Source code in src/taskq/testing/actor.py
async def mark_cancelled(
    self,
    job_id: UUID,
    worker_id: UUID,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    *,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool:
    self.mark_cancelled_calls.append(
        {
            "job_id": job_id,
            "worker_id": worker_id,
            "progress_seq": progress_seq,
            "progress_state": progress_state,
        }
    )
    return self._land_terminal_write(job_id, "cancelled")

write_cancel_escalation async

write_cancel_escalation(
    job_id: UUID, worker_id: UUID, phase: Literal[2]
) -> bool
Source code in src/taskq/testing/actor.py
async def write_cancel_escalation(
    self, job_id: UUID, worker_id: UUID, phase: Literal[2]
) -> bool:
    return False

mark_abandoned async

mark_abandoned(
    job_id: UUID,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
) -> bool
Source code in src/taskq/testing/actor.py
async def mark_abandoned(
    self,
    job_id: UUID,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
) -> bool:
    return False

mark_snoozed async

mark_snoozed(
    job_id: UUID,
    worker_id: UUID,
    delay: timedelta,
    *,
    metadata_update: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    outcome: SnoozeOutcome = "snoozed",
    attempt: int | None = None,
    claim_epoch: int | None = None,
    denial_reason: DenialReason = "capacity",
) -> Literal["scheduled", "failed", "noop"]
Source code in src/taskq/testing/actor.py
async def mark_snoozed(
    self,
    job_id: UUID,
    worker_id: UUID,
    delay: timedelta,
    *,
    metadata_update: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    outcome: SnoozeOutcome = "snoozed",
    attempt: int | None = None,
    claim_epoch: int | None = None,
    denial_reason: DenialReason = "capacity",
) -> Literal["scheduled", "failed", "noop"]:
    self.mark_snoozed_calls.append(
        {
            "job_id": job_id,
            "worker_id": worker_id,
            "delay": delay,
            "metadata_update": metadata_update,
            "progress_seq": progress_seq,
            "progress_state": progress_state,
            "outcome": outcome,
            "denial_reason": denial_reason,
        }
    )
    return self._mark_snoozed_return

mark_retry_after async

mark_retry_after(
    job_id: UUID,
    worker_id: UUID,
    delay: timedelta,
    *,
    consume_budget: bool = True,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> Literal[
    "scheduled",
    "failed:DeadlineExceeded",
    "failed:MaxAttemptsExceeded",
    "noop",
]
Source code in src/taskq/testing/actor.py
async def mark_retry_after(
    self,
    job_id: UUID,
    worker_id: UUID,
    delay: timedelta,
    *,
    consume_budget: bool = True,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> Literal["scheduled", "failed:DeadlineExceeded", "failed:MaxAttemptsExceeded", "noop"]:
    self.mark_retry_after_calls.append(
        {
            "job_id": job_id,
            "worker_id": worker_id,
            "delay": delay,
            "consume_budget": consume_budget,
            "progress_seq": progress_seq,
            "progress_state": progress_state,
        }
    )
    return self._mark_retry_after_return

mark_interrupted async

mark_interrupted(
    job_id: UUID,
    worker_id: UUID,
    *,
    attempt: int,
    hold: timedelta,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    claim_epoch: int | None = None,
) -> Literal[
    "pending",
    "scheduled",
    "failed:DeadlineExceeded",
    "noop",
]
Source code in src/taskq/testing/actor.py
async def mark_interrupted(
    self,
    job_id: UUID,
    worker_id: UUID,
    *,
    attempt: int,
    hold: timedelta,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    claim_epoch: int | None = None,
) -> Literal["pending", "scheduled", "failed:DeadlineExceeded", "noop"]:
    self.mark_interrupted_calls.append(
        {
            "job_id": job_id,
            "worker_id": worker_id,
            "attempt": attempt,
            "hold": hold,
            "progress_seq": progress_seq,
            "progress_state": progress_state,
        }
    )
    return self._mark_interrupted_return

write_attempt async

write_attempt(attempt: AttemptRow) -> None
Source code in src/taskq/testing/actor.py
async def write_attempt(self, attempt: AttemptRow) -> None:
    pass

get_attempts async

get_attempts(job_id: UUID) -> list[AttemptRow]
Source code in src/taskq/testing/actor.py
async def get_attempts(self, job_id: UUID) -> list[AttemptRow]:
    return []

get_events async

get_events(job_id: UUID) -> list[EventRow]
Source code in src/taskq/testing/actor.py
async def get_events(self, job_id: UUID) -> list[EventRow]:
    return []

write_cancel_request async

write_cancel_request(
    job_id: UUID, reason: str | None
) -> bool
Source code in src/taskq/testing/actor.py
async def write_cancel_request(self, job_id: UUID, reason: str | None) -> bool:
    return False

poll_cancel_flags async

poll_cancel_flags(worker_id: UUID) -> list[CancelFlag]
Source code in src/taskq/testing/actor.py
async def poll_cancel_flags(self, worker_id: UUID) -> list[CancelFlag]:
    return []

scheduled_to_pending async

scheduled_to_pending() -> int
Source code in src/taskq/testing/actor.py
async def scheduled_to_pending(self) -> int:
    return 0

deadline_sweep async

deadline_sweep() -> int
Source code in src/taskq/testing/actor.py
async def deadline_sweep(self) -> int:
    return 0

reclaim_expired_locks async

reclaim_expired_locks(
    cancel_grace: timedelta, cleanup_grace: timedelta
) -> int
Source code in src/taskq/testing/actor.py
async def reclaim_expired_locks(self, cancel_grace: timedelta, cleanup_grace: timedelta) -> int:
    return 0

get async

get(job_id: UUID) -> JobRow | None
Source code in src/taskq/testing/actor.py
async def get(self, job_id: UUID) -> JobRow | None:
    return None

list_jobs async

list_jobs(filters: JobFilter) -> list[JobRow]
Source code in src/taskq/testing/actor.py
async def list_jobs(self, filters: JobFilter) -> list[JobRow]:
    return []

count_pending_jobs async

count_pending_jobs(actors: list[str]) -> dict[str, int]
Source code in src/taskq/testing/actor.py
async def count_pending_jobs(self, actors: list[str]) -> dict[str, int]:
    return {}

count_active_jobs async

count_active_jobs(queues: list[str]) -> int
Source code in src/taskq/testing/actor.py
async def count_active_jobs(self, queues: list[str]) -> int:
    return 0

get_actor_max_pending async

get_actor_max_pending() -> dict[str, int | None]
Source code in src/taskq/testing/actor.py
async def get_actor_max_pending(self) -> dict[str, int | None]:
    return {}

enqueue_batch async

enqueue_batch(
    args_list: list[EnqueueArgs],
    *,
    connection: object = None,
    enforce_max_pending: bool = True,
) -> list[JobRow]
Source code in src/taskq/testing/actor.py
async def enqueue_batch(
    self,
    args_list: list[EnqueueArgs],
    *,
    connection: object = None,
    enforce_max_pending: bool = True,
) -> list[JobRow]:
    raise NotImplementedError

subscribe_wake

subscribe_wake() -> AbstractAsyncContextManager[
    asyncio.Event
]
Source code in src/taskq/testing/actor.py
def subscribe_wake(self) -> AbstractAsyncContextManager[asyncio.Event]:
    raise NotImplementedError

create_schedule async

create_schedule(args: ScheduleCreateArgs) -> object
Source code in src/taskq/testing/actor.py
async def create_schedule(self, args: ScheduleCreateArgs) -> object:
    raise NotImplementedError

list_schedules async

list_schedules(
    *, actor: str | None = None, enabled: bool | None = None
) -> list[object]
Source code in src/taskq/testing/actor.py
async def list_schedules(
    self,
    *,
    actor: str | None = None,
    enabled: bool | None = None,
) -> list[object]:
    raise NotImplementedError

update_schedule async

update_schedule(
    schedule_id: UUID, args: ScheduleUpdateArgs
) -> object
Source code in src/taskq/testing/actor.py
async def update_schedule(self, schedule_id: UUID, args: ScheduleUpdateArgs) -> object:
    raise NotImplementedError

delete_schedule async

delete_schedule(schedule_id: UUID) -> None
Source code in src/taskq/testing/actor.py
async def delete_schedule(self, schedule_id: UUID) -> None:
    raise NotImplementedError

StubActorConfig dataclass

StubActorConfig(
    retry: RetryPolicy,
    non_retryable_exceptions: tuple[
        type[Exception], ...
    ] = (),
    retry_classifier: RetryClassifierHook | None = None,
    on_retry_exhausted: OnRetryExhausted | None = None,
    on_retry_exhausted_timeout: float = 3.0,
    on_success: OnSuccess | None = None,
    on_success_timeout: float = 3.0,
    on_cancel: OnCancel | None = None,
    on_cancel_timeout: float = 3.0,
)

retry instance-attribute

retry: RetryPolicy

non_retryable_exceptions class-attribute instance-attribute

non_retryable_exceptions: tuple[type[Exception], ...] = ()

retry_classifier class-attribute instance-attribute

retry_classifier: RetryClassifierHook | None = None

on_retry_exhausted class-attribute instance-attribute

on_retry_exhausted: OnRetryExhausted | None = None

on_retry_exhausted_timeout class-attribute instance-attribute

on_retry_exhausted_timeout: float = 3.0

on_success class-attribute instance-attribute

on_success: OnSuccess | None = None

on_success_timeout class-attribute instance-attribute

on_success_timeout: float = 3.0

on_cancel class-attribute instance-attribute

on_cancel: OnCancel | None = None

on_cancel_timeout class-attribute instance-attribute

on_cancel_timeout: float = 3.0

FakeClock

FakeClock(start: datetime)

Deterministic clock for tests.

Accepts a start datetime (typically datetime(2025, 1, 1, tzinfo=UTC)). now() returns the current internal time; move_to and advance let tests control the clock explicitly. monotonic() returns elapsed seconds from _EPOCH so that elapsed-time guards see a non-zero starting value.

Source code in src/taskq/clock.py
def __init__(self, start: datetime) -> None:
    self._now = start

now

now() -> datetime
Source code in src/taskq/clock.py
def now(self) -> datetime:
    return self._now

move_to

move_to(when: datetime) -> None

Set the clock to when.

Source code in src/taskq/clock.py
def move_to(self, when: datetime) -> None:
    """Set the clock to *when*."""
    self._now = when

advance

advance(delta: timedelta) -> None

Add delta to the clock.

Source code in src/taskq/clock.py
def advance(self, delta: timedelta) -> None:
    """Add *delta* to the clock."""
    self._now = self._now + delta

monotonic

monotonic() -> float

Elapsed seconds since _EPOCH, consistent with now().

Same wall-clock position always returns the same float; never decreases within a test.

Source code in src/taskq/clock.py
def monotonic(self) -> float:
    """Elapsed seconds since ``_EPOCH``, consistent with ``now()``.

    Same wall-clock position always returns the same float; never
    decreases within a test.
    """
    return (self._now - _EPOCH).total_seconds()

InMemoryBackend

InMemoryBackend(
    clock: Clock,
    cancellation_grace_period: timedelta = timedelta(
        seconds=30
    ),
    cleanup_grace_period: timedelta = timedelta(seconds=30),
    *,
    actor_configs: Iterable[ActorConfig] | None = None,
    result_max_bytes: int = MAX_RESULT_BYTES,
    max_retry_backoff: timedelta = DEFAULT_MAX_RETRY_BACKOFF,
)

Deterministic, in-memory backend for unit tests.

All state is held as per-instance attributes: no module-level mutable state, no class-level caches. Two InMemoryBackend instances created in the same test session are fully isolated.

Single-threaded by contract, do not share across threads or event loops. Intra-coroutine re-entry within a single event loop is acceptable; cross-thread use is a caller bug.

Source code in src/taskq/testing/in_memory.py
def __init__(
    self,
    clock: Clock,
    cancellation_grace_period: timedelta = timedelta(seconds=30),
    cleanup_grace_period: timedelta = timedelta(seconds=30),
    *,
    actor_configs: Iterable[ActorConfig] | None = None,
    result_max_bytes: int = MAX_RESULT_BYTES,
    max_retry_backoff: timedelta = DEFAULT_MAX_RETRY_BACKOFF,
) -> None:
    self._clock = clock
    self._result_max_bytes = result_max_bytes
    self._cancellation_grace = cancellation_grace_period
    self._cleanup_grace = cleanup_grace_period
    # The WorkerSettings.max_retry_backoff knob, carried the same way
    # the grace knobs above are: the reclaim sweep twin clamps the
    # reschedule delay at min(row cap, this ceiling), mirroring the
    # PG sweep's bound parameter.
    self._max_retry_backoff = max_retry_backoff
    # The claim cursor's knob, carried like the knobs above: the
    # dispatch twin reads it per round (0 disables the cursor - the
    # naive shape). The default mirrors WorkerSettings'
    # claim_cursor_reset_seconds' OPT-IN default (0 = disabled, the
    # A/B-measured posture), NOT the mechanism's 60s cadence constant
    # (backend/_claim_cursor.py CLAIM_CURSOR_RESET_SECONDS) - the
    # posture and the cadence are deliberately different numbers.
    self._claim_cursor_reset_seconds: float = 0.0
    self._worker_id: UUID = new_uuid()

    self._jobs: _JobStore = _JobStore(clock)
    # The claim cursor: this backend's per-queue high-water mark of
    # successfully-claimed ids, the twin of PostgresBackend's (see
    # backend/_claim_cursor.py). Clocked by the backend's own clock so
    # tests drive the jitter reset like every other clocked seam.
    self._claim_cursor = ClaimCursor(clock=clock.monotonic)
    self._attempts: dict[JobId, list[AttemptRow]] = {}
    self._events: list[EventRow] = []
    # The event-prune watermark twin (PG: job_events_prune_state,
    # migration 01.00.20_02): the highest event id any deleter has
    # committed a delete below-or-at. Advanced by the prune
    # simulation (testing/_runner.archive_terminal_jobs, the mirror
    # of the PG cascade) so _poll_reclaim_events' gap gate sees the
    # same signal both backends raise.
    self._events_pruned_through: int = 0
    self._idempotency_index: dict[tuple[str, str], JobId] = {}
    self._event_seq: int = 0
    self._cancel_observed_at: dict[JobId, datetime] = {}
    self._cancel_events: dict[JobId, asyncio.Event] = {}
    # The (job, task) of the attempt run_until_drained is currently
    # executing, keyed by job id so only the abandon of the job
    # actually in flight can act on it. tick_cancel_polling's
    # both-graces arm cancels it, the runner's mirror of production
    # phase 2's active.task.cancel(), so a non-cooperative attempt
    # cannot park the drain forever beside a row that already says
    # abandoned. None whenever no attempt is executing.
    self._inflight_attempt: tuple[JobId, asyncio.Task[object]] | None = None
    self._wake_subscribers: set[asyncio.Event] = set()
    self._wake_queues: dict[asyncio.Event, frozenset[str] | None] = {}
    self._cancel_wake_subscribers: set[asyncio.Event] = set()
    # Leadership wake registry: parity with the PG backend's
    # subscribe_leader_wake keeps the differential tests honest about
    # the subscription contract. The in-memory backend never emits the
    # broadcast (there is no LISTEN wire); the event is set only by a
    # test that drives the wake by hand.
    self._leader_wake_subscribers: set[asyncio.Event] = set()
    self._actor_stubs: dict[str, StubFn] = {}
    self._actor_configs: dict[str, _InMemoryActorConfig] = {}
    self._actor_configs_meta: dict[str, ActorConfig] = {}
    # Mirror of actor_config.last_claimed_at (the cross-round actor
    # rotation stamp the dispatch cut orders by):
    # PG stamps claiming actors with the statement's wall time, which
    # is strictly increasing across rounds; a frozen FakeClock cannot
    # supply that, so the twin stamps with a per-backend tick
    # incremented once per CLAIMING dispatch round. Same-round winners
    # share one tick and fall through to scheduled_at/id, exactly the
    # tie shape PG's single statement_timestamp() produces, and an
    # actor never claimed has no entry, mirroring NULL (sorts first).
    self._actor_claim_ticks: dict[str, int] = {}
    self._claim_tick: int = 0
    if actor_configs is not None:
        for cfg in actor_configs:
            self._actor_configs_meta[cfg.actor] = cfg
    self._slot_table: _SlotTable | None = None
    self._archive: dict[JobId, _ArchivedJobRow] = {}
    self._archive_attempts: dict[JobId, list[AttemptRow]] = {}
    self._schedules: dict[UUID, ScheduleRecord] = {}
    self._queues: dict[str, QueueMode] = {}
    self._batches: dict[UUID, BatchRow] = {}

BACKEND_PROTOCOL_VERSION class-attribute

BACKEND_PROTOCOL_VERSION: int = BACKEND_PROTOCOL_VERSION

supports_transactional_simulation class-attribute

supports_transactional_simulation: bool = True

supports_progress_stream class-attribute

supports_progress_stream: bool = False

slot_table property

slot_table: _SlotTable

advance_clock_to

advance_clock_to(when: datetime) -> None
Source code in src/taskq/testing/in_memory.py
def advance_clock_to(self, when: datetime) -> None:
    _advance_clock_to(self, when)

register_stub

register_stub(
    actor_name: str | ActorRef[Any, Any],
    fn: StubFn,
    *,
    retry: RetryPolicy | None = None,
    non_retryable_exceptions: tuple[
        type[BaseException], ...
    ] = (),
    retry_classifier: RetryClassifierHook | None = None,
    on_retry_exhausted: OnRetryExhausted | None = None,
    on_retry_exhausted_timeout: float = 3.0,
    on_success: OnSuccess | None = None,
    on_success_timeout: float = 3.0,
    on_cancel: OnCancel | None = None,
    on_cancel_timeout: float = 3.0,
    result_ttl: timedelta | None = None,
    payload_type: type[BaseModel] | None = None,
) -> None
Source code in src/taskq/testing/in_memory.py
def register_stub(
    self,
    actor_name: "str | ActorRef[Any, Any]",
    fn: StubFn,
    *,
    retry: RetryPolicy | None = None,
    non_retryable_exceptions: tuple[type[BaseException], ...] = (),
    retry_classifier: RetryClassifierHook | None = None,
    on_retry_exhausted: OnRetryExhausted | None = None,
    on_retry_exhausted_timeout: float = 3.0,
    on_success: OnSuccess | None = None,
    on_success_timeout: float = 3.0,
    on_cancel: OnCancel | None = None,
    on_cancel_timeout: float = 3.0,
    result_ttl: timedelta | None = None,
    payload_type: type[BaseModel] | None = None,
) -> None:
    _register_stub(
        self,
        actor_name,
        fn,
        retry=retry,
        non_retryable_exceptions=non_retryable_exceptions,
        retry_classifier=retry_classifier,
        on_retry_exhausted=on_retry_exhausted,
        on_retry_exhausted_timeout=on_retry_exhausted_timeout,
        on_success=on_success,
        on_success_timeout=on_success_timeout,
        on_cancel=on_cancel,
        on_cancel_timeout=on_cancel_timeout,
        result_ttl=result_ttl,
        payload_type=payload_type,
    )

register_cancel_event

register_cancel_event(job_id: JobId, event: Event) -> None
Source code in src/taskq/testing/in_memory.py
def register_cancel_event(self, job_id: JobId, event: asyncio.Event) -> None:
    _register_cancel_event(self, job_id, event)

get_events async

get_events(job_id: JobId) -> list[EventRow]
Source code in src/taskq/testing/in_memory.py
async def get_events(self, job_id: JobId) -> list[EventRow]:
    return await _get_events(self, job_id)

poll_reclaim_events async

poll_reclaim_events(
    after_id: int,
    limit: int = DEFAULT_RECLAIM_POLL_LIMIT,
    *,
    visibility_delay: timedelta | None = None,
) -> list[EventRow]
Source code in src/taskq/testing/in_memory.py
async def poll_reclaim_events(
    self,
    after_id: int,
    limit: int = DEFAULT_RECLAIM_POLL_LIMIT,
    *,
    visibility_delay: timedelta | None = None,
) -> list[EventRow]:
    return await _poll_reclaim_events(self, after_id, limit, visibility_delay=visibility_delay)

event_prune_watermark async

event_prune_watermark() -> int

The twin's event-prune watermark (PG: job_events_prune_state, migration 01.00.20_02). Advanced by the prune simulation's cascade arm (testing._runner.archive_terminal_jobs); no sweep on this backend deletes events any other way, so no other writer exists. watch_reclaims' gap gate reads this through the same getattr-probe it applies to the PG backend.

Source code in src/taskq/testing/in_memory.py
async def event_prune_watermark(self) -> int:
    """The twin's event-prune watermark (PG:
    ``job_events_prune_state``, migration 01.00.20_02). Advanced by
    the prune simulation's cascade arm
    (``testing._runner.archive_terminal_jobs``); no sweep on this
    backend deletes events any other way, so no other writer exists.
    watch_reclaims' gap gate reads this through the same
    getattr-probe it applies to the PG backend.
    """
    return self._events_pruned_through

enqueue async

enqueue(args: EnqueueArgs) -> JobRow
Source code in src/taskq/testing/in_memory.py
async def enqueue(self, args: EnqueueArgs) -> JobRow:
    return await _enqueue(self, args)

enqueue_with_conn async

enqueue_with_conn(
    conn: object, args: EnqueueArgs
) -> JobRow
Source code in src/taskq/testing/in_memory.py
async def enqueue_with_conn(
    self,
    conn: object,
    args: EnqueueArgs,
) -> JobRow:
    return await _enqueue_with_conn(self, conn, args)

enqueue_batch async

enqueue_batch(
    args_list: list[EnqueueArgs],
    *,
    connection: object = None,
    enforce_max_pending: bool = True,
) -> list[JobRow]
Source code in src/taskq/testing/in_memory.py
async def enqueue_batch(
    self,
    args_list: list[EnqueueArgs],
    *,
    connection: object = None,
    enforce_max_pending: bool = True,
) -> list[JobRow]:
    return await _enqueue_batch(
        self, args_list, connection=connection, enforce_max_pending=enforce_max_pending
    )

enqueue_batch_fast async

enqueue_batch_fast(
    args_list: list[EnqueueArgs],
    *,
    connection: object = None,
    enforce_max_pending: bool = True,
) -> int
Source code in src/taskq/testing/in_memory.py
async def enqueue_batch_fast(
    self,
    args_list: list[EnqueueArgs],
    *,
    connection: object = None,
    enforce_max_pending: bool = True,
) -> int:
    return await _enqueue_batch_fast(
        self, args_list, connection=connection, enforce_max_pending=enforce_max_pending
    )

register_actor_config

register_actor_config(
    *,
    actor: str,
    max_concurrent: int | None = None,
    max_pending: int | None = None,
    queue: str = "default",
    metadata: dict[str, object] | None = None,
) -> None
Source code in src/taskq/testing/in_memory.py
def register_actor_config(
    self,
    *,
    actor: str,
    max_concurrent: int | None = None,
    max_pending: int | None = None,
    queue: str = "default",
    metadata: dict[str, object] | None = None,
) -> None:
    _register_actor_config(
        self,
        actor=actor,
        max_concurrent=max_concurrent,
        max_pending=max_pending,
        queue=queue,
        metadata=metadata,
    )

register_actor_configs

register_actor_configs(
    configs: Iterable[ActorConfig],
) -> None
Source code in src/taskq/testing/in_memory.py
def register_actor_configs(self, configs: Iterable[ActorConfig]) -> None:
    _register_actor_configs(self, configs)

set_queue_mode

set_queue_mode(queue_name: str, mode: QueueMode) -> None
Source code in src/taskq/testing/in_memory.py
def set_queue_mode(self, queue_name: str, mode: QueueMode) -> None:
    _set_queue_mode(self, queue_name, mode)

dispatch_batch async

dispatch_batch(
    worker_id: UUID,
    queues: list[str],
    limit: int,
    lock_lease: timedelta,
) -> list[JobRow]
Source code in src/taskq/testing/in_memory.py
async def dispatch_batch(
    self,
    worker_id: UUID,
    queues: list[str],
    limit: int,
    lock_lease: timedelta,
) -> list[JobRow]:
    return await _dispatch_batch(self, worker_id, queues, limit, lock_lease)

heartbeat_jobs async

heartbeat_jobs(
    worker_id: UUID,
    lock_lease: timedelta,
    *,
    disowned: Collection[UUID] = (),
) -> int
Source code in src/taskq/testing/in_memory.py
async def heartbeat_jobs(
    self,
    worker_id: UUID,
    lock_lease: timedelta,
    *,
    disowned: Collection[UUID] = (),
) -> int:
    now = self._clock.now()
    excluded = set(disowned)
    count = 0
    for job_id, row in list(self._jobs.items()):
        if (
            row.status == "running"
            and row.locked_by_worker == worker_id
            and job_id not in excluded
        ):
            self._jobs[job_id] = replace(
                row,
                lock_expires_at=now + lock_lease,
                last_heartbeat_at=now,
            )
            count += 1
    return count

extend_reservation_leases async

extend_reservation_leases(
    worker_id: UUID,
    lock_lease: timedelta,
    *,
    disowned: Collection[UUID] = (),
) -> int
Source code in src/taskq/testing/in_memory.py
async def extend_reservation_leases(
    self,
    worker_id: UUID,
    lock_lease: timedelta,
    *,
    disowned: Collection[UUID] = (),
) -> int:
    now = self._clock.now()
    excluded = set(disowned)
    count = 0
    for job_id, row in list(self._jobs.items()):
        if (
            row.status == "running"
            and row.locked_by_worker == worker_id
            and job_id not in excluded
            and self._slot_table is not None
        ):
            # The count is SLOT rows renewed, the twin of PG's
            # UPDATE_RESERVATION_LEASES_SQL_TEMPLATE rowcount: dispatch
            # never creates a reservation_slots row (the reservation
            # registry's acquisition does), so a worker holding plain
            # running jobs extends nothing and answers 0. The mirror's
            # slot-table-less default models exactly that deployment -
            # a former `count += 1` fallback here counted every held
            # running job instead, reporting slot renewals production
            # never makes (pinned in
            # tests/test_heartbeat_seam_parity.py).
            count += self._slot_table.extend_leases_for_job(job_id, now, lock_lease)
    return count

mark_succeeded async

mark_succeeded(
    job_id: JobId,
    worker_id: UUID,
    result: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    fallback_result_ttl: timedelta | None = None,
    *,
    result_bytes: bytes | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool
Source code in src/taskq/testing/in_memory.py
async def mark_succeeded(
    self,
    job_id: JobId,
    worker_id: UUID,
    result: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    fallback_result_ttl: timedelta | None = None,
    *,
    result_bytes: bytes | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool:
    return await _mark_succeeded(
        self,
        job_id,
        worker_id,
        result,
        progress_seq,
        progress_state,
        fallback_result_ttl,
        result_bytes=result_bytes,
        attempt=attempt,
        claim_epoch=claim_epoch,
    )

mark_succeeded_with_conn async

mark_succeeded_with_conn(
    conn: object,
    job_id: JobId,
    worker_id: UUID,
    result: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    fallback_result_ttl: timedelta | None = None,
    *,
    result_bytes: bytes | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool
Source code in src/taskq/testing/in_memory.py
async def mark_succeeded_with_conn(
    self,
    conn: object,
    job_id: JobId,
    worker_id: UUID,
    result: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    fallback_result_ttl: timedelta | None = None,
    *,
    result_bytes: bytes | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool:
    return await _mark_succeeded_with_conn(
        self,
        conn,
        job_id,
        worker_id,
        result,
        progress_seq,
        progress_state,
        fallback_result_ttl,
        result_bytes=result_bytes,
        attempt=attempt,
        claim_epoch=claim_epoch,
    )

mark_failed_or_retry async

mark_failed_or_retry(
    job_id: JobId,
    worker_id: UUID,
    error_info: ErrorInfo,
    retry_delay: timedelta | None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    *,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> JobRow
Source code in src/taskq/testing/in_memory.py
async def mark_failed_or_retry(
    self,
    job_id: JobId,
    worker_id: UUID,
    error_info: ErrorInfo,
    retry_delay: timedelta | None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    *,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> JobRow:
    return await _mark_failed_or_retry(
        self,
        job_id,
        worker_id,
        error_info,
        retry_delay,
        progress_seq,
        progress_state,
        attempt=attempt,
        claim_epoch=claim_epoch,
    )

mark_cancelled async

mark_cancelled(
    job_id: JobId,
    worker_id: UUID,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    *,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool
Source code in src/taskq/testing/in_memory.py
async def mark_cancelled(
    self,
    job_id: JobId,
    worker_id: UUID,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    *,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> bool:
    return await _mark_cancelled(
        self,
        job_id,
        worker_id,
        progress_seq,
        progress_state,
        attempt=attempt,
        claim_epoch=claim_epoch,
    )

write_cancel_escalation async

write_cancel_escalation(
    job_id: JobId, worker_id: UUID, phase: Literal[2]
) -> bool
Source code in src/taskq/testing/in_memory.py
async def write_cancel_escalation(
    self,
    job_id: JobId,
    worker_id: UUID,
    phase: Literal[2],
) -> bool:
    return await _write_cancel_escalation(self, job_id, worker_id, phase)

mark_abandoned async

mark_abandoned(
    job_id: JobId,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
) -> bool
Source code in src/taskq/testing/in_memory.py
async def mark_abandoned(
    self,
    job_id: JobId,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
) -> bool:
    return await _mark_abandoned(self, job_id, progress_seq, progress_state)

mark_snoozed async

mark_snoozed(
    job_id: JobId,
    worker_id: UUID,
    delay: timedelta,
    *,
    metadata_update: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    outcome: SnoozeOutcome = "snoozed",
    attempt: int | None = None,
    claim_epoch: int | None = None,
    denial_reason: DenialReason = "capacity",
) -> Literal["scheduled", "failed", "noop"]
Source code in src/taskq/testing/in_memory.py
async def mark_snoozed(
    self,
    job_id: JobId,
    worker_id: UUID,
    delay: timedelta,
    *,
    metadata_update: dict[str, object] | None = None,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    outcome: SnoozeOutcome = "snoozed",
    attempt: int | None = None,
    claim_epoch: int | None = None,
    denial_reason: DenialReason = "capacity",
) -> Literal["scheduled", "failed", "noop"]:
    return await _mark_snoozed(
        self,
        job_id,
        worker_id,
        delay,
        metadata_update=metadata_update,
        progress_seq=progress_seq,
        progress_state=progress_state,
        outcome=outcome,
        attempt=attempt,
        claim_epoch=claim_epoch,
        denial_reason=denial_reason,
    )

mark_retry_after async

mark_retry_after(
    job_id: JobId,
    worker_id: UUID,
    delay: timedelta,
    *,
    consume_budget: bool = True,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> Literal[
    "scheduled",
    "failed:DeadlineExceeded",
    "failed:MaxAttemptsExceeded",
    "noop",
]
Source code in src/taskq/testing/in_memory.py
async def mark_retry_after(
    self,
    job_id: JobId,
    worker_id: UUID,
    delay: timedelta,
    *,
    consume_budget: bool = True,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    attempt: int | None = None,
    claim_epoch: int | None = None,
) -> Literal["scheduled", "failed:DeadlineExceeded", "failed:MaxAttemptsExceeded", "noop"]:
    return await _mark_retry_after(
        self,
        job_id,
        worker_id,
        delay,
        consume_budget=consume_budget,
        progress_seq=progress_seq,
        progress_state=progress_state,
        attempt=attempt,
        claim_epoch=claim_epoch,
    )

mark_interrupted async

mark_interrupted(
    job_id: JobId,
    worker_id: UUID,
    *,
    attempt: int,
    hold: timedelta,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    claim_epoch: int | None = None,
) -> Literal[
    "pending",
    "scheduled",
    "failed:DeadlineExceeded",
    "noop",
]
Source code in src/taskq/testing/in_memory.py
async def mark_interrupted(
    self,
    job_id: JobId,
    worker_id: UUID,
    *,
    attempt: int,
    hold: timedelta,
    progress_seq: int = 0,
    progress_state: dict[str, object] | None = None,
    claim_epoch: int | None = None,
) -> Literal["pending", "scheduled", "failed:DeadlineExceeded", "noop"]:
    return await _mark_interrupted(
        self,
        job_id,
        worker_id,
        attempt=attempt,
        hold=hold,
        progress_seq=progress_seq,
        progress_state=progress_state,
        claim_epoch=claim_epoch,
    )

write_attempt async

write_attempt(attempt: AttemptRow) -> None
Source code in src/taskq/testing/in_memory.py
async def write_attempt(self, attempt: AttemptRow) -> None:
    await _write_attempt(self, attempt)

get_attempts async

get_attempts(job_id: JobId) -> list[AttemptRow]
Source code in src/taskq/testing/in_memory.py
async def get_attempts(self, job_id: JobId) -> list[AttemptRow]:
    return await _get_attempts(self, job_id)

write_cancel_request async

write_cancel_request(
    job_id: JobId, reason: str | None
) -> bool
Source code in src/taskq/testing/in_memory.py
async def write_cancel_request(
    self,
    job_id: JobId,
    reason: str | None,
) -> bool:
    row = self._jobs.get(job_id)
    if row is None:
        return False

    if row.status == "running" and row.cancel_phase == 0:
        now = self._clock.now()
        self._jobs[job_id] = replace(
            row,
            cancel_requested_at=now,
            cancel_phase=1,
        )
        self._append_cancel_request_event(job_id, now, reason)
        for event in self._cancel_wake_subscribers:
            event.set()
        logger.debug(
            "cancel_requested",
            kind="state_change",
            from_state="running",
            to_state="running",
            job_id=job_id,
            cancel_phase=1,
        )
        return True

    if row.status in ("pending", "scheduled"):
        now = self._clock.now()
        prev_status = row.status
        self._jobs[job_id] = replace(
            row,
            status="cancelled",
            finished_at=now,
            # Twin of cancel_pending_scheduled: the before-start origin
            # marker goes on the ROW only, the state_change event
            # detail keeps its {from_state, to_state} shape on both
            # backends (the differential suite pins it exactly).
            error_class=CANCEL_ORIGIN_PENDING,
        )
        self._append_state_change_event(
            job_id=job_id,
            from_state=prev_status,
            to_state="cancelled",
            now=now,
        )
        self._append_cancel_request_event(job_id, now, reason)
        logger.debug(
            "state-change",
            kind="state_change",
            from_state=prev_status,
            to_state="cancelled",
            job_id=job_id,
        )
        return True

    return False

poll_cancel_flags async

poll_cancel_flags(worker_id: UUID) -> list[CancelFlag]
Source code in src/taskq/testing/in_memory.py
async def poll_cancel_flags(
    self,
    worker_id: UUID,
) -> list[CancelFlag]:
    return [
        CancelFlag(job_id=row.id, cancel_phase=row.cancel_phase)
        for row in self._jobs.values()
        if row.cancel_requested_at is not None
        and row.status == "running"
        and row.locked_by_worker == worker_id
    ]

cancel_where async

cancel_where(
    filter: JobFilter, reason: str | None
) -> BulkCancelResult
Source code in src/taskq/testing/in_memory.py
async def cancel_where(
    self,
    filter: JobFilter,
    reason: str | None,
) -> BulkCancelResult:
    return await _cancel_where(self, filter, reason)

retry_job async

retry_job(job_id: JobId) -> bool
Source code in src/taskq/testing/in_memory.py
async def retry_job(self, job_id: JobId) -> bool:
    return await _retry_job(self, job_id)

scheduled_to_pending async

scheduled_to_pending(
    *, batch_size: int = DEFAULT_EVENT_WRITER_BATCH_SIZE
) -> int
Source code in src/taskq/testing/in_memory.py
async def scheduled_to_pending(
    self,
    *,
    batch_size: int = DEFAULT_EVENT_WRITER_BATCH_SIZE,
) -> int:
    return await _scheduled_to_pending(self, batch_size=batch_size)

deadline_sweep async

deadline_sweep(
    *, batch_size: int = DEFAULT_EVENT_WRITER_BATCH_SIZE
) -> int
Source code in src/taskq/testing/in_memory.py
async def deadline_sweep(
    self,
    *,
    batch_size: int = DEFAULT_EVENT_WRITER_BATCH_SIZE,
) -> int:
    return await _deadline_sweep(self, batch_size=batch_size)

reclaim_expired_locks async

reclaim_expired_locks(
    cancel_grace: timedelta,
    cleanup_grace: timedelta,
    *,
    batch_size: int = DEFAULT_EVENT_WRITER_BATCH_SIZE,
) -> int
Source code in src/taskq/testing/in_memory.py
async def reclaim_expired_locks(
    self,
    cancel_grace: timedelta,
    cleanup_grace: timedelta,
    *,
    batch_size: int = DEFAULT_EVENT_WRITER_BATCH_SIZE,
) -> int:
    return await _reclaim_expired_locks(
        self, cancel_grace, cleanup_grace, batch_size=batch_size
    )

archive_terminal_jobs

archive_terminal_jobs(
    retention: timedelta,
    archive_retention: timedelta,
    *,
    statuses: frozenset[str] | None = None,
) -> PruneResult
Source code in src/taskq/testing/in_memory.py
def archive_terminal_jobs(
    self,
    retention: timedelta,
    archive_retention: timedelta,
    *,
    statuses: frozenset[str] | None = None,
) -> "PruneResult":
    return _archive_terminal_jobs(self, retention, archive_retention, statuses=statuses)

expire_archived_jobs

expire_archived_jobs() -> ArchiveExpiryResult
Source code in src/taskq/testing/in_memory.py
def expire_archived_jobs(self) -> "ArchiveExpiryResult":
    return _expire_archived_jobs(self)

get_archived async

get_archived(job_id: JobId) -> _ArchivedJobRow | None
Source code in src/taskq/testing/in_memory.py
async def get_archived(self, job_id: JobId) -> _ArchivedJobRow | None:
    return await _get_archived(self, job_id)

get async

get(job_id: JobId) -> JobRow | None
Source code in src/taskq/testing/in_memory.py
async def get(self, job_id: JobId) -> JobRow | None:
    return await _get(self, job_id)

list_jobs async

list_jobs(filters: JobFilter) -> list[JobRow]
Source code in src/taskq/testing/in_memory.py
async def list_jobs(self, filters: JobFilter) -> list[JobRow]:
    return await _list_jobs(self, filters)

count_pending_jobs async

count_pending_jobs(actors: list[str]) -> dict[str, int]
Source code in src/taskq/testing/in_memory.py
async def count_pending_jobs(self, actors: list[str]) -> dict[str, int]:
    return await _count_pending_jobs(self, actors)

count_active_jobs async

count_active_jobs(queues: list[str]) -> int
Source code in src/taskq/testing/in_memory.py
async def count_active_jobs(self, queues: list[str]) -> int:
    if not queues:
        return 0
    queue_set = set(queues)
    return sum(
        1 for r in self._jobs.values() if r.queue in queue_set and r.status in ACTIVE_STATUSES
    )

count_pending_jobs_by_queue async

count_pending_jobs_by_queue(
    queues: list[str],
) -> dict[str, int]

Grouped pending+scheduled counts per queue, the admission count the max_pending cap governs (the LIB-2 backpressure read's DEPTH half).

The in-memory twin of sql.count_pending_jobs_by_queue: same statuses (pending + scheduled, exactly what enqueue_max_pending_count counts), grouped per queue, absent queues read 0 client-side.

Source code in src/taskq/testing/in_memory.py
async def count_pending_jobs_by_queue(self, queues: list[str]) -> dict[str, int]:
    """Grouped pending+scheduled counts per queue, the admission count the
    max_pending cap governs (the LIB-2 backpressure read's DEPTH half).

    The in-memory twin of ``sql.count_pending_jobs_by_queue``: same
    statuses (pending + scheduled, exactly what
    ``enqueue_max_pending_count`` counts), grouped per queue, absent
    queues read 0 client-side.
    """
    if not queues:
        return {}
    queue_set = set(queues)
    counts: dict[str, int] = {}
    for r in self._jobs.values():
        if r.queue in queue_set and r.status in ("pending", "scheduled"):
            counts[r.queue] = counts.get(r.queue, 0) + 1
    return counts

count_pending_children_by_queue async

count_pending_children_by_queue(
    parent_id: JobId,
) -> dict[str, int]

The parent's pending children, exact and grouped by queue (the LIB-2 backpressure read's FAN-OUT half), the in-memory twin of sql.count_pending_children_by_queue.

parent_id is a plain column, no FK: a purged or never-existed parent is the same defined-empty result.

Source code in src/taskq/testing/in_memory.py
async def count_pending_children_by_queue(self, parent_id: JobId) -> dict[str, int]:
    """The parent's pending children, exact and grouped by queue (the LIB-2
    backpressure read's FAN-OUT half), the in-memory twin of
    ``sql.count_pending_children_by_queue``.

    ``parent_id`` is a plain column, no FK: a purged or never-existed
    parent is the same defined-empty result.
    """
    counts: dict[str, int] = {}
    for r in self._jobs.values():
        if r.parent_id == parent_id and r.status in ("pending", "scheduled"):
            counts[r.queue] = counts.get(r.queue, 0) + 1
    return counts

get_actor_max_pending async

get_actor_max_pending() -> dict[str, int | None]
Source code in src/taskq/testing/in_memory.py
async def get_actor_max_pending(self) -> dict[str, int | None]:
    return await _get_actor_max_pending(self)

get_actor_queues async

get_actor_queues() -> dict[str, str]
Source code in src/taskq/testing/in_memory.py
async def get_actor_queues(self) -> dict[str, str]:
    return await _get_actor_queues(self)

subscribe_wake

subscribe_wake(
    queues: Iterable[str] | None = None,
) -> AsyncContextManager[asyncio.Event]

Queue-scoped wake subscription, the PG backend's contract mirrored.

queues=None wakes on every enqueue; a queue set wakes only when the enqueued row's queue is served by this subscriber. Parity with the PG backend's payload filtering keeps the differential tests honest about the wake contract.

Source code in src/taskq/testing/in_memory.py
def subscribe_wake(
    self, queues: Iterable[str] | None = None
) -> AsyncContextManager[asyncio.Event]:
    """Queue-scoped wake subscription, the PG backend's contract mirrored.

    ``queues=None`` wakes on every enqueue; a queue set wakes only when
    the enqueued row's queue is served by this subscriber. Parity with
    the PG backend's payload filtering keeps the differential tests
    honest about the wake contract.
    """
    event = asyncio.Event()
    # A bare str satisfies Iterable[str] and would char-split into a
    # nonsense set ('reports' -> {'r','e',...}), silently falling the
    # subscriber back to poll cadence: single strings are the whole set.
    queue_set = (
        frozenset({queues})
        if isinstance(queues, str)
        else (frozenset(queues) if queues else None)
    )
    return _SubscriberContext(
        event,
        self._wake_subscribers,
        queue_registry=self._wake_queues,
        queues=queue_set,
    )

subscribe_cancel_wake

subscribe_cancel_wake() -> AsyncContextManager[
    asyncio.Event
]
Source code in src/taskq/testing/in_memory.py
def subscribe_cancel_wake(self) -> AsyncContextManager[asyncio.Event]:
    event = asyncio.Event()
    return _SubscriberContext(event, self._cancel_wake_subscribers)

subscribe_leader_wake

subscribe_leader_wake() -> AsyncContextManager[
    asyncio.Event
]

Parity with the PG backend: subscribe to the leader's resign broadcast. Nothing in-memory emits it (no LISTEN wire), so a subscriber keeps the heartbeat-tick cadence, exactly the poll fallback the hint semantics promise.

Source code in src/taskq/testing/in_memory.py
def subscribe_leader_wake(self) -> AsyncContextManager[asyncio.Event]:
    """Parity with the PG backend: subscribe to the leader's resign
    broadcast. Nothing in-memory emits it (no LISTEN wire), so a
    subscriber keeps the heartbeat-tick cadence, exactly the poll
    fallback the hint semantics promise."""
    event = asyncio.Event()
    return _SubscriberContext(event, self._leader_wake_subscribers)

tick_cancel_polling async

tick_cancel_polling() -> None
Source code in src/taskq/testing/in_memory.py
async def tick_cancel_polling(self) -> None:
    await _tick_cancel_polling(self)

run_until_drained async

run_until_drained(*, cancel_polling: bool = False) -> None
Source code in src/taskq/testing/in_memory.py
async def run_until_drained(self, *, cancel_polling: bool = False) -> None:
    await _run_until_drained(self, cancel_polling=cancel_polling)

create_schedule async

create_schedule(args: ScheduleCreateArgs) -> ScheduleRecord
Source code in src/taskq/testing/in_memory.py
async def create_schedule(self, args: ScheduleCreateArgs) -> ScheduleRecord:
    for rec in self._schedules.values():
        if rec.actor == args.actor and rec.name == args.name:
            raise ValueError(
                f"schedule for actor {args.actor!r} name {args.name!r} already exists"
            )
    sid = new_uuid()
    # Same create-time provenance rule as backend/_schedules.py: an
    # operator-owned schedule created disabled carries operator intent
    # from birth; a code-owned one starts unowned (NULL).
    disabled_by: str | None = None
    if not args.enabled and args.owner == "operator":
        disabled_by = "operator"
    record = ScheduleRecord(
        id=sid,
        actor=args.actor,
        name=args.name,
        cron_expr=args.cron_expr,
        timezone=args.timezone,
        dst_strategy=args.dst_strategy,
        payload_factory=args.payload_factory,
        identity_key=args.identity_key,
        enabled=args.enabled,
        disabled_by=disabled_by,
        last_fired_at=None,
        last_fire_error=None,
        consecutive_failures=0,
        next_fire_at=args.next_fire_at,
        # PG serialises metadata into jsonb at INSERT time and reads it
        # back through loads, store the round-trip of the same
        # serialization, so a caller-held dict can never reach storage
        # by reference and values whose orjson encoding differs from
        # the Python object read back exactly as PG reads them.
        metadata=loads(dumps_jsonb_str(args.metadata)),
    )
    self._schedules[sid] = record
    return _schedule_read_copy(record)

list_schedules async

list_schedules(
    *, actor: str | None = None, enabled: bool | None = None
) -> list[ScheduleRecord]
Source code in src/taskq/testing/in_memory.py
async def list_schedules(
    self,
    *,
    actor: str | None = None,
    enabled: bool | None = None,
) -> list[ScheduleRecord]:
    results: list[ScheduleRecord] = []
    for rec in self._schedules.values():
        if actor is not None and rec.actor != actor:
            continue
        if enabled is not None and rec.enabled != enabled:
            continue
        results.append(_schedule_read_copy(rec))
    return results

update_schedule async

update_schedule(
    schedule_id: UUID, args: ScheduleUpdateArgs
) -> ScheduleRecord
Source code in src/taskq/testing/in_memory.py
async def update_schedule(
    self,
    schedule_id: UUID,
    args: ScheduleUpdateArgs,
) -> ScheduleRecord:
    rec = self._schedules.get(schedule_id)
    if rec is None:
        raise KeyError(f"schedule {schedule_id} not found")

    updates: dict[str, object] = {}
    if args.cron_expr is not None:
        updates["cron_expr"] = args.cron_expr
    if args.next_fire_at is not None:
        updates["next_fire_at"] = args.next_fire_at
    if args.enabled is not None:
        updates["enabled"] = args.enabled
        if args.enabled:
            updates["consecutive_failures"] = 0
            updates["last_fire_error"] = None
            updates["disabled_by"] = None
        else:
            # Mirror of backend/_schedules.update_schedule: a disable
            # through the backend API is operator-driven, the cron
            # loop's auto-disable stamps 'auto' in its own statement.
            updates["disabled_by"] = "operator"
    if args.payload_factory is not None:
        updates["payload_factory"] = args.payload_factory
    elif args.clear_payload_factory:
        updates["payload_factory"] = None
    if args.metadata is not None:
        # Same storage contract as create_schedule: the round-trip of
        # the same serialization PG binds, no caller reference in
        # storage, PG's jsonb read-back values.
        updates["metadata"] = loads(dumps_jsonb_str(args.metadata))
    if args.consecutive_failures is not None:
        updates["consecutive_failures"] = args.consecutive_failures
    if args.last_fire_error is not None:
        updates["last_fire_error"] = args.last_fire_error

    updated = rec.model_copy(update=updates)
    self._schedules[schedule_id] = updated
    return _schedule_read_copy(updated)

delete_schedule async

delete_schedule(schedule_id: UUID) -> None
Source code in src/taskq/testing/in_memory.py
async def delete_schedule(self, schedule_id: UUID) -> None:
    self._schedules.pop(schedule_id, None)

enqueue_batch_atomic async

enqueue_batch_atomic(
    items: Iterable[EnqueueArgs],
    *,
    batch_id: UUID,
    queue: str,
    batch_row: BatchRow | None,
    finalizer_args: EnqueueArgs | None,
    chunk_size: int = DEFAULT_CHUNK_SIZE,
) -> list[JobRow]
Source code in src/taskq/testing/in_memory.py
async def enqueue_batch_atomic(
    self,
    items: Iterable[EnqueueArgs],
    *,
    batch_id: UUID,
    queue: str,
    batch_row: BatchRow | None,
    finalizer_args: EnqueueArgs | None,
    chunk_size: int = DEFAULT_CHUNK_SIZE,
) -> list[JobRow]:
    return await _enqueue_batch_atomic(
        self,
        items,
        batch_id=batch_id,
        queue=queue,
        batch_row=batch_row,
        finalizer_args=finalizer_args,
        chunk_size=chunk_size,
    )

create_batch async

create_batch(
    batch_id: UUID,
    queue: str,
    expected_size: int,
    failure_threshold: int | None,
    finalizer_job_id: UUID | None,
    originating_actor: str | None,
    *,
    connection: object = None,
) -> None
Source code in src/taskq/testing/in_memory.py
async def create_batch(
    self,
    batch_id: UUID,
    queue: str,
    expected_size: int,
    failure_threshold: int | None,
    finalizer_job_id: UUID | None,
    originating_actor: str | None,
    *,
    connection: object = None,
) -> None:
    _create_batch(
        self,
        batch_id,
        queue,
        expected_size,
        failure_threshold,
        finalizer_job_id,
        originating_actor,
        connection,
    )

increment_batch_failures async

increment_batch_failures(
    batch_id: UUID, *, connection: object = None
) -> tuple[int, int | None, int]
Source code in src/taskq/testing/in_memory.py
async def increment_batch_failures(
    self,
    batch_id: UUID,
    *,
    connection: object = None,
) -> tuple[int, int | None, int]:
    return _increment_batch_failures(self, batch_id, connection)

reset_batch_failures async

reset_batch_failures(
    batch_id: UUID, *, connection: object = None
) -> int
Source code in src/taskq/testing/in_memory.py
async def reset_batch_failures(
    self,
    batch_id: UUID,
    *,
    connection: object = None,
) -> int:
    return _reset_batch_failures(self, batch_id, connection)

abort_batch async

abort_batch(
    batch_id: UUID, *, connection: object = None
) -> int
Source code in src/taskq/testing/in_memory.py
async def abort_batch(
    self,
    batch_id: UUID,
    *,
    connection: object = None,
) -> int:
    return _abort_batch(self, batch_id, connection)

complete_batch async

complete_batch(
    batch_id: UUID, *, connection: object = None
) -> bool
Source code in src/taskq/testing/in_memory.py
async def complete_batch(
    self,
    batch_id: UUID,
    *,
    connection: object = None,
) -> bool:
    # The in-memory backend is single-threaded and its completion
    # decision reads the live member set at the write, so it never
    # gates out mid-drain and never owes a reissue.
    _complete_batch(self, batch_id, connection)
    return False

get_batch async

get_batch(batch_id: UUID) -> BatchRow | None
Source code in src/taskq/testing/in_memory.py
async def get_batch(
    self,
    batch_id: UUID,
) -> BatchRow | None:
    return _get_batch(self, batch_id)

list_batches async

list_batches(
    filter: BatchFilter,
) -> list[tuple[BatchRow, BatchCounts]]
Source code in src/taskq/testing/in_memory.py
async def list_batches(
    self,
    filter: BatchFilter,
) -> list[tuple[BatchRow, BatchCounts]]:
    return _list_batches(self, filter)

count_batch_non_terminal async

count_batch_non_terminal(
    batch_id: UUID, *, connection: object = None
) -> int
Source code in src/taskq/testing/in_memory.py
async def count_batch_non_terminal(
    self,
    batch_id: UUID,
    *,
    connection: object = None,
) -> int:
    return _count_batch_non_terminal(self, batch_id)

prune_old_batches async

prune_old_batches(cutoff: datetime) -> int
Source code in src/taskq/testing/in_memory.py
async def prune_old_batches(
    self,
    cutoff: datetime,
) -> int:
    return _prune_old_batches(self, cutoff)

JobContext dataclass

JobContext(
    job_id: JobId,
    actor: str,
    queue: str,
    attempt: int,
    claim_epoch: int,
    payload: P,
    cancel_event: Event,
    worker_id: UUID,
    jobs: SubJobEnqueuer,
    log: BoundLogger,
    snooze_count: int = 0,
    span: Span | None = None,
    deps: dict[str, object] | None = None,
    abort_requested: Event = threading.Event(),
    progress_reports: list[dict[str, object]] = list[
        dict[str, object]
    ](),
    cancel_origin: CancelOrigin = CancelOrigin.NONE,
)

Test-scoped context with deps for fixture-injected dependencies.

Field shape mirrors :class:taskq.context.JobContext (the production class) and adds deps. The bound on P matches production , payload is always a :class:pydantic.BaseModel. Tests that pass raw dicts as payload should validate them through a wrapper model (:class:taskq.testing.in_memory._PassthroughPayload is the permissive default for register_stub callers).

job_id instance-attribute

job_id: JobId

actor instance-attribute

actor: str

queue instance-attribute

queue: str

attempt instance-attribute

attempt: int

claim_epoch instance-attribute

claim_epoch: int

payload instance-attribute

payload: P

cancel_event instance-attribute

cancel_event: Event

worker_id instance-attribute

worker_id: UUID

jobs instance-attribute

jobs: SubJobEnqueuer

log instance-attribute

log: BoundLogger

snooze_count class-attribute instance-attribute

snooze_count: int = 0

span class-attribute instance-attribute

span: Span | None = None

deps class-attribute instance-attribute

deps: dict[str, object] | None = field(default=None)

abort_requested class-attribute instance-attribute

abort_requested: Event = field(
    default_factory=threading.Event
)

progress_reports class-attribute instance-attribute

progress_reports: list[dict[str, object]] = field(
    default_factory=list[dict[str, object]]
)

cancel_origin class-attribute instance-attribute

cancel_origin: CancelOrigin = CancelOrigin.NONE

cancellation_requested property

cancellation_requested: bool

True when the cancel event has been set.

check_cancelled

check_cancelled() -> None

Raise :class:asyncio.CancelledError when cancellation has been requested, the production contract, so actors using the raising style are exercisable through the harness.

Source code in src/taskq/testing/job_context.py
def check_cancelled(self) -> None:
    """Raise :class:`asyncio.CancelledError` when cancellation has
    been requested, the production contract, so actors using the
    raising style are exercisable through the harness."""
    if self.cancel_event.is_set():
        raise asyncio.CancelledError

should_abort

should_abort() -> bool

Synchronous cancellation check for sync actors (thread-safe).

Source code in src/taskq/testing/job_context.py
def should_abort(self) -> bool:
    """Synchronous cancellation check for sync actors (thread-safe)."""
    return self.abort_requested.is_set()

progress async

progress(
    *,
    step: int | None = None,
    percent: float | None = None,
    detail: str | None = None,
    data: dict[str, object] | None = None,
) -> None

Record a progress report on the context (see the progress_reports field). Signature and seq monotonicity mirror the production :meth:taskq.context.JobContext.progress; the harness never blocks on the network because it never publishes.

Source code in src/taskq/testing/job_context.py
async def progress(
    self,
    *,
    step: int | None = None,
    percent: float | None = None,
    detail: str | None = None,
    data: dict[str, object] | None = None,
) -> None:
    """Record a progress report on the context (see the
    ``progress_reports`` field). Signature and ``seq`` monotonicity
    mirror the production :meth:`taskq.context.JobContext.progress`;
    the harness never blocks on the network because it never
    publishes."""
    self.progress_reports.append(
        {
            "seq": len(self.progress_reports) + 1,
            "step": step,
            "percent": percent,
            "detail": detail,
            "data": data,
        }
    )

JobTriple

Bases: NamedTuple

row instance-attribute

row: Record

attempts instance-attribute

attempts: list[Record]

events instance-attribute

events: list[Record]

WarningSpy

WarningSpy()

Records how many times warning() was called, without sniffing args.

Source code in src/taskq/testing/spy.py
def __init__(self) -> None:
    self.warning_count = 0

warning_count instance-attribute

warning_count = 0

warning

warning(*_args: object, **_kwargs: object) -> None
Source code in src/taskq/testing/spy.py
def warning(self, *_args: object, **_kwargs: object) -> None:
    self.warning_count += 1

as_backend

as_backend(fb: FakeBackend) -> Backend
Source code in src/taskq/testing/actor.py
def as_backend(fb: FakeBackend) -> Backend:
    return cast(Backend, fb)

default_actor_config

default_actor_config() -> StubActorConfig
Source code in src/taskq/testing/actor.py
def default_actor_config() -> StubActorConfig:
    return StubActorConfig(retry=RetryPolicy(kind="transient", max_attempts=3, jitter=0.0))

assert_attempt

assert_attempt(
    attempts: Sequence[object],
    index: int,
    *,
    outcome: str | None = None,
    error_class: str | None = None,
    attempt_num: int | None = None,
) -> object

Assert on the attempt row at index.

Source code in src/taskq/testing/assertions.py
def assert_attempt(
    attempts: Sequence[object],
    index: int,
    *,
    outcome: str | None = None,
    error_class: str | None = None,
    attempt_num: int | None = None,
) -> object:
    """Assert on the attempt row at *index*."""
    if index < 0 or index >= len(attempts):
        raise AssertionError(f"Attempt index {index} out of range (len={len(attempts)})")
    row = attempts[index]
    if outcome is not None:
        actual = _get(row, "outcome")
        if actual != outcome:
            raise AssertionError(f"attempts[{index}]: expected outcome {outcome!r}, got {actual!r}")
    if error_class is not None:
        actual = _get(row, "error_class")
        if actual != error_class:
            raise AssertionError(
                f"attempts[{index}]: expected error_class {error_class!r}, got {actual!r}"
            )
    if attempt_num is not None:
        actual = _get(row, "attempt")
        if actual != attempt_num:
            raise AssertionError(f"attempts[{index}]: expected attempt {attempt_num}, got {actual}")
    return row

assert_has_event

assert_has_event(
    events: Sequence[Record],
    kind: str,
    *,
    from_state: str | None = None,
    to_state: str | None = None,
) -> asyncpg.Record

Find at least one event matching kind and optional state filters.

Source code in src/taskq/testing/assertions.py
def assert_has_event(
    events: Sequence[asyncpg.Record],
    kind: str,
    *,
    from_state: str | None = None,
    to_state: str | None = None,
) -> asyncpg.Record:
    """Find at least one event matching kind and optional state filters."""
    for ev in events:
        if _get(ev, "kind") != kind:
            continue
        if from_state is not None or to_state is not None:
            detail = parse_detail(_get(ev, "detail"))
            if from_state is not None and detail.get("from_state") != from_state:
                continue
            if to_state is not None and detail.get("to_state") != to_state:
                continue
        return ev
    available = [(i, _get(e, "kind")) for i, e in enumerate(events)]
    msg = f"No event with kind={kind!r}"
    if from_state is not None or to_state is not None:
        msg += f" (from_state={from_state!r}, to_state={to_state!r})"
    msg += f"; available events: {available}"
    raise AssertionError(msg)

assert_has_otel_event

assert_has_otel_event(
    exporter: _SpanExporter,
    span_name: str,
    event_name: str,
    *,
    from_state: str | None = None,
    to_state: str | None = None,
) -> object

Find an OTel event by span and event name; assert state attributes if provided.

Source code in src/taskq/testing/assertions.py
def assert_has_otel_event(
    exporter: _SpanExporter,
    span_name: str,
    event_name: str,
    *,
    from_state: str | None = None,
    to_state: str | None = None,
) -> object:
    """Find an OTel event by span and event name; assert state attributes if provided."""
    events = exporter.events_on(span_name, event_name)
    if not events:
        raise AssertionError(f"No OTel event {event_name!r} on span {span_name!r}")
    if from_state is not None or to_state is not None:
        for ev in events:
            attrs = ev.attributes
            if attrs is None:
                continue
            if from_state is not None and attrs.get("from_state") != from_state:
                continue
            if to_state is not None and attrs.get("to_state") != to_state:
                continue
            return ev
        raise AssertionError(
            f"No OTel event {event_name!r} on span {span_name!r} "
            f"with from_state={from_state!r}, to_state={to_state!r}"
        )
    return events[0]

assert_has_span

assert_has_span(
    exporter: _SpanExporter,
    name: str,
    *,
    kind: object = None,
    status: object = None,
) -> ReadableSpan

Find a span by name on the exporter; assert kind/status if provided.

Source code in src/taskq/testing/assertions.py
def assert_has_span(
    exporter: _SpanExporter,
    name: str,
    *,
    kind: object = None,
    status: object = None,
) -> ReadableSpan:
    """Find a span by name on the exporter; assert kind/status if provided."""
    span = exporter.span_named(name)
    if span is None:
        names = [s.name for s in exporter.spans]
        raise AssertionError(f"No span named {name!r}; available: {names}")
    if kind is not None and span.kind != kind:
        raise AssertionError(f"Span {name!r}: expected kind={kind!r}, got {span.kind!r}")
    if status is not None and span.status.status_code != status:
        raise AssertionError(
            f"Span {name!r}: expected status_code={status!r}, got {span.status.status_code!r}"
        )
    return span

assert_job_status

assert_job_status(
    row: Record | None,
    status: str,
    *,
    error_class: str | None = None,
    attempt: int | None = None,
    finished: bool | None = None,
) -> asyncpg.Record

Assert a job row has the expected status and optional fields.

Returns the row (guaranteed non-None on success) so callers can chain attribute/subscript access without pyright narrowing issues.

Source code in src/taskq/testing/assertions.py
def assert_job_status(
    row: asyncpg.Record | None,
    status: str,
    *,
    error_class: str | None = None,
    attempt: int | None = None,
    finished: bool | None = None,
) -> asyncpg.Record:
    """Assert a job row has the expected status and optional fields.

    Returns the row (guaranteed non-None on success) so callers can
    chain attribute/subscript access without pyright narrowing issues.
    """
    assert row is not None, "Expected a row but got None"
    actual_status = _get(row, "status")
    if actual_status != status:
        raise AssertionError(f"Expected status {status!r}, got {actual_status!r}")
    if error_class is not None:
        actual_ec = _get(row, "error_class")
        if actual_ec != error_class:
            raise AssertionError(f"Expected error_class {error_class!r}, got {actual_ec!r}")
    if attempt is not None:
        actual_attempt = _get(row, "attempt")
        if actual_attempt != attempt:
            raise AssertionError(f"Expected attempt {attempt}, got {actual_attempt}")
    if finished is not None:
        finished_at = _get(row, "finished_at")
        if finished and finished_at is None:
            raise AssertionError("Expected finished_at to be set, but it is None")
        if not finished and finished_at is not None:
            raise AssertionError(f"Expected finished_at to be None, got {finished_at!r}")
    return row

assert_job_terminal

assert_job_terminal(
    row: Record | None,
    status: str,
    *,
    error_class: str | None = None,
) -> asyncpg.Record

Assert a job is in a terminal status with finished_at set.

Source code in src/taskq/testing/assertions.py
def assert_job_terminal(
    row: asyncpg.Record | None,
    status: str,
    *,
    error_class: str | None = None,
) -> asyncpg.Record:
    """Assert a job is in a terminal status with finished_at set."""
    return assert_job_status(row, status, error_class=error_class, finished=True)

assert_transition_sequence

assert_transition_sequence(
    events: Sequence[object],
    expected: Sequence[tuple[str | None, str | None]],
) -> None

Assert the (from_state, to_state) sequence from state_change events matches expected.

Source code in src/taskq/testing/assertions.py
def assert_transition_sequence(
    events: Sequence[object],
    expected: Sequence[tuple[str | None, str | None]],
) -> None:
    """Assert the (from_state, to_state) sequence from state_change events matches expected."""
    transitions: list[tuple[object, object]] = []
    for ev in events:
        if _get(ev, "kind") != "state_change":
            continue
        detail = parse_detail(_get(ev, "detail"))
        transitions.append((detail.get("from_state"), detail.get("to_state")))
    if transitions != list(expected):
        raise AssertionError(f"Expected transition sequence {list(expected)}, got {transitions}")

parse_detail

parse_detail(detail: object) -> dict[str, object]

Normalize a detail value (dict, JSON string, or other) to a dict.

Source code in src/taskq/testing/assertions.py
def parse_detail(detail: object) -> dict[str, object]:
    """Normalize a detail value (dict, JSON string, or other) to a dict."""
    if isinstance(detail, dict):
        return detail  # type: ignore[return-value]  # Why: isinstance(detail, dict) guarantees a dict at runtime; the value type is object so pyright cannot narrow dict[unknown, unknown] to dict[str, object].
    if isinstance(detail, str):
        return dict(loads(detail))
    return {}

pg_now async

pg_now(conn: Connection) -> datetime

Return PG's clock_timestamp(), the realtime clock the server uses.

Use this instead of datetime.now(UTC) when a test needs to compute cutoffs/margins that are compared against rows written via SQL: the Python wall clock and PG's realtime clock can diverge enough under parallel load to make Python-computed margins flaky.

Source code in src/taskq/testing/assertions.py
async def pg_now(conn: asyncpg.Connection) -> datetime:
    """Return PG's ``clock_timestamp()``, the realtime clock the server uses.

    Use this instead of ``datetime.now(UTC)`` when a test needs to compute
    cutoffs/margins that are compared against rows written via SQL: the
    Python wall clock and PG's realtime clock can diverge enough under
    parallel load to make Python-computed margins flaky.
    """
    value: datetime = await conn.fetchval("SELECT clock_timestamp()")
    return value

wait_for async

wait_for(
    event: Event,
    timeout: float = 2.0,
    *,
    description: str | None = None,
) -> None

Wait for an asyncio.Event with test-failure semantics on timeout.

description names the awaited thing in the failure message so a timeout says what never happened ("<description> not set within Ns"); without it the generic "Event not set" is used.

Source code in src/taskq/testing/assertions.py
async def wait_for(
    event: asyncio.Event,
    timeout: float = 2.0,  # noqa: ASYNC109  # Why: a `timeout` parameter is the point, callers pass per-call deadlines, not an enclosing asyncio.timeout scope; the repo's established wait-helper shape.
    *,
    description: str | None = None,
) -> None:
    """Wait for an asyncio.Event with test-failure semantics on timeout.

    ``description`` names the awaited thing in the failure message so a
    timeout says what never happened (``"<description> not set within
    Ns"``); without it the generic ``"Event not set"`` is used.
    """
    try:
        await asyncio.wait_for(event.wait(), timeout=timeout)
    except TimeoutError:
        label = description or "Event"
        raise AssertionError(f"{label} not set within {timeout}s") from None

wait_for_job_status async

wait_for_job_status(
    backend: _AssertBackend,
    job_id: JobId,
    status: str,
    *,
    timeout: float = 2.0,
    poll_interval: float = 0.05,
) -> JobRow

Poll backend.get until the job reaches the expected status.

Source code in src/taskq/testing/assertions.py
async def wait_for_job_status(
    backend: _AssertBackend,
    job_id: JobId,
    status: str,
    *,
    timeout: float = 2.0,  # noqa: ASYNC109
    poll_interval: float = 0.05,
) -> JobRow:
    """Poll backend.get until the job reaches the expected status."""
    loop = asyncio.get_running_loop()
    deadline = loop.time() + timeout
    while True:
        row = await backend.get(job_id)
        if row is not None and _get(row, "status") == status:
            return row
        remaining = deadline - loop.time()
        if remaining <= 0:
            actual = _get(row, "status") if row is not None else None
            raise AssertionError(
                f"Job {job_id} did not reach status {status!r} within {timeout}s "
                f"(actual: {actual!r})"
            )
        await asyncio.sleep(min(poll_interval, remaining))

wait_for_leader async

wait_for_leader(
    deps: _LeaderDeps, timeout: float = 5.0
) -> None

Wait for the leader event on WorkerDeps with test-failure semantics.

Source code in src/taskq/testing/assertions.py
async def wait_for_leader(deps: _LeaderDeps, timeout: float = 5.0) -> None:  # noqa: ASYNC109
    """Wait for the leader event on WorkerDeps with test-failure semantics."""
    try:
        await asyncio.wait_for(deps.is_leader.wait(), timeout=timeout)
    except TimeoutError:
        raise AssertionError(f"Leader event not set within {timeout}s") from None

unique_health_sock_path

unique_health_sock_path(module: str) -> str

Return a unique unix-socket path for one test's health server.

/tmp/tq-<module>-<pid>-<token>.sock: the pid scopes across xdist workers, the random token across tests within a worker (stateless, no shared counter, so uniqueness survives any pytest import mode), and the module label identifies the owner when debugging stale files. The short /tmp/tq- prefix keeps paths well under the 104-char AF_UNIX sun_path limit on macOS.

Stale files are a resource-only leak: the per-run-unique token means a leftover path can never collide with a future run. HealthServer.stop unlinks on normal completion; suites wanting a crash backstop can sweep /tmp/tq-*-<pid>-*.sock scoped to their own pid (see TaskQ's tests/conftest.py::_sweep_health_sock_files).

:param module: label embedded in the path. Must not contain path separators, / would point the socket at a nonexistent directory and surface as a confusing ENOENT at bind time. :raises ValueError: if module contains / or \.

Source code in src/taskq/testing/health.py
def unique_health_sock_path(module: str) -> str:
    """Return a unique unix-socket path for one test's health server.

    ``/tmp/tq-<module>-<pid>-<token>.sock``: the pid scopes across xdist
    workers, the random token across tests within a worker (stateless, no
    shared counter, so uniqueness survives any pytest import mode), and the
    module label identifies the owner when debugging stale files. The short
    ``/tmp/tq-`` prefix keeps paths well under the 104-char AF_UNIX
    sun_path limit on macOS.

    Stale files are a resource-only leak: the per-run-unique token means a
    leftover path can never collide with a future run. ``HealthServer.stop``
    unlinks on normal completion; suites wanting a crash backstop can sweep
    ``/tmp/tq-*-<pid>-*.sock`` scoped to their own pid (see TaskQ's
    ``tests/conftest.py::_sweep_health_sock_files``).

    :param module: label embedded in the path. Must not contain path
        separators, ``/`` would point the socket at a nonexistent
        directory and surface as a confusing ENOENT at bind time.
    :raises ValueError: if *module* contains ``/`` or ``\\``.
    """
    if "/" in module or "\\" in module:
        raise ValueError(
            f"module label must not contain path separators, got {module!r} "
            "(it is embedded verbatim in the socket path)"
        )
    return f"/tmp/tq-{module}-{os.getpid()}-{new_base62()}.sock"  # noqa: S108  # Why: test-only socket files; /tmp is the shortest safe prefix.

error_info

error_info(
    error_class: str = "ValueError",
    error_message: str = "boom",
) -> ErrorInfo

Shorthand for ErrorInfo with error_traceback=None.

Source code in src/taskq/testing/jobs.py
def error_info(
    error_class: str = "ValueError",
    error_message: str = "boom",
) -> ErrorInfo:
    """Shorthand for ErrorInfo with error_traceback=None."""
    return ErrorInfo(
        error_class=error_class,
        error_message=error_message,
        error_traceback=None,
    )

make_enqueue_args

make_enqueue_args(
    *,
    actor: str = "test_actor",
    queue: str = "default",
    payload: dict[str, object] | None = None,
    idempotency_key: str | None = None,
    idempotency_scope: str = "",
    identity_key: str | None = None,
    scheduled_at: datetime | None = None,
    max_attempts: int = 3,
    retry_kind: RetryKind = "transient",
    priority: int = 0,
    schedule_to_close: datetime | None = None,
    metadata: dict[str, object] | None = None,
    tags: tuple[str, ...] | None = None,
    retry_jitter: float | None = None,
) -> EnqueueArgs

Build EnqueueArgs with sensible defaults.

scheduled_at defaults to 1 second in the past (relative to the Python wall clock) so freshly-enqueued test jobs can never classify as a future-"scheduled" job: the enqueue SQL compares against PG's clock_timestamp(), and Python's monotonic/wall clock can diverge from PG's realtime clock enough under parallel load that a "now" computed here reads as still-future by the time the row lands.

retry_jitter defaults to the EnqueueArgs field's own default; pass 0.0 to make a test's reclaim/retry timing assertions exact , jitter is fleet randomness, and the suite doubles randomness at the boundary rather than sampling it.

Source code in src/taskq/testing/jobs.py
def make_enqueue_args(
    *,
    actor: str = "test_actor",
    queue: str = "default",
    payload: dict[str, object] | None = None,
    idempotency_key: str | None = None,
    idempotency_scope: str = "",
    identity_key: str | None = None,
    scheduled_at: datetime | None = None,
    max_attempts: int = 3,
    retry_kind: RetryKind = "transient",
    priority: int = 0,
    schedule_to_close: datetime | None = None,
    metadata: dict[str, object] | None = None,
    tags: tuple[str, ...] | None = None,
    retry_jitter: float | None = None,
) -> EnqueueArgs:
    """Build EnqueueArgs with sensible defaults.

    ``scheduled_at`` defaults to 1 second in the past (relative to the
    Python wall clock) so freshly-enqueued test jobs can never classify
    as a future-"scheduled" job: the enqueue SQL compares against PG's
    ``clock_timestamp()``, and Python's monotonic/wall clock can diverge
    from PG's realtime clock enough under parallel load that a "now"
    computed here reads as still-future by the time the row lands.

    ``retry_jitter`` defaults to the ``EnqueueArgs`` field's own default;
    pass ``0.0`` to make a test's reclaim/retry timing assertions exact ,
    jitter is fleet randomness, and the suite doubles randomness at the
    boundary rather than sampling it.
    """
    base = EnqueueArgs(
        id=new_job_id(),
        actor=actor,
        queue=queue,
        payload=payload or {"value": 1},
        max_attempts=max_attempts,
        retry_kind=retry_kind,
        scheduled_at=scheduled_at or (datetime.now(UTC) - timedelta(seconds=1)),
        priority=priority,
        schedule_to_close=schedule_to_close,
        idempotency_key=IdempotencyKey(idempotency_key) if idempotency_key is not None else None,
        idempotency_scope=idempotency_scope,
        identity_key=IdentityKey(identity_key) if identity_key is not None else None,
        metadata=metadata or {},
        tags=tags if tags is not None else (),
    )
    if retry_jitter is None:
        return base
    return replace(base, retry_jitter=retry_jitter)

make_job_row

make_job_row(
    *,
    attempt: int = 1,
    max_attempts: int = 3,
    retry_kind: RetryKind = "transient",
    schedule_to_close: datetime | None = None,
    identity_key: IdentityKey | None = None,
    trace_id: str | None = None,
    span_id: str | None = None,
    start_to_close: timedelta | None = None,
    heartbeat_timeout: timedelta | None = None,
    cancel_phase: int | CancelPhase | None = None,
    status: JobStatus = "running",
    priority: int = 0,
    error_class: str | None = None,
    error_message: str | None = None,
    payload: dict[str, object] | None = None,
    queue: str = "default",
    actor: str = "test_actor",
    progress_seq: int = 0,
) -> JobRow

Build a JobRow with sensible defaults.

Source code in src/taskq/testing/jobs.py
def make_job_row(
    *,
    attempt: int = 1,
    max_attempts: int = 3,
    retry_kind: RetryKind = "transient",
    schedule_to_close: datetime | None = None,
    identity_key: IdentityKey | None = None,
    trace_id: str | None = None,
    span_id: str | None = None,
    start_to_close: timedelta | None = None,
    heartbeat_timeout: timedelta | None = None,
    cancel_phase: int | CancelPhase | None = None,
    status: JobStatus = "running",
    priority: int = 0,
    error_class: str | None = None,
    error_message: str | None = None,
    payload: dict[str, object] | None = None,
    queue: str = "default",
    actor: str = "test_actor",
    progress_seq: int = 0,
) -> JobRow:
    """Build a JobRow with sensible defaults."""
    phase: CancelPhase
    if cancel_phase is None:
        phase = CancelPhase.NONE
    elif isinstance(cancel_phase, CancelPhase):
        phase = cancel_phase
    else:
        phase = CancelPhase(cancel_phase)

    locked_by = _WORKER_ID if status == "running" else None

    return JobRow(
        id=new_job_id(),
        actor=actor,
        queue=queue,
        identity_key=identity_key,
        fairness_key=None,
        payload=payload or {},
        payload_schema_ver=1,
        status=status,
        priority=priority,
        attempt=attempt,
        max_attempts=max_attempts,
        retry_kind=retry_kind,
        schedule_to_close=schedule_to_close,
        start_to_close=start_to_close,
        heartbeat_timeout=heartbeat_timeout,
        created_at=_NOW,
        scheduled_at=_NOW,
        started_at=_NOW if status == "running" else None,
        finished_at=None,
        last_heartbeat_at=None,
        locked_by_worker=locked_by,
        lock_expires_at=None,
        cancel_requested_at=None,
        cancel_phase=phase,
        error_class=error_class,
        error_message=error_message,
        error_traceback=None,
        progress_state={},
        progress_seq=progress_seq,
        result=None,
        result_size_bytes=None,
        result_expires_at=None,
        idempotency_key=None,
        idempotency_scope="",
        trace_id=trace_id,
        span_id=span_id,
        metadata={},
        tags=(),
    )

create_pending_job async

create_pending_job(
    conn: _Conn,
    schema: str,
    job_id: UUID | None = None,
    *,
    schedule_to_close: datetime | None = None,
    status: str = "pending",
    scheduled_at: datetime | None = None,
) -> UUID

Seed one job row directly. scheduled_at defaults to the application clock's now(), a stamp a claim CTE comparing against the database's statement_timestamp() reads as not-yet-due whenever the database clock lags the application clock (Docker VM pause and NTP drift both cause it). Seed a past margin or an explicit scheduled_at whenever the test then asserts the row is claimable.

Source code in src/taskq/testing/pg.py
async def create_pending_job(
    conn: _Conn,
    schema: str,
    job_id: UUID | None = None,
    *,
    schedule_to_close: datetime | None = None,
    status: str = "pending",
    scheduled_at: datetime | None = None,
) -> UUID:
    """Seed one job row directly. ``scheduled_at`` defaults to the
    application clock's ``now()``, a stamp a claim CTE comparing against
    the database's ``statement_timestamp()`` reads as not-yet-due whenever
    the database clock lags the application clock (Docker VM pause and NTP
    drift both cause it). Seed a past margin or an explicit ``scheduled_at``
    whenever the test then asserts the row is claimable.
    """
    if not _IDENT_RE.match(schema):
        raise ValueError(f"invalid schema name {schema!r}")
    job_id = job_id or new_uuid()
    stc = schedule_to_close or (datetime.now(UTC) + timedelta(seconds=60))
    sa = scheduled_at or datetime.now(UTC)
    await conn.execute(
        f"""INSERT INTO "{schema}".jobs (
            id, actor, queue, payload, max_attempts, retry_kind,
            status, priority, scheduled_at, schedule_to_close
        ) VALUES (
            $1, $2, $3, $4::jsonb, $5, $6,
            $7, 0, $8, $9
        )""",  # noqa: S608
        job_id,
        "test_actor",
        "default",
        '{"key": "value"}',
        3,
        "transient",
        status,
        sa,
        stc,
    )
    return job_id

create_running_job async

create_running_job(
    conn: _Conn,
    schema: str,
    worker_id: UUID,
    job_id: UUID | None = None,
    *,
    cancel_phase: int = 0,
    max_attempts: int = 3,
    retry_kind: str = "transient",
    attempt: int = 1,
    cancel_requested_at: datetime | None = None,
    lock_expires_at: datetime | None = None,
    schedule_to_close: datetime | None = None,
    with_events: bool = True,
) -> UUID
Source code in src/taskq/testing/pg.py
async def create_running_job(
    conn: _Conn,
    schema: str,
    worker_id: UUID,
    job_id: UUID | None = None,
    *,
    cancel_phase: int = 0,
    max_attempts: int = 3,
    retry_kind: str = "transient",
    attempt: int = 1,
    cancel_requested_at: datetime | None = None,
    lock_expires_at: datetime | None = None,
    schedule_to_close: datetime | None = None,
    with_events: bool = True,
) -> UUID:
    if not _IDENT_RE.match(schema):
        raise ValueError(f"invalid schema name {schema!r}")
    job_id = job_id or new_uuid()
    expires_at = lock_expires_at or (datetime.now(UTC) + timedelta(seconds=60))
    now = datetime.now(UTC)
    await conn.execute(
        f"""INSERT INTO "{schema}".jobs (
            id, actor, queue, payload, max_attempts, retry_kind,
            status, priority, attempt, claim_epoch, scheduled_at,
            locked_by_worker, lock_expires_at, started_at, last_heartbeat_at,
            cancel_phase, cancel_requested_at, schedule_to_close
        ) VALUES (
            $1, $2, $3, $4::jsonb, $5, $6,
            'running', 0, $7, $13, clock_timestamp(),
            $8, $9, clock_timestamp(), clock_timestamp(),
            $10, $11, $12
        )""",  # noqa: S608
        job_id,
        "test_actor",
        "default",
        '{"key": "value"}',
        max_attempts,
        retry_kind,
        attempt,
        worker_id,
        expires_at,
        cancel_phase,
        cancel_requested_at,
        schedule_to_close,
        # The epoch seed models the refund-free history this helper
        # represents: a row that reached attempt N through N claims, each
        # of which stamped attempt and claim_epoch together, so the row's
        # epoch is N and the fences under test are satisfiable by
        # presenting the row's own values. attempt and claim_epoch are
        # distinct counters that DO diverge in production (the attempt
        # saturates at the smallint ceiling and non-terminal releases
        # refund it; the epoch only ever increments, so the row's epoch is
        # always >= its attempt); the equality fences treat a diverged row
        # identically, and a test that needs a diverged shape seeds the
        # two columns separately.
        attempt,
    )
    if with_events:
        detail = dumps_str(
            {"from_state": "pending", "to_state": "running", "worker_id": str(worker_id)}
        )
        await conn.execute(
            f'INSERT INTO "{schema}".job_events (job_id, occurred_at, kind, detail) '  # noqa: S608
            "VALUES ($1, $2, 'state_change', $3::jsonb)",
            job_id,
            now,
            detail,
        )
    return job_id

create_workered_running_job async

create_workered_running_job(
    conn: _Conn,
    schema: str,
    *,
    worker_id: UUID | None = None,
    **job_kwargs: Any,
) -> tuple[UUID, UUID]

Create a worker row and a running job row, returning (worker_id, job_id).

Passthrough wrapper: creates a worker (generating a UUID if none provided), then creates a running job belonging to that worker. All extra keyword arguments are forwarded to :func:create_running_job.

Source code in src/taskq/testing/pg.py
async def create_workered_running_job(
    conn: _Conn,
    schema: str,
    *,
    worker_id: UUID | None = None,
    **job_kwargs: Any,
) -> tuple[UUID, UUID]:
    """Create a worker row and a running job row, returning ``(worker_id, job_id)``.

    Passthrough wrapper: creates a worker (generating a UUID if none provided),
    then creates a running job belonging to that worker.  All extra keyword
    arguments are forwarded to :func:`create_running_job`.
    """
    wid = worker_id or new_uuid()
    await _create_worker(conn, schema, wid)
    jid = await create_running_job(conn, schema, wid, **job_kwargs)
    return wid, jid

get_job_triple async

get_job_triple(
    conn: _Conn, schema: str, job_id: UUID
) -> JobTriple
Source code in src/taskq/testing/pg.py
async def get_job_triple(conn: _Conn, schema: str, job_id: UUID) -> JobTriple:
    if not _IDENT_RE.match(schema):
        raise ValueError(f"invalid schema name {schema!r}")
    row = await conn.fetchrow(
        f'SELECT * FROM "{schema}".jobs WHERE id = $1',  # noqa: S608
        job_id,
    )
    assert row is not None
    attempts = await conn.fetch(
        f'SELECT * FROM "{schema}".job_attempts WHERE job_id = $1',  # noqa: S608
        job_id,
    )
    events = await conn.fetch(
        f'SELECT * FROM "{schema}".job_events WHERE job_id = $1 ORDER BY occurred_at',  # noqa: S608
        job_id,
    )
    return JobTriple(row=row, attempts=list(attempts), events=list(events))

reset_schema async

reset_schema(
    conn: _Conn,
    schema: str,
    *,
    actors: Sequence[str] | None = None,
) -> None

Truncate all dynamic tables then seed default actor_config rows.

Tests needing a custom actor set can pass actors=[...]; tests that need an empty actor_config can pass actors=[].

Source code in src/taskq/testing/pg.py
async def reset_schema(
    conn: _Conn,
    schema: str,
    *,
    actors: Sequence[str] | None = None,
) -> None:
    """Truncate all dynamic tables then seed default actor_config rows.

    Tests needing a custom actor set can pass ``actors=[...]``;
    tests that need an empty actor_config can pass ``actors=[]``.
    """
    await truncate_schema(conn, schema)
    await seed_actors(conn, schema, actors=actors)

seed_actors async

seed_actors(
    conn: _Conn,
    schema: str,
    *,
    actors: Sequence[str] | None = None,
) -> None

Insert actor_config rows for the given actors (or DEFAULT_ACTORS).

ON CONFLICT (actor) DO NOTHING makes this safe to call alongside custom seed data, it never overwrites existing rows.

Source code in src/taskq/testing/pg.py
async def seed_actors(
    conn: _Conn,
    schema: str,
    *,
    actors: Sequence[str] | None = None,
) -> None:
    """Insert actor_config rows for the given actors (or DEFAULT_ACTORS).

    ``ON CONFLICT (actor) DO NOTHING`` makes this safe to call
    alongside custom seed data, it never overwrites existing rows.
    """
    if not _IDENT_RE.match(schema):
        raise ValueError(f"invalid schema name {schema!r}")
    target = actors if actors is not None else DEFAULT_ACTORS
    await conn.executemany(
        f'INSERT INTO "{schema}".actor_config (actor, queue) VALUES ($1, $2) ON CONFLICT (actor) DO NOTHING',  # noqa: S608
        [(actor, "default") for actor in target],
    )

setup_running_job async

setup_running_job(
    conn: _Conn,
    schema: str,
    *,
    worker_id: UUID | None = None,
    job_id: UUID | None = None,
    attempt: int = 1,
    max_attempts: int = 3,
    retry_kind: str = "transient",
    cancel_phase: int = 0,
    cancel_requested_at: datetime | None = None,
    lock_expires_at: datetime | None = None,
    schedule_to_close: datetime | None = None,
    with_events: bool = True,
) -> tuple[UUID, UUID]

Create a worker row and a running job row in one call.

Returns (worker_id, job_id). Delegates to :func:create_workered_running_job.

Source code in src/taskq/testing/pg.py
async def setup_running_job(
    conn: _Conn,
    schema: str,
    *,
    worker_id: UUID | None = None,
    job_id: UUID | None = None,
    attempt: int = 1,
    max_attempts: int = 3,
    retry_kind: str = "transient",
    cancel_phase: int = 0,
    cancel_requested_at: datetime | None = None,
    lock_expires_at: datetime | None = None,
    schedule_to_close: datetime | None = None,
    with_events: bool = True,
) -> tuple[UUID, UUID]:
    """Create a worker row and a running job row in one call.

    Returns ``(worker_id, job_id)``.  Delegates to
    :func:`create_workered_running_job`.
    """
    return await create_workered_running_job(
        conn,
        schema,
        worker_id=worker_id,
        job_id=job_id,
        cancel_phase=cancel_phase,
        max_attempts=max_attempts,
        retry_kind=retry_kind,
        attempt=attempt,
        cancel_requested_at=cancel_requested_at,
        lock_expires_at=lock_expires_at,
        schedule_to_close=schedule_to_close,
        with_events=with_events,
    )

truncate_schema async

truncate_schema(conn: _Conn, schema: str) -> None

Truncate all dynamic tables in FK-safe order using CASCADE, and drop triggers a previous test added to them.

Leaves schema_migrations intact. Safe to call repeatedly.

Source code in src/taskq/testing/pg.py
async def truncate_schema(conn: _Conn, schema: str) -> None:
    """Truncate all dynamic tables in FK-safe order using CASCADE, and drop
    triggers a previous test added to them.

    Leaves ``schema_migrations`` intact.  Safe to call repeatedly.
    """
    if not _IDENT_RE.match(schema):
        raise ValueError(f"invalid schema name {schema!r}")
    for table in _TRUNCATE_TABLES:
        await conn.execute(f'TRUNCATE TABLE "{schema}"."{table}" CASCADE')
    await _reset_triggers(conn, schema)

make_integration_settings

make_integration_settings(
    pg_dsn: str, **overrides: str
) -> WorkerSettings

Construct WorkerSettings with fast intervals for integration tests.

The schema defaults to a per-call unique tq_<token> name (see module docstring); pass schema_name="..." to pin it.

Source code in src/taskq/testing/settings.py
def make_integration_settings(pg_dsn: str, **overrides: str) -> WorkerSettings:
    """Construct WorkerSettings with fast intervals for integration tests.

    The schema defaults to a per-call unique ``tq_<token>`` name (see module
    docstring); pass ``schema_name="..."`` to pin it.
    """
    return WorkerSettings.load_from_dict(_build_dict(pg_dsn, **overrides))

make_integration_settings_dict

make_integration_settings_dict(
    pg_dsn: str, **overrides: str
) -> dict[str, str]

Return the raw dict passed to WorkerSettings.load_from_dict.

Same per-call unique schema default as :func:make_integration_settings.

Source code in src/taskq/testing/settings.py
def make_integration_settings_dict(pg_dsn: str, **overrides: str) -> dict[str, str]:
    """Return the raw dict passed to WorkerSettings.load_from_dict.

    Same per-call unique schema default as :func:`make_integration_settings`.
    """
    return _build_dict(pg_dsn, **overrides)

FakeClock home: taskq.clock

clock

FakeClock, deterministic time control for tests.

Canonical home: taskq.clock. This module is production code — it imports only datetime and nothing from taskq.testing — so import taskq (which re-exports FakeClock from here) never pulls in the test-double backend under taskq.testing.

Clock and SystemClock remain in taskq.backend.clock (production); taskq.testing.clock re-exports FakeClock from here for backcompat.

__all__ module-attribute

__all__ = ['FakeClock']

FakeClock

FakeClock(start: datetime)

Deterministic clock for tests.

Accepts a start datetime (typically datetime(2025, 1, 1, tzinfo=UTC)). now() returns the current internal time; move_to and advance let tests control the clock explicitly. monotonic() returns elapsed seconds from _EPOCH so that elapsed-time guards see a non-zero starting value.

Source code in src/taskq/clock.py
def __init__(self, start: datetime) -> None:
    self._now = start

now

now() -> datetime
Source code in src/taskq/clock.py
def now(self) -> datetime:
    return self._now

move_to

move_to(when: datetime) -> None

Set the clock to when.

Source code in src/taskq/clock.py
def move_to(self, when: datetime) -> None:
    """Set the clock to *when*."""
    self._now = when

advance

advance(delta: timedelta) -> None

Add delta to the clock.

Source code in src/taskq/clock.py
def advance(self, delta: timedelta) -> None:
    """Add *delta* to the clock."""
    self._now = self._now + delta

monotonic

monotonic() -> float

Elapsed seconds since _EPOCH, consistent with now().

Same wall-clock position always returns the same float; never decreases within a test.

Source code in src/taskq/clock.py
def monotonic(self) -> float:
    """Elapsed seconds since ``_EPOCH``, consistent with ``now()``.

    Same wall-clock position always returns the same float; never
    decreases within a test.
    """
    return (self._now - _EPOCH).total_seconds()

Backcompat shim: taskq.testing.clock

clock

Backcompat shim: re-export FakeClock from its canonical home.

FakeClock lives in :mod:taskq.clock (production, dependency-free — imports only datetime) so import taskq never pulls in taskq.testing and its test-double backend. Import either way:

from taskq.clock import FakeClock            # canonical
from taskq.testing.clock import FakeClock    # backcompat shim

__all__ module-attribute

__all__ = ['FakeClock']

FakeClock

FakeClock(start: datetime)

Deterministic clock for tests.

Accepts a start datetime (typically datetime(2025, 1, 1, tzinfo=UTC)). now() returns the current internal time; move_to and advance let tests control the clock explicitly. monotonic() returns elapsed seconds from _EPOCH so that elapsed-time guards see a non-zero starting value.

Source code in src/taskq/clock.py
def __init__(self, start: datetime) -> None:
    self._now = start

now

now() -> datetime
Source code in src/taskq/clock.py
def now(self) -> datetime:
    return self._now

move_to

move_to(when: datetime) -> None

Set the clock to when.

Source code in src/taskq/clock.py
def move_to(self, when: datetime) -> None:
    """Set the clock to *when*."""
    self._now = when

advance

advance(delta: timedelta) -> None

Add delta to the clock.

Source code in src/taskq/clock.py
def advance(self, delta: timedelta) -> None:
    """Add *delta* to the clock."""
    self._now = self._now + delta

monotonic

monotonic() -> float

Elapsed seconds since _EPOCH, consistent with now().

Same wall-clock position always returns the same float; never decreases within a test.

Source code in src/taskq/clock.py
def monotonic(self) -> float:
    """Elapsed seconds since ``_EPOCH``, consistent with ``now()``.

    Same wall-clock position always returns the same float; never
    decreases within a test.
    """
    return (self._now - _EPOCH).total_seconds()

Pytest fixtures

The fixtures are not re-exported from taskq.testing.__init__ (importing pytest/asyncpg at the package top level is deliberately avoided), so they render from their defining module. The module defines 17 fixtures (sync via pytest.fixture, async via pytest_asyncio.fixture) plus the run_isolation_token helper and RUN_TOKEN_ENV_VAR used to isolate per-run/per-worker database and schema names, and the RedisContainerLike protocol that redis_url_for reads a running (or killable) container as:

fixtures

__all__ module-attribute

__all__ = [
    "RUN_TOKEN_ENV_VAR",
    "TEST_REDIS_CONNECT_TIMEOUT_SECS",
    "ActorRunnerCallable",
    "JobsApp",
    "ModulePgSchema",
    "RedisContainerLike",
    "actor_runner",
    "backend_pair",
    "clean_jobs_app",
    "clean_pg_conn",
    "clean_redis_client",
    "clean_redis_url",
    "jobs_app",
    "killable_redis_container",
    "memory_jobs",
    "module_jobs_app",
    "module_pg_pool",
    "module_pg_schema",
    "module_redis_url",
    "private_redis_url",
    "redis_container",
    "redis_url",
    "redis_url_for",
    "run_isolation_token",
    "worker_with_running_job",
]

RUN_TOKEN_ENV_VAR module-attribute

RUN_TOKEN_ENV_VAR = 'TASKQ_TEST_RUN_TOKEN'

TEST_REDIS_CONNECT_TIMEOUT_SECS module-attribute

TEST_REDIS_CONNECT_TIMEOUT_SECS: float = 30.0

Connect budget for every fixture client that dials a test broker.

redis-py 8 defaults socket_connect_timeout to 5s. On a loaded runner that budget is a red band, not a safety net: the testcontainer's address resolution (getaddrinfo, in the loop's executor) can out-run 5s and the first command dies with Timeout connecting to server — the rotating one-test-in-~12.7k red (PR #682's 3.13 leg). The connect phase is setup weather, never a pin's subject — the pins' own bounds are the tests' overall_timeouts and the suite-wide pytest-timeout — so every fixture client dials with this budget instead: the tree's outside-system bound (:data:~taskq.client._transport.POLL_FAILURE_BUDGET_SECS, the reload factory timeout, the notify reconnect ceiling — all 30).

JobsApp

Bases: NamedTuple

deps instance-attribute

deps: object

backend instance-attribute

backend: object

ModulePgSchema

Bases: NamedTuple

schema_name instance-attribute

schema_name: str

pg_dsn instance-attribute

pg_dsn: str

ActorRunnerCallable

Bases: Protocol

Protocol for the callable yielded by the actor_runner fixture.

actor_fn accepts a bare handler function or an :class:~taskq.actor.ActorRef (the decorated handle); the runner omits the ctx argument when the handler does not declare one, mirroring the production dispatch decision (worker/dispatch.py reads actor_ref.wants_ctx).

payload is permissively typed: a :class:pydantic.BaseModel (typed actor payload) or a dict[str, object] / object that the runner wraps in a :class:PassthroughPayload model. The JobContext.payload handed to the actor is always a :class:pydantic.BaseModel per the locked architecture; the coercion happens here so test authors don't have to declare a model for ad-hoc payloads.

__call__ async

__call__(
    actor_fn: Callable[..., object] | ActorRef[Any, Any],
    payload: BaseModel | dict[str, object] | object,
    *,
    backend: InMemoryBackend,
    job_id: JobId | UUID | None = ...,
    attempt: int = ...,
    claim_epoch: int = ...,
    snooze_count: int = ...,
    cancel_event: Event | None = ...,
    actor: str = ...,
    queue: str = ...,
    **deps: object,
) -> object
Source code in src/taskq/testing/fixtures.py
async def __call__(
    self,
    actor_fn: Callable[..., object] | ActorRef[Any, Any],
    payload: BaseModel | dict[str, object] | object,
    *,
    backend: InMemoryBackend,
    job_id: JobId | UUID | None = ...,
    attempt: int = ...,
    claim_epoch: int = ...,
    snooze_count: int = ...,
    cancel_event: asyncio.Event | None = ...,
    actor: str = ...,
    queue: str = ...,
    **deps: object,
) -> object: ...

RedisContainerLike

Bases: Protocol

The RedisContainer surface redis_url_for needs, structural, so both the real testcontainers object (killable_redis_container) and the shared-pair shim (redis_container) satisfy it.

get_container_host_ip

get_container_host_ip() -> str
Source code in src/taskq/testing/fixtures.py
def get_container_host_ip(self) -> str: ...

get_exposed_port

get_exposed_port(port: int) -> int
Source code in src/taskq/testing/fixtures.py
def get_exposed_port(self, port: int) -> int: ...

memory_jobs async

memory_jobs() -> AsyncIterator[InMemoryBackend]

Yield a fresh InMemoryBackend with a FakeClock starting at datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC). Default cancellation and cleanup grace from InMemoryBackend.__init__ (30s each). No teardown beyond GC (fully isolated per fixture instance, each call constructs a new backend and clock; nothing is shared at module level).

Source code in src/taskq/testing/fixtures.py
@pytest_asyncio.fixture
async def memory_jobs() -> AsyncIterator[InMemoryBackend]:
    """Yield a fresh ``InMemoryBackend`` with a ``FakeClock`` starting at
    ``datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)``.  Default cancellation
    and cleanup grace from ``InMemoryBackend.__init__`` (30s each).
    No teardown beyond GC (fully isolated per fixture instance, each call
    constructs a new backend and clock; nothing is shared at module level).
    """
    clock = FakeClock(start=datetime(2025, 1, 1, tzinfo=UTC))
    backend = InMemoryBackend(clock=clock)
    for actor in DEFAULT_ACTORS:
        backend.register_actor_config(actor=actor)
    yield backend

actor_runner

actor_runner() -> ActorRunnerCallable

Yield a callable that constructs a synthetic JobContext and calls actor_fn(payload, ctx), or actor_fn(payload) when the handler declares no ctx parameter (a bare payload-only handler, or an :class:~taskq.actor.ActorRef whose wants_ctx is false; calling a no-ctx actor with a context raises TypeError in production, so the fixture must mirror that contract).

Accepts cancel_event to test cancellation paths and **deps to forward ad-hoc keyword-injected collaborators (e.g. stub HTTP clients or database sessions) directly to actor_fn without wiring the full DI scope hierarchy. Only the dependency parameters the handler declared are injected as keyword arguments (the same selectivity as the production DI pass); the full deps mapping is also available to the actor as ctx.deps.

Source code in src/taskq/testing/fixtures.py
@pytest.fixture
def actor_runner() -> ActorRunnerCallable:
    """Yield a callable that constructs a synthetic ``JobContext`` and
    calls ``actor_fn(payload, ctx)``, or ``actor_fn(payload)`` when the
    handler declares no ``ctx`` parameter (a bare ``payload``-only
    handler, or an :class:`~taskq.actor.ActorRef` whose ``wants_ctx`` is
    false; calling a no-ctx actor with a context raises ``TypeError`` in
    production, so the fixture must mirror that contract).

    Accepts ``cancel_event`` to test cancellation paths and ``**deps``
    to forward ad-hoc keyword-injected collaborators (e.g. stub HTTP
    clients or database sessions) directly to ``actor_fn`` without
    wiring the full DI scope hierarchy. Only the dependency parameters
    the handler declared are injected as keyword arguments (the same
    selectivity as the production DI pass); the full ``deps`` mapping is
    also available to the actor as ``ctx.deps``.
    """

    async def run_actor(
        actor_fn: Callable[..., object] | ActorRef[Any, Any],
        payload: BaseModel | dict[str, object] | object,
        *,
        backend: InMemoryBackend,
        job_id: JobId | UUID | None = None,
        attempt: int = 1,
        # The same one-claim default attempt carries: a fresh dispatch
        # hands the actor attempt 1 and epoch 1, so the context's epoch
        # fence is satisfiable against a row the fixture's caller just
        # dispatched. A caller that overrides either value must keep the
        # pair consistent with the row it dispatched (fences compare the
        # epoch for equality; a stale one no-ops the terminal write).
        claim_epoch: int = 1,
        snooze_count: int = 0,
        cancel_event: asyncio.Event | None = None,
        actor: str = "test_actor",
        queue: str = "default",
        **deps: object,
    ) -> object:
        jid: JobId = JobId(job_id) if job_id is not None else new_job_id()
        evt = cancel_event or asyncio.Event()
        backend.register_cancel_event(jid, evt)

        # Coerce arbitrary payload shapes into BaseModel, the production
        # JobContext requires P: BaseModel. Real test payloads (BaseModel
        # instances) pass through; raw dicts and other shapes get wrapped
        # in the permissive PassthroughPayload (extra="allow").
        ctx_payload: BaseModel
        if isinstance(payload, BaseModel):
            ctx_payload = payload
        elif isinstance(payload, dict):
            ctx_payload = PassthroughPayload.model_validate(payload)
        else:
            ctx_payload = PassthroughPayload.model_validate({"value": payload})

        ctx: JobContext[BaseModel] = JobContext(
            job_id=jid,
            actor=actor,
            queue=queue,
            attempt=attempt,
            claim_epoch=claim_epoch,
            snooze_count=snooze_count,
            payload=ctx_payload,
            cancel_event=evt,
            worker_id=backend._worker_id,  # type: ignore[reportPrivateUsage]  # Why: fixture is an owned helper; _worker_id is private to InMemoryBackend but readable here for JobContext construction
            # Why a marker for worker_pool: SubJobEnqueuer only None-checks
            # the pool (the gate on its autonomous fallback arm, which
            # writes through the backend and never dereferences it); the
            # in-memory backend has no asyncpg pool, and without the
            # marker every ctx.jobs.enqueue would raise "ctx.jobs is only
            # available inside an actor body".
            jobs=SubJobEnqueuer(
                loop_scope_resolved=None,
                worker_pool=object(),  # type: ignore[arg-type]  # Why: SubJobEnqueuer only None-checks the pool, it gates its autonomous fallback arm which writes through the backend and never dereferences it; the in-memory backend has no asyncpg pool.
                backend=backend,
            ),
            log=bind_job_context(
                structlog.get_logger("taskq.testing.actor_runner"),
                job_id=jid,
                actor=actor,
                queue=queue,
                attempt=attempt,
                identity_key=None,
                trace_id="",
            ),
            deps=deps if deps else None,
        )

        wants_ctx, declared_deps = _handler_call_shape(actor_fn)
        call_deps = (
            {name: deps[name] for name in declared_deps if name in deps}
            if declared_deps is not None
            else {}
        )
        # An ActorRef's handler declares a typed payload model, so it is
        # handed the coerced BaseModel the production worker would pass;
        # a bare test function keeps the historic raw-payload shape.
        ref_like = isinstance(getattr(actor_fn, "wants_ctx", None), bool)
        # Why the cast: the earlier payload-shape narrowing leaves the
        # parameter's residual union partially unknown (dict[Unknown]);
        # the fixture only needs the top type here.
        payload_arg: object = cast("object", ctx_payload if ref_like else payload)
        if wants_ctx:
            # Why the ignore: an ActorRef's __call__ declares
            # JobContext[Any] for the ctx parameter and that generic is
            # invariant, so the fixture's JobContext[BaseModel] cannot be
            # proven assignable even though the runtime contract is exact.
            result: object = actor_fn(payload_arg, ctx, **call_deps)  # type: ignore[arg-type]  # Why: see comment above.
        else:
            result = actor_fn(payload_arg, **call_deps)
        if isinstance(result, Awaitable):
            result = await cast(Awaitable[object], result)
        return result

    return run_actor

jobs_app async

jobs_app(
    pg_dsn: str, request: FixtureRequest
) -> AsyncIterator[JobsApp]

Yield a :class:JobsApp named tuple (deps, backend) against the database named by the consumer-provided pg_dsn fixture.

Access the fields as jobs_app.deps and jobs_app.backend instead of unpacking, the named-tuple interface is clearer and type-safe.

Per-test isolation: drops the schema CASCADE before each test (schema name is hashed from the test's own node id via :func:_schema_name_from_test, so distinct tests never share a schema), applies migrations, opens pools, constructs the backend. Teardown via AsyncExitStack unwind closes pools; the schema is dropped at the next invocation's setup for the same test, same pattern as pg_conn.

Source code in src/taskq/testing/fixtures.py
@pytest_asyncio.fixture
async def jobs_app(pg_dsn: str, request: pytest.FixtureRequest) -> AsyncIterator[JobsApp]:
    """Yield a :class:`JobsApp` named tuple ``(deps, backend)`` against the
    database named by the consumer-provided ``pg_dsn`` fixture.

    Access the fields as ``jobs_app.deps`` and ``jobs_app.backend`` instead
    of unpacking, the named-tuple interface is clearer and type-safe.

    Per-test isolation: drops the schema CASCADE before each test (schema
    name is hashed from the test's own node id via
    :func:`_schema_name_from_test`, so distinct tests never share a schema),
    applies migrations, opens pools, constructs the backend.  Teardown via
    ``AsyncExitStack`` unwind closes pools; the schema is dropped at the
    next invocation's setup for the same test, same pattern as ``pg_conn``.
    """
    stack, deps, backend = await _open_pg_backend(
        pg_dsn, schema_name=_schema_name_from_test(request)
    )
    try:
        yield JobsApp(deps=deps, backend=backend)
    finally:
        await stack.aclose()

backend_pair async

backend_pair(
    request: FixtureRequest,
) -> AsyncIterator[Backend]

Yield a single Backend instance per parametrize id.

  • memory: same construction as memory_jobs.
  • pg: requires a consumer-provided pg_dsn fixture (see the module docstring) plus migrations; reuses the same settings / migration sequence via :func:_open_pg_backend. Returns the PostgresBackend instance.

Tests using this fixture must be marked @pytest.mark.integration so the PG branch does not run in the unit tier. The guard below enforces this: the pg param is automatically skipped when the test lacks @pytest.mark.integration, preventing testcontainers from booting during unit-only runs.

pg_dsn is resolved lazily via request.getfixturevalue only inside the pg branch (after the integration-marker check) so the memory variant never triggers the pg_dsn fixture chain and stays container-free.

Source code in src/taskq/testing/fixtures.py
@pytest_asyncio.fixture(params=["memory", "pg"], ids=["memory", "pg"])
async def backend_pair(request: pytest.FixtureRequest) -> AsyncIterator[Backend]:
    """Yield a single ``Backend`` instance per parametrize id.

    - ``memory``: same construction as ``memory_jobs``.
    - ``pg``: requires a consumer-provided ``pg_dsn`` fixture (see the
      module docstring) plus migrations; reuses the same settings /
      migration sequence via :func:`_open_pg_backend`.  Returns the
      ``PostgresBackend`` instance.

    Tests using this fixture must be marked ``@pytest.mark.integration``
    so the PG branch does not run in the unit tier.  The guard below
    enforces this: the ``pg`` param is automatically skipped when the
    test lacks ``@pytest.mark.integration``, preventing testcontainers
    from booting during unit-only runs.

    ``pg_dsn`` is resolved lazily via ``request.getfixturevalue`` only
    inside the ``pg`` branch (after the integration-marker check) so
    the ``memory`` variant never triggers the ``pg_dsn`` fixture
    chain and stays container-free.
    """
    if request.param == "memory":
        clock = FakeClock(start=datetime(2025, 1, 1, tzinfo=UTC))
        backend: Backend = InMemoryBackend(clock=clock)
        for actor in DEFAULT_ACTORS:
            backend.register_actor_config(actor=actor)
        yield backend
    else:
        if not request.node.get_closest_marker("integration"):
            pytest.skip("PG backend requires @pytest.mark.integration")
        pg_dsn: str = request.getfixturevalue("pg_dsn")
        stack, _deps, pg_backend = await _open_pg_backend(
            pg_dsn, schema_name=_schema_name_from_test(request)
        )
        try:
            yield pg_backend
        finally:
            await stack.aclose()

redis_container

redis_container(
    tmp_path_factory: TempPathFactory,
) -> Iterator[_RedisContainerShim]

One shared Dragonfly (Redis-compatible) container per pytest invocation , every xdist worker of it, and no other invocation of any repo.

Backs onto :func:taskq.testing._shared_containers.shared_service_pair (file lock + per-invocation holder registry under the invocation's state dir, see :func:taskq.testing._shared_containers.invocation_state_dir): the first worker to take the lock boots the pair (--dbnum 1024 so every consumer , module or test function, across ALL workers of the invocation, gets its own logical DB; sharing would let one consumer's FLUSHDB wipe another's mid-run state); later workers reuse it; the last worker to finish removes it. The shim exposes get_container_host_ip / get_exposed_port so redis_url_for(redis_container, db) keeps working unchanged. Skips with a reason (never errors) when the Docker daemon is unreachable.

Source code in src/taskq/testing/fixtures.py
@pytest.fixture(scope="session")
def redis_container(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_RedisContainerShim]:
    """One shared Dragonfly (Redis-compatible) container per pytest invocation ,
    every xdist worker of it, and no other invocation of any repo.

    Backs onto :func:`taskq.testing._shared_containers.shared_service_pair` (file
    lock + per-invocation holder registry under the invocation's state dir, see
    :func:`taskq.testing._shared_containers.invocation_state_dir`): the first
    worker to take the lock boots the pair (``--dbnum 1024`` so every consumer ,
    module or test function, across ALL workers of the invocation, gets its own
    logical DB; sharing would let one consumer's FLUSHDB wipe another's mid-run
    state); later workers reuse it; the last worker to finish removes it. The
    shim exposes ``get_container_host_ip`` / ``get_exposed_port`` so
    ``redis_url_for(redis_container, db)`` keeps working unchanged. Skips
    with a reason (never errors) when the Docker daemon is unreachable.
    """
    skip_test_without_docker()
    state_dir = invocation_state_dir(tmp_path_factory)
    with shared_service_pair(state_dir) as services:
        yield _RedisContainerShim(
            host=services.redis_host, port=services.redis_port, state_dir=state_dir
        )

killable_redis_container

killable_redis_container() -> Iterator[RedisContainer]

Function-scoped disposable Dragonfly for chaos tests that stop/restart Redis.

Chaos tests must NEVER stop the shared container (redis_container) , every worker of the run shares it, and a slow restart leaks failures into unrelated tests. Each kill test gets its own container (~1s boot). The ownership labels (creator_labels) keep a crashed run's leftovers sweepable, Ryuk is disabled process-wide by the shared-container machinery (see :mod:taskq.testing._shared_containers), so labeling is what lets the next run's stale sweep remove these once their owner pids are dead. Skips with a reason (never errors) when the Docker daemon is unreachable.

Source code in src/taskq/testing/fixtures.py
@pytest.fixture
def killable_redis_container() -> Iterator[RedisContainer]:
    """Function-scoped disposable Dragonfly for chaos tests that stop/restart Redis.

    Chaos tests must NEVER stop the shared container (``redis_container``) ,
    every worker of the run shares it, and a slow restart leaks failures into
    unrelated tests. Each kill test gets its own container (~1s boot). The
    ownership labels (``creator_labels``) keep a crashed run's leftovers
    sweepable, Ryuk is disabled process-wide by the shared-container machinery
    (see :mod:`taskq.testing._shared_containers`), so labeling is what lets the
    next run's stale sweep remove these once their owner pids are dead.
    Skips with a reason (never errors) when the Docker daemon is unreachable.
    """
    skip_test_without_docker()
    from testcontainers.community.redis import RedisContainer

    with (
        RedisContainer(image=DRAGONFLY_IMAGE)
        .with_command(DRAGONFLY_RESOURCE_FLAGS)
        .with_kwargs(labels=creator_labels())
    ) as rc:
        yield rc

redis_url_for

redis_url_for(
    container: RedisContainerLike, db: int = 0
) -> str

Build a redis://host:port/{db} URL for container.

Accepts anything with the RedisContainer host/port surface, the real testcontainers object or the shared-pair shim.

Source code in src/taskq/testing/fixtures.py
def redis_url_for(container: RedisContainerLike, db: int = 0) -> str:
    """Build a ``redis://host:port/{db}`` URL for *container*.

    Accepts anything with the ``RedisContainer`` host/port surface, the real
    testcontainers object or the shared-pair shim.
    """
    host = container.get_container_host_ip()
    port = container.get_exposed_port(6379)
    return f"redis://{host}:{port}/{db}"

redis_url

redis_url(redis_container: _RedisContainerShim) -> str

Per-test Redis URL with a UNIQUE logical DB, a guaranteed clean slate.

Each test function gets its own DB (never reused within the invocation), so no setup/teardown flushing is needed and no two tests can observe each other's keys. DB indices are drawn from the invocation's counter (see next_redis_logical_db) so consumers on different xdist workers can never collide inside the ONE shared Dragonfly.

Tenancy contract (what MAY run here, and what MUST NOT)

redis_url (and the SHARED Dragonfly behind it) is one broker for every redis-marked test of the invocation across all xdist workers. A test may keep it as its broker only if ALL of these hold:

  • every command is scoped to the test's own logical DB (get/set/eval/ subscribe on unique keys and channels) — no broker-GLOBAL command (CLIENT PAUSE/CLIENT KILL/CONFIG/FLUSHALL/restart), which hits every co-tenant's connection at once;
  • no assertion measures broker latency (a PING budget, a fanout deadline) — a co-tenant's stall band lands on the shared broker and reds the assertion without any defect in the code under test;
  • no assertion depends on wall-clock boundary timing measured across broker round trips — the same stall band shifts the boundary.

Everything else (chaos kills/restarts, CLIENT PAUSE scenarios, ping assertions, pub/sub fanout- and boundary-timing pins) takes :func:private_redis_url — a per-test disposable Dragonfly, ~1s boot, the same image and flags as the shared pair. The historical record: every cross/extras-leg "weather" red of this class (the subscriber wake, the ping pins, the attack478 CLIENT PAUSE) was the shared broker's co-tenancy, and every cure that patched ONE test's budget found the NEXT exposed test in the following run. The durable cure is tenancy discipline, not budget inflation.

Source code in src/taskq/testing/fixtures.py
@pytest.fixture
def redis_url(redis_container: _RedisContainerShim) -> str:
    """Per-test Redis URL with a UNIQUE logical DB, a guaranteed clean slate.

    Each test function gets its own DB (never reused within the invocation), so no
    setup/teardown flushing is needed and no two tests can observe each other's
    keys. DB indices are drawn from the invocation's counter (see
    ``next_redis_logical_db``) so consumers on different xdist workers can never
    collide inside the ONE shared Dragonfly.

    Tenancy contract (what MAY run here, and what MUST NOT)
    -------------------------------------------------------
    ``redis_url`` (and the SHARED Dragonfly behind it) is one broker for every
    redis-marked test of the invocation across all xdist workers. A test may
    keep it as its broker only if ALL of these hold:

    * every command is scoped to the test's own logical DB (get/set/eval/
      subscribe on unique keys and channels) — no broker-GLOBAL command
      (``CLIENT PAUSE``/``CLIENT KILL``/``CONFIG``/``FLUSHALL``/restart), which
      hits every co-tenant's connection at once;
    * no assertion measures broker latency (a PING budget, a fanout deadline) —
      a co-tenant's stall band lands on the shared broker and reds the
      assertion without any defect in the code under test;
    * no assertion depends on wall-clock boundary timing measured across
      broker round trips — the same stall band shifts the boundary.

    Everything else (chaos kills/restarts, ``CLIENT PAUSE`` scenarios, ping
    assertions, pub/sub fanout- and boundary-timing pins) takes
    :func:`private_redis_url` — a per-test disposable Dragonfly, ~1s boot, the
    same image and flags as the shared pair. The historical record: every
    cross/extras-leg "weather" red of this class (the subscriber wake, the
    ping pins, the attack478 ``CLIENT PAUSE``) was the shared broker's
    co-tenancy, and every cure that patched ONE test's budget found the NEXT
    exposed test in the following run. The durable cure is tenancy
    discipline, not budget inflation.
    """
    return redis_url_for(redis_container, db=_next_redis_db(redis_container))

private_redis_url

private_redis_url(
    killable_redis_container: RedisContainer,
) -> str

Per-test PRIVATE Dragonfly URL — for every test the shared pair must not host.

This is the killable_redis_container + redis_url_for idiom as a drop-in URL fixture: one function-scoped container (creator_labels() ownership tags, same image/flags as the shared pair, ~1s boot), DB 0, and no co-tenants — not even a sibling test on another xdist worker. A test on private_redis_url may pause it, kill it, time its replies, and assert fanout deadlines: whatever it does to this broker, it does to NOBODY else, and a stall band on the shared pair cannot reach its assertions.

Consumers: the CLIENT PAUSE conservation attacks, the ping-budget pins, the pub/sub fanout-timing pins, the skew-proxy crossings, and the wall-clock boundary pins — see the tenancy contract on :func:redis_url for the line between the two brokers.

Source code in src/taskq/testing/fixtures.py
@pytest.fixture
def private_redis_url(killable_redis_container: RedisContainer) -> str:
    """Per-test PRIVATE Dragonfly URL — for every test the shared pair must not host.

    This is the ``killable_redis_container`` + ``redis_url_for`` idiom as a
    drop-in URL fixture: one function-scoped container (``creator_labels()``
    ownership tags, same image/flags as the shared pair, ~1s boot), DB 0, and
    no co-tenants — not even a sibling test on another xdist worker. A test on
    ``private_redis_url`` may pause it, kill it, time its replies, and assert
    fanout deadlines: whatever it does to this broker, it does to NOBODY else,
    and a stall band on the shared pair cannot reach its assertions.

    Consumers: the ``CLIENT PAUSE`` conservation attacks, the ping-budget
    pins, the pub/sub fanout-timing pins, the skew-proxy crossings, and the
    wall-clock boundary pins — see the tenancy contract on :func:`redis_url`
    for the line between the two brokers.
    """
    return redis_url_for(killable_redis_container, db=0)

run_isolation_token

run_isolation_token() -> str

The token mixed into every hashed per-module/per-test database and schema name: the xdist worker id under xdist, the conftest-published per-invocation token in serial runs, master as a last-resort fallback (direct library use outside this repo's conftest).

The token's essential job is WITHIN one invocation: the shared pair serves every xdist worker of it, and two workers hashing the same module or node id must not land the same database or schema on that one pair , each worker's DROP DATABASE ... WITH (FORCE) / DROP SCHEMA ... CASCADE would kill the other's live pools mid-test.

Invocation-uniqueness of the token (the conftest publishes the invocation-unique basetemp dir name, e.g. pytest-41) and the master fallback are defense-in-depth, not the isolation mechanism: the pair itself is per-invocation (:func:taskq.testing._shared_containers.invocation_state_dir), so two invocations' names can never land in one cluster. If that pair isolation ever regressed, per-invocation tokens would still keep the runs' names distinct on whatever they ended up sharing, mutual clobbering could not silently return.

Source code in src/taskq/testing/fixtures.py
def run_isolation_token() -> str:
    """The token mixed into every hashed per-module/per-test database and schema
    name: the xdist worker id under xdist, the conftest-published per-invocation
    token in serial runs, ``master`` as a last-resort fallback (direct library
    use outside this repo's conftest).

    The token's essential job is WITHIN one invocation: the shared pair
    serves every xdist worker of it, and two workers hashing the same module
    or node id must not land the same database or schema on that one pair ,
    each worker's ``DROP DATABASE ... WITH (FORCE)`` / ``DROP SCHEMA ... CASCADE``
    would kill the other's live pools mid-test.

    Invocation-uniqueness of the token (the conftest publishes the
    invocation-unique basetemp dir name, e.g. ``pytest-41``) and the
    ``master`` fallback are defense-in-depth, not the isolation mechanism:
    the pair itself is per-invocation
    (:func:`taskq.testing._shared_containers.invocation_state_dir`), so two
    invocations' names can never land in one cluster. If that pair isolation
    ever regressed, per-invocation tokens would still keep the runs' names
    distinct on whatever they ended up sharing, mutual clobbering could not
    silently return.
    """
    token = os.environ.get(RUN_TOKEN_ENV_VAR)
    if token:
        return token
    return os.environ.get("PYTEST_XDIST_WORKER", "master")

module_pg_schema async

module_pg_schema(
    request: FixtureRequest, pg_dsn: str
) -> AsyncIterator[ModulePgSchema]

Module-scoped PG schema: create once per test file, truncate per test.

Derives a unique schema name from the test module, applies migrations, seeds default actor_config rows. Drops the schema CASCADE on module teardown.

All tests in the module share the same schema; per-function isolation is provided by :func:clean_pg_conn and :func:clean_jobs_app which truncate every table before each test.

Source code in src/taskq/testing/fixtures.py
@pytest_asyncio.fixture(scope="module")
async def module_pg_schema(
    request: pytest.FixtureRequest,
    pg_dsn: str,
) -> AsyncIterator[ModulePgSchema]:
    """Module-scoped PG schema: create once per test file, truncate per test.

    Derives a unique schema name from the test module, applies migrations,
    seeds default actor_config rows.  Drops the schema CASCADE on module
    teardown.

    All tests in the module share the same schema; per-function isolation
    is provided by :func:`clean_pg_conn` and :func:`clean_jobs_app` which
    truncate every table before each test.
    """
    import asyncpg

    from taskq.migrate import apply_pending

    schema_name = _schema_name_from_module(request)
    conn = await asyncpg.connect(pg_dsn)
    try:
        await conn.execute(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE')
        await apply_pending(conn, schema=schema_name)
        await seed_actors(conn, schema_name)
    finally:
        await conn.close()

    yield ModulePgSchema(schema_name=schema_name, pg_dsn=pg_dsn)

    conn = await asyncpg.connect(pg_dsn)
    try:
        await conn.execute(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE')
    finally:
        await conn.close()

module_redis_url

module_redis_url(
    redis_container: _RedisContainerShim,
) -> Iterator[str]

Module-scoped Redis URL with a unique DB per test file.

Assigns a unique Redis DB id (1-1023, never reused, unique across ALL workers of the invocation, see _next_redis_db) and returns redis://host:port/{db}. FLUSHDB at setup (clean slate even after a crashed run) and again on teardown.

Source code in src/taskq/testing/fixtures.py
@pytest.fixture(scope="module")
def module_redis_url(
    redis_container: _RedisContainerShim,
) -> Iterator[str]:
    """Module-scoped Redis URL with a unique DB per test file.

    Assigns a unique Redis DB id (1-1023, never reused, unique across ALL
    workers of the invocation, see ``_next_redis_db``) and returns
    ``redis://host:port/{db}``. FLUSHDB at setup (clean slate even after a
    crashed run) and again on teardown.
    """
    import redis as redis_sync

    url = redis_url_for(redis_container, db=_next_redis_db(redis_container))
    # socket_timeout=None: the setup/teardown flushes open a FRESH
    # connection per module, and redis-py 8's 5s default read budget loses
    # the HELLO handshake race on a co-tenanted runner (the EEE setup-error
    # class on the cross legs). A wedged connection is the suite-wide
    # pytest-timeout's job, not a fixture budget.
    # socket_connect_timeout=TEST_REDIS_CONNECT_TIMEOUT_SECS: redis-py 8's
    # 5s CONNECT default is its own band - on a loaded runner the
    # testcontainer's address resolution (getaddrinfo in the loop's
    # executor) can out-run 5s and the first command dies with
    # ``Timeout connecting to server`` (the red 3.13 leg of PR #682, one
    # test in ~12.7k). The connect phase is setup weather, not a pin's
    # subject: bound it at the tree's outside-system budget.
    with redis_sync.from_url(
        url,
        decode_responses=False,
        socket_timeout=None,
        socket_connect_timeout=TEST_REDIS_CONNECT_TIMEOUT_SECS,
    ) as client:
        client.flushdb()
    yield url

    # Teardown: FLUSHDB the module's DB via sync client (safe in any context).
    with redis_sync.from_url(
        url,
        decode_responses=False,
        socket_timeout=None,
        socket_connect_timeout=TEST_REDIS_CONNECT_TIMEOUT_SECS,
    ) as client:
        client.flushdb()

module_pg_pool async

module_pg_pool(
    module_pg_schema: ModulePgSchema,
) -> AsyncIterator[asyncpg.Pool]

Module-scoped asyncpg pool for the module's PG schema.

Pool is shared by all tests in a module. Per-test isolation is handled by clean_pg_conn/clean_jobs_app which truncate tables between tests.

Source code in src/taskq/testing/fixtures.py
@pytest_asyncio.fixture(scope="module")
async def module_pg_pool(module_pg_schema: ModulePgSchema) -> AsyncIterator[asyncpg.Pool]:
    """Module-scoped asyncpg pool for the module's PG schema.

    Pool is shared by all tests in a module. Per-test isolation is handled
    by clean_pg_conn/clean_jobs_app which truncate tables between tests.
    """
    import asyncpg

    from taskq.connections import (
        DEFAULT_MAX_CACHED_STATEMENT_LIFETIME,
        DEFAULT_STATEMENT_CACHE_SIZE,
    )

    pool = await asyncpg.create_pool(
        module_pg_schema.pg_dsn,
        min_size=1,
        max_size=4,
        statement_cache_size=DEFAULT_STATEMENT_CACHE_SIZE,
        max_cached_statement_lifetime=DEFAULT_MAX_CACHED_STATEMENT_LIFETIME,
    )
    try:
        yield pool
    finally:
        await pool.close()

module_jobs_app async

module_jobs_app(
    module_pg_schema: ModulePgSchema,
) -> AsyncIterator[JobsApp]

Module-scoped JobsApp (WorkerDeps + PostgresBackend) on the module's PG schema.

Pools are opened once per test module. Per-test isolation is handled by clean_jobs_app which truncates tables between tests against the same schema. The backend instance is shared, callers should NOT cache or mutate backend state across test boundaries.

Source code in src/taskq/testing/fixtures.py
@pytest_asyncio.fixture(scope="module")
async def module_jobs_app(module_pg_schema: ModulePgSchema) -> AsyncIterator[JobsApp]:
    """Module-scoped JobsApp (WorkerDeps + PostgresBackend) on the module's PG schema.

    Pools are opened once per test module. Per-test isolation is handled by
    clean_jobs_app which truncates tables between tests against the same schema.
    The backend instance is shared, callers should NOT cache or mutate backend
    state across test boundaries.
    """
    stack, deps, backend = await _open_pg_backend_on_schema(
        module_pg_schema.pg_dsn,
        module_pg_schema.schema_name,
    )
    try:
        yield JobsApp(deps=deps, backend=backend)
    finally:
        await stack.aclose()

clean_pg_conn async

clean_pg_conn(
    module_pg_schema: ModulePgSchema,
) -> AsyncIterator[asyncpg.Connection]

Per-test clean asyncpg connection on the module's PG schema.

Truncates every dynamic table (FK-safe CASCADE) then re-seeds default actor_config rows, guaranteeing a blank slate for each test while keeping migration state intact.

Source code in src/taskq/testing/fixtures.py
@pytest_asyncio.fixture
async def clean_pg_conn(module_pg_schema: ModulePgSchema) -> AsyncIterator[asyncpg.Connection]:
    """Per-test clean asyncpg connection on the module's PG schema.

    Truncates every dynamic table (FK-safe CASCADE) then re-seeds default
    actor_config rows, guaranteeing a blank slate for each test while
    keeping migration state intact.
    """
    import asyncpg as _asyncpg

    conn = await _asyncpg.connect(module_pg_schema.pg_dsn)
    try:
        await reset_schema(conn, module_pg_schema.schema_name)
        yield conn
    finally:
        await conn.close()

worker_with_running_job async

worker_with_running_job(
    clean_pg_conn: Connection,
    module_pg_schema: ModulePgSchema,
) -> AsyncIterator[tuple[UUID, UUID, asyncpg.Connection]]

Per-test clean connection with a pre-created worker + running job.

Yields (worker_id, job_id, conn) where conn is the same asyncpg.Connection provided by :func:clean_pg_conn. The connection lifecycle is managed by clean_pg_conn, this fixture only inserts the worker and job rows.

Source code in src/taskq/testing/fixtures.py
@pytest_asyncio.fixture
async def worker_with_running_job(
    clean_pg_conn: asyncpg.Connection,
    module_pg_schema: ModulePgSchema,
) -> AsyncIterator[tuple[UUID, UUID, asyncpg.Connection]]:
    """Per-test clean connection with a pre-created worker + running job.

    Yields ``(worker_id, job_id, conn)`` where *conn* is the same
    ``asyncpg.Connection`` provided by :func:`clean_pg_conn`.  The
    connection lifecycle is managed by ``clean_pg_conn``, this fixture
    only inserts the worker and job rows.
    """
    wid, jid = await create_workered_running_job(clean_pg_conn, module_pg_schema.schema_name)
    yield wid, jid, clean_pg_conn

clean_jobs_app async

clean_jobs_app(
    module_pg_schema: ModulePgSchema,
) -> AsyncIterator[JobsApp]

Per-test clean JobsApp (WorkerDeps + PostgresBackend) on the module's PG schema.

Truncates + re-seeds before each test, then opens WorkerDeps and constructs a PostgresBackend. Faster than the jobs_app fixture which drops/recreates the schema every test.

Source code in src/taskq/testing/fixtures.py
@pytest_asyncio.fixture
async def clean_jobs_app(module_pg_schema: ModulePgSchema) -> AsyncIterator[JobsApp]:
    """Per-test clean ``JobsApp`` (WorkerDeps + PostgresBackend) on the
    module's PG schema.

    Truncates + re-seeds before each test, then opens WorkerDeps and
    constructs a PostgresBackend.  Faster than the ``jobs_app`` fixture
    which drops/recreates the schema every test.
    """
    import asyncpg

    conn = await asyncpg.connect(module_pg_schema.pg_dsn)
    try:
        await reset_schema(conn, module_pg_schema.schema_name)
    finally:
        await conn.close()

    stack, deps, backend = await _open_pg_backend_on_schema(
        module_pg_schema.pg_dsn,
        module_pg_schema.schema_name,
    )
    try:
        yield JobsApp(deps=deps, backend=backend)
    finally:
        await stack.aclose()

clean_redis_url

clean_redis_url(module_redis_url: str) -> str

Per-test clean Redis URL.

FLUSHDB on the module's Redis DB before each test, guaranteeing no cross-test state within the module. Uses the synchronous redis.Redis client so the flush is reliable regardless of whether tests are sync or async.

Source code in src/taskq/testing/fixtures.py
@pytest.fixture
def clean_redis_url(module_redis_url: str) -> str:
    """Per-test clean Redis URL.

    FLUSHDB on the module's Redis DB before each test, guaranteeing
    no cross-test state within the module.  Uses the synchronous
    ``redis.Redis`` client so the flush is reliable regardless of
    whether tests are sync or async.
    """
    import redis as redis_sync

    # socket_timeout=None: same fresh-connection HELLO race as
    # module_redis_url above; the per-test flush must not inherit the 5s
    # read budget on a co-tenanted runner.
    # socket_connect_timeout: the DNS/connect band (see module_redis_url).
    client = redis_sync.from_url(
        module_redis_url,
        decode_responses=False,
        socket_timeout=None,
        socket_connect_timeout=TEST_REDIS_CONNECT_TIMEOUT_SECS,
    )
    try:
        client.flushdb()
    finally:
        client.close()

    return module_redis_url

clean_redis_client async

clean_redis_client(
    clean_redis_url: str,
) -> AsyncIterator[object]

Per-test clean Redis async client against the module's DB.

Returns a fresh redis.asyncio.Redis client with decode_responses=False.

Source code in src/taskq/testing/fixtures.py
@pytest_asyncio.fixture
async def clean_redis_client(clean_redis_url: str) -> AsyncIterator[object]:
    """Per-test clean Redis async client against the module's DB.

    Returns a fresh ``redis.asyncio.Redis`` client with ``decode_responses=False``.
    """
    from redis.asyncio import from_url as redis_from_url

    # socket_timeout=None: the client handed to every test of a module
    # rides the shared co-tenanted broker for the test's whole life; the
    # suite-wide pytest-timeout owns wedged connections, not a 5s read
    # budget redis-py 8 started defaulting (the pre-8 behaviour).
    # socket_connect_timeout: the DNS/connect band (see module_redis_url).
    client = redis_from_url(
        clean_redis_url,
        decode_responses=False,
        socket_timeout=None,
        socket_connect_timeout=TEST_REDIS_CONNECT_TIMEOUT_SECS,
    )
    try:
        yield client
    finally:
        await client.aclose()

Health-socket helpers

health

Health-socket path helpers for test suites.

Solves the EADDRINUSE-under-xdist problem with the worker health server: WorkerSettings.health_socket_path defaults to the shared production path /tmp/taskq_health.sock, and worker._main starts a real HealthServer on it. Under pytest-xdist, two workers running _main in-process concurrently race on that one filesystem path, the loser gets EADDRINUSE (there is a TOCTOU window in create_unix_server's stale-file removal), or silently steals the socket from the live winner.

Recommended consumer pattern, mint a unique path per test at settings construction time::

from taskq.testing.health import unique_health_sock_path
from taskq.testing.settings import make_integration_settings

settings = make_integration_settings(
    pg_dsn, HEALTH_SOCKET_PATH=unique_health_sock_path("my_test_module")
)

Suites that build settings through the env cascade (WorkerSettings.load()) instead of a factory should additionally redirect HealthServer.start away from the shared default via an autouse monkeypatch, see tests/conftest.py::_isolate_health_server_socket in the TaskQ repo for the reference implementation (it is repo-specific and deliberately not published).

__all__ module-attribute

__all__ = ['unique_health_sock_path']

unique_health_sock_path

unique_health_sock_path(module: str) -> str

Return a unique unix-socket path for one test's health server.

/tmp/tq-<module>-<pid>-<token>.sock: the pid scopes across xdist workers, the random token across tests within a worker (stateless, no shared counter, so uniqueness survives any pytest import mode), and the module label identifies the owner when debugging stale files. The short /tmp/tq- prefix keeps paths well under the 104-char AF_UNIX sun_path limit on macOS.

Stale files are a resource-only leak: the per-run-unique token means a leftover path can never collide with a future run. HealthServer.stop unlinks on normal completion; suites wanting a crash backstop can sweep /tmp/tq-*-<pid>-*.sock scoped to their own pid (see TaskQ's tests/conftest.py::_sweep_health_sock_files).

:param module: label embedded in the path. Must not contain path separators, / would point the socket at a nonexistent directory and surface as a confusing ENOENT at bind time. :raises ValueError: if module contains / or \.

Source code in src/taskq/testing/health.py
def unique_health_sock_path(module: str) -> str:
    """Return a unique unix-socket path for one test's health server.

    ``/tmp/tq-<module>-<pid>-<token>.sock``: the pid scopes across xdist
    workers, the random token across tests within a worker (stateless, no
    shared counter, so uniqueness survives any pytest import mode), and the
    module label identifies the owner when debugging stale files. The short
    ``/tmp/tq-`` prefix keeps paths well under the 104-char AF_UNIX
    sun_path limit on macOS.

    Stale files are a resource-only leak: the per-run-unique token means a
    leftover path can never collide with a future run. ``HealthServer.stop``
    unlinks on normal completion; suites wanting a crash backstop can sweep
    ``/tmp/tq-*-<pid>-*.sock`` scoped to their own pid (see TaskQ's
    ``tests/conftest.py::_sweep_health_sock_files``).

    :param module: label embedded in the path. Must not contain path
        separators, ``/`` would point the socket at a nonexistent
        directory and surface as a confusing ENOENT at bind time.
    :raises ValueError: if *module* contains ``/`` or ``\\``.
    """
    if "/" in module or "\\" in module:
        raise ValueError(
            f"module label must not contain path separators, got {module!r} "
            "(it is embedded verbatim in the socket path)"
        )
    return f"/tmp/tq-{module}-{os.getpid()}-{new_base62()}.sock"  # noqa: S108  # Why: test-only socket files; /tmp is the shortest safe prefix.

asyncpg chaos tools

ChaosConnection wraps a real asyncpg connection and raises ChaosException (or a fail_with exception type) just before the configured call number — counting execute / fetchrow / fetch / fetchval in execution order — while transaction() still delegates to the real connection so asyncpg rolls the transaction back. ChaosPool wraps a pool whose acquire() hands out chaos connections. Import from taskq.testing.asyncpg_chaos directly:

asyncpg_chaos

Chaos testing helpers for asyncpg connections.

Provides :class:ChaosConnection and :class:ChaosPool for simulating mid-transaction failures in integration tests. The wrapper raises :class:ChaosException on the configured call number, allowing tests to verify that transaction rollback works correctly when a failure occurs between SQL statements inside a transaction.

Contract:

  • :class:ChaosException is raised on the Nth query call (execute, fetchrow, fetch, fetchval). Query calls are counted in execution order regardless of method name.
  • Does not swallow CancelledError, it propagates naturally from the wrapped connection.
  • :class:ChaosPool honours acquire(timeout=...) against its acquire_delay, raising TimeoutError on expiry exactly as an exhausted asyncpg.Pool does.
  • Defers all other semantics (transaction management, type codecs, connection lifecycle) to the wrapped connection.
  • The transaction() method delegates to the real connection so asyncpg's transaction management (commit / rollback) works correctly when ChaosException is raised inside a transaction block.

__all__ module-attribute

__all__ = ["ChaosConnection", "ChaosException", "ChaosPool"]

ChaosException

ChaosException(call_number: int)

Bases: Exception

Raised by :class:ChaosConnection on the configured call number.

Carries the call number for debugging. Does not represent a real PG error, it simulates a failure between SQL statements inside a transaction, causing the transaction to roll back.

Source code in src/taskq/testing/asyncpg_chaos.py
def __init__(self, call_number: int) -> None:
    self.call_number = call_number
    super().__init__(f"ChaosException: simulated failure on call {call_number}")

call_number instance-attribute

call_number = call_number

ChaosConnection

ChaosConnection(
    conn: _Conn,
    fail_on_call: int,
    fail_with: type[BaseException] = ChaosException,
)

Async wrapper around an asyncpg Connection that raises :class:ChaosException on the configured call number.

fail_on_call counts query methods (execute, fetchrow, fetch, fetchval) in execution order. When the counter reaches fail_on_call, ChaosException is raised before the query is sent to PG, simulating a mid-transaction failure.

Set fail_with to raise a different exception type (e.g. :class:asyncpg.PostgresConnectionError or :class:asyncpg.QueryCanceledError) instead of the default :class:ChaosException.

transaction() delegates to the real connection so that asyncpg's transaction context manager can roll back the real transaction when ChaosException propagates through async with conn.transaction():.

Source code in src/taskq/testing/asyncpg_chaos.py
def __init__(
    self,
    conn: _Conn,
    fail_on_call: int,
    fail_with: type[BaseException] = ChaosException,
) -> None:
    self._conn: _Conn = conn
    self._fail_on_call = fail_on_call
    self._fail_with = fail_with
    self._call_count = 0

execute async

execute(
    query: str, *args: object, timeout: float | None = None
) -> str
Source code in src/taskq/testing/asyncpg_chaos.py
async def execute(self, query: str, *args: object, timeout: float | None = None) -> str:
    self._check()
    return await self._conn.execute(  # type: ignore[union-attr]  # Why: _conn is typed as `object` at runtime; asyncpg is TYPE_CHECKING-only to avoid transitive imports
        query, *args, timeout=timeout
    )

fetchrow async

fetchrow(
    query: str, *args: object, timeout: float | None = None
) -> object | None
Source code in src/taskq/testing/asyncpg_chaos.py
async def fetchrow(
    self, query: str, *args: object, timeout: float | None = None
) -> object | None:
    self._check()
    return await self._conn.fetchrow(  # type: ignore[union-attr]  # Why: _conn is typed as `object` at runtime; asyncpg is TYPE_CHECKING-only to avoid transitive imports
        query, *args, timeout=timeout
    )

fetch async

fetch(
    query: str, *args: object, timeout: float | None = None
) -> list[object]
Source code in src/taskq/testing/asyncpg_chaos.py
async def fetch(self, query: str, *args: object, timeout: float | None = None) -> list[object]:
    self._check()
    return await self._conn.fetch(  # type: ignore[union-attr]  # Why: _conn is typed as `object` at runtime; asyncpg is TYPE_CHECKING-only to avoid transitive imports
        query, *args, timeout=timeout
    )

fetchval async

fetchval(
    query: str,
    *args: object,
    column: int = 0,
    timeout: float | None = None,
) -> object | None
Source code in src/taskq/testing/asyncpg_chaos.py
async def fetchval(
    self,
    query: str,
    *args: object,
    column: int = 0,
    timeout: float | None = None,
) -> object | None:
    self._check()
    return await self._conn.fetchval(  # type: ignore[union-attr]  # Why: _conn is typed as `object` at runtime; asyncpg is TYPE_CHECKING-only to avoid transitive imports
        query, *args, column=column, timeout=timeout
    )

transaction

transaction(**kwargs: object) -> object
Source code in src/taskq/testing/asyncpg_chaos.py
def transaction(self, **kwargs: object) -> object:
    return self._conn.transaction(**kwargs)  # type: ignore[union-attr]  # Why: _conn is typed as `object` at runtime; asyncpg is TYPE_CHECKING-only to avoid transitive imports

close async

close() -> None
Source code in src/taskq/testing/asyncpg_chaos.py
async def close(self) -> None:
    await self._conn.close()  # type: ignore[union-attr]  # Why: _conn is typed as `object` at runtime; asyncpg is TYPE_CHECKING-only to avoid transitive imports

ChaosPool

ChaosPool(
    chaos_conn: ChaosConnection,
    *,
    acquire_delay: float | None = None,
)

Pool-like object that yields a :class:ChaosConnection from acquire().

Used to inject a ChaosConnection into backend methods that acquire connections from self._worker_pool. Temporarily replace backend._worker_pool with a ChaosPool to test mid-transaction failures.

acquire_delay simulates a pool with no free connection: acquire() waits that many seconds before yielding. Combined with the caller's timeout=, this is what makes the bounded-wait invariant testable , a call site that forgets timeout= hangs here exactly as it would against a wedged Postgres, and one that passes it raises TimeoutError. Default None means a connection is immediately available, so acquire() returns without waiting and timeout= can never fire, matching a real pool that is not exhausted, and leaving the mid-transaction-failure tests unaffected.

timeout raising (rather than being accepted and silently discarded) is what makes an acquire bound observable to any test built on this pool at all.

Deliberate divergences from asyncpg.Pool, all inherent to a single-connection double and none of them silent: there is no holder queue (so no contention between concurrent acquirers), no release()/reset cycle, no closing/uninitialised state (a real pool raises InterfaceError from acquire() for those before it waits, regardless of timeout), and no bare-await acquire form. Tests needing any of those want a real pool.

Source code in src/taskq/testing/asyncpg_chaos.py
def __init__(self, chaos_conn: ChaosConnection, *, acquire_delay: float | None = None) -> None:
    self._conn = chaos_conn
    self._acquire_delay = acquire_delay

acquire

acquire(
    *, timeout: float | None = None
) -> _ChaosAcquireCtx
Source code in src/taskq/testing/asyncpg_chaos.py
def acquire(self, *, timeout: float | None = None) -> _ChaosAcquireCtx:
    # ``timeout`` mirrors asyncpg.Pool.acquire and is honoured against
    # ``acquire_delay``. ASYNC109 is suppressed file-wide via
    # per-file-ignores in pyproject.toml.
    return _ChaosAcquireCtx(self._conn, self._acquire_delay, timeout)

OTel test utilities

These require the [otel] extra (opentelemetry-sdk) and are not re-exported from the package — import them from taskq.testing.otel directly. The module's public surface: ListSpanExporter (records finished spans; query with its span_named / spans_named / events_on methods), setup_tracer, setup_meter, collect_metrics, counter_value, counter_data_points, histogram_points, reset_otel_gauge_caches, restore_otel_enabled, restore_logging_configured, and attribute_str. The module also exports three autouse pytest fixtures — _otel_enabled_guard, _otel_gauge_cache_guard, and _logging_configured_guard — that activate automatically for any test importing from it:

__all__ on the module is exactly that public surface: the autouse guards are deliberately left private (the test conftest imports them directly, not through the contract).

otel

OTel test utilities: span exporter, tracer/meter setup, and metric query helpers.

Provides a self-contained test OTel stack so individual tests don't need to set up providers, exporters, or patching themselves.

ListSpanExporter

ListSpanExporter()

Bases: SpanExporter

Records finished spans and provides name-based query helpers.

Tests should use span_named, spans_named, and events_on instead of indexing into self.spans directly.

Source code in src/taskq/testing/otel.py
def __init__(self) -> None:
    self.spans: list[ReadableSpan] = []

spans instance-attribute

spans: list[ReadableSpan] = []

export

export(spans: Sequence[ReadableSpan]) -> SpanExportResult
Source code in src/taskq/testing/otel.py
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
    self.spans.extend(spans)
    return SpanExportResult.SUCCESS

shutdown

shutdown() -> None
Source code in src/taskq/testing/otel.py
def shutdown(self) -> None:
    pass

span_named

span_named(name: str) -> ReadableSpan | None

Return the first finished span with name, or None.

Source code in src/taskq/testing/otel.py
def span_named(self, name: str) -> ReadableSpan | None:
    """Return the first finished span with *name*, or None."""
    for s in self.spans:
        if s.name == name:
            return s
    return None

spans_named

spans_named(name: str) -> list[ReadableSpan]

Return all finished spans with name.

Source code in src/taskq/testing/otel.py
def spans_named(self, name: str) -> list[ReadableSpan]:
    """Return all finished spans with *name*."""
    return [s for s in self.spans if s.name == name]

events_on

events_on(span_name: str, event_name: str) -> list[Any]

Return all events named event_name on the first span named span_name.

Returns an empty list if the span doesn't exist.

Source code in src/taskq/testing/otel.py
def events_on(self, span_name: str, event_name: str) -> list[Any]:
    """Return all events named *event_name* on the first span named *span_name*.

    Returns an empty list if the span doesn't exist.
    """
    span = self.span_named(span_name)
    if span is None:
        return []
    return [ev for ev in span.events if ev.name == event_name]

spans_with_kind

spans_with_kind(kind: SpanKind) -> list[ReadableSpan]

Return all finished spans with the given kind.

Source code in src/taskq/testing/otel.py
def spans_with_kind(self, kind: trace.SpanKind) -> list[ReadableSpan]:
    """Return all finished spans with the given *kind*."""
    return [s for s in self.spans if s.kind == kind]

attribute_str

attribute_str(value: AttributeValue) -> str

Narrow an OTel attribute value to the str callers assert on.

OTel 1.45.0 widened AttributeValue with Sequence[AnyValue] and Mapping[str, AnyValue] arms, which are unhashable, so pyright (correctly) refuses set-membership on a raw attribute lookup. Every value the metric suites mint is a str; this says so at runtime too, rather than a comprehension silently dropping a non-string.

Source code in src/taskq/testing/otel.py
def attribute_str(value: AttributeValue) -> str:
    """Narrow an OTel attribute value to the ``str`` callers assert on.

    OTel 1.45.0 widened ``AttributeValue`` with ``Sequence[AnyValue]`` and
    ``Mapping[str, AnyValue]`` arms, which are unhashable, so pyright
    (correctly) refuses set-membership on a raw attribute lookup. Every
    value the metric suites mint is a ``str``; this says so at runtime
    too, rather than a comprehension silently dropping a non-string.
    """
    assert isinstance(value, str), value
    return value

collect_metrics

collect_metrics(
    reader: InMemoryMetricReader,
) -> list[Metric]

Flush the reader and return a flat list of all metrics.

Source code in src/taskq/testing/otel.py
def collect_metrics(reader: InMemoryMetricReader) -> list[Metric]:
    """Flush the reader and return a flat list of all metrics."""
    md = reader.get_metrics_data()
    if md is None:
        return []
    results: list[Metric] = []
    for rm in md.resource_metrics:
        for sm in rm.scope_metrics:
            results.extend(sm.metrics)
    return results

counter_data_points

counter_data_points(
    reader: InMemoryMetricReader, name: str
) -> list[NumberDataPoint]

Return NumberDataPoint list for the counter named name.

Source code in src/taskq/testing/otel.py
def counter_data_points(reader: InMemoryMetricReader, name: str) -> list[NumberDataPoint]:
    """Return NumberDataPoint list for the counter named *name*."""
    for m in collect_metrics(reader):
        if m.name == name:
            return [p for p in m.data.data_points if isinstance(p, NumberDataPoint)]
    return []

counter_value

counter_value(
    reader: InMemoryMetricReader, name: str
) -> int

Return the summed counter value for the metric named name.

Returns 0 if the metric has not been recorded.

Source code in src/taskq/testing/otel.py
def counter_value(reader: InMemoryMetricReader, name: str) -> int:
    """Return the summed counter value for the metric named *name*.

    Returns 0 if the metric has not been recorded.
    """
    for m in collect_metrics(reader):
        if m.name == name:
            total = 0
            for p in m.data.data_points:
                if isinstance(p, NumberDataPoint):
                    total += int(p.value)
            return total
    return 0

histogram_points

histogram_points(
    reader: InMemoryMetricReader, name: str
) -> list[HistogramDataPoint]

Return histogram data points for the metric named name.

Source code in src/taskq/testing/otel.py
def histogram_points(reader: InMemoryMetricReader, name: str) -> list[HistogramDataPoint]:
    """Return histogram data points for the metric named *name*."""
    for m in collect_metrics(reader):
        if m.name == name:
            return list(m.data.data_points)  # type: ignore[return-value]  # Why: histogram metric data_points are always HistogramDataPoint instances.
    return []

reset_otel_gauge_caches

reset_otel_gauge_caches() -> None

Reset every process-global in :mod:taskq.obs._otel that a gauge observer, or a test asserting on one, can read, to construction state.

Rebind, never mutate in place: the gauge caches follow a copy-on-write rebind discipline in production precisely because the OTel reader thread iterates them, and an in-place dict.clear() landing while any reader's iterator is open is the dictionary changed size during iteration hazard that discipline exists to prevent. Rebinding is also coherent for importers: every reader of these globals reads through the module namespace at call time (worker/health.py reads _otel._sweep_success_monotonic_cache / _otel._sweep_batch_size_cache that way, the _observe_* callbacks read the module globals, and the tests read otel_mod.<attr>), so no holder of a stale object exists to desynchronize.

Covers the gauge-fed caches (queue depth, stranded jobs, reservation slots, keyed-reclaim pending, sweep success/batch-size stamps, the leader lease TTL, jobs-by-status, actor backlog and oldest-pending age, oldest-due age, scheduled count, running-lease-expired count, heartbeat consecutive failures, disabled schedules, the slot-pool occupancy source) plus the two label-admission sets and the cron failure-level ledger, emitter-side process globals the cardinality tests were already forced to pin per test because a prior test's admissions widen a later test's boundary. Deliberately NOT reset: _lazy_counters / _lazy_histograms (memoized per meter identity; a stale entry self-replaces on the first call after a meter swap, and clearing would undo the memoization under test), _library_tracer / _library_meter (resolve-once resolvers that are safe across provider transitions; tests patch the accessor functions, not these values), and _otel_enabled (restored by _otel_enabled_guard).

Source code in src/taskq/testing/otel.py
def reset_otel_gauge_caches() -> None:
    """Reset every process-global in :mod:`taskq.obs._otel` that a gauge
    observer, or a test asserting on one, can read, to construction
    state.

    Rebind, never mutate in place: the gauge caches follow a copy-on-write
    rebind discipline in production precisely because the OTel reader
    thread iterates them, and an in-place ``dict.clear()`` landing while
    any reader's iterator is open is the ``dictionary changed size during
    iteration`` hazard that discipline exists to prevent.  Rebinding is
    also coherent for importers: every reader of these globals reads
    through the module namespace at call time (``worker/health.py`` reads
    ``_otel._sweep_success_monotonic_cache`` / ``_otel._sweep_batch_size_cache``
    that way, the ``_observe_*`` callbacks read the module globals, and
    the tests read ``otel_mod.<attr>``), so no holder of a stale object
    exists to desynchronize.

    Covers the gauge-fed caches (queue depth, stranded jobs, reservation
    slots, keyed-reclaim pending, sweep success/batch-size stamps, the
    leader lease TTL, jobs-by-status, actor backlog and oldest-pending
    age, oldest-due age, scheduled count, running-lease-expired count,
    heartbeat consecutive failures, disabled schedules, the slot-pool
    occupancy source) plus the two label-admission sets and the cron
    failure-level ledger, emitter-side process globals the cardinality
    tests were already forced to pin per test because a prior test's
    admissions widen a later test's boundary.  Deliberately NOT reset:
    ``_lazy_counters`` / ``_lazy_histograms`` (memoized per meter
    identity; a stale entry self-replaces on the first call after a meter
    swap, and clearing would undo the memoization under test),
    ``_library_tracer`` / ``_library_meter`` (resolve-once resolvers that
    are safe across provider transitions; tests patch the accessor
    functions, not these values), and ``_otel_enabled`` (restored by
    ``_otel_enabled_guard``).
    """
    otel_mod._queue_depth_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: test isolation seam, the suite's established pattern for module-global caches with no other reset surface.
    otel_mod._stranded_jobs_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._queue_live_workers_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._reservation_slots_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._keyed_reclaim_pending = 0  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._sweep_success_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._sweep_success_monotonic_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: the monotonic twin ledger maintenance_health reads; cleared with the wall one so no stamp leaks between tests.
    otel_mod._sweep_batch_size_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._sweep_batch_size_configured_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._leader_lease_expires_in_seconds_cache = None  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._jobs_by_status_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._actor_backlog_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._actor_oldest_pending_age_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._oldest_due_age_seconds = 0.0  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._scheduled_count = 0  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._running_lease_expired_count = 0  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._heartbeat_consecutive_failures_count = 0  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._disabled_schedules_count = 0  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._cron_slots_behind_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above - the slots-behind gauge's per-actor depth cache is process state a cron-driving test must not leave behind.
    otel_mod._slot_pool_occupancy_source = None  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._worker_capacity_source = None  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._jobs_running_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._actor_oldest_running_age_cache = {}  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.
    otel_mod._queue_label_values.clear()  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above, the set is iterated only on the emitter thread, so in-place clear is the copy-on-write exemption's exact case.
    otel_mod._cron_actor_label_values.clear()  # pyright: ignore[reportPrivateUsage]  # Why: same seam and same exemption as above.
    otel_mod._cron_failure_levels.clear()  # pyright: ignore[reportPrivateUsage]  # Why: same seam as above.

restore_logging_configured

restore_logging_configured(saved: bool) -> None

Restore obs._structlog._logging_configured to a previously saved value.

Source code in src/taskq/testing/otel.py
def restore_logging_configured(saved: bool) -> None:
    """Restore ``obs._structlog._logging_configured`` to a previously saved value."""
    structlog_mod._logging_configured = saved  # type: ignore[reportPrivateUsage]  # Why: testing utility restores private module flag from snapshot.

restore_otel_enabled

restore_otel_enabled(saved: bool) -> None

Restore obs._otel._otel_enabled to a previously saved value.

Source code in src/taskq/testing/otel.py
def restore_otel_enabled(saved: bool) -> None:
    """Restore ``obs._otel._otel_enabled`` to a previously saved value."""
    otel_mod._otel_enabled = saved  # type: ignore[reportPrivateUsage]  # Why: testing utility restores private module flag from snapshot.

setup_meter

setup_meter(
    monkeypatch: MonkeyPatch,
    *,
    extra_instruments: dict[str, str] | None = None,
) -> InMemoryMetricReader

Create a test-scoped MeterProvider and patch obs.get_meter.

By default patches the instruments used by the dispatch and consumer paths. Pass extra_instruments as {module_attr: instrument_type} (where type is "counter" or "histogram") to patch additional module-level instruments.

Returns the InMemoryMetricReader for metric assertions.

Source code in src/taskq/testing/otel.py
def setup_meter(
    monkeypatch: pytest.MonkeyPatch,
    *,
    extra_instruments: dict[str, str] | None = None,
) -> InMemoryMetricReader:
    """Create a test-scoped MeterProvider and patch ``obs.get_meter``.

    By default patches the instruments used by the dispatch and consumer
    paths.  Pass *extra_instruments* as ``{module_attr: instrument_type}``
    (where type is ``"counter"`` or ``"histogram"``) to patch additional
    module-level instruments.

    Returns the ``InMemoryMetricReader`` for metric assertions.
    """
    from opentelemetry.sdk.metrics import MeterProvider

    reader = InMemoryMetricReader()
    new_provider = MeterProvider(metric_readers=[reader])
    new_meter = new_provider.get_meter(obs_mod.INSTRUMENTATION_NAME, otel_mod._version())  # type: ignore[reportPrivateUsage]  # Why: testing utility reads private module state for test-scoped meter setup.

    monkeypatch.setattr(
        otel_mod,
        "_dispatch_duration",
        new_meter.create_histogram("taskq.dispatch.duration", unit="s"),
    )
    monkeypatch.setattr(
        otel_mod,
        "_dispatch_failures",
        new_meter.create_counter("taskq.dispatch.failures", unit="1"),
    )
    monkeypatch.setattr(
        otel_mod,
        "_pool_acquire_duration",
        new_meter.create_histogram("taskq.dispatch.pool_acquire_duration", unit="s"),
    )
    monkeypatch.setattr(
        otel_mod,
        "_consumed_messages",
        new_meter.create_counter("messaging.client.consumed.messages", unit="1"),
    )
    monkeypatch.setattr(
        otel_mod,
        "_process_duration",
        new_meter.create_histogram("messaging.process.duration", unit="s"),
    )
    monkeypatch.setattr(
        otel_mod,
        "_published_messages",
        new_meter.create_counter("messaging.client.published.messages", unit="1"),
    )
    monkeypatch.setattr(otel_mod, "get_meter", lambda: new_meter)
    monkeypatch.setattr(obs_mod, "get_meter", lambda: new_meter)

    if extra_instruments:
        for attr, kind in extra_instruments.items():
            if kind == "counter":
                monkeypatch.setattr(otel_mod, attr, new_meter.create_counter(attr, unit="1"))
            elif kind == "histogram":
                monkeypatch.setattr(otel_mod, attr, new_meter.create_histogram(attr, unit="s"))

    return reader

setup_tracer

setup_tracer(
    monkeypatch: MonkeyPatch,
) -> tuple[TracerProvider, ListSpanExporter]

Create a test-scoped TracerProvider and patch obs.get_tracer.

OTel's global TracerProvider has a _Once guard that prevents trace.set_tracer_provider from being called more than once per process. Patching obs_mod.get_tracer to return a tracer from our per-test provider avoids the guard entirely.

Also sets otel_mod._otel_enabled = True so that safe_start_span delegates to the real tracer.

Source code in src/taskq/testing/otel.py
def setup_tracer(
    monkeypatch: pytest.MonkeyPatch,
) -> tuple[TracerProvider, ListSpanExporter]:
    """Create a test-scoped TracerProvider and patch ``obs.get_tracer``.

    OTel's global TracerProvider has a ``_Once`` guard that prevents
    ``trace.set_tracer_provider`` from being called more than once per
    process.  Patching ``obs_mod.get_tracer`` to return a tracer from
    our per-test provider avoids the guard entirely.

    Also sets ``otel_mod._otel_enabled = True`` so that
    ``safe_start_span`` delegates to the real tracer.
    """
    exporter = ListSpanExporter()
    provider = TracerProvider()
    provider.add_span_processor(SimpleSpanProcessor(exporter))
    test_tracer = provider.get_tracer(obs_mod.INSTRUMENTATION_NAME, otel_mod._version())  # type: ignore[reportPrivateUsage]  # Why: testing utility reads private module state for test-scoped tracer setup.
    monkeypatch.setattr(otel_mod, "get_tracer", lambda: test_tracer)
    monkeypatch.setattr(obs_mod, "get_tracer", lambda: test_tracer)
    otel_mod.set_otel_enabled(True)
    return provider, exporter