Web¶
Admin UI and progress streaming routers.
taskq.web is an empty module (docstring only); it renders no members. The directives
below target the concrete submodules instead. See also the Admin UI guide
and Progress & Streaming guide for task-oriented walkthroughs.
Progress SSE router¶
progress ¶
FastAPI router: SSE progress bridge and poll-state endpoint.
Bridges Redis pub/sub progress events to Server-Sent Events for browsers and
API clients. Mount at prefix="/jobs" to produce the canonical URLs:
GET /jobs/api/job/{job_id}/progress/stream , SSE stream
GET /jobs/api/job/{job_id}/state , poll-state (JSON)
Importing this module requires the taskq[fastapi] optional extra (which
includes sse-starlette).
Design notes¶
- Subscribe-before-query: the Redis channel is subscribed BEFORE the PG snapshot read to eliminate the race window where an event published between the PG read and the subscribe would be silently lost.
- The PG connection is released immediately after the initial row fetch; no PG connection is held during streaming.
- The Redis subscribe and PG query happen in the handler body (before
EventSourceResponseis created) so that 404/503 errors produce proper HTTP status codes rather than appearing inside an already-started SSE stream. - Keepalive comments are emitted every
sse_heartbeat_intervalseconds via aget_message(timeout=...)polling loop (avoids blockinglisten()which has no per-message timeout support). - Each broker read carries an app-level deadline: the delegated
timeout=bounds only the read itself, while a reconnect inside redis-py'sparse_response(broker dropped mid-read) is bounded by nothing, so the loop wraps every read inasyncio.wait_forand a read that outlives the deadline ends the stream, exactly as broker death does. - On client disconnect,
try/finallyin the generator callspubsub.unsubscribe()andpubsub.aclose()to prevent stale Redis subscriptions. - Session re-check (#316): the router-level
Depends(auth_dependency)runs once at request acceptance; a stream that authenticated once would otherwise keep delivering frames long after its session was revoked. The generator therefore re-invokes an optionalsession_verifier-- the re-check the auth dependency itself exposes (create_auth_dependencyattaches one) -- before every yielded event and at every keepalive tick, and ends the stream on failure. The finally releases the SSE slot and the Redis subscription. Bounded staleness: at most one keepalive interval between the revocation and the stream ending; the effective keepalive interval is capped at_SSE_HEARTBEAT_CAP_SECSso the bound holds no matter how a host configuressse_heartbeat_interval(the clamp is logged at startup). The re-check itself is bounded too (SESSION_RECHECK_TIMEOUT_SECS): a verifier that hangs is fail-closed revocation, never a frozen generator. The check is cookie-only -- no session store, no DB round trip -- so server-side revocation of a still-validly-signed cookie is impossible by construction (docs/guides/sso.md states the same limits).
create_router ¶
create_router(
pg_pool: Pool,
redis_client: Any,
*,
schema: str = "taskq",
auth_dependency: Callable[..., Any] | None = None,
sse_heartbeat_interval: timedelta = timedelta(
seconds=15
),
max_sse_connections: int | None = None,
resolve_pg_pool: Callable[[Request], Pool]
| None = None,
resolve_redis_client: Callable[[Request], Any]
| None = None,
session_verifier: Callable[[Request], Awaitable[bool]]
| None = None,
) -> APIRouter
Return a FastAPI APIRouter exposing the SSE progress bridge.
Mount at prefix="/jobs" to produce the canonical paths::
GET /jobs/api/job/{job_id}/progress/stream
GET /jobs/api/job/{job_id}/state
Parameters¶
pg_pool:
asyncpg connection pool for snapshot reads and poll-state queries.
redis_client:
redis.asyncio.Redis instance, or None when Redis is not
configured. If None, the SSE endpoint returns HTTP 503.
schema:
PostgreSQL schema name (default "taskq").
auth_dependency:
Optional FastAPI dependency callable; if provided it is injected via
Depends() on all routes (same pattern as
taskq.web.admin.create_router). Outside a dev environment
(TASKQ_ENVIRONMENT not dev/development) the factory
raises RuntimeError when it is omitted, unless
TASKQ_PROGRESS_REQUIRE_AUTH=false suppresses the check; serving
without auth always logs a warning.
sse_heartbeat_interval:
Cadence for ': keepalive' SSE comments (default 15 s).
max_sse_connections:
Maximum concurrent progress streams this process will serve; further
connections get HTTP 429. Defaults to
TASKQ_PROGRESS_MAX_SSE_CONNECTIONS (50). Each stream holds a Redis
pubsub subscription and an asyncio task for as long as the client stays
connected, so an uncapped endpoint lets any principal who can reach the
route exhaust Redis connections, event-loop tasks and file descriptors
on the app hosting the pipeline. The admin /sse/{topic} endpoint has
had such a cap; this one did not.
resolve_pg_pool / resolve_redis_client:
Per-request resolvers for the pool and the Redis client, used
instead of pg_pool / redis_client when given. A host whose pool
is replaced while it runs - taskq ui serve rebuilds it on a
credential rotation - resolves the live one from its own state on
every request rather than serving from the pool this router was
constructed with, which that rotation has closed. Each is a
FastAPI dependency: it may declare request: Request.
session_verifier:
Optional async re-check for long-lived SSE streams
(#316): Callable[[Request], Awaitable[bool]] returning whether the
session that opened the stream is still valid. Live streams re-invoke
it before every yielded event and at every keepalive tick and end on
failure, so a session revoked mid-stream (secret rotation, expiry, an
allowlist change) stops receiving frames within one keepalive
interval, capped at 60 s. Each invocation is bounded by
SESSION_RECHECK_TIMEOUT_SECS (5 s): a verifier that hangs or
outlives the bound is treated as failure -- fail closed -- so a wedged
host verifier cannot freeze the generator inside its keepalive path.
When omitted, the router derives it from the session_verifier
attribute the taskq auth dependencies (create_auth_dependency,
token_auth) attach to the callable they return. A host supplying
its own auth_dependency MUST pass session_verifier explicitly
to keep the re-check: without the attribute the router logs a
one-per-router progress-stream-no-session-verifier warning and
the streams authenticate once, with no per-frame re-check.
Source code in src/taskq/web/progress.py
521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 | |
Admin UI router factory¶
_factory ¶
Admin UI router factory: Jinja2 setup, auth hook, route registration.
Importing this module requires the taskq[fastapi] optional extra.
GZipStaticOnly ¶
Bases: GZipMiddleware
GZip only static assets (/static/*), not HTML or JSON responses.
__call__
async
¶
Source code in src/taskq/web/admin/_factory.py
ActorFirePolicyLike ¶
Bases: Protocol
Structural twin of worker.cron_loop.ActorFirePolicy.
The admin package must stay importable without the worker
(test_no_worker_import), so the run-now handler cannot name the
cron loop's class at module level; this Protocol pins the two
attributes the fire path needs. The cron loop's frozen dataclass
satisfies it structurally, and so does any host-supplied mapping
value with the same shape.
AdminBundle
dataclass
¶
AdminBundle(
router: APIRouter,
templates: Environment,
pg_pool: Pool,
schema: str,
redis_client: Any | None,
settings: TaskQSettings,
base_path: str,
backend: Backend | None = None,
rate_limit_registry: RateLimitRegistry | None = None,
actor_fire_policies: Mapping[str, ActorFirePolicyLike]
| None = None,
session_verifier: Callable[[Request], Awaitable[bool]]
| None = None,
)
Returned by create_router(); contains the router and all app.state values.
Pass this to setup_admin_state(app, bundle) in your lifespan before
the first request, then mount bundle.router via app.include_router.
rate_limit_registry scopes the registry the rate-limit/reservation
pages read; None resolves to the module singleton (the default ,
same-process behavior is unchanged).
refresh_db_clock_offset
async
¶
Re-measure the app-to-database clock offset, at most once per TTL.
Installed as a router-level dependency so every admin request keeps the
offset fresh for the (synchronous) Jinja filter. A failed probe keeps
the previous offset and is logged, once per back-off window, as
admin-clock-offset-probe-failed: a clock probe must never take
down a page, and a slightly stale offset is still far closer to the
truth than ignoring skew entirely - but every relative age on every
page renders against it, so a probe that keeps failing has to be
visible rather than silently ageing what the operator reads.
Source code in src/taskq/web/admin/_factory.py
get_realtime_mode
async
¶
Return (realtime_mode, mode_label) using a 5 s server-side cache.
realtime_mode ∈ {"realtime", "polling", "polling-degraded"}. Cache is module-level, one entry covers all admin UI routes on the same process.
Source code in src/taskq/web/admin/_factory.py
get_pg_pool ¶
Dependency: yields the asyncpg pool from app.state.
The raw pool - for the streams and library calls that manage their own
checkouts. Request handlers that run queries take :class:BoundedPool
through :func:get_admin_pool instead, whose every checkout is
bounded.
Source code in src/taskq/web/admin/_factory.py
get_settings ¶
get_admin_pool ¶
get_admin_pool(
pool: Pool = Depends(get_pg_pool),
settings: TaskQSettings = Depends(get_settings),
) -> BoundedPool
Dependency: the admin pool with bounded checkouts (see :class:BoundedPool).
Source code in src/taskq/web/admin/_factory.py
get_backend ¶
get_actor_fire_policies ¶
Dependency: the host's actor fire policies, if it supplied any.
setup_admin_state always sets the key when the bundle carried a
mapping; the getattr fallback keeps hand-assembled app.state
setups (which never set the key) working exactly as before: no
policies, no stamping, the documented residual.
Source code in src/taskq/web/admin/_factory.py
get_rl_registry ¶
Dependency: yields the RateLimitRegistry from app.state.
setup_admin_state always sets the key (bundle instance or
singleton). The getattr fallback keeps hand-assembled
app.state setups (which never set the key) working exactly as
today: the module singleton. Internal, deliberately not exported.
Source code in src/taskq/web/admin/_factory.py
get_schema ¶
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.
Source code in src/taskq/web/admin/_factory.py
get_redis_client ¶
get_session_verifier ¶
Dependency: the SSE session re-check from app.state (#316).
Long-lived SSE streams re-invoke it (before every yielded event, at every
keepalive tick) so a session revoked mid-stream stops receiving frames.
getattr with a None default rather than an attribute read: a host
that mounted the router without calling setup_admin_state -- the state
the other page dependencies already require -- gets an unrechecked stream
(authenticate-once) rather than a 500 on every /sse route.
Source code in src/taskq/web/admin/_factory.py
get_templates ¶
get_realtime_ctx
async
¶
Dependency: returns (realtime_mode, mode_label) for template rendering.
get_base_path ¶
get_csrf_token ¶
Dependency: returns the CSRF token.
Prefers the token set by _CsrfRoute via request.state
so the form hidden field and the cookie always carry the same value.
Falls back to the cookie (present from a prior GET), then generates
a fresh token.
Source code in src/taskq/web/admin/_factory.py
validate_csrf
async
¶
Dependency: validates the synchronizer-token CSRF on POST requests.
Source code in src/taskq/web/admin/_factory.py
require_actions_enabled
async
¶
Dependency: refuses mutations on a deployment that disabled them.
Declared BEFORE validate_csrf on every mutation route (FastAPI
resolves dependencies in declaration order): whether actions are
enabled is a configuration state, safe to answer before any token
validation -- an operator who POSTs to a disabled deployment gets the
actionable config message, never a CSRF error that reads as a bug.
The token still guards every route when actions ARE enabled (the
enabled-check is a precondition, not a replacement).
Source code in src/taskq/web/admin/_factory.py
setup_admin_state ¶
Populate app.state from bundle so route handler dependencies resolve.
Call this in your FastAPI lifespan after creating the bundle and before the first request arrives.
This also installs the admin error handlers on the host app (U1/U2):
the Postgres-connection failure family becomes a 503 (the JSON
envelope {"detail": "postgres_unavailable"} with Retry-After
for the machine routes, a branded self-retrying HTML page for the
operator routes) and the HTML-wanting 4xxs (a missing job's 404, a
refused filter's 400, an unparseable parameter's 422) render the
error page instead of a JSON wall. The API-ish routes (/api/*,
/jobs/count, /sse/*) always keep JSON, and a host route
outside the admin base path is never touched.
Source code in src/taskq/web/admin/_factory.py
get_principal ¶
Dependency: the principal the auth dependency returned for this request.
An :class:~taskq.web.admin.auth.IdentityClaims for the shipped SSO
dependencies, whatever a host's custom auth_dependency returns, or
None on the no-auth dev path (resolved to anonymous at the
audit boundary). Mutation handlers pass it straight through to
:func:taskq.web.admin._audit.record_admin_action.
Source code in src/taskq/web/admin/_factory.py
create_router ¶
create_router(
pg_pool: Pool,
*,
schema: str = "taskq",
redis_client: Any | None = None,
auth_dependency: Callable[..., Any] | None = None,
base_path: str = "",
backend: Backend | None = None,
rate_limit_registry: RateLimitRegistry | None = None,
actor_fire_policies: Mapping[str, ActorFirePolicyLike]
| None = None,
session_verifier: Callable[[Request], Awaitable[bool]]
| None = None,
) -> AdminBundle
Create the admin UI FastAPI router.
Route handlers access shared resources (pool, schema, redis, settings,
templates) via Depends(get_pg_pool) etc., which read from
request.app.state. Call setup_admin_state(app, bundle) in your
lifespan to populate those keys, then mount bundle.router at your
chosen prefix via app.include_router.
base_path must match the prefix passed to include_router (e.g.
"/admin"). It is injected as a Jinja2 global so templates can build
prefix-safe URLs with {{ base_path }}/queues etc.
rate_limit_registry is an optional owned :class:RateLimitRegistry
the admin pages read configured primitives from (e.g. the API-process
instance in a multi-process deployment). Default None resolves to
the module singleton, same-process behavior is unchanged.
actor_fire_policies is an optional mapping from actor name to the
fire policy that actor's ActorRef declares (singleton, max_pending),
the same mapping the worker bootstrap hands the cron tick. The run-now
fire path consults it because the stored actor_config row cannot
carry code-declared flags: without it a run-now fire for a singleton
actor lands WITHOUT metadata["singleton"] -- invisible to the
jobs_singleton_uniq index and to every later singleton preflight,
the "at most one active job" contract silently gone from the admin
surface. Pass the worker's mapping when the admin app is mounted in a
process that has the registry (the worker itself); None keeps the
residual: a standalone admin process cannot know, and the run-now fire
stamps nothing (disclosed here and in ops.py, not silent).
session_verifier is the optional async re-check long-lived SSE streams
re-invoke (#316) -- Callable[[Request], Awaitable[bool]] returning
whether the session that opened the stream is still valid. When omitted,
it is derived from the session_verifier attribute the taskq auth
dependencies (create_auth_dependency, token_auth) attach to the
callable they return. A host supplying its own auth_dependency
without that attribute MUST pass session_verifier explicitly to keep
the re-check; otherwise the admin /sse/{topic} streams authenticate
once at subscribe with no mid-stream re-check, and a one-per-router
admin-sse-no-session-verifier warning fires at startup.
Source code in src/taskq/web/admin/_factory.py
812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 | |
Health router¶
health ¶
FastAPI router for /jobs/health/{live,ready}.
GET /jobs/health/metrics is served by taskq.contrib.prometheus.create_metrics_router (requires taskq[prometheus]) and must be mounted alongside this router.
Importing this module requires the taskq[fastapi] optional extra.
create_health_router ¶
Create a FastAPI router at /jobs/health/{live,ready}.
Captures deps via closure, no FastAPI dependency injection. Mount alongside create_metrics_router (taskq.contrib.prometheus) for the full /jobs/health surface including Prometheus metrics.