Skip to content

Commit b03a030

Browse files
authored
feat: use server_lifespan
feat: use `server_lifespan`
2 parents 9bd05bf + fdf34b3 commit b03a030

9 files changed

Lines changed: 1052 additions & 672 deletions

File tree

.pre-commit-config.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ default_language_version:
22
python: "3.11"
33
repos:
44
- repo: https://github.com/compilerla/conventional-pre-commit
5-
rev: v2.4.0
5+
rev: v3.0.0
66
hooks:
77
- id: conventional-pre-commit
88
stages: [commit-msg]
@@ -17,7 +17,7 @@ repos:
1717
- id: mixed-line-ending
1818
- id: trailing-whitespace
1919
- repo: https://github.com/charliermarsh/ruff-pre-commit
20-
rev: "v0.1.0"
20+
rev: "v0.1.8"
2121
hooks:
2222
- id: ruff
2323
args: ["--fix"]
@@ -28,12 +28,12 @@ repos:
2828
- id: codespell
2929
exclude: "pdm.lock"
3030
- repo: https://github.com/psf/black
31-
rev: 23.10.0
31+
rev: 23.12.0
3232
hooks:
3333
- id: black
3434
args: [--config=./pyproject.toml]
3535
- repo: https://github.com/pre-commit/mirrors-mypy
36-
rev: "v1.6.0"
36+
rev: "v1.7.1"
3737
hooks:
3838
- id: mypy
3939
exclude: "docs"
@@ -53,6 +53,6 @@ repos:
5353
"litestar[cli]",
5454
]
5555
- repo: https://github.com/sphinx-contrib/sphinx-lint
56-
rev: "v0.8.1"
56+
rev: "v0.9.1"
5757
hooks:
5858
- id: sphinx-lint

