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 +13 -0
- kickdown/client.py +32 -0
- kickdown/consumer.py +144 -0
- kickdown/log.py +14 -0
- kickdown/models.py +26 -0
- kickdown/py.typed +0 -0
- kickdown/queue.py +60 -0
- kickdown/scheduler.py +47 -0
- kickdown/server.py +151 -0
- kickdown/store.py +162 -0
- kickdown/web/__init__.py +98 -0
- kickdown/web/admin.html +45 -0
- kickdown/web/assets/admin.css +95 -0
- kickdown/web/assets/admin.js +1 -0
- kickdown-0.4.0a1.dist-info/METADATA +273 -0
- kickdown-0.4.0a1.dist-info/RECORD +18 -0
- kickdown-0.4.0a1.dist-info/WHEEL +4 -0
- kickdown-0.4.0a1.dist-info/licenses/LICENSE +21 -0
kickdown/__init__.py
ADDED
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()
|
kickdown/web/__init__.py
ADDED
|
@@ -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\">—</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()
|
kickdown/web/admin.html
ADDED
|
@@ -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,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.
|