kickdown 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run *)"
5
+ ]
6
+ }
7
+ }
@@ -0,0 +1 @@
1
+ * @vklokov
@@ -0,0 +1,40 @@
1
+ name: PR Verify
2
+ on:
3
+ pull_request:
4
+ types:
5
+ - opened
6
+ - reopened
7
+ - synchronize
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v6
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
16
+ with:
17
+ enable-cache: true
18
+
19
+ - name: Run Ruff
20
+ uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 #v3.6.1
21
+
22
+ - name: Check formatting
23
+ uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 #v3.6.1
24
+ with:
25
+ args: "format --check"
26
+
27
+ - name: Run ty
28
+ run: uv run ty check
29
+
30
+
31
+ test:
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v6
35
+ - name: Install uv
36
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
37
+ with:
38
+ enable-cache: true
39
+ - name: Run Pytest
40
+ run: uv run pytest -q
@@ -0,0 +1,19 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ tmp/
13
+
14
+ _client.py
15
+ _server.py
16
+
17
+ .mypy_cache
18
+ .pytest_cache
19
+ .ruff_cache
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,11 @@
1
+ {
2
+ "editor.tabSize": 2,
3
+ "editor.insertSpaces": true,
4
+ "editor.detectIndentation": true,
5
+ "editor.renderWhitespace": "all",
6
+ "editor.formatOnSave": true,
7
+ "python.defaultInterpreterPath": "./.venv/bin/python",
8
+ "[python]": {
9
+ "editor.defaultFormatter": "charliermarsh.ruff",
10
+ },
11
+ }
@@ -0,0 +1,152 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 - 2026-09-17
4
+
5
+ ### Added
6
+ - Tasks are no longer lost when a worker process dies mid-task: claiming a task atomically moves it from its queue into an in-flight list private to that consumer (`kickdown:inflight:{consumer_id}`), and it is only removed once the task finishes
7
+ - Every server keeps a heartbeat key (`kickdown:beat:{consumer_id}`) alive and registers itself in `kickdown:consumers`; a `Reaper` loop returns the in-flight tasks of any consumer whose heartbeat expired, guarded by a lock so servers do not race
8
+ - A clean shutdown returns the server's own unfinished tasks to their queues instead of leaving them for the reaper
9
+ - The admin dashboard's `In-flight` column now shows real per-queue counts
10
+
11
+ ### Changed
12
+ - Delivery is now at-least-once, so workers must be idempotent: a task interrupted by a crash runs again
13
+ - Queues are polled by a Lua claim script instead of `BLPOP`; polling is now interval-based (100ms) because no Redis primitive can block on a move across several keys at once
14
+ - Workers can be registered as classes with `perform` as a `classmethod`, not just as instances — `add_workers` accepts both, typed as the new `Worker` alias (`type[Performable] | Performable`); passing a class did not type-check before
15
+ - `Task` field defaults are declared so that type checkers see them — `Task(queue=..., operation=..., params=...)` now type-checks for users, which it did not before despite the package shipping `py.typed`
16
+
17
+ ## 0.4.0a1 - 2026-09-17
18
+
19
+ ### Changed
20
+ - Rewritten around an arbitrary number of queues instead of a single configured queue
21
+ - Workers (`Performable`) now declare their own `queue` and `operation`; the server derives the set of polled queues from the registered workers automatically
22
+ - Queues are polled in round-robin order with equal frequency — no priority between queues at this stage
23
+ - Workers are keyed by `(queue, operation)`, so the same `operation` name can be reused safely across different queues
24
+ - `Client.enqueue` now takes a single `Task` (carrying `queue`, `operation`, `params`, `jid`, `retry_count`) instead of a worker class and payload dict
25
+ - `Client.pending(queue)` / `Client.stats(queue)` moved onto the queue handle: `client.queue(name).pending()` / `.stats()`
26
+ - `Client` is now fully async (`enqueue`, `pending`, `close`), and supports `async with` instead of a sync context manager
27
+ - `Server` no longer takes a `Config` object; it's constructed directly with `redis_url` and `concurrency`
28
+ - Retries use a hardcoded exponential backoff (`1s * 1.5 ** attempt`) instead of a per-task `backoff_coefficient` — a failed task is retried with `retry_count` decremented and `attempt` incremented until `retry_count` reaches `0`
29
+ - Retries are no longer awaited in memory: the failed task is stored in a scheduled sorted set in Redis and picked up when due, so it survives a process restart and does not hold a concurrency slot for the duration of the delay
30
+ - On shutdown, the server now waits for in-flight tasks (including ones mid-retry) to finish before closing the Redis connection
31
+
32
+ ### Added
33
+ - A default logger (plain `logging.Logger`, text output to stdout) on `Client` and `Server` — no custom logger interface/protocol; swap it by assigning `server.logger = ...` / `client.logger = ...`
34
+ - `Client` logs when a task is accepted (enqueued)
35
+ - `Server` logs process start/shutdown (with the polled queues and concurrency), each task's start/completion, retries, permanent failures, and unrecognized operations — all messages include the task's `jid`
36
+ - `Server` always starts a small HTTP server (`web_port`, default `3030`) with `/live` and `/ready` for liveness/readiness probes; `/ready` checks Redis connectivity
37
+ - Per-queue `processed`/`failed` counters (`Stats` model) via `client.queue(name).stats()`; the consumer increments them on task completion, permanent failure (retries exhausted), and unrecognized operations
38
+ - `GET /admin` renders an HTML dashboard: total processed/failed/scheduled counters, and a per-queue table of pending/scheduled/in-flight counts (in-flight is a placeholder until tasks are tracked while they run); optionally gated behind HTTP Basic Auth via `Server(admin_username=..., admin_password=...)` (no auth if either is left unset)
39
+ - `Queue.purge()` drops every pending task in a queue and returns how many were dropped (scheduled tasks and counters are untouched); not exposed over HTTP
40
+ - `Server.enqueue(task)` pushes a task using the server's own Redis connection, so workers/hooks can schedule tasks without a separate `Client`
41
+ - A `Scheduler` loop runs inside every `Server`, moving due tasks from each queue's `kickdown:scheduled:{name}` set back into the queue (atomically, via a Lua script); a server only sweeps the queues it has workers for
42
+ - `Queue`, a handle bound to one queue name, carrying every per-queue operation (`push`, `pending`, `length`, `scheduled`, `stats`, `purge`, counters); obtained via `Client.queue(name)` / `Server.queue(name)`, and `Server.queues` now returns `Queue` objects instead of names
43
+
44
+ ### Removed
45
+ - The old `Loggable` protocol and the custom `Logger` wrapper class (replaced by the stdlib logger above)
46
+ - `Server.uptime()` / `Status` and the `humanize` dependency
47
+
48
+ ---
49
+
50
+ ## 0.3.3 - 2026-06-19
51
+
52
+ ### Changed
53
+ - Pinned `redis` to the 7.x line (`~=7.4`) to avoid an unintended upgrade to `redis` 8.x; previously `>=7.4.0` allowed any newer major version
54
+
55
+ ---
56
+
57
+ ## 0.3.2 - 2026-06-03
58
+
59
+ ### Added
60
+ - Job execution duration is now tracked and logged (in seconds) on job completion
61
+
62
+ ---
63
+
64
+ ## 0.3.1 - 2026-05-26
65
+
66
+ ### Added
67
+ - Lifecycle hooks: `Server.on_startup` and `Server.on_shutdown` register async callbacks for server startup and shutdown
68
+ - Usable as decorators or called directly with a callable argument
69
+ - Startup hooks run after the Redis connection is verified, before the consumer starts
70
+ - Shutdown hooks run after the consumer is stopped, before the Redis connection is closed
71
+ - Exceptions in hooks are logged and do not crash the server
72
+
73
+ ---
74
+
75
+ ## 0.3.0 - 2026-05-21
76
+
77
+ ### Added
78
+ - Retry mechanism for failed jobs
79
+ - `Client.enqueue` accepts `retry_count` (default: `1`) and `backoff_coefficient` (default: `1.5`)
80
+ - Both values are stored on the `Job` model and travel with the job through the queue
81
+ - On failure, the consumer re-enqueues the job with an exponential backoff delay (`backoff_coefficient ** attempt` seconds) until retries are exhausted
82
+ - Once all retries are exhausted the job is marked failed as before
83
+ - Jobs log a warning on each retry attempt and an error only when permanently failed
84
+
85
+ ---
86
+
87
+ ## 0.2.8 - 2026-05-20
88
+
89
+ ### Changed
90
+ - `Server.add_worker` now accepts a worker **instance** (`Performable`) — the same instance is reused across all jobs of that type
91
+ - `Client.enqueue` accepts a worker **class** (`type[Performable]`) — no instantiation cost at enqueue time, only the class name is needed to build the job
92
+ - Healthcheck rewritten with FastAPI + uvicorn, replacing the manual raw TCP/HTTP server
93
+ - `GET /live` — always returns 200, used as a liveness probe
94
+ - `GET /ready` — checks consumer heartbeat and Redis connectivity (`store.ping`); returns 503 with reason on failure
95
+ - Job IDs switched from `uuid4` (stdlib) to `uuid7` (`uuid_extensions`), providing time-sortable identifiers
96
+
97
+ ### Fixed
98
+ - `config.queue` was logged as a bound method object; now called correctly as `config.queue()`
99
+
100
+ ### Dependencies
101
+ - Added `fastapi>=0.136.1`
102
+ - Added `uvicorn>=0.34.0`
103
+
104
+ ---
105
+
106
+ ## 0.2.6 - 2026-05-20
107
+
108
+ ### Changed
109
+ - All Redis operations extracted into a new internal `Store` class (`kickdown/store.py`)
110
+ - Redis key names (`kickdown:queue:*`, `kickdown:stats:*`) are now centralized in `Store`
111
+ - `Consumer` and `Client` no longer interact with Redis directly — all calls go through `Store`
112
+ - `asyncio.to_thread` wrapping for blocking Redis calls moved from `Consumer` into `Store`
113
+
114
+ ---
115
+
116
+ ## 0.2.5 - 2026-05-20
117
+
118
+ ### Changed
119
+ - Workers are now registered on `Server` via `add_worker()` instead of being passed to `Config`
120
+ - `Server` lazily initializes the consumer on `start()`, decoupling construction from Redis connection
121
+ - `Consumer` receives workers directly rather than reading them from `Config`
122
+
123
+ ### Added
124
+ - `Server.uptime()` returns a human-readable duration (e.g. `"1 day, 4 hours"`) via the `humanize` library
125
+ - `Status` response includes an `uptime` field
126
+ - `Server.start()` raises `RuntimeError` if no workers have been registered
127
+
128
+ ### Fixed
129
+ - `Consumer.is_ok` converted from a method to a property, fixing incorrect truthy evaluation in `status()`
130
+
131
+ ---
132
+
133
+ ## 0.2.4 — 2026-05-19
134
+
135
+ ### Changed
136
+ - Queue names are now raw identifiers (e.g. `"default"`, `"emails"`); the full Redis key is constructed internally as `kickdown:queue:{name}`
137
+ - Stats counters moved from a Redis hash to separate keys (`kickdown:stats:processed`, `kickdown:stats:failed`), incremented with `INCR`
138
+
139
+ ---
140
+
141
+ ## 0.2.3 — 2026-05-19
142
+
143
+ ### Added
144
+ - `Client.stats()` returns a `Stats` model with cumulative `processed` and `failed` job counts
145
+ - Consumer increments `kickdown.stats.processed` / `kickdown.stats.failed` in Redis after each job completes or raises
146
+
147
+ ---
148
+
149
+ ## 0.2.2 — 2026-05-19
150
+
151
+ ### Added
152
+ - `Client.pending()` returns a list of jobs currently waiting in the queue (non-destructive)
kickdown-0.4.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vladimir Klokov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,314 @@
1
+ Metadata-Version: 2.5
2
+ Name: kickdown
3
+ Version: 0.4.0
4
+ Summary: A Redis-backed background job queue for Python
5
+ Project-URL: Homepage, https://github.com/vklokov/kickdown
6
+ Project-URL: Repository, https://github.com/vklokov/kickdown
7
+ Project-URL: Issues, https://github.com/vklokov/kickdown/issues
8
+ Project-URL: Changelog, https://github.com/vklokov/kickdown/blob/main/CHANGELOG.md
9
+ Author-email: Vladimir Klokov <klokov.dev@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: asyncio,background,jobs,queue,redis,tasks,worker
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: System :: Distributed Computing
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.13
23
+ Requires-Dist: fastapi>=0.136.1
24
+ Requires-Dist: pydantic>=2.13.4
25
+ Requires-Dist: redis~=7.4
26
+ Requires-Dist: uuid7>=0.1.0
27
+ Requires-Dist: uvicorn>=0.34.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # kickdown
31
+
32
+ A Redis-backed background job queue for Python.
33
+
34
+ Requires Python 3.13+ and **Redis 6.2 or newer** (the queue relies on `LMOVE`
35
+ and on server-side Lua scripts introduced in that release).
36
+
37
+ ## Installation
38
+
39
+ ```sh
40
+ uv add kickdown
41
+ ```
42
+
43
+ Pre-releases are not picked up by default, so ask for one explicitly:
44
+
45
+ ```sh
46
+ uv add kickdown --prerelease=allow
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ### Defining a worker
52
+
53
+ A worker declares which queue it consumes from, which `operation` name
54
+ identifies it, and an async `perform`:
55
+
56
+ ```python
57
+ class SendEmailWorker:
58
+ queue = "emails"
59
+ operation = "send_email"
60
+
61
+ @classmethod
62
+ async def perform(cls, payload: dict) -> None:
63
+ recipient = payload["to"]
64
+ # ... send email
65
+ ```
66
+
67
+ Workers are registered as classes, so nothing has to be instantiated to run a
68
+ task. If a worker needs per-instance state, register an instance instead — with
69
+ `perform` as a regular `async def perform(self, payload)`; both forms are
70
+ accepted, and `Worker` is the type covering them.
71
+
72
+ Any number of queues is supported — a worker's `queue` attribute is what
73
+ determines which Redis list it consumes from. The server automatically polls
74
+ every queue that has at least one registered worker.
75
+
76
+ ### Running the server
77
+
78
+ ```python
79
+ import asyncio
80
+ from kickdown import Server
81
+ from your_workers import SendEmailWorker, ExportReportWorker
82
+
83
+ server = Server(
84
+ redis_url="redis://localhost:6379",
85
+ concurrency=5, # optional, default: 1 - max tasks processed concurrently
86
+ )
87
+ server.add_workers(SendEmailWorker, ExportReportWorker)
88
+
89
+ asyncio.run(server.run())
90
+ ```
91
+
92
+ `add_workers` accepts any number of workers, as classes or as instances.
93
+ Workers are resolved by their `(queue, operation)` pair, so the same
94
+ `operation` name can be reused safely across different queues.
95
+
96
+ Queues are polled with equal frequency in round-robin order — there is
97
+ currently no notion of priority between queues.
98
+
99
+ ### Enqueueing jobs
100
+
101
+ ```python
102
+ from kickdown import Client, Task
103
+
104
+ client = Client(redis_url="redis://localhost:6379")
105
+
106
+ task = Task(
107
+ queue="emails",
108
+ operation="send_email",
109
+ params={"to": "user@example.com"},
110
+ )
111
+ jid = await client.enqueue(task)
112
+ ```
113
+
114
+ `enqueue` returns the job ID (`jid`) that can be used for tracing. It is
115
+ generated automatically (a time-sortable `uuid7`) if not set explicitly on
116
+ the `Task`.
117
+
118
+ `Client` can also be used as an async context manager, which closes the
119
+ underlying Redis connection on exit:
120
+
121
+ ```python
122
+ async with Client(redis_url="redis://localhost:6379") as client:
123
+ await client.enqueue(task)
124
+ ```
125
+
126
+ `Server` can enqueue tasks too, using the same Redis connection — handy for
127
+ a worker that needs to schedule a follow-up task, or for enqueueing from a
128
+ startup hook, without opening a separate `Client`:
129
+
130
+ ```python
131
+ jid = await server.enqueue(task)
132
+ ```
133
+
134
+ #### Retries
135
+
136
+ `retry_count` (default `1`) on `Task` sets how many times a failed task is
137
+ retried before being dropped. On failure, the consumer re-enqueues the task
138
+ with `retry_count` decremented by one and `attempt` incremented by one, after
139
+ an exponentially growing delay. Once `retry_count` reaches `0` the task is
140
+ dropped.
141
+
142
+ The delay is `1s * 1.5 ** attempt` — both the base delay and the backoff
143
+ coefficient are fixed in the library and cannot be configured per task:
144
+
145
+ | Retry | Delay |
146
+ | ----- | ----- |
147
+ | 1st | 1.0s |
148
+ | 2nd | 1.5s |
149
+ | 3rd | 2.3s |
150
+
151
+ A retried task is not held in memory while it waits: it goes into the queue's
152
+ scheduled sorted set in Redis (`kickdown:scheduled:{name}`, scored by its due
153
+ timestamp), and a scheduler loop running inside every server moves due tasks
154
+ back into the queue. So a retry survives a process restart, and the
155
+ concurrency slot is freed immediately instead of being blocked for the whole
156
+ delay.
157
+
158
+ A server only sweeps the queues it has workers for, which is also the only
159
+ place its own retries can land.
160
+
161
+ #### Crash recovery
162
+
163
+ A task is never held only in the worker process's memory. Claiming one moves
164
+ it, in a single Redis operation, from its queue into an in-flight list private
165
+ to that consumer (`kickdown:inflight:{consumer_id}`), where it stays until the
166
+ worker finishes. Each server keeps a heartbeat key alive while it runs, and a
167
+ reaper loop inside every server watches for consumers whose heartbeat has
168
+ expired: whatever is left in a dead consumer's list is pushed back into the
169
+ queue it came from. On a clean shutdown a server returns its own unfinished
170
+ tasks immediately instead of waiting to be reaped.
171
+
172
+ That makes delivery **at-least-once**: a task interrupted by a crash runs
173
+ again, and a task that crashed the process *after* its side effects completed
174
+ runs those side effects twice. Workers must be idempotent.
175
+
176
+ Two consequences worth knowing:
177
+
178
+ - A task that reliably kills its process (an OOM, say) will be requeued and
179
+ kill it again. There is no poison-pill limit yet.
180
+ - A reaped task keeps its `retry_count`: being interrupted is not counted as a
181
+ failed attempt.
182
+
183
+ Queue names are raw identifiers (e.g. `"default"`, `"emails"`). The client
184
+ constructs the full Redis key internally as `kickdown:queue:{name}`.
185
+
186
+ ### Inspecting a queue
187
+
188
+ `client.queue(name)` returns a `Queue` handle — every per-queue operation
189
+ lives on it, so the queue name is given once instead of on every call:
190
+
191
+ ```python
192
+ emails = client.queue("emails")
193
+
194
+ # Tasks waiting to be processed (non-destructive)
195
+ tasks = await emails.pending()
196
+ count = await emails.length()
197
+
198
+ # Tasks waiting for their retry delay to elapse
199
+ retries = await emails.scheduled()
200
+
201
+ # Cumulative processed/failed counters
202
+ stats = await emails.stats()
203
+ print(stats.processed, stats.failed)
204
+ ```
205
+
206
+ Enqueueing stays on the client (`client.enqueue(task)`): a `Task` already
207
+ carries its own `queue`, and that field remains the single source of truth
208
+ for routing.
209
+
210
+ `purge()` drops every task waiting in the queue and returns how many were
211
+ dropped. It does not touch scheduled tasks or the counters, and there is no
212
+ undo:
213
+
214
+ ```python
215
+ dropped = await emails.purge()
216
+ ```
217
+
218
+ `processed` counts tasks whose worker completed successfully; `failed`
219
+ counts tasks that were permanently dropped (retries exhausted, or no
220
+ worker registered for the task's `operation`).
221
+
222
+ ### Lifecycle hooks
223
+
224
+ Register async callbacks to run on server startup and shutdown — useful for
225
+ initialising shared resources like database pools.
226
+
227
+ ```python
228
+ server = Server(redis_url=...)
229
+
230
+
231
+ @server.on_startup
232
+ async def init_db():
233
+ app.db = await asyncpg.create_pool(DATABASE_URL)
234
+
235
+
236
+ @server.on_shutdown
237
+ async def close_db():
238
+ await app.db.close()
239
+ ```
240
+
241
+ Both methods can also be called directly instead of used as decorators:
242
+
243
+ ```python
244
+ server.on_startup(init_db)
245
+ server.on_shutdown(close_db)
246
+ ```
247
+
248
+ Startup hooks run after the Redis connection is verified, before the
249
+ consumer starts. Shutdown hooks run after the consumer stops (whether by
250
+ `SIGTERM`/`SIGINT` or an unexpected error), before the Redis connection is
251
+ closed.
252
+
253
+ ### Logging
254
+
255
+ `Client` and `Server` each expose a plain `logging.Logger` as `.logger`,
256
+ pre-configured to write text to stdout — there's no custom logger
257
+ interface to implement. To use your own logger (different format, handler,
258
+ sink, etc.), just assign it:
259
+
260
+ ```python
261
+ import logging
262
+
263
+ server.logger = logging.getLogger("myapp.kickdown")
264
+ client.logger = logging.getLogger("myapp.kickdown")
265
+ ```
266
+
267
+ `Client` logs when a task is accepted. `Server` logs process start/shutdown
268
+ (with the polled queues and concurrency), and each task's start,
269
+ completion, retries and permanent failures — every task-related message
270
+ includes the task's `jid`.
271
+
272
+ ### Healthcheck
273
+
274
+ `Server` always starts a small HTTP server for liveness/readiness probes,
275
+ on the port given by `web_port` (default `3030`):
276
+
277
+ ```python
278
+ server = Server(redis_url=..., web_port=3030)
279
+ ```
280
+
281
+ ```
282
+ GET /live -> 200 {"status": "ok"} # process is up
283
+ GET /ready -> 200 {"status": "ok"} # Redis reachable
284
+ -> 503 {"status": "redis unavailable"} # Redis unreachable
285
+ ```
286
+
287
+ ### Admin page
288
+
289
+ `GET /admin` renders an HTML dashboard: a summary block with total
290
+ processed/failed counters (aggregated across all queues, from the same
291
+ counters as `client.stats()`), and a table of every polled queue with its
292
+ current pending count (how many tasks are physically waiting in it).
293
+
294
+ By default it's open to anyone who can reach the port. To require HTTP
295
+ Basic Auth, set both `admin_username` and `admin_password`:
296
+
297
+ ```python
298
+ server = Server(
299
+ redis_url=...,
300
+ admin_username="alice",
301
+ admin_password="secret",
302
+ )
303
+ ```
304
+
305
+ If either is left unset, `/admin` requires no credentials.
306
+
307
+ ## Scaling
308
+
309
+ `Server.run()` uses a single asyncio event loop with an `asyncio.Semaphore`
310
+ to cap concurrent task execution within the process — this is a good fit
311
+ since `Performable.perform` is a coroutine. To scale across CPU cores or
312
+ machines, run multiple `Server` processes against the same Redis instance;
313
+ each process independently pops from the shared queues, so Redis balances
314
+ the work between them without any extra coordination.