kickdown 0.4.0__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,14 @@
1
+ from .client import Client
2
+ from .models import Performable, Stats, Task, Worker
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
+ "Worker",
14
+ ]
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,206 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import socket
5
+ import time
6
+ from collections import deque
7
+ from collections.abc import Awaitable, Callable, Mapping
8
+
9
+ from pydantic import ValidationError
10
+ from uuid_extensions import uuid7str
11
+
12
+ from .log import default_logger
13
+ from .models import Task, Worker
14
+ from .queue import Queue
15
+ from .store import Store, StoreError
16
+
17
+ _default_poll_interval = 0.1
18
+ _retry_delay = 1
19
+ _backoff_coefficient = 1.5
20
+ _heartbeat_interval = 10
21
+ _heartbeat_ttl = 30
22
+
23
+
24
+ def _default_consumer_id() -> str:
25
+ return f"{socket.gethostname()}:{os.getpid()}:{uuid7str()[-8:]}"
26
+
27
+
28
+ class Consumer:
29
+ def __init__(
30
+ self,
31
+ store: Store,
32
+ workers: Mapping[tuple[str, str], Worker],
33
+ concurrency: int = 1,
34
+ logger: logging.Logger | None = None,
35
+ consumer_id: str | None = None,
36
+ ):
37
+ self.id = consumer_id or _default_consumer_id()
38
+ self._store = store
39
+ self._workers = workers
40
+ self._queues: dict[str, Queue] = {
41
+ name: Queue(name, store)
42
+ for name in sorted({worker.queue for worker in workers.values()})
43
+ }
44
+ self._order: deque[str] = deque(self._queues)
45
+ self._semaphore = asyncio.Semaphore(concurrency)
46
+ self.logger = logger or default_logger()
47
+ self._tasks: set[asyncio.Task] = set()
48
+
49
+ async def start(self) -> None:
50
+ await asyncio.to_thread(self._store.register_consumer, self.id, _heartbeat_ttl)
51
+ self.logger.info(f"consumer {self.id} registered")
52
+
53
+ async def stop(self) -> None:
54
+ """Returns whatever is still in flight, then leaves the registry.
55
+
56
+ Without this a clean restart would leave its tasks sitting until the
57
+ heartbeat expired and another server reaped them.
58
+ """
59
+ try:
60
+ returned = await asyncio.to_thread(self._store.reap, self.id)
61
+ if returned:
62
+ self.logger.warning(
63
+ f"consumer {self.id} returned {returned} unfinished task(s)"
64
+ )
65
+ await asyncio.to_thread(self._store.deregister_consumer, self.id)
66
+ except StoreError as err:
67
+ self.logger.error(
68
+ "redis error while deregistering consumer", extra={"error": str(err)}
69
+ )
70
+
71
+ async def heartbeat(self) -> None:
72
+ while True:
73
+ try:
74
+ await asyncio.to_thread(self._store.heartbeat, self.id, _heartbeat_ttl)
75
+ except StoreError as err:
76
+ self.logger.error(
77
+ "redis error while refreshing heartbeat",
78
+ extra={"error": str(err)},
79
+ )
80
+ await asyncio.sleep(_heartbeat_interval)
81
+
82
+ async def consume(self) -> None:
83
+ while True:
84
+ await self._semaphore.acquire()
85
+
86
+ try:
87
+ claimed = await asyncio.to_thread(
88
+ self._store.claim, self._poll_order(), self.id
89
+ )
90
+ except StoreError as err:
91
+ self._semaphore.release()
92
+ self.logger.error(
93
+ "redis error while claiming task", extra={"error": str(err)}
94
+ )
95
+ await asyncio.sleep(_default_poll_interval)
96
+ continue
97
+ except ValidationError as err:
98
+ self._semaphore.release()
99
+ self.logger.error(
100
+ "failed to parse task payload", extra={"error": str(err)}
101
+ )
102
+ continue
103
+
104
+ if claimed is None:
105
+ self._semaphore.release()
106
+ await asyncio.sleep(_default_poll_interval)
107
+ continue
108
+
109
+ raw, task = claimed
110
+ handle = asyncio.create_task(self._run_task(task, raw))
111
+ self._tasks.add(handle)
112
+ handle.add_done_callback(self._tasks.discard)
113
+
114
+ async def drain(self) -> None:
115
+ if self._tasks:
116
+ await asyncio.gather(*self._tasks, return_exceptions=True)
117
+
118
+ def _poll_order(self) -> list[str]:
119
+ order = list(self._order)
120
+ self._order.rotate(-1)
121
+ return order
122
+
123
+ def _queue(self, name: str) -> Queue:
124
+ # a payload naming a queue we do not serve is malformed, but its stats
125
+ # should still land on the queue it claims
126
+ return self._queues.get(name) or Queue(name, self._store)
127
+
128
+ async def _run_task(self, task: Task, raw: bytes) -> None:
129
+ queue = self._queue(task.queue)
130
+ failure: Exception | None = None
131
+ try:
132
+ worker = self._workers.get((task.queue, task.operation))
133
+ if worker is None:
134
+ self.logger.error(
135
+ f"no worker registered for task jid={task.jid}",
136
+ extra={
137
+ "jid": task.jid,
138
+ "queue": task.queue,
139
+ "operation": task.operation,
140
+ },
141
+ )
142
+ await self._increment(queue.increment_failed)
143
+ await self._ack(raw)
144
+ return
145
+
146
+ self.logger.info(
147
+ f"jid={task.jid} started",
148
+ extra={"queue": task.queue, "operation": task.operation},
149
+ )
150
+ await worker.perform(task.params)
151
+ self.logger.info(f"jid={task.jid} done")
152
+ await self._increment(queue.increment_processed)
153
+ except Exception as err: # noqa: BLE001 - worker code is arbitrary; retry boundary must catch anything
154
+ failure = err
155
+ finally:
156
+ self._semaphore.release()
157
+
158
+ if failure is None:
159
+ await self._ack(raw)
160
+ return
161
+
162
+ if task.retry_count > 0:
163
+ delay = _retry_delay * _backoff_coefficient**task.attempt
164
+ self.logger.warning(
165
+ f"jid={task.jid} failed, retrying in {delay:.1f}s "
166
+ f"({task.retry_count} attempt(s) left)",
167
+ extra={"error": str(failure)},
168
+ )
169
+ retry_task = task.model_copy(
170
+ update={
171
+ "retry_count": task.retry_count - 1,
172
+ "attempt": task.attempt + 1,
173
+ }
174
+ )
175
+ try:
176
+ await queue.schedule(retry_task, time.time() + delay)
177
+ except StoreError as schedule_err:
178
+ # leave it in flight: the reaper will put it back rather than
179
+ # drop it on the floor
180
+ self.logger.error(
181
+ f"jid={task.jid} failed to schedule retry",
182
+ extra={"error": str(schedule_err)},
183
+ )
184
+ return
185
+ await self._ack(raw)
186
+ else:
187
+ self.logger.error(
188
+ f"jid={task.jid} failed permanently", extra={"error": str(failure)}
189
+ )
190
+ await self._increment(queue.increment_failed)
191
+ await self._ack(raw)
192
+
193
+ async def _ack(self, raw: bytes) -> None:
194
+ try:
195
+ await asyncio.to_thread(self._store.ack, self.id, raw)
196
+ except StoreError as err:
197
+ self.logger.error(
198
+ "failed to remove task from the in-flight list",
199
+ extra={"error": str(err)},
200
+ )
201
+
202
+ async def _increment(self, increment: Callable[[], Awaitable[None]]) -> None:
203
+ try:
204
+ await increment()
205
+ except StoreError as err:
206
+ 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,31 @@
1
+ from typing import 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
+ # Workers are registered either as classes (with `perform` as a classmethod) or
16
+ # as instances; both are called the same way.
17
+ type Worker = type[Performable] | Performable
18
+
19
+
20
+ class Task(BaseModel):
21
+ queue: str
22
+ operation: str
23
+ params: dict
24
+ jid: str = Field(default_factory=lambda: uuid7str())
25
+ retry_count: int = 1
26
+ attempt: int = 0
27
+
28
+
29
+ class Stats(BaseModel):
30
+ processed: int = 0
31
+ 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/reaper.py ADDED
@@ -0,0 +1,64 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ from .log import default_logger
5
+ from .store import Store, StoreError
6
+
7
+ _default_poll_interval = 15
8
+ _reap_lock_ttl = 60
9
+
10
+
11
+ class Reaper:
12
+ """Returns tasks stranded by consumers that died mid-task.
13
+
14
+ A consumer holds its claimed tasks in its own in-flight list and keeps a
15
+ heartbeat key alive. When the heartbeat expires the consumer is gone, and
16
+ whatever is left in its list is pushed back into the queues it came from.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ store: Store,
22
+ poll_interval: float = _default_poll_interval,
23
+ logger: logging.Logger | None = None,
24
+ ):
25
+ self._store = store
26
+ self._poll_interval = poll_interval
27
+ self.logger = logger or default_logger()
28
+
29
+ async def run(self) -> None:
30
+ while True:
31
+ await self._tick()
32
+ await asyncio.sleep(self._poll_interval)
33
+
34
+ async def _tick(self) -> None:
35
+ try:
36
+ consumers = await asyncio.to_thread(self._store.consumers)
37
+ except StoreError as err:
38
+ self.logger.error(
39
+ "redis error while listing consumers", extra={"error": str(err)}
40
+ )
41
+ return
42
+
43
+ for consumer_id in consumers:
44
+ try:
45
+ if await asyncio.to_thread(self._store.is_alive, consumer_id):
46
+ continue
47
+ # one server reaps a given corpse; the rest skip it
48
+ if not await asyncio.to_thread(
49
+ self._store.claim_reap, consumer_id, _reap_lock_ttl
50
+ ):
51
+ continue
52
+ reaped = await asyncio.to_thread(self._store.reap, consumer_id)
53
+ await asyncio.to_thread(self._store.deregister_consumer, consumer_id)
54
+ except StoreError as err:
55
+ self.logger.error(
56
+ "redis error while reaping consumer",
57
+ extra={"consumer": consumer_id, "error": str(err)},
58
+ )
59
+ continue
60
+
61
+ self.logger.warning(
62
+ f"consumer {consumer_id} died, requeued {reaped} task(s)",
63
+ extra={"consumer": consumer_id},
64
+ )
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,155 @@
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 Task, Worker
8
+ from .queue import Queue
9
+ from .reaper import Reaper
10
+ from .scheduler import Scheduler
11
+ from .store import Store, StoreError
12
+ from .web import Web
13
+
14
+ Hook = Callable[[], Awaitable[None]]
15
+
16
+
17
+ class Server:
18
+ def __init__(
19
+ self,
20
+ redis_url: str,
21
+ concurrency: int = 1,
22
+ web_port: int = 3030,
23
+ admin_username: str | None = None,
24
+ admin_password: str | None = None,
25
+ ):
26
+ self._store = Store(redis_url)
27
+ self._concurrency = concurrency
28
+ self._web_port = web_port
29
+ self._admin_username = admin_username
30
+ self._admin_password = admin_password
31
+ self._startup_hooks: list[Hook] = []
32
+ self._shutdown_hooks: list[Hook] = []
33
+ self._worker: dict[tuple[str, str], Worker] = {}
34
+ self.logger = default_logger()
35
+
36
+ @property
37
+ def store(self) -> Store:
38
+ return self._store
39
+
40
+ @property
41
+ def queues(self) -> list[Queue]:
42
+ return [
43
+ Queue(name, self._store)
44
+ for name in sorted({worker.queue for worker in self._worker.values()})
45
+ ]
46
+
47
+ def queue(self, name: str) -> Queue:
48
+ return Queue(name, self._store)
49
+
50
+ def add_workers(self, *args: Worker):
51
+ for worker in args:
52
+ self._worker[(worker.queue, worker.operation)] = worker
53
+
54
+ async def enqueue(self, task: Task) -> str:
55
+ await self.queue(task.queue).push(task)
56
+ self.logger.info(
57
+ f"jid={task.jid} accepted",
58
+ extra={"queue": task.queue, "operation": task.operation},
59
+ )
60
+ return task.jid
61
+
62
+ def on_startup(self, fn: Hook) -> Hook:
63
+ self._startup_hooks.append(fn)
64
+ return fn
65
+
66
+ def on_shutdown(self, fn: Hook) -> Hook:
67
+ self._shutdown_hooks.append(fn)
68
+ return fn
69
+
70
+ async def run(self):
71
+ if not self._worker:
72
+ raise RuntimeError(
73
+ "No workers registered. Register them with add_workers() before running the server."
74
+ )
75
+
76
+ try:
77
+ await asyncio.to_thread(self._store.ping)
78
+ except StoreError as e:
79
+ raise RuntimeError(f"Redis connection failed: {e}") from e
80
+
81
+ consumer = Consumer(
82
+ store=self._store,
83
+ workers=self._worker,
84
+ concurrency=self._concurrency,
85
+ logger=self.logger,
86
+ )
87
+ scheduler = Scheduler(queues=self.queues, logger=self.logger)
88
+ reaper = Reaper(store=self._store, logger=self.logger)
89
+ web = Web(
90
+ port=self._web_port,
91
+ server=self,
92
+ admin_username=self._admin_username,
93
+ admin_password=self._admin_password,
94
+ )
95
+
96
+ await self._run_hooks(self._startup_hooks, "startup")
97
+ await consumer.start()
98
+
99
+ stop_event = asyncio.Event()
100
+ loop = asyncio.get_running_loop()
101
+ for sig in (signal.SIGTERM, signal.SIGINT):
102
+ loop.add_signal_handler(sig, stop_event.set)
103
+
104
+ self.logger.info(
105
+ f"server starting (queues={[q.name for q in self.queues]}, "
106
+ f"concurrency={self._concurrency}, web_port={self._web_port})"
107
+ )
108
+
109
+ consume_task = asyncio.create_task(consumer.consume(), name="consumer")
110
+ beat_task = asyncio.create_task(consumer.heartbeat(), name="heartbeat")
111
+ schedule_task = asyncio.create_task(scheduler.run(), name="scheduler")
112
+ reap_task = asyncio.create_task(reaper.run(), name="reaper")
113
+ web_task = asyncio.create_task(web.run(), name="web")
114
+ stop_task = asyncio.create_task(stop_event.wait(), name="stop")
115
+
116
+ background = (consume_task, beat_task, schedule_task, reap_task, web_task)
117
+
118
+ try:
119
+ await asyncio.wait(
120
+ [*background, stop_task],
121
+ return_when=asyncio.FIRST_COMPLETED,
122
+ )
123
+
124
+ for task in background:
125
+ if task.done() and not task.cancelled():
126
+ exc = task.exception()
127
+ if exc is not None:
128
+ self.logger.error(
129
+ f"{task.get_name()} task failed unexpectedly",
130
+ extra={"error": str(exc)},
131
+ )
132
+ finally:
133
+ self.logger.info("server shutting down")
134
+ for task in (*background, stop_task):
135
+ task.cancel()
136
+ await asyncio.gather(*background, stop_task, return_exceptions=True)
137
+ await consumer.drain()
138
+ await consumer.stop()
139
+
140
+ await self._run_hooks(self._shutdown_hooks, "shutdown")
141
+
142
+ await asyncio.to_thread(self._store.close)
143
+
144
+ async def _run_hooks(self, hooks: list[Hook], phase: str) -> None:
145
+ for hook in hooks:
146
+ try:
147
+ await hook()
148
+ except Exception as err: # noqa: BLE001 - hook code is arbitrary; must not abort the server
149
+ self.logger.error(
150
+ f"{phase} hook failed",
151
+ extra={
152
+ "hook": getattr(hook, "__name__", repr(hook)),
153
+ "error": str(err),
154
+ },
155
+ )