examples/basic.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ async def samples_queue_info(self, task_queues: TaskQueues) -> QueueInfo:
2626
config=SAQConfig(
2727
redis_url="redis://localhost:6397/0",
2828
web_enabled=True,
29+
use_server_lifespan=True,
2930
queue_configs=[
3031
QueueConfig(
3132
name="samples",

litestar_saq/_util.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
from __future__ import annotations
22

3-
import pkgutil
3+
import importlib.util
44
import sys
55
from functools import lru_cache
66
from importlib import import_module
7-
from importlib.machinery import SourceFileLoader
87
from pathlib import Path
98
from typing import TYPE_CHECKING, Any
109

@@ -24,11 +23,11 @@ def module_to_os_path(dotted_path: str) -> Path:
2423
2524
Ensures that pkgutil returns a valid source file loader.
2625
"""
27-
src = pkgutil.get_loader(dotted_path)
28-
if not isinstance(src, SourceFileLoader):
26+
src = importlib.util.find_spec(dotted_path)
27+
if src is None:
2928
msg = f"Couldn't find the path for {dotted_path}"
3029
raise TypeError(msg)
31-
return Path(str(src.path).removesuffix("/__init__.py"))
30+
return Path(str(src.origin).removesuffix("/__init__.py")) # type: ignore[unreachable]
3231

3332

3433
def import_string(dotted_path: str) -> Any:

litestar_saq/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import asyncio
44
from dataclasses import dataclass, field
5-
from typing import TYPE_CHECKING, Any, cast
5+
from typing import TYPE_CHECKING, Any, Literal, cast
66

77
from saq import Job as SaqJob
88
from saq import Worker as SaqWorker
@@ -106,8 +106,10 @@ def __init__(
106106
timers: PartialTimersDict | None = None,
107107
dequeue_timeout: float = 0,
108108
separate_process: bool = True,
109+
multiprocessing_mode: Literal["multiprocessing", "threading"] = "multiprocessing",
109110
) -> None:
110111
self.separate_process = separate_process
112+
self.multiprocessing_mode = multiprocessing_mode
111113
super().__init__(
112114
cast("SaqQueue", queue),
113115
functions,

litestar_saq/cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,11 @@ def run_worker(
5353
show_saq_info(app, workers, plugin)
5454
if workers > 1:
5555
for _ in range(workers - 1):
56-
p = multiprocessing.Process(target=run_worker_process, args=(plugin.get_workers(), app.logging_config))
56+
p = multiprocessing.Process(target=run_saq_worker, args=(plugin.get_workers(), app.logging_config))
5757
p.start()
5858

5959
try:
60-
run_worker_process(
60+
run_saq_worker(
6161
workers=plugin.get_workers(),
6262
logging_config=cast("BaseLoggingConfig", app.logging_config),
6363
)
@@ -84,7 +84,7 @@ def worker_status(
8484
if debug is not None or verbose is not None:
8585
app.debug = True
8686
plugin = get_saq_plugin(app)
87-
show_saq_info(app, 1, plugin)
87+
show_saq_info(app, plugin.config.worker_processes, plugin)
8888

8989
return background_worker_group
9090

@@ -128,7 +128,7 @@ def show_saq_info(app: Litestar, workers: int, plugin: SAQPlugin) -> None: # pr
128128
console.print(table)
129129

130130

131-
def run_worker_process(workers: list[Worker], logging_config: BaseLoggingConfig | None) -> None:
131+
def run_saq_worker(workers: list[Worker], logging_config: BaseLoggingConfig | None) -> None:
132132
"""Run a worker."""
133133
import asyncio
134134

litestar_saq/config.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dataclasses import dataclass, field
44
from pathlib import Path
5-
from typing import TYPE_CHECKING, Callable, Collection, Dict, Mapping, TypeVar, Union, cast
5+
from typing import TYPE_CHECKING, Callable, Collection, Dict, Literal, Mapping, TypeVar, Union, cast
66

77
from litestar.exceptions import ImproperlyConfiguredException
88
from litestar.serialization import decode_json, encode_json
@@ -40,7 +40,7 @@ def serializer(value: Any) -> str:
4040

4141

4242
def _get_static_files() -> Path:
43-
return Path(module_to_os_path("saq.web") / "static")
43+
return Path(module_to_os_path("saq") / "web" / "static")
4444

4545

4646
@dataclass
@@ -80,8 +80,7 @@ class SAQConfig:
8080
8181
Default is set to 1.
8282
"""
83-
web_enabled: bool = False
84-
"""If true, the worker admin UI is launched on worker startup.."""
83+
8584
json_deserializer: LoadType = decode_json
8685
"""This is a Python callable that will
8786
convert a JSON string to a Python object. By default, this is set to Litestar's
@@ -91,10 +90,16 @@ class SAQConfig:
9190
By default, Litestar's :attr:`encode_json() <.serialization.encode_json>` is used."""
9291
static_files: Path = field(default_factory=_get_static_files)
9392
"""Location of the static files to serve for the SAQ UI"""
93+
web_enabled: bool = False
94+
"""If true, the worker admin UI is launched on worker startup.."""
9495
web_path = "/saq"
9596
"""Base path to serve the SAQ web UI"""
9697
web_guards: list[Guard] | None = field(default=None)
9798
"""Guards to apply to web endpoints."""
99+
web_include_in_schema: bool = True
100+
"""Include Queue API endpoints in generated OpenAPI schema"""
101+
use_server_lifespan: bool = False
102+
"""Utilize the server lifespan hook to run SAQ."""
98103

99104
def __post_init__(self) -> None:
100105
if self.redis is not None and self.redis_url is not None:
@@ -189,7 +194,7 @@ class QueueConfig:
189194
concurrency: int = 10
190195
"""Number of jobs to process concurrently"""
191196
max_concurrent_ops: int = 15
192-
"""Maximum concurrent operations. (default 20)
197+
"""Maximum concurrent operations. (default 15)
193198
This throttles calls to `enqueue`, `job`, and `abort` to prevent the Queue
194199
from consuming too many Redis connections."""
195200
tasks: Collection[ReceivesContext | tuple[str, Function] | str] = field(default_factory=list)
@@ -212,6 +217,9 @@ class QueueConfig:
212217
abort: how often to check if a job is aborted"""
213218
dequeue_timeout: float = 0
214219
"""How long it will wait to dequeue"""
220+
multiprocessing_mode: Literal["multiprocessing", "threading"] = "multiprocessing"
221+
"""Executes with the multiprocessing or threading backend.
222+
Set it threading for workloads that aren't CPU bound."""
215223
separate_process: bool = True
216224
"""Executes as a separate event loop when True.
217225
Set it False to execute within the Litestar application."""

litestar_saq/plugin.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Collection, TypeVar, cast
3+
from contextlib import contextmanager
4+
from typing import TYPE_CHECKING, Collection, Iterator, TypeVar, cast
45

5-
from litestar.di import Provide
6-
from litestar.plugins import CLIPluginProtocol, InitPluginProtocol
7-
from litestar.static_files import StaticFilesConfig
6+
from litestar.plugins import CLIPlugin, InitPluginProtocol
87

98
from litestar_saq.base import Worker
10-
from litestar_saq.controllers import build_controller
119

1210
if TYPE_CHECKING:
1311
from click import Group
12+
from litestar import Litestar
1413
from litestar.config.app import AppConfig
1514
from saq.types import Function
1615

@@ -20,7 +19,7 @@
2019
T = TypeVar("T")
2120

2221

23-
class SAQPlugin(InitPluginProtocol, CLIPluginProtocol):
22+
class SAQPlugin(InitPluginProtocol, CLIPlugin):
2423
"""SAQ plugin."""
2524

2625
__slots__ = ("_config", "_worker_instances")
@@ -34,6 +33,10 @@ def __init__(self, config: SAQConfig) -> None:
3433
self._config = config
3534
self._worker_instances: list[Worker] | None = None
3635

36+
@property
37+
def config(self) -> SAQConfig:
38+
return self._config
39+
3740
def on_cli_init(self, cli: Group) -> None:
3841
from litestar_saq.cli import build_cli_app
3942

@@ -46,6 +49,12 @@ def on_app_init(self, app_config: AppConfig) -> AppConfig:
4649
Args:
4750
app_config: The :class:`AppConfig <.config.app.AppConfig>` instance.
4851
"""
52+
53+
from litestar.di import Provide
54+
from litestar.static_files import StaticFilesConfig
55+
56+
from litestar_saq.controllers import build_controller
57+
4958
app_config.dependencies.update(
5059
{
5160
self._config.queues_dependency_key: Provide(
@@ -102,3 +111,27 @@ def get_queues(self) -> TaskQueues:
102111

103112
def get_queue(self, name: str) -> Queue:
104113
return self.get_queues().get(name)
114+
115+
@contextmanager
116+
def server_lifespan(self, app: Litestar) -> Iterator[None]:
117+
import multiprocessing
118+
119+
from litestar_saq.cli import run_saq_worker
120+
121+
if self._config.use_server_lifespan:
122+
processes = [
123+
multiprocessing.Process(target=run_saq_worker, args=(self.get_workers(), app.logging_config))
124+
for _ in range(self._config.worker_processes)
125+
]
126+
127+
try:
128+
for p in processes:
129+
p.start()
130+
yield
131+
finally:
132+
for p in processes:
133+
if p.is_alive():
134+
p.terminate()
135+
p.join()
136+
else:
137+
yield

0 commit comments

Comments
 (0)