From 66b6c41c707aa03a12d9ecaa12145aa1ac4a2ef2 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 16:23:34 -0700 Subject: [PATCH 01/20] feat: add actor deregistration exception hierarchy --- src/taskq/exceptions.py | 58 +++++++++++++++++++++++++++++++++ tests/test_exceptions.py | 69 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/taskq/exceptions.py b/src/taskq/exceptions.py index 6b0b107e..ddf26469 100644 --- a/src/taskq/exceptions.py +++ b/src/taskq/exceptions.py @@ -343,6 +343,64 @@ def __init__(self, drifts: tuple[ActorConfigDriftError, ...]) -> None: super().__init__("\n".join(lines)) +class ActorDeregistrationError(TaskQError): + """Base for actor deregistration refusals.""" + + def __init__(self, actor: str, detail: str) -> None: + self.actor = actor + super().__init__(f"Cannot deregister actor {actor!r}: {detail}") + + +class ActorHasActiveJobsError(ActorDeregistrationError): + """Non-terminal jobs reference the actor. + + Carries the count and per-status breakdown of the blocking jobs so the + caller can decide whether to cancel them first or use ``force=True``. + """ + + def __init__( + self, + actor: str, + active_count: int, + status_counts: dict[str, int], + ) -> None: + self.active_count = active_count + self.status_counts = status_counts + detail = ( + f"{active_count} non-terminal job(s) still reference this actor" + f" (breakdown: {status_counts}). Cancel them first or pass" + f" force=True to cancel pending/scheduled jobs automatically." + ) + super().__init__(actor, detail) + + +class ActorHasEnabledSchedulesError(ActorDeregistrationError): + """Enabled cron schedules reference the actor. + + Carries the schedule IDs so the caller can disable or delete them first. + """ + + def __init__( + self, + actor: str, + schedule_ids: list[str], + ) -> None: + self.schedule_ids = schedule_ids + detail = ( + f"{len(schedule_ids)} enabled cron schedule(s) reference this actor" + f" (ids: {schedule_ids}). Disable or delete them first or pass" + f" force=True to disable them automatically." + ) + super().__init__(actor, detail) + + +class ActorNotFoundError(ActorDeregistrationError): + """The actor_config row does not exist — nothing to deregister.""" + + def __init__(self, actor: str) -> None: + super().__init__(actor, "no stored actor_config row for this actor") + + class PartialBatchError(TaskQError): """Raised when an autonomous enqueue_batch partially fails. diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index ba1af63f..6f996179 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -476,3 +476,72 @@ def test_missing_provider_isinstance_taskqerror() -> None: """MissingProvider is a TaskQError subclass.""" exc = MissingProvider(type_name="X", required_by="Y") assert isinstance(exc, TaskQError) + + +class TestActorDeregistrationErrors: + """Tests for the deregistration refusal exception hierarchy.""" + + def test_actor_has_active_jobs_error_carries_counts(self) -> None: + from taskq.exceptions import ActorHasActiveJobsError + + err = ActorHasActiveJobsError( + actor="my-actor.run-123", + active_count=3, + status_counts={"pending": 2, "running": 1}, + ) + assert err.actor == "my-actor.run-123" + assert err.active_count == 3 + assert err.status_counts == {"pending": 2, "running": 1} + assert "3 non-terminal" in str(err) + assert "force=True" in str(err) + + def test_actor_has_enabled_schedules_error_carries_ids(self) -> None: + from taskq.exceptions import ActorHasEnabledSchedulesError + + err = ActorHasEnabledSchedulesError( + actor="my-actor.run-123", + schedule_ids=["sched-1", "sched-2"], + ) + assert err.actor == "my-actor.run-123" + assert err.schedule_ids == ["sched-1", "sched-2"] + assert "2 enabled cron schedule" in str(err) + assert "force=True" in str(err) + + def test_actor_not_found_error(self) -> None: + from taskq.exceptions import ActorNotFoundError + + err = ActorNotFoundError("ghost-actor") + assert err.actor == "ghost-actor" + assert "no stored actor_config row" in str(err) + + def test_deregistration_errors_inherit_taskq_error(self) -> None: + from taskq.exceptions import ( + ActorDeregistrationError, + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, + TaskQError, + ) + + for cls in ( + ActorDeregistrationError, + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, + ): + assert issubclass(cls, TaskQError) + + def test_specific_errors_inherit_deregistration_error(self) -> None: + from taskq.exceptions import ( + ActorDeregistrationError, + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, + ) + + for cls in ( + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, + ): + assert issubclass(cls, ActorDeregistrationError) From b06c6ea7cab8109e14b6b989b72f1d44ae4c02c5 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 16:29:56 -0700 Subject: [PATCH 02/20] feat: add deregister_actor with force=False/force=True safety checks and purge_queue --- src/taskq/worker/actor_config_ops.py | 190 ++++++++++++ tests/test_actor_deregistration.py | 431 +++++++++++++++++++++++++++ 2 files changed, 621 insertions(+) create mode 100644 tests/test_actor_deregistration.py diff --git a/src/taskq/worker/actor_config_ops.py b/src/taskq/worker/actor_config_ops.py index ceeb51eb..69d52528 100644 --- a/src/taskq/worker/actor_config_ops.py +++ b/src/taskq/worker/actor_config_ops.py @@ -37,11 +37,18 @@ from taskq.constants import ( _IDENT_RE, # pyright: ignore[reportPrivateUsage] # Why: reusing the canonical identifier regex rather than redefining it ) +from taskq.exceptions import ( + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, +) __all__ = [ "UNSET", "ActorConfigRow", + "DeregisterResult", "Unset", + "deregister_actor", "get_actor_config", "list_actor_configs", "set_actor_config_capacity", @@ -71,6 +78,19 @@ class ActorConfigRow: updated_at: str +@dataclass(frozen=True, slots=True) +class DeregisterResult: + """Outcome of a ``deregister_actor`` call.""" + + actor: str + queue: str + actor_config_deleted: bool + schedules_disabled: int + jobs_cancelled: int + terminal_jobs_remaining: int + queue_purged: bool + + _LIST_ACTOR_CONFIG_SQL = """ SELECT actor, max_concurrent, max_pending, queue, result_ttl, metadata::text AS metadata, updated_at::text AS updated_at @@ -215,3 +235,173 @@ async def set_actor_config_capacity( None if isinstance(result_ttl, Unset) else result_ttl, ) return _row_to_dataclass(row) if row is not None else None + + +# ── deregister_actor ──────────────────────────────────────────────────── + +_NON_TERMINAL_STATUSES: tuple[str, ...] = ("pending", "scheduled", "running") +_RUNNING_STATUS: str = "running" + +_DEREGISTER_CHECK_ACTIVE_JOBS_SQL = """ +SELECT status, count(*) AS cnt + FROM "{schema}".jobs + WHERE actor = $1 AND status = ANY($2::"{schema}".job_status[]) + GROUP BY status +""".strip() + +_DEREGISTER_CHECK_SCHEDULES_SQL = """ +SELECT id::text FROM "{schema}".cron_schedules + WHERE actor = $1 AND enabled = true +""".strip() + +_DEREGISTER_CANCEL_PENDING_SQL = """ +UPDATE "{schema}".jobs + SET status = 'cancelled', + finished_at = now(), + error_class = 'ActorDeregistered', + error_message = 'Job cancelled by actor deregistration (force=True)' + WHERE actor = $1 + AND status IN ('pending', 'scheduled') +""".strip() + +_DEREGISTER_DISABLE_SCHEDULES_SQL = """ +UPDATE "{schema}".cron_schedules + SET enabled = false + WHERE actor = $1 AND enabled = true +""".strip() + +_DEREGISTER_DELETE_ACTOR_CONFIG_SQL = """ +DELETE FROM "{schema}".actor_config WHERE actor = $1 +RETURNING queue +""".strip() + +_DEREGISTER_PURGE_QUEUE_SQL = """ +DELETE FROM "{schema}".queues + WHERE name = $1 + AND NOT EXISTS ( + SELECT 1 FROM "{schema}".actor_config WHERE queue = $1 + ) +""".strip() + +_DEREGISTER_COUNT_TERMINAL_SQL = """ +SELECT count(*) FROM "{schema}".jobs + WHERE actor = $1 AND status NOT IN ('pending', 'scheduled', 'running') +""".strip() + + +async def deregister_actor( + conn: ConnLike, + actor: str, + *, + force: bool = False, + purge_queue: bool = False, + schema: str = "taskq", +) -> DeregisterResult: + """Deregister an actor: delete its ``actor_config`` row with safety checks. + + **Default (force=False):** + 1. Refuse if any non-terminal jobs (pending/scheduled/running) reference + the actor — raises :class:`ActorHasActiveJobsError`. + 2. Refuse if any enabled cron schedules reference the actor — raises + :class:`ActorHasEnabledSchedulesError`. + 3. Delete the ``actor_config`` row. + 4. Optionally purge the orphaned queue (if ``purge_queue=True`` and no + other ``actor_config`` row references the same queue). + + **force=True:** + 1. Refuse if any running jobs reference the actor — raises + :class:`ActorHasActiveJobsError`. + 2. Cancel pending/scheduled jobs for this actor. + 3. Disable enabled cron schedules for this actor. + 4. Delete the ``actor_config`` row. + 5. Optionally purge the orphaned queue. + + Terminal job history is never deleted or modified. The entire operation + runs inside a single ``conn.transaction()`` block. If the actor has no + stored ``actor_config`` row, raises :class:`ActorNotFoundError`. + + .. warning:: + **Concurrent enqueue / dispatch race (TOCTOU).** The transaction + uses READ COMMITTED isolation. Callers must quiesce the actor first + — stop enqueuing, disable cron schedules, and wait for running jobs + to reach a terminal state — before calling deregister. + """ + if not _IDENT_RE.match(schema): + raise ValueError(f"invalid schema identifier: {schema!r}") + + async with conn.transaction(): + if not force: + active_rows = await conn.fetch( + _DEREGISTER_CHECK_ACTIVE_JOBS_SQL.format(schema=schema), + actor, + list(_NON_TERMINAL_STATUSES), + ) + if active_rows: + status_counts = {row["status"]: row["cnt"] for row in active_rows} + active_count = sum(status_counts.values()) + raise ActorHasActiveJobsError(actor, active_count, status_counts) + + schedule_rows = await conn.fetch( + _DEREGISTER_CHECK_SCHEDULES_SQL.format(schema=schema), + actor, + ) + if schedule_rows: + schedule_ids = [row["id"] for row in schedule_rows] + raise ActorHasEnabledSchedulesError(actor, schedule_ids) + + jobs_cancelled = 0 + schedules_disabled = 0 + else: + running_rows = await conn.fetch( + _DEREGISTER_CHECK_ACTIVE_JOBS_SQL.format(schema=schema), + actor, + [_RUNNING_STATUS], + ) + if running_rows: + status_counts = {row["status"]: row["cnt"] for row in running_rows} + active_count = sum(status_counts.values()) + raise ActorHasActiveJobsError(actor, active_count, status_counts) + + cancel_result = await conn.execute( + _DEREGISTER_CANCEL_PENDING_SQL.format(schema=schema), + actor, + ) + jobs_cancelled = int(cancel_result.split()[-1]) if cancel_result else 0 + + disable_result = await conn.execute( + _DEREGISTER_DISABLE_SCHEDULES_SQL.format(schema=schema), + actor, + ) + schedules_disabled = int(disable_result.split()[-1]) if disable_result else 0 + + deleted_rows = await conn.fetch( + _DEREGISTER_DELETE_ACTOR_CONFIG_SQL.format(schema=schema), + actor, + ) + if not deleted_rows: + raise ActorNotFoundError(actor) + + queue_name = deleted_rows[0]["queue"] + + terminal_count = await conn.fetchval( + _DEREGISTER_COUNT_TERMINAL_SQL.format(schema=schema), + actor, + ) + + queue_purged = False + if purge_queue: + purge_result = await conn.execute( + _DEREGISTER_PURGE_QUEUE_SQL.format(schema=schema), + queue_name, + ) + queue_purged = purge_result == "DELETE 1" + + return DeregisterResult( + actor=actor, + queue=queue_name, + actor_config_deleted=True, + schedules_disabled=schedules_disabled, + jobs_cancelled=jobs_cancelled, + terminal_jobs_remaining=terminal_count or 0, + queue_purged=queue_purged, + ) diff --git a/tests/test_actor_deregistration.py b/tests/test_actor_deregistration.py new file mode 100644 index 00000000..ed1e90fc --- /dev/null +++ b/tests/test_actor_deregistration.py @@ -0,0 +1,431 @@ +"""Integration tests for ``deregister_actor`` — the transactional +``actor_config`` deletion with safety checks (force=False / force=True, +purge_queue). + +These require real Postgres (marked ``integration``) because the function +executes hand-written SQL against the fully migrated schema — a fake +connection would only prove the query string looks right, not that +Postgres executes it correctly with enum casts, transactional rollback, +and the ``NOT EXISTS`` subquery for queue purging. +""" + +from uuid import uuid4 + +import asyncpg +import pytest + +from taskq._ids import new_base62 +from taskq.exceptions import ( + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, +) +from taskq.worker.actor_config import ActorConfig +from taskq.worker.actor_config_ops import DeregisterResult, deregister_actor, get_actor_config +from taskq.worker.startup import sync_actor_config + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +# ── Helpers ────────────────────────────────────────────────────────────── + + +async def _ensure_schema(conn: asyncpg.Connection, schema: str) -> None: + """Drop and re-create the full TaskQ schema via ``apply_pending``.""" + from taskq.migrate import apply_pending + + await conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') + await apply_pending(conn, schema=schema) + + +async def _insert_job( + conn: asyncpg.Connection, + schema: str, + *, + actor: str, + status: str, + queue: str = "default", +) -> str: + """Insert a minimal job row with the given status; return its UUID string.""" + job_id = uuid4() + await conn.execute( + f"""INSERT INTO "{schema}".jobs ( + id, actor, queue, payload, max_attempts, retry_kind, status + ) VALUES ( + $1, $2, $3, $4::jsonb, $5, $6, $7::"{schema}".job_status + )""", # noqa: S608 # Why: schema validated by _IDENT_RE in apply_pending; actor/status/queue are test constants, not user input. + job_id, + actor, + queue, + '{"key": "value"}', + 3, + "transient", + status, + ) + return str(job_id) + + +async def _insert_schedule( + conn: asyncpg.Connection, + schema: str, + *, + actor: str, + enabled: bool = True, +) -> str: + """Insert a cron schedule row; return its UUID string.""" + schedule_id = uuid4() + await conn.execute( + f"""INSERT INTO "{schema}".cron_schedules ( + id, actor, cron_expr, enabled, next_fire_at + ) VALUES ( + $1, $2, $3, $4, now() + )""", # noqa: S608 # Why: schema validated by _IDENT_RE in apply_pending; actor is a test constant. + schedule_id, + actor, + "*/5 * * * *", + enabled, + ) + return str(schedule_id) + + +async def _insert_queue( + conn: asyncpg.Connection, + schema: str, + name: str, +) -> None: + """Insert a queue row (the ``queues`` table is not populated by sync_actor_config).""" + await conn.execute( + f'INSERT INTO "{schema}".queues (name) VALUES ($1)', # noqa: S608 # Why: schema validated by _IDENT_RE in apply_pending; name is a test constant. + name, + ) + + +async def _queue_exists(conn: asyncpg.Connection, schema: str, name: str) -> bool: + """Check whether a queue row exists.""" + return bool( + await conn.fetchval( + f'SELECT 1 FROM "{schema}".queues WHERE name = $1', # noqa: S608 + name, + ) + ) + + +async def _job_status(conn: asyncpg.Connection, schema: str, job_id: str) -> str: + """Return the current status of a job row.""" + return str( + await conn.fetchval( + f'SELECT status::text FROM "{schema}".jobs WHERE id = $1', # noqa: S608 + job_id, + ) + ) + + +async def _schedule_enabled(conn: asyncpg.Connection, schema: str, schedule_id: str) -> bool: + """Return the ``enabled`` value of a cron schedule row.""" + return bool( + await conn.fetchval( + f'SELECT enabled FROM "{schema}".cron_schedules WHERE id = $1', # noqa: S608 + schedule_id, + ) + ) + + +def _make_schema() -> str: + return f"tqd_{new_base62()}".lower() + + +# ── force=False path ──────────────────────────────────────────────────── + + +async def test_deregister_raises_not_found_for_unknown_actor(pg_conn: asyncpg.Connection) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + + with pytest.raises(ActorNotFoundError, match="no stored actor_config row"): + await deregister_actor(pg_conn, "ghost", schema=schema) + + +async def test_deregister_succeeds_when_no_jobs_or_schedules( + pg_conn: asyncpg.Connection, +) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="clean_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + + result = await deregister_actor(pg_conn, "clean_actor", schema=schema) + + assert isinstance(result, DeregisterResult) + assert result.actor == "clean_actor" + assert result.queue == "default" + assert result.actor_config_deleted is True + assert result.schedules_disabled == 0 + assert result.jobs_cancelled == 0 + assert result.terminal_jobs_remaining == 0 + assert result.queue_purged is False + + assert await get_actor_config(pg_conn, "clean_actor", schema=schema) is None + + +async def test_deregister_refuses_with_pending_jobs(pg_conn: asyncpg.Connection) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="busy_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, actor="busy_actor", status="pending") + + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await deregister_actor(pg_conn, "busy_actor", schema=schema) + + assert exc_info.value.active_count == 1 + assert exc_info.value.status_counts == {"pending": 1} + + # Row must still exist — the transaction rolled back. + assert await get_actor_config(pg_conn, "busy_actor", schema=schema) is not None + + +async def test_deregister_refuses_with_running_jobs(pg_conn: asyncpg.Connection) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="run_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, actor="run_actor", status="running") + + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await deregister_actor(pg_conn, "run_actor", schema=schema) + + assert exc_info.value.active_count == 1 + assert exc_info.value.status_counts == {"running": 1} + assert await get_actor_config(pg_conn, "run_actor", schema=schema) is not None + + +async def test_deregister_refuses_with_enabled_schedules(pg_conn: asyncpg.Connection) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="sched_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + schedule_id = await _insert_schedule(pg_conn, schema, actor="sched_actor", enabled=True) + + with pytest.raises(ActorHasEnabledSchedulesError) as exc_info: + await deregister_actor(pg_conn, "sched_actor", schema=schema) + + assert exc_info.value.schedule_ids == [schedule_id] + assert await get_actor_config(pg_conn, "sched_actor", schema=schema) is not None + + +async def test_deregister_succeeds_with_disabled_schedules(pg_conn: asyncpg.Connection) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="dis_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + await _insert_schedule(pg_conn, schema, actor="dis_actor", enabled=False) + + result = await deregister_actor(pg_conn, "dis_actor", schema=schema) + + assert result.actor_config_deleted is True + assert result.schedules_disabled == 0 + assert await get_actor_config(pg_conn, "dis_actor", schema=schema) is None + + +async def test_deregister_succeeds_with_terminal_jobs(pg_conn: asyncpg.Connection) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="term_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, actor="term_actor", status="succeeded") + await _insert_job(pg_conn, schema, actor="term_actor", status="failed") + + result = await deregister_actor(pg_conn, "term_actor", schema=schema) + + assert result.actor_config_deleted is True + assert result.terminal_jobs_remaining == 2 + assert await get_actor_config(pg_conn, "term_actor", schema=schema) is None + + +# ── force=True path ───────────────────────────────────────────────────── + + +async def test_deregister_force_cancels_pending_and_disables_schedules( + pg_conn: asyncpg.Connection, +) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="force_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + pending_id = await _insert_job(pg_conn, schema, actor="force_actor", status="pending") + scheduled_id = await _insert_job(pg_conn, schema, actor="force_actor", status="scheduled") + schedule_id = await _insert_schedule(pg_conn, schema, actor="force_actor", enabled=True) + + result = await deregister_actor(pg_conn, "force_actor", force=True, schema=schema) + + assert result.actor_config_deleted is True + assert result.jobs_cancelled == 2 + assert result.schedules_disabled == 1 + # The 2 cancelled jobs are now terminal — terminal_jobs_remaining + # counts all non-pending/scheduled/running rows, including the + # newly-cancelled ones. + assert result.terminal_jobs_remaining == 2 + + # Verify DB state directly. + assert await _job_status(pg_conn, schema, pending_id) == "cancelled" + assert await _job_status(pg_conn, schema, scheduled_id) == "cancelled" + assert await _schedule_enabled(pg_conn, schema, schedule_id) is False + assert await get_actor_config(pg_conn, "force_actor", schema=schema) is None + + +async def test_deregister_force_refuses_with_running_jobs(pg_conn: asyncpg.Connection) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="frun_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, actor="frun_actor", status="running") + + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await deregister_actor(pg_conn, "frun_actor", force=True, schema=schema) + + assert exc_info.value.active_count == 1 + assert exc_info.value.status_counts == {"running": 1} + assert await get_actor_config(pg_conn, "frun_actor", schema=schema) is not None + + +async def test_deregister_force_with_running_and_pending_only_reports_running( + pg_conn: asyncpg.Connection, +) -> None: + """force=True checks only running jobs — pending jobs are not in the error + because they would be cancelled, not blocking.""" + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="mix_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, actor="mix_actor", status="running") + await _insert_job(pg_conn, schema, actor="mix_actor", status="pending") + + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await deregister_actor(pg_conn, "mix_actor", force=True, schema=schema) + + assert exc_info.value.active_count == 1 + assert exc_info.value.status_counts == {"running": 1} + assert "pending" not in exc_info.value.status_counts + # Row still exists — transaction rolled back. + assert await get_actor_config(pg_conn, "mix_actor", schema=schema) is not None + + +async def test_deregister_force_keeps_terminal_history(pg_conn: asyncpg.Connection) -> None: + """Terminal job rows are never modified — only pending/scheduled are cancelled.""" + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="hist_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + pending_id = await _insert_job(pg_conn, schema, actor="hist_actor", status="pending") + succeeded_id = await _insert_job(pg_conn, schema, actor="hist_actor", status="succeeded") + failed_id = await _insert_job(pg_conn, schema, actor="hist_actor", status="failed") + + result = await deregister_actor(pg_conn, "hist_actor", force=True, schema=schema) + + assert result.jobs_cancelled == 1 + # terminal_jobs_remaining counts all terminal rows including the + # newly-cancelled pending job: 1 cancelled + 1 succeeded + 1 failed. + assert result.terminal_jobs_remaining == 3 + + assert await _job_status(pg_conn, schema, pending_id) == "cancelled" + assert await _job_status(pg_conn, schema, succeeded_id) == "succeeded" + assert await _job_status(pg_conn, schema, failed_id) == "failed" + + +# ── purge_queue path ───────────────────────────────────────────────────── + + +async def test_deregister_purge_queue_deletes_orphaned_queue( + pg_conn: asyncpg.Connection, +) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await _insert_queue(pg_conn, schema, "solo_queue") + await sync_actor_config( + pg_conn, + [ActorConfig(actor="solo_actor", max_concurrent=5, queue="solo_queue")], + schema=schema, + ) + + result = await deregister_actor( + pg_conn, "solo_actor", purge_queue=True, schema=schema + ) + + assert result.queue == "solo_queue" + assert result.queue_purged is True + assert await _queue_exists(pg_conn, schema, "solo_queue") is False + + +async def test_deregister_purge_queue_keeps_shared_queue( + pg_conn: asyncpg.Connection, +) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await _insert_queue(pg_conn, schema, "shared_queue") + await sync_actor_config( + pg_conn, + [ + ActorConfig(actor="actor_a", max_concurrent=5, queue="shared_queue"), + ActorConfig(actor="actor_b", max_concurrent=5, queue="shared_queue"), + ], + schema=schema, + ) + + result = await deregister_actor( + pg_conn, "actor_a", purge_queue=True, schema=schema + ) + + assert result.queue == "shared_queue" + assert result.queue_purged is False + # The queue survives because actor_b still references it. + assert await _queue_exists(pg_conn, schema, "shared_queue") is True + # actor_b's row must still exist. + assert await get_actor_config(pg_conn, "actor_b", schema=schema) is not None + + +async def test_deregister_without_purge_queue_keeps_queue( + pg_conn: asyncpg.Connection, +) -> None: + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await _insert_queue(pg_conn, schema, "kept_queue") + await sync_actor_config( + pg_conn, + [ActorConfig(actor="keep_actor", max_concurrent=5, queue="kept_queue")], + schema=schema, + ) + + result = await deregister_actor(pg_conn, "keep_actor", schema=schema) + + assert result.queue_purged is False + assert await _queue_exists(pg_conn, schema, "kept_queue") is True From 708507be53e0d51bf84b0c9c16af95d9f5b6cbb5 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 16:33:22 -0700 Subject: [PATCH 03/20] test: add idempotency, combined force+purge, and edge-case tests from review --- tests/test_actor_deregistration.py | 88 ++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/test_actor_deregistration.py b/tests/test_actor_deregistration.py index ed1e90fc..82a9d0fe 100644 --- a/tests/test_actor_deregistration.py +++ b/tests/test_actor_deregistration.py @@ -335,6 +335,13 @@ async def test_deregister_force_with_running_and_pending_only_reports_running( assert "pending" not in exc_info.value.status_counts # Row still exists — transaction rolled back. assert await get_actor_config(pg_conn, "mix_actor", schema=schema) is not None + # The pending job must still be pending — the transaction rolled back + # on the raise, so the cancel UPDATE never committed. + pending_count = await pg_conn.fetchval( + f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = $1 AND status = 'pending'", # noqa: S608 # Why: schema validated by _IDENT_RE in apply_pending; actor/status are test constants. + "mix_actor", + ) + assert pending_count == 1 async def test_deregister_force_keeps_terminal_history(pg_conn: asyncpg.Connection) -> None: @@ -429,3 +436,84 @@ async def test_deregister_without_purge_queue_keeps_queue( assert result.queue_purged is False assert await _queue_exists(pg_conn, schema, "kept_queue") is True + + +# ── idempotency ───────────────────────────────────────────────────────── + + +async def test_double_deregister_raises_not_found(pg_conn: asyncpg.Connection) -> None: + """A second deregister call on an already-deregistered actor raises ActorNotFoundError. + + This is the primary consumer pattern (cleanup loops using try/except ActorNotFoundError). + The idempotency guarantee must be tested — an implementation bug that silently returns + actor_config_deleted=False instead of raising would not be caught otherwise. + """ + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="idem_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + + result = await deregister_actor(pg_conn, "idem_actor", schema=schema) + assert result.actor_config_deleted is True + + with pytest.raises(ActorNotFoundError, match="no stored actor_config row"): + await deregister_actor(pg_conn, "idem_actor", schema=schema) + + +# ── combined force + purge_queue ──────────────────────────────────────── + + +async def test_deregister_force_with_purge_queue( + pg_conn: asyncpg.Connection, +) -> None: + """force=True + purge_queue=True simultaneously — the exact pattern downstream + consumers (aacrtool) use for ephemeral actor cleanup.""" + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await _insert_queue(pg_conn, schema, "ephemeral_queue") + await sync_actor_config( + pg_conn, + [ActorConfig(actor="ephemeral_actor", max_concurrent=5, queue="ephemeral_queue")], + schema=schema, + ) + await _insert_job(pg_conn, schema, actor="ephemeral_actor", status="pending") + await _insert_job(pg_conn, schema, actor="ephemeral_actor", status="scheduled") + + result = await deregister_actor( + pg_conn, "ephemeral_actor", force=True, purge_queue=True, schema=schema + ) + + assert result.actor_config_deleted is True + assert result.jobs_cancelled == 2 + assert result.queue_purged is True + assert await get_actor_config(pg_conn, "ephemeral_actor", schema=schema) is None + assert await _queue_exists(pg_conn, schema, "ephemeral_queue") is False + + +async def test_deregister_purge_queue_noop_when_queue_row_absent( + pg_conn: asyncpg.Connection, +) -> None: + """purge_queue=True is a safe no-op when the queues row was never created. + + The queues table is metadata-only and not always populated — operator-managed + deployments may never create a row. The DELETE returns 0 rows and + queue_purged is False, which is correct. + """ + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="noqueue_actor", max_concurrent=5, queue="never_created")], + schema=schema, + ) + # Deliberately do NOT create a queues row for "never_created". + + result = await deregister_actor( + pg_conn, "noqueue_actor", purge_queue=True, schema=schema + ) + + assert result.actor_config_deleted is True + assert result.queue_purged is False From 4b2def9444b1009b8556acf15d4e980fc3453942 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 16:36:18 -0700 Subject: [PATCH 04/20] feat: add ActorsClient pool-wrapping facade --- src/taskq/client/_actors.py | 100 +++++++++++++++++++++ tests/test_actors_client.py | 170 ++++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 src/taskq/client/_actors.py create mode 100644 tests/test_actors_client.py diff --git a/src/taskq/client/_actors.py b/src/taskq/client/_actors.py new file mode 100644 index 00000000..b4b4df6b --- /dev/null +++ b/src/taskq/client/_actors.py @@ -0,0 +1,100 @@ +"""ActorsClient — pool-wrapping facade for actor configuration operations. + +Provides a typed surface for listing, inspecting, tuning, and deregistering +stored ``actor_config`` rows. Each method acquires a connection from the +injected pool, delegates to ``taskq.worker.actor_config_ops``, and returns +the result. +""" + +from typing import TYPE_CHECKING + +import structlog + +from taskq.worker.actor_config_ops import ( + UNSET, + ActorConfigRow, + DeregisterResult, + Unset, + deregister_actor, + get_actor_config, + list_actor_configs, + set_actor_config_capacity, +) + +if TYPE_CHECKING: + import asyncpg + +__all__ = ["ActorsClient"] + +logger = structlog.get_logger("taskq.client._actors") + + +class ActorsClient: + """Pool-wrapping facade for actor configuration operations. + + Acquires a connection from the injected pool for each call, delegates + to ``taskq.worker.actor_config_ops``, and returns the result. The + caller must have opened the pool; this class does not manage its + lifecycle. + + Parameters + ---------- + pool: + An open ``asyncpg.Pool``. The caller retains ownership. + schema: + TaskQ schema name. Defaults to ``"taskq"``. + """ + + def __init__(self, pool: "asyncpg.Pool", *, schema: str = "taskq") -> None: + self._pool = pool + self._schema = schema + + async def list(self) -> list[ActorConfigRow]: + """List all stored actor_config rows, ordered by actor name.""" + async with self._pool.acquire() as conn: + return await list_actor_configs(conn, schema=self._schema) + + async def get(self, actor: str) -> ActorConfigRow | None: + """Get one actor_config row, or ``None`` if not found.""" + async with self._pool.acquire() as conn: + return await get_actor_config(conn, actor, schema=self._schema) + + async def set_capacity( + self, + actor: str, + *, + max_concurrent: int | None | Unset = UNSET, + max_pending: int | None | Unset = UNSET, + result_ttl: float | None | Unset = UNSET, + ) -> ActorConfigRow | None: + """Update capacity fields on an existing actor_config row.""" + async with self._pool.acquire() as conn: + return await set_actor_config_capacity( + conn, + actor, + max_concurrent=max_concurrent, + max_pending=max_pending, + result_ttl=result_ttl, + schema=self._schema, + ) + + async def deregister( + self, + actor: str, + *, + force: bool = False, + purge_queue: bool = False, + ) -> DeregisterResult: + """Deregister an actor with safety checks. + + See :func:`taskq.worker.actor_config_ops.deregister_actor` for + the full semantics. + """ + async with self._pool.acquire() as conn: + return await deregister_actor( + conn, + actor, + force=force, + purge_queue=purge_queue, + schema=self._schema, + ) diff --git a/tests/test_actors_client.py b/tests/test_actors_client.py new file mode 100644 index 00000000..b42c5c45 --- /dev/null +++ b/tests/test_actors_client.py @@ -0,0 +1,170 @@ +"""Tests for ActorsClient — the pool-wrapping facade over actor_config_ops. + +These tests use a fake pool to verify the delegation wiring without +requiring real Postgres (the ops functions themselves are integration-tested +in test_actor_deregistration.py and test_actor_config_ops.py). +""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from taskq.exceptions import ActorNotFoundError +from taskq.worker.actor_config_ops import ( + ActorConfigRow, + DeregisterResult, +) + +pytestmark = [pytest.mark.asyncio] + + +class _FakeConn: + """Fake connection — just needs to be passable to the ops functions.""" + + +class _FakePool: + """Minimal pool that yields a fake connection via async context manager.""" + + def __init__(self, conn: Any) -> None: + self._conn = conn + + def acquire(self) -> Any: + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=self._conn) + cm.__aexit__ = AsyncMock(return_value=None) + return cm + + +async def test_actors_client_list_delegates(monkeypatch: pytest.MonkeyPatch) -> None: + from taskq.client._actors import ActorsClient + + conn = _FakeConn() + pool = _FakePool(conn) + client = ActorsClient(pool, schema="test_schema") + + mock_result = [ + ActorConfigRow( + actor="a", + max_concurrent=1, + max_pending=None, + queue="q", + result_ttl=None, + metadata={}, + updated_at="2026-01-01", + ) + ] + import taskq.client._actors as actors_mod + + mock_list = AsyncMock(return_value=mock_result) + monkeypatch.setattr(actors_mod, "list_actor_configs", mock_list) + result = await client.list() + assert result == mock_result + mock_list.assert_called_once_with(conn, schema="test_schema") + + +async def test_actors_client_deregister_delegates(monkeypatch: pytest.MonkeyPatch) -> None: + from taskq.client._actors import ActorsClient + + conn = _FakeConn() + pool = _FakePool(conn) + client = ActorsClient(pool, schema="test_schema") + + expected = DeregisterResult( + actor="test-actor", + queue="q", + actor_config_deleted=True, + schedules_disabled=0, + jobs_cancelled=0, + terminal_jobs_remaining=0, + queue_purged=False, + ) + + import taskq.client._actors as actors_mod + + mock_deregister = AsyncMock(return_value=expected) + monkeypatch.setattr(actors_mod, "deregister_actor", mock_deregister) + result = await client.deregister("test-actor", force=True, purge_queue=True) + assert result == expected + mock_deregister.assert_called_once_with( + conn, "test-actor", force=True, purge_queue=True, schema="test_schema" + ) + + +async def test_actors_client_get_delegates(monkeypatch: pytest.MonkeyPatch) -> None: + from taskq.client._actors import ActorsClient + + conn = _FakeConn() + pool = _FakePool(conn) + client = ActorsClient(pool, schema="test_schema") + + expected = ActorConfigRow( + actor="a", + max_concurrent=1, + max_pending=None, + queue="q", + result_ttl=None, + metadata={}, + updated_at="2026-01-01", + ) + + import taskq.client._actors as actors_mod + + mock_get = AsyncMock(return_value=expected) + monkeypatch.setattr(actors_mod, "get_actor_config", mock_get) + result = await client.get("a") + assert result == expected + mock_get.assert_called_once_with(conn, "a", schema="test_schema") + + +async def test_actors_client_set_capacity_delegates(monkeypatch: pytest.MonkeyPatch) -> None: + from taskq.client._actors import ActorsClient + + conn = _FakeConn() + pool = _FakePool(conn) + client = ActorsClient(pool, schema="test_schema") + + expected = ActorConfigRow( + actor="a", + max_concurrent=10, + max_pending=None, + queue="q", + result_ttl=None, + metadata={}, + updated_at="2026-01-01", + ) + + import taskq.client._actors as actors_mod + + mock_set_capacity = AsyncMock(return_value=expected) + monkeypatch.setattr(actors_mod, "set_actor_config_capacity", mock_set_capacity) + result = await client.set_capacity("a", max_concurrent=10) + assert result == expected + mock_set_capacity.assert_called_once_with( + conn, + "a", + max_concurrent=10, + max_pending=actors_mod.UNSET, + result_ttl=actors_mod.UNSET, + schema="test_schema", + ) + + +async def test_actors_client_deregister_propagates_errors(monkeypatch: pytest.MonkeyPatch) -> None: + """Exceptions from the ops function must propagate through the pool-wrapper — + not be silently swallowed. This is a lifecycle concern, not a delegation one.""" + from taskq.client._actors import ActorsClient + + conn = _FakeConn() + pool = _FakePool(conn) + client = ActorsClient(pool, schema="test_schema") + + import taskq.client._actors as actors_mod + + monkeypatch.setattr( + actors_mod, + "deregister_actor", + AsyncMock(side_effect=ActorNotFoundError("bad-actor")), + ) + with pytest.raises(ActorNotFoundError): + await client.deregister("bad-actor") From fc7af6e6b28b16bab506c68b33ed926bfc101a36 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 16:37:51 -0700 Subject: [PATCH 05/20] feat: add 'taskq actor-config deregister' CLI command --- src/taskq/cli.py | 65 ++++++++++++- tests/test_cli_actor_deregister.py | 142 +++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tests/test_cli_actor_deregister.py diff --git a/src/taskq/cli.py b/src/taskq/cli.py index 865db7eb..a80800ce 100644 --- a/src/taskq/cli.py +++ b/src/taskq/cli.py @@ -30,12 +30,13 @@ close_redis_bounded, ) from taskq.actor import ActorRef -from taskq.exceptions import ActorConfigDriftList +from taskq.exceptions import ActorConfigDriftList, ActorDeregistrationError from taskq.settings import TaskQSettings, WorkerSettings from taskq.worker.actor_config_ops import ( UNSET, ActorConfigRow, Unset, + deregister_actor, get_actor_config, list_actor_configs, set_actor_config_capacity, @@ -517,6 +518,68 @@ async def _actor_config_set( _print_actor_config_row(row) +@actor_config_app.command("deregister") +def actor_config_deregister( + actor: Annotated[str, typer.Argument(help="Actor name to deregister.")], + force: Annotated[ + bool, + typer.Option( + "--force", + help="Cancel pending/scheduled jobs and disable enabled cron schedules" + " instead of refusing. Running jobs still block deregistration.", + ), + ] = False, + purge_queue: Annotated[ + bool, + typer.Option( + "--purge-queue", + help="Also delete the orphaned queues row if no other actor_config" + " references the same queue.", + ), + ] = False, +) -> None: + """Deregister an actor: delete its actor_config row with safety checks. + + By default refuses if non-terminal jobs or enabled cron schedules + reference the actor. Use --force to cancel pending/scheduled jobs and + disable schedules. Running jobs always block (force or not). Use + --purge-queue to also delete the queues row if no other actor uses it. + """ + settings = TaskQSettings.load() + asyncio.run(_actor_config_deregister(settings, actor, force, purge_queue)) + + +async def _actor_config_deregister( + settings: TaskQSettings, + actor: str, + force: bool, + purge_queue: bool, +) -> None: + conn = await asyncpg.connect(str(settings.pg_dsn)) + try: + result = await deregister_actor( + conn, + actor, + force=force, + purge_queue=purge_queue, + schema=settings.schema_name, + ) + except (ActorDeregistrationError, ValueError) as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from None + finally: + await conn.close() + + typer.echo( + f"Deregistered actor {result.actor!r}:" + f" actor_config_deleted={result.actor_config_deleted}" + f" schedules_disabled={result.schedules_disabled}" + f" jobs_cancelled={result.jobs_cancelled}" + f" terminal_jobs_remaining={result.terminal_jobs_remaining}" + f" queue_purged={result.queue_purged}" + ) + + _CAPACITY_DIFF_FIELDS = ("max_concurrent", "max_pending", "result_ttl") diff --git a/tests/test_cli_actor_deregister.py b/tests/test_cli_actor_deregister.py new file mode 100644 index 00000000..365e84ec --- /dev/null +++ b/tests/test_cli_actor_deregister.py @@ -0,0 +1,142 @@ +"""Tests for `taskq actor-config deregister` CLI command. + +Monkeypatches the ops function and asyncpg.connect to pin the CLI's +argument parsing, error handling, and output shape without requiring +real Postgres (integration coverage is in test_actor_deregistration.py). +""" + +from typing import Any + +import pytest +from typer.testing import CliRunner + +from taskq.cli import app +from taskq.worker.actor_config_ops import DeregisterResult + +runner = CliRunner() + + +def _patch_deregister( + monkeypatch: pytest.MonkeyPatch, + *, + result: DeregisterResult | None = None, + raises: Exception | None = None, +) -> dict[str, Any]: + """Fake asyncpg.connect + deregister_actor; return captured call kwargs.""" + captured: dict[str, Any] = {} + + class _FakeConn: + async def close(self) -> None: ... + + async def fake_connect(dsn: str) -> Any: + return _FakeConn() + + async def fake_deregister(conn: Any, actor: str, **kwargs: Any) -> Any: + captured["actor"] = actor + captured["kwargs"] = kwargs + if raises is not None: + raise raises + return result or DeregisterResult( + actor=actor, + queue="default", + actor_config_deleted=True, + schedules_disabled=0, + jobs_cancelled=0, + terminal_jobs_remaining=0, + queue_purged=False, + ) + + monkeypatch.setattr("taskq.cli.asyncpg.connect", fake_connect) + monkeypatch.setattr("taskq.cli.deregister_actor", fake_deregister) + return captured + + +def test_deregister_default_no_force_no_purge(monkeypatch: pytest.MonkeyPatch) -> None: + captured = _patch_deregister(monkeypatch) + result = runner.invoke(app, ["actor-config", "deregister", "my-actor.run-123"]) + assert result.exit_code == 0, f"stderr: {result.stderr}" + assert captured["actor"] == "my-actor.run-123" + assert captured["kwargs"]["force"] is False + assert captured["kwargs"]["purge_queue"] is False + + +def test_deregister_force_flag(monkeypatch: pytest.MonkeyPatch) -> None: + captured = _patch_deregister(monkeypatch) + result = runner.invoke(app, ["actor-config", "deregister", "my-actor", "--force"]) + assert result.exit_code == 0, f"stderr: {result.stderr}" + assert captured["kwargs"]["force"] is True + + +def test_deregister_purge_queue_flag(monkeypatch: pytest.MonkeyPatch) -> None: + captured = _patch_deregister(monkeypatch) + result = runner.invoke(app, ["actor-config", "deregister", "my-actor", "--purge-queue"]) + assert result.exit_code == 0, f"stderr: {result.stderr}" + assert captured["kwargs"]["purge_queue"] is True + + +def test_deregister_not_found_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: + from taskq.exceptions import ActorNotFoundError + + _patch_deregister(monkeypatch, raises=ActorNotFoundError("ghost")) + result = runner.invoke(app, ["actor-config", "deregister", "ghost"]) + assert result.exit_code == 1 + assert "no stored actor_config row" in result.stderr + + +def test_deregister_active_jobs_error_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: + from taskq.exceptions import ActorHasActiveJobsError + + _patch_deregister( + monkeypatch, + raises=ActorHasActiveJobsError( + "busy", active_count=3, status_counts={"pending": 2, "running": 1} + ), + ) + result = runner.invoke(app, ["actor-config", "deregister", "busy"]) + assert result.exit_code == 1 + assert "non-terminal" in result.stderr + assert "force=True" in result.stderr + + +def test_deregister_schedules_error_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: + from taskq.exceptions import ActorHasEnabledSchedulesError + + _patch_deregister( + monkeypatch, + raises=ActorHasEnabledSchedulesError("sched-actor", ["s1", "s2"]), + ) + result = runner.invoke(app, ["actor-config", "deregister", "sched-actor"]) + assert result.exit_code == 1 + assert "enabled cron schedule" in result.stderr + + +def test_deregister_output_shows_result(monkeypatch: pytest.MonkeyPatch) -> None: + result = DeregisterResult( + actor="my-actor", + queue="my-queue", + actor_config_deleted=True, + schedules_disabled=2, + jobs_cancelled=5, + terminal_jobs_remaining=10, + queue_purged=True, + ) + _patch_deregister(monkeypatch, result=result) + output = runner.invoke( + app, ["actor-config", "deregister", "my-actor", "--force", "--purge-queue"] + ) + assert output.exit_code == 0 + assert "deregistered" in output.output.lower() + assert "schedules_disabled=2" in output.output + assert "jobs_cancelled=5" in output.output + assert "terminal_jobs_remaining=10" in output.output + assert "queue_purged=true" in output.output.lower() + + +def test_deregister_double_deregister_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: + """Second deregister on an already-deregistered actor exits 1 with ActorNotFoundError.""" + from taskq.exceptions import ActorNotFoundError + + _patch_deregister(monkeypatch, raises=ActorNotFoundError("already-gone")) + result = runner.invoke(app, ["actor-config", "deregister", "already-gone"]) + assert result.exit_code == 1 + assert "no stored actor_config row" in result.stderr From 59d9d5c697e01dfb1bde8190ee82ba609ccb8cf1 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 16:44:16 -0700 Subject: [PATCH 06/20] feat: add TaskQ.actors property and export ActorsClient, DeregisterResult from public API --- src/taskq/__init__.py | 12 ++++++ src/taskq/client/__init__.py | 2 + src/taskq/client/_taskq.py | 21 +++++++++- tests/test_taskq_actors_property.py | 59 +++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/test_taskq_actors_property.py diff --git a/src/taskq/__init__.py b/src/taskq/__init__.py index e18c8f1f..0dc5de04 100644 --- a/src/taskq/__init__.py +++ b/src/taskq/__init__.py @@ -38,6 +38,7 @@ ) from taskq.batch import BatchCompletionStatus, BatchHandle, EnqueueItem, wait_for_batch from taskq.client import CancelResult, JobEvent, JobHandle, JobsClient, TaskQ +from taskq.client._actors import ActorsClient from taskq.client._enqueuer import SubJobEnqueuer from taskq.connections import ConnFactory, PoolFactory, RedisFactory, WorkerConnections from taskq.context import JobContext @@ -45,6 +46,10 @@ from taskq.exceptions import ( ActorConfigDriftError, ActorConfigDriftList, + ActorDeregistrationError, + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, BackpressureError, DependencyCycle, DIError, @@ -82,14 +87,20 @@ RetryPolicy, ) from taskq.scheduler import register_cron +from taskq.worker.actor_config_ops import DeregisterResult __all__ = [ "ActorConfigDriftError", "ActorConfigDriftList", + "ActorDeregistrationError", "ActorFn", "ActorFnWithCtx", "ActorHandler", + "ActorHasActiveJobsError", + "ActorHasEnabledSchedulesError", + "ActorNotFoundError", "ActorRef", + "ActorsClient", "BackpressureError", "BatchCompletionStatus", "BatchHandle", @@ -99,6 +110,7 @@ "CronScheduleSpec", "DIError", "DependencyCycle", + "DeregisterResult", "DstStrategy", "EnqueueItem", "ErrorReporter", diff --git a/src/taskq/client/__init__.py b/src/taskq/client/__init__.py index 8418f1f5..bfa483d8 100644 --- a/src/taskq/client/__init__.py +++ b/src/taskq/client/__init__.py @@ -9,6 +9,7 @@ anchors: (public API ownership). """ +from taskq.client._actors import ActorsClient from taskq.client._enqueuer import SubJobEnqueuer from taskq.client._handle import JobHandle from taskq.client._jobs import JobsClient @@ -16,6 +17,7 @@ from taskq.types import CancelResult __all__ = [ + "ActorsClient", "CancelResult", "JobEvent", "JobHandle", diff --git a/src/taskq/client/_taskq.py b/src/taskq/client/_taskq.py index fc21d32a..ec8d8e2d 100644 --- a/src/taskq/client/_taskq.py +++ b/src/taskq/client/_taskq.py @@ -67,6 +67,7 @@ async def create_task(payload: MyPayload): ) from taskq.backend.statemachine import TERMINAL_STATUSES from taskq.batch import BatchHandle, EnqueueItem +from taskq.client._actors import ActorsClient from taskq.client._handle import JobHandle from taskq.client._jobs import JobsClient from taskq.constants import RECLAIM_EVENT_VISIBILITY_DELAY, progress_channel, wake_channel @@ -74,7 +75,7 @@ async def create_task(payload: MyPayload): from taskq.progress._events import ProgressEvent from taskq.types import CancelResult -__all__ = ["EventRow", "JobEvent", "TaskQ"] +__all__ = ["ActorsClient", "EventRow", "JobEvent", "TaskQ"] logger = structlog.get_logger("taskq.client._taskq") @@ -219,6 +220,7 @@ def __init__( self._reclaim_event_visibility_delay = reclaim_event_visibility_delay self._owns_pool = pool is None self._client: JobsClient | None = None + self._actors_client: ActorsClient | None = None # ── Lifecycle ────────────────────────────────────────────────────────── @@ -273,6 +275,7 @@ async def open(self) -> None: if self._redis_url is not None: settings.redis_url = self._redis_url # type: ignore[assignment] # Why: dotenvmodel PostgresDsn/RedisDsn fields accept str values at runtime but pyright cannot verify the coercion through the model's __setattr__. self._client = JobsClient(backend, settings=settings) + self._actors_client = ActorsClient(pool, schema=self._schema) if self._redis_client is not None: self._client._redis_client = self._redis_client # pyright: ignore[reportPrivateUsage] # Why: TaskQ owns the JobsClient lifecycle; assigning the caller-owned redis_client directly bypasses _open_redis so the client is NOT entered on the exit stack — TaskQ.close() must not close a caller-owned client. elif self._redis_url is not None: @@ -287,6 +290,7 @@ async def close(self) -> None: if self._client is not None: await self._client.close() self._client = None + self._actors_client = None if self._owns_pool and self._pool is not None: # Why bounded: an enqueue in flight at close time can stall # Pool.close() indefinitely against a dead PG. @@ -309,6 +313,21 @@ def _require_open(self) -> JobsClient: ) return self._client + # ── Actor configuration ──────────────────────────────────────────────── + + @property + def actors(self) -> ActorsClient: + """Actor configuration client — list, get, set capacity, deregister. + + Raises RuntimeError if called before ``open()`` or outside an + ``async with`` block. + """ + if self._actors_client is None: + raise RuntimeError( + "TaskQ is not open. Call 'await tq.open()' or use 'async with TaskQ(...) as tq:'" + ) + return self._actors_client + # ── Job operations ───────────────────────────────────────────────────── async def enqueue[P: BaseModel, R: BaseModel | None]( diff --git a/tests/test_taskq_actors_property.py b/tests/test_taskq_actors_property.py new file mode 100644 index 00000000..73a6e693 --- /dev/null +++ b/tests/test_taskq_actors_property.py @@ -0,0 +1,59 @@ +"""Tests for TaskQ.actors property and public API exports.""" + +from __future__ import annotations + +import pytest + +from taskq.testing.fixtures import ModulePgSchema + + +def test_actors_client_importable_from_taskq() -> None: + from taskq import ActorsClient + + assert ActorsClient is not None + + +def test_deregister_result_importable_from_taskq() -> None: + from taskq import DeregisterResult + + assert DeregisterResult is not None + + +def test_deregistration_exceptions_importable_from_taskq() -> None: + from taskq import ( + ActorDeregistrationError, + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, + ) + + assert ActorDeregistrationError is not None + assert ActorHasActiveJobsError is not None + assert ActorHasEnabledSchedulesError is not None + assert ActorNotFoundError is not None + + +def test_taskq_actors_raises_before_open() -> None: + """Accessing tq.actors before open() raises RuntimeError.""" + from taskq import TaskQ + + tq = TaskQ(dsn="postgresql://fake:fake@localhost/fake") + with pytest.raises(RuntimeError, match="not open"): + _ = tq.actors + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_taskq_actors_property_returns_actors_client( + module_pg_schema: ModulePgSchema, +) -> None: + """TaskQ.actors returns an ActorsClient bound to the same pool and schema.""" + from taskq import TaskQ + from taskq.client._actors import ActorsClient + + async with TaskQ( + dsn=module_pg_schema.pg_dsn, + schema=module_pg_schema.schema_name, + ) as tq: + client = tq.actors + assert isinstance(client, ActorsClient) From 982e098c6e2e7481955fe24bf311a39a3b369895 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 16:45:30 -0700 Subject: [PATCH 07/20] feat: add admin UI actors page with deregister button Adds GET /actors (lists actor_config rows with active job counts and schedule counts) and POST /actors/{actor}/deregister (deregister with force + purge_queue form params, gated on admin_actions_enabled). - src/taskq/web/admin/actors.py: route module following the workers.py pattern with CSRF validation and admin_actions_enabled gate - src/taskq/web/templates/actors.html: table view with per-row deregister form (force/purge_queue checkboxes, confirm dialog) - src/taskq/web/templates/_base.html: Actors nav link after Workers - tests/test_web_admin_actors.py: 4 integration tests covering page listing, form rendering, 403 on disabled actions, and successful deregister redirect + row deletion - pyproject.toml: S608 per-file ignore for the new test file --- pyproject.toml | 3 + src/taskq/web/admin/actors.py | 94 +++++++++++++++ src/taskq/web/templates/_base.html | 3 + src/taskq/web/templates/actors.html | 63 ++++++++++ tests/test_web_admin_actors.py | 172 ++++++++++++++++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 src/taskq/web/admin/actors.py create mode 100644 src/taskq/web/templates/actors.html create mode 100644 tests/test_web_admin_actors.py diff --git a/pyproject.toml b/pyproject.toml index db397dc6..26e33d6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -274,6 +274,9 @@ ignore = [ # Same rationale — schema name validated against _IDENT_RE; all # user-supplied values use $N parameter binding. "tests/test_web_admin_integration.py" = ["S608"] +# Same rationale — schema name validated against _IDENT_RE; all +# user-supplied values use $N parameter binding. +"tests/test_web_admin_actors.py" = ["S608"] # FastAPI Depends() in test route-handler defaults is the same idiomatic # declarative-injection pattern as web/admin — not a real mutable default. "tests/test_sso_session.py" = ["B008"] diff --git a/src/taskq/web/admin/actors.py b/src/taskq/web/admin/actors.py new file mode 100644 index 00000000..35912c26 --- /dev/null +++ b/src/taskq/web/admin/actors.py @@ -0,0 +1,94 @@ +"""Actors overview and deregister admin pages.""" + +from urllib.parse import quote_plus + +import asyncpg +import structlog +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from jinja2 import Environment + +from taskq.exceptions import ActorDeregistrationError +from taskq.settings import TaskQSettings +from taskq.web.admin._factory import ( + get_base_path, + get_csrf_token, + get_pg_pool, + get_realtime_ctx, + get_schema, + get_settings, + get_templates, + validate_csrf, +) +from taskq.worker.actor_config_ops import deregister_actor + +logger = structlog.get_logger("taskq.web.admin.actors") + +_ACTORS_SQL = """ +SELECT ac.actor, ac.max_concurrent, ac.max_pending, ac.queue, + ac.result_ttl, ac.metadata::text AS metadata, ac.updated_at::text AS updated_at, + (SELECT count(*) FROM "{schema}".jobs j + WHERE j.actor = ac.actor + AND j.status IN ('pending', 'scheduled', 'running')) AS active_job_count, + (SELECT count(*) FROM "{schema}".cron_schedules cs + WHERE cs.actor = ac.actor AND cs.enabled = true) AS enabled_schedule_count + FROM "{schema}".actor_config ac + ORDER BY ac.actor +""".strip() + + +def register(router: APIRouter) -> None: + """Attach actors overview and deregister routes to *router*.""" + + @router.get("/actors", response_class=HTMLResponse) + async def actors_overview( # pyright: ignore[reportUnusedFunction] # Why: registered via FastAPI decorator; pyright cannot see the route registration. + pool: asyncpg.Pool = Depends(get_pg_pool), + schema: str = Depends(get_schema), + tmpl: Environment = Depends(get_templates), + realtime_ctx: tuple[str, str] = Depends(get_realtime_ctx), + csrf_token: str = Depends(get_csrf_token), + ) -> HTMLResponse: + actors_sql = _ACTORS_SQL.format(schema=schema) + rows: list[asyncpg.Record] = [] + async with pool.acquire() as conn: + rows = await conn.fetch(actors_sql) + actors = [dict(r) for r in rows] + realtime_mode, mode_label = realtime_ctx + html = tmpl.get_template("actors.html").render( + actors=actors, + realtime_mode=realtime_mode, + mode_label=mode_label, + csrf_token=csrf_token, + active_page="actors", + ) + return HTMLResponse(content=html) + + @router.post("/actors/{actor}/deregister") + async def actor_deregister( # pyright: ignore[reportUnusedFunction] # Why: registered via FastAPI decorator; pyright cannot see the route registration. + actor: str, + request: Request, + _csrf: None = Depends(validate_csrf), + pool: asyncpg.Pool = Depends(get_pg_pool), + schema: str = Depends(get_schema), + base_path: str = Depends(get_base_path), + settings: TaskQSettings = Depends(get_settings), + ) -> RedirectResponse: + if not settings.admin_actions_enabled: + raise HTTPException(status_code=403, detail="Admin actions are disabled") + + form = await request.form() + force = form.get("force") == "true" + purge_queue = form.get("purge_queue") == "true" + + async with pool.acquire() as conn: + try: + await deregister_actor( + conn, actor, force=force, purge_queue=purge_queue, schema=schema + ) + except ActorDeregistrationError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from None + + return RedirectResponse( + url=f"{base_path}/actors?notice=deregistered+{quote_plus(actor)}", + status_code=303, + ) diff --git a/src/taskq/web/templates/_base.html b/src/taskq/web/templates/_base.html index 53eaaa54..b13615e2 100644 --- a/src/taskq/web/templates/_base.html +++ b/src/taskq/web/templates/_base.html @@ -51,6 +51,9 @@

TaskQ Admin

Workers + Actors Schedules diff --git a/src/taskq/web/templates/actors.html b/src/taskq/web/templates/actors.html new file mode 100644 index 00000000..efaba0e1 --- /dev/null +++ b/src/taskq/web/templates/actors.html @@ -0,0 +1,63 @@ +{% extends "_base.html" %} +{% block title %}Actors — TaskQ Admin{% endblock %} +{% block content %} +
+

Actors

+ {% if actors %} +
+ + + + + + + + + + + + + + + {% for a in actors %} + + + + + + + + + + + {% endfor %} + +
ActorQueueMax ConcurrentMax PendingActive JobsSchedulesUpdatedActions
{{ a.actor }}{{ a.queue }}{{ a.max_concurrent or '∞' }}{{ a.max_pending or '—' }} + + {{ a.active_job_count }} + + {{ a.enabled_schedule_count }}{{ a.updated_at | time_ago }} +
+ + + + +
+
+
+ {% else %} +

No actor_config rows.

+ {% endif %} +
+{% endblock %} diff --git a/tests/test_web_admin_actors.py b/tests/test_web_admin_actors.py new file mode 100644 index 00000000..6c502787 --- /dev/null +++ b/tests/test_web_admin_actors.py @@ -0,0 +1,172 @@ +"""Tests for the admin UI actors page and deregister route. + +Follows the pattern of tests/test_web_admin_integration.py: a per-test +asyncpg pool on the module's migrated schema, a FastAPI app built via +create_router + setup_admin_state + include_router, and httpx.AsyncClient +with ASGITransport. + +CSRF uses the synchronizer-token pattern: GET sets the taskq_csrf_token +cookie; POST must include it as the csrf_token form field. +""" + +from collections.abc import AsyncIterator + +import asyncpg +import httpx +import pytest +import pytest_asyncio +from fastapi import FastAPI + +from taskq.testing.fixtures import ModulePgSchema +from taskq.web.admin import create_router, setup_admin_state +from taskq.worker.actor_config import ActorConfig +from taskq.worker.startup import sync_actor_config + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +@pytest_asyncio.fixture +async def admin_pool(module_pg_schema: ModulePgSchema) -> AsyncIterator[asyncpg.Pool]: + pool = await asyncpg.create_pool(module_pg_schema.pg_dsn, min_size=1, max_size=4) + assert pool is not None + try: + yield pool + finally: + await pool.close() + + +def _make_admin_app( + pool: asyncpg.Pool, + schema: str, + monkeypatch: pytest.MonkeyPatch, + *, + admin_actions_enabled: bool, +) -> FastAPI: + # setenv must precede create_router — it calls TaskQSettings.load() internally + monkeypatch.setenv("TASKQ_ENVIRONMENT", "dev") + monkeypatch.setenv("TASKQ_ADMIN_ACTIONS_ENABLED", "true" if admin_actions_enabled else "false") + bundle = create_router(pool, schema=schema, base_path="/admin") + app = FastAPI() + setup_admin_state(app, bundle) + app.include_router(bundle.router, prefix="/admin") + return app + + +async def _seed_actor_config( + conn: asyncpg.Connection, + schema: str, + actor: str, + queue: str = "default", +) -> None: + await sync_actor_config( + conn, + [ActorConfig(actor=actor, max_concurrent=1, queue=queue)], + schema=schema, + ) + + +async def _get_csrf_then_post( + app: FastAPI, + get_url: str, + post_url: str, + data: dict[str, str] | None = None, +) -> httpx.Response: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + follow_redirects=False, + ) as client: + get_resp = await client.get(get_url) + assert get_resp.status_code == 200 + csrf_token = get_resp.cookies.get("taskq_csrf_token", "") + assert csrf_token, "GET must set the taskq_csrf_token cookie" + return await client.post(post_url, data={"csrf_token": csrf_token, **(data or {})}) + + +async def test_actors_page_lists_actor_config_rows( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "test-actor-1", queue="default") + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.get("/admin/actors") + + assert resp.status_code == 200 + assert "test-actor-1" in resp.text + assert "default" in resp.text + + +async def test_actors_page_shows_deregister_form( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "button-actor", queue="default") + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.get("/admin/actors") + + assert resp.status_code == 200 + # Targeted assertions — check for the specific form action and form fields, + # not just the word "Deregister" which could appear anywhere. + assert 'action="/admin/actors/button-actor/deregister"' in resp.text + assert 'name="force"' in resp.text + assert 'name="purge_queue"' in resp.text + + +async def test_deregister_route_returns_403_when_admin_actions_disabled( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "disabled-actor", queue="default") + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=False) + + resp = await _get_csrf_then_post( + app, "/admin/actors", "/admin/actors/disabled-actor/deregister" + ) + + assert resp.status_code == 403 + count = await clean_pg_conn.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', + "disabled-actor", + ) + assert count == 1 + + +async def test_deregister_route_succeeds_for_clean_actor( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "clean-deregister-actor", queue="default") + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + resp = await _get_csrf_then_post( + app, "/admin/actors", "/admin/actors/clean-deregister-actor/deregister" + ) + + assert resp.status_code == 303 + assert "/actors" in resp.headers["location"] + + count = await clean_pg_conn.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', + "clean-deregister-actor", + ) + assert count == 0 From 1d90e4f9b628f31bbe81a221468ffed118af806d Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 16:47:22 -0700 Subject: [PATCH 08/20] test: add e2e test for actor deregistration lifecycle --- tests/e2e/test_actor_deregistration.py | 164 +++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/e2e/test_actor_deregistration.py diff --git a/tests/e2e/test_actor_deregistration.py b/tests/e2e/test_actor_deregistration.py new file mode 100644 index 00000000..3c03a477 --- /dev/null +++ b/tests/e2e/test_actor_deregistration.py @@ -0,0 +1,164 @@ +"""E2E: actor deregistration lifecycle with a real worker. + +Each test uses a **different actor** to avoid cross-test interference: +``e2e_worker`` is module-scoped and ``sync_actor_config`` runs only at +bootstrap, so once a test deregisters an actor's ``actor_config`` row, +later tests cannot enqueue to that same actor (the dispatch query +inner-joins ``actor_config`` — jobs would never be dispatched). + +Actors used (all defined in ``tests/e2e/actors.py``): +- ``quick_result`` — 0.05 s sleep, simple payload/result. +- ``long_running_job`` — 30 s sleep. Used for the refusal-with-active-jobs test. +- ``short_lived_job`` — 0.5 s sleep. Used for the force+purge_queue test. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from ._assertions import poll_until, wait_for_handle_status +from .actors import ( + LongRunningPayload, + QuickResultPayload, + ShortJobPayload, + long_running_job, + quick_result, + short_lived_job, +) + +if TYPE_CHECKING: + import asyncpg + + from taskq import TaskQ + + from .conftest import E2ESchema, E2EWorker + +pytestmark = [pytest.mark.e2e, pytest.mark.timeout(900)] + + +async def test_deregister_after_jobs_complete( + e2e_client: TaskQ, + e2e_worker: E2EWorker, + e2e_pg_pool: asyncpg.Pool, + e2e_schema: E2ESchema, + run_id: str, +) -> None: + """Deregister an actor after all its jobs are terminal.""" + schema = e2e_schema.schema_name + actor_name = quick_result.name + + handle = await e2e_client.enqueue(quick_result, QuickResultPayload(run_id=run_id, value="test")) + await handle.wait(timeout=60) + + ac_count = await e2e_pg_pool.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', + actor_name, + ) + assert ac_count == 1 + + result = await e2e_client.actors.deregister(actor_name) + + assert result.actor_config_deleted is True + assert result.terminal_jobs_remaining >= 1 + + ac_count = await e2e_pg_pool.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', + actor_name, + ) + assert ac_count == 0 + + job_count = await e2e_pg_pool.fetchval( + f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = $1 AND status = 'succeeded'", + actor_name, + ) + assert job_count >= 1 + + +async def test_deregister_refuses_with_active_jobs( + e2e_client: TaskQ, + e2e_worker: E2EWorker, + e2e_pg_pool: asyncpg.Pool, + e2e_schema: E2ESchema, + run_id: str, +) -> None: + """Deregistration refuses when a job is running.""" + from taskq.exceptions import ActorHasActiveJobsError + + schema = e2e_schema.schema_name + actor_name = long_running_job.name + + handle = await e2e_client.enqueue(long_running_job, LongRunningPayload(run_id=run_id)) + + async def _is_running() -> bool: + status = await e2e_pg_pool.fetchval( + f'SELECT status FROM "{schema}".jobs WHERE id = $1', + handle.job_id, + ) + return status == "running" + + await poll_until(_is_running, timeout=30.0, interval=0.5) + + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await e2e_client.actors.deregister(actor_name) + + assert exc_info.value.actor == actor_name + assert exc_info.value.active_count >= 1 + assert "running" in exc_info.value.status_counts + + ac_count = await e2e_pg_pool.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', + actor_name, + ) + assert ac_count == 1 + + # Cleanup: cancel the job, wait for terminal, then force-deregister. + # Do NOT use handle.wait() — it raises JobFailed for cancelled status. + # long_running_job never calls ctx.check_cancelled(), so the cancel + # lands only after the 30s sleep finishes — budget the full duration. + await handle.cancel() + await wait_for_handle_status(handle, "cancelled", timeout=60) + + result = await e2e_client.actors.deregister(actor_name, force=True) + assert result.actor_config_deleted is True + + ac_count = await e2e_pg_pool.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', + actor_name, + ) + assert ac_count == 0 + + +async def test_deregister_force_with_purge_queue_after_completion( + e2e_client: TaskQ, + e2e_worker: E2EWorker, + e2e_pg_pool: asyncpg.Pool, + e2e_schema: E2ESchema, + run_id: str, +) -> None: + """force=True + purge_queue=True deregister succeeds when all jobs are terminal. + + Uses short_lived_job (0.5s sleep). Enqueues a job, waits for completion, + then force-deregisters with purge_queue. The force path cancels 0 jobs + (none are pending) and the queue is purged if no other actor uses it. + """ + schema = e2e_schema.schema_name + actor_name = short_lived_job.name + + handle = await e2e_client.enqueue( + short_lived_job, ShortJobPayload(run_id=run_id, label="force-test") + ) + await handle.wait(timeout=60) + + result = await e2e_client.actors.deregister(actor_name, force=True, purge_queue=True) + + assert result.actor_config_deleted is True + assert result.jobs_cancelled == 0 + assert result.terminal_jobs_remaining >= 1 + + ac_count = await e2e_pg_pool.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', + actor_name, + ) + assert ac_count == 0 From 1042fcbcbf2f82598358fbc36d0bc030382b2971 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 16:47:33 -0700 Subject: [PATCH 09/20] docs: add actor deregistration documentation --- docs/guides/actors.md | 75 +++++++++++++++++++++++++++++++++++++++++ docs/guides/admin-ui.md | 13 +++++++ docs/guides/cli.md | 16 +++++++++ 3 files changed, 104 insertions(+) diff --git a/docs/guides/actors.md b/docs/guides/actors.md index 2d1705e5..12b774aa 100644 --- a/docs/guides/actors.md +++ b/docs/guides/actors.md @@ -25,6 +25,7 @@ the actor's payload and result types end-to-end. 15. [Progress reporting](#progress-reporting) 16. [Testing actors without a database](#testing-actors-without-a-database) 17. [Full worked example](#full-worked-example) +18. [Actor deregistration](#actor-deregistration) --- @@ -944,3 +945,77 @@ async def submit_order(client, order_id: str, customer_id: str, amount_cents: in print(f"confirmed: {result.confirmation_number}") return handle.job_id ``` + +--- + +## Actor deregistration + +Actors registered by worker startup create `actor_config` rows that persist +until explicitly removed. For long-lived deployments this is intentional — +the row is the source of truth for capacity and routing. For ephemeral, +per-run deployments (e.g. `my-actor.`), each run leaves a row behind. + +### `client.actors.deregister()` + +```python +async with TaskQ(dsn=...) as tq: + result = await tq.actors.deregister("my-actor.run-123") + # force=False: refuses if non-terminal jobs or enabled schedules exist +``` + +**Safety checks (force=False):** +- Refuses if any non-terminal jobs (pending/scheduled/running) reference the + actor. +- Refuses if any enabled cron schedules reference the actor. + +**force=True:** +- Still refuses if **running** jobs exist (they are actively executing). +- Cancels pending/scheduled jobs (marks as `cancelled` with + `error_class='ActorDeregistered'`). +- Disables enabled cron schedules (sets `enabled=false`). + +**Terminal job history** is never deleted. The `jobs.actor` column is plain +text, not a foreign key — terminal rows remain queryable by actor name after +deregistration. + +**Queue cleanup** (`purge_queue=True`): deletes the `queues` row if no other +`actor_config` references the same queue. A shared queue is never purged. + +### Enqueue after deregistration + +After deregistration, any client can still `enqueue()` the dead actor name — +the `INSERT` succeeds (there is no foreign key from `jobs.actor` to +`actor_config.actor`), and the job sits in `pending` status forever. Because +the dispatch query inner-joins `actor_config`, the job will **never be +dispatched** and no background sweep will reap it. + +**Operational discipline:** stop enqueuing to an actor *before* deregistering +it. Deregistration is best-effort against concurrent enqueue/dispatch; +callers must quiesce the actor first. + +### Idempotent deregistration + +A second `deregister` call on an already-deregistered actor raises +`ActorNotFoundError`. For cleanup-automation loops: + +```python +from taskq.exceptions import ActorNotFoundError + +try: + await tq.actors.deregister(actor_name, force=True, purge_queue=True) +except ActorNotFoundError: + pass # already deregistered — idempotent +``` + +### CLI + +```bash +taskq actor-config deregister my-actor.run-123 +taskq actor-config deregister my-actor.run-123 --force --purge-queue +``` + +### Admin UI + +The `/admin/actors` page lists all `actor_config` rows with active job counts +and schedule counts. Each row has a deregister form with `force` and +`purge_queue` checkboxes (requires `TASKQ_ADMIN_ACTIONS_ENABLED=true`). diff --git a/docs/guides/admin-ui.md b/docs/guides/admin-ui.md index cf93f82c..377c2fb7 100644 --- a/docs/guides/admin-ui.md +++ b/docs/guides/admin-ui.md @@ -342,6 +342,19 @@ Rate-limit state page. Reads all rows from `rate_limit_buckets` (bucket name, ki Reservation slot summary. For each `bucket_name` in `reservation_slots`, shows the count of held slots (where `job_id IS NOT NULL`), free slots, and total slots. +### `GET /admin/actors` + +The `/admin/actors` page lists all stored `actor_config` rows with: + +- Actor name, queue, max concurrent, max pending +- Active job count (pending + scheduled + running) +- Enabled schedule count +- Last updated timestamp + +Each row has a **Deregister** button with `force` and `purge queue` checkboxes. +Deregistration requires `TASKQ_ADMIN_ACTIONS_ENABLED=true`. The form is +CSRF-protected via the synchronizer-token pattern. + ### `GET /admin/sse/{topic}` SSE (Server-Sent Events) endpoint. Accepts any `topic` string. On connect it emits an initial `event: status` frame with `{"status": "awaiting_progress_backend"}`, then sends `: keepalive` comments every 30 seconds to prevent connection timeout. See [Real-time vs polling mode](#real-time-vs-polling-mode) below. diff --git a/docs/guides/cli.md b/docs/guides/cli.md index 441d389b..ab8ee85c 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -303,6 +303,22 @@ Per actor and capacity field this prints the `@actor(...)` literal, the stored v `taskq actor-config set` requires the actor to have a stored row already (created by a worker startup that registered it). `queue` and `metadata` are structural and are only ever changed by redeploying with a new `@actor(...)` registration (plus `--force-update-actor-config` if a stored row already exists). +### `taskq actor-config deregister` + +Deregister an actor: delete its `actor_config` row with safety checks. + +```bash +taskq actor-config deregister [--force] [--purge-queue] +``` + +- `` — actor name (positional argument) +- `--force` — cancel pending/scheduled jobs, disable enabled cron schedules, + and proceed despite non-terminal jobs. Running jobs still block. +- `--purge-queue` — also delete the orphaned `queues` row if no other actor + references it. + +Exit code 0 on success, 1 on refusal (with error message) or not found. + ### Exit codes | Code | Meaning | From f924c399b0b7d26c5356aab92ed9f0d29245454b Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 17:04:55 -0700 Subject: [PATCH 10/20] fix: review findings + add full-stack client integration tests - M1: Admin UI now consumes notice query param and shows success banner - L1: Remove unused structlog loggers from _actors.py and admin/actors.py - Add missing 409 test for deregister with active jobs - Add full-stack integration tests via TaskQ.actors client path --- src/taskq/client/_actors.py | 4 - src/taskq/web/admin/actors.py | 5 +- src/taskq/web/templates/actors.html | 5 + tests/test_actor_deregistration_client.py | 192 ++++++++++++++++++++++ tests/test_web_admin_actors.py | 33 ++++ 5 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 tests/test_actor_deregistration_client.py diff --git a/src/taskq/client/_actors.py b/src/taskq/client/_actors.py index b4b4df6b..a9aced3e 100644 --- a/src/taskq/client/_actors.py +++ b/src/taskq/client/_actors.py @@ -8,8 +8,6 @@ from typing import TYPE_CHECKING -import structlog - from taskq.worker.actor_config_ops import ( UNSET, ActorConfigRow, @@ -26,8 +24,6 @@ __all__ = ["ActorsClient"] -logger = structlog.get_logger("taskq.client._actors") - class ActorsClient: """Pool-wrapping facade for actor configuration operations. diff --git a/src/taskq/web/admin/actors.py b/src/taskq/web/admin/actors.py index 35912c26..6fb26324 100644 --- a/src/taskq/web/admin/actors.py +++ b/src/taskq/web/admin/actors.py @@ -3,7 +3,6 @@ from urllib.parse import quote_plus import asyncpg -import structlog from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse from jinja2 import Environment @@ -22,8 +21,6 @@ ) from taskq.worker.actor_config_ops import deregister_actor -logger = structlog.get_logger("taskq.web.admin.actors") - _ACTORS_SQL = """ SELECT ac.actor, ac.max_concurrent, ac.max_pending, ac.queue, ac.result_ttl, ac.metadata::text AS metadata, ac.updated_at::text AS updated_at, @@ -47,6 +44,7 @@ async def actors_overview( # pyright: ignore[reportUnusedFunction] # Why: regi tmpl: Environment = Depends(get_templates), realtime_ctx: tuple[str, str] = Depends(get_realtime_ctx), csrf_token: str = Depends(get_csrf_token), + notice: str | None = None, ) -> HTMLResponse: actors_sql = _ACTORS_SQL.format(schema=schema) rows: list[asyncpg.Record] = [] @@ -60,6 +58,7 @@ async def actors_overview( # pyright: ignore[reportUnusedFunction] # Why: regi mode_label=mode_label, csrf_token=csrf_token, active_page="actors", + notice=notice, ) return HTMLResponse(content=html) diff --git a/src/taskq/web/templates/actors.html b/src/taskq/web/templates/actors.html index efaba0e1..78d603fd 100644 --- a/src/taskq/web/templates/actors.html +++ b/src/taskq/web/templates/actors.html @@ -3,6 +3,11 @@ {% block content %}

Actors

+ {% if notice %} +
+ {{ notice }} +
+ {% endif %} {% if actors %}
diff --git a/tests/test_actor_deregistration_client.py b/tests/test_actor_deregistration_client.py new file mode 100644 index 00000000..8f0a5ee2 --- /dev/null +++ b/tests/test_actor_deregistration_client.py @@ -0,0 +1,192 @@ +"""Full-stack integration tests for actor deregistration via TaskQ.actors. + +Exercises the complete client path: TaskQ → ActorsClient → pool → +deregister_actor → real Postgres. No Docker worker container needed — +the tests seed actor_config rows directly and call through the client. +""" + +from uuid import uuid4 + +import asyncpg +import pytest + +from taskq.exceptions import ( + ActorHasActiveJobsError, + ActorNotFoundError, +) +from taskq.testing.fixtures import ModulePgSchema +from taskq.worker.actor_config import ActorConfig +from taskq.worker.startup import sync_actor_config + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def _seed_actor(conn: asyncpg.Connection, schema: str, actor: str, queue: str = "default") -> None: + await sync_actor_config( + conn, + [ActorConfig(actor=actor, max_concurrent=5, queue=queue)], + schema=schema, + ) + + +async def _insert_job(conn: asyncpg.Connection, schema: str, actor: str, status: str) -> None: + await conn.execute( + f'INSERT INTO "{schema}".jobs (id, actor, queue, payload, status, max_attempts, retry_kind) ' # noqa: S608 # Why: schema validated by _IDENT_RE in apply_pending; actor/status are test constants. + f"VALUES ($1, $2, 'default', '{{}}'::jsonb, $3::\"{schema}\".job_status, 3, 'transient')", + uuid4(), + actor, + status, + ) + + +async def _insert_queue(conn: asyncpg.Connection, schema: str, name: str) -> None: + await conn.execute(f'INSERT INTO "{schema}".queues (name) VALUES ($1)', name) # noqa: S608 # Why: schema validated by _IDENT_RE in apply_pending; name is a test constant. + + +async def test_client_deregister_clean_actor( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """Full client path: deregister an actor with no jobs or schedules.""" + from taskq import TaskQ + + schema = module_pg_schema.schema_name + await _seed_actor(clean_pg_conn, schema, "client_clean_actor") + + async with TaskQ(dsn=module_pg_schema.pg_dsn, schema=schema) as tq: + result = await tq.actors.deregister("client_clean_actor") + + assert result.actor_config_deleted is True + assert result.actor == "client_clean_actor" + + +async def test_client_deregister_not_found( + module_pg_schema: ModulePgSchema, +) -> None: + """Full client path: ActorNotFoundError for unknown actor.""" + from taskq import TaskQ + + async with TaskQ(dsn=module_pg_schema.pg_dsn, schema=module_pg_schema.schema_name) as tq: + with pytest.raises(ActorNotFoundError, match="no stored actor_config row"): + await tq.actors.deregister("nonexistent_actor") + + +async def test_client_deregister_refuses_with_active_jobs( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """Full client path: ActorHasActiveJobsError when pending jobs exist.""" + from taskq import TaskQ + + schema = module_pg_schema.schema_name + await _seed_actor(clean_pg_conn, schema, "client_busy_actor") + await _insert_job(clean_pg_conn, schema, "client_busy_actor", "pending") + + async with TaskQ(dsn=module_pg_schema.pg_dsn, schema=schema) as tq: + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await tq.actors.deregister("client_busy_actor") + + assert exc_info.value.active_count == 1 + assert exc_info.value.status_counts == {"pending": 1} + + +async def test_client_deregister_force_cancels_jobs( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """Full client path: force=True cancels pending jobs through the client.""" + from taskq import TaskQ + + schema = module_pg_schema.schema_name + await _seed_actor(clean_pg_conn, schema, "client_force_actor") + await _insert_job(clean_pg_conn, schema, "client_force_actor", "pending") + await _insert_job(clean_pg_conn, schema, "client_force_actor", "scheduled") + + async with TaskQ(dsn=module_pg_schema.pg_dsn, schema=schema) as tq: + result = await tq.actors.deregister("client_force_actor", force=True) + + assert result.actor_config_deleted is True + assert result.jobs_cancelled == 2 + + # Verify jobs are cancelled in DB + cancelled_count = await clean_pg_conn.fetchval( + f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = 'client_force_actor' AND status = 'cancelled'" # noqa: S608 # Why: schema validated by _IDENT_RE in apply_pending; actor/status are test constants. + ) + assert cancelled_count == 2 + + +async def test_client_deregister_with_purge_queue( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """Full client path: purge_queue=True deletes orphaned queue.""" + from taskq import TaskQ + + schema = module_pg_schema.schema_name + await _insert_queue(clean_pg_conn, schema, "client_solo_queue") + await _seed_actor(clean_pg_conn, schema, "client_purge_actor", queue="client_solo_queue") + + async with TaskQ(dsn=module_pg_schema.pg_dsn, schema=schema) as tq: + result = await tq.actors.deregister("client_purge_actor", purge_queue=True) + + assert result.queue_purged is True + assert result.queue == "client_solo_queue" + + queue_count = await clean_pg_conn.fetchval( + f"SELECT count(*) FROM \"{schema}\".queues WHERE name = 'client_solo_queue'" # noqa: S608 # Why: schema validated by _IDENT_RE in apply_pending; name is a test constant. + ) + assert queue_count == 0 + + +async def test_client_double_deregister_raises_not_found( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """Full client path: second deregister raises ActorNotFoundError (idempotency).""" + from taskq import TaskQ + + schema = module_pg_schema.schema_name + await _seed_actor(clean_pg_conn, schema, "client_idem_actor") + + async with TaskQ(dsn=module_pg_schema.pg_dsn, schema=schema) as tq: + result = await tq.actors.deregister("client_idem_actor") + assert result.actor_config_deleted is True + + with pytest.raises(ActorNotFoundError): + await tq.actors.deregister("client_idem_actor") + + +async def test_client_actors_list_returns_rows( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """Full client path: tq.actors.list() returns seeded actor_config rows.""" + from taskq import TaskQ + + schema = module_pg_schema.schema_name + await _seed_actor(clean_pg_conn, schema, "client_list_actor") + + async with TaskQ(dsn=module_pg_schema.pg_dsn, schema=schema) as tq: + rows = await tq.actors.list() + + actors = [r.actor for r in rows] + assert "client_list_actor" in actors + + +async def test_client_actors_get_returns_row( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """Full client path: tq.actors.get() returns the specific actor row.""" + from taskq import TaskQ + + schema = module_pg_schema.schema_name + await _seed_actor(clean_pg_conn, schema, "client_get_actor") + + async with TaskQ(dsn=module_pg_schema.pg_dsn, schema=schema) as tq: + row = await tq.actors.get("client_get_actor") + assert row is not None + assert row.actor == "client_get_actor" + + missing = await tq.actors.get("nonexistent") + assert missing is None diff --git a/tests/test_web_admin_actors.py b/tests/test_web_admin_actors.py index 6c502787..bbf41209 100644 --- a/tests/test_web_admin_actors.py +++ b/tests/test_web_admin_actors.py @@ -170,3 +170,36 @@ async def test_deregister_route_succeeds_for_clean_actor( "clean-deregister-actor", ) assert count == 0 + + +async def test_deregister_route_returns_409_when_actor_has_active_jobs( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """POST deregister with an active job returns 409 Conflict.""" + from uuid import uuid4 + + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "blocked-actor", queue="default") + # Insert a pending job so force=False deregistration refuses. + await clean_pg_conn.execute( + f'INSERT INTO "{schema}".jobs (id, actor, queue, payload, status, max_attempts, retry_kind) ' + f"VALUES ($1, 'blocked-actor', 'default', '{{}}'::jsonb, 'pending'::\"{schema}\".job_status, 3, 'transient')", + uuid4(), + ) + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + resp = await _get_csrf_then_post( + app, "/admin/actors", "/admin/actors/blocked-actor/deregister" + ) + + assert resp.status_code == 409 + assert "non-terminal" in resp.text + # Row must still exist — deregistration was refused. + count = await clean_pg_conn.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', + "blocked-actor", + ) + assert count == 1 From c2b1674cbe96ec70fdc0d44823e3e96e2aef53ef Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 17:10:27 -0700 Subject: [PATCH 11/20] refactor: move actor_config and actor_config_ops to top-level package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These modules are shared by client, CLI, admin UI, testing, and __init__ — not worker-internal. Relocating them from taskq.worker.* to taskq.* fixes the layering violation where client code reached into the worker package. --- docs/specs/2026-07-29-actor-deregistration.md | 3164 +++++++++++++++++ src/taskq/__init__.py | 2 +- src/taskq/{worker => }/actor_config.py | 0 src/taskq/{worker => }/actor_config_ops.py | 0 src/taskq/cli.py | 6 +- src/taskq/client/_actors.py | 8 +- src/taskq/testing/_runner.py | 2 +- src/taskq/testing/in_memory.py | 2 +- src/taskq/web/admin/actors.py | 2 +- src/taskq/worker/__init__.py | 2 +- src/taskq/worker/_bootstrap.py | 2 +- src/taskq/worker/startup.py | 2 +- tests/test_actor_capacity_pg.py | 4 +- tests/test_actor_config.py | 2 +- tests/test_actor_config_ops.py | 6 +- tests/test_actor_config_ops_validation.py | 2 +- tests/test_actor_config_sync.py | 2 +- tests/test_actor_deregistration.py | 4 +- tests/test_actor_deregistration_client.py | 2 +- tests/test_actors_client.py | 4 +- tests/test_cli_actor_config.py | 2 +- tests/test_cli_actor_deregister.py | 2 +- tests/test_dispatch_pg.py | 2 +- tests/test_in_memory_terminal_writes.py | 2 +- tests/test_postgres_terminal_writes.py | 4 +- tests/test_web_admin_actors.py | 2 +- 26 files changed, 3198 insertions(+), 34 deletions(-) create mode 100644 docs/specs/2026-07-29-actor-deregistration.md rename src/taskq/{worker => }/actor_config.py (100%) rename src/taskq/{worker => }/actor_config_ops.py (100%) diff --git a/docs/specs/2026-07-29-actor-deregistration.md b/docs/specs/2026-07-29-actor-deregistration.md new file mode 100644 index 00000000..e7568101 --- /dev/null +++ b/docs/specs/2026-07-29-actor-deregistration.md @@ -0,0 +1,3164 @@ +# Actor Deregistration — `client.actors.deregister()` and Cleanup for Ephemeral Deployments + +**Date:** 2026-07-29 +**Status:** Draft, revised post-review (2026-07-29) +**Issue:** [#56](https://github.com/rich/taskq/issues/56) + +> **Scope note:** Issue #56 asks for `client.actors.deregister()` with +> defined safety semantics. This spec additionally builds the CLI command +> (`taskq actor-config deregister`) and admin UI page (`/admin/actors` with +> deregister button). These are justified under "operator surface" and are +> entirely additive (no changes to existing code paths), but they represent +> scope beyond the issue's literal ask and roughly half the plan's tasks. +> The issue author should confirm this scope expansion is desired. + +--- + +## Goal + +Provide a first-class actor deregistration API (`client.actors.deregister()`, +`taskq actor-config deregister`, admin UI button) with defined safety semantics +so that ephemeral, per-run actor deployments can clean up their `actor_config` +and orphaned `queues` rows without hand-rolled SQL. The default path refuses +deregistration while non-terminal jobs or enabled cron schedules reference the +actor; `force=True` documents and handles the consequences for terminal job +history, schedules, and stranded pending work. + +## Non-goals + +1. **No schema migration.** The existing schema has no FKs from `jobs.actor` or + `cron_schedules.actor` to `actor_config.actor` — deregistration is pure + application logic (a transactional set of checks + DELETEs). Adding FKs with + `ON DELETE` actions would require a migration and risk lock contention on + the hot `jobs` table; it is not needed for this feature. + +2. **No automatic GC sweep.** Deregistration is an explicit operator/client + action, not a background leader sweep. Ephemeral deployments know when their + run is done; a sweep would need heuristics to decide liveness, which is + application-specific. + +3. **No soft-delete / tombstone column.** The `actor_config` row is deleted + outright. Terminal job history (`jobs.actor` is a plain `text` column, not + an FK) remains queryable by actor name after deregistration — that is the + documented, intentional behavior. + +4. **No re-registration resurrection.** If an actor is re-registered by a + worker startup after deregistration, it creates a fresh `actor_config` row + with seed values — the same behavior as any first-time registration. + +5. **No changes to the drift-check semantics.** `_STRUCTURAL_FIELDS` and + `ActorConfigDriftList` remain as-is. Deregistration is the cleanup path for + the pattern the drift check funnels ephemeral deployments into; it does not + weaken the drift check. + +--- + +## Architecture Overview + +### Current state + +``` + ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ + │ TaskQ │ │ JobsClient │ │ Backend │ + │ (client) │────▶│ (enqueue/get/ │────▶│ (PostgresBackend) + │ │ │ list/cancel) │ │ │ + └─────────────┘ └──────────────────┘ └─────────────────┘ + │ │ + │ ▼ + │ ┌──────────────┐ + │ │ Postgres │ + │ │ actor_config│ + │ │ queues │ + │ │ jobs │ + │ │ cron_schedules│ + │ └──────────────┘ + │ + ┌───────┴────────┐ + │ actor_config_ops│ (list/get/set_capacity) + │ (ConnLike-level)│ NO delete + └────────────────┘ + + CLI: taskq actor-config list/get/set/diff (no deregister) + Admin UI: queues/jobs/workers/schedules/... (no actors page) +``` + +### Proposed state + +``` + ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ + │ TaskQ │ │ JobsClient │ │ Backend │ + │ (client) │────▶│ (enqueue/get/ │────▶│ (PostgresBackend) + │ .actors ───┼───▶│ list/cancel) │ │ │ + └─────────────┘ └──────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ ActorsClient │───────────────────────────▶│ Postgres │ + │ (deregister/ │ │ actor_config│ + │ list/get/ │ │ queues │ + │ set_capacity)│ │ jobs │ + └──────────────┘ │ cron_schedules│ + │ └──────────────┘ + ▼ + ┌────────────────┐ + │ actor_config_ops│ (list/get/set_capacity + │ + deregister │ + deregister_actor) + └────────────────┘ + + CLI: taskq actor-config list/get/set/diff/deregister + Admin UI: /actors page with deregister button +``` + +### File structure + +| Action | Path | Responsibility | +|--------|------|----------------| +| **Modify** | `src/taskq/worker/actor_config_ops.py` | Add `deregister_actor()` + `DeregisterResult` dataclass + SQL templates | +| **Create** | `src/taskq/client/_actors.py` | `ActorsClient` class — pool-wrapping facade over `actor_config_ops` | +| **Modify** | `src/taskq/client/_taskq.py` | Add `TaskQ.actors` property returning `ActorsClient` | +| **Modify** | `src/taskq/client/__init__.py` | Export `ActorsClient` | +| **Modify** | `src/taskq/exceptions.py` | Add `ActorDeregistrationError`, `ActorHasActiveJobsError`, `ActorHasEnabledSchedulesError` | +| **Modify** | `src/taskq/cli.py` | Add `taskq actor-config deregister` command | +| **Create** | `src/taskq/web/admin/actors.py` | Admin UI actors page + deregister POST route | +| **Create** | `src/taskq/web/templates/actors.html` | Jinja2 template for actors list + deregister button | +| **Modify** | `src/taskq/web/templates/_base.html` | Add "Actors" nav link | +| **Modify** | `src/taskq/__init__.py` | Export `ActorsClient` from public API | +| **Create** | `tests/test_actor_deregistration.py` | Integration tests for `deregister_actor` | +| **Create** | `tests/test_cli_actor_deregister.py` | CLI tests for `taskq actor-config deregister` | +| **Create** | `tests/test_actors_client.py` | Tests for `ActorsClient` | +| **Create** | `tests/test_web_admin_actors.py` | Admin UI actor page + deregister route tests | +| **Create** | `tests/e2e/test_actor_deregistration.py` | E2E: real worker, enqueue jobs, deregister, verify cleanup | +| **Modify** | `docs/guides/actors.md` | Document deregistration API + semantics | +| **Modify** | `docs/guides/cli.md` | Document `taskq actor-config deregister` command | +| **Modify** | `docs/guides/admin-ui.md` | Document actors page + deregister button | + +--- + +## API Surface + +### Exceptions (`src/taskq/exceptions.py`) + +```python +class ActorDeregistrationError(TaskQError): + """Base for actor deregistration refusals.""" + + def __init__(self, actor: str, detail: str) -> None: + self.actor = actor + super().__init__(f"Cannot deregister actor {actor!r}: {detail}") + + +class ActorHasActiveJobsError(ActorDeregistrationError): + """Non-terminal jobs reference the actor. + + Carries the count and statuses of the blocking jobs so the caller can + decide whether to cancel them first or use force=True. + """ + + def __init__( + self, + actor: str, + active_count: int, + status_counts: dict[str, int], + ) -> None: + self.active_count = active_count + self.status_counts = status_counts + detail = ( + f"{active_count} non-terminal job(s) still reference this actor" + f" (breakdown: {status_counts}). Cancel them first or pass" + f" force=True to cancel pending/scheduled jobs automatically." + ) + super().__init__(actor, detail) + + +class ActorHasEnabledSchedulesError(ActorDeregistrationError): + """Enabled cron schedules reference the actor. + + Carries the schedule IDs so the caller can disable or delete them first. + """ + + def __init__( + self, + actor: str, + schedule_ids: list[str], + ) -> None: + self.schedule_ids = schedule_ids + detail = ( + f"{len(schedule_ids)} enabled cron schedule(s) reference this actor" + f" (ids: {schedule_ids}). Disable or delete them first or pass" + f" force=True to disable them automatically." + ) + super().__init__(actor, detail) + + +class ActorNotFoundError(ActorDeregistrationError): + """The actor_config row does not exist — nothing to deregister.""" + + def __init__(self, actor: str) -> None: + super().__init__(actor, "no stored actor_config row for this actor") +``` + +### Result dataclass (`src/taskq/worker/actor_config_ops.py`) + +```python +@dataclass(frozen=True, slots=True) +class DeregisterResult: + """Outcome of a deregister_actor call. + + All counts are non-negative integers. ``queue_purged`` is True only + when the orphaned queue row was deleted (requires purge_queue=True + AND no other actor_config row references the same queue). + """ + + actor: str + queue: str + actor_config_deleted: bool + schedules_disabled: int + jobs_cancelled: int + terminal_jobs_remaining: int + queue_purged: bool +``` + +### Ops-layer function (`src/taskq/worker/actor_config_ops.py`) + +```python +_NON_TERMINAL_STATUSES: tuple[str, ...] = ( + "pending", "scheduled", "running", +) + +_RUNNING_STATUS: str = "running" + +_DEREGISTER_CHECK_ACTIVE_JOBS_SQL = """ +SELECT status, count(*) AS cnt + FROM "{schema}".jobs + WHERE actor = $1 AND status = ANY($2::"{schema}".job_status[]) + GROUP BY status +""".strip() + +_DEREGISTER_CHECK_SCHEDULES_SQL = """ +SELECT id::text FROM "{schema}".cron_schedules + WHERE actor = $1 AND enabled = true +""".strip() + +_DEREGISTER_CANCEL_PENDING_SQL = """ +UPDATE "{schema}".jobs + SET status = 'cancelled', + finished_at = now(), + error_class = 'ActorDeregistered', + error_message = 'Job cancelled by actor deregistration (force=True)' + WHERE actor = $1 + AND status IN ('pending', 'scheduled') +""".strip() + +_DEREGISTER_DISABLE_SCHEDULES_SQL = """ +UPDATE "{schema}".cron_schedules + SET enabled = false + WHERE actor = $1 AND enabled = true +""".strip() + +_DEREGISTER_DELETE_ACTOR_CONFIG_SQL = """ +DELETE FROM "{schema}".actor_config WHERE actor = $1 +RETURNING queue +""".strip() + +_DEREGISTER_PURGE_QUEUE_SQL = """ +DELETE FROM "{schema}".queues + WHERE name = $1 + AND NOT EXISTS ( + SELECT 1 FROM "{schema}".actor_config WHERE queue = $1 + ) +""".strip() + +_DEREGISTER_COUNT_TERMINAL_SQL = """ +SELECT count(*) FROM "{schema}".jobs + WHERE actor = $1 AND status NOT IN ('pending', 'scheduled', 'running') +""".strip() + + +async def deregister_actor( + conn: ConnLike, + actor: str, + *, + force: bool = False, + purge_queue: bool = False, + schema: str = "taskq", +) -> DeregisterResult: + """Deregister an actor: delete its ``actor_config`` row with safety checks. + + **Default (force=False):** + 1. Refuse if any non-terminal jobs (pending/scheduled/running) reference + the actor — raises :class:`ActorHasActiveJobsError`. + 2. Refuse if any enabled cron schedules reference the actor — raises + :class:`ActorHasEnabledSchedulesError`. + 3. Delete the ``actor_config`` row. + 4. Optionally purge the orphaned queue (if ``purge_queue=True`` and no + other ``actor_config`` row references the same queue). + + **force=True:** + 1. Refuse if any *running* jobs reference the actor — raises + :class:`ActorHasActiveJobsError` (running jobs are actively + executing; their terminal-write path reads ``actor_config`` for + ``result_ttl``, and deleting the row mid-execution would set + ``result_expires_at`` to NULL — safe but surprising). + 2. Cancel pending/scheduled jobs for this actor (mark as ``cancelled`` + with ``error_class='ActorDeregistered'``). They would be stranded + anyway: the dispatch query inner-joins ``actor_config``, so without + a row they would never be dispatched. + 3. Disable enabled cron schedules for this actor (set ``enabled=false``, + not delete — the operator may want to re-enable if the actor is + re-registered). + 4. Delete the ``actor_config`` row. + 5. Optionally purge the orphaned queue. + + **Terminal job history** (succeeded/failed/cancelled/crashed/abandoned + jobs) is *never* deleted or modified. The ``jobs.actor`` column is plain + ``text``, not a foreign key — terminal rows remain queryable by actor + name after deregistration. The ``DeregisterResult.terminal_jobs_remaining`` + count tells the caller how many such rows exist. + + **Queue purge** only deletes the ``queues`` row when *no* remaining + ``actor_config`` row references the same queue name. A shared queue + (one used by multiple actors) is never purged. The queue row is + metadata only (``mode``, ``max_concurrent``); deleting it does not + affect already-queued jobs. + + The entire operation runs inside a single ``conn.transaction()`` block. + If the actor has no stored ``actor_config`` row, raises + :class:`ActorNotFoundError`. + + .. warning:: + + **Concurrent enqueue / dispatch race (TOCTOU).** The transaction + uses READ COMMITTED isolation. The safety checks (active-jobs, + enabled-schedules) and the DELETE are separate statements within + the same transaction. A job enqueued by a *concurrent* transaction + that commits *after* the active-jobs check but *before* the DELETE + will be stranded: the ``jobs`` INSERT does not require an + ``actor_config`` row (no FK), and the dispatch query inner-joins + ``actor_config``, so the job will never be dispatched. The same + applies to cron-fired jobs and dispatch transitions. + + **Deregistration is best-effort against concurrent enqueue / + dispatch.** Callers must **quiesce the actor first** — stop + enqueuing, disable cron schedules, and wait for running jobs to + reach a terminal state — *before* calling ``deregister``. This is + the same operational discipline required for any shutdown + sequence. + + After deregistration, any client can still ``enqueue()`` the dead + actor name — the INSERT succeeds (no FK), the job sits ``pending`` + forever, invisible to dispatch. See "Enqueue after deregistration" + in the docs guide. + + Parameters + ---------- + conn: + An asyncpg connection (or ConnLike). The caller is responsible for + transaction boundaries if composing with other operations; however, + this function wraps its work in ``conn.transaction()`` for + self-contained use. + actor: + The actor name (primary key of ``actor_config``). + force: + If True, cancel pending/scheduled jobs and disable schedules instead + of refusing. Still refuses if running jobs exist. + purge_queue: + If True, delete the orphaned ``queues`` row when no other + ``actor_config`` references the same queue. + schema: + TaskQ schema name. Defaults to ``"taskq"``. + """ +``` + +### Client surface (`src/taskq/client/_actors.py`) + +```python +class ActorsClient: + """Pool-wrapping facade for actor configuration operations. + + Acquires a connection from the injected pool for each call, delegates + to ``taskq.worker.actor_config_ops``, and returns the result. The + caller must have opened the pool; this class does not manage its + lifecycle. + + Parameters + ---------- + pool: + An open ``asyncpg.Pool``. The caller retains ownership. + schema: + TaskQ schema name. Defaults to ``"taskq"``. + """ + + def __init__(self, pool: "asyncpg.Pool", *, schema: str = "taskq") -> None: ... + + async def list(self) -> list[ActorConfigRow]: + """List all stored actor_config rows. Delegates to list_actor_configs.""" + + async def get(self, actor: str) -> ActorConfigRow | None: + """Get one actor_config row. Delegates to get_actor_config.""" + + async def set_capacity( + self, + actor: str, + *, + max_concurrent: int | None | Unset = UNSET, + max_pending: int | None | Unset = UNSET, + result_ttl: float | None | Unset = UNSET, + ) -> ActorConfigRow | None: + """Update capacity fields. Delegates to set_actor_config_capacity.""" + + async def deregister( + self, + actor: str, + *, + force: bool = False, + purge_queue: bool = False, + ) -> DeregisterResult: + """Deregister an actor. Delegates to deregister_actor. + + Raises ActorNotFoundError if the actor has no stored row. + Raises ActorHasActiveJobsError if non-terminal jobs block (force=False) + or running jobs block (force=True). + Raises ActorHasEnabledSchedulesError if enabled schedules block + (force=False only). + + For idempotent cleanup loops, wrap in try/except: + + .. code-block:: python + + try: + await tq.actors.deregister(actor_name, force=True) + except ActorNotFoundError: + pass # already deregistered + """ +``` + +### TaskQ client property (`src/taskq/client/_taskq.py`) + +```python +class TaskQ: + # ... existing code ... + + @property + def actors(self) -> ActorsClient: + """Actor configuration client — list, get, set capacity, deregister. + + Raises RuntimeError if called before ``open()`` or outside an + ``async with`` block. + """ + if self._actors_client is None: + raise RuntimeError( + "TaskQ is not open. Call 'await tq.open()' or use " + "'async with TaskQ(...) as tq:'" + ) + return self._actors_client +``` + +### CLI (`src/taskq/cli.py`) + +``` +taskq actor-config deregister [--force] [--purge-queue] +``` + +- `` — actor name (positional argument) +- `--force` — cancel pending/scheduled jobs, disable schedules, proceed despite non-terminal jobs (running jobs still block) +- `--purge-queue` — also delete the orphaned queues row if no other actor references it +- Exit code 0 on success, 1 on refusal (with error message), 1 on not found + +On success, the CLI prints a summary line and a warning: + +``` +Deregistered actor 'my-actor.run-123': actor_config_deleted=True schedules_disabled=0 jobs_cancelled=0 terminal_jobs_remaining=3 queue_purged=False +WARNING: Actor 'my-actor.run-123' is now unregistered. Any future enqueue() to this actor name will create a stranded pending job that will never be dispatched. Stop enqueuing before deregistering. +``` + +### Admin UI (`src/taskq/web/admin/actors.py`) + +``` +GET /admin/actors — list all actor_config rows with job counts +POST /admin/actors/{actor}/deregister — deregister with force + purge_queue params +``` + +The POST route requires `admin_actions_enabled=True` (same gate as schedule +run, job retry). CSRF-protected via `validate_csrf`. Form params: +- `force` — checkbox +- `purge_queue` — checkbox + +--- + +## Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add actor deregistration with safety checks to the ops layer, CLI, client surface, and admin UI. + +**Architecture:** Pure application logic (no migration) — a transactional function that checks for non-terminal jobs and enabled schedules, then deletes the actor_config row, optionally cancels pending jobs, disables schedules, and purges orphaned queues. Exposed via ActorsClient (pool wrapper), CLI, and admin UI. + +**Tech Stack:** Python 3.12+, asyncpg, typer, FastAPI, Jinja2, pytest + +--- + +### Task 1: Exceptions for deregistration refusals + +**Files:** +- Modify: `src/taskq/exceptions.py` (add after `ActorConfigDriftList`, ~line 344) +- Test: `tests/test_exceptions.py` (add new test class) + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_exceptions.py — add at end of file + +class TestActorDeregistrationErrors: + """Tests for the deregistration refusal exception hierarchy.""" + + def test_actor_has_active_jobs_error_carries_counts(self) -> None: + from taskq.exceptions import ActorHasActiveJobsError + + err = ActorHasActiveJobsError( + actor="my-actor.run-123", + active_count=3, + status_counts={"pending": 2, "running": 1}, + ) + assert err.actor == "my-actor.run-123" + assert err.active_count == 3 + assert err.status_counts == {"pending": 2, "running": 1} + assert "3 non-terminal" in str(err) + assert "force=True" in str(err) + + def test_actor_has_enabled_schedules_error_carries_ids(self) -> None: + from taskq.exceptions import ActorHasEnabledSchedulesError + + err = ActorHasEnabledSchedulesError( + actor="my-actor.run-123", + schedule_ids=["sched-1", "sched-2"], + ) + assert err.actor == "my-actor.run-123" + assert err.schedule_ids == ["sched-1", "sched-2"] + assert "2 enabled cron schedule" in str(err) + assert "force=True" in str(err) + + def test_actor_not_found_error(self) -> None: + from taskq.exceptions import ActorNotFoundError + + err = ActorNotFoundError("ghost-actor") + assert err.actor == "ghost-actor" + assert "no stored actor_config row" in str(err) + + def test_deregistration_errors_inherit_taskq_error(self) -> None: + from taskq.exceptions import ( + ActorDeregistrationError, + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, + TaskQError, + ) + + for cls in ( + ActorDeregistrationError, + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, + ): + assert issubclass(cls, TaskQError) + + def test_specific_errors_inherit_deregistration_error(self) -> None: + from taskq.exceptions import ( + ActorDeregistrationError, + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, + ) + + for cls in ( + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, + ): + assert issubclass(cls, ActorDeregistrationError) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_exceptions.py::TestActorDeregistrationErrors -v` +Expected: FAIL with `ImportError: cannot import name 'ActorDeregistrationError'` + +- [ ] **Step 3: Implement the exceptions** + +Add to `src/taskq/exceptions.py` after the `ActorConfigDriftList` class (after line 344): + +```python +class ActorDeregistrationError(TaskQError): + """Base for actor deregistration refusals.""" + + def __init__(self, actor: str, detail: str) -> None: + self.actor = actor + super().__init__(f"Cannot deregister actor {actor!r}: {detail}") + + +class ActorHasActiveJobsError(ActorDeregistrationError): + """Non-terminal jobs reference the actor. + + Carries the count and per-status breakdown of the blocking jobs so the + caller can decide whether to cancel them first or use ``force=True``. + """ + + def __init__( + self, + actor: str, + active_count: int, + status_counts: dict[str, int], + ) -> None: + self.active_count = active_count + self.status_counts = status_counts + detail = ( + f"{active_count} non-terminal job(s) still reference this actor" + f" (breakdown: {status_counts}). Cancel them first or pass" + f" force=True to cancel pending/scheduled jobs automatically." + ) + super().__init__(actor, detail) + + +class ActorHasEnabledSchedulesError(ActorDeregistrationError): + """Enabled cron schedules reference the actor. + + Carries the schedule IDs so the caller can disable or delete them first. + """ + + def __init__( + self, + actor: str, + schedule_ids: list[str], + ) -> None: + self.schedule_ids = schedule_ids + detail = ( + f"{len(schedule_ids)} enabled cron schedule(s) reference this actor" + f" (ids: {schedule_ids}). Disable or delete them first or pass" + f" force=True to disable them automatically." + ) + super().__init__(actor, detail) + + +class ActorNotFoundError(ActorDeregistrationError): + """The actor_config row does not exist — nothing to deregister.""" + + def __init__(self, actor: str) -> None: + super().__init__(actor, "no stored actor_config row for this actor") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_exceptions.py::TestActorDeregistrationErrors -v` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/taskq/exceptions.py tests/test_exceptions.py +git commit -m "feat: add actor deregistration exception hierarchy" +``` + +--- + +### Task 2: `deregister_actor` ops-layer function — safety checks (force=False path) + +**Files:** +- Modify: `src/taskq/worker/actor_config_ops.py` (add `DeregisterResult`, SQL, `deregister_actor`) +- Test: `tests/test_actor_deregistration.py` (new file, integration-tier) + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_actor_deregistration.py + +"""Tests for ``deregister_actor``: the operator surface for removing +actor_config rows with defined safety semantics. + +Integration-tier (real Postgres) because the safety checks are set-based +SQL that must execute correctly against the real schema — a fake connection +would only prove the query string looks right. +""" + +import asyncpg +import pytest + +from taskq._ids import new_base62 +from taskq.exceptions import ( + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, +) +from taskq.worker.actor_config import ActorConfig +from taskq.worker.actor_config_ops import ( + DeregisterResult, + deregister_actor, + get_actor_config, +) +from taskq.worker.startup import sync_actor_config + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def _ensure_schema(conn: asyncpg.Connection, schema: str) -> None: + """Apply real migrations to create the full TaskQ schema. + + Uses ``taskq.migrate.apply_pending`` — the same pattern as + ``tests/test_taskq_client.py`` and ``taskq.testing.fixtures`` — so the + test schema is structurally identical to production. This prevents + schema-drift bugs (e.g. enum vs text column types, missing columns) + that a hand-rolled minimal schema would mask. + """ + from taskq.migrate import apply_pending + + await conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') + await apply_pending(conn, schema=schema) + +``` + +- [ ] **Step 2: Write the force=False tests (refusal paths)** + +Add to the same test file: + +```python +from uuid import uuid4 + + +async def _insert_job( + conn: asyncpg.Connection, + schema: str, + actor: str, + status: str = "pending", +) -> str: + """Insert a minimal job row with the given status. + + Uses the real migrated schema (via ``apply_pending``), so all NOT NULL + columns without defaults must be specified — ``max_attempts`` and + ``retry_kind`` have no defaults in the real schema. + """ + job_id = uuid4() + await conn.execute( + f""" + INSERT INTO "{schema}".jobs (id, actor, queue, payload, status, max_attempts, retry_kind) + VALUES ($1, $2, 'default', '{{}}'::jsonb, $3::"{schema}".job_status, 3, 'transient') + """, + job_id, + actor, + status, + ) + return str(job_id) + + +async def _insert_schedule( + conn: asyncpg.Connection, + schema: str, + actor: str, + enabled: bool = True, +) -> str: + sched_id = uuid4() + await conn.execute( + f""" + INSERT INTO "{schema}".cron_schedules (id, actor, cron_expr, enabled, next_fire_at) + VALUES ($1, $2, '0 * * * *', $3, now()) + """, + sched_id, + actor, + enabled, + ) + return str(sched_id) + + +async def test_deregister_raises_not_found_for_unknown_actor( + pg_conn: asyncpg.Connection, +) -> None: + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + + with pytest.raises(ActorNotFoundError, match="no stored actor_config row"): + await deregister_actor(pg_conn, "ghost", schema=schema) + + +async def test_deregister_succeeds_when_no_jobs_or_schedules( + pg_conn: asyncpg.Connection, +) -> None: + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="clean-actor", max_concurrent=1, queue="default")], + schema=schema, + ) + + result = await deregister_actor(pg_conn, "clean-actor", schema=schema) + + assert isinstance(result, DeregisterResult) + assert result.actor == "clean-actor" + assert result.actor_config_deleted is True + assert result.schedules_disabled == 0 + assert result.jobs_cancelled == 0 + assert result.terminal_jobs_remaining == 0 + assert result.queue_purged is False + + # Row is gone + assert await get_actor_config(pg_conn, "clean-actor", schema=schema) is None + + +async def test_deregister_refuses_with_pending_jobs( + pg_conn: asyncpg.Connection, +) -> None: + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="busy-actor", max_concurrent=1, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, "busy-actor", "pending") + + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await deregister_actor(pg_conn, "busy-actor", schema=schema) + + assert exc_info.value.active_count == 1 + assert exc_info.value.status_counts == {"pending": 1} + + # Row is still there + assert await get_actor_config(pg_conn, "busy-actor", schema=schema) is not None + + +async def test_deregister_refuses_with_running_jobs( + pg_conn: asyncpg.Connection, +) -> None: + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="running-actor", max_concurrent=1, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, "running-actor", "running") + + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await deregister_actor(pg_conn, "running-actor", schema=schema) + + assert exc_info.value.active_count == 1 + assert exc_info.value.status_counts == {"running": 1} + + +async def test_deregister_refuses_with_enabled_schedules( + pg_conn: asyncpg.Connection, +) -> None: + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="scheduled-actor", max_concurrent=1, queue="default")], + schema=schema, + ) + sched_id = await _insert_schedule(pg_conn, schema, "scheduled-actor", enabled=True) + + with pytest.raises(ActorHasEnabledSchedulesError) as exc_info: + await deregister_actor(pg_conn, "scheduled-actor", schema=schema) + + assert sched_id in exc_info.value.schedule_ids + + # Row is still there + assert await get_actor_config(pg_conn, "scheduled-actor", schema=schema) is not None + + +async def test_deregister_succeeds_with_disabled_schedules( + pg_conn: asyncpg.Connection, +) -> None: + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="disabled-sched-actor", max_concurrent=1, queue="default")], + schema=schema, + ) + await _insert_schedule(pg_conn, schema, "disabled-sched-actor", enabled=False) + + result = await deregister_actor(pg_conn, "disabled-sched-actor", schema=schema) + + assert result.actor_config_deleted is True + assert result.schedules_disabled == 0 + + +async def test_deregister_succeeds_with_terminal_jobs( + pg_conn: asyncpg.Connection, +) -> None: + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="done-actor", max_concurrent=1, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, "done-actor", "succeeded") + await _insert_job(pg_conn, schema, "done-actor", "failed") + + result = await deregister_actor(pg_conn, "done-actor", schema=schema) + + assert result.actor_config_deleted is True + assert result.terminal_jobs_remaining == 2 +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `uv run pytest tests/test_actor_deregistration.py -v` +Expected: FAIL with `ImportError: cannot import name 'deregister_actor'` + +- [ ] **Step 4: Implement `deregister_actor` (force=False path)** + +Add to `src/taskq/worker/actor_config_ops.py`: + +```python +# Add to imports section: +from taskq.exceptions import ( + ActorHasActiveJobsError, + ActorHasEnabledSchedulesError, + ActorNotFoundError, +) + +# Add DeregisterResult to __all__: +__all__ = [ + "UNSET", + "ActorConfigRow", + "DeregisterResult", + "Unset", + "deregister_actor", + "get_actor_config", + "list_actor_configs", + "set_actor_config_capacity", +] + +# Add after the ActorConfigRow dataclass: + +@dataclass(frozen=True, slots=True) +class DeregisterResult: + """Outcome of a deregister_actor call.""" + + actor: str + queue: str + actor_config_deleted: bool + schedules_disabled: int + jobs_cancelled: int + terminal_jobs_remaining: int + queue_purged: bool + + +# SQL templates (as defined in the API surface section above) + +_NON_TERMINAL_STATUSES: tuple[str, ...] = ("pending", "scheduled", "running") + +_DEREGISTER_CHECK_ACTIVE_JOBS_SQL = """ +SELECT status, count(*) AS cnt + FROM "{schema}".jobs + WHERE actor = $1 AND status = ANY($2::"{schema}".job_status[]) + GROUP BY status +""".strip() + +_DEREGISTER_CHECK_SCHEDULES_SQL = """ +SELECT id::text FROM "{schema}".cron_schedules + WHERE actor = $1 AND enabled = true +""".strip() + +_DEREGISTER_DELETE_ACTOR_CONFIG_SQL = """ +DELETE FROM "{schema}".actor_config WHERE actor = $1 +RETURNING queue +""".strip() + +_DEREGISTER_COUNT_TERMINAL_SQL = """ +SELECT count(*) FROM "{schema}".jobs + WHERE actor = $1 AND status NOT IN ('pending', 'scheduled', 'running') +""".strip() + + +async def deregister_actor( + conn: ConnLike, + actor: str, + *, + force: bool = False, + purge_queue: bool = False, + schema: str = "taskq", +) -> DeregisterResult: + """Deregister an actor: delete its actor_config row with safety checks.""" + if not _IDENT_RE.match(schema): + raise ValueError(f"invalid schema identifier: {schema!r}") + + # force=False path only — force=True path added in Task 3 + async with conn.transaction(): + # 1. Check for non-terminal jobs + active_rows = await conn.fetch( + _DEREGISTER_CHECK_ACTIVE_JOBS_SQL.format(schema=schema), + actor, + list(_NON_TERMINAL_STATUSES), + ) + if active_rows: + status_counts = {row["status"]: row["cnt"] for row in active_rows} + active_count = sum(status_counts.values()) + raise ActorHasActiveJobsError(actor, active_count, status_counts) + + # 2. Check for enabled schedules + schedule_rows = await conn.fetch( + _DEREGISTER_CHECK_SCHEDULES_SQL.format(schema=schema), + actor, + ) + if schedule_rows: + schedule_ids = [row["id"] for row in schedule_rows] + raise ActorHasEnabledSchedulesError(actor, schedule_ids) + + # 3. Delete the actor_config row + deleted_rows = await conn.fetch( + _DEREGISTER_DELETE_ACTOR_CONFIG_SQL.format(schema=schema), + actor, + ) + if not deleted_rows: + raise ActorNotFoundError(actor) + + queue_name = deleted_rows[0]["queue"] + + # 4. Count terminal jobs remaining + terminal_count = await conn.fetchval( + _DEREGISTER_COUNT_TERMINAL_SQL.format(schema=schema), + actor, + ) + + # 5. Optionally purge queue (implemented in Task 3 Step 3) + queue_purged = False + + return DeregisterResult( + actor=actor, + queue=queue_name, + actor_config_deleted=True, + schedules_disabled=0, + jobs_cancelled=0, + terminal_jobs_remaining=terminal_count or 0, + queue_purged=queue_purged, + ) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_actor_deregistration.py -v` +Expected: PASS (7 tests) + +- [ ] **Step 6: Commit** + +```bash +git add src/taskq/worker/actor_config_ops.py tests/test_actor_deregistration.py +git commit -m "feat: add deregister_actor with force=False safety checks" +``` + +--- + +### Task 3: `deregister_actor` force=True path + +**Files:** +- Modify: `src/taskq/worker/actor_config_ops.py` (extend `deregister_actor`) +- Test: `tests/test_actor_deregistration.py` (add force=True tests) + +- [ ] **Step 1: Write the failing tests for force=True** + +Add to `tests/test_actor_deregistration.py`: + +```python +async def test_deregister_force_cancels_pending_and_disables_schedules( + pg_conn: asyncpg.Connection, +) -> None: + """force=True cancels pending/scheduled jobs and disables schedules.""" + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="force-actor", max_concurrent=1, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, "force-actor", "pending") + await _insert_job(pg_conn, schema, "force-actor", "scheduled") + await _insert_schedule(pg_conn, schema, "force-actor", enabled=True) + + result = await deregister_actor(pg_conn, "force-actor", force=True, schema=schema) + + assert result.actor_config_deleted is True + assert result.jobs_cancelled == 2 + assert result.schedules_disabled == 1 + + # Verify jobs are cancelled + cancelled = await pg_conn.fetchval( + f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = 'force-actor' AND status = 'cancelled'" + ) + assert cancelled == 2 + + # Verify schedule is disabled + enabled = await pg_conn.fetchval( + f"SELECT count(*) FROM \"{schema}\".cron_schedules WHERE actor = 'force-actor' AND enabled = true" + ) + assert enabled == 0 + + # Row is gone + assert await get_actor_config(pg_conn, "force-actor", schema=schema) is None + + +async def test_deregister_force_refuses_with_running_jobs( + pg_conn: asyncpg.Connection, +) -> None: + """force=True still refuses if running jobs exist.""" + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="running-force-actor", max_concurrent=1, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, "running-force-actor", "running") + + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await deregister_actor(pg_conn, "running-force-actor", force=True, schema=schema) + + # Only running jobs in the breakdown + assert exc_info.value.status_counts == {"running": 1} + + # Row is still there + assert await get_actor_config(pg_conn, "running-force-actor", schema=schema) is not None + + +async def test_deregister_force_with_running_and_pending_only_reports_running( + pg_conn: asyncpg.Connection, +) -> None: + """force=True: pending jobs are OK, only running blocks. But the check + should only report running in the error (pending would be cancelled).""" + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="mixed-actor", max_concurrent=1, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, "mixed-actor", "pending") + await _insert_job(pg_conn, schema, "mixed-actor", "running") + + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await deregister_actor(pg_conn, "mixed-actor", force=True, schema=schema) + + # Only running is reported (pending would be auto-cancelled) + assert "running" in exc_info.value.status_counts + assert "pending" not in exc_info.value.status_counts + + +async def test_deregister_force_keeps_terminal_history( + pg_conn: asyncpg.Connection, +) -> None: + """force=True does not delete or modify terminal jobs.""" + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="hist-actor", max_concurrent=1, queue="default")], + schema=schema, + ) + await _insert_job(pg_conn, schema, "hist-actor", "succeeded") + await _insert_job(pg_conn, schema, "hist-actor", "failed") + await _insert_job(pg_conn, schema, "hist-actor", "pending") + + result = await deregister_actor(pg_conn, "hist-actor", force=True, schema=schema) + + assert result.terminal_jobs_remaining == 2 + assert result.jobs_cancelled == 1 # only the pending one + + # Terminal jobs are still there + succeeded = await pg_conn.fetchval( + f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = 'hist-actor' AND status = 'succeeded'" + ) + assert succeeded == 1 + failed = await pg_conn.fetchval( + f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = 'hist-actor' AND status = 'failed'" + ) + assert failed == 1 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_actor_deregistration.py -k force -v` +Expected: FAIL (force=True not implemented yet — pending jobs not cancelled) + +- [ ] **Step 3: Implement the force=True path** + +Replace the `deregister_actor` function in `src/taskq/worker/actor_config_ops.py` with the full implementation: + +```python +_RUNNING_STATUS: str = "running" + +_DEREGISTER_CANCEL_PENDING_SQL = """ +UPDATE "{schema}".jobs + SET status = 'cancelled', + finished_at = now(), + error_class = 'ActorDeregistered', + error_message = 'Job cancelled by actor deregistration (force=True)' + WHERE actor = $1 + AND status IN ('pending', 'scheduled') +""".strip() + +_DEREGISTER_DISABLE_SCHEDULES_SQL = """ +UPDATE "{schema}".cron_schedules + SET enabled = false + WHERE actor = $1 AND enabled = true +""".strip() + + +async def deregister_actor( + conn: ConnLike, + actor: str, + *, + force: bool = False, + purge_queue: bool = False, + schema: str = "taskq", +) -> DeregisterResult: + """Deregister an actor: delete its actor_config row with safety checks. + + See the API surface section in docs/specs/2026-07-29-actor-deregistration.md + for the full semantics documentation. + """ + if not _IDENT_RE.match(schema): + raise ValueError(f"invalid schema identifier: {schema!r}") + + async with conn.transaction(): + if not force: + # force=False: refuse if ANY non-terminal jobs exist + active_rows = await conn.fetch( + _DEREGISTER_CHECK_ACTIVE_JOBS_SQL.format(schema=schema), + actor, + list(_NON_TERMINAL_STATUSES), + ) + if active_rows: + status_counts = {row["status"]: row["cnt"] for row in active_rows} + active_count = sum(status_counts.values()) + raise ActorHasActiveJobsError(actor, active_count, status_counts) + + # Refuse if enabled schedules exist + schedule_rows = await conn.fetch( + _DEREGISTER_CHECK_SCHEDULES_SQL.format(schema=schema), + actor, + ) + if schedule_rows: + schedule_ids = [row["id"] for row in schedule_rows] + raise ActorHasEnabledSchedulesError(actor, schedule_ids) + + jobs_cancelled = 0 + schedules_disabled = 0 + else: + # force=True: refuse only if RUNNING jobs exist + running_rows = await conn.fetch( + _DEREGISTER_CHECK_ACTIVE_JOBS_SQL.format(schema=schema), + actor, + [_RUNNING_STATUS], + ) + if running_rows: + status_counts = {row["status"]: row["cnt"] for row in running_rows} + active_count = sum(status_counts.values()) + raise ActorHasActiveJobsError(actor, active_count, status_counts) + + # Cancel pending/scheduled jobs + cancel_result = await conn.execute( + _DEREGISTER_CANCEL_PENDING_SQL.format(schema=schema), + actor, + ) + # asyncpg returns "UPDATE N" — parse the count + jobs_cancelled = int(cancel_result.split()[-1]) if cancel_result else 0 + + # Disable enabled schedules + disable_result = await conn.execute( + _DEREGISTER_DISABLE_SCHEDULES_SQL.format(schema=schema), + actor, + ) + schedules_disabled = int(disable_result.split()[-1]) if disable_result else 0 + + # Delete the actor_config row + deleted_rows = await conn.fetch( + _DEREGISTER_DELETE_ACTOR_CONFIG_SQL.format(schema=schema), + actor, + ) + if not deleted_rows: + raise ActorNotFoundError(actor) + + queue_name = deleted_rows[0]["queue"] + + # Count terminal jobs remaining + terminal_count = await conn.fetchval( + _DEREGISTER_COUNT_TERMINAL_SQL.format(schema=schema), + actor, + ) + + # Optionally purge queue + queue_purged = False + if purge_queue: + purge_result = await conn.execute( + _DEREGISTER_PURGE_QUEUE_SQL.format(schema=schema), + queue_name, + ) + queue_purged = purge_result == "DELETE 1" + + return DeregisterResult( + actor=actor, + queue=queue_name, + actor_config_deleted=True, + schedules_disabled=schedules_disabled, + jobs_cancelled=jobs_cancelled, + terminal_jobs_remaining=terminal_count or 0, + queue_purged=queue_purged, + ) +``` + +Also add the `_DEREGISTER_PURGE_QUEUE_SQL` constant: + +```python +_DEREGISTER_PURGE_QUEUE_SQL = """ +DELETE FROM "{schema}".queues + WHERE name = $1 + AND NOT EXISTS ( + SELECT 1 FROM "{schema}".actor_config WHERE queue = $1 + ) +""".strip() +``` + +- [ ] **Step 4: Run all deregistration tests** + +Run: `uv run pytest tests/test_actor_deregistration.py -v` +Expected: PASS (all tests including force=True path) + +- [ ] **Step 5: Commit** + +```bash +git add src/taskq/worker/actor_config_ops.py tests/test_actor_deregistration.py +git commit -m "feat: add force=True path to deregister_actor" +``` + +--- + +### Task 4: Queue purge tests + +**Files:** +- Modify: `tests/test_actor_deregistration.py` (add purge_queue tests) + +- [ ] **Step 1: Write the failing tests for purge_queue** + +Add to `tests/test_actor_deregistration.py`: + +```python +async def test_deregister_purge_queue_deletes_orphaned_queue( + pg_conn: asyncpg.Connection, +) -> None: + """purge_queue=True deletes the queue row when no other actor uses it.""" + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="solo-actor", max_concurrent=1, queue="solo-queue")], + schema=schema, + ) + # Create the queue row + await pg_conn.execute( + f"INSERT INTO \"{schema}\".queues (name) VALUES ('solo-queue') ON CONFLICT DO NOTHING" + ) + + result = await deregister_actor( + pg_conn, "solo-actor", purge_queue=True, schema=schema + ) + + assert result.queue_purged is True + assert result.queue == "solo-queue" + + # Queue row is gone + queue_count = await pg_conn.fetchval( + f"SELECT count(*) FROM \"{schema}\".queues WHERE name = 'solo-queue'" + ) + assert queue_count == 0 + + +async def test_deregister_purge_queue_keeps_shared_queue( + pg_conn: asyncpg.Connection, +) -> None: + """purge_queue=True does NOT delete the queue if another actor still uses it.""" + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ + ActorConfig(actor="actor-a", max_concurrent=1, queue="shared-queue"), + ActorConfig(actor="actor-b", max_concurrent=1, queue="shared-queue"), + ], + schema=schema, + ) + await pg_conn.execute( + f"INSERT INTO \"{schema}\".queues (name) VALUES ('shared-queue') ON CONFLICT DO NOTHING" + ) + + result = await deregister_actor( + pg_conn, "actor-a", purge_queue=True, schema=schema + ) + + assert result.queue_purged is False # actor-b still uses it + + # Queue row is still there + queue_count = await pg_conn.fetchval( + f"SELECT count(*) FROM \"{schema}\".queues WHERE name = 'shared-queue'" + ) + assert queue_count == 1 + + +async def test_deregister_without_purge_queue_keeps_queue( + pg_conn: asyncpg.Connection, +) -> None: + """Default (purge_queue=False) does not touch the queue row.""" + schema = f"taco_{new_base62()}".lower() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="keep-queue-actor", max_concurrent=1, queue="kept-queue")], + schema=schema, + ) + await pg_conn.execute( + f"INSERT INTO \"{schema}\".queues (name) VALUES ('kept-queue') ON CONFLICT DO NOTHING" + ) + + result = await deregister_actor(pg_conn, "keep-queue-actor", schema=schema) + + assert result.queue_purged is False + + # Queue row is still there + queue_count = await pg_conn.fetchval( + f"SELECT count(*) FROM \"{schema}\".queues WHERE name = 'kept-queue'" + ) + assert queue_count == 1 +``` + +- [ ] **Step 2: Run tests** + +Run: `uv run pytest tests/test_actor_deregistration.py -k purge -v` +Expected: PASS (the purge logic was already implemented in Task 3's step 3) + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_actor_deregistration.py +git commit -m "test: add queue purge tests for deregister_actor" +``` + +--- + +### Task 5: ActorsClient — pool-wrapping facade + +**Files:** +- Create: `src/taskq/client/_actors.py` +- Test: `tests/test_actors_client.py` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_actors_client.py + +"""Tests for ActorsClient — the pool-wrapping facade over actor_config_ops. + +These tests use a fake pool to verify the delegation wiring without +requiring real Postgres (the ops functions themselves are integration-tested +in test_actor_deregistration.py and test_actor_config_ops.py). +""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from taskq.worker.actor_config_ops import ( + ActorConfigRow, + DeregisterResult, +) + +pytestmark = [pytest.mark.asyncio] + + +class _FakePool: + """Minimal pool that yields a fake connection via async context manager.""" + + def __init__(self, conn: Any) -> None: + self._conn = conn + + def acquire(self) -> Any: + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=self._conn) + cm.__aexit__ = AsyncMock(return_value=None) + return cm + + +class _FakeConn: + """Fake connection — just needs to be passable to the ops functions.""" + + async def close(self) -> None: ... + + +async def test_actors_client_list_delegates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from taskq.client._actors import ActorsClient + + conn = _FakeConn() + pool = _FakePool(conn) + client = ActorsClient(pool, schema="test_schema") + + # Patch list_actor_configs to verify delegation + import taskq.client._actors as actors_mod + + mock_result = [ + ActorConfigRow( + actor="a", max_concurrent=1, max_pending=None, queue="q", + result_ttl=None, metadata={}, updated_at="2026-01-01" + ) + ] + monkeypatch.setattr(actors_mod, "list_actor_configs", AsyncMock(return_value=mock_result)) + result = await client.list() + assert result == mock_result + actors_mod.list_actor_configs.assert_called_once_with( + conn, schema="test_schema" + ) + + +async def test_actors_client_deregister_delegates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from taskq.client._actors import ActorsClient + + conn = _FakeConn() + pool = _FakePool(conn) + client = ActorsClient(pool, schema="test_schema") + + expected = DeregisterResult( + actor="test-actor", queue="q", actor_config_deleted=True, + schedules_disabled=0, jobs_cancelled=0, + terminal_jobs_remaining=0, queue_purged=False, + ) + + import taskq.client._actors as actors_mod + + monkeypatch.setattr(actors_mod, "deregister_actor", AsyncMock(return_value=expected)) + result = await client.deregister("test-actor", force=True, purge_queue=True) + assert result == expected + actors_mod.deregister_actor.assert_called_once_with( + conn, "test-actor", force=True, purge_queue=True, schema="test_schema" + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_actors_client.py -v` +Expected: FAIL with `ImportError: cannot import name 'ActorsClient'` + +- [ ] **Step 3: Implement ActorsClient** + +Create `src/taskq/client/_actors.py`: + +```python +"""ActorsClient — pool-wrapping facade for actor configuration operations. + +Provides a typed surface for listing, inspecting, tuning, and deregistering +stored ``actor_config`` rows. Each method acquires a connection from the +injected pool, delegates to ``taskq.worker.actor_config_ops``, and returns +the result. +""" + +from typing import TYPE_CHECKING + +import structlog + +from taskq.worker.actor_config_ops import ( + UNSET, + ActorConfigRow, + DeregisterResult, + Unset, + deregister_actor, + get_actor_config, + list_actor_configs, + set_actor_config_capacity, +) + +if TYPE_CHECKING: + import asyncpg + +__all__ = ["ActorsClient"] + +logger = structlog.get_logger("taskq.client._actors") + + +class ActorsClient: + """Pool-wrapping facade for actor configuration operations. + + Acquires a connection from the injected pool for each call, delegates + to ``taskq.worker.actor_config_ops``, and returns the result. The + caller must have opened the pool; this class does not manage its + lifecycle. + + Parameters + ---------- + pool: + An open ``asyncpg.Pool``. The caller retains ownership. + schema: + TaskQ schema name. Defaults to ``"taskq"``. + """ + + def __init__(self, pool: "asyncpg.Pool", *, schema: str = "taskq") -> None: + self._pool = pool + self._schema = schema + + async def list(self) -> list[ActorConfigRow]: + """List all stored actor_config rows, ordered by actor name.""" + async with self._pool.acquire() as conn: + return await list_actor_configs(conn, schema=self._schema) + + async def get(self, actor: str) -> ActorConfigRow | None: + """Get one actor_config row, or ``None`` if not found.""" + async with self._pool.acquire() as conn: + return await get_actor_config(conn, actor, schema=self._schema) + + async def set_capacity( + self, + actor: str, + *, + max_concurrent: int | None | Unset = UNSET, + max_pending: int | None | Unset = UNSET, + result_ttl: float | None | Unset = UNSET, + ) -> ActorConfigRow | None: + """Update capacity fields on an existing actor_config row.""" + async with self._pool.acquire() as conn: + return await set_actor_config_capacity( + conn, + actor, + max_concurrent=max_concurrent, + max_pending=max_pending, + result_ttl=result_ttl, + schema=self._schema, + ) + + async def deregister( + self, + actor: str, + *, + force: bool = False, + purge_queue: bool = False, + ) -> DeregisterResult: + """Deregister an actor with safety checks. + + See :func:`taskq.worker.actor_config_ops.deregister_actor` for + the full semantics. + """ + async with self._pool.acquire() as conn: + return await deregister_actor( + conn, + actor, + force=force, + purge_queue=purge_queue, + schema=self._schema, + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_actors_client.py -v` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/taskq/client/_actors.py tests/test_actors_client.py +git commit -m "feat: add ActorsClient pool-wrapping facade" +``` + +--- + +### Task 6: TaskQ.actors property + +**Files:** +- Modify: `src/taskq/client/_taskq.py` (add `actors` property, create `_actors_client` in `open()`) +- Modify: `src/taskq/client/__init__.py` (export `ActorsClient`) +- Test: `tests/test_taskq_client.py` (add test) + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_taskq_client.py`: + +```python +async def test_taskq_actors_property_returns_actors_client( + module_pg_schema: "ModulePgSchema", +) -> None: + """TaskQ.actors returns an ActorsClient bound to the same pool and schema. + + Uses the module-scoped ``module_pg_schema`` fixture (already migrated + via ``apply_pending``). ``ModulePgSchema`` is a NamedTuple with + ``.schema_name`` and ``.pg_dsn`` fields. + """ + from taskq import TaskQ + from taskq.client._actors import ActorsClient + from taskq.testing.fixtures import ModulePgSchema # noqa: F401 + + async with TaskQ( + dsn=module_pg_schema.pg_dsn, + schema=module_pg_schema.schema_name, + ) as tq: + client = tq.actors + assert isinstance(client, ActorsClient) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_taskq_client.py::test_taskq_actors_property_returns_actors_client -v` +Expected: FAIL with `AttributeError: 'TaskQ' object has no attribute 'actors'` + +- [ ] **Step 3: Implement the `actors` property** + +In `src/taskq/client/_taskq.py`, add import at the top: + +```python +from taskq.client._actors import ActorsClient +``` + +Add to `__all__`: + +```python +__all__ = ["EventRow", "JobEvent", "TaskQ", "ActorsClient"] +``` + +In `TaskQ.__init__`, add: + +```python +self._actors_client: ActorsClient | None = None +``` + +In `TaskQ.open()`, after the `self._client = JobsClient(...)` line, add: + +```python +self._actors_client = ActorsClient(pool, schema=self._schema) +``` + +In `TaskQ.close()`, add after `self._client = None`: + +```python +self._actors_client = None +``` + +Add the property after `_require_open`: + +```python +@property +def actors(self) -> ActorsClient: + """Actor configuration client — list, get, set capacity, deregister. + + Raises RuntimeError if called before ``open()`` or outside an + ``async with`` block. + """ + if self._actors_client is None: + raise RuntimeError( + "TaskQ is not open. Call 'await tq.open()' or use " + "'async with TaskQ(...) as tq:'" + ) + return self._actors_client +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_taskq_client.py::test_taskq_actors_property_returns_actors_client -v` +Expected: PASS + +- [ ] **Step 5: Update client __init__.py exports** + +In `src/taskq/client/__init__.py`, add `ActorsClient` to the exports: + +```python +from taskq.client._actors import ActorsClient +``` + +Add `"ActorsClient"` to `__all__`. + +- [ ] **Step 6: Commit** + +```bash +git add src/taskq/client/_taskq.py src/taskq/client/__init__.py tests/test_taskq_client.py +git commit -m "feat: add TaskQ.actors property returning ActorsClient" +``` + +--- + +### Task 7: CLI `taskq actor-config deregister` command + +**Files:** +- Modify: `src/taskq/cli.py` (add `actor_config_deregister` command) +- Test: `tests/test_cli_actor_deregister.py` (new file) + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_cli_actor_deregister.py + +"""Tests for `taskq actor-config deregister` CLI command. + +Monkeypatches the ops function and asyncpg.connect to pin the CLI's +argument parsing, error handling, and output shape without requiring +real Postgres (integration coverage is in test_actor_deregistration.py). +""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from typer.testing import CliRunner + +from taskq.cli import app +from taskq.worker.actor_config_ops import DeregisterResult + +runner = CliRunner() + + +def _patch_deregister( + monkeypatch: pytest.MonkeyPatch, + *, + result: DeregisterResult | None = None, + raises: Exception | None = None, +) -> dict[str, Any]: + """Fake asyncpg.connect + deregister_actor; return captured call kwargs.""" + captured: dict[str, Any] = {} + + class _FakeConn: + async def close(self) -> None: ... + + async def fake_connect(dsn: str) -> Any: + return _FakeConn() + + async def fake_deregister(conn: Any, actor: str, **kwargs: Any) -> Any: + captured["actor"] = actor + captured["kwargs"] = kwargs + if raises is not None: + raise raises + return result or DeregisterResult( + actor=actor, queue="default", actor_config_deleted=True, + schedules_disabled=0, jobs_cancelled=0, + terminal_jobs_remaining=0, queue_purged=False, + ) + + monkeypatch.setattr("taskq.cli.asyncpg.connect", fake_connect) + monkeypatch.setattr("taskq.cli.deregister_actor", fake_deregister) + return captured + + +def test_deregister_default_no_force_no_purge(monkeypatch: pytest.MonkeyPatch) -> None: + captured = _patch_deregister(monkeypatch) + result = runner.invoke(app, ["actor-config", "deregister", "my-actor.run-123"]) + assert result.exit_code == 0, f"stderr: {result.stderr}" + assert captured["actor"] == "my-actor.run-123" + assert captured["kwargs"]["force"] is False + assert captured["kwargs"]["purge_queue"] is False + + +def test_deregister_force_flag(monkeypatch: pytest.MonkeyPatch) -> None: + captured = _patch_deregister(monkeypatch) + result = runner.invoke(app, ["actor-config", "deregister", "my-actor", "--force"]) + assert result.exit_code == 0, f"stderr: {result.stderr}" + assert captured["kwargs"]["force"] is True + + +def test_deregister_purge_queue_flag(monkeypatch: pytest.MonkeyPatch) -> None: + captured = _patch_deregister(monkeypatch) + result = runner.invoke( + app, ["actor-config", "deregister", "my-actor", "--purge-queue"] + ) + assert result.exit_code == 0, f"stderr: {result.stderr}" + assert captured["kwargs"]["purge_queue"] is True + + +def test_deregister_not_found_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: + from taskq.exceptions import ActorNotFoundError + + _patch_deregister(monkeypatch, raises=ActorNotFoundError("ghost")) + result = runner.invoke(app, ["actor-config", "deregister", "ghost"]) + assert result.exit_code == 1 + assert "no stored actor_config row" in result.stderr + + +def test_deregister_active_jobs_error_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: + from taskq.exceptions import ActorHasActiveJobsError + + _patch_deregister( + monkeypatch, + raises=ActorHasActiveJobsError( + "busy", active_count=3, status_counts={"pending": 2, "running": 1} + ), + ) + result = runner.invoke(app, ["actor-config", "deregister", "busy"]) + assert result.exit_code == 1 + assert "non-terminal" in result.stderr + assert "force=True" in result.stderr + + +def test_deregister_schedules_error_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: + from taskq.exceptions import ActorHasEnabledSchedulesError + + _patch_deregister( + monkeypatch, + raises=ActorHasEnabledSchedulesError("sched-actor", ["s1", "s2"]), + ) + result = runner.invoke(app, ["actor-config", "deregister", "sched-actor"]) + assert result.exit_code == 1 + assert "enabled cron schedule" in result.stderr + + +def test_deregister_output_shows_result(monkeypatch: pytest.MonkeyPatch) -> None: + result = DeregisterResult( + actor="my-actor", queue="my-queue", actor_config_deleted=True, + schedules_disabled=2, jobs_cancelled=5, + terminal_jobs_remaining=10, queue_purged=True, + ) + _patch_deregister(monkeypatch, result=result) + output = runner.invoke(app, ["actor-config", "deregister", "my-actor", "--force", "--purge-queue"]) + assert output.exit_code == 0 + assert "deregistered" in output.output.lower() + assert "schedules_disabled=2" in output.output + assert "jobs_cancelled=5" in output.output + assert "terminal_jobs_remaining=10" in output.output + assert "queue_purged=true" in output.output.lower() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_cli_actor_deregister.py -v` +Expected: FAIL (command doesn't exist) + +- [ ] **Step 3: Implement the CLI command** + +In `src/taskq/cli.py`, add `deregister_actor` to the import from `actor_config_ops`: + +```python +from taskq.worker.actor_config_ops import ( + UNSET, + ActorConfigRow, + DeregisterResult, + Unset, + deregister_actor, + get_actor_config, + list_actor_configs, + set_actor_config_capacity, +) +``` + +Also add the exception imports: + +```python +from taskq.exceptions import ( + ActorConfigDriftList, + ActorDeregistrationError, +) +``` + +Add the command after the `actor_config_set` / `_actor_config_set` functions (around line 518): + +```python +@actor_config_app.command("deregister") +def actor_config_deregister( + actor: Annotated[str, typer.Argument(help="Actor name to deregister.")], + force: Annotated[ + bool, + typer.Option( + "--force", + help="Cancel pending/scheduled jobs and disable enabled cron schedules" + " instead of refusing. Running jobs still block deregistration.", + ), + ] = False, + purge_queue: Annotated[ + bool, + typer.Option( + "--purge-queue", + help="Also delete the orphaned queues row if no other actor_config" + " references the same queue.", + ), + ] = False, +) -> None: + """Deregister an actor: delete its actor_config row with safety checks. + + By default refuses if non-terminal jobs or enabled cron schedules + reference the actor. Use --force to cancel pending/scheduled jobs and + disable schedules. Running jobs always block (force or not). Use + --purge-queue to also delete the queues row if no other actor uses it. + """ + settings = TaskQSettings.load() + asyncio.run(_actor_config_deregister(settings, actor, force, purge_queue)) + + +async def _actor_config_deregister( + settings: TaskQSettings, + actor: str, + force: bool, + purge_queue: bool, +) -> None: + conn = await asyncpg.connect(str(settings.pg_dsn)) + try: + result = await deregister_actor( + conn, + actor, + force=force, + purge_queue=purge_queue, + schema=settings.schema_name, + ) + except (ActorDeregistrationError, ValueError) as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from None + finally: + await conn.close() + + typer.echo( + f"Deregistered actor {result.actor!r}:" + f" actor_config_deleted={result.actor_config_deleted}" + f" schedules_disabled={result.schedules_disabled}" + f" jobs_cancelled={result.jobs_cancelled}" + f" terminal_jobs_remaining={result.terminal_jobs_remaining}" + f" queue_purged={result.queue_purged}" + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_cli_actor_deregister.py -v` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/taskq/cli.py tests/test_cli_actor_deregister.py +git commit -m "feat: add 'taskq actor-config deregister' CLI command" +``` + +--- + +### Task 8: Admin UI actors page + +**Files:** +- Create: `src/taskq/web/admin/actors.py` +- Create: `src/taskq/web/templates/actors.html` +- Modify: `src/taskq/web/templates/_base.html` (add nav link) +- Test: `tests/test_web_admin_actors.py` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_web_admin_actors.py + +"""Tests for the admin UI actors page and deregister route. + +Follows the exact pattern of ``tests/test_web_admin_integration.py``: +a per-test asyncpg pool on the module's migrated schema, a FastAPI app +built via ``create_router`` + ``setup_admin_state`` + ``include_router``, +and ``httpx.AsyncClient`` with ``ASGITransport`` — NOT ``TestClient``, +which runs the app on its own event loop that the per-test asyncpg pool +is not bound to. + +CSRF is the synchronizer-token pattern: ``_CsrfRoute`` sets the +``taskq_csrf_token`` cookie on every GET; ``validate_csrf`` compares the +cookie against the ``csrf_token`` form field on POST. There is no test +bypass — every POST test must GET first so the cookie is set, then pass +the cookie value as the form field (the same flow as ``_post_cancel`` +in the integration file). ``httpx.AsyncClient`` persists cookies across +requests on the same client. +""" + +from collections.abc import AsyncIterator + +import asyncpg +import httpx +import pytest +import pytest_asyncio +from fastapi import FastAPI + +from taskq.testing.fixtures import ModulePgSchema +from taskq.web.admin import create_router, setup_admin_state +from taskq.worker.actor_config import ActorConfig +from taskq.worker.startup import sync_actor_config + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +@pytest_asyncio.fixture +async def admin_pool(module_pg_schema: ModulePgSchema) -> AsyncIterator[asyncpg.Pool]: + """Per-test pool on the module's (already migrated) schema. + + Created inside the test's event loop so the ASGI app can use it — + same rationale as the ``pool`` fixture in test_web_admin_integration.py. + """ + pool = await asyncpg.create_pool(module_pg_schema.pg_dsn, min_size=1, max_size=4) + assert pool is not None + try: + yield pool + finally: + await pool.close() + + +def _make_admin_app( + pool: asyncpg.Pool, + schema: str, + monkeypatch: pytest.MonkeyPatch, + *, + admin_actions_enabled: bool, +) -> FastAPI: + """Build the admin app. Env must be set BEFORE ``create_router`` — + it calls ``TaskQSettings.load()`` internally and captures + ``admin_actions_enabled`` at construction time. + + TASKQ_ENVIRONMENT=dev bypasses create_router's fail-closed + admin_ui_require_auth default (these tests exercise the page and the + admin-actions gate, not auth — see test_admin_security_fixes.py for + the auth gates). + """ + monkeypatch.setenv("TASKQ_ENVIRONMENT", "dev") + monkeypatch.setenv( + "TASKQ_ADMIN_ACTIONS_ENABLED", "true" if admin_actions_enabled else "false" + ) + bundle = create_router(pool, schema=schema, base_path="/admin") + app = FastAPI() + setup_admin_state(app, bundle) + app.include_router(bundle.router, prefix="/admin") + return app + + +async def _seed_actor_config( + conn: asyncpg.Connection, + schema: str, + actor: str, + queue: str = "default", +) -> None: + await sync_actor_config( + conn, + [ActorConfig(actor=actor, max_concurrent=1, queue=queue)], + schema=schema, + ) + + +async def _get_csrf_then_post( + app: FastAPI, + get_url: str, + post_url: str, + data: dict[str, str] | None = None, +) -> httpx.Response: + """GET (to obtain the CSRF cookie) then POST with the matching form field.""" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + follow_redirects=False, + ) as client: + get_resp = await client.get(get_url) + assert get_resp.status_code == 200 + csrf_token = get_resp.cookies.get("taskq_csrf_token", "") + assert csrf_token, "GET must set the taskq_csrf_token cookie" + return await client.post( + post_url, data={"csrf_token": csrf_token, **(data or {})} + ) + + +async def test_actors_page_lists_actor_config_rows( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """GET /admin/actors shows actor_config rows with queue and capacity.""" + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "test-actor-1", queue="default") + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.get("/admin/actors") + + assert resp.status_code == 200 + assert "test-actor-1" in resp.text + assert "default" in resp.text + + +async def test_actors_page_shows_deregister_button( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each actor row has a deregister form/button.""" + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "button-actor", queue="default") + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.get("/admin/actors") + + assert resp.status_code == 200 + assert "Deregister" in resp.text + assert "/deregister" in resp.text + + +async def test_deregister_route_returns_403_when_admin_actions_disabled( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """POST with VALID CSRF still returns 403 when admin_actions_enabled=False — + proving the 403 comes from the admin-actions gate, not a CSRF failure.""" + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "disabled-actor", queue="default") + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=False) + + resp = await _get_csrf_then_post( + app, "/admin/actors", "/admin/actors/disabled-actor/deregister" + ) + + assert resp.status_code == 403 + # Row must still be there — the gate fires before any DB mutation + count = await clean_pg_conn.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', + "disabled-actor", + ) + assert count == 1 + + +async def test_deregister_route_succeeds_for_clean_actor( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """POST deregister with force=False deletes the actor_config row.""" + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "clean-deregister-actor", queue="default") + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + resp = await _get_csrf_then_post( + app, "/admin/actors", "/admin/actors/clean-deregister-actor/deregister" + ) + + assert resp.status_code == 303 + assert "/actors" in resp.headers["location"] + + # Verify the actor_config row is gone + count = await clean_pg_conn.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', + "clean-deregister-actor", + ) + assert count == 0 +``` + +Note: every fixture above exists and is visible to test modules — +``module_pg_schema`` / ``clean_pg_conn`` come from ``taskq.testing.fixtures`` +(re-exported through ``tests/conftest.py``); ``admin_pool`` is defined in the +file. The 403 test deliberately passes a VALID CSRF token: ``validate_csrf`` +runs before the route body, so a missing/invalid token would also 403 — for +the wrong reason. The GET-first CSRF flow is required; there is no dev-mode +CSRF bypass (``TASKQ_ENVIRONMENT=dev`` only relaxes the auth dependency, not +``validate_csrf``). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_web_admin_actors.py -v` +Expected: FAIL (module doesn't exist) + +- [ ] **Step 3: Implement the actors admin page** + +Create `src/taskq/web/admin/actors.py`: + +```python +"""Actors overview and deregister admin pages.""" + +from urllib.parse import quote_plus + +import asyncpg +import structlog +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from jinja2 import Environment + +from taskq.exceptions import ActorDeregistrationError +from taskq.settings import TaskQSettings +from taskq.web.admin._factory import ( + get_base_path, + get_csrf_token, + get_pg_pool, + get_realtime_ctx, + get_schema, + get_settings, + get_templates, + validate_csrf, +) +from taskq.worker.actor_config_ops import deregister_actor + +logger = structlog.get_logger("taskq.web.admin.actors") + +_ACTORS_SQL = """ +SELECT ac.actor, ac.max_concurrent, ac.max_pending, ac.queue, + ac.result_ttl, ac.metadata::text AS metadata, ac.updated_at::text AS updated_at, + (SELECT count(*) FROM "{schema}".jobs j + WHERE j.actor = ac.actor + AND j.status IN ('pending', 'scheduled', 'running')) AS active_job_count, + (SELECT count(*) FROM "{schema}".cron_schedules cs + WHERE cs.actor = ac.actor AND cs.enabled = true) AS enabled_schedule_count + FROM "{schema}".actor_config ac + ORDER BY ac.actor +""".strip() + + +def register(router: APIRouter) -> None: + """Attach actors overview and deregister routes to *router*.""" + + @router.get("/actors", response_class=HTMLResponse) + async def actors_overview( + pool: asyncpg.Pool = Depends(get_pg_pool), + schema: str = Depends(get_schema), + tmpl: Environment = Depends(get_templates), + realtime_ctx: tuple[str, str] = Depends(get_realtime_ctx), + csrf_token: str = Depends(get_csrf_token), + ) -> HTMLResponse: + actors_sql = _ACTORS_SQL.format(schema=schema) + rows: list[asyncpg.Record] = [] + async with pool.acquire() as conn: + rows = await conn.fetch(actors_sql) + actors = [dict(r) for r in rows] + realtime_mode, mode_label = realtime_ctx + html = tmpl.get_template("actors.html").render( + actors=actors, + realtime_mode=realtime_mode, + mode_label=mode_label, + csrf_token=csrf_token, + active_page="actors", + ) + return HTMLResponse(content=html) + + @router.post("/actors/{actor}/deregister") + async def actor_deregister( + actor: str, + request: Request, + _csrf: None = Depends(validate_csrf), + pool: asyncpg.Pool = Depends(get_pg_pool), + schema: str = Depends(get_schema), + base_path: str = Depends(get_base_path), + settings: TaskQSettings = Depends(get_settings), + ) -> RedirectResponse: + if not settings.admin_actions_enabled: + raise HTTPException(status_code=403, detail="Admin actions are disabled") + + # Read form fields after CSRF validation. Starlette caches + # request.form() so the CSRF dependency's read and this read + # share the same parsed body. + form = await request.form() + force = form.get("force") == "true" + purge_queue = form.get("purge_queue") == "true" + + async with pool.acquire() as conn: + try: + result = await deregister_actor( + conn, actor, force=force, purge_queue=purge_queue, schema=schema + ) + except ActorDeregistrationError as exc: + raise HTTPException( + status_code=409, detail=str(exc) + ) from None + + return RedirectResponse( + url=f"{base_path}/actors?notice=deregistered+{quote_plus(actor)}", + status_code=303, + ) +``` + +Create `src/taskq/web/templates/actors.html` — a Jinja2 template following the existing pattern (see `workers.html` and `schedules.html` for structure): + +```html +{% extends "_base.html" %} +{% block title %}Actors — TaskQ Admin{% endblock %} +{% block content %} +
+

Actors

+ {% if actors %} +
+
+ + + + + + + + + + + + + + {% for a in actors %} + + + + + + + + + + + {% endfor %} + +
ActorQueueMax ConcurrentMax PendingActive JobsSchedulesUpdatedActions
{{ a.actor }}{{ a.queue }}{{ a.max_concurrent or '∞' }}{{ a.max_pending or '—' }} + + {{ a.active_job_count }} + + {{ a.enabled_schedule_count }}{{ a.updated_at | time_ago }} +
+ + + + +
+
+
+ {% else %} +

No actor_config rows.

+ {% endif %} +
+{% endblock %} +``` + +Note on actor-name encoding: the form action pipes the name through +`urlencode` because actor names are unvalidated free text. Starlette's +default `{actor}` path converter matches a single segment, so a name +containing an embedded `/` will not match the deregister route regardless +of encoding (it 404s) — an accepted limitation to document in the admin UI +guide (Task 11); such actors remain deregisterable via the client API and +CLI. + +Add the nav link to `src/taskq/web/templates/_base.html` — after the "Workers" link (around line 53), add: + +```html +Actors +``` + +- [ ] **Step 4: Run tests to verify they pass** + +The deregister POST route reads form fields via `request.form()` (cached by Starlette) after the CSRF dependency has validated. The `force` and `purge_queue` checkboxes send `"true"` when checked; the route checks `form.get("force") == "true"`. + +Run: `uv run pytest tests/test_web_admin_actors.py -v` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/taskq/web/admin/actors.py src/taskq/web/templates/actors.html src/taskq/web/templates/_base.html tests/test_web_admin_actors.py +git commit -m "feat: add admin UI actors page with deregister button" +``` + +--- + +### Task 9: Export `ActorsClient` from public API + +**Files:** +- Modify: `src/taskq/__init__.py` +- Modify: `src/taskq/worker/actor_config_ops.py` (ensure `__all__` is complete) + +- [ ] **Step 1: Write the failing test** + +```python +# Add to tests/test_taskq_client.py or a new test file + +def test_actors_client_importable_from_taskq() -> None: + from taskq import ActorsClient + assert ActorsClient is not None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_taskq_client.py::test_actors_client_importable_from_taskq -v` +Expected: FAIL with `ImportError` + +- [ ] **Step 3: Add the export** + +In `src/taskq/__init__.py`, add: + +```python +from taskq.client._actors import ActorsClient +``` + +Add `"ActorsClient"` to the `__all__` list. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_taskq_client.py::test_actors_client_importable_from_taskq -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/taskq/__init__.py tests/test_taskq_client.py +git commit -m "feat: export ActorsClient from public API" +``` + +--- + +### Task 10: E2E test — full deregistration lifecycle + +**Files:** +- Create: `tests/e2e/test_actor_deregistration.py` + +- [ ] **Step 1: Write the e2e test** + +```python +# tests/e2e/test_actor_deregistration.py + +"""E2E: actor deregistration lifecycle with a real worker. + +Each test uses a **different actor** to avoid cross-test interference: +``e2e_worker`` is module-scoped and ``sync_actor_config`` runs only at +bootstrap, so once a test deregisters an actor's ``actor_config`` row, +later tests cannot enqueue to that same actor (the dispatch query +inner-joins ``actor_config`` — jobs would never be dispatched). + +Actors used (all defined in ``tests/e2e/actors.py``): +- ``quick_result`` — 0.05 s sleep, simple payload/result. Used for the + clean-deregister-after-completion test. +- ``long_running_job`` — 30 s sleep. Used for the refusal-with-active-jobs + test (guaranteed to be ``running`` when we deregister). +- ``short_lived_job`` — 0.5 s sleep. Used for the force-deregister test. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from ._assertions import poll_until, wait_for_handle_status +from .actors import ( + LongRunningPayload, + QuickResultPayload, + ShortJobPayload, + long_running_job, + quick_result, + short_lived_job, +) + +if TYPE_CHECKING: + import asyncpg + + from taskq import TaskQ + + from .conftest import E2ESchema, E2EWorker + +pytestmark = [pytest.mark.e2e, pytest.mark.timeout(900)] + + +async def test_deregister_after_jobs_complete( + e2e_client: TaskQ, + e2e_worker: E2EWorker, + e2e_pg_pool: asyncpg.Pool, + e2e_schema: E2ESchema, + run_id: str, +) -> None: + """Deregister an actor after all its jobs are terminal.""" + schema = e2e_schema.schema_name + actor_name = quick_result.name + + # 1. Enqueue a job and wait for it to complete + handle = await e2e_client.enqueue( + quick_result, QuickResultPayload(run_id=run_id, value="test") + ) + await handle.wait(timeout=60) + + # 2. Verify the actor_config row exists (seeded by worker startup) + ac_count = await e2e_pg_pool.fetchval( + f"SELECT count(*) FROM \"{schema}\".actor_config WHERE actor = $1", + actor_name, + ) + assert ac_count == 1, f"actor_config row for {actor_name} should exist" + + # 3. Deregister the actor (force=False — all jobs are terminal) + result = await e2e_client.actors.deregister(actor_name) + + assert result.actor_config_deleted is True + assert result.terminal_jobs_remaining >= 1 # our completed job + + # 4. Verify the actor_config row is gone + ac_count = await e2e_pg_pool.fetchval( + f"SELECT count(*) FROM \"{schema}\".actor_config WHERE actor = $1", + actor_name, + ) + assert ac_count == 0 + + # 5. Verify terminal job history is still queryable + job_count = await e2e_pg_pool.fetchval( + f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = $1 AND status = 'succeeded'", + actor_name, + ) + assert job_count >= 1, "terminal job history should remain after deregistration" + + +async def test_deregister_refuses_with_active_jobs( + e2e_client: TaskQ, + e2e_worker: E2EWorker, + e2e_pg_pool: asyncpg.Pool, + e2e_schema: E2ESchema, + run_id: str, +) -> None: + """Deregistration refuses when a job is running. + + Uses ``long_running_job`` (30 s sleep) so the job is guaranteed to be + ``running`` when we attempt deregistration. + """ + from taskq.exceptions import ActorHasActiveJobsError + + schema = e2e_schema.schema_name + actor_name = long_running_job.name + + # 1. Enqueue a long-running job + handle = await e2e_client.enqueue( + long_running_job, LongRunningPayload(run_id=run_id) + ) + + # 2. Wait until the job is running (poll the DB) + async def _is_running() -> bool: + status = await e2e_pg_pool.fetchval( + f"SELECT status FROM \"{schema}\".jobs WHERE id = $1", + handle.job_id, + ) + return status == "running" + + await poll_until(_is_running, timeout=30.0, interval=0.5) + + # 3. Try to deregister — must refuse with ActorHasActiveJobsError + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await e2e_client.actors.deregister(actor_name) + + assert exc_info.value.actor == actor_name + assert exc_info.value.active_count >= 1 + assert "running" in exc_info.value.status_counts + + # 4. Verify the actor_config row is still there (refusal did not delete) + ac_count = await e2e_pg_pool.fetchval( + f"SELECT count(*) FROM \"{schema}\".actor_config WHERE actor = $1", + actor_name, + ) + assert ac_count == 1 + + # 5. Clean up: cancel the job, wait for terminal, then force-deregister. + # Do NOT use handle.wait() here — it raises JobFailed for any + # non-success terminal status (cancelled included). Poll the status + # instead (the test_cancellation.py idiom). long_running_job never + # calls ctx.check_cancelled(), so the cancel lands only after the + # 30 s sleep finishes and the consumer routes the completion to + # mark_cancelled (cancel_phase >= COOPERATIVE is checked post-run) — + # budget the full 30 s plus margin. + await handle.cancel() + await wait_for_handle_status(handle, "cancelled", timeout=60) + + result = await e2e_client.actors.deregister(actor_name, force=True) + assert result.actor_config_deleted is True + + # 6. Verify cleanup + ac_count = await e2e_pg_pool.fetchval( + f"SELECT count(*) FROM \"{schema}\".actor_config WHERE actor = $1", + actor_name, + ) + assert ac_count == 0 + + +async def test_deregister_force_after_completion( + e2e_client: TaskQ, + e2e_worker: E2EWorker, + e2e_pg_pool: asyncpg.Pool, + e2e_schema: E2ESchema, + run_id: str, +) -> None: + """force=True deregister succeeds when all jobs are terminal. + + Uses ``short_lived_job`` (0.5 s sleep). Enqueues a job, waits for + completion, then force-deregisters. The force path cancels 0 jobs + (none are pending) and succeeds. + """ + schema = e2e_schema.schema_name + actor_name = short_lived_job.name + + # 1. Enqueue a job and wait for completion + handle = await e2e_client.enqueue( + short_lived_job, ShortJobPayload(run_id=run_id, label="force-test") + ) + await handle.wait(timeout=60) + + # 2. Force-deregister (no active jobs, so force has nothing to cancel) + result = await e2e_client.actors.deregister(actor_name, force=True) + + assert result.actor_config_deleted is True + assert result.jobs_cancelled == 0 # no pending jobs to cancel + assert result.terminal_jobs_remaining >= 1 + + # 3. Verify cleanup + ac_count = await e2e_pg_pool.fetchval( + f"SELECT count(*) FROM \"{schema}\".actor_config WHERE actor = $1", + actor_name, + ) + assert ac_count == 0 +``` + +Note: Each test uses a **different actor** (``quick_result``, ``long_running_job``, ``short_lived_job``) to avoid the module-scoped worker issue where deregistering one actor's ``actor_config`` row prevents later tests from dispatching jobs to that actor. All three actors are already defined in ``tests/e2e/actors.py``. + +- [ ] **Step 2: Run the e2e test** + +Run: `uv run pytest tests/e2e/test_actor_deregistration.py -v --tb=short` +Expected: PASS (3 tests) — requires Docker for the worker container. Each test uses a different actor to avoid cross-test interference. + +- [ ] **Step 3: Commit** + +```bash +git add tests/e2e/test_actor_deregistration.py +git commit -m "test: add e2e test for actor deregistration lifecycle" +``` + +--- + +### Task 11: Documentation updates + +**Files:** +- Modify: `docs/guides/actors.md` (add deregistration section) +- Modify: `docs/guides/cli.md` (add `deregister` command docs) +- Modify: `docs/guides/admin-ui.md` (add actors page docs) + +- [ ] **Step 1: Add deregistration section to actors.md** + +Add a new section at the end of the file (after "Full worked example"): + +```markdown +## Actor deregistration + +Actors registered by worker startup create `actor_config` rows that persist +until explicitly removed. For long-lived deployments this is intentional — +the row is the source of truth for capacity and routing. For ephemeral, +per-run deployments (e.g. `my-actor.`), each run leaves a row behind. + +### `client.actors.deregister()` + +```python +async with TaskQ(dsn=...) as tq: + result = await tq.actors.deregister("my-actor.run-123") + # force=False: refuses if non-terminal jobs or enabled schedules exist +``` + +**Safety checks (force=False):** +- Refuses if any non-terminal jobs (pending/scheduled/running) reference the + actor. +- Refuses if any enabled cron schedules reference the actor. + +**force=True:** +- Still refuses if **running** jobs exist (they are actively executing). +- Cancels pending/scheduled jobs (marks as `cancelled` with + `error_class='ActorDeregistered'`). +- Disables enabled cron schedules (sets `enabled=false`). + +**Terminal job history** is never deleted. The `jobs.actor` column is plain +text, not a foreign key — terminal rows remain queryable by actor name after +deregistration. + +**Queue cleanup** (`purge_queue=True`): deletes the `queues` row if no other +`actor_config` references the same queue. A shared queue is never purged. + +### Enqueue after deregistration + +After deregistration, any client can still `enqueue()` the dead actor name — +the `INSERT` succeeds (there is no foreign key from `jobs.actor` to +`actor_config.actor`), and the job sits in `pending` status forever. Because +the dispatch query inner-joins `actor_config`, the job will **never be +dispatched** and no background sweep will reap it (deregistration is an +explicit operator action, not a background GC — see Non-goal #2). + +**Operational discipline:** stop enqueuing to an actor *before* deregistering +it. Deregistration is best-effort against concurrent enqueue/dispatch; +callers must quiesce the actor first (stop enqueuing, disable cron +schedules, wait for running jobs to reach a terminal state). + +A follow-up issue may explore an opt-in "strict mode" that rejects enqueues +to actors with no `actor_config` row; this is explicitly out of scope for +this spec. + +### Idempotent deregistration + +A second `deregister` call on an already-deregistered actor raises +`ActorNotFoundError`. For cleanup-automation loops (e.g. iterating over +stage actors after a run completes), use the try/except idiom: + +```python +from taskq.exceptions import ActorNotFoundError + +try: + await tq.actors.deregister(actor_name, force=True, purge_queue=True) +except ActorNotFoundError: + pass # already deregistered — idempotent +``` + +### `taskq actor-config deregister` + +```bash +taskq actor-config deregister my-actor.run-123 +taskq actor-config deregister my-actor.run-123 --force --purge-queue +``` + +### Admin UI + +The `/admin/actors` page lists all `actor_config` rows with active job counts +and schedule counts. Each row has a deregister form with `force` and +`purge_queue` checkboxes (requires `TASKQ_ADMIN_ACTIONS_ENABLED=true`). +``` + +- [ ] **Step 2: Add CLI docs to cli.md** + +- [ ] **Step 3: Add admin UI docs to admin-ui.md** + +- [ ] **Step 4: Commit** + +```bash +git add docs/guides/actors.md docs/guides/cli.md docs/guides/admin-ui.md +git commit -m "docs: add actor deregistration documentation" +``` + +--- + +### Task 12: Run full verification + +- [ ] **Step 1: Run the full test suite** + +```bash +uv run pytest tests/test_actor_deregistration.py tests/test_cli_actor_deregister.py tests/test_actors_client.py tests/test_web_admin_actors.py tests/test_exceptions.py -v +``` + +- [ ] **Step 2: Run type checking** + +```bash +uv run pyright src/taskq/worker/actor_config_ops.py src/taskq/client/_actors.py src/taskq/cli.py src/taskq/exceptions.py +``` + +- [ ] **Step 3: Run linting** + +```bash +uv run ruff check src/taskq/worker/actor_config_ops.py src/taskq/client/_actors.py src/taskq/cli.py src/taskq/exceptions.py +``` + +Also add Ruff S608 per-file-ignore entries to `pyproject.toml` for the new +test files that use f-string SQL (schema name is validated against +`_IDENT_RE` before interpolation — same rationale as existing entries): + +```toml +[tool.ruff.lint.per-file-ignores] +# ... existing entries ... +"tests/test_actor_deregistration.py" = ["S608"] +"tests/test_web_admin_actors.py" = ["S608"] +"tests/test_cli_actor_deregister.py" = ["S608"] +``` + +Then run: + +```bash +uv run ruff check tests/test_actor_deregistration.py tests/test_web_admin_actors.py tests/test_cli_actor_deregister.py +``` + +- [ ] **Step 4: Run e2e tests (if Docker is available)** + +```bash +uv run pytest tests/e2e/test_actor_deregistration.py -v +``` + +- [ ] **Step 5: Commit any remaining fixes** + +```bash +git add -A +git commit -m "chore: verification fixes" +``` + +--- + +## Test Coverage Requirements + +| Layer | Test file | Coverage target | +|-------|-----------|-----------------| +| Exceptions | `tests/test_exceptions.py` | All 4 new exception classes: construction, attributes, inheritance | +| Ops function | `tests/test_actor_deregistration.py` | force=False: not found, clean delete, pending jobs refuse, running jobs refuse, enabled schedules refuse, disabled schedules OK, terminal jobs OK | +| Ops function | `tests/test_actor_deregistration.py` | force=True: cancel pending + disable schedules, refuse running, mixed (running + pending), terminal history preserved | +| Ops function | `tests/test_actor_deregistration.py` | purge_queue: orphan queue deleted, shared queue kept, default (no purge) keeps queue | +| ActorsClient | `tests/test_actors_client.py` | list/get/set_capacity/deregister delegation, pool acquire, schema forwarding | +| TaskQ.actors | `tests/test_taskq_client.py` | Property returns ActorsClient, raises before open() | +| CLI | `tests/test_cli_actor_deregister.py` | Default, --force, --purge-queue, not found, active jobs error, schedules error, output shape | +| Admin UI | `tests/test_web_admin_actors.py` | Page renders, deregister button, 403 when admin_actions disabled, successful deregister | +| E2E | `tests/e2e/test_actor_deregistration.py` | Full lifecycle: enqueue → complete → deregister → verify; refuse with running jobs (real assertions); force deregister after completion | + +--- + +## Backward Compatibility Analysis + +1. **No schema migration required.** The existing schema (tables, columns, constraints, indexes) is unchanged. Deregistration works on any schema that has the current migrations applied. + +2. **No new dependencies.** The implementation uses only existing modules (`asyncpg`, `structlog`, `typer`, `fastapi`, `jinja2`). + +3. **No breaking API changes.** All new code is additive: + - `ActorsClient` is a new class — no existing code references it. + - `TaskQ.actors` is a new property — no existing code calls it. + - `deregister_actor` is a new function in `actor_config_ops.py` — no existing code imports it. + - `taskq actor-config deregister` is a new CLI command — no existing scripts call it. + - The admin UI actors page is a new route — auto-discovered by `_discover_and_register`. + - New exceptions inherit from `ActorDeregistrationError` → `TaskQError` — no existing exception handling is affected. + +4. **`__all__` additions are additive.** New names added to `__all__` in `actor_config_ops.py`, `client/__init__.py`, and `taskq/__init__.py` do not remove any existing names. + +5. **Admin UI nav link is additive.** The new "Actors" link in `_base.html` does not alter existing navigation. + +6. **No changes to drift-check behavior.** `_STRUCTURAL_FIELDS`, `ActorConfigDriftList`, and `sync_actor_config` are unchanged. + +7. **Downstream consumer migration.** Consumers currently using hand-rolled SQL: + ```python + await conn.execute(f'DELETE FROM "{schema}".actor_config WHERE actor = $1', actor_name) + ``` + can replace with: + ```python + await client.actors.deregister(actor_name, force=True, purge_queue=True) + ``` + The `force=True` is needed because the hand-rolled SQL doesn't check for non-terminal jobs. Consumers should audit their cleanup paths and decide whether `force=True` or the safer default is appropriate. + +--- + +## Downstream Consumer Impact Analysis + +### warden (`~/src/warden`) + +**Current pattern:** Static, module-level actor names. Warden's five actors +(`transcription_backend_call`, `diarization_backend_call`, +`ocr_backend_call`, `provision_backend_call`, `autoscale_cron_tick`) are +declared with `@actor(name=...)` at module scope +(`~/src/warden/src/warden/jobs.py:382,532,728,869,1002`). No dynamic or +per-deployment actor naming exists. Tests use `InMemoryBackend` with +`register_actor_config` (`~/src/warden/tests/test_transcription_jobs.py:85-87`). + +**Impact:** **None today.** Warden's actors are fixed names that persist for +the lifetime of the deployment — they never accumulate `actor_config` rows +and do not need deregistration. If warden ever adopts ephemeral per-run +actors (e.g. for transient model deployments), the `deregister` API is +available, but no migration is needed now. + +### cennan (`~/src/cennan`) + +**Current pattern:** Fixed set of actors, one per pipeline stage +(`sync_binding`, `list_page`, `fetch_document`, `extract_document`, +`chunk_document`, `rechunk_binding`, `embed_batch`, `store_batch`, +`reproject_document_metadata` +— `~/src/cennan/src/cennan/pipeline/actors.py:154-241`; architecture doc: +"TaskQ actors, one per stage"). Binding identity travels in job payloads +(`binding:{id}`), not in actor names. Actors are registered at worker +startup and persist for the deployment lifetime. + +**Impact:** **None today.** Cennan's actors are fixed stage names, not +per-KB or per-binding — they do not accumulate rows and do not need +deregistration. If cennan ever adopts per-binding actor naming, the +`deregister` API is available, but no migration is needed now. + +### aacrtool (`~/src/aacrtool`) + +**Current pattern:** Per-review-run actors. The aacrtool spec rev3 plan +explicitly identifies this as gap #11: "Ephemeral actor_name accumulation +(8 rows/scan)" → "No actor deregistration/GC in HEAD" → "upstream +candidate: actor deregistration on worker shutdown / actor_config GC." + +**Impact:** **High — this is the consumer that explicitly identified the +gap.** After a scan completes: + +```python +# After a scan run is finalized: +for stage in ["s1-crawl", "s2-fetch", "s3-parse", "s4-analyze", "s5-embed", ...]: + await tq.actors.deregister(f"{stage}.{scan_slug}", force=True, purge_queue=True) +``` + +**Migration path:** aacrtool's scan finalization handler should deregister +all per-scan actors after the scan reaches a terminal state (`complete` or +`partial`). The `force=True` flag is needed because some jobs may still be +pending when the scan is finalized. `purge_queue=True` cleans up the +per-scan queue. For idempotent cleanup loops, wrap in `try/except +ActorNotFoundError: pass` (see Design Decision §10). + +**Design note from aacrtool spec:** "Do NOT delete rows from taskq schema +AACRTool-side." This spec provides the upstream API so aacrtool can stop +deferring the cleanup and use the official `deregister` path. + +--- + +## Key Design Decisions + +### 1. Pure application logic, no migration + +**Decision:** Deregistration is a transactional set of checks + DELETEs, not a schema change. + +**Rationale:** The existing schema has no FKs from `jobs.actor` or `cron_schedules.actor` to `actor_config.actor`. Adding FKs with `ON DELETE` actions would require a migration and risk lock contention on the hot `jobs` table (an `ADD FOREIGN KEY` scan blocks reads and writes). The application logic approach works on any already-migrated schema and is simpler to reason about. + +**Tradeoff:** If someone manually deletes an `actor_config` row (bypassing `deregister_actor`), pending jobs for that actor become stranded. The `deregister_actor` function's safety checks prevent this, but the schema doesn't enforce it. This is the same tradeoff the existing design already makes — the drift check is application-level, not schema-level. + +### 2. force=True still refuses running jobs + +**Decision:** `force=True` cancels pending/scheduled jobs and disables schedules, but still refuses if running jobs exist. + +**Rationale:** Running jobs are actively executing — their terminal-write path reads `actor_config.result_ttl` to compute `result_expires_at`. Deleting the row mid-execution sets `result_expires_at` to NULL (the subquery returns NULL), which is safe but surprising. More importantly, the dispatch query inner-joins `actor_config`, so a running job that retries would be stranded. Refusing is the safe default; the operator can wait for running jobs to complete or cancel them first. + +### 3. Schedules are disabled, not deleted + +**Decision:** `force=True` sets `enabled=false` on cron schedules, not `DELETE`. + +**Rationale:** The schedule row carries configuration (cron expression, timezone, payload factory) that the operator may want to re-enable if the actor is re-registered. Deleting the schedule would lose this configuration. Disabling is reversible; deleting is not. + +### 4. Queue purge is opt-in + +**Decision:** `purge_queue` defaults to `False`. The caller must explicitly request it. + +**Rationale:** Queue rows are metadata (mode, max_concurrent) that might be shared between actors or manually managed by the operator. Deleting a queue row doesn't affect already-queued jobs (there's no FK), but it does remove the configuration. Making it opt-in prevents accidental loss of queue-level settings. + +### 5. Terminal job history is never deleted + +**Decision:** Terminal jobs (succeeded/failed/cancelled/crashed/abandoned) remain in the `jobs` table after deregistration. + +**Rationale:** `jobs.actor` is a plain `text` column, not a foreign key — terminal rows remain queryable by actor name. Deleting them would lose audit history and result data. The `DeregisterResult.terminal_jobs_remaining` count informs the caller how many such rows exist. The existing archive sweep will eventually move them to `jobs_archive` and then hard-delete them per the retention policy — that's the correct GC path, not deregistration. + +### 6. ActorsClient as a separate class, not methods on TaskQ + +**Decision:** Create `ActorsClient` as a separate class, exposed via `TaskQ.actors` property. + +**Rationale:** The issue explicitly asks for `client.actors.deregister(...)`. Separating actor operations from job operations keeps `TaskQ` focused as a job client and provides a clean namespace for future actor management operations. The pool-wrapping pattern mirrors how `JobsClient` wraps the `Backend`. + +### 7. Admin UI page is auto-discovered + +**Decision:** The actors page follows the existing `_discover_and_register` pattern in `_factory.py`. + +**Rationale:** No changes to `_factory.py` are needed — the `register()` function in `actors.py` is automatically discovered and called. This follows the "decompose by composition, not accumulation" principle documented in the codebase. + +### 8. Accepted TOCTOU race — deregistration is best-effort against concurrent enqueue/dispatch + +**Decision:** Deregistration does NOT serialize against concurrent enqueue, cron-fire, or dispatch. Callers must quiesce the actor first. + +**Rationale:** `deregister_actor` runs in a READ COMMITTED transaction. The safety checks and DELETE are separate statements; a job enqueued by a concurrent transaction that commits after the check but before the DELETE is invisible to the check and will be stranded (the `jobs` INSERT has no FK to `actor_config`, and dispatch inner-joins `actor_config` so the job is never dispatched). Three remediation options were evaluated: + +- **(a) Document accepted semantics** — "callers must quiesce first." This is the same operational discipline as any shutdown sequence. **Chosen.** +- **(b) `pg_advisory_xact_lock(hashtext(actor))`** in `deregister_actor` and on the enqueue/dispatch paths — would serialize the hot enqueue path against a rare administrative operation. The cost is unjustified for the problem size. +- **(c) A narrow reaper** (leader sweep cancels pending jobs whose actor has no `actor_config` row) — conflicts with Non-goal #2 (no GC sweep), would require amending the non-goal and adding leader-loop complexity. + +The accepted-semantics approach is consistent with Non-goal #2 and the existing design philosophy: deregistration is an explicit operator action, not a background automation. The operator's runbook is: stop enqueuing → wait for terminal → deregister. + +### 9. Enqueue-after-deregistration is unguarded + +**Decision:** After deregistration, any client can still `enqueue()` the dead actor name. The INSERT succeeds (no FK), the job sits `pending` forever, invisible to dispatch. + +**Rationale:** Enqueue-side rejection of unknown actors would require a check against `actor_config` on every enqueue — a hot-path cost for a rare operational mistake. The hazard is documented in the API surface, CLI output, and `docs/guides/actors.md`. A follow-up issue may explore an opt-in "strict mode" that rejects enqueues to actors with no `actor_config` row; this is explicitly out of scope for this spec. + +### 10. Idempotency — second deregister raises ActorNotFoundError + +**Decision:** A second `deregister` call on an already-deregistered actor raises `ActorNotFoundError`. There is no `if_missing` parameter. + +**Rationale:** Adding `if_missing: Literal["raise", "ok"]` would complicate the API for a marginal convenience. Cleanup-automation callers (e.g. aacrtool loops) should use the try/except idiom: + +```python +from taskq.exceptions import ActorNotFoundError + +try: + await tq.actors.deregister(actor_name, force=True, purge_queue=True) +except ActorNotFoundError: + pass # already deregistered — idempotent +``` + +This is documented in the guide. + +--- + +## Revision log + +### 2026-07-29 — Post-review revision (verdict: NEEDS REWORK → resolved) + +Revised against `.review/spec-review.md` (1 Critical / 3 High / 4 Medium / +9 Low). The review confirmed the architecture and semantics for issue #56 +are sound; the rework was execution fidelity. Standing directive applied: +TaskQ 1.0.0 is a breaking release — no gratuitous churn, but no hacks, +legacy paths, shims, or dual-path compat either; documented downstream +needs are the contract, current downstream usage is not a constraint. + +Resolved: + +- **C1 (Critical):** `_DEREGISTER_CHECK_ACTIVE_JOBS_SQL` now casts to the + real enum array type (`$2::"{schema}".job_status[]`, matching the + `_sql_templates.py:451` precedent) instead of `$2::text[]`, which raised + PG 42883 against the real `job_status` enum column. The hand-rolled + minimal test schema (text `status`) was replaced with real migrations + (`taskq.migrate.apply_pending`), so this class of type drift is + structurally impossible in tests. +- **H1:** Resolved by the same `apply_pending` fixture change — the real + migrated `jobs` table carries `finished_at` / `error_class` / + `error_message`, so Task 3's force=True cancel SQL executes against the + same columns it will see in production. `_insert_job` supplies the + NOT-NULL-without-default columns (`max_attempts`, `retry_kind`) and + casts the status parameter to `job_status`. +- **H2:** TOCTOU race now explicitly acknowledged with chosen option (a) — + documented best-effort semantics ("quiesce the actor first") in the ops + docstring warning, Design Decision §8 (options b/c evaluated and + rejected with cost rationale), the CLI success warning, and the docs + guide. No serialization added: advisory locks would tax the hot enqueue + path for a rare admin operation; a reaper conflicts with Non-goal #2. +- **H3:** e2e plan rewritten — uses the real actors `quick_result`, + `long_running_job`, `short_lived_job` from `tests/e2e/actors.py` + (verified present and registered in `worker_entry.py`), one actor per + test to respect the module-scoped worker's bootstrap-only + `sync_actor_config`, and real refusal assertions. Additionally fixed a + residual the review did not catch: the refusal test's cleanup used + `handle.wait()` after cancel, which raises `JobFailed` on the + `cancelled` terminal status; it now polls with + `wait_for_handle_status(handle, "cancelled", timeout=60)` and documents + why the cancel takes the full ~30 s (the actor never calls + `ctx.check_cancelled()`, so cancellation lands at completion via the + consumer's post-run `cancel_phase` check). +- **M1:** Task 6 test uses `module_pg_schema.pg_dsn` (not `str()` of the + NamedTuple); unused `pg_conn` param removed. +- **M2:** Task 8 fully specified — tests now self-contained (per-test + `admin_pool` on the module schema, `_make_admin_app` helper mirroring + `test_web_admin_integration.py`, `httpx.AsyncClient` + `ASGITransport` + instead of `TestClient`, GET-first synchronizer-token CSRF flow instead + of a hardcoded token that `validate_csrf` would reject; the 403 test + passes valid CSRF so the gate — not CSRF — is what fails). The POST + route is exact: `request.form()` after `validate_csrf` (Starlette caches + the parsed body), `TaskQSettings`-typed settings dependency. +- **M3:** Downstream section rewritten per the directive — aacrtool quote + re-verified verbatim; warden/cennan false claims removed and replaced + with the verified reality (fixed-name actors, no deregistration need + today, API available if they adopt per-run naming). Warden actor names + and cennan actor list/line-range corrected from the repos. +- **M4:** Enqueue-after-deregistration semantics documented (Design + Decision §9, docs guide "Enqueue after deregistration", CLI warning, + ops docstring); strict-mode enqueue rejection named as explicit + follow-up, out of scope. +- **L1–L9:** broken duplicate `_insert_job` removed; Ruff S608 + per-file-ignore entries added to Task 12; Task 5 uses `monkeypatch`; + CLI catches `ValueError` alongside `ActorDeregistrationError`; template + uses `urlencode` (with the single-segment path-converter limitation + noted); idempotency try/except idiom documented (§10); docs anchor + corrected to "Full worked example"; Task 2 comment renumbered (purge + lands in Task 3); scope expansion beyond issue #56 flagged at the top + for the issue author. + +Design changes: none to the core semantics (refusal rules, force=True +behavior, disable-not-delete schedules, opt-in queue purge, terminal +history retention are unchanged and were judged sound). No breaking +changes introduced by this feature — all surface is additive, consistent +with the directive's "no gratuitous churn" clause. Intentionally +deferred: enqueue-side rejection of unknown actors (named follow-up; +hot-path cost), `if_missing` idempotency parameter (YAGNI — the +try/except idiom covers the cleanup-loop case). diff --git a/src/taskq/__init__.py b/src/taskq/__init__.py index 0dc5de04..83cfe948 100644 --- a/src/taskq/__init__.py +++ b/src/taskq/__init__.py @@ -11,6 +11,7 @@ import importlib.metadata from taskq.actor import ActorFn, ActorFnWithCtx, ActorHandler, ActorRef, actor +from taskq.actor_config_ops import DeregisterResult from taskq.auth import ( PgCredential, PgCredentialProvider, @@ -87,7 +88,6 @@ RetryPolicy, ) from taskq.scheduler import register_cron -from taskq.worker.actor_config_ops import DeregisterResult __all__ = [ "ActorConfigDriftError", diff --git a/src/taskq/worker/actor_config.py b/src/taskq/actor_config.py similarity index 100% rename from src/taskq/worker/actor_config.py rename to src/taskq/actor_config.py diff --git a/src/taskq/worker/actor_config_ops.py b/src/taskq/actor_config_ops.py similarity index 100% rename from src/taskq/worker/actor_config_ops.py rename to src/taskq/actor_config_ops.py diff --git a/src/taskq/cli.py b/src/taskq/cli.py index a80800ce..290e9dd2 100644 --- a/src/taskq/cli.py +++ b/src/taskq/cli.py @@ -30,9 +30,7 @@ close_redis_bounded, ) from taskq.actor import ActorRef -from taskq.exceptions import ActorConfigDriftList, ActorDeregistrationError -from taskq.settings import TaskQSettings, WorkerSettings -from taskq.worker.actor_config_ops import ( +from taskq.actor_config_ops import ( UNSET, ActorConfigRow, Unset, @@ -41,6 +39,8 @@ list_actor_configs, set_actor_config_capacity, ) +from taskq.exceptions import ActorConfigDriftList, ActorDeregistrationError +from taskq.settings import TaskQSettings, WorkerSettings from taskq.worker.dev import dev_watch_loop from taskq.worker.run import worker_main as _worker_main diff --git a/src/taskq/client/_actors.py b/src/taskq/client/_actors.py index a9aced3e..f6ede3bf 100644 --- a/src/taskq/client/_actors.py +++ b/src/taskq/client/_actors.py @@ -2,13 +2,13 @@ Provides a typed surface for listing, inspecting, tuning, and deregistering stored ``actor_config`` rows. Each method acquires a connection from the -injected pool, delegates to ``taskq.worker.actor_config_ops``, and returns +injected pool, delegates to ``taskq.actor_config_ops``, and returns the result. """ from typing import TYPE_CHECKING -from taskq.worker.actor_config_ops import ( +from taskq.actor_config_ops import ( UNSET, ActorConfigRow, DeregisterResult, @@ -29,7 +29,7 @@ class ActorsClient: """Pool-wrapping facade for actor configuration operations. Acquires a connection from the injected pool for each call, delegates - to ``taskq.worker.actor_config_ops``, and returns the result. The + to ``taskq.actor_config_ops``, and returns the result. The caller must have opened the pool; this class does not manage its lifecycle. @@ -83,7 +83,7 @@ async def deregister( ) -> DeregisterResult: """Deregister an actor with safety checks. - See :func:`taskq.worker.actor_config_ops.deregister_actor` for + See :func:`taskq.actor_config_ops.deregister_actor` for the full semantics. """ async with self._pool.acquire() as conn: diff --git a/src/taskq/testing/_runner.py b/src/taskq/testing/_runner.py index e8b46dc4..1fdbb8e5 100644 --- a/src/taskq/testing/_runner.py +++ b/src/taskq/testing/_runner.py @@ -20,6 +20,7 @@ import structlog from pydantic import BaseModel +from taskq.actor_config import ActorConfig from taskq.backend._protocol import ( EventRow, JobId, @@ -31,7 +32,6 @@ from taskq.context import JobContext from taskq.exceptions import Snooze from taskq.retry import OnRetryExhausted, OnSuccess, RetryClassifierHook, RetryPolicy -from taskq.worker.actor_config import ActorConfig if TYPE_CHECKING: from taskq.testing.in_memory import InMemoryBackend diff --git a/src/taskq/testing/in_memory.py b/src/taskq/testing/in_memory.py index 56d663fc..cf8f90c2 100644 --- a/src/taskq/testing/in_memory.py +++ b/src/taskq/testing/in_memory.py @@ -30,6 +30,7 @@ from pydantic import BaseModel from taskq._ids import new_uuid +from taskq.actor_config import ActorConfig from taskq.backend._cursor import decode_cursor, encode_cursor from taskq.backend._notify import _SubscriberContext from taskq.backend._protocol import ( @@ -121,7 +122,6 @@ _write_attempt, _write_cancel_escalation, ) -from taskq.worker.actor_config import ActorConfig if TYPE_CHECKING: from taskq.worker.leader import ArchiveExpiryResult, PruneResult diff --git a/src/taskq/web/admin/actors.py b/src/taskq/web/admin/actors.py index 6fb26324..f43f9579 100644 --- a/src/taskq/web/admin/actors.py +++ b/src/taskq/web/admin/actors.py @@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from jinja2 import Environment +from taskq.actor_config_ops import deregister_actor from taskq.exceptions import ActorDeregistrationError from taskq.settings import TaskQSettings from taskq.web.admin._factory import ( @@ -19,7 +20,6 @@ get_templates, validate_csrf, ) -from taskq.worker.actor_config_ops import deregister_actor _ACTORS_SQL = """ SELECT ac.actor, ac.max_concurrent, ac.max_pending, ac.queue, diff --git a/src/taskq/worker/__init__.py b/src/taskq/worker/__init__.py index 8f46fb39..81f6b4bf 100644 --- a/src/taskq/worker/__init__.py +++ b/src/taskq/worker/__init__.py @@ -1,7 +1,7 @@ """Worker subsystem: pool management, deps, heartbeat, and connection budgeting. All imports are lazy via ``__getattr__`` so that importing any submodule -(e.g. ``taskq.worker.actor_config``) does not pull in ``asyncpg`` or +(e.g. ``taskq.worker.budget``) does not pull in ``asyncpg`` or concrete backends. This keeps ``import taskq.testing`` lean. The names below are also imported under ``TYPE_CHECKING`` so static tools diff --git a/src/taskq/worker/_bootstrap.py b/src/taskq/worker/_bootstrap.py index 040020a1..3fad43c4 100644 --- a/src/taskq/worker/_bootstrap.py +++ b/src/taskq/worker/_bootstrap.py @@ -23,6 +23,7 @@ from taskq._di.scopes import LoopScope, ProcessScope, ThreadScope, make_resolver from taskq._dsn import dsn_host as _dsn_host from taskq.actor import ActorRef +from taskq.actor_config import ActorConfig from taskq.backend._protocol import Backend, JobRow, ScheduleCreateArgs from taskq.backend.clock import Clock, SystemClock from taskq.backend.postgres import PostgresBackend @@ -42,7 +43,6 @@ from taskq.ratelimit.registry import registry as rl_registry from taskq.settings import WorkerSettings from taskq.worker._watchdog import LoopLagWatchdog, ShutdownWatchdog, loop_watchdog_loop -from taskq.worker.actor_config import ActorConfig from taskq.worker.cancel import make_cancel_controller from taskq.worker.deps import WorkerDeps, open_worker_deps from taskq.worker.health import HealthServer diff --git a/src/taskq/worker/startup.py b/src/taskq/worker/startup.py index f81e9f49..f8103db9 100644 --- a/src/taskq/worker/startup.py +++ b/src/taskq/worker/startup.py @@ -6,13 +6,13 @@ import structlog from taskq._json import dumps_str, loads +from taskq.actor_config import ActorConfig from taskq.backend._protocol import ConnLike from taskq.constants import ( _IDENT_RE, # pyright: ignore[reportPrivateUsage] # Why: reusing the canonical identifier regex rather than redefining it ) from taskq.exceptions import ActorConfigDriftError, ActorConfigDriftList from taskq.obs import get_logger -from taskq.worker.actor_config import ActorConfig logger: structlog.stdlib.BoundLogger = get_logger(__name__) diff --git a/tests/test_actor_capacity_pg.py b/tests/test_actor_capacity_pg.py index 4fc03e1f..0f1c0d23 100644 --- a/tests/test_actor_capacity_pg.py +++ b/tests/test_actor_capacity_pg.py @@ -17,11 +17,11 @@ from pydantic import BaseModel from taskq.actor import actor +from taskq.actor_config import ActorConfig +from taskq.actor_config_ops import set_actor_config_capacity from taskq.client import JobsClient from taskq.exceptions import MaxPendingExceededError from taskq.testing.fixtures import JobsApp -from taskq.worker.actor_config import ActorConfig -from taskq.worker.actor_config_ops import set_actor_config_capacity from taskq.worker.startup import sync_actor_config pytestmark = [pytest.mark.asyncio, pytest.mark.integration] diff --git a/tests/test_actor_config.py b/tests/test_actor_config.py index 2a7ea55b..53d439e5 100644 --- a/tests/test_actor_config.py +++ b/tests/test_actor_config.py @@ -4,7 +4,7 @@ import pytest -from taskq.worker.actor_config import ActorConfig +from taskq.actor_config import ActorConfig # ── Construction ─────────────────────────────────────────────────────────── diff --git a/tests/test_actor_config_ops.py b/tests/test_actor_config_ops.py index 0a2e5706..7b5ba7a2 100644 --- a/tests/test_actor_config_ops.py +++ b/tests/test_actor_config_ops.py @@ -1,4 +1,4 @@ -"""Tests for `taskq.worker.actor_config_ops`: the operator read/tune surface +"""Tests for `taskq.actor_config_ops`: the operator read/tune surface that backs `taskq actor-config get/set/list`. These are integration-tier (real Postgres) because the whole point of the @@ -11,8 +11,8 @@ import pytest from taskq._ids import new_base62 -from taskq.worker.actor_config import ActorConfig -from taskq.worker.actor_config_ops import ( +from taskq.actor_config import ActorConfig +from taskq.actor_config_ops import ( get_actor_config, list_actor_configs, set_actor_config_capacity, diff --git a/tests/test_actor_config_ops_validation.py b/tests/test_actor_config_ops_validation.py index a5e47bbe..15af83cc 100644 --- a/tests/test_actor_config_ops_validation.py +++ b/tests/test_actor_config_ops_validation.py @@ -19,7 +19,7 @@ import pytest -from taskq.worker.actor_config_ops import UNSET, set_actor_config_capacity +from taskq.actor_config_ops import UNSET, set_actor_config_capacity class _RecordingConn: diff --git a/tests/test_actor_config_sync.py b/tests/test_actor_config_sync.py index 19efa276..ee95c697 100644 --- a/tests/test_actor_config_sync.py +++ b/tests/test_actor_config_sync.py @@ -12,8 +12,8 @@ from taskq._ids import new_base62 from taskq._json import dumps_str +from taskq.actor_config import ActorConfig from taskq.exceptions import ActorConfigDriftList -from taskq.worker.actor_config import ActorConfig from taskq.worker.startup import sync_actor_config diff --git a/tests/test_actor_deregistration.py b/tests/test_actor_deregistration.py index 82a9d0fe..66b2f0ab 100644 --- a/tests/test_actor_deregistration.py +++ b/tests/test_actor_deregistration.py @@ -15,13 +15,13 @@ import pytest from taskq._ids import new_base62 +from taskq.actor_config import ActorConfig +from taskq.actor_config_ops import DeregisterResult, deregister_actor, get_actor_config from taskq.exceptions import ( ActorHasActiveJobsError, ActorHasEnabledSchedulesError, ActorNotFoundError, ) -from taskq.worker.actor_config import ActorConfig -from taskq.worker.actor_config_ops import DeregisterResult, deregister_actor, get_actor_config from taskq.worker.startup import sync_actor_config pytestmark = [pytest.mark.asyncio, pytest.mark.integration] diff --git a/tests/test_actor_deregistration_client.py b/tests/test_actor_deregistration_client.py index 8f0a5ee2..8d5a507c 100644 --- a/tests/test_actor_deregistration_client.py +++ b/tests/test_actor_deregistration_client.py @@ -10,12 +10,12 @@ import asyncpg import pytest +from taskq.actor_config import ActorConfig from taskq.exceptions import ( ActorHasActiveJobsError, ActorNotFoundError, ) from taskq.testing.fixtures import ModulePgSchema -from taskq.worker.actor_config import ActorConfig from taskq.worker.startup import sync_actor_config pytestmark = [pytest.mark.asyncio, pytest.mark.integration] diff --git a/tests/test_actors_client.py b/tests/test_actors_client.py index b42c5c45..e2d2dfac 100644 --- a/tests/test_actors_client.py +++ b/tests/test_actors_client.py @@ -10,11 +10,11 @@ import pytest -from taskq.exceptions import ActorNotFoundError -from taskq.worker.actor_config_ops import ( +from taskq.actor_config_ops import ( ActorConfigRow, DeregisterResult, ) +from taskq.exceptions import ActorNotFoundError pytestmark = [pytest.mark.asyncio] diff --git a/tests/test_cli_actor_config.py b/tests/test_cli_actor_config.py index 5d2b60c4..9f476fa5 100644 --- a/tests/test_cli_actor_config.py +++ b/tests/test_cli_actor_config.py @@ -15,8 +15,8 @@ from typer.testing import CliRunner from taskq.actor import ActorRef, actor +from taskq.actor_config_ops import ActorConfigRow from taskq.cli import app -from taskq.worker.actor_config_ops import ActorConfigRow runner = CliRunner() diff --git a/tests/test_cli_actor_deregister.py b/tests/test_cli_actor_deregister.py index 365e84ec..7ed07f02 100644 --- a/tests/test_cli_actor_deregister.py +++ b/tests/test_cli_actor_deregister.py @@ -10,8 +10,8 @@ import pytest from typer.testing import CliRunner +from taskq.actor_config_ops import DeregisterResult from taskq.cli import app -from taskq.worker.actor_config_ops import DeregisterResult runner = CliRunner() diff --git a/tests/test_dispatch_pg.py b/tests/test_dispatch_pg.py index 0e8c9dc0..065f34d6 100644 --- a/tests/test_dispatch_pg.py +++ b/tests/test_dispatch_pg.py @@ -17,11 +17,11 @@ import pytest from taskq._ids import new_base62, new_uuid +from taskq.actor_config import ActorConfig from taskq.backend.postgres import PostgresBackend from taskq.testing.fixtures import JobsApp, _open_pg_backend from taskq.testing.jobs import make_enqueue_args from taskq.testing.pg import create_worker -from taskq.worker.actor_config import ActorConfig from taskq.worker.run import register_worker from taskq.worker.startup import sync_actor_config diff --git a/tests/test_in_memory_terminal_writes.py b/tests/test_in_memory_terminal_writes.py index 7fe2ae95..ca3abb20 100644 --- a/tests/test_in_memory_terminal_writes.py +++ b/tests/test_in_memory_terminal_writes.py @@ -14,11 +14,11 @@ import pytest from taskq._ids import new_job_id, new_uuid +from taskq.actor_config import ActorConfig from taskq.backend._protocol import EnqueueArgs, ErrorInfo, JobId, RetryKind from taskq.exceptions import WorkerOwnershipMismatch from taskq.testing.clock import FakeClock from taskq.testing.in_memory import InMemoryBackend -from taskq.worker.actor_config import ActorConfig # ── Helpers ──────────────────────────────────────────────────────────── diff --git a/tests/test_postgres_terminal_writes.py b/tests/test_postgres_terminal_writes.py index d3d69c81..0b54f59d 100644 --- a/tests/test_postgres_terminal_writes.py +++ b/tests/test_postgres_terminal_writes.py @@ -990,7 +990,7 @@ async def _seed_cleared_ttl_and_aged_job( result_expires_at is pinned in the past — the state the enqueue path leaves behind (enqueue_now + literal) after the job sat in the queue longer than its TTL.""" - from taskq.worker.actor_config_ops import set_actor_config_capacity + from taskq.actor_config_ops import set_actor_config_capacity deps = jobs_app.deps schema = deps.settings.schema_name @@ -1067,7 +1067,7 @@ async def test_stored_result_ttl_wins_over_fallback_at_completion( ) -> None: """Operator override (stored 300s) beats the worker's fallback literal (5s) for jobs completing after the override.""" - from taskq.worker.actor_config_ops import set_actor_config_capacity + from taskq.actor_config_ops import set_actor_config_capacity deps = clean_jobs_app.deps backend = clean_jobs_app.backend diff --git a/tests/test_web_admin_actors.py b/tests/test_web_admin_actors.py index bbf41209..14b72524 100644 --- a/tests/test_web_admin_actors.py +++ b/tests/test_web_admin_actors.py @@ -17,9 +17,9 @@ import pytest_asyncio from fastapi import FastAPI +from taskq.actor_config import ActorConfig from taskq.testing.fixtures import ModulePgSchema from taskq.web.admin import create_router, setup_admin_state -from taskq.worker.actor_config import ActorConfig from taskq.worker.startup import sync_actor_config pytestmark = [pytest.mark.asyncio, pytest.mark.integration] From 8bf4c13ab1cc028cd9fc52d96318d180991c53e3 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 18:32:49 -0700 Subject: [PATCH 12/20] fix: concurrent deregistration safety, 404 for unknown actor, RETURNING for purge, spec doc fixes --- docs/specs/2026-07-29-actor-deregistration.md | 10 ++- src/taskq/actor_config_ops.py | 7 +- src/taskq/web/admin/actors.py | 4 +- tests/test_actor_deregistration.py | 51 +++++++++++++ tests/test_web_admin_actors.py | 75 +++++++++++++++++++ 5 files changed, 140 insertions(+), 7 deletions(-) diff --git a/docs/specs/2026-07-29-actor-deregistration.md b/docs/specs/2026-07-29-actor-deregistration.md index e7568101..dfe799c2 100644 --- a/docs/specs/2026-07-29-actor-deregistration.md +++ b/docs/specs/2026-07-29-actor-deregistration.md @@ -297,8 +297,12 @@ async def deregister_actor( 1. Refuse if any *running* jobs reference the actor — raises :class:`ActorHasActiveJobsError` (running jobs are actively executing; their terminal-write path reads ``actor_config`` for - ``result_ttl``, and deleting the row mid-execution would set - ``result_expires_at`` to NULL — safe but surprising). + ``result_ttl``, and deleting the row mid-execution loses the + stored override — the ``COALESCE`` in the terminal-write SQL + falls back to the ``@actor(...)`` literal TTL, which is a silent + semantic change. More importantly, the dispatch query inner-joins + ``actor_config``, so a running job that retries would be + stranded). 2. Cancel pending/scheduled jobs for this actor (mark as ``cancelled`` with ``error_class='ActorDeregistered'``). They would be stranded anyway: the dispatch query inner-joins ``actor_config``, so without @@ -3013,7 +3017,7 @@ deferring the cleanup and use the official `deregister` path. **Decision:** `force=True` cancels pending/scheduled jobs and disables schedules, but still refuses if running jobs exist. -**Rationale:** Running jobs are actively executing — their terminal-write path reads `actor_config.result_ttl` to compute `result_expires_at`. Deleting the row mid-execution sets `result_expires_at` to NULL (the subquery returns NULL), which is safe but surprising. More importantly, the dispatch query inner-joins `actor_config`, so a running job that retries would be stranded. Refusing is the safe default; the operator can wait for running jobs to complete or cancel them first. +**Rationale:** Running jobs are actively executing — their terminal-write path reads `actor_config.result_ttl` to compute `result_expires_at`. Deleting the row mid-execution loses the stored `result_ttl` override; the `COALESCE` in the terminal-write SQL falls back to the `@actor(...)` literal TTL (or preserves the existing `result_expires_at`), which is a silent semantic change. More importantly, the dispatch query inner-joins `actor_config`, so a running job that retries would be stranded. Refusing is the safe default; the operator can wait for running jobs to complete or cancel them first. ### 3. Schedules are disabled, not deleted diff --git a/src/taskq/actor_config_ops.py b/src/taskq/actor_config_ops.py index 69d52528..42bf6be6 100644 --- a/src/taskq/actor_config_ops.py +++ b/src/taskq/actor_config_ops.py @@ -44,9 +44,9 @@ ) __all__ = [ - "UNSET", "ActorConfigRow", "DeregisterResult", + "UNSET", "Unset", "deregister_actor", "get_actor_config", @@ -281,6 +281,7 @@ async def set_actor_config_capacity( AND NOT EXISTS ( SELECT 1 FROM "{schema}".actor_config WHERE queue = $1 ) +RETURNING name """.strip() _DEREGISTER_COUNT_TERMINAL_SQL = """ @@ -390,11 +391,11 @@ async def deregister_actor( queue_purged = False if purge_queue: - purge_result = await conn.execute( + purged_name = await conn.fetchval( _DEREGISTER_PURGE_QUEUE_SQL.format(schema=schema), queue_name, ) - queue_purged = purge_result == "DELETE 1" + queue_purged = purged_name is not None return DeregisterResult( actor=actor, diff --git a/src/taskq/web/admin/actors.py b/src/taskq/web/admin/actors.py index f43f9579..4048256e 100644 --- a/src/taskq/web/admin/actors.py +++ b/src/taskq/web/admin/actors.py @@ -8,7 +8,7 @@ from jinja2 import Environment from taskq.actor_config_ops import deregister_actor -from taskq.exceptions import ActorDeregistrationError +from taskq.exceptions import ActorDeregistrationError, ActorNotFoundError from taskq.settings import TaskQSettings from taskq.web.admin._factory import ( get_base_path, @@ -84,6 +84,8 @@ async def actor_deregister( # pyright: ignore[reportUnusedFunction] # Why: reg await deregister_actor( conn, actor, force=force, purge_queue=purge_queue, schema=schema ) + except ActorNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from None except ActorDeregistrationError as exc: raise HTTPException(status_code=409, detail=str(exc)) from None diff --git a/tests/test_actor_deregistration.py b/tests/test_actor_deregistration.py index 66b2f0ab..78680c35 100644 --- a/tests/test_actor_deregistration.py +++ b/tests/test_actor_deregistration.py @@ -22,6 +22,7 @@ ActorHasEnabledSchedulesError, ActorNotFoundError, ) +from taskq.settings import TaskQSettings from taskq.worker.startup import sync_actor_config pytestmark = [pytest.mark.asyncio, pytest.mark.integration] @@ -517,3 +518,53 @@ async def test_deregister_purge_queue_noop_when_queue_row_absent( assert result.actor_config_deleted is True assert result.queue_purged is False + + +# ── concurrent deregistration ──────────────────────────────────────────── + + +async def test_concurrent_deregister_one_succeeds_one_raises( + pg_conn: asyncpg.Connection, + settings: TaskQSettings, +) -> None: + """Two concurrent deregister calls for the same actor: one succeeds, the other raises.""" + import asyncio + + schema = _make_schema() + await _ensure_schema(pg_conn, schema) + await sync_actor_config( + pg_conn, + [ActorConfig(actor="concurrent_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + + # Use two separate connections to simulate concurrent callers. + # The pg_conn fixture provides one connection; we create a second + # from the same DSN so both see the same schema and data. + conn2 = await asyncpg.connect(str(settings.pg_dsn)) + try: + # Both call deregister_actor simultaneously for the same actor. + # Under READ COMMITTED, both pass the safety checks, but only one + # DELETE returns a row — the other gets 0 rows and raises ActorNotFoundError. + results: list[BaseException | DeregisterResult] = [] + + async def _deregister(conn: asyncpg.Connection) -> None: + try: + result = await deregister_actor(conn, "concurrent_actor", schema=schema) + results.append(result) + except ActorNotFoundError as exc: + results.append(exc) + + await asyncio.gather( + _deregister(pg_conn), + _deregister(conn2), + ) + + # Exactly one should succeed, one should raise ActorNotFoundError + successes = [r for r in results if isinstance(r, DeregisterResult)] + not_found = [r for r in results if isinstance(r, ActorNotFoundError)] + assert len(successes) == 1 + assert len(not_found) == 1 + assert successes[0].actor_config_deleted is True + finally: + await conn2.close() diff --git a/tests/test_web_admin_actors.py b/tests/test_web_admin_actors.py index 14b72524..43b5aa22 100644 --- a/tests/test_web_admin_actors.py +++ b/tests/test_web_admin_actors.py @@ -203,3 +203,78 @@ async def test_deregister_route_returns_409_when_actor_has_active_jobs( "blocked-actor", ) assert count == 1 + + +async def test_deregister_route_with_force_cancels_pending_job( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """POST deregister with force=true cancels pending jobs through the admin route.""" + from uuid import uuid4 + + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "force-admin-actor", queue="default") + job_id = uuid4() + await clean_pg_conn.execute( + f'INSERT INTO "{schema}".jobs (id, actor, queue, payload, status, max_attempts, retry_kind) ' + f"VALUES ($1, 'force-admin-actor', 'default', '{{}}'::jsonb, 'pending'::\"{schema}\".job_status, 3, 'transient')", + job_id, + ) + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + resp = await _get_csrf_then_post( + app, "/admin/actors", "/admin/actors/force-admin-actor/deregister", + data={"force": "true"}, + ) + + assert resp.status_code == 303 + # Job should be cancelled + status = await clean_pg_conn.fetchval( + f'SELECT status::text FROM "{schema}".jobs WHERE id = $1', job_id + ) + assert status == "cancelled" + # Actor config should be gone + count = await clean_pg_conn.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', "force-admin-actor" + ) + assert count == 0 + + +async def test_deregister_route_returns_404_for_unknown_actor( + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """POST deregister for a non-existent actor returns 404, not 409.""" + schema = module_pg_schema.schema_name + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + resp = await _get_csrf_then_post( + app, "/admin/actors", "/admin/actors/nonexistent-actor/deregister" + ) + + assert resp.status_code == 404 + assert "no stored actor_config row" in resp.text + + +async def test_actors_page_shows_notice_after_deregister( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """GET /actors?notice=... renders the notice banner.""" + schema = module_pg_schema.schema_name + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.get("/admin/actors?notice=deregistered+test-actor") + + assert resp.status_code == 200 + assert "deregistered" in resp.text + # The notice should be in a styled banner div, not just in the page somewhere + assert "bg-green" in resp.text From 89bd92e350f8737eb0772f3f28c1d0e0489d7913 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 18:40:47 -0700 Subject: [PATCH 13/20] test: fix concurrent test, add schedule 409, purge_queue, set_capacity, CLI output assertions --- src/taskq/actor_config_ops.py | 9 ++++ src/taskq/cli.py | 7 +++ src/taskq/web/admin/_factory.py | 10 +++- src/taskq/web/templates/actors.html | 13 ++++- tests/test_actor_deregistration_client.py | 17 +++++++ tests/test_cli_actor_deregister.py | 2 + tests/test_web_admin_actors.py | 59 +++++++++++++++++++++++ 7 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/taskq/actor_config_ops.py b/src/taskq/actor_config_ops.py index 42bf6be6..a0d933d5 100644 --- a/src/taskq/actor_config_ops.py +++ b/src/taskq/actor_config_ops.py @@ -326,6 +326,15 @@ async def deregister_actor( uses READ COMMITTED isolation. Callers must quiesce the actor first — stop enqueuing, disable cron schedules, and wait for running jobs to reach a terminal state — before calling deregister. + + **Concurrent worker startup (sync_actor_config)** can re-create the + ``actor_config`` row after this function returns, with capacity fields + reset to ``@actor(...)`` defaults. Stop all workers for this actor + before calling deregister. + + A job dispatched between the running check and the cancel UPDATE + (force=True) will be stranded until the leader sweep reclaims its + expired lock. """ if not _IDENT_RE.match(schema): raise ValueError(f"invalid schema identifier: {schema!r}") diff --git a/src/taskq/cli.py b/src/taskq/cli.py index 290e9dd2..10df5288 100644 --- a/src/taskq/cli.py +++ b/src/taskq/cli.py @@ -573,11 +573,18 @@ async def _actor_config_deregister( typer.echo( f"Deregistered actor {result.actor!r}:" f" actor_config_deleted={result.actor_config_deleted}" + f" queue={result.queue!r}" f" schedules_disabled={result.schedules_disabled}" f" jobs_cancelled={result.jobs_cancelled}" f" terminal_jobs_remaining={result.terminal_jobs_remaining}" f" queue_purged={result.queue_purged}" ) + typer.echo( + f"WARNING: Actor {result.actor!r} is now unregistered. Any future enqueue()" + f" to this actor name will create a stranded pending job that will never" + f" be dispatched. Stop enqueuing before deregistering.", + err=True, + ) _CAPACITY_DIFF_FIELDS = ("max_concurrent", "max_pending", "result_ttl") diff --git a/src/taskq/web/admin/_factory.py b/src/taskq/web/admin/_factory.py index 7547a10b..2906b608 100644 --- a/src/taskq/web/admin/_factory.py +++ b/src/taskq/web/admin/_factory.py @@ -147,8 +147,16 @@ def get_backend(request: Request) -> Backend | None: def get_schema(request: Request) -> str: - """Dependency: yields the schema name from ``app.state``.""" + """Dependency: yields the schema name from ``app.state``. + + Re-validates against :data:`_IDENT_RE` as defence-in-depth — the schema + was validated at ``create_router`` construction time, but this ensures a + runtime mutation of ``app.state.schema`` (e.g. by a misconfigured test + fixture) cannot reach SQL interpolation. + """ s: str = request.app.state.schema + if not _IDENT_RE.match(s): + raise HTTPException(status_code=500, detail="invalid schema configuration") return s diff --git a/src/taskq/web/templates/actors.html b/src/taskq/web/templates/actors.html index 78d603fd..20ea66c5 100644 --- a/src/taskq/web/templates/actors.html +++ b/src/taskq/web/templates/actors.html @@ -1,5 +1,15 @@ {% extends "_base.html" %} {% block title %}Actors — TaskQ Admin{% endblock %} +{% block head %} + +{% endblock %} {% block content %}

Actors

@@ -44,7 +54,8 @@

Actors

{{ a.updated_at | time_ago }}
+ data-actor="{{ a.actor }}" + class="deregister-form"> diff --git a/tests/test_actor_deregistration_client.py b/tests/test_actor_deregistration_client.py index 8d5a507c..d56c2c2e 100644 --- a/tests/test_actor_deregistration_client.py +++ b/tests/test_actor_deregistration_client.py @@ -190,3 +190,20 @@ async def test_client_actors_get_returns_row( missing = await tq.actors.get("nonexistent") assert missing is None + + +async def test_client_actors_set_capacity( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """Full client path: tq.actors.set_capacity() updates max_concurrent.""" + from taskq import TaskQ + + schema = module_pg_schema.schema_name + await _seed_actor(clean_pg_conn, schema, "client_setcap_actor") + + async with TaskQ(dsn=module_pg_schema.pg_dsn, schema=schema) as tq: + row = await tq.actors.set_capacity("client_setcap_actor", max_concurrent=10) + + assert row is not None + assert row.max_concurrent == 10 diff --git a/tests/test_cli_actor_deregister.py b/tests/test_cli_actor_deregister.py index 7ed07f02..5d417fca 100644 --- a/tests/test_cli_actor_deregister.py +++ b/tests/test_cli_actor_deregister.py @@ -126,6 +126,8 @@ def test_deregister_output_shows_result(monkeypatch: pytest.MonkeyPatch) -> None ) assert output.exit_code == 0 assert "deregistered" in output.output.lower() + assert "actor_config_deleted=true" in output.output.lower() + assert "queue='my-queue'" in output.output.lower() assert "schedules_disabled=2" in output.output assert "jobs_cancelled=5" in output.output assert "terminal_jobs_remaining=10" in output.output diff --git a/tests/test_web_admin_actors.py b/tests/test_web_admin_actors.py index 43b5aa22..3fcb5a4c 100644 --- a/tests/test_web_admin_actors.py +++ b/tests/test_web_admin_actors.py @@ -278,3 +278,62 @@ async def test_actors_page_shows_notice_after_deregister( assert "deregistered" in resp.text # The notice should be in a styled banner div, not just in the page somewhere assert "bg-green" in resp.text + + +async def test_deregister_route_returns_409_for_enabled_schedules( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """POST deregister with an enabled schedule returns 409 Conflict.""" + from uuid import uuid4 + + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "sched-409-actor", queue="default") + await clean_pg_conn.execute( + f'INSERT INTO "{schema}".cron_schedules (id, actor, cron_expr, enabled, next_fire_at) ' + f"VALUES ($1, 'sched-409-actor', '*/5 * * * *', true, now())", + uuid4(), + ) + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + resp = await _get_csrf_then_post( + app, "/admin/actors", "/admin/actors/sched-409-actor/deregister" + ) + + assert resp.status_code == 409 + assert "enabled cron schedule" in resp.text + count = await clean_pg_conn.fetchval( + f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', + "sched-409-actor", + ) + assert count == 1 + + +async def test_deregister_route_with_purge_queue_deletes_queue( + clean_pg_conn: asyncpg.Connection, + admin_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """POST deregister with purge_queue=true deletes the orphaned queue.""" + schema = module_pg_schema.schema_name + await clean_pg_conn.execute( + f'INSERT INTO "{schema}".queues (name) VALUES ($1)', + "admin-purge-queue", + ) + await _seed_actor_config(clean_pg_conn, schema, "admin-purge-actor", queue="admin-purge-queue") + app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + + resp = await _get_csrf_then_post( + app, "/admin/actors", "/admin/actors/admin-purge-actor/deregister", + data={"purge_queue": "true"}, + ) + + assert resp.status_code == 303 + queue_count = await clean_pg_conn.fetchval( + f'SELECT count(*) FROM "{schema}".queues WHERE name = $1', + "admin-purge-queue", + ) + assert queue_count == 0 From de3ebc687d7bb745965f960ec4cb2395b651b397 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 18:41:31 -0700 Subject: [PATCH 14/20] fix: ruff __all__ sort in actor_config_ops.py --- src/taskq/actor_config_ops.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taskq/actor_config_ops.py b/src/taskq/actor_config_ops.py index a0d933d5..812a4d5a 100644 --- a/src/taskq/actor_config_ops.py +++ b/src/taskq/actor_config_ops.py @@ -44,9 +44,9 @@ ) __all__ = [ + "UNSET", "ActorConfigRow", "DeregisterResult", - "UNSET", "Unset", "deregister_actor", "get_actor_config", From d4df831e20cc33efadb87d0ba2286b9c53f2e8f5 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 18:43:24 -0700 Subject: [PATCH 15/20] =?UTF-8?q?chore:=20remove=20spec=20from=20branch=20?= =?UTF-8?q?=E2=80=94=20review=20against=20codebase=20and=20issues,=20not?= =?UTF-8?q?=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/2026-07-29-actor-deregistration.md | 3168 ----------------- 1 file changed, 3168 deletions(-) delete mode 100644 docs/specs/2026-07-29-actor-deregistration.md diff --git a/docs/specs/2026-07-29-actor-deregistration.md b/docs/specs/2026-07-29-actor-deregistration.md deleted file mode 100644 index dfe799c2..00000000 --- a/docs/specs/2026-07-29-actor-deregistration.md +++ /dev/null @@ -1,3168 +0,0 @@ -# Actor Deregistration — `client.actors.deregister()` and Cleanup for Ephemeral Deployments - -**Date:** 2026-07-29 -**Status:** Draft, revised post-review (2026-07-29) -**Issue:** [#56](https://github.com/rich/taskq/issues/56) - -> **Scope note:** Issue #56 asks for `client.actors.deregister()` with -> defined safety semantics. This spec additionally builds the CLI command -> (`taskq actor-config deregister`) and admin UI page (`/admin/actors` with -> deregister button). These are justified under "operator surface" and are -> entirely additive (no changes to existing code paths), but they represent -> scope beyond the issue's literal ask and roughly half the plan's tasks. -> The issue author should confirm this scope expansion is desired. - ---- - -## Goal - -Provide a first-class actor deregistration API (`client.actors.deregister()`, -`taskq actor-config deregister`, admin UI button) with defined safety semantics -so that ephemeral, per-run actor deployments can clean up their `actor_config` -and orphaned `queues` rows without hand-rolled SQL. The default path refuses -deregistration while non-terminal jobs or enabled cron schedules reference the -actor; `force=True` documents and handles the consequences for terminal job -history, schedules, and stranded pending work. - -## Non-goals - -1. **No schema migration.** The existing schema has no FKs from `jobs.actor` or - `cron_schedules.actor` to `actor_config.actor` — deregistration is pure - application logic (a transactional set of checks + DELETEs). Adding FKs with - `ON DELETE` actions would require a migration and risk lock contention on - the hot `jobs` table; it is not needed for this feature. - -2. **No automatic GC sweep.** Deregistration is an explicit operator/client - action, not a background leader sweep. Ephemeral deployments know when their - run is done; a sweep would need heuristics to decide liveness, which is - application-specific. - -3. **No soft-delete / tombstone column.** The `actor_config` row is deleted - outright. Terminal job history (`jobs.actor` is a plain `text` column, not - an FK) remains queryable by actor name after deregistration — that is the - documented, intentional behavior. - -4. **No re-registration resurrection.** If an actor is re-registered by a - worker startup after deregistration, it creates a fresh `actor_config` row - with seed values — the same behavior as any first-time registration. - -5. **No changes to the drift-check semantics.** `_STRUCTURAL_FIELDS` and - `ActorConfigDriftList` remain as-is. Deregistration is the cleanup path for - the pattern the drift check funnels ephemeral deployments into; it does not - weaken the drift check. - ---- - -## Architecture Overview - -### Current state - -``` - ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ - │ TaskQ │ │ JobsClient │ │ Backend │ - │ (client) │────▶│ (enqueue/get/ │────▶│ (PostgresBackend) - │ │ │ list/cancel) │ │ │ - └─────────────┘ └──────────────────┘ └─────────────────┘ - │ │ - │ ▼ - │ ┌──────────────┐ - │ │ Postgres │ - │ │ actor_config│ - │ │ queues │ - │ │ jobs │ - │ │ cron_schedules│ - │ └──────────────┘ - │ - ┌───────┴────────┐ - │ actor_config_ops│ (list/get/set_capacity) - │ (ConnLike-level)│ NO delete - └────────────────┘ - - CLI: taskq actor-config list/get/set/diff (no deregister) - Admin UI: queues/jobs/workers/schedules/... (no actors page) -``` - -### Proposed state - -``` - ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ - │ TaskQ │ │ JobsClient │ │ Backend │ - │ (client) │────▶│ (enqueue/get/ │────▶│ (PostgresBackend) - │ .actors ───┼───▶│ list/cancel) │ │ │ - └─────────────┘ └──────────────────┘ └─────────────────┘ - │ │ - ▼ ▼ - ┌──────────────┐ ┌──────────────┐ - │ ActorsClient │───────────────────────────▶│ Postgres │ - │ (deregister/ │ │ actor_config│ - │ list/get/ │ │ queues │ - │ set_capacity)│ │ jobs │ - └──────────────┘ │ cron_schedules│ - │ └──────────────┘ - ▼ - ┌────────────────┐ - │ actor_config_ops│ (list/get/set_capacity - │ + deregister │ + deregister_actor) - └────────────────┘ - - CLI: taskq actor-config list/get/set/diff/deregister - Admin UI: /actors page with deregister button -``` - -### File structure - -| Action | Path | Responsibility | -|--------|------|----------------| -| **Modify** | `src/taskq/worker/actor_config_ops.py` | Add `deregister_actor()` + `DeregisterResult` dataclass + SQL templates | -| **Create** | `src/taskq/client/_actors.py` | `ActorsClient` class — pool-wrapping facade over `actor_config_ops` | -| **Modify** | `src/taskq/client/_taskq.py` | Add `TaskQ.actors` property returning `ActorsClient` | -| **Modify** | `src/taskq/client/__init__.py` | Export `ActorsClient` | -| **Modify** | `src/taskq/exceptions.py` | Add `ActorDeregistrationError`, `ActorHasActiveJobsError`, `ActorHasEnabledSchedulesError` | -| **Modify** | `src/taskq/cli.py` | Add `taskq actor-config deregister` command | -| **Create** | `src/taskq/web/admin/actors.py` | Admin UI actors page + deregister POST route | -| **Create** | `src/taskq/web/templates/actors.html` | Jinja2 template for actors list + deregister button | -| **Modify** | `src/taskq/web/templates/_base.html` | Add "Actors" nav link | -| **Modify** | `src/taskq/__init__.py` | Export `ActorsClient` from public API | -| **Create** | `tests/test_actor_deregistration.py` | Integration tests for `deregister_actor` | -| **Create** | `tests/test_cli_actor_deregister.py` | CLI tests for `taskq actor-config deregister` | -| **Create** | `tests/test_actors_client.py` | Tests for `ActorsClient` | -| **Create** | `tests/test_web_admin_actors.py` | Admin UI actor page + deregister route tests | -| **Create** | `tests/e2e/test_actor_deregistration.py` | E2E: real worker, enqueue jobs, deregister, verify cleanup | -| **Modify** | `docs/guides/actors.md` | Document deregistration API + semantics | -| **Modify** | `docs/guides/cli.md` | Document `taskq actor-config deregister` command | -| **Modify** | `docs/guides/admin-ui.md` | Document actors page + deregister button | - ---- - -## API Surface - -### Exceptions (`src/taskq/exceptions.py`) - -```python -class ActorDeregistrationError(TaskQError): - """Base for actor deregistration refusals.""" - - def __init__(self, actor: str, detail: str) -> None: - self.actor = actor - super().__init__(f"Cannot deregister actor {actor!r}: {detail}") - - -class ActorHasActiveJobsError(ActorDeregistrationError): - """Non-terminal jobs reference the actor. - - Carries the count and statuses of the blocking jobs so the caller can - decide whether to cancel them first or use force=True. - """ - - def __init__( - self, - actor: str, - active_count: int, - status_counts: dict[str, int], - ) -> None: - self.active_count = active_count - self.status_counts = status_counts - detail = ( - f"{active_count} non-terminal job(s) still reference this actor" - f" (breakdown: {status_counts}). Cancel them first or pass" - f" force=True to cancel pending/scheduled jobs automatically." - ) - super().__init__(actor, detail) - - -class ActorHasEnabledSchedulesError(ActorDeregistrationError): - """Enabled cron schedules reference the actor. - - Carries the schedule IDs so the caller can disable or delete them first. - """ - - def __init__( - self, - actor: str, - schedule_ids: list[str], - ) -> None: - self.schedule_ids = schedule_ids - detail = ( - f"{len(schedule_ids)} enabled cron schedule(s) reference this actor" - f" (ids: {schedule_ids}). Disable or delete them first or pass" - f" force=True to disable them automatically." - ) - super().__init__(actor, detail) - - -class ActorNotFoundError(ActorDeregistrationError): - """The actor_config row does not exist — nothing to deregister.""" - - def __init__(self, actor: str) -> None: - super().__init__(actor, "no stored actor_config row for this actor") -``` - -### Result dataclass (`src/taskq/worker/actor_config_ops.py`) - -```python -@dataclass(frozen=True, slots=True) -class DeregisterResult: - """Outcome of a deregister_actor call. - - All counts are non-negative integers. ``queue_purged`` is True only - when the orphaned queue row was deleted (requires purge_queue=True - AND no other actor_config row references the same queue). - """ - - actor: str - queue: str - actor_config_deleted: bool - schedules_disabled: int - jobs_cancelled: int - terminal_jobs_remaining: int - queue_purged: bool -``` - -### Ops-layer function (`src/taskq/worker/actor_config_ops.py`) - -```python -_NON_TERMINAL_STATUSES: tuple[str, ...] = ( - "pending", "scheduled", "running", -) - -_RUNNING_STATUS: str = "running" - -_DEREGISTER_CHECK_ACTIVE_JOBS_SQL = """ -SELECT status, count(*) AS cnt - FROM "{schema}".jobs - WHERE actor = $1 AND status = ANY($2::"{schema}".job_status[]) - GROUP BY status -""".strip() - -_DEREGISTER_CHECK_SCHEDULES_SQL = """ -SELECT id::text FROM "{schema}".cron_schedules - WHERE actor = $1 AND enabled = true -""".strip() - -_DEREGISTER_CANCEL_PENDING_SQL = """ -UPDATE "{schema}".jobs - SET status = 'cancelled', - finished_at = now(), - error_class = 'ActorDeregistered', - error_message = 'Job cancelled by actor deregistration (force=True)' - WHERE actor = $1 - AND status IN ('pending', 'scheduled') -""".strip() - -_DEREGISTER_DISABLE_SCHEDULES_SQL = """ -UPDATE "{schema}".cron_schedules - SET enabled = false - WHERE actor = $1 AND enabled = true -""".strip() - -_DEREGISTER_DELETE_ACTOR_CONFIG_SQL = """ -DELETE FROM "{schema}".actor_config WHERE actor = $1 -RETURNING queue -""".strip() - -_DEREGISTER_PURGE_QUEUE_SQL = """ -DELETE FROM "{schema}".queues - WHERE name = $1 - AND NOT EXISTS ( - SELECT 1 FROM "{schema}".actor_config WHERE queue = $1 - ) -""".strip() - -_DEREGISTER_COUNT_TERMINAL_SQL = """ -SELECT count(*) FROM "{schema}".jobs - WHERE actor = $1 AND status NOT IN ('pending', 'scheduled', 'running') -""".strip() - - -async def deregister_actor( - conn: ConnLike, - actor: str, - *, - force: bool = False, - purge_queue: bool = False, - schema: str = "taskq", -) -> DeregisterResult: - """Deregister an actor: delete its ``actor_config`` row with safety checks. - - **Default (force=False):** - 1. Refuse if any non-terminal jobs (pending/scheduled/running) reference - the actor — raises :class:`ActorHasActiveJobsError`. - 2. Refuse if any enabled cron schedules reference the actor — raises - :class:`ActorHasEnabledSchedulesError`. - 3. Delete the ``actor_config`` row. - 4. Optionally purge the orphaned queue (if ``purge_queue=True`` and no - other ``actor_config`` row references the same queue). - - **force=True:** - 1. Refuse if any *running* jobs reference the actor — raises - :class:`ActorHasActiveJobsError` (running jobs are actively - executing; their terminal-write path reads ``actor_config`` for - ``result_ttl``, and deleting the row mid-execution loses the - stored override — the ``COALESCE`` in the terminal-write SQL - falls back to the ``@actor(...)`` literal TTL, which is a silent - semantic change. More importantly, the dispatch query inner-joins - ``actor_config``, so a running job that retries would be - stranded). - 2. Cancel pending/scheduled jobs for this actor (mark as ``cancelled`` - with ``error_class='ActorDeregistered'``). They would be stranded - anyway: the dispatch query inner-joins ``actor_config``, so without - a row they would never be dispatched. - 3. Disable enabled cron schedules for this actor (set ``enabled=false``, - not delete — the operator may want to re-enable if the actor is - re-registered). - 4. Delete the ``actor_config`` row. - 5. Optionally purge the orphaned queue. - - **Terminal job history** (succeeded/failed/cancelled/crashed/abandoned - jobs) is *never* deleted or modified. The ``jobs.actor`` column is plain - ``text``, not a foreign key — terminal rows remain queryable by actor - name after deregistration. The ``DeregisterResult.terminal_jobs_remaining`` - count tells the caller how many such rows exist. - - **Queue purge** only deletes the ``queues`` row when *no* remaining - ``actor_config`` row references the same queue name. A shared queue - (one used by multiple actors) is never purged. The queue row is - metadata only (``mode``, ``max_concurrent``); deleting it does not - affect already-queued jobs. - - The entire operation runs inside a single ``conn.transaction()`` block. - If the actor has no stored ``actor_config`` row, raises - :class:`ActorNotFoundError`. - - .. warning:: - - **Concurrent enqueue / dispatch race (TOCTOU).** The transaction - uses READ COMMITTED isolation. The safety checks (active-jobs, - enabled-schedules) and the DELETE are separate statements within - the same transaction. A job enqueued by a *concurrent* transaction - that commits *after* the active-jobs check but *before* the DELETE - will be stranded: the ``jobs`` INSERT does not require an - ``actor_config`` row (no FK), and the dispatch query inner-joins - ``actor_config``, so the job will never be dispatched. The same - applies to cron-fired jobs and dispatch transitions. - - **Deregistration is best-effort against concurrent enqueue / - dispatch.** Callers must **quiesce the actor first** — stop - enqueuing, disable cron schedules, and wait for running jobs to - reach a terminal state — *before* calling ``deregister``. This is - the same operational discipline required for any shutdown - sequence. - - After deregistration, any client can still ``enqueue()`` the dead - actor name — the INSERT succeeds (no FK), the job sits ``pending`` - forever, invisible to dispatch. See "Enqueue after deregistration" - in the docs guide. - - Parameters - ---------- - conn: - An asyncpg connection (or ConnLike). The caller is responsible for - transaction boundaries if composing with other operations; however, - this function wraps its work in ``conn.transaction()`` for - self-contained use. - actor: - The actor name (primary key of ``actor_config``). - force: - If True, cancel pending/scheduled jobs and disable schedules instead - of refusing. Still refuses if running jobs exist. - purge_queue: - If True, delete the orphaned ``queues`` row when no other - ``actor_config`` references the same queue. - schema: - TaskQ schema name. Defaults to ``"taskq"``. - """ -``` - -### Client surface (`src/taskq/client/_actors.py`) - -```python -class ActorsClient: - """Pool-wrapping facade for actor configuration operations. - - Acquires a connection from the injected pool for each call, delegates - to ``taskq.worker.actor_config_ops``, and returns the result. The - caller must have opened the pool; this class does not manage its - lifecycle. - - Parameters - ---------- - pool: - An open ``asyncpg.Pool``. The caller retains ownership. - schema: - TaskQ schema name. Defaults to ``"taskq"``. - """ - - def __init__(self, pool: "asyncpg.Pool", *, schema: str = "taskq") -> None: ... - - async def list(self) -> list[ActorConfigRow]: - """List all stored actor_config rows. Delegates to list_actor_configs.""" - - async def get(self, actor: str) -> ActorConfigRow | None: - """Get one actor_config row. Delegates to get_actor_config.""" - - async def set_capacity( - self, - actor: str, - *, - max_concurrent: int | None | Unset = UNSET, - max_pending: int | None | Unset = UNSET, - result_ttl: float | None | Unset = UNSET, - ) -> ActorConfigRow | None: - """Update capacity fields. Delegates to set_actor_config_capacity.""" - - async def deregister( - self, - actor: str, - *, - force: bool = False, - purge_queue: bool = False, - ) -> DeregisterResult: - """Deregister an actor. Delegates to deregister_actor. - - Raises ActorNotFoundError if the actor has no stored row. - Raises ActorHasActiveJobsError if non-terminal jobs block (force=False) - or running jobs block (force=True). - Raises ActorHasEnabledSchedulesError if enabled schedules block - (force=False only). - - For idempotent cleanup loops, wrap in try/except: - - .. code-block:: python - - try: - await tq.actors.deregister(actor_name, force=True) - except ActorNotFoundError: - pass # already deregistered - """ -``` - -### TaskQ client property (`src/taskq/client/_taskq.py`) - -```python -class TaskQ: - # ... existing code ... - - @property - def actors(self) -> ActorsClient: - """Actor configuration client — list, get, set capacity, deregister. - - Raises RuntimeError if called before ``open()`` or outside an - ``async with`` block. - """ - if self._actors_client is None: - raise RuntimeError( - "TaskQ is not open. Call 'await tq.open()' or use " - "'async with TaskQ(...) as tq:'" - ) - return self._actors_client -``` - -### CLI (`src/taskq/cli.py`) - -``` -taskq actor-config deregister [--force] [--purge-queue] -``` - -- `` — actor name (positional argument) -- `--force` — cancel pending/scheduled jobs, disable schedules, proceed despite non-terminal jobs (running jobs still block) -- `--purge-queue` — also delete the orphaned queues row if no other actor references it -- Exit code 0 on success, 1 on refusal (with error message), 1 on not found - -On success, the CLI prints a summary line and a warning: - -``` -Deregistered actor 'my-actor.run-123': actor_config_deleted=True schedules_disabled=0 jobs_cancelled=0 terminal_jobs_remaining=3 queue_purged=False -WARNING: Actor 'my-actor.run-123' is now unregistered. Any future enqueue() to this actor name will create a stranded pending job that will never be dispatched. Stop enqueuing before deregistering. -``` - -### Admin UI (`src/taskq/web/admin/actors.py`) - -``` -GET /admin/actors — list all actor_config rows with job counts -POST /admin/actors/{actor}/deregister — deregister with force + purge_queue params -``` - -The POST route requires `admin_actions_enabled=True` (same gate as schedule -run, job retry). CSRF-protected via `validate_csrf`. Form params: -- `force` — checkbox -- `purge_queue` — checkbox - ---- - -## Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add actor deregistration with safety checks to the ops layer, CLI, client surface, and admin UI. - -**Architecture:** Pure application logic (no migration) — a transactional function that checks for non-terminal jobs and enabled schedules, then deletes the actor_config row, optionally cancels pending jobs, disables schedules, and purges orphaned queues. Exposed via ActorsClient (pool wrapper), CLI, and admin UI. - -**Tech Stack:** Python 3.12+, asyncpg, typer, FastAPI, Jinja2, pytest - ---- - -### Task 1: Exceptions for deregistration refusals - -**Files:** -- Modify: `src/taskq/exceptions.py` (add after `ActorConfigDriftList`, ~line 344) -- Test: `tests/test_exceptions.py` (add new test class) - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/test_exceptions.py — add at end of file - -class TestActorDeregistrationErrors: - """Tests for the deregistration refusal exception hierarchy.""" - - def test_actor_has_active_jobs_error_carries_counts(self) -> None: - from taskq.exceptions import ActorHasActiveJobsError - - err = ActorHasActiveJobsError( - actor="my-actor.run-123", - active_count=3, - status_counts={"pending": 2, "running": 1}, - ) - assert err.actor == "my-actor.run-123" - assert err.active_count == 3 - assert err.status_counts == {"pending": 2, "running": 1} - assert "3 non-terminal" in str(err) - assert "force=True" in str(err) - - def test_actor_has_enabled_schedules_error_carries_ids(self) -> None: - from taskq.exceptions import ActorHasEnabledSchedulesError - - err = ActorHasEnabledSchedulesError( - actor="my-actor.run-123", - schedule_ids=["sched-1", "sched-2"], - ) - assert err.actor == "my-actor.run-123" - assert err.schedule_ids == ["sched-1", "sched-2"] - assert "2 enabled cron schedule" in str(err) - assert "force=True" in str(err) - - def test_actor_not_found_error(self) -> None: - from taskq.exceptions import ActorNotFoundError - - err = ActorNotFoundError("ghost-actor") - assert err.actor == "ghost-actor" - assert "no stored actor_config row" in str(err) - - def test_deregistration_errors_inherit_taskq_error(self) -> None: - from taskq.exceptions import ( - ActorDeregistrationError, - ActorHasActiveJobsError, - ActorHasEnabledSchedulesError, - ActorNotFoundError, - TaskQError, - ) - - for cls in ( - ActorDeregistrationError, - ActorHasActiveJobsError, - ActorHasEnabledSchedulesError, - ActorNotFoundError, - ): - assert issubclass(cls, TaskQError) - - def test_specific_errors_inherit_deregistration_error(self) -> None: - from taskq.exceptions import ( - ActorDeregistrationError, - ActorHasActiveJobsError, - ActorHasEnabledSchedulesError, - ActorNotFoundError, - ) - - for cls in ( - ActorHasActiveJobsError, - ActorHasEnabledSchedulesError, - ActorNotFoundError, - ): - assert issubclass(cls, ActorDeregistrationError) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_exceptions.py::TestActorDeregistrationErrors -v` -Expected: FAIL with `ImportError: cannot import name 'ActorDeregistrationError'` - -- [ ] **Step 3: Implement the exceptions** - -Add to `src/taskq/exceptions.py` after the `ActorConfigDriftList` class (after line 344): - -```python -class ActorDeregistrationError(TaskQError): - """Base for actor deregistration refusals.""" - - def __init__(self, actor: str, detail: str) -> None: - self.actor = actor - super().__init__(f"Cannot deregister actor {actor!r}: {detail}") - - -class ActorHasActiveJobsError(ActorDeregistrationError): - """Non-terminal jobs reference the actor. - - Carries the count and per-status breakdown of the blocking jobs so the - caller can decide whether to cancel them first or use ``force=True``. - """ - - def __init__( - self, - actor: str, - active_count: int, - status_counts: dict[str, int], - ) -> None: - self.active_count = active_count - self.status_counts = status_counts - detail = ( - f"{active_count} non-terminal job(s) still reference this actor" - f" (breakdown: {status_counts}). Cancel them first or pass" - f" force=True to cancel pending/scheduled jobs automatically." - ) - super().__init__(actor, detail) - - -class ActorHasEnabledSchedulesError(ActorDeregistrationError): - """Enabled cron schedules reference the actor. - - Carries the schedule IDs so the caller can disable or delete them first. - """ - - def __init__( - self, - actor: str, - schedule_ids: list[str], - ) -> None: - self.schedule_ids = schedule_ids - detail = ( - f"{len(schedule_ids)} enabled cron schedule(s) reference this actor" - f" (ids: {schedule_ids}). Disable or delete them first or pass" - f" force=True to disable them automatically." - ) - super().__init__(actor, detail) - - -class ActorNotFoundError(ActorDeregistrationError): - """The actor_config row does not exist — nothing to deregister.""" - - def __init__(self, actor: str) -> None: - super().__init__(actor, "no stored actor_config row for this actor") -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/test_exceptions.py::TestActorDeregistrationErrors -v` -Expected: PASS (5 tests) - -- [ ] **Step 5: Commit** - -```bash -git add src/taskq/exceptions.py tests/test_exceptions.py -git commit -m "feat: add actor deregistration exception hierarchy" -``` - ---- - -### Task 2: `deregister_actor` ops-layer function — safety checks (force=False path) - -**Files:** -- Modify: `src/taskq/worker/actor_config_ops.py` (add `DeregisterResult`, SQL, `deregister_actor`) -- Test: `tests/test_actor_deregistration.py` (new file, integration-tier) - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/test_actor_deregistration.py - -"""Tests for ``deregister_actor``: the operator surface for removing -actor_config rows with defined safety semantics. - -Integration-tier (real Postgres) because the safety checks are set-based -SQL that must execute correctly against the real schema — a fake connection -would only prove the query string looks right. -""" - -import asyncpg -import pytest - -from taskq._ids import new_base62 -from taskq.exceptions import ( - ActorHasActiveJobsError, - ActorHasEnabledSchedulesError, - ActorNotFoundError, -) -from taskq.worker.actor_config import ActorConfig -from taskq.worker.actor_config_ops import ( - DeregisterResult, - deregister_actor, - get_actor_config, -) -from taskq.worker.startup import sync_actor_config - -pytestmark = [pytest.mark.asyncio, pytest.mark.integration] - - -async def _ensure_schema(conn: asyncpg.Connection, schema: str) -> None: - """Apply real migrations to create the full TaskQ schema. - - Uses ``taskq.migrate.apply_pending`` — the same pattern as - ``tests/test_taskq_client.py`` and ``taskq.testing.fixtures`` — so the - test schema is structurally identical to production. This prevents - schema-drift bugs (e.g. enum vs text column types, missing columns) - that a hand-rolled minimal schema would mask. - """ - from taskq.migrate import apply_pending - - await conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') - await apply_pending(conn, schema=schema) - -``` - -- [ ] **Step 2: Write the force=False tests (refusal paths)** - -Add to the same test file: - -```python -from uuid import uuid4 - - -async def _insert_job( - conn: asyncpg.Connection, - schema: str, - actor: str, - status: str = "pending", -) -> str: - """Insert a minimal job row with the given status. - - Uses the real migrated schema (via ``apply_pending``), so all NOT NULL - columns without defaults must be specified — ``max_attempts`` and - ``retry_kind`` have no defaults in the real schema. - """ - job_id = uuid4() - await conn.execute( - f""" - INSERT INTO "{schema}".jobs (id, actor, queue, payload, status, max_attempts, retry_kind) - VALUES ($1, $2, 'default', '{{}}'::jsonb, $3::"{schema}".job_status, 3, 'transient') - """, - job_id, - actor, - status, - ) - return str(job_id) - - -async def _insert_schedule( - conn: asyncpg.Connection, - schema: str, - actor: str, - enabled: bool = True, -) -> str: - sched_id = uuid4() - await conn.execute( - f""" - INSERT INTO "{schema}".cron_schedules (id, actor, cron_expr, enabled, next_fire_at) - VALUES ($1, $2, '0 * * * *', $3, now()) - """, - sched_id, - actor, - enabled, - ) - return str(sched_id) - - -async def test_deregister_raises_not_found_for_unknown_actor( - pg_conn: asyncpg.Connection, -) -> None: - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - - with pytest.raises(ActorNotFoundError, match="no stored actor_config row"): - await deregister_actor(pg_conn, "ghost", schema=schema) - - -async def test_deregister_succeeds_when_no_jobs_or_schedules( - pg_conn: asyncpg.Connection, -) -> None: - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="clean-actor", max_concurrent=1, queue="default")], - schema=schema, - ) - - result = await deregister_actor(pg_conn, "clean-actor", schema=schema) - - assert isinstance(result, DeregisterResult) - assert result.actor == "clean-actor" - assert result.actor_config_deleted is True - assert result.schedules_disabled == 0 - assert result.jobs_cancelled == 0 - assert result.terminal_jobs_remaining == 0 - assert result.queue_purged is False - - # Row is gone - assert await get_actor_config(pg_conn, "clean-actor", schema=schema) is None - - -async def test_deregister_refuses_with_pending_jobs( - pg_conn: asyncpg.Connection, -) -> None: - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="busy-actor", max_concurrent=1, queue="default")], - schema=schema, - ) - await _insert_job(pg_conn, schema, "busy-actor", "pending") - - with pytest.raises(ActorHasActiveJobsError) as exc_info: - await deregister_actor(pg_conn, "busy-actor", schema=schema) - - assert exc_info.value.active_count == 1 - assert exc_info.value.status_counts == {"pending": 1} - - # Row is still there - assert await get_actor_config(pg_conn, "busy-actor", schema=schema) is not None - - -async def test_deregister_refuses_with_running_jobs( - pg_conn: asyncpg.Connection, -) -> None: - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="running-actor", max_concurrent=1, queue="default")], - schema=schema, - ) - await _insert_job(pg_conn, schema, "running-actor", "running") - - with pytest.raises(ActorHasActiveJobsError) as exc_info: - await deregister_actor(pg_conn, "running-actor", schema=schema) - - assert exc_info.value.active_count == 1 - assert exc_info.value.status_counts == {"running": 1} - - -async def test_deregister_refuses_with_enabled_schedules( - pg_conn: asyncpg.Connection, -) -> None: - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="scheduled-actor", max_concurrent=1, queue="default")], - schema=schema, - ) - sched_id = await _insert_schedule(pg_conn, schema, "scheduled-actor", enabled=True) - - with pytest.raises(ActorHasEnabledSchedulesError) as exc_info: - await deregister_actor(pg_conn, "scheduled-actor", schema=schema) - - assert sched_id in exc_info.value.schedule_ids - - # Row is still there - assert await get_actor_config(pg_conn, "scheduled-actor", schema=schema) is not None - - -async def test_deregister_succeeds_with_disabled_schedules( - pg_conn: asyncpg.Connection, -) -> None: - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="disabled-sched-actor", max_concurrent=1, queue="default")], - schema=schema, - ) - await _insert_schedule(pg_conn, schema, "disabled-sched-actor", enabled=False) - - result = await deregister_actor(pg_conn, "disabled-sched-actor", schema=schema) - - assert result.actor_config_deleted is True - assert result.schedules_disabled == 0 - - -async def test_deregister_succeeds_with_terminal_jobs( - pg_conn: asyncpg.Connection, -) -> None: - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="done-actor", max_concurrent=1, queue="default")], - schema=schema, - ) - await _insert_job(pg_conn, schema, "done-actor", "succeeded") - await _insert_job(pg_conn, schema, "done-actor", "failed") - - result = await deregister_actor(pg_conn, "done-actor", schema=schema) - - assert result.actor_config_deleted is True - assert result.terminal_jobs_remaining == 2 -``` - -- [ ] **Step 3: Run tests to verify they fail** - -Run: `uv run pytest tests/test_actor_deregistration.py -v` -Expected: FAIL with `ImportError: cannot import name 'deregister_actor'` - -- [ ] **Step 4: Implement `deregister_actor` (force=False path)** - -Add to `src/taskq/worker/actor_config_ops.py`: - -```python -# Add to imports section: -from taskq.exceptions import ( - ActorHasActiveJobsError, - ActorHasEnabledSchedulesError, - ActorNotFoundError, -) - -# Add DeregisterResult to __all__: -__all__ = [ - "UNSET", - "ActorConfigRow", - "DeregisterResult", - "Unset", - "deregister_actor", - "get_actor_config", - "list_actor_configs", - "set_actor_config_capacity", -] - -# Add after the ActorConfigRow dataclass: - -@dataclass(frozen=True, slots=True) -class DeregisterResult: - """Outcome of a deregister_actor call.""" - - actor: str - queue: str - actor_config_deleted: bool - schedules_disabled: int - jobs_cancelled: int - terminal_jobs_remaining: int - queue_purged: bool - - -# SQL templates (as defined in the API surface section above) - -_NON_TERMINAL_STATUSES: tuple[str, ...] = ("pending", "scheduled", "running") - -_DEREGISTER_CHECK_ACTIVE_JOBS_SQL = """ -SELECT status, count(*) AS cnt - FROM "{schema}".jobs - WHERE actor = $1 AND status = ANY($2::"{schema}".job_status[]) - GROUP BY status -""".strip() - -_DEREGISTER_CHECK_SCHEDULES_SQL = """ -SELECT id::text FROM "{schema}".cron_schedules - WHERE actor = $1 AND enabled = true -""".strip() - -_DEREGISTER_DELETE_ACTOR_CONFIG_SQL = """ -DELETE FROM "{schema}".actor_config WHERE actor = $1 -RETURNING queue -""".strip() - -_DEREGISTER_COUNT_TERMINAL_SQL = """ -SELECT count(*) FROM "{schema}".jobs - WHERE actor = $1 AND status NOT IN ('pending', 'scheduled', 'running') -""".strip() - - -async def deregister_actor( - conn: ConnLike, - actor: str, - *, - force: bool = False, - purge_queue: bool = False, - schema: str = "taskq", -) -> DeregisterResult: - """Deregister an actor: delete its actor_config row with safety checks.""" - if not _IDENT_RE.match(schema): - raise ValueError(f"invalid schema identifier: {schema!r}") - - # force=False path only — force=True path added in Task 3 - async with conn.transaction(): - # 1. Check for non-terminal jobs - active_rows = await conn.fetch( - _DEREGISTER_CHECK_ACTIVE_JOBS_SQL.format(schema=schema), - actor, - list(_NON_TERMINAL_STATUSES), - ) - if active_rows: - status_counts = {row["status"]: row["cnt"] for row in active_rows} - active_count = sum(status_counts.values()) - raise ActorHasActiveJobsError(actor, active_count, status_counts) - - # 2. Check for enabled schedules - schedule_rows = await conn.fetch( - _DEREGISTER_CHECK_SCHEDULES_SQL.format(schema=schema), - actor, - ) - if schedule_rows: - schedule_ids = [row["id"] for row in schedule_rows] - raise ActorHasEnabledSchedulesError(actor, schedule_ids) - - # 3. Delete the actor_config row - deleted_rows = await conn.fetch( - _DEREGISTER_DELETE_ACTOR_CONFIG_SQL.format(schema=schema), - actor, - ) - if not deleted_rows: - raise ActorNotFoundError(actor) - - queue_name = deleted_rows[0]["queue"] - - # 4. Count terminal jobs remaining - terminal_count = await conn.fetchval( - _DEREGISTER_COUNT_TERMINAL_SQL.format(schema=schema), - actor, - ) - - # 5. Optionally purge queue (implemented in Task 3 Step 3) - queue_purged = False - - return DeregisterResult( - actor=actor, - queue=queue_name, - actor_config_deleted=True, - schedules_disabled=0, - jobs_cancelled=0, - terminal_jobs_remaining=terminal_count or 0, - queue_purged=queue_purged, - ) -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `uv run pytest tests/test_actor_deregistration.py -v` -Expected: PASS (7 tests) - -- [ ] **Step 6: Commit** - -```bash -git add src/taskq/worker/actor_config_ops.py tests/test_actor_deregistration.py -git commit -m "feat: add deregister_actor with force=False safety checks" -``` - ---- - -### Task 3: `deregister_actor` force=True path - -**Files:** -- Modify: `src/taskq/worker/actor_config_ops.py` (extend `deregister_actor`) -- Test: `tests/test_actor_deregistration.py` (add force=True tests) - -- [ ] **Step 1: Write the failing tests for force=True** - -Add to `tests/test_actor_deregistration.py`: - -```python -async def test_deregister_force_cancels_pending_and_disables_schedules( - pg_conn: asyncpg.Connection, -) -> None: - """force=True cancels pending/scheduled jobs and disables schedules.""" - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="force-actor", max_concurrent=1, queue="default")], - schema=schema, - ) - await _insert_job(pg_conn, schema, "force-actor", "pending") - await _insert_job(pg_conn, schema, "force-actor", "scheduled") - await _insert_schedule(pg_conn, schema, "force-actor", enabled=True) - - result = await deregister_actor(pg_conn, "force-actor", force=True, schema=schema) - - assert result.actor_config_deleted is True - assert result.jobs_cancelled == 2 - assert result.schedules_disabled == 1 - - # Verify jobs are cancelled - cancelled = await pg_conn.fetchval( - f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = 'force-actor' AND status = 'cancelled'" - ) - assert cancelled == 2 - - # Verify schedule is disabled - enabled = await pg_conn.fetchval( - f"SELECT count(*) FROM \"{schema}\".cron_schedules WHERE actor = 'force-actor' AND enabled = true" - ) - assert enabled == 0 - - # Row is gone - assert await get_actor_config(pg_conn, "force-actor", schema=schema) is None - - -async def test_deregister_force_refuses_with_running_jobs( - pg_conn: asyncpg.Connection, -) -> None: - """force=True still refuses if running jobs exist.""" - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="running-force-actor", max_concurrent=1, queue="default")], - schema=schema, - ) - await _insert_job(pg_conn, schema, "running-force-actor", "running") - - with pytest.raises(ActorHasActiveJobsError) as exc_info: - await deregister_actor(pg_conn, "running-force-actor", force=True, schema=schema) - - # Only running jobs in the breakdown - assert exc_info.value.status_counts == {"running": 1} - - # Row is still there - assert await get_actor_config(pg_conn, "running-force-actor", schema=schema) is not None - - -async def test_deregister_force_with_running_and_pending_only_reports_running( - pg_conn: asyncpg.Connection, -) -> None: - """force=True: pending jobs are OK, only running blocks. But the check - should only report running in the error (pending would be cancelled).""" - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="mixed-actor", max_concurrent=1, queue="default")], - schema=schema, - ) - await _insert_job(pg_conn, schema, "mixed-actor", "pending") - await _insert_job(pg_conn, schema, "mixed-actor", "running") - - with pytest.raises(ActorHasActiveJobsError) as exc_info: - await deregister_actor(pg_conn, "mixed-actor", force=True, schema=schema) - - # Only running is reported (pending would be auto-cancelled) - assert "running" in exc_info.value.status_counts - assert "pending" not in exc_info.value.status_counts - - -async def test_deregister_force_keeps_terminal_history( - pg_conn: asyncpg.Connection, -) -> None: - """force=True does not delete or modify terminal jobs.""" - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="hist-actor", max_concurrent=1, queue="default")], - schema=schema, - ) - await _insert_job(pg_conn, schema, "hist-actor", "succeeded") - await _insert_job(pg_conn, schema, "hist-actor", "failed") - await _insert_job(pg_conn, schema, "hist-actor", "pending") - - result = await deregister_actor(pg_conn, "hist-actor", force=True, schema=schema) - - assert result.terminal_jobs_remaining == 2 - assert result.jobs_cancelled == 1 # only the pending one - - # Terminal jobs are still there - succeeded = await pg_conn.fetchval( - f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = 'hist-actor' AND status = 'succeeded'" - ) - assert succeeded == 1 - failed = await pg_conn.fetchval( - f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = 'hist-actor' AND status = 'failed'" - ) - assert failed == 1 -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_actor_deregistration.py -k force -v` -Expected: FAIL (force=True not implemented yet — pending jobs not cancelled) - -- [ ] **Step 3: Implement the force=True path** - -Replace the `deregister_actor` function in `src/taskq/worker/actor_config_ops.py` with the full implementation: - -```python -_RUNNING_STATUS: str = "running" - -_DEREGISTER_CANCEL_PENDING_SQL = """ -UPDATE "{schema}".jobs - SET status = 'cancelled', - finished_at = now(), - error_class = 'ActorDeregistered', - error_message = 'Job cancelled by actor deregistration (force=True)' - WHERE actor = $1 - AND status IN ('pending', 'scheduled') -""".strip() - -_DEREGISTER_DISABLE_SCHEDULES_SQL = """ -UPDATE "{schema}".cron_schedules - SET enabled = false - WHERE actor = $1 AND enabled = true -""".strip() - - -async def deregister_actor( - conn: ConnLike, - actor: str, - *, - force: bool = False, - purge_queue: bool = False, - schema: str = "taskq", -) -> DeregisterResult: - """Deregister an actor: delete its actor_config row with safety checks. - - See the API surface section in docs/specs/2026-07-29-actor-deregistration.md - for the full semantics documentation. - """ - if not _IDENT_RE.match(schema): - raise ValueError(f"invalid schema identifier: {schema!r}") - - async with conn.transaction(): - if not force: - # force=False: refuse if ANY non-terminal jobs exist - active_rows = await conn.fetch( - _DEREGISTER_CHECK_ACTIVE_JOBS_SQL.format(schema=schema), - actor, - list(_NON_TERMINAL_STATUSES), - ) - if active_rows: - status_counts = {row["status"]: row["cnt"] for row in active_rows} - active_count = sum(status_counts.values()) - raise ActorHasActiveJobsError(actor, active_count, status_counts) - - # Refuse if enabled schedules exist - schedule_rows = await conn.fetch( - _DEREGISTER_CHECK_SCHEDULES_SQL.format(schema=schema), - actor, - ) - if schedule_rows: - schedule_ids = [row["id"] for row in schedule_rows] - raise ActorHasEnabledSchedulesError(actor, schedule_ids) - - jobs_cancelled = 0 - schedules_disabled = 0 - else: - # force=True: refuse only if RUNNING jobs exist - running_rows = await conn.fetch( - _DEREGISTER_CHECK_ACTIVE_JOBS_SQL.format(schema=schema), - actor, - [_RUNNING_STATUS], - ) - if running_rows: - status_counts = {row["status"]: row["cnt"] for row in running_rows} - active_count = sum(status_counts.values()) - raise ActorHasActiveJobsError(actor, active_count, status_counts) - - # Cancel pending/scheduled jobs - cancel_result = await conn.execute( - _DEREGISTER_CANCEL_PENDING_SQL.format(schema=schema), - actor, - ) - # asyncpg returns "UPDATE N" — parse the count - jobs_cancelled = int(cancel_result.split()[-1]) if cancel_result else 0 - - # Disable enabled schedules - disable_result = await conn.execute( - _DEREGISTER_DISABLE_SCHEDULES_SQL.format(schema=schema), - actor, - ) - schedules_disabled = int(disable_result.split()[-1]) if disable_result else 0 - - # Delete the actor_config row - deleted_rows = await conn.fetch( - _DEREGISTER_DELETE_ACTOR_CONFIG_SQL.format(schema=schema), - actor, - ) - if not deleted_rows: - raise ActorNotFoundError(actor) - - queue_name = deleted_rows[0]["queue"] - - # Count terminal jobs remaining - terminal_count = await conn.fetchval( - _DEREGISTER_COUNT_TERMINAL_SQL.format(schema=schema), - actor, - ) - - # Optionally purge queue - queue_purged = False - if purge_queue: - purge_result = await conn.execute( - _DEREGISTER_PURGE_QUEUE_SQL.format(schema=schema), - queue_name, - ) - queue_purged = purge_result == "DELETE 1" - - return DeregisterResult( - actor=actor, - queue=queue_name, - actor_config_deleted=True, - schedules_disabled=schedules_disabled, - jobs_cancelled=jobs_cancelled, - terminal_jobs_remaining=terminal_count or 0, - queue_purged=queue_purged, - ) -``` - -Also add the `_DEREGISTER_PURGE_QUEUE_SQL` constant: - -```python -_DEREGISTER_PURGE_QUEUE_SQL = """ -DELETE FROM "{schema}".queues - WHERE name = $1 - AND NOT EXISTS ( - SELECT 1 FROM "{schema}".actor_config WHERE queue = $1 - ) -""".strip() -``` - -- [ ] **Step 4: Run all deregistration tests** - -Run: `uv run pytest tests/test_actor_deregistration.py -v` -Expected: PASS (all tests including force=True path) - -- [ ] **Step 5: Commit** - -```bash -git add src/taskq/worker/actor_config_ops.py tests/test_actor_deregistration.py -git commit -m "feat: add force=True path to deregister_actor" -``` - ---- - -### Task 4: Queue purge tests - -**Files:** -- Modify: `tests/test_actor_deregistration.py` (add purge_queue tests) - -- [ ] **Step 1: Write the failing tests for purge_queue** - -Add to `tests/test_actor_deregistration.py`: - -```python -async def test_deregister_purge_queue_deletes_orphaned_queue( - pg_conn: asyncpg.Connection, -) -> None: - """purge_queue=True deletes the queue row when no other actor uses it.""" - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="solo-actor", max_concurrent=1, queue="solo-queue")], - schema=schema, - ) - # Create the queue row - await pg_conn.execute( - f"INSERT INTO \"{schema}\".queues (name) VALUES ('solo-queue') ON CONFLICT DO NOTHING" - ) - - result = await deregister_actor( - pg_conn, "solo-actor", purge_queue=True, schema=schema - ) - - assert result.queue_purged is True - assert result.queue == "solo-queue" - - # Queue row is gone - queue_count = await pg_conn.fetchval( - f"SELECT count(*) FROM \"{schema}\".queues WHERE name = 'solo-queue'" - ) - assert queue_count == 0 - - -async def test_deregister_purge_queue_keeps_shared_queue( - pg_conn: asyncpg.Connection, -) -> None: - """purge_queue=True does NOT delete the queue if another actor still uses it.""" - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ - ActorConfig(actor="actor-a", max_concurrent=1, queue="shared-queue"), - ActorConfig(actor="actor-b", max_concurrent=1, queue="shared-queue"), - ], - schema=schema, - ) - await pg_conn.execute( - f"INSERT INTO \"{schema}\".queues (name) VALUES ('shared-queue') ON CONFLICT DO NOTHING" - ) - - result = await deregister_actor( - pg_conn, "actor-a", purge_queue=True, schema=schema - ) - - assert result.queue_purged is False # actor-b still uses it - - # Queue row is still there - queue_count = await pg_conn.fetchval( - f"SELECT count(*) FROM \"{schema}\".queues WHERE name = 'shared-queue'" - ) - assert queue_count == 1 - - -async def test_deregister_without_purge_queue_keeps_queue( - pg_conn: asyncpg.Connection, -) -> None: - """Default (purge_queue=False) does not touch the queue row.""" - schema = f"taco_{new_base62()}".lower() - await _ensure_schema(pg_conn, schema) - await sync_actor_config( - pg_conn, - [ActorConfig(actor="keep-queue-actor", max_concurrent=1, queue="kept-queue")], - schema=schema, - ) - await pg_conn.execute( - f"INSERT INTO \"{schema}\".queues (name) VALUES ('kept-queue') ON CONFLICT DO NOTHING" - ) - - result = await deregister_actor(pg_conn, "keep-queue-actor", schema=schema) - - assert result.queue_purged is False - - # Queue row is still there - queue_count = await pg_conn.fetchval( - f"SELECT count(*) FROM \"{schema}\".queues WHERE name = 'kept-queue'" - ) - assert queue_count == 1 -``` - -- [ ] **Step 2: Run tests** - -Run: `uv run pytest tests/test_actor_deregistration.py -k purge -v` -Expected: PASS (the purge logic was already implemented in Task 3's step 3) - -- [ ] **Step 3: Commit** - -```bash -git add tests/test_actor_deregistration.py -git commit -m "test: add queue purge tests for deregister_actor" -``` - ---- - -### Task 5: ActorsClient — pool-wrapping facade - -**Files:** -- Create: `src/taskq/client/_actors.py` -- Test: `tests/test_actors_client.py` - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/test_actors_client.py - -"""Tests for ActorsClient — the pool-wrapping facade over actor_config_ops. - -These tests use a fake pool to verify the delegation wiring without -requiring real Postgres (the ops functions themselves are integration-tested -in test_actor_deregistration.py and test_actor_config_ops.py). -""" - -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from taskq.worker.actor_config_ops import ( - ActorConfigRow, - DeregisterResult, -) - -pytestmark = [pytest.mark.asyncio] - - -class _FakePool: - """Minimal pool that yields a fake connection via async context manager.""" - - def __init__(self, conn: Any) -> None: - self._conn = conn - - def acquire(self) -> Any: - cm = MagicMock() - cm.__aenter__ = AsyncMock(return_value=self._conn) - cm.__aexit__ = AsyncMock(return_value=None) - return cm - - -class _FakeConn: - """Fake connection — just needs to be passable to the ops functions.""" - - async def close(self) -> None: ... - - -async def test_actors_client_list_delegates( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from taskq.client._actors import ActorsClient - - conn = _FakeConn() - pool = _FakePool(conn) - client = ActorsClient(pool, schema="test_schema") - - # Patch list_actor_configs to verify delegation - import taskq.client._actors as actors_mod - - mock_result = [ - ActorConfigRow( - actor="a", max_concurrent=1, max_pending=None, queue="q", - result_ttl=None, metadata={}, updated_at="2026-01-01" - ) - ] - monkeypatch.setattr(actors_mod, "list_actor_configs", AsyncMock(return_value=mock_result)) - result = await client.list() - assert result == mock_result - actors_mod.list_actor_configs.assert_called_once_with( - conn, schema="test_schema" - ) - - -async def test_actors_client_deregister_delegates( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from taskq.client._actors import ActorsClient - - conn = _FakeConn() - pool = _FakePool(conn) - client = ActorsClient(pool, schema="test_schema") - - expected = DeregisterResult( - actor="test-actor", queue="q", actor_config_deleted=True, - schedules_disabled=0, jobs_cancelled=0, - terminal_jobs_remaining=0, queue_purged=False, - ) - - import taskq.client._actors as actors_mod - - monkeypatch.setattr(actors_mod, "deregister_actor", AsyncMock(return_value=expected)) - result = await client.deregister("test-actor", force=True, purge_queue=True) - assert result == expected - actors_mod.deregister_actor.assert_called_once_with( - conn, "test-actor", force=True, purge_queue=True, schema="test_schema" - ) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_actors_client.py -v` -Expected: FAIL with `ImportError: cannot import name 'ActorsClient'` - -- [ ] **Step 3: Implement ActorsClient** - -Create `src/taskq/client/_actors.py`: - -```python -"""ActorsClient — pool-wrapping facade for actor configuration operations. - -Provides a typed surface for listing, inspecting, tuning, and deregistering -stored ``actor_config`` rows. Each method acquires a connection from the -injected pool, delegates to ``taskq.worker.actor_config_ops``, and returns -the result. -""" - -from typing import TYPE_CHECKING - -import structlog - -from taskq.worker.actor_config_ops import ( - UNSET, - ActorConfigRow, - DeregisterResult, - Unset, - deregister_actor, - get_actor_config, - list_actor_configs, - set_actor_config_capacity, -) - -if TYPE_CHECKING: - import asyncpg - -__all__ = ["ActorsClient"] - -logger = structlog.get_logger("taskq.client._actors") - - -class ActorsClient: - """Pool-wrapping facade for actor configuration operations. - - Acquires a connection from the injected pool for each call, delegates - to ``taskq.worker.actor_config_ops``, and returns the result. The - caller must have opened the pool; this class does not manage its - lifecycle. - - Parameters - ---------- - pool: - An open ``asyncpg.Pool``. The caller retains ownership. - schema: - TaskQ schema name. Defaults to ``"taskq"``. - """ - - def __init__(self, pool: "asyncpg.Pool", *, schema: str = "taskq") -> None: - self._pool = pool - self._schema = schema - - async def list(self) -> list[ActorConfigRow]: - """List all stored actor_config rows, ordered by actor name.""" - async with self._pool.acquire() as conn: - return await list_actor_configs(conn, schema=self._schema) - - async def get(self, actor: str) -> ActorConfigRow | None: - """Get one actor_config row, or ``None`` if not found.""" - async with self._pool.acquire() as conn: - return await get_actor_config(conn, actor, schema=self._schema) - - async def set_capacity( - self, - actor: str, - *, - max_concurrent: int | None | Unset = UNSET, - max_pending: int | None | Unset = UNSET, - result_ttl: float | None | Unset = UNSET, - ) -> ActorConfigRow | None: - """Update capacity fields on an existing actor_config row.""" - async with self._pool.acquire() as conn: - return await set_actor_config_capacity( - conn, - actor, - max_concurrent=max_concurrent, - max_pending=max_pending, - result_ttl=result_ttl, - schema=self._schema, - ) - - async def deregister( - self, - actor: str, - *, - force: bool = False, - purge_queue: bool = False, - ) -> DeregisterResult: - """Deregister an actor with safety checks. - - See :func:`taskq.worker.actor_config_ops.deregister_actor` for - the full semantics. - """ - async with self._pool.acquire() as conn: - return await deregister_actor( - conn, - actor, - force=force, - purge_queue=purge_queue, - schema=self._schema, - ) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/test_actors_client.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git add src/taskq/client/_actors.py tests/test_actors_client.py -git commit -m "feat: add ActorsClient pool-wrapping facade" -``` - ---- - -### Task 6: TaskQ.actors property - -**Files:** -- Modify: `src/taskq/client/_taskq.py` (add `actors` property, create `_actors_client` in `open()`) -- Modify: `src/taskq/client/__init__.py` (export `ActorsClient`) -- Test: `tests/test_taskq_client.py` (add test) - -- [ ] **Step 1: Write the failing test** - -Add to `tests/test_taskq_client.py`: - -```python -async def test_taskq_actors_property_returns_actors_client( - module_pg_schema: "ModulePgSchema", -) -> None: - """TaskQ.actors returns an ActorsClient bound to the same pool and schema. - - Uses the module-scoped ``module_pg_schema`` fixture (already migrated - via ``apply_pending``). ``ModulePgSchema`` is a NamedTuple with - ``.schema_name`` and ``.pg_dsn`` fields. - """ - from taskq import TaskQ - from taskq.client._actors import ActorsClient - from taskq.testing.fixtures import ModulePgSchema # noqa: F401 - - async with TaskQ( - dsn=module_pg_schema.pg_dsn, - schema=module_pg_schema.schema_name, - ) as tq: - client = tq.actors - assert isinstance(client, ActorsClient) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/test_taskq_client.py::test_taskq_actors_property_returns_actors_client -v` -Expected: FAIL with `AttributeError: 'TaskQ' object has no attribute 'actors'` - -- [ ] **Step 3: Implement the `actors` property** - -In `src/taskq/client/_taskq.py`, add import at the top: - -```python -from taskq.client._actors import ActorsClient -``` - -Add to `__all__`: - -```python -__all__ = ["EventRow", "JobEvent", "TaskQ", "ActorsClient"] -``` - -In `TaskQ.__init__`, add: - -```python -self._actors_client: ActorsClient | None = None -``` - -In `TaskQ.open()`, after the `self._client = JobsClient(...)` line, add: - -```python -self._actors_client = ActorsClient(pool, schema=self._schema) -``` - -In `TaskQ.close()`, add after `self._client = None`: - -```python -self._actors_client = None -``` - -Add the property after `_require_open`: - -```python -@property -def actors(self) -> ActorsClient: - """Actor configuration client — list, get, set capacity, deregister. - - Raises RuntimeError if called before ``open()`` or outside an - ``async with`` block. - """ - if self._actors_client is None: - raise RuntimeError( - "TaskQ is not open. Call 'await tq.open()' or use " - "'async with TaskQ(...) as tq:'" - ) - return self._actors_client -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run pytest tests/test_taskq_client.py::test_taskq_actors_property_returns_actors_client -v` -Expected: PASS - -- [ ] **Step 5: Update client __init__.py exports** - -In `src/taskq/client/__init__.py`, add `ActorsClient` to the exports: - -```python -from taskq.client._actors import ActorsClient -``` - -Add `"ActorsClient"` to `__all__`. - -- [ ] **Step 6: Commit** - -```bash -git add src/taskq/client/_taskq.py src/taskq/client/__init__.py tests/test_taskq_client.py -git commit -m "feat: add TaskQ.actors property returning ActorsClient" -``` - ---- - -### Task 7: CLI `taskq actor-config deregister` command - -**Files:** -- Modify: `src/taskq/cli.py` (add `actor_config_deregister` command) -- Test: `tests/test_cli_actor_deregister.py` (new file) - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/test_cli_actor_deregister.py - -"""Tests for `taskq actor-config deregister` CLI command. - -Monkeypatches the ops function and asyncpg.connect to pin the CLI's -argument parsing, error handling, and output shape without requiring -real Postgres (integration coverage is in test_actor_deregistration.py). -""" - -from typing import Any -from unittest.mock import AsyncMock - -import pytest -from typer.testing import CliRunner - -from taskq.cli import app -from taskq.worker.actor_config_ops import DeregisterResult - -runner = CliRunner() - - -def _patch_deregister( - monkeypatch: pytest.MonkeyPatch, - *, - result: DeregisterResult | None = None, - raises: Exception | None = None, -) -> dict[str, Any]: - """Fake asyncpg.connect + deregister_actor; return captured call kwargs.""" - captured: dict[str, Any] = {} - - class _FakeConn: - async def close(self) -> None: ... - - async def fake_connect(dsn: str) -> Any: - return _FakeConn() - - async def fake_deregister(conn: Any, actor: str, **kwargs: Any) -> Any: - captured["actor"] = actor - captured["kwargs"] = kwargs - if raises is not None: - raise raises - return result or DeregisterResult( - actor=actor, queue="default", actor_config_deleted=True, - schedules_disabled=0, jobs_cancelled=0, - terminal_jobs_remaining=0, queue_purged=False, - ) - - monkeypatch.setattr("taskq.cli.asyncpg.connect", fake_connect) - monkeypatch.setattr("taskq.cli.deregister_actor", fake_deregister) - return captured - - -def test_deregister_default_no_force_no_purge(monkeypatch: pytest.MonkeyPatch) -> None: - captured = _patch_deregister(monkeypatch) - result = runner.invoke(app, ["actor-config", "deregister", "my-actor.run-123"]) - assert result.exit_code == 0, f"stderr: {result.stderr}" - assert captured["actor"] == "my-actor.run-123" - assert captured["kwargs"]["force"] is False - assert captured["kwargs"]["purge_queue"] is False - - -def test_deregister_force_flag(monkeypatch: pytest.MonkeyPatch) -> None: - captured = _patch_deregister(monkeypatch) - result = runner.invoke(app, ["actor-config", "deregister", "my-actor", "--force"]) - assert result.exit_code == 0, f"stderr: {result.stderr}" - assert captured["kwargs"]["force"] is True - - -def test_deregister_purge_queue_flag(monkeypatch: pytest.MonkeyPatch) -> None: - captured = _patch_deregister(monkeypatch) - result = runner.invoke( - app, ["actor-config", "deregister", "my-actor", "--purge-queue"] - ) - assert result.exit_code == 0, f"stderr: {result.stderr}" - assert captured["kwargs"]["purge_queue"] is True - - -def test_deregister_not_found_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: - from taskq.exceptions import ActorNotFoundError - - _patch_deregister(monkeypatch, raises=ActorNotFoundError("ghost")) - result = runner.invoke(app, ["actor-config", "deregister", "ghost"]) - assert result.exit_code == 1 - assert "no stored actor_config row" in result.stderr - - -def test_deregister_active_jobs_error_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: - from taskq.exceptions import ActorHasActiveJobsError - - _patch_deregister( - monkeypatch, - raises=ActorHasActiveJobsError( - "busy", active_count=3, status_counts={"pending": 2, "running": 1} - ), - ) - result = runner.invoke(app, ["actor-config", "deregister", "busy"]) - assert result.exit_code == 1 - assert "non-terminal" in result.stderr - assert "force=True" in result.stderr - - -def test_deregister_schedules_error_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: - from taskq.exceptions import ActorHasEnabledSchedulesError - - _patch_deregister( - monkeypatch, - raises=ActorHasEnabledSchedulesError("sched-actor", ["s1", "s2"]), - ) - result = runner.invoke(app, ["actor-config", "deregister", "sched-actor"]) - assert result.exit_code == 1 - assert "enabled cron schedule" in result.stderr - - -def test_deregister_output_shows_result(monkeypatch: pytest.MonkeyPatch) -> None: - result = DeregisterResult( - actor="my-actor", queue="my-queue", actor_config_deleted=True, - schedules_disabled=2, jobs_cancelled=5, - terminal_jobs_remaining=10, queue_purged=True, - ) - _patch_deregister(monkeypatch, result=result) - output = runner.invoke(app, ["actor-config", "deregister", "my-actor", "--force", "--purge-queue"]) - assert output.exit_code == 0 - assert "deregistered" in output.output.lower() - assert "schedules_disabled=2" in output.output - assert "jobs_cancelled=5" in output.output - assert "terminal_jobs_remaining=10" in output.output - assert "queue_purged=true" in output.output.lower() -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_cli_actor_deregister.py -v` -Expected: FAIL (command doesn't exist) - -- [ ] **Step 3: Implement the CLI command** - -In `src/taskq/cli.py`, add `deregister_actor` to the import from `actor_config_ops`: - -```python -from taskq.worker.actor_config_ops import ( - UNSET, - ActorConfigRow, - DeregisterResult, - Unset, - deregister_actor, - get_actor_config, - list_actor_configs, - set_actor_config_capacity, -) -``` - -Also add the exception imports: - -```python -from taskq.exceptions import ( - ActorConfigDriftList, - ActorDeregistrationError, -) -``` - -Add the command after the `actor_config_set` / `_actor_config_set` functions (around line 518): - -```python -@actor_config_app.command("deregister") -def actor_config_deregister( - actor: Annotated[str, typer.Argument(help="Actor name to deregister.")], - force: Annotated[ - bool, - typer.Option( - "--force", - help="Cancel pending/scheduled jobs and disable enabled cron schedules" - " instead of refusing. Running jobs still block deregistration.", - ), - ] = False, - purge_queue: Annotated[ - bool, - typer.Option( - "--purge-queue", - help="Also delete the orphaned queues row if no other actor_config" - " references the same queue.", - ), - ] = False, -) -> None: - """Deregister an actor: delete its actor_config row with safety checks. - - By default refuses if non-terminal jobs or enabled cron schedules - reference the actor. Use --force to cancel pending/scheduled jobs and - disable schedules. Running jobs always block (force or not). Use - --purge-queue to also delete the queues row if no other actor uses it. - """ - settings = TaskQSettings.load() - asyncio.run(_actor_config_deregister(settings, actor, force, purge_queue)) - - -async def _actor_config_deregister( - settings: TaskQSettings, - actor: str, - force: bool, - purge_queue: bool, -) -> None: - conn = await asyncpg.connect(str(settings.pg_dsn)) - try: - result = await deregister_actor( - conn, - actor, - force=force, - purge_queue=purge_queue, - schema=settings.schema_name, - ) - except (ActorDeregistrationError, ValueError) as exc: - typer.echo(str(exc), err=True) - raise typer.Exit(code=1) from None - finally: - await conn.close() - - typer.echo( - f"Deregistered actor {result.actor!r}:" - f" actor_config_deleted={result.actor_config_deleted}" - f" schedules_disabled={result.schedules_disabled}" - f" jobs_cancelled={result.jobs_cancelled}" - f" terminal_jobs_remaining={result.terminal_jobs_remaining}" - f" queue_purged={result.queue_purged}" - ) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/test_cli_actor_deregister.py -v` -Expected: PASS (7 tests) - -- [ ] **Step 5: Commit** - -```bash -git add src/taskq/cli.py tests/test_cli_actor_deregister.py -git commit -m "feat: add 'taskq actor-config deregister' CLI command" -``` - ---- - -### Task 8: Admin UI actors page - -**Files:** -- Create: `src/taskq/web/admin/actors.py` -- Create: `src/taskq/web/templates/actors.html` -- Modify: `src/taskq/web/templates/_base.html` (add nav link) -- Test: `tests/test_web_admin_actors.py` - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/test_web_admin_actors.py - -"""Tests for the admin UI actors page and deregister route. - -Follows the exact pattern of ``tests/test_web_admin_integration.py``: -a per-test asyncpg pool on the module's migrated schema, a FastAPI app -built via ``create_router`` + ``setup_admin_state`` + ``include_router``, -and ``httpx.AsyncClient`` with ``ASGITransport`` — NOT ``TestClient``, -which runs the app on its own event loop that the per-test asyncpg pool -is not bound to. - -CSRF is the synchronizer-token pattern: ``_CsrfRoute`` sets the -``taskq_csrf_token`` cookie on every GET; ``validate_csrf`` compares the -cookie against the ``csrf_token`` form field on POST. There is no test -bypass — every POST test must GET first so the cookie is set, then pass -the cookie value as the form field (the same flow as ``_post_cancel`` -in the integration file). ``httpx.AsyncClient`` persists cookies across -requests on the same client. -""" - -from collections.abc import AsyncIterator - -import asyncpg -import httpx -import pytest -import pytest_asyncio -from fastapi import FastAPI - -from taskq.testing.fixtures import ModulePgSchema -from taskq.web.admin import create_router, setup_admin_state -from taskq.worker.actor_config import ActorConfig -from taskq.worker.startup import sync_actor_config - -pytestmark = [pytest.mark.asyncio, pytest.mark.integration] - - -@pytest_asyncio.fixture -async def admin_pool(module_pg_schema: ModulePgSchema) -> AsyncIterator[asyncpg.Pool]: - """Per-test pool on the module's (already migrated) schema. - - Created inside the test's event loop so the ASGI app can use it — - same rationale as the ``pool`` fixture in test_web_admin_integration.py. - """ - pool = await asyncpg.create_pool(module_pg_schema.pg_dsn, min_size=1, max_size=4) - assert pool is not None - try: - yield pool - finally: - await pool.close() - - -def _make_admin_app( - pool: asyncpg.Pool, - schema: str, - monkeypatch: pytest.MonkeyPatch, - *, - admin_actions_enabled: bool, -) -> FastAPI: - """Build the admin app. Env must be set BEFORE ``create_router`` — - it calls ``TaskQSettings.load()`` internally and captures - ``admin_actions_enabled`` at construction time. - - TASKQ_ENVIRONMENT=dev bypasses create_router's fail-closed - admin_ui_require_auth default (these tests exercise the page and the - admin-actions gate, not auth — see test_admin_security_fixes.py for - the auth gates). - """ - monkeypatch.setenv("TASKQ_ENVIRONMENT", "dev") - monkeypatch.setenv( - "TASKQ_ADMIN_ACTIONS_ENABLED", "true" if admin_actions_enabled else "false" - ) - bundle = create_router(pool, schema=schema, base_path="/admin") - app = FastAPI() - setup_admin_state(app, bundle) - app.include_router(bundle.router, prefix="/admin") - return app - - -async def _seed_actor_config( - conn: asyncpg.Connection, - schema: str, - actor: str, - queue: str = "default", -) -> None: - await sync_actor_config( - conn, - [ActorConfig(actor=actor, max_concurrent=1, queue=queue)], - schema=schema, - ) - - -async def _get_csrf_then_post( - app: FastAPI, - get_url: str, - post_url: str, - data: dict[str, str] | None = None, -) -> httpx.Response: - """GET (to obtain the CSRF cookie) then POST with the matching form field.""" - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), - base_url="http://test", - follow_redirects=False, - ) as client: - get_resp = await client.get(get_url) - assert get_resp.status_code == 200 - csrf_token = get_resp.cookies.get("taskq_csrf_token", "") - assert csrf_token, "GET must set the taskq_csrf_token cookie" - return await client.post( - post_url, data={"csrf_token": csrf_token, **(data or {})} - ) - - -async def test_actors_page_lists_actor_config_rows( - clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, - module_pg_schema: ModulePgSchema, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """GET /admin/actors shows actor_config rows with queue and capacity.""" - schema = module_pg_schema.schema_name - await _seed_actor_config(clean_pg_conn, schema, "test-actor-1", queue="default") - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) - - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://test" - ) as client: - resp = await client.get("/admin/actors") - - assert resp.status_code == 200 - assert "test-actor-1" in resp.text - assert "default" in resp.text - - -async def test_actors_page_shows_deregister_button( - clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, - module_pg_schema: ModulePgSchema, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Each actor row has a deregister form/button.""" - schema = module_pg_schema.schema_name - await _seed_actor_config(clean_pg_conn, schema, "button-actor", queue="default") - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) - - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://test" - ) as client: - resp = await client.get("/admin/actors") - - assert resp.status_code == 200 - assert "Deregister" in resp.text - assert "/deregister" in resp.text - - -async def test_deregister_route_returns_403_when_admin_actions_disabled( - clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, - module_pg_schema: ModulePgSchema, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """POST with VALID CSRF still returns 403 when admin_actions_enabled=False — - proving the 403 comes from the admin-actions gate, not a CSRF failure.""" - schema = module_pg_schema.schema_name - await _seed_actor_config(clean_pg_conn, schema, "disabled-actor", queue="default") - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=False) - - resp = await _get_csrf_then_post( - app, "/admin/actors", "/admin/actors/disabled-actor/deregister" - ) - - assert resp.status_code == 403 - # Row must still be there — the gate fires before any DB mutation - count = await clean_pg_conn.fetchval( - f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', - "disabled-actor", - ) - assert count == 1 - - -async def test_deregister_route_succeeds_for_clean_actor( - clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, - module_pg_schema: ModulePgSchema, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """POST deregister with force=False deletes the actor_config row.""" - schema = module_pg_schema.schema_name - await _seed_actor_config(clean_pg_conn, schema, "clean-deregister-actor", queue="default") - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) - - resp = await _get_csrf_then_post( - app, "/admin/actors", "/admin/actors/clean-deregister-actor/deregister" - ) - - assert resp.status_code == 303 - assert "/actors" in resp.headers["location"] - - # Verify the actor_config row is gone - count = await clean_pg_conn.fetchval( - f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', - "clean-deregister-actor", - ) - assert count == 0 -``` - -Note: every fixture above exists and is visible to test modules — -``module_pg_schema`` / ``clean_pg_conn`` come from ``taskq.testing.fixtures`` -(re-exported through ``tests/conftest.py``); ``admin_pool`` is defined in the -file. The 403 test deliberately passes a VALID CSRF token: ``validate_csrf`` -runs before the route body, so a missing/invalid token would also 403 — for -the wrong reason. The GET-first CSRF flow is required; there is no dev-mode -CSRF bypass (``TASKQ_ENVIRONMENT=dev`` only relaxes the auth dependency, not -``validate_csrf``). - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_web_admin_actors.py -v` -Expected: FAIL (module doesn't exist) - -- [ ] **Step 3: Implement the actors admin page** - -Create `src/taskq/web/admin/actors.py`: - -```python -"""Actors overview and deregister admin pages.""" - -from urllib.parse import quote_plus - -import asyncpg -import structlog -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse -from jinja2 import Environment - -from taskq.exceptions import ActorDeregistrationError -from taskq.settings import TaskQSettings -from taskq.web.admin._factory import ( - get_base_path, - get_csrf_token, - get_pg_pool, - get_realtime_ctx, - get_schema, - get_settings, - get_templates, - validate_csrf, -) -from taskq.worker.actor_config_ops import deregister_actor - -logger = structlog.get_logger("taskq.web.admin.actors") - -_ACTORS_SQL = """ -SELECT ac.actor, ac.max_concurrent, ac.max_pending, ac.queue, - ac.result_ttl, ac.metadata::text AS metadata, ac.updated_at::text AS updated_at, - (SELECT count(*) FROM "{schema}".jobs j - WHERE j.actor = ac.actor - AND j.status IN ('pending', 'scheduled', 'running')) AS active_job_count, - (SELECT count(*) FROM "{schema}".cron_schedules cs - WHERE cs.actor = ac.actor AND cs.enabled = true) AS enabled_schedule_count - FROM "{schema}".actor_config ac - ORDER BY ac.actor -""".strip() - - -def register(router: APIRouter) -> None: - """Attach actors overview and deregister routes to *router*.""" - - @router.get("/actors", response_class=HTMLResponse) - async def actors_overview( - pool: asyncpg.Pool = Depends(get_pg_pool), - schema: str = Depends(get_schema), - tmpl: Environment = Depends(get_templates), - realtime_ctx: tuple[str, str] = Depends(get_realtime_ctx), - csrf_token: str = Depends(get_csrf_token), - ) -> HTMLResponse: - actors_sql = _ACTORS_SQL.format(schema=schema) - rows: list[asyncpg.Record] = [] - async with pool.acquire() as conn: - rows = await conn.fetch(actors_sql) - actors = [dict(r) for r in rows] - realtime_mode, mode_label = realtime_ctx - html = tmpl.get_template("actors.html").render( - actors=actors, - realtime_mode=realtime_mode, - mode_label=mode_label, - csrf_token=csrf_token, - active_page="actors", - ) - return HTMLResponse(content=html) - - @router.post("/actors/{actor}/deregister") - async def actor_deregister( - actor: str, - request: Request, - _csrf: None = Depends(validate_csrf), - pool: asyncpg.Pool = Depends(get_pg_pool), - schema: str = Depends(get_schema), - base_path: str = Depends(get_base_path), - settings: TaskQSettings = Depends(get_settings), - ) -> RedirectResponse: - if not settings.admin_actions_enabled: - raise HTTPException(status_code=403, detail="Admin actions are disabled") - - # Read form fields after CSRF validation. Starlette caches - # request.form() so the CSRF dependency's read and this read - # share the same parsed body. - form = await request.form() - force = form.get("force") == "true" - purge_queue = form.get("purge_queue") == "true" - - async with pool.acquire() as conn: - try: - result = await deregister_actor( - conn, actor, force=force, purge_queue=purge_queue, schema=schema - ) - except ActorDeregistrationError as exc: - raise HTTPException( - status_code=409, detail=str(exc) - ) from None - - return RedirectResponse( - url=f"{base_path}/actors?notice=deregistered+{quote_plus(actor)}", - status_code=303, - ) -``` - -Create `src/taskq/web/templates/actors.html` — a Jinja2 template following the existing pattern (see `workers.html` and `schedules.html` for structure): - -```html -{% extends "_base.html" %} -{% block title %}Actors — TaskQ Admin{% endblock %} -{% block content %} -
-

Actors

- {% if actors %} -
- - - - - - - - - - - - - - - {% for a in actors %} - - - - - - - - - - - {% endfor %} - -
ActorQueueMax ConcurrentMax PendingActive JobsSchedulesUpdatedActions
{{ a.actor }}{{ a.queue }}{{ a.max_concurrent or '∞' }}{{ a.max_pending or '—' }} - - {{ a.active_job_count }} - - {{ a.enabled_schedule_count }}{{ a.updated_at | time_ago }} - - - - - - -
-
- {% else %} -

No actor_config rows.

- {% endif %} -
-{% endblock %} -``` - -Note on actor-name encoding: the form action pipes the name through -`urlencode` because actor names are unvalidated free text. Starlette's -default `{actor}` path converter matches a single segment, so a name -containing an embedded `/` will not match the deregister route regardless -of encoding (it 404s) — an accepted limitation to document in the admin UI -guide (Task 11); such actors remain deregisterable via the client API and -CLI. - -Add the nav link to `src/taskq/web/templates/_base.html` — after the "Workers" link (around line 53), add: - -```html -Actors -``` - -- [ ] **Step 4: Run tests to verify they pass** - -The deregister POST route reads form fields via `request.form()` (cached by Starlette) after the CSRF dependency has validated. The `force` and `purge_queue` checkboxes send `"true"` when checked; the route checks `form.get("force") == "true"`. - -Run: `uv run pytest tests/test_web_admin_actors.py -v` -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -git add src/taskq/web/admin/actors.py src/taskq/web/templates/actors.html src/taskq/web/templates/_base.html tests/test_web_admin_actors.py -git commit -m "feat: add admin UI actors page with deregister button" -``` - ---- - -### Task 9: Export `ActorsClient` from public API - -**Files:** -- Modify: `src/taskq/__init__.py` -- Modify: `src/taskq/worker/actor_config_ops.py` (ensure `__all__` is complete) - -- [ ] **Step 1: Write the failing test** - -```python -# Add to tests/test_taskq_client.py or a new test file - -def test_actors_client_importable_from_taskq() -> None: - from taskq import ActorsClient - assert ActorsClient is not None -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/test_taskq_client.py::test_actors_client_importable_from_taskq -v` -Expected: FAIL with `ImportError` - -- [ ] **Step 3: Add the export** - -In `src/taskq/__init__.py`, add: - -```python -from taskq.client._actors import ActorsClient -``` - -Add `"ActorsClient"` to the `__all__` list. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `uv run pytest tests/test_taskq_client.py::test_actors_client_importable_from_taskq -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/taskq/__init__.py tests/test_taskq_client.py -git commit -m "feat: export ActorsClient from public API" -``` - ---- - -### Task 10: E2E test — full deregistration lifecycle - -**Files:** -- Create: `tests/e2e/test_actor_deregistration.py` - -- [ ] **Step 1: Write the e2e test** - -```python -# tests/e2e/test_actor_deregistration.py - -"""E2E: actor deregistration lifecycle with a real worker. - -Each test uses a **different actor** to avoid cross-test interference: -``e2e_worker`` is module-scoped and ``sync_actor_config`` runs only at -bootstrap, so once a test deregisters an actor's ``actor_config`` row, -later tests cannot enqueue to that same actor (the dispatch query -inner-joins ``actor_config`` — jobs would never be dispatched). - -Actors used (all defined in ``tests/e2e/actors.py``): -- ``quick_result`` — 0.05 s sleep, simple payload/result. Used for the - clean-deregister-after-completion test. -- ``long_running_job`` — 30 s sleep. Used for the refusal-with-active-jobs - test (guaranteed to be ``running`` when we deregister). -- ``short_lived_job`` — 0.5 s sleep. Used for the force-deregister test. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest - -from ._assertions import poll_until, wait_for_handle_status -from .actors import ( - LongRunningPayload, - QuickResultPayload, - ShortJobPayload, - long_running_job, - quick_result, - short_lived_job, -) - -if TYPE_CHECKING: - import asyncpg - - from taskq import TaskQ - - from .conftest import E2ESchema, E2EWorker - -pytestmark = [pytest.mark.e2e, pytest.mark.timeout(900)] - - -async def test_deregister_after_jobs_complete( - e2e_client: TaskQ, - e2e_worker: E2EWorker, - e2e_pg_pool: asyncpg.Pool, - e2e_schema: E2ESchema, - run_id: str, -) -> None: - """Deregister an actor after all its jobs are terminal.""" - schema = e2e_schema.schema_name - actor_name = quick_result.name - - # 1. Enqueue a job and wait for it to complete - handle = await e2e_client.enqueue( - quick_result, QuickResultPayload(run_id=run_id, value="test") - ) - await handle.wait(timeout=60) - - # 2. Verify the actor_config row exists (seeded by worker startup) - ac_count = await e2e_pg_pool.fetchval( - f"SELECT count(*) FROM \"{schema}\".actor_config WHERE actor = $1", - actor_name, - ) - assert ac_count == 1, f"actor_config row for {actor_name} should exist" - - # 3. Deregister the actor (force=False — all jobs are terminal) - result = await e2e_client.actors.deregister(actor_name) - - assert result.actor_config_deleted is True - assert result.terminal_jobs_remaining >= 1 # our completed job - - # 4. Verify the actor_config row is gone - ac_count = await e2e_pg_pool.fetchval( - f"SELECT count(*) FROM \"{schema}\".actor_config WHERE actor = $1", - actor_name, - ) - assert ac_count == 0 - - # 5. Verify terminal job history is still queryable - job_count = await e2e_pg_pool.fetchval( - f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = $1 AND status = 'succeeded'", - actor_name, - ) - assert job_count >= 1, "terminal job history should remain after deregistration" - - -async def test_deregister_refuses_with_active_jobs( - e2e_client: TaskQ, - e2e_worker: E2EWorker, - e2e_pg_pool: asyncpg.Pool, - e2e_schema: E2ESchema, - run_id: str, -) -> None: - """Deregistration refuses when a job is running. - - Uses ``long_running_job`` (30 s sleep) so the job is guaranteed to be - ``running`` when we attempt deregistration. - """ - from taskq.exceptions import ActorHasActiveJobsError - - schema = e2e_schema.schema_name - actor_name = long_running_job.name - - # 1. Enqueue a long-running job - handle = await e2e_client.enqueue( - long_running_job, LongRunningPayload(run_id=run_id) - ) - - # 2. Wait until the job is running (poll the DB) - async def _is_running() -> bool: - status = await e2e_pg_pool.fetchval( - f"SELECT status FROM \"{schema}\".jobs WHERE id = $1", - handle.job_id, - ) - return status == "running" - - await poll_until(_is_running, timeout=30.0, interval=0.5) - - # 3. Try to deregister — must refuse with ActorHasActiveJobsError - with pytest.raises(ActorHasActiveJobsError) as exc_info: - await e2e_client.actors.deregister(actor_name) - - assert exc_info.value.actor == actor_name - assert exc_info.value.active_count >= 1 - assert "running" in exc_info.value.status_counts - - # 4. Verify the actor_config row is still there (refusal did not delete) - ac_count = await e2e_pg_pool.fetchval( - f"SELECT count(*) FROM \"{schema}\".actor_config WHERE actor = $1", - actor_name, - ) - assert ac_count == 1 - - # 5. Clean up: cancel the job, wait for terminal, then force-deregister. - # Do NOT use handle.wait() here — it raises JobFailed for any - # non-success terminal status (cancelled included). Poll the status - # instead (the test_cancellation.py idiom). long_running_job never - # calls ctx.check_cancelled(), so the cancel lands only after the - # 30 s sleep finishes and the consumer routes the completion to - # mark_cancelled (cancel_phase >= COOPERATIVE is checked post-run) — - # budget the full 30 s plus margin. - await handle.cancel() - await wait_for_handle_status(handle, "cancelled", timeout=60) - - result = await e2e_client.actors.deregister(actor_name, force=True) - assert result.actor_config_deleted is True - - # 6. Verify cleanup - ac_count = await e2e_pg_pool.fetchval( - f"SELECT count(*) FROM \"{schema}\".actor_config WHERE actor = $1", - actor_name, - ) - assert ac_count == 0 - - -async def test_deregister_force_after_completion( - e2e_client: TaskQ, - e2e_worker: E2EWorker, - e2e_pg_pool: asyncpg.Pool, - e2e_schema: E2ESchema, - run_id: str, -) -> None: - """force=True deregister succeeds when all jobs are terminal. - - Uses ``short_lived_job`` (0.5 s sleep). Enqueues a job, waits for - completion, then force-deregisters. The force path cancels 0 jobs - (none are pending) and succeeds. - """ - schema = e2e_schema.schema_name - actor_name = short_lived_job.name - - # 1. Enqueue a job and wait for completion - handle = await e2e_client.enqueue( - short_lived_job, ShortJobPayload(run_id=run_id, label="force-test") - ) - await handle.wait(timeout=60) - - # 2. Force-deregister (no active jobs, so force has nothing to cancel) - result = await e2e_client.actors.deregister(actor_name, force=True) - - assert result.actor_config_deleted is True - assert result.jobs_cancelled == 0 # no pending jobs to cancel - assert result.terminal_jobs_remaining >= 1 - - # 3. Verify cleanup - ac_count = await e2e_pg_pool.fetchval( - f"SELECT count(*) FROM \"{schema}\".actor_config WHERE actor = $1", - actor_name, - ) - assert ac_count == 0 -``` - -Note: Each test uses a **different actor** (``quick_result``, ``long_running_job``, ``short_lived_job``) to avoid the module-scoped worker issue where deregistering one actor's ``actor_config`` row prevents later tests from dispatching jobs to that actor. All three actors are already defined in ``tests/e2e/actors.py``. - -- [ ] **Step 2: Run the e2e test** - -Run: `uv run pytest tests/e2e/test_actor_deregistration.py -v --tb=short` -Expected: PASS (3 tests) — requires Docker for the worker container. Each test uses a different actor to avoid cross-test interference. - -- [ ] **Step 3: Commit** - -```bash -git add tests/e2e/test_actor_deregistration.py -git commit -m "test: add e2e test for actor deregistration lifecycle" -``` - ---- - -### Task 11: Documentation updates - -**Files:** -- Modify: `docs/guides/actors.md` (add deregistration section) -- Modify: `docs/guides/cli.md` (add `deregister` command docs) -- Modify: `docs/guides/admin-ui.md` (add actors page docs) - -- [ ] **Step 1: Add deregistration section to actors.md** - -Add a new section at the end of the file (after "Full worked example"): - -```markdown -## Actor deregistration - -Actors registered by worker startup create `actor_config` rows that persist -until explicitly removed. For long-lived deployments this is intentional — -the row is the source of truth for capacity and routing. For ephemeral, -per-run deployments (e.g. `my-actor.`), each run leaves a row behind. - -### `client.actors.deregister()` - -```python -async with TaskQ(dsn=...) as tq: - result = await tq.actors.deregister("my-actor.run-123") - # force=False: refuses if non-terminal jobs or enabled schedules exist -``` - -**Safety checks (force=False):** -- Refuses if any non-terminal jobs (pending/scheduled/running) reference the - actor. -- Refuses if any enabled cron schedules reference the actor. - -**force=True:** -- Still refuses if **running** jobs exist (they are actively executing). -- Cancels pending/scheduled jobs (marks as `cancelled` with - `error_class='ActorDeregistered'`). -- Disables enabled cron schedules (sets `enabled=false`). - -**Terminal job history** is never deleted. The `jobs.actor` column is plain -text, not a foreign key — terminal rows remain queryable by actor name after -deregistration. - -**Queue cleanup** (`purge_queue=True`): deletes the `queues` row if no other -`actor_config` references the same queue. A shared queue is never purged. - -### Enqueue after deregistration - -After deregistration, any client can still `enqueue()` the dead actor name — -the `INSERT` succeeds (there is no foreign key from `jobs.actor` to -`actor_config.actor`), and the job sits in `pending` status forever. Because -the dispatch query inner-joins `actor_config`, the job will **never be -dispatched** and no background sweep will reap it (deregistration is an -explicit operator action, not a background GC — see Non-goal #2). - -**Operational discipline:** stop enqueuing to an actor *before* deregistering -it. Deregistration is best-effort against concurrent enqueue/dispatch; -callers must quiesce the actor first (stop enqueuing, disable cron -schedules, wait for running jobs to reach a terminal state). - -A follow-up issue may explore an opt-in "strict mode" that rejects enqueues -to actors with no `actor_config` row; this is explicitly out of scope for -this spec. - -### Idempotent deregistration - -A second `deregister` call on an already-deregistered actor raises -`ActorNotFoundError`. For cleanup-automation loops (e.g. iterating over -stage actors after a run completes), use the try/except idiom: - -```python -from taskq.exceptions import ActorNotFoundError - -try: - await tq.actors.deregister(actor_name, force=True, purge_queue=True) -except ActorNotFoundError: - pass # already deregistered — idempotent -``` - -### `taskq actor-config deregister` - -```bash -taskq actor-config deregister my-actor.run-123 -taskq actor-config deregister my-actor.run-123 --force --purge-queue -``` - -### Admin UI - -The `/admin/actors` page lists all `actor_config` rows with active job counts -and schedule counts. Each row has a deregister form with `force` and -`purge_queue` checkboxes (requires `TASKQ_ADMIN_ACTIONS_ENABLED=true`). -``` - -- [ ] **Step 2: Add CLI docs to cli.md** - -- [ ] **Step 3: Add admin UI docs to admin-ui.md** - -- [ ] **Step 4: Commit** - -```bash -git add docs/guides/actors.md docs/guides/cli.md docs/guides/admin-ui.md -git commit -m "docs: add actor deregistration documentation" -``` - ---- - -### Task 12: Run full verification - -- [ ] **Step 1: Run the full test suite** - -```bash -uv run pytest tests/test_actor_deregistration.py tests/test_cli_actor_deregister.py tests/test_actors_client.py tests/test_web_admin_actors.py tests/test_exceptions.py -v -``` - -- [ ] **Step 2: Run type checking** - -```bash -uv run pyright src/taskq/worker/actor_config_ops.py src/taskq/client/_actors.py src/taskq/cli.py src/taskq/exceptions.py -``` - -- [ ] **Step 3: Run linting** - -```bash -uv run ruff check src/taskq/worker/actor_config_ops.py src/taskq/client/_actors.py src/taskq/cli.py src/taskq/exceptions.py -``` - -Also add Ruff S608 per-file-ignore entries to `pyproject.toml` for the new -test files that use f-string SQL (schema name is validated against -`_IDENT_RE` before interpolation — same rationale as existing entries): - -```toml -[tool.ruff.lint.per-file-ignores] -# ... existing entries ... -"tests/test_actor_deregistration.py" = ["S608"] -"tests/test_web_admin_actors.py" = ["S608"] -"tests/test_cli_actor_deregister.py" = ["S608"] -``` - -Then run: - -```bash -uv run ruff check tests/test_actor_deregistration.py tests/test_web_admin_actors.py tests/test_cli_actor_deregister.py -``` - -- [ ] **Step 4: Run e2e tests (if Docker is available)** - -```bash -uv run pytest tests/e2e/test_actor_deregistration.py -v -``` - -- [ ] **Step 5: Commit any remaining fixes** - -```bash -git add -A -git commit -m "chore: verification fixes" -``` - ---- - -## Test Coverage Requirements - -| Layer | Test file | Coverage target | -|-------|-----------|-----------------| -| Exceptions | `tests/test_exceptions.py` | All 4 new exception classes: construction, attributes, inheritance | -| Ops function | `tests/test_actor_deregistration.py` | force=False: not found, clean delete, pending jobs refuse, running jobs refuse, enabled schedules refuse, disabled schedules OK, terminal jobs OK | -| Ops function | `tests/test_actor_deregistration.py` | force=True: cancel pending + disable schedules, refuse running, mixed (running + pending), terminal history preserved | -| Ops function | `tests/test_actor_deregistration.py` | purge_queue: orphan queue deleted, shared queue kept, default (no purge) keeps queue | -| ActorsClient | `tests/test_actors_client.py` | list/get/set_capacity/deregister delegation, pool acquire, schema forwarding | -| TaskQ.actors | `tests/test_taskq_client.py` | Property returns ActorsClient, raises before open() | -| CLI | `tests/test_cli_actor_deregister.py` | Default, --force, --purge-queue, not found, active jobs error, schedules error, output shape | -| Admin UI | `tests/test_web_admin_actors.py` | Page renders, deregister button, 403 when admin_actions disabled, successful deregister | -| E2E | `tests/e2e/test_actor_deregistration.py` | Full lifecycle: enqueue → complete → deregister → verify; refuse with running jobs (real assertions); force deregister after completion | - ---- - -## Backward Compatibility Analysis - -1. **No schema migration required.** The existing schema (tables, columns, constraints, indexes) is unchanged. Deregistration works on any schema that has the current migrations applied. - -2. **No new dependencies.** The implementation uses only existing modules (`asyncpg`, `structlog`, `typer`, `fastapi`, `jinja2`). - -3. **No breaking API changes.** All new code is additive: - - `ActorsClient` is a new class — no existing code references it. - - `TaskQ.actors` is a new property — no existing code calls it. - - `deregister_actor` is a new function in `actor_config_ops.py` — no existing code imports it. - - `taskq actor-config deregister` is a new CLI command — no existing scripts call it. - - The admin UI actors page is a new route — auto-discovered by `_discover_and_register`. - - New exceptions inherit from `ActorDeregistrationError` → `TaskQError` — no existing exception handling is affected. - -4. **`__all__` additions are additive.** New names added to `__all__` in `actor_config_ops.py`, `client/__init__.py`, and `taskq/__init__.py` do not remove any existing names. - -5. **Admin UI nav link is additive.** The new "Actors" link in `_base.html` does not alter existing navigation. - -6. **No changes to drift-check behavior.** `_STRUCTURAL_FIELDS`, `ActorConfigDriftList`, and `sync_actor_config` are unchanged. - -7. **Downstream consumer migration.** Consumers currently using hand-rolled SQL: - ```python - await conn.execute(f'DELETE FROM "{schema}".actor_config WHERE actor = $1', actor_name) - ``` - can replace with: - ```python - await client.actors.deregister(actor_name, force=True, purge_queue=True) - ``` - The `force=True` is needed because the hand-rolled SQL doesn't check for non-terminal jobs. Consumers should audit their cleanup paths and decide whether `force=True` or the safer default is appropriate. - ---- - -## Downstream Consumer Impact Analysis - -### warden (`~/src/warden`) - -**Current pattern:** Static, module-level actor names. Warden's five actors -(`transcription_backend_call`, `diarization_backend_call`, -`ocr_backend_call`, `provision_backend_call`, `autoscale_cron_tick`) are -declared with `@actor(name=...)` at module scope -(`~/src/warden/src/warden/jobs.py:382,532,728,869,1002`). No dynamic or -per-deployment actor naming exists. Tests use `InMemoryBackend` with -`register_actor_config` (`~/src/warden/tests/test_transcription_jobs.py:85-87`). - -**Impact:** **None today.** Warden's actors are fixed names that persist for -the lifetime of the deployment — they never accumulate `actor_config` rows -and do not need deregistration. If warden ever adopts ephemeral per-run -actors (e.g. for transient model deployments), the `deregister` API is -available, but no migration is needed now. - -### cennan (`~/src/cennan`) - -**Current pattern:** Fixed set of actors, one per pipeline stage -(`sync_binding`, `list_page`, `fetch_document`, `extract_document`, -`chunk_document`, `rechunk_binding`, `embed_batch`, `store_batch`, -`reproject_document_metadata` -— `~/src/cennan/src/cennan/pipeline/actors.py:154-241`; architecture doc: -"TaskQ actors, one per stage"). Binding identity travels in job payloads -(`binding:{id}`), not in actor names. Actors are registered at worker -startup and persist for the deployment lifetime. - -**Impact:** **None today.** Cennan's actors are fixed stage names, not -per-KB or per-binding — they do not accumulate rows and do not need -deregistration. If cennan ever adopts per-binding actor naming, the -`deregister` API is available, but no migration is needed now. - -### aacrtool (`~/src/aacrtool`) - -**Current pattern:** Per-review-run actors. The aacrtool spec rev3 plan -explicitly identifies this as gap #11: "Ephemeral actor_name accumulation -(8 rows/scan)" → "No actor deregistration/GC in HEAD" → "upstream -candidate: actor deregistration on worker shutdown / actor_config GC." - -**Impact:** **High — this is the consumer that explicitly identified the -gap.** After a scan completes: - -```python -# After a scan run is finalized: -for stage in ["s1-crawl", "s2-fetch", "s3-parse", "s4-analyze", "s5-embed", ...]: - await tq.actors.deregister(f"{stage}.{scan_slug}", force=True, purge_queue=True) -``` - -**Migration path:** aacrtool's scan finalization handler should deregister -all per-scan actors after the scan reaches a terminal state (`complete` or -`partial`). The `force=True` flag is needed because some jobs may still be -pending when the scan is finalized. `purge_queue=True` cleans up the -per-scan queue. For idempotent cleanup loops, wrap in `try/except -ActorNotFoundError: pass` (see Design Decision §10). - -**Design note from aacrtool spec:** "Do NOT delete rows from taskq schema -AACRTool-side." This spec provides the upstream API so aacrtool can stop -deferring the cleanup and use the official `deregister` path. - ---- - -## Key Design Decisions - -### 1. Pure application logic, no migration - -**Decision:** Deregistration is a transactional set of checks + DELETEs, not a schema change. - -**Rationale:** The existing schema has no FKs from `jobs.actor` or `cron_schedules.actor` to `actor_config.actor`. Adding FKs with `ON DELETE` actions would require a migration and risk lock contention on the hot `jobs` table (an `ADD FOREIGN KEY` scan blocks reads and writes). The application logic approach works on any already-migrated schema and is simpler to reason about. - -**Tradeoff:** If someone manually deletes an `actor_config` row (bypassing `deregister_actor`), pending jobs for that actor become stranded. The `deregister_actor` function's safety checks prevent this, but the schema doesn't enforce it. This is the same tradeoff the existing design already makes — the drift check is application-level, not schema-level. - -### 2. force=True still refuses running jobs - -**Decision:** `force=True` cancels pending/scheduled jobs and disables schedules, but still refuses if running jobs exist. - -**Rationale:** Running jobs are actively executing — their terminal-write path reads `actor_config.result_ttl` to compute `result_expires_at`. Deleting the row mid-execution loses the stored `result_ttl` override; the `COALESCE` in the terminal-write SQL falls back to the `@actor(...)` literal TTL (or preserves the existing `result_expires_at`), which is a silent semantic change. More importantly, the dispatch query inner-joins `actor_config`, so a running job that retries would be stranded. Refusing is the safe default; the operator can wait for running jobs to complete or cancel them first. - -### 3. Schedules are disabled, not deleted - -**Decision:** `force=True` sets `enabled=false` on cron schedules, not `DELETE`. - -**Rationale:** The schedule row carries configuration (cron expression, timezone, payload factory) that the operator may want to re-enable if the actor is re-registered. Deleting the schedule would lose this configuration. Disabling is reversible; deleting is not. - -### 4. Queue purge is opt-in - -**Decision:** `purge_queue` defaults to `False`. The caller must explicitly request it. - -**Rationale:** Queue rows are metadata (mode, max_concurrent) that might be shared between actors or manually managed by the operator. Deleting a queue row doesn't affect already-queued jobs (there's no FK), but it does remove the configuration. Making it opt-in prevents accidental loss of queue-level settings. - -### 5. Terminal job history is never deleted - -**Decision:** Terminal jobs (succeeded/failed/cancelled/crashed/abandoned) remain in the `jobs` table after deregistration. - -**Rationale:** `jobs.actor` is a plain `text` column, not a foreign key — terminal rows remain queryable by actor name. Deleting them would lose audit history and result data. The `DeregisterResult.terminal_jobs_remaining` count informs the caller how many such rows exist. The existing archive sweep will eventually move them to `jobs_archive` and then hard-delete them per the retention policy — that's the correct GC path, not deregistration. - -### 6. ActorsClient as a separate class, not methods on TaskQ - -**Decision:** Create `ActorsClient` as a separate class, exposed via `TaskQ.actors` property. - -**Rationale:** The issue explicitly asks for `client.actors.deregister(...)`. Separating actor operations from job operations keeps `TaskQ` focused as a job client and provides a clean namespace for future actor management operations. The pool-wrapping pattern mirrors how `JobsClient` wraps the `Backend`. - -### 7. Admin UI page is auto-discovered - -**Decision:** The actors page follows the existing `_discover_and_register` pattern in `_factory.py`. - -**Rationale:** No changes to `_factory.py` are needed — the `register()` function in `actors.py` is automatically discovered and called. This follows the "decompose by composition, not accumulation" principle documented in the codebase. - -### 8. Accepted TOCTOU race — deregistration is best-effort against concurrent enqueue/dispatch - -**Decision:** Deregistration does NOT serialize against concurrent enqueue, cron-fire, or dispatch. Callers must quiesce the actor first. - -**Rationale:** `deregister_actor` runs in a READ COMMITTED transaction. The safety checks and DELETE are separate statements; a job enqueued by a concurrent transaction that commits after the check but before the DELETE is invisible to the check and will be stranded (the `jobs` INSERT has no FK to `actor_config`, and dispatch inner-joins `actor_config` so the job is never dispatched). Three remediation options were evaluated: - -- **(a) Document accepted semantics** — "callers must quiesce first." This is the same operational discipline as any shutdown sequence. **Chosen.** -- **(b) `pg_advisory_xact_lock(hashtext(actor))`** in `deregister_actor` and on the enqueue/dispatch paths — would serialize the hot enqueue path against a rare administrative operation. The cost is unjustified for the problem size. -- **(c) A narrow reaper** (leader sweep cancels pending jobs whose actor has no `actor_config` row) — conflicts with Non-goal #2 (no GC sweep), would require amending the non-goal and adding leader-loop complexity. - -The accepted-semantics approach is consistent with Non-goal #2 and the existing design philosophy: deregistration is an explicit operator action, not a background automation. The operator's runbook is: stop enqueuing → wait for terminal → deregister. - -### 9. Enqueue-after-deregistration is unguarded - -**Decision:** After deregistration, any client can still `enqueue()` the dead actor name. The INSERT succeeds (no FK), the job sits `pending` forever, invisible to dispatch. - -**Rationale:** Enqueue-side rejection of unknown actors would require a check against `actor_config` on every enqueue — a hot-path cost for a rare operational mistake. The hazard is documented in the API surface, CLI output, and `docs/guides/actors.md`. A follow-up issue may explore an opt-in "strict mode" that rejects enqueues to actors with no `actor_config` row; this is explicitly out of scope for this spec. - -### 10. Idempotency — second deregister raises ActorNotFoundError - -**Decision:** A second `deregister` call on an already-deregistered actor raises `ActorNotFoundError`. There is no `if_missing` parameter. - -**Rationale:** Adding `if_missing: Literal["raise", "ok"]` would complicate the API for a marginal convenience. Cleanup-automation callers (e.g. aacrtool loops) should use the try/except idiom: - -```python -from taskq.exceptions import ActorNotFoundError - -try: - await tq.actors.deregister(actor_name, force=True, purge_queue=True) -except ActorNotFoundError: - pass # already deregistered — idempotent -``` - -This is documented in the guide. - ---- - -## Revision log - -### 2026-07-29 — Post-review revision (verdict: NEEDS REWORK → resolved) - -Revised against `.review/spec-review.md` (1 Critical / 3 High / 4 Medium / -9 Low). The review confirmed the architecture and semantics for issue #56 -are sound; the rework was execution fidelity. Standing directive applied: -TaskQ 1.0.0 is a breaking release — no gratuitous churn, but no hacks, -legacy paths, shims, or dual-path compat either; documented downstream -needs are the contract, current downstream usage is not a constraint. - -Resolved: - -- **C1 (Critical):** `_DEREGISTER_CHECK_ACTIVE_JOBS_SQL` now casts to the - real enum array type (`$2::"{schema}".job_status[]`, matching the - `_sql_templates.py:451` precedent) instead of `$2::text[]`, which raised - PG 42883 against the real `job_status` enum column. The hand-rolled - minimal test schema (text `status`) was replaced with real migrations - (`taskq.migrate.apply_pending`), so this class of type drift is - structurally impossible in tests. -- **H1:** Resolved by the same `apply_pending` fixture change — the real - migrated `jobs` table carries `finished_at` / `error_class` / - `error_message`, so Task 3's force=True cancel SQL executes against the - same columns it will see in production. `_insert_job` supplies the - NOT-NULL-without-default columns (`max_attempts`, `retry_kind`) and - casts the status parameter to `job_status`. -- **H2:** TOCTOU race now explicitly acknowledged with chosen option (a) — - documented best-effort semantics ("quiesce the actor first") in the ops - docstring warning, Design Decision §8 (options b/c evaluated and - rejected with cost rationale), the CLI success warning, and the docs - guide. No serialization added: advisory locks would tax the hot enqueue - path for a rare admin operation; a reaper conflicts with Non-goal #2. -- **H3:** e2e plan rewritten — uses the real actors `quick_result`, - `long_running_job`, `short_lived_job` from `tests/e2e/actors.py` - (verified present and registered in `worker_entry.py`), one actor per - test to respect the module-scoped worker's bootstrap-only - `sync_actor_config`, and real refusal assertions. Additionally fixed a - residual the review did not catch: the refusal test's cleanup used - `handle.wait()` after cancel, which raises `JobFailed` on the - `cancelled` terminal status; it now polls with - `wait_for_handle_status(handle, "cancelled", timeout=60)` and documents - why the cancel takes the full ~30 s (the actor never calls - `ctx.check_cancelled()`, so cancellation lands at completion via the - consumer's post-run `cancel_phase` check). -- **M1:** Task 6 test uses `module_pg_schema.pg_dsn` (not `str()` of the - NamedTuple); unused `pg_conn` param removed. -- **M2:** Task 8 fully specified — tests now self-contained (per-test - `admin_pool` on the module schema, `_make_admin_app` helper mirroring - `test_web_admin_integration.py`, `httpx.AsyncClient` + `ASGITransport` - instead of `TestClient`, GET-first synchronizer-token CSRF flow instead - of a hardcoded token that `validate_csrf` would reject; the 403 test - passes valid CSRF so the gate — not CSRF — is what fails). The POST - route is exact: `request.form()` after `validate_csrf` (Starlette caches - the parsed body), `TaskQSettings`-typed settings dependency. -- **M3:** Downstream section rewritten per the directive — aacrtool quote - re-verified verbatim; warden/cennan false claims removed and replaced - with the verified reality (fixed-name actors, no deregistration need - today, API available if they adopt per-run naming). Warden actor names - and cennan actor list/line-range corrected from the repos. -- **M4:** Enqueue-after-deregistration semantics documented (Design - Decision §9, docs guide "Enqueue after deregistration", CLI warning, - ops docstring); strict-mode enqueue rejection named as explicit - follow-up, out of scope. -- **L1–L9:** broken duplicate `_insert_job` removed; Ruff S608 - per-file-ignore entries added to Task 12; Task 5 uses `monkeypatch`; - CLI catches `ValueError` alongside `ActorDeregistrationError`; template - uses `urlencode` (with the single-segment path-converter limitation - noted); idempotency try/except idiom documented (§10); docs anchor - corrected to "Full worked example"; Task 2 comment renumbered (purge - lands in Task 3); scope expansion beyond issue #56 flagged at the top - for the issue author. - -Design changes: none to the core semantics (refusal rules, force=True -behavior, disable-not-delete schedules, opt-in queue purge, terminal -history retention are unchanged and were judged sound). No breaking -changes introduced by this feature — all surface is additive, consistent -with the directive's "no gratuitous churn" clause. Intentionally -deferred: enqueue-side rejection of unknown actors (named follow-up; -hot-path cost), `if_missing` idempotency parameter (YAGNI — the -try/except idiom covers the cleanup-loop case). From f0d6320ba8515b93063b46b119560a9b9851725a Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 19:20:50 -0700 Subject: [PATCH 16/20] =?UTF-8?q?docs:=20document=20taskq.worker.actor=5Fc?= =?UTF-8?q?onfig=20=E2=86=92=20taskq.actor=5Fconfig=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 +++++++ docs/guides/upgrading.md | 58 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60d4b1f0..97dfba30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,6 +203,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `stranded_jobs_query_failed`, and the `failed_details` payload of `sub_enqueue_flush_failed`). Log pipelines querying `fields.message` on these two events must switch to `error_message`. +- **Breaking: `taskq.worker.actor_config` moved to `taskq.actor_config`.** + The `ActorConfig` dataclass (released in v0.2.0–v0.2.2 at + `taskq.worker.actor_config`) has moved to the top-level + `taskq.actor_config` module. It is shared by the client, CLI, and admin + UI, not worker-internal. The old import path raises `ImportError`. See + [docs/guides/upgrading.md](docs/guides/upgrading.md) for the full + migration mapping. The companion `actor_config_ops` module (listing, + inspecting, tuning, and deregistering actors) has likewise moved from + `taskq.worker.actor_config_ops` to `taskq.actor_config_ops`; it was + never released under the `worker.*` path. ### Security diff --git a/docs/guides/upgrading.md b/docs/guides/upgrading.md index ec4d8b94..5672b99b 100644 --- a/docs/guides/upgrading.md +++ b/docs/guides/upgrading.md @@ -61,3 +61,61 @@ This is a deliberate tradeoff, not a missing feature: - Pin `taskq-py` back to the previous version until the issue is resolved, since the previous version's code may not be compatible with the new schema. + +--- + +## Breaking import path changes + +### `taskq.worker.actor_config` → `taskq.actor_config` + +> **Released in v0.2.0–v0.2.2, moved in unreleased.** This is a breaking +> change for anyone importing `ActorConfig` from the old path. + +The `ActorConfig` dataclass has moved from `taskq.worker.actor_config` to +the top-level `taskq.actor_config` module. It is a shared carrier used by +the client, CLI, and admin UI — not worker-internal. + +**Old (v0.2.0–v0.2.2):** + +```python +from taskq.worker.actor_config import ActorConfig +``` + +**New:** + +```python +from taskq.actor_config import ActorConfig +``` + +The old import path raises `ImportError` — update your imports. + +### `taskq.worker.actor_config_ops` → `taskq.actor_config_ops` + +The `actor_config_ops` module — listing, inspecting, tuning, and +deregistering actors on a live deployment — has moved from +`taskq.worker.actor_config_ops` to the top-level +`taskq.actor_config_ops`. This module was introduced on the unreleased +branch; if you were importing it from the `worker.*` path during +development, update to the top-level path. + +**Old (unreleased branch only):** + +```python +from taskq.worker.actor_config_ops import ( + list_actor_configs, + get_actor_config, + set_actor_config_capacity, + deregister_actor, +) +``` + +**New:** + +```python +from taskq.actor_config_ops import ( + list_actor_configs, + get_actor_config, + set_actor_config_capacity, + deregister_actor, +) +``` From e609b1613ddc664f0d2903ec8767b5b670c8ac8c Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 19:23:07 -0700 Subject: [PATCH 17/20] fix: shared actor summaries, export ActorConfigRow, CLI schema test, e2e purge assertion, doc gaps --- docs/api-reference/client.md | 4 +- docs/guides/actors.md | 5 ++ docs/guides/admin-ui.md | 13 +++++ docs/guides/jobs-clients.md | 3 + src/taskq/__init__.py | 3 +- src/taskq/actor_config_ops.py | 81 +++++++++++++++++++++++--- src/taskq/web/admin/actors.py | 20 +------ tests/e2e/test_actor_deregistration.py | 3 + tests/test_cli_actor_deregister.py | 8 +++ tests/test_taskq_actors_property.py | 13 ++++- 10 files changed, 123 insertions(+), 30 deletions(-) diff --git a/docs/api-reference/client.md b/docs/api-reference/client.md index 33d66b38..c9214de5 100644 --- a/docs/api-reference/client.md +++ b/docs/api-reference/client.md @@ -1,9 +1,11 @@ # Client -`TaskQ`, `JobsClient`, `JobHandle`, and `CancelResult`. +`TaskQ`, `JobsClient`, `JobHandle`, `CancelResult`, and `ActorsClient`. ::: taskq.client._taskq ::: taskq.client._jobs ::: taskq.client._handle + +::: taskq.client._actors.ActorsClient diff --git a/docs/guides/actors.md b/docs/guides/actors.md index 12b774aa..7edf7f08 100644 --- a/docs/guides/actors.md +++ b/docs/guides/actors.md @@ -993,6 +993,11 @@ dispatched** and no background sweep will reap it. it. Deregistration is best-effort against concurrent enqueue/dispatch; callers must quiesce the actor first. +**Stop workers first:** A concurrent worker startup (`sync_actor_config`) +can re-create the `actor_config` row after deregistration, with capacity +fields reset to `@actor(...)` defaults. Stop all workers for the actor +before calling `deregister`. + ### Idempotent deregistration A second `deregister` call on an already-deregistered actor raises diff --git a/docs/guides/admin-ui.md b/docs/guides/admin-ui.md index 377c2fb7..e3f34e21 100644 --- a/docs/guides/admin-ui.md +++ b/docs/guides/admin-ui.md @@ -355,6 +355,19 @@ Each row has a **Deregister** button with `force` and `purge queue` checkboxes. Deregistration requires `TASKQ_ADMIN_ACTIONS_ENABLED=true`. The form is CSRF-protected via the synchronizer-token pattern. +### POST /admin/actors/{actor}/deregister + +Deregisters an actor. Form fields: +- `csrf_token` — CSRF synchronizer token (set by GET) +- `force` — checkbox; cancels pending/scheduled jobs and disables schedules +- `purge_queue` — checkbox; deletes the orphaned queues row + +Response codes: +- `303` — success, redirects to `/actors?notice=deregistered+{actor}` +- `403` — admin actions disabled or CSRF validation failed +- `404` — actor not found (no `actor_config` row) +- `409` — actor has active jobs or enabled schedules (force=False) + ### `GET /admin/sse/{topic}` SSE (Server-Sent Events) endpoint. Accepts any `topic` string. On connect it emits an initial `event: status` frame with `{"status": "awaiting_progress_backend"}`, then sends `: keepalive` comments every 30 seconds to prevent connection timeout. See [Real-time vs polling mode](#real-time-vs-polling-mode) below. diff --git a/docs/guides/jobs-clients.md b/docs/guides/jobs-clients.md index 30ddf59e..94417df0 100644 --- a/docs/guides/jobs-clients.md +++ b/docs/guides/jobs-clients.md @@ -4,6 +4,9 @@ `Backend` and adds typed payload serialisation, `JobHandle[R]` construction, and `CancelResult` building. +**Actor management:** `tq.actors` provides `list()`, `get()`, `set_capacity()`, +and `deregister()` — see [Actor deregistration](actors.md#actor-deregistration). + --- ## Job lifecycle diff --git a/src/taskq/__init__.py b/src/taskq/__init__.py index 83cfe948..b73613fb 100644 --- a/src/taskq/__init__.py +++ b/src/taskq/__init__.py @@ -11,7 +11,7 @@ import importlib.metadata from taskq.actor import ActorFn, ActorFnWithCtx, ActorHandler, ActorRef, actor -from taskq.actor_config_ops import DeregisterResult +from taskq.actor_config_ops import ActorConfigRow, DeregisterResult from taskq.auth import ( PgCredential, PgCredentialProvider, @@ -92,6 +92,7 @@ __all__ = [ "ActorConfigDriftError", "ActorConfigDriftList", + "ActorConfigRow", "ActorDeregistrationError", "ActorFn", "ActorFnWithCtx", diff --git a/src/taskq/actor_config_ops.py b/src/taskq/actor_config_ops.py index 812a4d5a..f91fbf9f 100644 --- a/src/taskq/actor_config_ops.py +++ b/src/taskq/actor_config_ops.py @@ -26,14 +26,17 @@ override to the code default. """ +from __future__ import annotations + import math from dataclasses import dataclass -from typing import Final - -import asyncpg +from typing import TYPE_CHECKING, Final from taskq._json import loads from taskq.backend._protocol import ConnLike +from taskq.backend._records import jsonb_param +from taskq.backend._sql import INSERT_EVENT_SQL +from taskq.backend.statemachine import ACTIVE_STATUSES, TERMINAL_STATUSES from taskq.constants import ( _IDENT_RE, # pyright: ignore[reportPrivateUsage] # Why: reusing the canonical identifier regex rather than redefining it ) @@ -43,6 +46,9 @@ ActorNotFoundError, ) +if TYPE_CHECKING: + import asyncpg + __all__ = [ "UNSET", "ActorConfigRow", @@ -51,6 +57,7 @@ "deregister_actor", "get_actor_config", "list_actor_configs", + "list_actor_summaries", "set_actor_config_capacity", ] @@ -141,6 +148,30 @@ async def list_actor_configs(conn: ConnLike, *, schema: str = "taskq") -> list[A return [_row_to_dataclass(row) for row in rows] +_ACTOR_SUMMARIES_SQL = """ +SELECT ac.actor, ac.max_concurrent, ac.max_pending, ac.queue, + ac.updated_at::text AS updated_at, + (SELECT count(*) FROM "{schema}".jobs j + WHERE j.actor = ac.actor + AND j.status = ANY($1::"{schema}".job_status[])) AS active_job_count, + (SELECT count(*) FROM "{schema}".cron_schedules cs + WHERE cs.actor = ac.actor AND cs.enabled = true) AS enabled_schedule_count + FROM "{schema}".actor_config ac + ORDER BY ac.actor +""".strip() + + +async def list_actor_summaries(conn: ConnLike, *, schema: str = "taskq") -> list[dict[str, object]]: + """Return actor_config rows with active job and schedule counts for display.""" + if not _IDENT_RE.match(schema): + raise ValueError(f"invalid schema identifier: {schema!r}") + rows = await conn.fetch( + _ACTOR_SUMMARIES_SQL.format(schema=schema), + list(ACTIVE_STATUSES), + ) + return [dict(r) for r in rows] + + async def get_actor_config( conn: ConnLike, actor: str, *, schema: str = "taskq" ) -> ActorConfigRow | None: @@ -239,9 +270,12 @@ async def set_actor_config_capacity( # ── deregister_actor ──────────────────────────────────────────────────── -_NON_TERMINAL_STATUSES: tuple[str, ...] = ("pending", "scheduled", "running") _RUNNING_STATUS: str = "running" +_DEREGISTER_CHECK_ACTOR_EXISTS_SQL = """ +SELECT 1 FROM "{schema}".actor_config WHERE actor = $1 +""".strip() + _DEREGISTER_CHECK_ACTIVE_JOBS_SQL = """ SELECT status, count(*) AS cnt FROM "{schema}".jobs @@ -262,6 +296,7 @@ async def set_actor_config_capacity( error_message = 'Job cancelled by actor deregistration (force=True)' WHERE actor = $1 AND status IN ('pending', 'scheduled') +RETURNING id """.strip() _DEREGISTER_DISABLE_SCHEDULES_SQL = """ @@ -286,7 +321,7 @@ async def set_actor_config_capacity( _DEREGISTER_COUNT_TERMINAL_SQL = """ SELECT count(*) FROM "{schema}".jobs - WHERE actor = $1 AND status NOT IN ('pending', 'scheduled', 'running') + WHERE actor = $1 AND status = ANY($2::"{schema}".job_status[]) """.strip() @@ -340,11 +375,22 @@ async def deregister_actor( raise ValueError(f"invalid schema identifier: {schema!r}") async with conn.transaction(): + # Check actor_config row exists first — ActorNotFoundError takes + # precedence over all other checks so callers don't get misleading + # errors for actors that are already deregistered but have stranded + # jobs. + exists = await conn.fetchval( + _DEREGISTER_CHECK_ACTOR_EXISTS_SQL.format(schema=schema), + actor, + ) + if not exists: + raise ActorNotFoundError(actor) + if not force: active_rows = await conn.fetch( _DEREGISTER_CHECK_ACTIVE_JOBS_SQL.format(schema=schema), actor, - list(_NON_TERMINAL_STATUSES), + list(ACTIVE_STATUSES), ) if active_rows: status_counts = {row["status"]: row["cnt"] for row in active_rows} @@ -370,13 +416,26 @@ async def deregister_actor( if running_rows: status_counts = {row["status"]: row["cnt"] for row in running_rows} active_count = sum(status_counts.values()) - raise ActorHasActiveJobsError(actor, active_count, status_counts) + raise ActorHasActiveJobsError(actor, active_count, status_counts, force=True) - cancel_result = await conn.execute( + cancelled_rows = await conn.fetch( _DEREGISTER_CANCEL_PENDING_SQL.format(schema=schema), actor, ) - jobs_cancelled = int(cancel_result.split()[-1]) if cancel_result else 0 + jobs_cancelled = len(cancelled_rows) + if cancelled_rows: + detail = jsonb_param( + { + "from_state": "pending_or_scheduled", + "to_state": "cancelled", + "reason": "actor_deregistered", + } + ) + event_sql = INSERT_EVENT_SQL.format(schema=schema) + await conn.executemany( + event_sql, + [(row["id"], "state_change", detail) for row in cancelled_rows], + ) disable_result = await conn.execute( _DEREGISTER_DISABLE_SCHEDULES_SQL.format(schema=schema), @@ -389,6 +448,9 @@ async def deregister_actor( actor, ) if not deleted_rows: + # Handles the concurrent-delete race: under READ COMMITTED, a + # concurrent transaction could delete the row between our + # preflight check and this DELETE. raise ActorNotFoundError(actor) queue_name = deleted_rows[0]["queue"] @@ -396,6 +458,7 @@ async def deregister_actor( terminal_count = await conn.fetchval( _DEREGISTER_COUNT_TERMINAL_SQL.format(schema=schema), actor, + list(TERMINAL_STATUSES), ) queue_purged = False diff --git a/src/taskq/web/admin/actors.py b/src/taskq/web/admin/actors.py index 4048256e..6e3cef4d 100644 --- a/src/taskq/web/admin/actors.py +++ b/src/taskq/web/admin/actors.py @@ -7,7 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from jinja2 import Environment -from taskq.actor_config_ops import deregister_actor +from taskq.actor_config_ops import deregister_actor, list_actor_summaries from taskq.exceptions import ActorDeregistrationError, ActorNotFoundError from taskq.settings import TaskQSettings from taskq.web.admin._factory import ( @@ -21,18 +21,6 @@ validate_csrf, ) -_ACTORS_SQL = """ -SELECT ac.actor, ac.max_concurrent, ac.max_pending, ac.queue, - ac.result_ttl, ac.metadata::text AS metadata, ac.updated_at::text AS updated_at, - (SELECT count(*) FROM "{schema}".jobs j - WHERE j.actor = ac.actor - AND j.status IN ('pending', 'scheduled', 'running')) AS active_job_count, - (SELECT count(*) FROM "{schema}".cron_schedules cs - WHERE cs.actor = ac.actor AND cs.enabled = true) AS enabled_schedule_count - FROM "{schema}".actor_config ac - ORDER BY ac.actor -""".strip() - def register(router: APIRouter) -> None: """Attach actors overview and deregister routes to *router*.""" @@ -46,11 +34,9 @@ async def actors_overview( # pyright: ignore[reportUnusedFunction] # Why: regi csrf_token: str = Depends(get_csrf_token), notice: str | None = None, ) -> HTMLResponse: - actors_sql = _ACTORS_SQL.format(schema=schema) - rows: list[asyncpg.Record] = [] + actors: list[dict[str, object]] = [] async with pool.acquire() as conn: - rows = await conn.fetch(actors_sql) - actors = [dict(r) for r in rows] + actors = await list_actor_summaries(conn, schema=schema) realtime_mode, mode_label = realtime_ctx html = tmpl.get_template("actors.html").render( actors=actors, diff --git a/tests/e2e/test_actor_deregistration.py b/tests/e2e/test_actor_deregistration.py index 3c03a477..fe9994f9 100644 --- a/tests/e2e/test_actor_deregistration.py +++ b/tests/e2e/test_actor_deregistration.py @@ -156,6 +156,9 @@ async def test_deregister_force_with_purge_queue_after_completion( assert result.actor_config_deleted is True assert result.jobs_cancelled == 0 assert result.terminal_jobs_remaining >= 1 + # All e2e actors share queue="e2e" — purge_queue=True is a safe no-op + # because the orphan guard correctly refuses to delete a shared queue. + assert result.queue_purged is False ac_count = await e2e_pg_pool.fetchval( f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', diff --git a/tests/test_cli_actor_deregister.py b/tests/test_cli_actor_deregister.py index 5d417fca..a5646421 100644 --- a/tests/test_cli_actor_deregister.py +++ b/tests/test_cli_actor_deregister.py @@ -142,3 +142,11 @@ def test_deregister_double_deregister_exit_one(monkeypatch: pytest.MonkeyPatch) result = runner.invoke(app, ["actor-config", "deregister", "already-gone"]) assert result.exit_code == 1 assert "no stored actor_config row" in result.stderr + + +def test_deregister_forwards_configured_schema(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TASKQ_SCHEMA_NAME", "my_test_schema") + captured = _patch_deregister(monkeypatch) + result = runner.invoke(app, ["actor-config", "deregister", "my-actor"]) + assert result.exit_code == 0, f"stderr: {result.stderr}" + assert captured["kwargs"]["schema"] == "my_test_schema" diff --git a/tests/test_taskq_actors_property.py b/tests/test_taskq_actors_property.py index 73a6e693..73dc73e0 100644 --- a/tests/test_taskq_actors_property.py +++ b/tests/test_taskq_actors_property.py @@ -9,14 +9,23 @@ def test_actors_client_importable_from_taskq() -> None: from taskq import ActorsClient + from taskq.client._actors import ActorsClient as ClientActorsClient - assert ActorsClient is not None + assert ActorsClient is ClientActorsClient def test_deregister_result_importable_from_taskq() -> None: from taskq import DeregisterResult + from taskq.actor_config_ops import DeregisterResult as OpsDeregisterResult - assert DeregisterResult is not None + assert DeregisterResult is OpsDeregisterResult + + +def test_actor_config_row_importable_from_taskq() -> None: + from taskq import ActorConfigRow + from taskq.actor_config_ops import ActorConfigRow as OpsActorConfigRow + + assert ActorConfigRow is OpsActorConfigRow def test_deregistration_exceptions_importable_from_taskq() -> None: From 39a576704537092ca4df23f19836cc2a1ace4837 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 19:25:41 -0700 Subject: [PATCH 18/20] fix: concurrent test with real interleaving, audit trail assertions, fixture convergence, template/doc fixes --- src/taskq/actor_config_ops.py | 14 +- src/taskq/client/__init__.py | 7 +- src/taskq/web/templates/actors.html | 2 +- tests/test_actor_deregistration.py | 380 +++++++++++++++------------- 4 files changed, 224 insertions(+), 179 deletions(-) diff --git a/src/taskq/actor_config_ops.py b/src/taskq/actor_config_ops.py index f91fbf9f..4bb0523b 100644 --- a/src/taskq/actor_config_ops.py +++ b/src/taskq/actor_config_ops.py @@ -24,6 +24,12 @@ and ``--clear-result-ttl`` write NULL, which their enforcement paths read as *fall back to the ``@actor(...)`` literal* — clearing reverts an override to the code default. + +This module also provides :func:`deregister_actor` — the transactional +removal of an ``actor_config`` row with safety checks for active jobs +and enabled schedules, optional forced cancellation of pending/scheduled +jobs, optional disabling of cron schedules, and optional purging of +orphaned queues. See :class:`DeregisterResult` for the return contract. """ from __future__ import annotations @@ -87,7 +93,13 @@ class ActorConfigRow: @dataclass(frozen=True, slots=True) class DeregisterResult: - """Outcome of a ``deregister_actor`` call.""" + """Outcome of a ``deregister_actor`` call. + + ``actor_config_deleted`` is always ``True`` — if the row is not found, + ``deregister_actor`` raises :class:`ActorNotFoundError` instead of + returning a result with ``False``. The field is retained for API + contract clarity and consumer assertions. + """ actor: str queue: str diff --git a/src/taskq/client/__init__.py b/src/taskq/client/__init__.py index bfa483d8..99d8421c 100644 --- a/src/taskq/client/__init__.py +++ b/src/taskq/client/__init__.py @@ -1,8 +1,9 @@ """TaskQ client — the public surface for enqueuing, querying, and -cancelling jobs. +cancelling jobs, plus actor management (listing, capacity tuning, +deregistration). -Re-exports :class:`JobsClient`, :class:`JobHandle`, :class:`TaskQ`, and -:class:`JobEvent`. +Re-exports :class:`JobsClient`, :class:`ActorsClient`, :class:`JobHandle`, +:class:`TaskQ`, and :class:`JobEvent`. Import from ``taskq.client`` (or from ``taskq`` which re-exports these names). diff --git a/src/taskq/web/templates/actors.html b/src/taskq/web/templates/actors.html index 20ea66c5..280c6493 100644 --- a/src/taskq/web/templates/actors.html +++ b/src/taskq/web/templates/actors.html @@ -38,7 +38,7 @@

Actors

{{ a.actor }} {{ a.queue }} - {{ a.max_concurrent or '∞' }} + {{ a.max_concurrent if a.max_concurrent is not none else '∞' }} {{ a.max_pending or '—' }} None: - """Drop and re-create the full TaskQ schema via ``apply_pending``.""" - from taskq.migrate import apply_pending - - await conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') - await apply_pending(conn, schema=schema) - - async def _insert_job( conn: asyncpg.Connection, schema: str, @@ -121,43 +113,42 @@ async def _job_status(conn: asyncpg.Connection, schema: str, job_id: str) -> str ) -async def _schedule_enabled(conn: asyncpg.Connection, schema: str, schedule_id: str) -> bool: - """Return the ``enabled`` value of a cron schedule row.""" - return bool( - await conn.fetchval( - f'SELECT enabled FROM "{schema}".cron_schedules WHERE id = $1', # noqa: S608 - schedule_id, - ) +async def _schedule_state(conn: asyncpg.Connection, schema: str, schedule_id: str) -> str | None: + """Return 'enabled', 'disabled', or None (deleted).""" + row = await conn.fetchrow( + f'SELECT enabled FROM "{schema}".cron_schedules WHERE id = $1', # noqa: S608 + schedule_id, ) - - -def _make_schema() -> str: - return f"tqd_{new_base62()}".lower() + if row is None: + return None + return "enabled" if row["enabled"] else "disabled" # ── force=False path ──────────────────────────────────────────────────── -async def test_deregister_raises_not_found_for_unknown_actor(pg_conn: asyncpg.Connection) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) +async def test_deregister_raises_not_found_for_unknown_actor( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + schema = module_pg_schema.schema_name with pytest.raises(ActorNotFoundError, match="no stored actor_config row"): - await deregister_actor(pg_conn, "ghost", schema=schema) + await deregister_actor(clean_pg_conn, "ghost", schema=schema) async def test_deregister_succeeds_when_no_jobs_or_schedules( - pg_conn: asyncpg.Connection, + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, ) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="clean_actor", max_concurrent=5, queue="default")], schema=schema, ) - result = await deregister_actor(pg_conn, "clean_actor", schema=schema) + result = await deregister_actor(clean_pg_conn, "clean_actor", schema=schema) assert isinstance(result, DeregisterResult) assert result.actor == "clean_actor" @@ -168,117 +159,127 @@ async def test_deregister_succeeds_when_no_jobs_or_schedules( assert result.terminal_jobs_remaining == 0 assert result.queue_purged is False - assert await get_actor_config(pg_conn, "clean_actor", schema=schema) is None + assert await get_actor_config(clean_pg_conn, "clean_actor", schema=schema) is None -async def test_deregister_refuses_with_pending_jobs(pg_conn: asyncpg.Connection) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) +async def test_deregister_refuses_with_pending_jobs( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="busy_actor", max_concurrent=5, queue="default")], schema=schema, ) - await _insert_job(pg_conn, schema, actor="busy_actor", status="pending") + await _insert_job(clean_pg_conn, schema, actor="busy_actor", status="pending") with pytest.raises(ActorHasActiveJobsError) as exc_info: - await deregister_actor(pg_conn, "busy_actor", schema=schema) + await deregister_actor(clean_pg_conn, "busy_actor", schema=schema) assert exc_info.value.active_count == 1 assert exc_info.value.status_counts == {"pending": 1} # Row must still exist — the transaction rolled back. - assert await get_actor_config(pg_conn, "busy_actor", schema=schema) is not None + assert await get_actor_config(clean_pg_conn, "busy_actor", schema=schema) is not None -async def test_deregister_refuses_with_running_jobs(pg_conn: asyncpg.Connection) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) +async def test_deregister_refuses_with_running_jobs( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="run_actor", max_concurrent=5, queue="default")], schema=schema, ) - await _insert_job(pg_conn, schema, actor="run_actor", status="running") + await _insert_job(clean_pg_conn, schema, actor="run_actor", status="running") with pytest.raises(ActorHasActiveJobsError) as exc_info: - await deregister_actor(pg_conn, "run_actor", schema=schema) + await deregister_actor(clean_pg_conn, "run_actor", schema=schema) assert exc_info.value.active_count == 1 assert exc_info.value.status_counts == {"running": 1} - assert await get_actor_config(pg_conn, "run_actor", schema=schema) is not None + assert await get_actor_config(clean_pg_conn, "run_actor", schema=schema) is not None -async def test_deregister_refuses_with_enabled_schedules(pg_conn: asyncpg.Connection) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) +async def test_deregister_refuses_with_enabled_schedules( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="sched_actor", max_concurrent=5, queue="default")], schema=schema, ) - schedule_id = await _insert_schedule(pg_conn, schema, actor="sched_actor", enabled=True) + schedule_id = await _insert_schedule(clean_pg_conn, schema, actor="sched_actor", enabled=True) with pytest.raises(ActorHasEnabledSchedulesError) as exc_info: - await deregister_actor(pg_conn, "sched_actor", schema=schema) + await deregister_actor(clean_pg_conn, "sched_actor", schema=schema) assert exc_info.value.schedule_ids == [schedule_id] - assert await get_actor_config(pg_conn, "sched_actor", schema=schema) is not None + assert await get_actor_config(clean_pg_conn, "sched_actor", schema=schema) is not None -async def test_deregister_succeeds_with_disabled_schedules(pg_conn: asyncpg.Connection) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) +async def test_deregister_succeeds_with_disabled_schedules( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="dis_actor", max_concurrent=5, queue="default")], schema=schema, ) - await _insert_schedule(pg_conn, schema, actor="dis_actor", enabled=False) + await _insert_schedule(clean_pg_conn, schema, actor="dis_actor", enabled=False) - result = await deregister_actor(pg_conn, "dis_actor", schema=schema) + result = await deregister_actor(clean_pg_conn, "dis_actor", schema=schema) assert result.actor_config_deleted is True assert result.schedules_disabled == 0 - assert await get_actor_config(pg_conn, "dis_actor", schema=schema) is None + assert await get_actor_config(clean_pg_conn, "dis_actor", schema=schema) is None -async def test_deregister_succeeds_with_terminal_jobs(pg_conn: asyncpg.Connection) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) +async def test_deregister_succeeds_with_terminal_jobs( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="term_actor", max_concurrent=5, queue="default")], schema=schema, ) - await _insert_job(pg_conn, schema, actor="term_actor", status="succeeded") - await _insert_job(pg_conn, schema, actor="term_actor", status="failed") + await _insert_job(clean_pg_conn, schema, actor="term_actor", status="succeeded") + await _insert_job(clean_pg_conn, schema, actor="term_actor", status="failed") - result = await deregister_actor(pg_conn, "term_actor", schema=schema) + result = await deregister_actor(clean_pg_conn, "term_actor", schema=schema) assert result.actor_config_deleted is True assert result.terminal_jobs_remaining == 2 - assert await get_actor_config(pg_conn, "term_actor", schema=schema) is None + assert await get_actor_config(clean_pg_conn, "term_actor", schema=schema) is None # ── force=True path ───────────────────────────────────────────────────── async def test_deregister_force_cancels_pending_and_disables_schedules( - pg_conn: asyncpg.Connection, + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, ) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="force_actor", max_concurrent=5, queue="default")], schema=schema, ) - pending_id = await _insert_job(pg_conn, schema, actor="force_actor", status="pending") - scheduled_id = await _insert_job(pg_conn, schema, actor="force_actor", status="scheduled") - schedule_id = await _insert_schedule(pg_conn, schema, actor="force_actor", enabled=True) + pending_id = await _insert_job(clean_pg_conn, schema, actor="force_actor", status="pending") + scheduled_id = await _insert_job(clean_pg_conn, schema, actor="force_actor", status="scheduled") + schedule_id = await _insert_schedule(clean_pg_conn, schema, actor="force_actor", enabled=True) - result = await deregister_actor(pg_conn, "force_actor", force=True, schema=schema) + result = await deregister_actor(clean_pg_conn, "force_actor", force=True, schema=schema) assert result.actor_config_deleted is True assert result.jobs_cancelled == 2 @@ -289,213 +290,232 @@ async def test_deregister_force_cancels_pending_and_disables_schedules( assert result.terminal_jobs_remaining == 2 # Verify DB state directly. - assert await _job_status(pg_conn, schema, pending_id) == "cancelled" - assert await _job_status(pg_conn, schema, scheduled_id) == "cancelled" - assert await _schedule_enabled(pg_conn, schema, schedule_id) is False - assert await get_actor_config(pg_conn, "force_actor", schema=schema) is None + assert await _job_status(clean_pg_conn, schema, pending_id) == "cancelled" + assert await _job_status(clean_pg_conn, schema, scheduled_id) == "cancelled" + assert await _schedule_state(clean_pg_conn, schema, schedule_id) == "disabled" + assert await get_actor_config(clean_pg_conn, "force_actor", schema=schema) is None + + # Verify audit trail fields on cancelled jobs (M12). + job_row = await clean_pg_conn.fetchrow( + f'SELECT error_class, error_message, finished_at ' # noqa: S608 # Why: schema validated by _IDENT_RE; pending_id is a test-generated UUID. + f'FROM "{schema}".jobs WHERE id = $1', + pending_id, + ) + assert job_row is not None + assert job_row["error_class"] == "ActorDeregistered" + assert job_row["error_message"] is not None + assert "actor deregistration" in job_row["error_message"] + assert job_row["finished_at"] is not None -async def test_deregister_force_refuses_with_running_jobs(pg_conn: asyncpg.Connection) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) +async def test_deregister_force_refuses_with_running_jobs( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="frun_actor", max_concurrent=5, queue="default")], schema=schema, ) - await _insert_job(pg_conn, schema, actor="frun_actor", status="running") + await _insert_job(clean_pg_conn, schema, actor="frun_actor", status="running") with pytest.raises(ActorHasActiveJobsError) as exc_info: - await deregister_actor(pg_conn, "frun_actor", force=True, schema=schema) + await deregister_actor(clean_pg_conn, "frun_actor", force=True, schema=schema) assert exc_info.value.active_count == 1 assert exc_info.value.status_counts == {"running": 1} - assert await get_actor_config(pg_conn, "frun_actor", schema=schema) is not None + assert await get_actor_config(clean_pg_conn, "frun_actor", schema=schema) is not None async def test_deregister_force_with_running_and_pending_only_reports_running( - pg_conn: asyncpg.Connection, + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, ) -> None: """force=True checks only running jobs — pending jobs are not in the error because they would be cancelled, not blocking.""" - schema = _make_schema() - await _ensure_schema(pg_conn, schema) + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="mix_actor", max_concurrent=5, queue="default")], schema=schema, ) - await _insert_job(pg_conn, schema, actor="mix_actor", status="running") - await _insert_job(pg_conn, schema, actor="mix_actor", status="pending") + await _insert_job(clean_pg_conn, schema, actor="mix_actor", status="running") + await _insert_job(clean_pg_conn, schema, actor="mix_actor", status="pending") with pytest.raises(ActorHasActiveJobsError) as exc_info: - await deregister_actor(pg_conn, "mix_actor", force=True, schema=schema) + await deregister_actor(clean_pg_conn, "mix_actor", force=True, schema=schema) assert exc_info.value.active_count == 1 assert exc_info.value.status_counts == {"running": 1} assert "pending" not in exc_info.value.status_counts # Row still exists — transaction rolled back. - assert await get_actor_config(pg_conn, "mix_actor", schema=schema) is not None + assert await get_actor_config(clean_pg_conn, "mix_actor", schema=schema) is not None # The pending job must still be pending — the transaction rolled back # on the raise, so the cancel UPDATE never committed. - pending_count = await pg_conn.fetchval( + pending_count = await clean_pg_conn.fetchval( f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = $1 AND status = 'pending'", # noqa: S608 # Why: schema validated by _IDENT_RE in apply_pending; actor/status are test constants. "mix_actor", ) assert pending_count == 1 -async def test_deregister_force_keeps_terminal_history(pg_conn: asyncpg.Connection) -> None: +async def test_deregister_force_keeps_terminal_history( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: """Terminal job rows are never modified — only pending/scheduled are cancelled.""" - schema = _make_schema() - await _ensure_schema(pg_conn, schema) + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="hist_actor", max_concurrent=5, queue="default")], schema=schema, ) - pending_id = await _insert_job(pg_conn, schema, actor="hist_actor", status="pending") - succeeded_id = await _insert_job(pg_conn, schema, actor="hist_actor", status="succeeded") - failed_id = await _insert_job(pg_conn, schema, actor="hist_actor", status="failed") + pending_id = await _insert_job(clean_pg_conn, schema, actor="hist_actor", status="pending") + succeeded_id = await _insert_job(clean_pg_conn, schema, actor="hist_actor", status="succeeded") + failed_id = await _insert_job(clean_pg_conn, schema, actor="hist_actor", status="failed") - result = await deregister_actor(pg_conn, "hist_actor", force=True, schema=schema) + result = await deregister_actor(clean_pg_conn, "hist_actor", force=True, schema=schema) assert result.jobs_cancelled == 1 # terminal_jobs_remaining counts all terminal rows including the # newly-cancelled pending job: 1 cancelled + 1 succeeded + 1 failed. assert result.terminal_jobs_remaining == 3 - assert await _job_status(pg_conn, schema, pending_id) == "cancelled" - assert await _job_status(pg_conn, schema, succeeded_id) == "succeeded" - assert await _job_status(pg_conn, schema, failed_id) == "failed" + assert await _job_status(clean_pg_conn, schema, pending_id) == "cancelled" + assert await _job_status(clean_pg_conn, schema, succeeded_id) == "succeeded" + assert await _job_status(clean_pg_conn, schema, failed_id) == "failed" # ── purge_queue path ───────────────────────────────────────────────────── async def test_deregister_purge_queue_deletes_orphaned_queue( - pg_conn: asyncpg.Connection, + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, ) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) - await _insert_queue(pg_conn, schema, "solo_queue") + schema = module_pg_schema.schema_name + await _insert_queue(clean_pg_conn, schema, "solo_queue") await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="solo_actor", max_concurrent=5, queue="solo_queue")], schema=schema, ) result = await deregister_actor( - pg_conn, "solo_actor", purge_queue=True, schema=schema + clean_pg_conn, "solo_actor", purge_queue=True, schema=schema ) assert result.queue == "solo_queue" assert result.queue_purged is True - assert await _queue_exists(pg_conn, schema, "solo_queue") is False + assert await _queue_exists(clean_pg_conn, schema, "solo_queue") is False async def test_deregister_purge_queue_keeps_shared_queue( - pg_conn: asyncpg.Connection, + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, ) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) - await _insert_queue(pg_conn, schema, "shared_queue") + schema = module_pg_schema.schema_name + await _insert_queue(clean_pg_conn, schema, "shared_queue") await sync_actor_config( - pg_conn, + clean_pg_conn, [ - ActorConfig(actor="actor_a", max_concurrent=5, queue="shared_queue"), - ActorConfig(actor="actor_b", max_concurrent=5, queue="shared_queue"), + ActorConfig(actor="shared_a", max_concurrent=5, queue="shared_queue"), + ActorConfig(actor="shared_b", max_concurrent=5, queue="shared_queue"), ], schema=schema, ) result = await deregister_actor( - pg_conn, "actor_a", purge_queue=True, schema=schema + clean_pg_conn, "shared_a", purge_queue=True, schema=schema ) assert result.queue == "shared_queue" assert result.queue_purged is False - # The queue survives because actor_b still references it. - assert await _queue_exists(pg_conn, schema, "shared_queue") is True - # actor_b's row must still exist. - assert await get_actor_config(pg_conn, "actor_b", schema=schema) is not None + # The queue survives because shared_b still references it. + assert await _queue_exists(clean_pg_conn, schema, "shared_queue") is True + # shared_b's row must still exist. + assert await get_actor_config(clean_pg_conn, "shared_b", schema=schema) is not None async def test_deregister_without_purge_queue_keeps_queue( - pg_conn: asyncpg.Connection, + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, ) -> None: - schema = _make_schema() - await _ensure_schema(pg_conn, schema) - await _insert_queue(pg_conn, schema, "kept_queue") + schema = module_pg_schema.schema_name + await _insert_queue(clean_pg_conn, schema, "kept_queue") await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="keep_actor", max_concurrent=5, queue="kept_queue")], schema=schema, ) - result = await deregister_actor(pg_conn, "keep_actor", schema=schema) + result = await deregister_actor(clean_pg_conn, "keep_actor", schema=schema) assert result.queue_purged is False - assert await _queue_exists(pg_conn, schema, "kept_queue") is True + assert await _queue_exists(clean_pg_conn, schema, "kept_queue") is True # ── idempotency ───────────────────────────────────────────────────────── -async def test_double_deregister_raises_not_found(pg_conn: asyncpg.Connection) -> None: +async def test_double_deregister_raises_not_found( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: """A second deregister call on an already-deregistered actor raises ActorNotFoundError. This is the primary consumer pattern (cleanup loops using try/except ActorNotFoundError). The idempotency guarantee must be tested — an implementation bug that silently returns actor_config_deleted=False instead of raising would not be caught otherwise. """ - schema = _make_schema() - await _ensure_schema(pg_conn, schema) + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="idem_actor", max_concurrent=5, queue="default")], schema=schema, ) - result = await deregister_actor(pg_conn, "idem_actor", schema=schema) + result = await deregister_actor(clean_pg_conn, "idem_actor", schema=schema) assert result.actor_config_deleted is True with pytest.raises(ActorNotFoundError, match="no stored actor_config row"): - await deregister_actor(pg_conn, "idem_actor", schema=schema) + await deregister_actor(clean_pg_conn, "idem_actor", schema=schema) # ── combined force + purge_queue ──────────────────────────────────────── async def test_deregister_force_with_purge_queue( - pg_conn: asyncpg.Connection, + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, ) -> None: """force=True + purge_queue=True simultaneously — the exact pattern downstream consumers (aacrtool) use for ephemeral actor cleanup.""" - schema = _make_schema() - await _ensure_schema(pg_conn, schema) - await _insert_queue(pg_conn, schema, "ephemeral_queue") + schema = module_pg_schema.schema_name + await _insert_queue(clean_pg_conn, schema, "ephemeral_queue") await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="ephemeral_actor", max_concurrent=5, queue="ephemeral_queue")], schema=schema, ) - await _insert_job(pg_conn, schema, actor="ephemeral_actor", status="pending") - await _insert_job(pg_conn, schema, actor="ephemeral_actor", status="scheduled") + await _insert_job(clean_pg_conn, schema, actor="ephemeral_actor", status="pending") + await _insert_job(clean_pg_conn, schema, actor="ephemeral_actor", status="scheduled") result = await deregister_actor( - pg_conn, "ephemeral_actor", force=True, purge_queue=True, schema=schema + clean_pg_conn, "ephemeral_actor", force=True, purge_queue=True, schema=schema ) assert result.actor_config_deleted is True assert result.jobs_cancelled == 2 assert result.queue_purged is True - assert await get_actor_config(pg_conn, "ephemeral_actor", schema=schema) is None - assert await _queue_exists(pg_conn, schema, "ephemeral_queue") is False + assert await get_actor_config(clean_pg_conn, "ephemeral_actor", schema=schema) is None + assert await _queue_exists(clean_pg_conn, schema, "ephemeral_queue") is False async def test_deregister_purge_queue_noop_when_queue_row_absent( - pg_conn: asyncpg.Connection, + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, ) -> None: """purge_queue=True is a safe no-op when the queues row was never created. @@ -503,17 +523,16 @@ async def test_deregister_purge_queue_noop_when_queue_row_absent( deployments may never create a row. The DELETE returns 0 rows and queue_purged is False, which is correct. """ - schema = _make_schema() - await _ensure_schema(pg_conn, schema) + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, + clean_pg_conn, [ActorConfig(actor="noqueue_actor", max_concurrent=5, queue="never_created")], schema=schema, ) # Deliberately do NOT create a queues row for "never_created". result = await deregister_actor( - pg_conn, "noqueue_actor", purge_queue=True, schema=schema + clean_pg_conn, "noqueue_actor", purge_queue=True, schema=schema ) assert result.actor_config_deleted is True @@ -523,48 +542,61 @@ async def test_deregister_purge_queue_noop_when_queue_row_absent( # ── concurrent deregistration ──────────────────────────────────────────── -async def test_concurrent_deregister_one_succeeds_one_raises( - pg_conn: asyncpg.Connection, - settings: TaskQSettings, +async def test_concurrent_force_deregister_one_succeeds_one_raises( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, ) -> None: - """Two concurrent deregister calls for the same actor: one succeeds, the other raises.""" - import asyncio - - schema = _make_schema() - await _ensure_schema(pg_conn, schema) + """Two concurrent force=True deregister calls: one wins, one raises. + + Both connections target the same actor that has a pending job and an + enabled schedule. Under READ COMMITTED, row-level locking serializes + the UPDATEs: the first transaction locks the job rows, the second + blocks, then sees 0 rows after the first commits. The DELETE returns + a row for only one transaction; the other gets 0 rows and raises + ActorNotFoundError. + """ + schema = module_pg_schema.schema_name await sync_actor_config( - pg_conn, - [ActorConfig(actor="concurrent_actor", max_concurrent=5, queue="default")], + clean_pg_conn, + [ActorConfig(actor="concurrent_force_actor", max_concurrent=5, queue="default")], schema=schema, ) + job_id = await _insert_job(clean_pg_conn, schema, actor="concurrent_force_actor", status="pending") + sched_id = await _insert_schedule( + clean_pg_conn, schema, actor="concurrent_force_actor", enabled=True + ) - # Use two separate connections to simulate concurrent callers. - # The pg_conn fixture provides one connection; we create a second - # from the same DSN so both see the same schema and data. - conn2 = await asyncpg.connect(str(settings.pg_dsn)) + conn2 = await asyncpg.connect(module_pg_schema.pg_dsn) try: - # Both call deregister_actor simultaneously for the same actor. - # Under READ COMMITTED, both pass the safety checks, but only one - # DELETE returns a row — the other gets 0 rows and raises ActorNotFoundError. results: list[BaseException | DeregisterResult] = [] async def _deregister(conn: asyncpg.Connection) -> None: try: - result = await deregister_actor(conn, "concurrent_actor", schema=schema) + result = await deregister_actor( + conn, "concurrent_force_actor", force=True, schema=schema + ) results.append(result) except ActorNotFoundError as exc: results.append(exc) await asyncio.gather( - _deregister(pg_conn), + _deregister(clean_pg_conn), _deregister(conn2), ) - # Exactly one should succeed, one should raise ActorNotFoundError successes = [r for r in results if isinstance(r, DeregisterResult)] not_found = [r for r in results if isinstance(r, ActorNotFoundError)] assert len(successes) == 1 assert len(not_found) == 1 - assert successes[0].actor_config_deleted is True + + # The winner should have cancelled exactly 1 job and disabled 1 schedule + # — not double-cancelled by both transactions. + assert successes[0].jobs_cancelled == 1 + assert successes[0].schedules_disabled == 1 + + # Verify final DB state — job cancelled, schedule disabled, actor_config gone. + assert await get_actor_config(clean_pg_conn, "concurrent_force_actor", schema=schema) is None + assert await _job_status(clean_pg_conn, schema, job_id) == "cancelled" + assert await _schedule_state(clean_pg_conn, schema, sched_id) == "disabled" finally: await conn2.close() From 80c6ff32ff202d13431aff1e9f0a3e2a5db3e1e9 Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 19:28:10 -0700 Subject: [PATCH 19/20] fix: actor existence check first, job_events on cancel, canonical status sets, force-aware error message --- src/taskq/exceptions.py | 24 ++++++++++--- tests/test_actor_deregistration.py | 57 +++++++++++++++++++++++------- tests/test_exceptions.py | 19 ++++++++++ 3 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/taskq/exceptions.py b/src/taskq/exceptions.py index ddf26469..6d58a570 100644 --- a/src/taskq/exceptions.py +++ b/src/taskq/exceptions.py @@ -356,6 +356,10 @@ class ActorHasActiveJobsError(ActorDeregistrationError): Carries the count and per-status breakdown of the blocking jobs so the caller can decide whether to cancel them first or use ``force=True``. + + When *force* is ``True``, the message reflects that running jobs + cannot be cancelled by ``force=True`` — the caller must wait for + them to finish or cancel them individually first. """ def __init__( @@ -363,14 +367,24 @@ def __init__( actor: str, active_count: int, status_counts: dict[str, int], + *, + force: bool = False, ) -> None: self.active_count = active_count self.status_counts = status_counts - detail = ( - f"{active_count} non-terminal job(s) still reference this actor" - f" (breakdown: {status_counts}). Cancel them first or pass" - f" force=True to cancel pending/scheduled jobs automatically." - ) + if force: + detail = ( + f"{active_count} running job(s) still reference this actor" + f" (breakdown: {status_counts}). Running jobs cannot be" + f" cancelled by force=True \u2014 wait for them to finish or" + f" cancel them individually first." + ) + else: + detail = ( + f"{active_count} non-terminal job(s) still reference this actor" + f" (breakdown: {status_counts}). Cancel them first or pass" + f" force=True to cancel pending/scheduled jobs automatically." + ) super().__init__(actor, detail) diff --git a/tests/test_actor_deregistration.py b/tests/test_actor_deregistration.py index 74aaa6fb..ac5fd10f 100644 --- a/tests/test_actor_deregistration.py +++ b/tests/test_actor_deregistration.py @@ -297,7 +297,7 @@ async def test_deregister_force_cancels_pending_and_disables_schedules( # Verify audit trail fields on cancelled jobs (M12). job_row = await clean_pg_conn.fetchrow( - f'SELECT error_class, error_message, finished_at ' # noqa: S608 # Why: schema validated by _IDENT_RE; pending_id is a test-generated UUID. + f"SELECT error_class, error_message, finished_at " # noqa: S608 # Why: schema validated by _IDENT_RE; pending_id is a test-generated UUID. f'FROM "{schema}".jobs WHERE id = $1', pending_id, ) @@ -308,6 +308,41 @@ async def test_deregister_force_cancels_pending_and_disables_schedules( assert job_row["finished_at"] is not None +async def test_deregister_force_writes_job_events_on_cancel( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """H3: force=True cancel must insert job_events state_change rows — + every other cancel path does, and audit consumers rely on them.""" + schema = module_pg_schema.schema_name + await sync_actor_config( + clean_pg_conn, + [ActorConfig(actor="evt_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + pending_id = await _insert_job(clean_pg_conn, schema, actor="evt_actor", status="pending") + scheduled_id = await _insert_job(clean_pg_conn, schema, actor="evt_actor", status="scheduled") + + await deregister_actor(clean_pg_conn, "evt_actor", force=True, schema=schema) + + for jid in (pending_id, scheduled_id): + events = await clean_pg_conn.fetch( + f"SELECT kind, detail::text AS detail " # noqa: S608 # Why: schema validated by _IDENT_RE; jid is a test UUID. + f' FROM "{schema}".job_events ' + f" WHERE job_id = $1 " + f" ORDER BY occurred_at", + jid, + ) + assert len(events) >= 1, f"no job_events for cancelled job {jid}" + state_changes = [e for e in events if e["kind"] == "state_change"] + assert len(state_changes) == 1 + import json + + detail = json.loads(state_changes[0]["detail"]) + assert detail["to_state"] == "cancelled" + assert detail["reason"] == "actor_deregistered" + + async def test_deregister_force_refuses_with_running_jobs( clean_pg_conn: asyncpg.Connection, module_pg_schema: ModulePgSchema, @@ -402,9 +437,7 @@ async def test_deregister_purge_queue_deletes_orphaned_queue( schema=schema, ) - result = await deregister_actor( - clean_pg_conn, "solo_actor", purge_queue=True, schema=schema - ) + result = await deregister_actor(clean_pg_conn, "solo_actor", purge_queue=True, schema=schema) assert result.queue == "solo_queue" assert result.queue_purged is True @@ -426,9 +459,7 @@ async def test_deregister_purge_queue_keeps_shared_queue( schema=schema, ) - result = await deregister_actor( - clean_pg_conn, "shared_a", purge_queue=True, schema=schema - ) + result = await deregister_actor(clean_pg_conn, "shared_a", purge_queue=True, schema=schema) assert result.queue == "shared_queue" assert result.queue_purged is False @@ -531,9 +562,7 @@ async def test_deregister_purge_queue_noop_when_queue_row_absent( ) # Deliberately do NOT create a queues row for "never_created". - result = await deregister_actor( - clean_pg_conn, "noqueue_actor", purge_queue=True, schema=schema - ) + result = await deregister_actor(clean_pg_conn, "noqueue_actor", purge_queue=True, schema=schema) assert result.actor_config_deleted is True assert result.queue_purged is False @@ -561,7 +590,9 @@ async def test_concurrent_force_deregister_one_succeeds_one_raises( [ActorConfig(actor="concurrent_force_actor", max_concurrent=5, queue="default")], schema=schema, ) - job_id = await _insert_job(clean_pg_conn, schema, actor="concurrent_force_actor", status="pending") + job_id = await _insert_job( + clean_pg_conn, schema, actor="concurrent_force_actor", status="pending" + ) sched_id = await _insert_schedule( clean_pg_conn, schema, actor="concurrent_force_actor", enabled=True ) @@ -595,7 +626,9 @@ async def _deregister(conn: asyncpg.Connection) -> None: assert successes[0].schedules_disabled == 1 # Verify final DB state — job cancelled, schedule disabled, actor_config gone. - assert await get_actor_config(clean_pg_conn, "concurrent_force_actor", schema=schema) is None + assert ( + await get_actor_config(clean_pg_conn, "concurrent_force_actor", schema=schema) is None + ) assert await _job_status(clean_pg_conn, schema, job_id) == "cancelled" assert await _schedule_state(clean_pg_conn, schema, sched_id) == "disabled" finally: diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 6f996179..9c8bcb62 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -495,6 +495,25 @@ def test_actor_has_active_jobs_error_carries_counts(self) -> None: assert "3 non-terminal" in str(err) assert "force=True" in str(err) + def test_actor_has_active_jobs_error_force_true_message(self) -> None: + from taskq.exceptions import ActorHasActiveJobsError + + err = ActorHasActiveJobsError( + actor="my-actor.run-123", + active_count=2, + status_counts={"running": 2}, + force=True, + ) + assert err.actor == "my-actor.run-123" + assert err.active_count == 2 + assert err.status_counts == {"running": 2} + msg = str(err) + assert "2 running job(s)" in msg + assert "cannot be cancelled by force=True" in msg + assert "wait for them to finish" in msg + # Must NOT contain the force=False remediation advice + assert "pass" not in msg.lower().split("force=true")[0] + def test_actor_has_enabled_schedules_error_carries_ids(self) -> None: from taskq.exceptions import ActorHasEnabledSchedulesError From 048da631196c2c5545bb1578b5098332f445329c Mon Sep 17 00:00:00 2001 From: Rich Evans Date: Wed, 29 Jul 2026 19:37:02 -0700 Subject: [PATCH 20/20] =?UTF-8?q?fix:=20all=20remaining=20L=20findings=20?= =?UTF-8?q?=E2=80=94=20CSRF=20test,=20notice=20whitelist,=20exit=20codes,?= =?UTF-8?q?=20schema=20assertion,=20edge-case=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L1: Document admin UI limitation for actor names containing '/' L2: Whitelist notice query param to fixed messages (XSS prevention) L4: Fix warning wording — sweep transitions to terminal, not heals L5: Add cron fire/re-enable race warning to docstring L6: Add concurrent deregistration orphaned-queue warning to docstring L7: Type-annotate status_counts as dict[str, int] with explicit casts L10: Document ActorsClient as Postgres-only in class docstring L11: Clarify ActorNotFoundError scope in docstring L12: Distinct CLI exit codes: 2=refusal, 3=not-found (was: 1 for all) L15: Add set_capacity None-vs-UNSET forwarding test L16: Add refusal gap tests: scheduled-only, jobs+schedules precedence, invalid schema L17: Assert WARNING message and exception prefix in CLI tests L18: Add negative CSRF test for deregister route L19: Strengthen property test schema assertion, replace admin_pool with module_pg_pool, tighten e2e >=1 to ==1 --- src/taskq/actor_config_ops.py | 22 ++++-- src/taskq/cli.py | 10 ++- src/taskq/client/_actors.py | 8 +++ src/taskq/exceptions.py | 6 +- src/taskq/web/admin/actors.py | 14 ++-- tests/e2e/test_actor_deregistration.py | 8 +-- tests/test_actor_deregistration.py | 46 ++++++++++++ tests/test_actor_deregistration_client.py | 4 +- tests/test_actors_client.py | 41 +++++++++++ tests/test_cli_actor_deregister.py | 22 +++--- tests/test_taskq_actors_property.py | 1 + tests/test_web_admin_actors.py | 88 ++++++++++++----------- 12 files changed, 205 insertions(+), 65 deletions(-) diff --git a/src/taskq/actor_config_ops.py b/src/taskq/actor_config_ops.py index 4bb0523b..f77abb5c 100644 --- a/src/taskq/actor_config_ops.py +++ b/src/taskq/actor_config_ops.py @@ -380,8 +380,18 @@ async def deregister_actor( before calling deregister. A job dispatched between the running check and the cancel UPDATE - (force=True) will be stranded until the leader sweep reclaims its - expired lock. + (force=True) will be left running with no ``actor_config`` row. + When its lock expires, the leader sweep transitions it to a + terminal state (crashed/cancelled) — it is NOT re-dispatched. + + A cron schedule that fires between the disable UPDATE and the + ``actor_config`` DELETE will enqueue a job that can never be + dispatched (same stranding as enqueue-during-deregister). + + Concurrent deregistration of the last two actors sharing a queue + may leave the queue row orphaned (both transactions see the + other's ``actor_config`` row as still present under READ + COMMITTED). The queue can be manually deleted if needed. """ if not _IDENT_RE.match(schema): raise ValueError(f"invalid schema identifier: {schema!r}") @@ -405,7 +415,9 @@ async def deregister_actor( list(ACTIVE_STATUSES), ) if active_rows: - status_counts = {row["status"]: row["cnt"] for row in active_rows} + status_counts: dict[str, int] = { + str(row["status"]): int(row["cnt"]) for row in active_rows + } active_count = sum(status_counts.values()) raise ActorHasActiveJobsError(actor, active_count, status_counts) @@ -426,7 +438,9 @@ async def deregister_actor( [_RUNNING_STATUS], ) if running_rows: - status_counts = {row["status"]: row["cnt"] for row in running_rows} + status_counts: dict[str, int] = { + str(row["status"]): int(row["cnt"]) for row in running_rows + } active_count = sum(status_counts.values()) raise ActorHasActiveJobsError(actor, active_count, status_counts, force=True) diff --git a/src/taskq/cli.py b/src/taskq/cli.py index 10df5288..12a5aa5e 100644 --- a/src/taskq/cli.py +++ b/src/taskq/cli.py @@ -39,7 +39,7 @@ list_actor_configs, set_actor_config_capacity, ) -from taskq.exceptions import ActorConfigDriftList, ActorDeregistrationError +from taskq.exceptions import ActorConfigDriftList, ActorDeregistrationError, ActorNotFoundError from taskq.settings import TaskQSettings, WorkerSettings from taskq.worker.dev import dev_watch_loop from taskq.worker.run import worker_main as _worker_main @@ -544,6 +544,9 @@ def actor_config_deregister( reference the actor. Use --force to cancel pending/scheduled jobs and disable schedules. Running jobs always block (force or not). Use --purge-queue to also delete the queues row if no other actor uses it. + + Exit codes: 0 success, 2 refusal (active jobs/schedules or invalid + schema), 3 not found. """ settings = TaskQSettings.load() asyncio.run(_actor_config_deregister(settings, actor, force, purge_queue)) @@ -564,9 +567,12 @@ async def _actor_config_deregister( purge_queue=purge_queue, schema=settings.schema_name, ) + except ActorNotFoundError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=3) from None except (ActorDeregistrationError, ValueError) as exc: typer.echo(str(exc), err=True) - raise typer.Exit(code=1) from None + raise typer.Exit(code=2) from None finally: await conn.close() diff --git a/src/taskq/client/_actors.py b/src/taskq/client/_actors.py index f6ede3bf..2714157b 100644 --- a/src/taskq/client/_actors.py +++ b/src/taskq/client/_actors.py @@ -33,6 +33,14 @@ class ActorsClient: caller must have opened the pool; this class does not manage its lifecycle. + .. note:: + This client is Postgres-only — it delegates to + :mod:`taskq.actor_config_ops`, which executes raw SQL against + the ``actor_config`` table. The :class:`~taskq.backend._protocol.Backend` + protocol does not include actor config operations, so + :class:`~taskq.testing.InMemoryBackend` does not support + ``ActorsClient``. + Parameters ---------- pool: diff --git a/src/taskq/exceptions.py b/src/taskq/exceptions.py index 6d58a570..8587c5ea 100644 --- a/src/taskq/exceptions.py +++ b/src/taskq/exceptions.py @@ -409,7 +409,11 @@ def __init__( class ActorNotFoundError(ActorDeregistrationError): - """The actor_config row does not exist — nothing to deregister.""" + """The actor_config row does not exist — nothing to deregister. + + Currently raised only by :func:`deregister_actor`. Other ops + (``get``, ``set_capacity``) return ``None`` for missing rows. + """ def __init__(self, actor: str) -> None: super().__init__(actor, "no stored actor_config row for this actor") diff --git a/src/taskq/web/admin/actors.py b/src/taskq/web/admin/actors.py index 6e3cef4d..9a93036e 100644 --- a/src/taskq/web/admin/actors.py +++ b/src/taskq/web/admin/actors.py @@ -1,7 +1,5 @@ """Actors overview and deregister admin pages.""" -from urllib.parse import quote_plus - import asyncpg from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse @@ -21,6 +19,10 @@ validate_csrf, ) +_NOTICE_MESSAGES: dict[str, str] = { + "deregistered": "Actor deregistered successfully.", +} + def register(router: APIRouter) -> None: """Attach actors overview and deregister routes to *router*.""" @@ -38,16 +40,20 @@ async def actors_overview( # pyright: ignore[reportUnusedFunction] # Why: regi async with pool.acquire() as conn: actors = await list_actor_summaries(conn, schema=schema) realtime_mode, mode_label = realtime_ctx + notice_text: str | None = _NOTICE_MESSAGES.get(notice) if notice else None html = tmpl.get_template("actors.html").render( actors=actors, realtime_mode=realtime_mode, mode_label=mode_label, csrf_token=csrf_token, active_page="actors", - notice=notice, + notice=notice_text, ) return HTMLResponse(content=html) + # Note: {actor} matches a single path segment — actor names containing "/" + # cannot be deregistered via the admin UI (use the CLI or client API instead). + # This is an accepted limitation; %2F in URLs is decoded before routing. @router.post("/actors/{actor}/deregister") async def actor_deregister( # pyright: ignore[reportUnusedFunction] # Why: registered via FastAPI decorator; pyright cannot see the route registration. actor: str, @@ -76,6 +82,6 @@ async def actor_deregister( # pyright: ignore[reportUnusedFunction] # Why: reg raise HTTPException(status_code=409, detail=str(exc)) from None return RedirectResponse( - url=f"{base_path}/actors?notice=deregistered+{quote_plus(actor)}", + url=f"{base_path}/actors?notice=deregistered", status_code=303, ) diff --git a/tests/e2e/test_actor_deregistration.py b/tests/e2e/test_actor_deregistration.py index fe9994f9..29423abf 100644 --- a/tests/e2e/test_actor_deregistration.py +++ b/tests/e2e/test_actor_deregistration.py @@ -61,7 +61,7 @@ async def test_deregister_after_jobs_complete( result = await e2e_client.actors.deregister(actor_name) assert result.actor_config_deleted is True - assert result.terminal_jobs_remaining >= 1 + assert result.terminal_jobs_remaining == 1 ac_count = await e2e_pg_pool.fetchval( f'SELECT count(*) FROM "{schema}".actor_config WHERE actor = $1', @@ -73,7 +73,7 @@ async def test_deregister_after_jobs_complete( f"SELECT count(*) FROM \"{schema}\".jobs WHERE actor = $1 AND status = 'succeeded'", actor_name, ) - assert job_count >= 1 + assert job_count == 1 async def test_deregister_refuses_with_active_jobs( @@ -104,7 +104,7 @@ async def _is_running() -> bool: await e2e_client.actors.deregister(actor_name) assert exc_info.value.actor == actor_name - assert exc_info.value.active_count >= 1 + assert exc_info.value.active_count == 1 assert "running" in exc_info.value.status_counts ac_count = await e2e_pg_pool.fetchval( @@ -155,7 +155,7 @@ async def test_deregister_force_with_purge_queue_after_completion( assert result.actor_config_deleted is True assert result.jobs_cancelled == 0 - assert result.terminal_jobs_remaining >= 1 + assert result.terminal_jobs_remaining == 1 # All e2e actors share queue="e2e" — purge_queue=True is a safe no-op # because the orphan guard correctly refuses to delete a shared queue. assert result.queue_purged is False diff --git a/tests/test_actor_deregistration.py b/tests/test_actor_deregistration.py index ac5fd10f..1a91c748 100644 --- a/tests/test_actor_deregistration.py +++ b/tests/test_actor_deregistration.py @@ -633,3 +633,49 @@ async def _deregister(conn: asyncpg.Connection) -> None: assert await _schedule_state(clean_pg_conn, schema, sched_id) == "disabled" finally: await conn2.close() + + +# ── refusal gap coverage (L16) ─────────────────────────────────────────── + + +async def test_deregister_refuses_with_scheduled_jobs( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """force=False refuses with scheduled (not just pending) jobs.""" + schema = module_pg_schema.schema_name + await sync_actor_config( + clean_pg_conn, + [ActorConfig(actor="sched_job_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + await _insert_job(clean_pg_conn, schema, actor="sched_job_actor", status="scheduled") + with pytest.raises(ActorHasActiveJobsError) as exc_info: + await deregister_actor(clean_pg_conn, "sched_job_actor", schema=schema) + assert exc_info.value.status_counts == {"scheduled": 1} + + +async def test_deregister_active_jobs_takes_precedence_over_schedules( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """When both active jobs AND enabled schedules exist, the jobs error is raised first.""" + schema = module_pg_schema.schema_name + await sync_actor_config( + clean_pg_conn, + [ActorConfig(actor="precedence_actor", max_concurrent=5, queue="default")], + schema=schema, + ) + await _insert_job(clean_pg_conn, schema, actor="precedence_actor", status="pending") + await _insert_schedule(clean_pg_conn, schema, actor="precedence_actor", enabled=True) + with pytest.raises(ActorHasActiveJobsError): + await deregister_actor(clean_pg_conn, "precedence_actor", schema=schema) + + +async def test_deregister_invalid_schema_raises_value_error( + clean_pg_conn: asyncpg.Connection, + module_pg_schema: ModulePgSchema, +) -> None: + """Invalid schema identifier raises ValueError before any DB access.""" + with pytest.raises(ValueError, match="invalid schema identifier"): + await deregister_actor(clean_pg_conn, "any_actor", schema="bad; DROP TABLE") diff --git a/tests/test_actor_deregistration_client.py b/tests/test_actor_deregistration_client.py index d56c2c2e..319854f9 100644 --- a/tests/test_actor_deregistration_client.py +++ b/tests/test_actor_deregistration_client.py @@ -21,7 +21,9 @@ pytestmark = [pytest.mark.asyncio, pytest.mark.integration] -async def _seed_actor(conn: asyncpg.Connection, schema: str, actor: str, queue: str = "default") -> None: +async def _seed_actor( + conn: asyncpg.Connection, schema: str, actor: str, queue: str = "default" +) -> None: await sync_actor_config( conn, [ActorConfig(actor=actor, max_concurrent=5, queue=queue)], diff --git a/tests/test_actors_client.py b/tests/test_actors_client.py index e2d2dfac..c5e35d87 100644 --- a/tests/test_actors_client.py +++ b/tests/test_actors_client.py @@ -168,3 +168,44 @@ async def test_actors_client_deregister_propagates_errors(monkeypatch: pytest.Mo ) with pytest.raises(ActorNotFoundError): await client.deregister("bad-actor") + + +async def test_actors_client_set_capacity_forwards_none_vs_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """None means 'clear'; UNSET (default) means 'leave unchanged'. + + The client must forward both faithfully to set_actor_config_capacity + so operators can distinguish clearing a field from not touching it. + """ + from taskq.client._actors import UNSET, ActorsClient + + conn = _FakeConn() + pool = _FakePool(conn) + client = ActorsClient(pool, schema="test_schema") + + expected = ActorConfigRow( + actor="a", + max_concurrent=None, + max_pending=5, + queue="q", + result_ttl=None, + metadata={}, + updated_at="2026-01-01", + ) + + import taskq.client._actors as actors_mod + + mock_fn = AsyncMock(return_value=expected) + monkeypatch.setattr(actors_mod, "set_actor_config_capacity", mock_fn) + + await client.set_capacity("a", max_concurrent=None, max_pending=5) + + mock_fn.assert_called_once_with( + conn, + "a", + max_concurrent=None, + max_pending=5, + result_ttl=UNSET, + schema="test_schema", + ) diff --git a/tests/test_cli_actor_deregister.py b/tests/test_cli_actor_deregister.py index a5646421..a64c1c62 100644 --- a/tests/test_cli_actor_deregister.py +++ b/tests/test_cli_actor_deregister.py @@ -74,16 +74,17 @@ def test_deregister_purge_queue_flag(monkeypatch: pytest.MonkeyPatch) -> None: assert captured["kwargs"]["purge_queue"] is True -def test_deregister_not_found_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: +def test_deregister_not_found_exit_three(monkeypatch: pytest.MonkeyPatch) -> None: from taskq.exceptions import ActorNotFoundError _patch_deregister(monkeypatch, raises=ActorNotFoundError("ghost")) result = runner.invoke(app, ["actor-config", "deregister", "ghost"]) - assert result.exit_code == 1 + assert result.exit_code == 3 assert "no stored actor_config row" in result.stderr + assert "Cannot deregister actor" in result.stderr -def test_deregister_active_jobs_error_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: +def test_deregister_active_jobs_error_exit_two(monkeypatch: pytest.MonkeyPatch) -> None: from taskq.exceptions import ActorHasActiveJobsError _patch_deregister( @@ -93,12 +94,13 @@ def test_deregister_active_jobs_error_exit_one(monkeypatch: pytest.MonkeyPatch) ), ) result = runner.invoke(app, ["actor-config", "deregister", "busy"]) - assert result.exit_code == 1 + assert result.exit_code == 2 assert "non-terminal" in result.stderr assert "force=True" in result.stderr + assert "Cannot deregister actor" in result.stderr -def test_deregister_schedules_error_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: +def test_deregister_schedules_error_exit_two(monkeypatch: pytest.MonkeyPatch) -> None: from taskq.exceptions import ActorHasEnabledSchedulesError _patch_deregister( @@ -106,7 +108,7 @@ def test_deregister_schedules_error_exit_one(monkeypatch: pytest.MonkeyPatch) -> raises=ActorHasEnabledSchedulesError("sched-actor", ["s1", "s2"]), ) result = runner.invoke(app, ["actor-config", "deregister", "sched-actor"]) - assert result.exit_code == 1 + assert result.exit_code == 2 assert "enabled cron schedule" in result.stderr @@ -132,15 +134,17 @@ def test_deregister_output_shows_result(monkeypatch: pytest.MonkeyPatch) -> None assert "jobs_cancelled=5" in output.output assert "terminal_jobs_remaining=10" in output.output assert "queue_purged=true" in output.output.lower() + assert "WARNING" in output.output + assert "stranded pending job" in output.output -def test_deregister_double_deregister_exit_one(monkeypatch: pytest.MonkeyPatch) -> None: - """Second deregister on an already-deregistered actor exits 1 with ActorNotFoundError.""" +def test_deregister_double_deregister_exit_three(monkeypatch: pytest.MonkeyPatch) -> None: + """Second deregister on an already-deregistered actor exits 3 with ActorNotFoundError.""" from taskq.exceptions import ActorNotFoundError _patch_deregister(monkeypatch, raises=ActorNotFoundError("already-gone")) result = runner.invoke(app, ["actor-config", "deregister", "already-gone"]) - assert result.exit_code == 1 + assert result.exit_code == 3 assert "no stored actor_config row" in result.stderr diff --git a/tests/test_taskq_actors_property.py b/tests/test_taskq_actors_property.py index 73dc73e0..ca0420e5 100644 --- a/tests/test_taskq_actors_property.py +++ b/tests/test_taskq_actors_property.py @@ -66,3 +66,4 @@ async def test_taskq_actors_property_returns_actors_client( ) as tq: client = tq.actors assert isinstance(client, ActorsClient) + assert client._schema == module_pg_schema.schema_name diff --git a/tests/test_web_admin_actors.py b/tests/test_web_admin_actors.py index 3fcb5a4c..6214d2c0 100644 --- a/tests/test_web_admin_actors.py +++ b/tests/test_web_admin_actors.py @@ -9,12 +9,9 @@ cookie; POST must include it as the csrf_token form field. """ -from collections.abc import AsyncIterator - import asyncpg import httpx import pytest -import pytest_asyncio from fastapi import FastAPI from taskq.actor_config import ActorConfig @@ -25,16 +22,6 @@ pytestmark = [pytest.mark.asyncio, pytest.mark.integration] -@pytest_asyncio.fixture -async def admin_pool(module_pg_schema: ModulePgSchema) -> AsyncIterator[asyncpg.Pool]: - pool = await asyncpg.create_pool(module_pg_schema.pg_dsn, min_size=1, max_size=4) - assert pool is not None - try: - yield pool - finally: - await pool.close() - - def _make_admin_app( pool: asyncpg.Pool, schema: str, @@ -85,13 +72,13 @@ async def _get_csrf_then_post( async def test_actors_page_lists_actor_config_rows( clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, + module_pg_pool: asyncpg.Pool, module_pg_schema: ModulePgSchema, monkeypatch: pytest.MonkeyPatch, ) -> None: schema = module_pg_schema.schema_name await _seed_actor_config(clean_pg_conn, schema, "test-actor-1", queue="default") - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + app = _make_admin_app(module_pg_pool, schema, monkeypatch, admin_actions_enabled=True) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" @@ -105,13 +92,13 @@ async def test_actors_page_lists_actor_config_rows( async def test_actors_page_shows_deregister_form( clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, + module_pg_pool: asyncpg.Pool, module_pg_schema: ModulePgSchema, monkeypatch: pytest.MonkeyPatch, ) -> None: schema = module_pg_schema.schema_name await _seed_actor_config(clean_pg_conn, schema, "button-actor", queue="default") - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + app = _make_admin_app(module_pg_pool, schema, monkeypatch, admin_actions_enabled=True) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" @@ -128,13 +115,13 @@ async def test_actors_page_shows_deregister_form( async def test_deregister_route_returns_403_when_admin_actions_disabled( clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, + module_pg_pool: asyncpg.Pool, module_pg_schema: ModulePgSchema, monkeypatch: pytest.MonkeyPatch, ) -> None: schema = module_pg_schema.schema_name await _seed_actor_config(clean_pg_conn, schema, "disabled-actor", queue="default") - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=False) + app = _make_admin_app(module_pg_pool, schema, monkeypatch, admin_actions_enabled=False) resp = await _get_csrf_then_post( app, "/admin/actors", "/admin/actors/disabled-actor/deregister" @@ -150,13 +137,13 @@ async def test_deregister_route_returns_403_when_admin_actions_disabled( async def test_deregister_route_succeeds_for_clean_actor( clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, + module_pg_pool: asyncpg.Pool, module_pg_schema: ModulePgSchema, monkeypatch: pytest.MonkeyPatch, ) -> None: schema = module_pg_schema.schema_name await _seed_actor_config(clean_pg_conn, schema, "clean-deregister-actor", queue="default") - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + app = _make_admin_app(module_pg_pool, schema, monkeypatch, admin_actions_enabled=True) resp = await _get_csrf_then_post( app, "/admin/actors", "/admin/actors/clean-deregister-actor/deregister" @@ -174,7 +161,7 @@ async def test_deregister_route_succeeds_for_clean_actor( async def test_deregister_route_returns_409_when_actor_has_active_jobs( clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, + module_pg_pool: asyncpg.Pool, module_pg_schema: ModulePgSchema, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -189,11 +176,9 @@ async def test_deregister_route_returns_409_when_actor_has_active_jobs( f"VALUES ($1, 'blocked-actor', 'default', '{{}}'::jsonb, 'pending'::\"{schema}\".job_status, 3, 'transient')", uuid4(), ) - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + app = _make_admin_app(module_pg_pool, schema, monkeypatch, admin_actions_enabled=True) - resp = await _get_csrf_then_post( - app, "/admin/actors", "/admin/actors/blocked-actor/deregister" - ) + resp = await _get_csrf_then_post(app, "/admin/actors", "/admin/actors/blocked-actor/deregister") assert resp.status_code == 409 assert "non-terminal" in resp.text @@ -207,7 +192,7 @@ async def test_deregister_route_returns_409_when_actor_has_active_jobs( async def test_deregister_route_with_force_cancels_pending_job( clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, + module_pg_pool: asyncpg.Pool, module_pg_schema: ModulePgSchema, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -222,10 +207,12 @@ async def test_deregister_route_with_force_cancels_pending_job( f"VALUES ($1, 'force-admin-actor', 'default', '{{}}'::jsonb, 'pending'::\"{schema}\".job_status, 3, 'transient')", job_id, ) - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + app = _make_admin_app(module_pg_pool, schema, monkeypatch, admin_actions_enabled=True) resp = await _get_csrf_then_post( - app, "/admin/actors", "/admin/actors/force-admin-actor/deregister", + app, + "/admin/actors", + "/admin/actors/force-admin-actor/deregister", data={"force": "true"}, ) @@ -243,13 +230,13 @@ async def test_deregister_route_with_force_cancels_pending_job( async def test_deregister_route_returns_404_for_unknown_actor( - admin_pool: asyncpg.Pool, + module_pg_pool: asyncpg.Pool, module_pg_schema: ModulePgSchema, monkeypatch: pytest.MonkeyPatch, ) -> None: """POST deregister for a non-existent actor returns 404, not 409.""" schema = module_pg_schema.schema_name - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + app = _make_admin_app(module_pg_pool, schema, monkeypatch, admin_actions_enabled=True) resp = await _get_csrf_then_post( app, "/admin/actors", "/admin/actors/nonexistent-actor/deregister" @@ -261,28 +248,28 @@ async def test_deregister_route_returns_404_for_unknown_actor( async def test_actors_page_shows_notice_after_deregister( clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, + module_pg_pool: asyncpg.Pool, module_pg_schema: ModulePgSchema, monkeypatch: pytest.MonkeyPatch, ) -> None: """GET /actors?notice=... renders the notice banner.""" schema = module_pg_schema.schema_name - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + app = _make_admin_app(module_pg_pool, schema, monkeypatch, admin_actions_enabled=True) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as client: - resp = await client.get("/admin/actors?notice=deregistered+test-actor") + resp = await client.get("/admin/actors?notice=deregistered") assert resp.status_code == 200 - assert "deregistered" in resp.text + assert "Actor deregistered successfully" in resp.text # The notice should be in a styled banner div, not just in the page somewhere assert "bg-green" in resp.text async def test_deregister_route_returns_409_for_enabled_schedules( clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, + module_pg_pool: asyncpg.Pool, module_pg_schema: ModulePgSchema, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -296,7 +283,7 @@ async def test_deregister_route_returns_409_for_enabled_schedules( f"VALUES ($1, 'sched-409-actor', '*/5 * * * *', true, now())", uuid4(), ) - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + app = _make_admin_app(module_pg_pool, schema, monkeypatch, admin_actions_enabled=True) resp = await _get_csrf_then_post( app, "/admin/actors", "/admin/actors/sched-409-actor/deregister" @@ -313,7 +300,7 @@ async def test_deregister_route_returns_409_for_enabled_schedules( async def test_deregister_route_with_purge_queue_deletes_queue( clean_pg_conn: asyncpg.Connection, - admin_pool: asyncpg.Pool, + module_pg_pool: asyncpg.Pool, module_pg_schema: ModulePgSchema, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -324,10 +311,12 @@ async def test_deregister_route_with_purge_queue_deletes_queue( "admin-purge-queue", ) await _seed_actor_config(clean_pg_conn, schema, "admin-purge-actor", queue="admin-purge-queue") - app = _make_admin_app(admin_pool, schema, monkeypatch, admin_actions_enabled=True) + app = _make_admin_app(module_pg_pool, schema, monkeypatch, admin_actions_enabled=True) resp = await _get_csrf_then_post( - app, "/admin/actors", "/admin/actors/admin-purge-actor/deregister", + app, + "/admin/actors", + "/admin/actors/admin-purge-actor/deregister", data={"purge_queue": "true"}, ) @@ -337,3 +326,22 @@ async def test_deregister_route_with_purge_queue_deletes_queue( "admin-purge-queue", ) assert queue_count == 0 + + +async def test_deregister_route_returns_403_without_csrf_token( + clean_pg_conn: asyncpg.Connection, + module_pg_pool: asyncpg.Pool, + module_pg_schema: ModulePgSchema, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """POST without CSRF token returns 403.""" + schema = module_pg_schema.schema_name + await _seed_actor_config(clean_pg_conn, schema, "csrf-test-actor", queue="default") + app = _make_admin_app(module_pg_pool, schema, monkeypatch, admin_actions_enabled=True) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.post("/admin/actors/csrf-test-actor/deregister") + + assert resp.status_code == 403