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 +14 -0
- kickdown/client.py +32 -0
- kickdown/consumer.py +206 -0
- kickdown/log.py +14 -0
- kickdown/models.py +31 -0
- kickdown/py.typed +0 -0
- kickdown/queue.py +60 -0
- kickdown/reaper.py +64 -0
- kickdown/scheduler.py +47 -0
- kickdown/server.py +155 -0
- kickdown/store.py +287 -0
- kickdown/web/__init__.py +133 -0
- kickdown/web/admin.html +45 -0
- kickdown/web/assets/admin.css +95 -0
- kickdown/web/assets/admin.js +1 -0
- kickdown-0.4.0.dist-info/METADATA +314 -0
- kickdown-0.4.0.dist-info/RECORD +19 -0
- kickdown-0.4.0.dist-info/WHEEL +4 -0
- kickdown-0.4.0.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,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
|
+
)
|