starfish-queuing 3.0.0a5__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.
- starfish_queuing/__init__.py +42 -0
- starfish_queuing/base.py +28 -0
- starfish_queuing/config.py +24 -0
- starfish_queuing/memory.py +51 -0
- starfish_queuing/message.py +25 -0
- starfish_queuing/nats.py +66 -0
- starfish_queuing/plugin.py +49 -0
- starfish_queuing/publish.py +49 -0
- starfish_queuing-3.0.0a5.dist-info/METADATA +14 -0
- starfish_queuing-3.0.0a5.dist-info/RECORD +12 -0
- starfish_queuing-3.0.0a5.dist-info/WHEEL +5 -0
- starfish_queuing-3.0.0a5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""``starfish-queuing`` — change-event queuing extension.
|
|
2
|
+
|
|
3
|
+
Public surface: the ``AbstractQueue`` transport base, the in-process
|
|
4
|
+
``MemoryQueue`` and callback ``CustomQueue`` backends, the ``QueueMessage``
|
|
5
|
+
shape, the per-collection ``QueueConfig``, and ``create_queuing_server_plugin``
|
|
6
|
+
— a ``ServerPlugin`` whose ``after_write`` hook publishes a message after each
|
|
7
|
+
successful push.
|
|
8
|
+
|
|
9
|
+
``NatsQueue``/``NatsQueueOptions`` live in ``starfish_queuing.nats`` (require the
|
|
10
|
+
``nats`` optional extra) and are imported on demand.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from starfish_queuing.base import AbstractQueue
|
|
14
|
+
from starfish_queuing.memory import MemoryQueue, CustomQueue
|
|
15
|
+
from starfish_queuing.message import QueueMessage
|
|
16
|
+
from starfish_queuing.config import QueueConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def __getattr__(name: str):
|
|
20
|
+
"""Lazy import of ``create_queuing_server_plugin`` (keeps the
|
|
21
|
+
``starfish_protocol.plugins`` import off the hot path for backend-only
|
|
22
|
+
users) and of ``NatsQueue``/``NatsQueueOptions`` (keeps ``nats-py`` an
|
|
23
|
+
optional dependency)."""
|
|
24
|
+
if name == "create_queuing_server_plugin":
|
|
25
|
+
from starfish_queuing.plugin import create_queuing_server_plugin as _p
|
|
26
|
+
return _p
|
|
27
|
+
if name in ("NatsQueue", "NatsQueueOptions"):
|
|
28
|
+
from starfish_queuing import nats as _nats
|
|
29
|
+
return getattr(_nats, name)
|
|
30
|
+
raise AttributeError(f"module 'starfish_queuing' has no attribute {name!r}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"AbstractQueue",
|
|
35
|
+
"MemoryQueue",
|
|
36
|
+
"CustomQueue",
|
|
37
|
+
"QueueMessage",
|
|
38
|
+
"QueueConfig",
|
|
39
|
+
"create_queuing_server_plugin",
|
|
40
|
+
"NatsQueue",
|
|
41
|
+
"NatsQueueOptions",
|
|
42
|
+
]
|
starfish_queuing/base.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Abstract base class for queue backends."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AbstractQueue(ABC):
|
|
7
|
+
"""Publish-only queue interface for emitting data-change events.
|
|
8
|
+
|
|
9
|
+
Implementations must provide :meth:`publish`. The :meth:`connect` and
|
|
10
|
+
:meth:`close` lifecycle hooks have default no-ops — override them only
|
|
11
|
+
when the backend requires explicit connection management (e.g. NATS).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
async def connect(self) -> None:
|
|
15
|
+
"""Establish a connection to the queue backend.
|
|
16
|
+
|
|
17
|
+
Override for network-backed implementations. The default is a no-op.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def publish(self, subject: str, payload: bytes) -> None:
|
|
22
|
+
raise NotImplementedError("publish must be implemented")
|
|
23
|
+
|
|
24
|
+
async def close(self) -> None:
|
|
25
|
+
"""Tear down the connection. Safe to call multiple times.
|
|
26
|
+
|
|
27
|
+
Override for network-backed implementations. The default is a no-op.
|
|
28
|
+
"""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Per-collection queue configuration (owned by the queuing plugin)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class QueueConfig:
|
|
10
|
+
"""Per-collection queue publishing configuration.
|
|
11
|
+
|
|
12
|
+
Apps pass a ``{collection_name: QueueConfig}`` map to
|
|
13
|
+
:func:`create_queuing_server_plugin`. Collections absent from that map
|
|
14
|
+
publish nothing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
topic: str | None = None
|
|
18
|
+
"""Subject/topic to publish to. Defaults to the collection name."""
|
|
19
|
+
|
|
20
|
+
include_params: bool = False
|
|
21
|
+
"""Include the resolved route path parameters in the published message."""
|
|
22
|
+
|
|
23
|
+
include_body: bool = False
|
|
24
|
+
"""Include the pushed ``data`` object in the message (JSON collections only)."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""In-memory and callback-based queue implementations."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any, Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
from starfish_queuing.base import AbstractQueue
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MemoryQueue(AbstractQueue):
|
|
10
|
+
"""In-memory queue that records published messages for testing.
|
|
11
|
+
|
|
12
|
+
Every call to :meth:`publish` appends a ``(subject, payload)`` tuple
|
|
13
|
+
to :attr:`messages`::
|
|
14
|
+
|
|
15
|
+
queue = MemoryQueue()
|
|
16
|
+
await queue.publish("posts", b'{"collection":"posts"}')
|
|
17
|
+
assert len(queue.messages) == 1
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.messages: list[tuple[str, bytes]] = []
|
|
22
|
+
|
|
23
|
+
async def publish(self, subject: str, payload: bytes) -> None:
|
|
24
|
+
self.messages.append((subject, payload))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _call(fn: Callable[..., Any], *args: Any) -> Any:
|
|
28
|
+
"""Invoke *fn* with *args*, awaiting the result if it is a coroutine."""
|
|
29
|
+
result = fn(*args)
|
|
30
|
+
if inspect.isawaitable(result):
|
|
31
|
+
return await result
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
PublishFn = Callable[[str, bytes], None | Awaitable[None]]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CustomQueue(AbstractQueue):
|
|
39
|
+
"""Queue backed by a user-supplied callback function.
|
|
40
|
+
|
|
41
|
+
The callback may be synchronous or ``async``::
|
|
42
|
+
|
|
43
|
+
queue = CustomQueue(on_publish=lambda subject, payload: print(subject))
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, *, on_publish: PublishFn | None = None) -> None:
|
|
47
|
+
self._on_publish = on_publish
|
|
48
|
+
|
|
49
|
+
async def publish(self, subject: str, payload: bytes) -> None:
|
|
50
|
+
if self._on_publish is not None:
|
|
51
|
+
await _call(self._on_publish, subject, payload)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Typed shape of the message published to the queue after a successful push."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Required, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QueueMessage(TypedDict, total=False):
|
|
7
|
+
"""Message published to the queue after a successful document push.
|
|
8
|
+
|
|
9
|
+
Required fields (always present):
|
|
10
|
+
collection: Collection name.
|
|
11
|
+
hash: SHA-256 hex hash of the stored document.
|
|
12
|
+
timestamp: Milliseconds since epoch when the push completed.
|
|
13
|
+
|
|
14
|
+
Optional fields (present only when the corresponding QueueConfig flag is set):
|
|
15
|
+
params: Resolved URL path parameters — present when ``include_params=True``.
|
|
16
|
+
body: Push request data field — present when ``include_body=True``
|
|
17
|
+
(JSON collections only). Contains the document as sent by the client,
|
|
18
|
+
before server-side sanitization.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
collection: Required[str]
|
|
22
|
+
hash: Required[str]
|
|
23
|
+
timestamp: Required[int]
|
|
24
|
+
params: dict[str, str]
|
|
25
|
+
body: dict[str, Any]
|
starfish_queuing/nats.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""NATS queue implementation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import nats
|
|
8
|
+
from nats.aio.client import Client as NatsClient
|
|
9
|
+
|
|
10
|
+
from starfish_queuing.base import AbstractQueue
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class NatsQueueOptions:
|
|
17
|
+
"""Connection options for :class:`NatsQueue`."""
|
|
18
|
+
|
|
19
|
+
servers: str | list[str] = "nats://localhost:4222"
|
|
20
|
+
"""NATS server URL(s)."""
|
|
21
|
+
|
|
22
|
+
name: str | None = None
|
|
23
|
+
"""Optional client name (visible in NATS monitoring)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NatsQueue(AbstractQueue):
|
|
27
|
+
"""Publish messages to a NATS server.
|
|
28
|
+
|
|
29
|
+
Connects lazily on the first :meth:`publish` call if :meth:`connect` has
|
|
30
|
+
not been called explicitly. Use the explicit lifecycle for best control::
|
|
31
|
+
|
|
32
|
+
queue = NatsQueue(NatsQueueOptions(servers="nats://localhost:4222"))
|
|
33
|
+
await queue.connect()
|
|
34
|
+
# … use queue …
|
|
35
|
+
await queue.close()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, opts: NatsQueueOptions | None = None) -> None:
|
|
39
|
+
self._opts = opts or NatsQueueOptions()
|
|
40
|
+
self._nc: NatsClient | None = None
|
|
41
|
+
self._lock = asyncio.Lock()
|
|
42
|
+
|
|
43
|
+
async def connect(self) -> None:
|
|
44
|
+
async with self._lock:
|
|
45
|
+
if self._nc is not None and self._nc.is_connected:
|
|
46
|
+
return
|
|
47
|
+
self._nc = await nats.connect(
|
|
48
|
+
servers=self._opts.servers,
|
|
49
|
+
name=self._opts.name,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def _ensure_connected(self) -> NatsClient:
|
|
53
|
+
if self._nc is None or not self._nc.is_connected:
|
|
54
|
+
await self.connect()
|
|
55
|
+
assert self._nc is not None # noqa: S101
|
|
56
|
+
return self._nc
|
|
57
|
+
|
|
58
|
+
async def publish(self, subject: str, payload: bytes) -> None:
|
|
59
|
+
nc = await self._ensure_connected()
|
|
60
|
+
await nc.publish(subject, payload)
|
|
61
|
+
|
|
62
|
+
async def close(self) -> None:
|
|
63
|
+
async with self._lock:
|
|
64
|
+
if self._nc is not None and self._nc.is_connected:
|
|
65
|
+
await self._nc.close()
|
|
66
|
+
self._nc = None
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Server plugin for the queuing extension (Python mirror).
|
|
2
|
+
|
|
3
|
+
Implements the ``after_write`` write-path hook from the ``ServerPlugin``
|
|
4
|
+
contract: after a successful push the server hands the plugin a
|
|
5
|
+
:class:`WriteEvent`; for any collection present in the plugin's ``collections``
|
|
6
|
+
map it builds a :class:`QueueMessage` and publishes it to the configured
|
|
7
|
+
:class:`AbstractQueue`. ``shutdown`` closes the queue during graceful shutdown.
|
|
8
|
+
|
|
9
|
+
The ``ServerPlugin``/``WriteEvent`` types live in ``starfish-protocol`` (the
|
|
10
|
+
shared contract layer), so this package needs no dependency on
|
|
11
|
+
``starfish-server`` — applications wire both packages at the top level.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Mapping
|
|
17
|
+
|
|
18
|
+
from starfish_protocol.plugins import ServerPlugin, WriteEvent
|
|
19
|
+
|
|
20
|
+
from starfish_queuing.base import AbstractQueue
|
|
21
|
+
from starfish_queuing.config import QueueConfig
|
|
22
|
+
from starfish_queuing.publish import publish_change_event
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_queuing_server_plugin(
|
|
26
|
+
*,
|
|
27
|
+
queue: AbstractQueue,
|
|
28
|
+
collections: Mapping[str, QueueConfig],
|
|
29
|
+
) -> ServerPlugin:
|
|
30
|
+
"""Build a :class:`ServerPlugin` that publishes a change event to *queue*
|
|
31
|
+
after every successful push to a configured collection."""
|
|
32
|
+
|
|
33
|
+
async def _after_write(event: WriteEvent) -> None:
|
|
34
|
+
cfg = collections.get(event.collection)
|
|
35
|
+
if cfg is None:
|
|
36
|
+
return
|
|
37
|
+
await publish_change_event(queue, cfg, event)
|
|
38
|
+
|
|
39
|
+
async def _shutdown() -> None:
|
|
40
|
+
await queue.close()
|
|
41
|
+
|
|
42
|
+
return ServerPlugin(
|
|
43
|
+
name="starfish-queuing",
|
|
44
|
+
after_write=_after_write,
|
|
45
|
+
shutdown=_shutdown,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["create_queuing_server_plugin"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Build and publish a queue message from a :class:`WriteEvent`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from starfish_protocol.plugins import WriteEvent
|
|
9
|
+
|
|
10
|
+
from starfish_queuing.base import AbstractQueue
|
|
11
|
+
from starfish_queuing.config import QueueConfig
|
|
12
|
+
from starfish_queuing.message import QueueMessage
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def publish_change_event(
|
|
18
|
+
queue: AbstractQueue,
|
|
19
|
+
config: QueueConfig,
|
|
20
|
+
event: WriteEvent,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Publish a change event for *event* using *config*.
|
|
23
|
+
|
|
24
|
+
Errors are logged but never propagate — a queue outage must not break
|
|
25
|
+
client writes.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
subject = config.topic or event.collection
|
|
29
|
+
msg: QueueMessage = {
|
|
30
|
+
"collection": event.collection,
|
|
31
|
+
"hash": event.hash,
|
|
32
|
+
"timestamp": event.timestamp,
|
|
33
|
+
}
|
|
34
|
+
if config.include_params and event.params:
|
|
35
|
+
msg["params"] = dict(event.params)
|
|
36
|
+
if config.include_body:
|
|
37
|
+
if event.body is not None:
|
|
38
|
+
msg["body"] = dict(event.body)
|
|
39
|
+
else:
|
|
40
|
+
logger.warning(
|
|
41
|
+
"include_body enabled for %s but request data is not a plain "
|
|
42
|
+
"object; body omitted from queue message",
|
|
43
|
+
event.collection,
|
|
44
|
+
)
|
|
45
|
+
await queue.publish(subject, json.dumps(msg).encode())
|
|
46
|
+
except Exception:
|
|
47
|
+
logger.warning(
|
|
48
|
+
"Failed to publish queue event for %s", event.collection, exc_info=True,
|
|
49
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: starfish-queuing
|
|
3
|
+
Version: 3.0.0a5
|
|
4
|
+
Summary: Starfish change-event queuing extension (post-push publish hook, MemoryQueue/CustomQueue/NatsQueue backends, per-collection config)
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: starfish-protocol
|
|
7
|
+
Provides-Extra: nats
|
|
8
|
+
Requires-Dist: nats-py>=2.0; extra == "nats"
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
12
|
+
Requires-Dist: respx>=0.23.1; extra == "dev"
|
|
13
|
+
Requires-Dist: nats-py>=2.0; extra == "dev"
|
|
14
|
+
Requires-Dist: starfish-server; extra == "dev"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
starfish_queuing/__init__.py,sha256=QbSdMhPenO3wrQdw44uLaWGTxT0wOlent6R8_JgKDe4,1560
|
|
2
|
+
starfish_queuing/base.py,sha256=ZdZnh7vrD0uEr3yFeORZAjZ8FuN0oiUZWjsFXndZ8uo,947
|
|
3
|
+
starfish_queuing/config.py,sha256=oPvrzfJUo5veew-9EoUVhcWG4wSdVxVfqxpOgjAsA7A,724
|
|
4
|
+
starfish_queuing/memory.py,sha256=YaR1vuzTCEHw5lILEwQ_H8KcucaiyngT6K9p0tLsicg,1536
|
|
5
|
+
starfish_queuing/message.py,sha256=HZxqB2miIeOfmEDmm3KI_NSK00oX6BhpuWnjYzhjygQ,980
|
|
6
|
+
starfish_queuing/nats.py,sha256=laGwcFChtnXp3n2xtodOLH87IDXfsZ7aMMBjypaQEqo,1985
|
|
7
|
+
starfish_queuing/plugin.py,sha256=QsRod4uwzsAq7IrWq3L8INppj1DNIN2AI2oWFWRfsbg,1643
|
|
8
|
+
starfish_queuing/publish.py,sha256=aI8gfC85oWnNmnXxYgd37ebBCM2Cuk_WAYZHOcnapR0,1538
|
|
9
|
+
starfish_queuing-3.0.0a5.dist-info/METADATA,sha256=NwzUk6SqRs2HnOaW99B8Pq5QO1TIS8-WBCynqVMObCg,577
|
|
10
|
+
starfish_queuing-3.0.0a5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
starfish_queuing-3.0.0a5.dist-info/top_level.txt,sha256=ZSiigH0fBtHQ0DzxOy8SUtWeMy0cHCACbmmGwrcunSM,17
|
|
12
|
+
starfish_queuing-3.0.0a5.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
starfish_queuing
|