kickdown 0.4.0a1__py3-none-any.whl

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.
kickdown/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ from .client import Client
2
+ from .models import Performable, Stats, Task
3
+ from .queue import Queue
4
+ from .server import Server
5
+
6
+ __all__ = [
7
+ "Client",
8
+ "Performable",
9
+ "Queue",
10
+ "Server",
11
+ "Stats",
12
+ "Task",
13
+ ]
kickdown/client.py ADDED
@@ -0,0 +1,32 @@
1
+ import asyncio
2
+
3
+ from .log import default_logger
4
+ from .models import Task
5
+ from .queue import Queue
6
+ from .store import Store
7
+
8
+
9
+ class Client:
10
+ def __init__(self, redis_url: str):
11
+ self._store = Store(redis_url)
12
+ self.logger = default_logger()
13
+
14
+ def queue(self, name: str) -> Queue:
15
+ return Queue(name, self._store)
16
+
17
+ async def enqueue(self, task: Task) -> str:
18
+ await self.queue(task.queue).push(task)
19
+ self.logger.info(
20
+ f"jid={task.jid} accepted",
21
+ extra={"queue": task.queue, "operation": task.operation},
22
+ )
23
+ return task.jid
24
+
25
+ async def close(self):
26
+ await asyncio.to_thread(self._store.close)
27
+
28
+ async def __aenter__(self):
29
+ return self
30
+
31
+ async def __aexit__(self, *_exc_info):
32
+ await self.close()
kickdown/consumer.py ADDED
@@ -0,0 +1,144 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from collections import deque
5
+ from collections.abc import Awaitable, Callable, Mapping
6
+
7
+ from pydantic import ValidationError
8
+
9
+ from .log import default_logger
10
+ from .models import Performable, Task
11
+ from .queue import Queue
12
+ from .store import Store, StoreError
13
+
14
+ _default_pop_timeout = 5
15
+ _retry_delay = 1
16
+ _backoff_coefficient = 1.5
17
+
18
+
19
+ class Consumer:
20
+ def __init__(
21
+ self,
22
+ store: Store,
23
+ workers: Mapping[tuple[str, str], Performable],
24
+ concurrency: int = 1,
25
+ logger: logging.Logger | None = None,
26
+ ):
27
+ self._store = store
28
+ self._workers = workers
29
+ self._queues: dict[str, Queue] = {
30
+ name: Queue(name, store)
31
+ for name in sorted({worker.queue for worker in workers.values()})
32
+ }
33
+ self._order: deque[str] = deque(self._queues)
34
+ self._semaphore = asyncio.Semaphore(concurrency)
35
+ self.logger = logger or default_logger()
36
+ self._tasks: set[asyncio.Task] = set()
37
+
38
+ async def consume(self) -> None:
39
+ while True:
40
+ await self._semaphore.acquire()
41
+
42
+ try:
43
+ task = await asyncio.to_thread(
44
+ self._store.pop, self._poll_order(), _default_pop_timeout
45
+ )
46
+ except StoreError as err:
47
+ self._semaphore.release()
48
+ self.logger.error(
49
+ "redis error while popping task", extra={"error": str(err)}
50
+ )
51
+ await asyncio.sleep(_default_pop_timeout)
52
+ continue
53
+ except ValidationError as err:
54
+ self._semaphore.release()
55
+ self.logger.error(
56
+ "failed to parse task payload", extra={"error": str(err)}
57
+ )
58
+ continue
59
+
60
+ if task is None:
61
+ self._semaphore.release()
62
+ continue
63
+
64
+ handle = asyncio.create_task(self._run_task(task))
65
+ self._tasks.add(handle)
66
+ handle.add_done_callback(self._tasks.discard)
67
+
68
+ async def drain(self) -> None:
69
+ if self._tasks:
70
+ await asyncio.gather(*self._tasks, return_exceptions=True)
71
+
72
+ def _poll_order(self) -> list[str]:
73
+ order = list(self._order)
74
+ self._order.rotate(-1)
75
+ return order
76
+
77
+ def _queue(self, name: str) -> Queue:
78
+ # a payload naming a queue we do not serve is malformed, but its stats
79
+ # should still land on the queue it claims
80
+ return self._queues.get(name) or Queue(name, self._store)
81
+
82
+ async def _run_task(self, task: Task) -> None:
83
+ queue = self._queue(task.queue)
84
+ failure: Exception | None = None
85
+ try:
86
+ worker = self._workers.get((task.queue, task.operation))
87
+ if worker is None:
88
+ self.logger.error(
89
+ f"no worker registered for task jid={task.jid}",
90
+ extra={
91
+ "jid": task.jid,
92
+ "queue": task.queue,
93
+ "operation": task.operation,
94
+ },
95
+ )
96
+ await self._increment(queue.increment_failed)
97
+ return
98
+
99
+ self.logger.info(
100
+ f"jid={task.jid} started",
101
+ extra={"queue": task.queue, "operation": task.operation},
102
+ )
103
+ await worker.perform(task.params)
104
+ self.logger.info(f"jid={task.jid} done")
105
+ await self._increment(queue.increment_processed)
106
+ except Exception as err: # noqa: BLE001 - worker code is arbitrary; retry boundary must catch anything
107
+ failure = err
108
+ finally:
109
+ self._semaphore.release()
110
+
111
+ if failure is None:
112
+ return
113
+
114
+ if task.retry_count > 0:
115
+ delay = _retry_delay * _backoff_coefficient**task.attempt
116
+ self.logger.warning(
117
+ f"jid={task.jid} failed, retrying in {delay:.1f}s "
118
+ f"({task.retry_count} attempt(s) left)",
119
+ extra={"error": str(failure)},
120
+ )
121
+ retry_task = task.model_copy(
122
+ update={
123
+ "retry_count": task.retry_count - 1,
124
+ "attempt": task.attempt + 1,
125
+ }
126
+ )
127
+ try:
128
+ await queue.schedule(retry_task, time.time() + delay)
129
+ except StoreError as schedule_err:
130
+ self.logger.error(
131
+ f"jid={task.jid} failed to schedule retry",
132
+ extra={"error": str(schedule_err)},
133
+ )
134
+ else:
135
+ self.logger.error(
136
+ f"jid={task.jid} failed permanently", extra={"error": str(failure)}
137
+ )
138
+ await self._increment(queue.increment_failed)
139
+
140
+ async def _increment(self, increment: Callable[[], Awaitable[None]]) -> None:
141
+ try:
142
+ await increment()
143
+ except StoreError as err:
144
+ self.logger.error("failed to update stats", extra={"error": str(err)})
kickdown/log.py ADDED
@@ -0,0 +1,14 @@
1
+ import logging
2
+ import sys
3
+
4
+ _logger_name = "kickdown"
5
+
6
+
7
+ def default_logger() -> logging.Logger:
8
+ logger = logging.getLogger(_logger_name)
9
+ if not logger.handlers:
10
+ handler = logging.StreamHandler(sys.stdout)
11
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
12
+ logger.addHandler(handler)
13
+ logger.setLevel(logging.INFO)
14
+ return logger
kickdown/models.py ADDED
@@ -0,0 +1,26 @@
1
+ from typing import Annotated, Protocol, runtime_checkable
2
+
3
+ from pydantic import BaseModel, Field
4
+ from uuid_extensions import uuid7str
5
+
6
+
7
+ @runtime_checkable
8
+ class Performable(Protocol):
9
+ queue: str
10
+ operation: str
11
+
12
+ async def perform(self, payload: dict) -> None: ...
13
+
14
+
15
+ class Task(BaseModel):
16
+ queue: str
17
+ operation: str
18
+ params: dict
19
+ jid: Annotated[str, Field(default_factory=lambda: uuid7str())]
20
+ retry_count: Annotated[int, Field(default=1)]
21
+ attempt: Annotated[int, Field(default=0)]
22
+
23
+
24
+ class Stats(BaseModel):
25
+ processed: int = 0
26
+ failed: int = 0
kickdown/py.typed ADDED
File without changes
kickdown/queue.py ADDED
@@ -0,0 +1,60 @@
1
+ import asyncio
2
+
3
+ from .models import Stats, Task
4
+ from .store import Store
5
+
6
+
7
+ class Queue:
8
+ """
9
+ A named queue: every per-queue operation, bound to one name.
10
+
11
+ Wraps the synchronous `Store` calls in threads, so callers stay async.
12
+ """
13
+
14
+ def __init__(self, name: str, store: Store):
15
+ self._name = name
16
+ self._store = store
17
+
18
+ @property
19
+ def name(self) -> str:
20
+ return self._name
21
+
22
+ def __repr__(self) -> str:
23
+ return f"Queue({self._name!r})"
24
+
25
+ async def push(self, task: Task) -> None:
26
+ await asyncio.to_thread(self._store.push, task)
27
+
28
+ async def schedule(self, task: Task, run_at: float) -> None:
29
+ await asyncio.to_thread(self._store.schedule, task, run_at)
30
+
31
+ async def pending(self) -> list[Task]:
32
+ return await asyncio.to_thread(self._store.pending, self._name)
33
+
34
+ async def length(self) -> int:
35
+ return await asyncio.to_thread(self._store.queue_length, self._name)
36
+
37
+ async def scheduled(self) -> list[Task]:
38
+ return await asyncio.to_thread(self._store.scheduled, self._name)
39
+
40
+ async def scheduled_length(self) -> int:
41
+ return await asyncio.to_thread(self._store.scheduled_length, self._name)
42
+
43
+ async def enqueue_due(self, now: float, limit: int) -> int:
44
+ return await asyncio.to_thread(self._store.enqueue_due, self._name, now, limit)
45
+
46
+ async def purge(self) -> int:
47
+ """Drops every pending task, returning how many were dropped.
48
+
49
+ Scheduled tasks and the processed/failed counters are left alone.
50
+ """
51
+ return await asyncio.to_thread(self._store.purge, self._name)
52
+
53
+ async def stats(self) -> Stats:
54
+ return await asyncio.to_thread(self._store.stats, self._name)
55
+
56
+ async def increment_processed(self) -> None:
57
+ await asyncio.to_thread(self._store.increment_processed, self._name)
58
+
59
+ async def increment_failed(self) -> None:
60
+ await asyncio.to_thread(self._store.increment_failed, self._name)
kickdown/scheduler.py ADDED
@@ -0,0 +1,47 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+
5
+ from .log import default_logger
6
+ from .queue import Queue
7
+ from .store import StoreError
8
+
9
+ _default_poll_interval = 1
10
+ _default_batch_size = 100
11
+
12
+
13
+ class Scheduler:
14
+ """Moves tasks whose scheduled time has come back into their queues."""
15
+
16
+ def __init__(
17
+ self,
18
+ queues: list[Queue],
19
+ poll_interval: float = _default_poll_interval,
20
+ logger: logging.Logger | None = None,
21
+ ):
22
+ self._queues = queues
23
+ self._poll_interval = poll_interval
24
+ self.logger = logger or default_logger()
25
+
26
+ async def run(self) -> None:
27
+ while True:
28
+ await self._tick()
29
+ await asyncio.sleep(self._poll_interval)
30
+
31
+ async def _tick(self) -> None:
32
+ now = time.time()
33
+ for queue in self._queues:
34
+ try:
35
+ moved = await queue.enqueue_due(now, _default_batch_size)
36
+ except StoreError as err:
37
+ self.logger.error(
38
+ "redis error while enqueueing due tasks",
39
+ extra={"queue": queue.name, "error": str(err)},
40
+ )
41
+ continue
42
+
43
+ if moved:
44
+ self.logger.info(
45
+ f"scheduler enqueued {moved} due task(s)",
46
+ extra={"queue": queue.name},
47
+ )
kickdown/server.py ADDED
@@ -0,0 +1,151 @@
1
+ import asyncio
2
+ import signal
3
+ from collections.abc import Awaitable, Callable
4
+
5
+ from .consumer import Consumer
6
+ from .log import default_logger
7
+ from .models import Performable, Task
8
+ from .queue import Queue
9
+ from .scheduler import Scheduler
10
+ from .store import Store, StoreError
11
+ from .web import Web
12
+
13
+ Hook = Callable[[], Awaitable[None]]
14
+
15
+
16
+ class Server:
17
+ def __init__(
18
+ self,
19
+ redis_url: str,
20
+ concurrency: int = 1,
21
+ web_port: int = 3030,
22
+ admin_username: str | None = None,
23
+ admin_password: str | None = None,
24
+ ):
25
+ self._store = Store(redis_url)
26
+ self._concurrency = concurrency
27
+ self._web_port = web_port
28
+ self._admin_username = admin_username
29
+ self._admin_password = admin_password
30
+ self._startup_hooks: list[Hook] = []
31
+ self._shutdown_hooks: list[Hook] = []
32
+ self._worker: dict[tuple[str, str], Performable] = {}
33
+ self.logger = default_logger()
34
+
35
+ @property
36
+ def store(self) -> Store:
37
+ return self._store
38
+
39
+ @property
40
+ def queues(self) -> list[Queue]:
41
+ return [
42
+ Queue(name, self._store)
43
+ for name in sorted({worker.queue for worker in self._worker.values()})
44
+ ]
45
+
46
+ def queue(self, name: str) -> Queue:
47
+ return Queue(name, self._store)
48
+
49
+ def add_workers(self, *args: Performable):
50
+ for worker in args:
51
+ self._worker[(worker.queue, worker.operation)] = worker
52
+
53
+ async def enqueue(self, task: Task) -> str:
54
+ await self.queue(task.queue).push(task)
55
+ self.logger.info(
56
+ f"jid={task.jid} accepted",
57
+ extra={"queue": task.queue, "operation": task.operation},
58
+ )
59
+ return task.jid
60
+
61
+ def on_startup(self, fn: Hook) -> Hook:
62
+ self._startup_hooks.append(fn)
63
+ return fn
64
+
65
+ def on_shutdown(self, fn: Hook) -> Hook:
66
+ self._shutdown_hooks.append(fn)
67
+ return fn
68
+
69
+ async def run(self):
70
+ if not self._worker:
71
+ raise RuntimeError(
72
+ "No workers registered. Register them with add_workers() before running the server."
73
+ )
74
+
75
+ try:
76
+ await asyncio.to_thread(self._store.ping)
77
+ except StoreError as e:
78
+ raise RuntimeError(f"Redis connection failed: {e}") from e
79
+
80
+ consumer = Consumer(
81
+ store=self._store,
82
+ workers=self._worker,
83
+ concurrency=self._concurrency,
84
+ logger=self.logger,
85
+ )
86
+ scheduler = Scheduler(queues=self.queues, logger=self.logger)
87
+ web = Web(
88
+ port=self._web_port,
89
+ server=self,
90
+ admin_username=self._admin_username,
91
+ admin_password=self._admin_password,
92
+ )
93
+
94
+ await self._run_hooks(self._startup_hooks, "startup")
95
+
96
+ stop_event = asyncio.Event()
97
+ loop = asyncio.get_running_loop()
98
+ for sig in (signal.SIGTERM, signal.SIGINT):
99
+ loop.add_signal_handler(sig, stop_event.set)
100
+
101
+ self.logger.info(
102
+ f"server starting (queues={[q.name for q in self.queues]}, "
103
+ f"concurrency={self._concurrency}, web_port={self._web_port})"
104
+ )
105
+
106
+ consume_task = asyncio.create_task(consumer.consume(), name="consumer")
107
+ schedule_task = asyncio.create_task(scheduler.run(), name="scheduler")
108
+ web_task = asyncio.create_task(web.run(), name="web")
109
+ stop_task = asyncio.create_task(stop_event.wait(), name="stop")
110
+
111
+ try:
112
+ await asyncio.wait(
113
+ [consume_task, schedule_task, web_task, stop_task],
114
+ return_when=asyncio.FIRST_COMPLETED,
115
+ )
116
+
117
+ for task in (consume_task, schedule_task, web_task):
118
+ if task.done() and not task.cancelled():
119
+ exc = task.exception()
120
+ if exc is not None:
121
+ self.logger.error(
122
+ f"{task.get_name()} task failed unexpectedly",
123
+ extra={"error": str(exc)},
124
+ )
125
+ finally:
126
+ self.logger.info("server shutting down")
127
+ consume_task.cancel()
128
+ schedule_task.cancel()
129
+ web_task.cancel()
130
+ stop_task.cancel()
131
+ await asyncio.gather(
132
+ consume_task, schedule_task, web_task, stop_task, return_exceptions=True
133
+ )
134
+ await consumer.drain()
135
+
136
+ await self._run_hooks(self._shutdown_hooks, "shutdown")
137
+
138
+ await asyncio.to_thread(self._store.close)
139
+
140
+ async def _run_hooks(self, hooks: list[Hook], phase: str) -> None:
141
+ for hook in hooks:
142
+ try:
143
+ await hook()
144
+ except Exception as err: # noqa: BLE001 - hook code is arbitrary; must not abort the server
145
+ self.logger.error(
146
+ f"{phase} hook failed",
147
+ extra={
148
+ "hook": getattr(hook, "__name__", repr(hook)),
149
+ "error": str(err),
150
+ },
151
+ )
kickdown/store.py ADDED
@@ -0,0 +1,162 @@
1
+ from typing import cast
2
+
3
+ from redis import Redis
4
+ from redis.exceptions import RedisError
5
+
6
+ from .models import Stats, Task
7
+
8
+
9
+ class StoreError(Exception):
10
+ pass
11
+
12
+
13
+ # Moves due tasks from a queue's scheduled set (KEYS[1]) into the queue itself
14
+ # (KEYS[2]), atomically: a crash between the removal and the push would lose
15
+ # them. Only the payloads actually pushed are removed, so tasks that become due
16
+ # mid-script are left for the next sweep instead of being dropped.
17
+ _ENQUEUE_DUE_LUA = """
18
+ local due = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2])
19
+ if #due == 0 then
20
+ return 0
21
+ end
22
+ redis.call('RPUSH', KEYS[2], unpack(due))
23
+ redis.call('ZREM', KEYS[1], unpack(due))
24
+ return #due
25
+ """
26
+
27
+
28
+ class Store:
29
+ _QUEUE_PREFIX = "kickdown:queue:"
30
+ _STATS_PREFIX = "kickdown:stats:"
31
+ _SCHEDULED_PREFIX = "kickdown:scheduled:"
32
+
33
+ def __init__(self, redis_url: str):
34
+ self._redis = Redis.from_url(redis_url)
35
+ self._enqueue_due = self._redis.register_script(_ENQUEUE_DUE_LUA)
36
+
37
+ @classmethod
38
+ def queue_key(cls, name: str) -> str:
39
+ return f"{cls._QUEUE_PREFIX}{name}"
40
+
41
+ @classmethod
42
+ def scheduled_key(cls, name: str) -> str:
43
+ return f"{cls._SCHEDULED_PREFIX}{name}"
44
+
45
+ @classmethod
46
+ def _processed_key(cls, queue: str) -> str:
47
+ return f"{cls._STATS_PREFIX}{queue}:processed"
48
+
49
+ @classmethod
50
+ def _failed_key(cls, queue: str) -> str:
51
+ return f"{cls._STATS_PREFIX}{queue}:failed"
52
+
53
+ def ping(self) -> None:
54
+ try:
55
+ self._redis.ping()
56
+ except RedisError as e:
57
+ raise StoreError(str(e)) from e
58
+
59
+ def push(self, task: Task) -> None:
60
+ try:
61
+ self._redis.rpush(self.queue_key(task.queue), task.model_dump_json())
62
+ except RedisError as e:
63
+ raise StoreError(str(e)) from e
64
+
65
+ def schedule(self, task: Task, run_at: float) -> None:
66
+ try:
67
+ self._redis.zadd(
68
+ self.scheduled_key(task.queue), {task.model_dump_json(): run_at}
69
+ )
70
+ except RedisError as e:
71
+ raise StoreError(str(e)) from e
72
+
73
+ def enqueue_due(self, queue: str, now: float, limit: int) -> int:
74
+ try:
75
+ moved = self._enqueue_due(
76
+ keys=[self.scheduled_key(queue), self.queue_key(queue)],
77
+ args=[now, limit],
78
+ )
79
+ except RedisError as e:
80
+ raise StoreError(str(e)) from e
81
+ return cast(int, moved)
82
+
83
+ def scheduled(self, queue: str) -> list[Task]:
84
+ try:
85
+ raw_tasks = cast(
86
+ list[bytes], self._redis.zrange(self.scheduled_key(queue), 0, -1)
87
+ )
88
+ return [Task.model_validate_json(raw) for raw in raw_tasks]
89
+ except RedisError as e:
90
+ raise StoreError(str(e)) from e
91
+
92
+ def scheduled_length(self, queue: str) -> int:
93
+ try:
94
+ return cast(int, self._redis.zcard(self.scheduled_key(queue)))
95
+ except RedisError as e:
96
+ raise StoreError(str(e)) from e
97
+
98
+ def pending(self, queue: str) -> list[Task]:
99
+ try:
100
+ raw_tasks = cast(
101
+ list[bytes], self._redis.lrange(self.queue_key(queue), 0, -1)
102
+ )
103
+ return [Task.model_validate_json(raw) for raw in raw_tasks]
104
+ except RedisError as e:
105
+ raise StoreError(str(e)) from e
106
+
107
+ def purge(self, queue: str) -> int:
108
+ try:
109
+ # MULTI/EXEC so the reported count is exactly what was dropped
110
+ pipe = self._redis.pipeline()
111
+ pipe.llen(self.queue_key(queue))
112
+ pipe.delete(self.queue_key(queue))
113
+ length, _ = cast(tuple[int, int], pipe.execute())
114
+ except RedisError as e:
115
+ raise StoreError(str(e)) from e
116
+ return length
117
+
118
+ def queue_length(self, queue: str) -> int:
119
+ try:
120
+ return cast(int, self._redis.llen(self.queue_key(queue)))
121
+ except RedisError as e:
122
+ raise StoreError(str(e)) from e
123
+
124
+ def pop(self, queues: list[str], timeout: int) -> Task | None:
125
+ try:
126
+ keys = [self.queue_key(queue) for queue in queues]
127
+ result = cast(
128
+ tuple[bytes, bytes] | None,
129
+ self._redis.blpop(keys, timeout=timeout),
130
+ )
131
+ except RedisError as e:
132
+ raise StoreError(str(e)) from e
133
+ if result is None:
134
+ return None
135
+ _, raw = result
136
+ return Task.model_validate_json(raw)
137
+
138
+ def increment_processed(self, queue: str) -> None:
139
+ try:
140
+ self._redis.incr(self._processed_key(queue))
141
+ except RedisError as e:
142
+ raise StoreError(str(e)) from e
143
+
144
+ def increment_failed(self, queue: str) -> None:
145
+ try:
146
+ self._redis.incr(self._failed_key(queue))
147
+ except RedisError as e:
148
+ raise StoreError(str(e)) from e
149
+
150
+ def stats(self, queue: str) -> Stats:
151
+ try:
152
+ processed = cast(bytes | None, self._redis.get(self._processed_key(queue)))
153
+ failed = cast(bytes | None, self._redis.get(self._failed_key(queue)))
154
+ return Stats(
155
+ processed=int(processed) if processed else 0,
156
+ failed=int(failed) if failed else 0,
157
+ )
158
+ except RedisError as e:
159
+ raise StoreError(str(e)) from e
160
+
161
+ def close(self) -> None:
162
+ self._redis.close()
@@ -0,0 +1,98 @@
1
+ import asyncio
2
+ import secrets
3
+ from html import escape
4
+ from pathlib import Path
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ import uvicorn
8
+ from fastapi import Depends, FastAPI, HTTPException
9
+ from fastapi.responses import HTMLResponse, JSONResponse
10
+ from fastapi.security import HTTPBasic, HTTPBasicCredentials
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from ..queue import Queue
14
+ from ..store import Store, StoreError
15
+
16
+ _ASSETS_DIR = Path(__file__).parent / "assets"
17
+ _ADMIN_TEMPLATE = (Path(__file__).parent / "admin.html").read_text()
18
+
19
+ _security = HTTPBasic(auto_error=False)
20
+
21
+
22
+ @runtime_checkable
23
+ class ServerLike(Protocol):
24
+ @property
25
+ def store(self) -> Store: ...
26
+
27
+ @property
28
+ def queues(self) -> list[Queue]: ...
29
+
30
+
31
+ class Web:
32
+ def __init__(
33
+ self,
34
+ port: int,
35
+ server: ServerLike,
36
+ admin_username: str | None = None,
37
+ admin_password: str | None = None,
38
+ ):
39
+ self._port = port
40
+ self._server = server
41
+ self._admin_username = admin_username
42
+ self._admin_password = admin_password
43
+ self._api = FastAPI()
44
+ self._api.get("/live")(self._live)
45
+ self._api.get("/ready", response_model=None)(self._ready)
46
+ self._api.get("/admin", response_class=HTMLResponse)(self._admin)
47
+ self._api.mount("/admin/assets", StaticFiles(directory=_ASSETS_DIR), name="admin-assets")
48
+
49
+ async def _live(self) -> dict:
50
+ return {"status": "ok"}
51
+
52
+ async def _ready(self) -> dict | JSONResponse:
53
+ try:
54
+ await asyncio.to_thread(self._server.store.ping)
55
+ except StoreError:
56
+ return JSONResponse(status_code=503, content={"status": "redis unavailable"})
57
+ return {"status": "ok"}
58
+
59
+ def _authorized(self, credentials: HTTPBasicCredentials | None) -> bool:
60
+ if not (self._admin_username and self._admin_password):
61
+ return True
62
+ return (
63
+ credentials is not None
64
+ and secrets.compare_digest(credentials.username, self._admin_username)
65
+ and secrets.compare_digest(credentials.password, self._admin_password)
66
+ )
67
+
68
+ async def _admin(self, credentials: HTTPBasicCredentials | None = Depends(_security)) -> HTMLResponse:
69
+ if not self._authorized(credentials):
70
+ raise HTTPException(status_code=401, detail="Unauthorized", headers={"WWW-Authenticate": "Basic"})
71
+ return HTMLResponse(await self._render_admin())
72
+
73
+ async def _render_admin(self) -> str:
74
+ queues = self._server.queues
75
+ stats = await asyncio.gather(*(queue.stats() for queue in queues))
76
+ lengths = await asyncio.gather(*(queue.length() for queue in queues))
77
+ scheduled = await asyncio.gather(*(queue.scheduled_length() for queue in queues))
78
+
79
+ if queues:
80
+ rows = "\n".join(
81
+ f"<tr><td>{escape(queue.name)}</td><td>{length}</td>"
82
+ f"<td>{due}</td><td class=\"muted\">&mdash;</td></tr>"
83
+ for queue, length, due in zip(queues, lengths, scheduled, strict=True)
84
+ )
85
+ else:
86
+ rows = '<tr><td colspan="4">No queues registered</td></tr>'
87
+
88
+ return (
89
+ _ADMIN_TEMPLATE.replace("__TOTAL_PROCESSED__", str(sum(s.processed for s in stats)))
90
+ .replace("__TOTAL_FAILED__", str(sum(s.failed for s in stats)))
91
+ .replace("__TOTAL_SCHEDULED__", str(sum(scheduled)))
92
+ .replace("__QUEUE_ROWS__", rows)
93
+ )
94
+
95
+ async def run(self) -> None:
96
+ config = uvicorn.Config(self._api, host="0.0.0.0", port=self._port, log_level="error")
97
+ server = uvicorn.Server(config)
98
+ await server.serve()
@@ -0,0 +1,45 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>kickdown admin</title>
7
+ <link rel="stylesheet" href="/admin/assets/admin.css">
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <h1>kickdown</h1>
12
+ </header>
13
+
14
+ <section class="summary">
15
+ <div class="stat processed">
16
+ <span class="value">__TOTAL_PROCESSED__</span>
17
+ <span class="label">processed</span>
18
+ </div>
19
+ <div class="stat failed">
20
+ <span class="value">__TOTAL_FAILED__</span>
21
+ <span class="label">failed</span>
22
+ </div>
23
+ <div class="stat scheduled">
24
+ <span class="value">__TOTAL_SCHEDULED__</span>
25
+ <span class="label">scheduled</span>
26
+ </div>
27
+ </section>
28
+
29
+ <table class="queues">
30
+ <thead>
31
+ <tr>
32
+ <th>Queue</th>
33
+ <th>Pending</th>
34
+ <th>Scheduled</th>
35
+ <th>In-flight</th>
36
+ </tr>
37
+ </thead>
38
+ <tbody>
39
+ __QUEUE_ROWS__
40
+ </tbody>
41
+ </table>
42
+
43
+ <script src="/admin/assets/admin.js"></script>
44
+ </body>
45
+ </html>
@@ -0,0 +1,95 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ --bg: #0f1115;
4
+ --panel: #171a21;
5
+ --text: #e6e6e6;
6
+ --muted: #9aa0ab;
7
+ --danger: #ff5c5c;
8
+ --pending: #e5a13a;
9
+ --border: #262b35;
10
+ }
11
+
12
+ * {
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ body {
17
+ margin: 0;
18
+ padding: 2rem;
19
+ background: var(--bg);
20
+ color: var(--text);
21
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
22
+ }
23
+
24
+ h1 {
25
+ margin: 0 0 1.5rem;
26
+ font-size: 1.25rem;
27
+ font-weight: 600;
28
+ }
29
+
30
+ .summary {
31
+ display: flex;
32
+ gap: 1rem;
33
+ margin-bottom: 2rem;
34
+ }
35
+
36
+ .stat {
37
+ background: var(--panel);
38
+ border: 1px solid var(--border);
39
+ border-radius: 8px;
40
+ padding: 1rem 1.5rem;
41
+ min-width: 140px;
42
+ }
43
+
44
+ .stat .value {
45
+ display: block;
46
+ font-size: 2rem;
47
+ font-weight: 700;
48
+ }
49
+
50
+ .stat .label {
51
+ display: block;
52
+ color: var(--muted);
53
+ text-transform: uppercase;
54
+ font-size: 0.75rem;
55
+ letter-spacing: 0.05em;
56
+ }
57
+
58
+ .stat.failed .value {
59
+ color: var(--danger);
60
+ }
61
+
62
+ .stat.scheduled .value {
63
+ color: var(--pending);
64
+ }
65
+
66
+ table.queues {
67
+ width: 100%;
68
+ border-collapse: collapse;
69
+ background: var(--panel);
70
+ border: 1px solid var(--border);
71
+ border-radius: 8px;
72
+ overflow: hidden;
73
+ }
74
+
75
+ table.queues th,
76
+ table.queues td {
77
+ text-align: left;
78
+ padding: 0.6rem 1rem;
79
+ border-bottom: 1px solid var(--border);
80
+ }
81
+
82
+ table.queues th {
83
+ color: var(--muted);
84
+ font-size: 0.75rem;
85
+ text-transform: uppercase;
86
+ letter-spacing: 0.05em;
87
+ }
88
+
89
+ table.queues tr:last-child td {
90
+ border-bottom: none;
91
+ }
92
+
93
+ table.queues td.muted {
94
+ color: var(--muted);
95
+ }
@@ -0,0 +1 @@
1
+ setTimeout(() => window.location.reload(), 5000);
@@ -0,0 +1,273 @@
1
+ Metadata-Version: 2.5
2
+ Name: kickdown
3
+ Version: 0.4.0a1
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
+ ## Usage
35
+
36
+ ### Defining a worker
37
+
38
+ Implement the `Performable` protocol: declare which queue a worker consumes
39
+ from and which `operation` name identifies it, plus the async `perform` method.
40
+
41
+ ```python
42
+ class SendEmailWorker:
43
+ queue = "emails"
44
+ operation = "send_email"
45
+
46
+ async def perform(self, payload: dict) -> None:
47
+ recipient = payload["to"]
48
+ # ... send email
49
+ ```
50
+
51
+ Any number of queues is supported — a worker's `queue` attribute is what
52
+ determines which Redis list it consumes from. The server automatically polls
53
+ every queue that has at least one registered worker.
54
+
55
+ ### Running the server
56
+
57
+ ```python
58
+ import asyncio
59
+ from kickdown import Server
60
+ from your_workers import SendEmailWorker, ExportReportWorker
61
+
62
+ server = Server(
63
+ redis_url="redis://localhost:6379",
64
+ concurrency=5, # optional, default: 1 - max tasks processed concurrently
65
+ )
66
+ server.add_workers(SendEmailWorker(), ExportReportWorker())
67
+
68
+ asyncio.run(server.run())
69
+ ```
70
+
71
+ `add_workers` accepts any number of `Performable` instances. Workers are
72
+ resolved by their `(queue, operation)` pair, so the same `operation` name can
73
+ be reused safely across different queues.
74
+
75
+ Queues are polled with equal frequency in round-robin order — there is
76
+ currently no notion of priority between queues.
77
+
78
+ ### Enqueueing jobs
79
+
80
+ ```python
81
+ from kickdown import Client, Task
82
+
83
+ client = Client(redis_url="redis://localhost:6379")
84
+
85
+ task = Task(
86
+ queue="emails",
87
+ operation="send_email",
88
+ params={"to": "user@example.com"},
89
+ )
90
+ jid = await client.enqueue(task)
91
+ ```
92
+
93
+ `enqueue` returns the job ID (`jid`) that can be used for tracing. It is
94
+ generated automatically (a time-sortable `uuid7`) if not set explicitly on
95
+ the `Task`.
96
+
97
+ `Client` can also be used as an async context manager, which closes the
98
+ underlying Redis connection on exit:
99
+
100
+ ```python
101
+ async with Client(redis_url="redis://localhost:6379") as client:
102
+ await client.enqueue(task)
103
+ ```
104
+
105
+ `Server` can enqueue tasks too, using the same Redis connection — handy for
106
+ a worker that needs to schedule a follow-up task, or for enqueueing from a
107
+ startup hook, without opening a separate `Client`:
108
+
109
+ ```python
110
+ jid = await server.enqueue(task)
111
+ ```
112
+
113
+ #### Retries
114
+
115
+ `retry_count` (default `1`) on `Task` sets how many times a failed task is
116
+ retried before being dropped. On failure, the consumer re-enqueues the task
117
+ with `retry_count` decremented by one and `attempt` incremented by one, after
118
+ an exponentially growing delay. Once `retry_count` reaches `0` the task is
119
+ dropped.
120
+
121
+ The delay is `1s * 1.5 ** attempt` — both the base delay and the backoff
122
+ coefficient are fixed in the library and cannot be configured per task:
123
+
124
+ | Retry | Delay |
125
+ | ----- | ----- |
126
+ | 1st | 1.0s |
127
+ | 2nd | 1.5s |
128
+ | 3rd | 2.3s |
129
+
130
+ A retried task is not held in memory while it waits: it goes into the queue's
131
+ scheduled sorted set in Redis (`kickdown:scheduled:{name}`, scored by its due
132
+ timestamp), and a scheduler loop running inside every server moves due tasks
133
+ back into the queue. So a retry survives a process restart, and the
134
+ concurrency slot is freed immediately instead of being blocked for the whole
135
+ delay.
136
+
137
+ A server only sweeps the queues it has workers for, which is also the only
138
+ place its own retries can land.
139
+
140
+ Note that a task is still lost if the process dies *while its worker is
141
+ running* — closing that window is the next step (an in-flight list per
142
+ consumer plus a reaper).
143
+
144
+ Queue names are raw identifiers (e.g. `"default"`, `"emails"`). The client
145
+ constructs the full Redis key internally as `kickdown:queue:{name}`.
146
+
147
+ ### Inspecting a queue
148
+
149
+ `client.queue(name)` returns a `Queue` handle — every per-queue operation
150
+ lives on it, so the queue name is given once instead of on every call:
151
+
152
+ ```python
153
+ emails = client.queue("emails")
154
+
155
+ # Tasks waiting to be processed (non-destructive)
156
+ tasks = await emails.pending()
157
+ count = await emails.length()
158
+
159
+ # Tasks waiting for their retry delay to elapse
160
+ retries = await emails.scheduled()
161
+
162
+ # Cumulative processed/failed counters
163
+ stats = await emails.stats()
164
+ print(stats.processed, stats.failed)
165
+ ```
166
+
167
+ Enqueueing stays on the client (`client.enqueue(task)`): a `Task` already
168
+ carries its own `queue`, and that field remains the single source of truth
169
+ for routing.
170
+
171
+ `purge()` drops every task waiting in the queue and returns how many were
172
+ dropped. It does not touch scheduled tasks or the counters, and there is no
173
+ undo:
174
+
175
+ ```python
176
+ dropped = await emails.purge()
177
+ ```
178
+
179
+ `processed` counts tasks whose worker completed successfully; `failed`
180
+ counts tasks that were permanently dropped (retries exhausted, or no
181
+ worker registered for the task's `operation`).
182
+
183
+ ### Lifecycle hooks
184
+
185
+ Register async callbacks to run on server startup and shutdown — useful for
186
+ initialising shared resources like database pools.
187
+
188
+ ```python
189
+ server = Server(redis_url=...)
190
+
191
+ @server.on_startup
192
+ async def init_db():
193
+ app.db = await asyncpg.create_pool(DATABASE_URL)
194
+
195
+ @server.on_shutdown
196
+ async def close_db():
197
+ await app.db.close()
198
+ ```
199
+
200
+ Both methods can also be called directly instead of used as decorators:
201
+
202
+ ```python
203
+ server.on_startup(init_db)
204
+ server.on_shutdown(close_db)
205
+ ```
206
+
207
+ Startup hooks run after the Redis connection is verified, before the
208
+ consumer starts. Shutdown hooks run after the consumer stops (whether by
209
+ `SIGTERM`/`SIGINT` or an unexpected error), before the Redis connection is
210
+ closed.
211
+
212
+ ### Logging
213
+
214
+ `Client` and `Server` each expose a plain `logging.Logger` as `.logger`,
215
+ pre-configured to write text to stdout — there's no custom logger
216
+ interface to implement. To use your own logger (different format, handler,
217
+ sink, etc.), just assign it:
218
+
219
+ ```python
220
+ import logging
221
+
222
+ server.logger = logging.getLogger("myapp.kickdown")
223
+ client.logger = logging.getLogger("myapp.kickdown")
224
+ ```
225
+
226
+ `Client` logs when a task is accepted. `Server` logs process start/shutdown
227
+ (with the polled queues and concurrency), and each task's start,
228
+ completion, retries and permanent failures — every task-related message
229
+ includes the task's `jid`.
230
+
231
+ ### Healthcheck
232
+
233
+ `Server` always starts a small HTTP server for liveness/readiness probes,
234
+ on the port given by `web_port` (default `3030`):
235
+
236
+ ```python
237
+ server = Server(redis_url=..., web_port=3030)
238
+ ```
239
+
240
+ ```
241
+ GET /live -> 200 {"status": "ok"} # process is up
242
+ GET /ready -> 200 {"status": "ok"} # Redis reachable
243
+ -> 503 {"status": "redis unavailable"} # Redis unreachable
244
+ ```
245
+
246
+ ### Admin page
247
+
248
+ `GET /admin` renders an HTML dashboard: a summary block with total
249
+ processed/failed counters (aggregated across all queues, from the same
250
+ counters as `client.stats()`), and a table of every polled queue with its
251
+ current pending count (how many tasks are physically waiting in it).
252
+
253
+ By default it's open to anyone who can reach the port. To require HTTP
254
+ Basic Auth, set both `admin_username` and `admin_password`:
255
+
256
+ ```python
257
+ server = Server(
258
+ redis_url=...,
259
+ admin_username="alice",
260
+ admin_password="secret",
261
+ )
262
+ ```
263
+
264
+ If either is left unset, `/admin` requires no credentials.
265
+
266
+ ## Scaling
267
+
268
+ `Server.run()` uses a single asyncio event loop with an `asyncio.Semaphore`
269
+ to cap concurrent task execution within the process — this is a good fit
270
+ since `Performable.perform` is a coroutine. To scale across CPU cores or
271
+ machines, run multiple `Server` processes against the same Redis instance;
272
+ each process independently pops from the shared queues, so Redis balances
273
+ the work between them without any extra coordination.
@@ -0,0 +1,18 @@
1
+ kickdown/__init__.py,sha256=MWGFtF65s3-oJ1duf7pTEwaRzj2jET8az5z8KvxlwdA,224
2
+ kickdown/client.py,sha256=zo8eNcbf3yyBvqDn1wEl5lzT7zMIZADoaJH_De5ROik,809
3
+ kickdown/consumer.py,sha256=E4-IHwEHbkgInvWuiuBuzYCH6ANg3VdAojRW6AVYS0g,5054
4
+ kickdown/log.py,sha256=QrwyR4KwJgvqV4JUa3HMy6OBoVqfd1mpviwoOc2oxzo,400
5
+ kickdown/models.py,sha256=F5E1cRC09pUoV9Lh-V6eOzbDobQvrGYaNQsN9h-N4KI,583
6
+ kickdown/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ kickdown/queue.py,sha256=V0I0wu8LrwZV5kewqUjEKz73qd1tf8K4jvaAxlZ1cpA,1972
8
+ kickdown/scheduler.py,sha256=wb17YgO8Px6QLrKeildpHQuibutq7PEhVCWxLFx9p58,1331
9
+ kickdown/server.py,sha256=ydXVtxtzG2QTi4J3DFJTA-gtE_ReaNCG3epiMZDMiB0,5059
10
+ kickdown/store.py,sha256=dADwPw2mXW4qdhv22wB0hMxINBi2msUOJhIEalBOVMI,5365
11
+ kickdown/web/__init__.py,sha256=64q0t1gcVCraryPVkxP6LE7-SuZjBtx5Z8Gyo_dMhHw,3645
12
+ kickdown/web/admin.html,sha256=eD8n8lgAainfjOqGC-hCElEfoa4kutQ5Lf74B6u7dPg,1345
13
+ kickdown/web/assets/admin.css,sha256=IMVkbuEyobT3h5r_NCAuOykKmACfc2t5C8VsCInfAOQ,1584
14
+ kickdown/web/assets/admin.js,sha256=_5pHZNIZctM-x3XgrNH37NQMwrDyCb6VblTvOHkWPTA,50
15
+ kickdown-0.4.0a1.dist-info/METADATA,sha256=dY9fsDP7XlqPKFBI0aWd_1lJKO7T3tPvY-uFRpCmNw0,8710
16
+ kickdown-0.4.0a1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
17
+ kickdown-0.4.0a1.dist-info/licenses/LICENSE,sha256=cViUjOoePpSXj9vB-Ckzg1zdh8I9wIb3SciUnifkQCA,1072
18
+ kickdown-0.4.0a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.