starfish-queuing 3.0.0a5__tar.gz
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-3.0.0a5/PKG-INFO +14 -0
- starfish_queuing-3.0.0a5/README.md +47 -0
- starfish_queuing-3.0.0a5/pyproject.toml +30 -0
- starfish_queuing-3.0.0a5/setup.cfg +4 -0
- starfish_queuing-3.0.0a5/starfish_queuing/__init__.py +42 -0
- starfish_queuing-3.0.0a5/starfish_queuing/base.py +28 -0
- starfish_queuing-3.0.0a5/starfish_queuing/config.py +24 -0
- starfish_queuing-3.0.0a5/starfish_queuing/memory.py +51 -0
- starfish_queuing-3.0.0a5/starfish_queuing/message.py +25 -0
- starfish_queuing-3.0.0a5/starfish_queuing/nats.py +66 -0
- starfish_queuing-3.0.0a5/starfish_queuing/plugin.py +49 -0
- starfish_queuing-3.0.0a5/starfish_queuing/publish.py +49 -0
- starfish_queuing-3.0.0a5/starfish_queuing.egg-info/PKG-INFO +14 -0
- starfish_queuing-3.0.0a5/starfish_queuing.egg-info/SOURCES.txt +19 -0
- starfish_queuing-3.0.0a5/starfish_queuing.egg-info/dependency_links.txt +1 -0
- starfish_queuing-3.0.0a5/starfish_queuing.egg-info/requires.txt +11 -0
- starfish_queuing-3.0.0a5/starfish_queuing.egg-info/top_level.txt +1 -0
- starfish_queuing-3.0.0a5/tests/test_append_only_no_persist.py +172 -0
- starfish_queuing-3.0.0a5/tests/test_lifecycle_close.py +40 -0
- starfish_queuing-3.0.0a5/tests/test_memory.py +56 -0
- starfish_queuing-3.0.0a5/tests/test_plugin.py +311 -0
|
@@ -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,47 @@
|
|
|
1
|
+
# starfish-queuing
|
|
2
|
+
|
|
3
|
+
Change-event queuing extension for Starfish (Python). After a successful push,
|
|
4
|
+
the server hands each registered plugin a `WriteEvent`; this plugin publishes a
|
|
5
|
+
`QueueMessage` to a configured transport (`MemoryQueue`, `CustomQueue`,
|
|
6
|
+
`NatsQueue`, or your own `AbstractQueue`).
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pip install starfish-server starfish-queuing
|
|
12
|
+
# with NATS support:
|
|
13
|
+
pip install "starfish-queuing[nats]"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from starfish_server import create_sync_router, SyncRouterOptions
|
|
20
|
+
from starfish_queuing import create_queuing_server_plugin, MemoryQueue, QueueConfig
|
|
21
|
+
|
|
22
|
+
queue = MemoryQueue()
|
|
23
|
+
|
|
24
|
+
plugin = create_queuing_server_plugin(
|
|
25
|
+
queue=queue,
|
|
26
|
+
collections={
|
|
27
|
+
"events": QueueConfig(topic="events", include_params=True, include_body=True),
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
router = create_sync_router(
|
|
32
|
+
SyncRouterOptions(
|
|
33
|
+
config=config,
|
|
34
|
+
store=store,
|
|
35
|
+
# …
|
|
36
|
+
plugins=[plugin],
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The plugin publishes only for collections present in its `collections` map.
|
|
42
|
+
`topic` defaults to the collection name — an unset *or empty-string* topic falls
|
|
43
|
+
back to it (an empty broker subject is a footgun). `shutdown()` closes the queue
|
|
44
|
+
when the server's graceful-shutdown handler is given the plugin list.
|
|
45
|
+
|
|
46
|
+
See `docs/ts/queuing/` for the full guide (the TypeScript and Python APIs mirror
|
|
47
|
+
each other).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "starfish-queuing"
|
|
7
|
+
version = "3.0.0a5"
|
|
8
|
+
description = "Starfish change-event queuing extension (post-push publish hook, MemoryQueue/CustomQueue/NatsQueue backends, per-collection config)"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"starfish-protocol",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
nats = ["nats-py>=2.0"]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=7.0",
|
|
18
|
+
"pytest-asyncio>=0.21",
|
|
19
|
+
"respx>=0.23.1",
|
|
20
|
+
"nats-py>=2.0",
|
|
21
|
+
"starfish-server",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.uv.sources]
|
|
25
|
+
starfish-protocol = { path = "../protocol", editable = true }
|
|
26
|
+
starfish-server = { path = "../server", editable = true }
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
asyncio_mode = "auto"
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -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
|
+
]
|
|
@@ -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]
|
|
@@ -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,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
starfish_queuing/__init__.py
|
|
4
|
+
starfish_queuing/base.py
|
|
5
|
+
starfish_queuing/config.py
|
|
6
|
+
starfish_queuing/memory.py
|
|
7
|
+
starfish_queuing/message.py
|
|
8
|
+
starfish_queuing/nats.py
|
|
9
|
+
starfish_queuing/plugin.py
|
|
10
|
+
starfish_queuing/publish.py
|
|
11
|
+
starfish_queuing.egg-info/PKG-INFO
|
|
12
|
+
starfish_queuing.egg-info/SOURCES.txt
|
|
13
|
+
starfish_queuing.egg-info/dependency_links.txt
|
|
14
|
+
starfish_queuing.egg-info/requires.txt
|
|
15
|
+
starfish_queuing.egg-info/top_level.txt
|
|
16
|
+
tests/test_append_only_no_persist.py
|
|
17
|
+
tests/test_lifecycle_close.py
|
|
18
|
+
tests/test_memory.py
|
|
19
|
+
tests/test_plugin.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
starfish_queuing
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Tests for appendOnly+persist=false collection (replaces queueOnly), driven via the queuing plugin."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
7
|
+
from httpx import AsyncClient, ASGITransport
|
|
8
|
+
|
|
9
|
+
from starfish_server.config.schema import (
|
|
10
|
+
SyncConfig,
|
|
11
|
+
CollectionConfig,
|
|
12
|
+
AppendOnlyConfig,
|
|
13
|
+
)
|
|
14
|
+
from starfish_server.config.validate import validate_config
|
|
15
|
+
from starfish_server.router.route_builder import (
|
|
16
|
+
create_sync_router,
|
|
17
|
+
SyncRouterOptions,
|
|
18
|
+
AuthResult,
|
|
19
|
+
)
|
|
20
|
+
from starfish_queuing import MemoryQueue, QueueConfig, create_queuing_server_plugin
|
|
21
|
+
|
|
22
|
+
from tests.helpers import MemoryObjectStore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _make_col(**overrides) -> CollectionConfig:
|
|
26
|
+
defaults = dict(
|
|
27
|
+
name="events",
|
|
28
|
+
storagePath="events/{eventId}",
|
|
29
|
+
readRoles=["public"],
|
|
30
|
+
writeRoles=["admin"],
|
|
31
|
+
encryption="none",
|
|
32
|
+
maxBodyBytes=65536,
|
|
33
|
+
)
|
|
34
|
+
defaults.update(overrides)
|
|
35
|
+
return CollectionConfig(**defaults)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_app(col: CollectionConfig, queue_collections: dict[str, QueueConfig] | None = None):
|
|
39
|
+
store = MemoryObjectStore()
|
|
40
|
+
q = MemoryQueue()
|
|
41
|
+
config = SyncConfig(version=1, collections=[col])
|
|
42
|
+
|
|
43
|
+
async def role_resolver(request: Request) -> AuthResult:
|
|
44
|
+
return AuthResult(identity="user-1", roles=["admin"])
|
|
45
|
+
|
|
46
|
+
plugin = create_queuing_server_plugin(queue=q, collections=queue_collections or {})
|
|
47
|
+
router = create_sync_router(
|
|
48
|
+
SyncRouterOptions(
|
|
49
|
+
store=store, config=config, role_resolver=role_resolver, plugins=[plugin],
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
app = FastAPI()
|
|
53
|
+
app.include_router(router)
|
|
54
|
+
return app, store, q
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _push(client: AsyncClient, path: str = "/push/events/evt-1", base_hash=None):
|
|
58
|
+
return await client.post(
|
|
59
|
+
path,
|
|
60
|
+
json={"data": {"type": "click"}, "baseHash": base_hash},
|
|
61
|
+
headers={"content-type": "application/json"},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_push_returns_hash_and_timestamp():
|
|
67
|
+
app, _, _ = _build_app(_make_col(appendOnly=AppendOnlyConfig(type="by_timestamp", persist=False)))
|
|
68
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
69
|
+
resp = await _push(client)
|
|
70
|
+
assert resp.status_code == 200
|
|
71
|
+
body = resp.json()
|
|
72
|
+
assert isinstance(body["hash"], str)
|
|
73
|
+
assert len(body["hash"]) == 64 # SHA-256 hex
|
|
74
|
+
assert isinstance(body["timestamp"], int)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_does_not_write_to_storage():
|
|
79
|
+
app, store, _ = _build_app(_make_col(appendOnly=AppendOnlyConfig(type="by_timestamp", persist=False)))
|
|
80
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
81
|
+
await _push(client)
|
|
82
|
+
stored = await store.get_string("events/evt-1")
|
|
83
|
+
assert stored is None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_pull_returns_empty_data():
|
|
88
|
+
"""Nothing stored → pull returns empty."""
|
|
89
|
+
app, _, _ = _build_app(_make_col(appendOnly=AppendOnlyConfig(type="by_timestamp", persist=False)))
|
|
90
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
91
|
+
await _push(client)
|
|
92
|
+
resp = await client.get("/pull/events/evt-1")
|
|
93
|
+
assert resp.status_code == 200
|
|
94
|
+
body = resp.json()
|
|
95
|
+
assert body["data"] == {}
|
|
96
|
+
assert body["hash"] == ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_accepts_any_base_hash():
|
|
101
|
+
"""No conflict detection — any baseHash is accepted."""
|
|
102
|
+
app, _, _ = _build_app(_make_col(appendOnly=AppendOnlyConfig(type="by_timestamp", persist=False)))
|
|
103
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
104
|
+
await _push(client)
|
|
105
|
+
resp = await _push(client, base_hash="arbitrary-wrong-hash")
|
|
106
|
+
assert resp.status_code == 200
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.asyncio
|
|
110
|
+
async def test_consistent_hash_for_same_data():
|
|
111
|
+
app, _, _ = _build_app(_make_col(appendOnly=AppendOnlyConfig(type="by_timestamp", persist=False)))
|
|
112
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
113
|
+
resp1 = await _push(client, "/push/events/evt-1")
|
|
114
|
+
resp2 = await _push(client, "/push/events/evt-2")
|
|
115
|
+
assert resp1.json()["hash"] == resp2.json()["hash"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
async def test_publishes_queue_event():
|
|
120
|
+
col = _make_col(appendOnly=AppendOnlyConfig(type="by_timestamp", persist=False))
|
|
121
|
+
app, _, q = _build_app(col, {"events": QueueConfig(topic="events.created")})
|
|
122
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
123
|
+
resp = await _push(client)
|
|
124
|
+
push_body = resp.json()
|
|
125
|
+
|
|
126
|
+
assert len(q.messages) == 1
|
|
127
|
+
subject, payload = q.messages[0]
|
|
128
|
+
msg = json.loads(payload)
|
|
129
|
+
assert subject == "events.created"
|
|
130
|
+
assert msg["collection"] == "events"
|
|
131
|
+
assert msg["hash"] == push_body["hash"]
|
|
132
|
+
assert msg["timestamp"] == push_body["timestamp"]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.asyncio
|
|
136
|
+
async def test_push_accepted_without_queue_configured():
|
|
137
|
+
"""appendOnly+persist=false without a configured queue: ephemeral (no storage, no event)."""
|
|
138
|
+
col = _make_col(appendOnly=AppendOnlyConfig(type="by_timestamp", persist=False))
|
|
139
|
+
app, _, q = _build_app(col) # no queue_collections
|
|
140
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
141
|
+
resp = await _push(client)
|
|
142
|
+
assert resp.status_code == 200
|
|
143
|
+
assert len(q.messages) == 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.asyncio
|
|
147
|
+
async def test_still_validates_missing_data_field():
|
|
148
|
+
app, _, _ = _build_app(_make_col(appendOnly=AppendOnlyConfig(type="by_timestamp", persist=False)))
|
|
149
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
150
|
+
resp = await client.post(
|
|
151
|
+
"/push/events/evt-1",
|
|
152
|
+
json={"baseHash": None},
|
|
153
|
+
headers={"content-type": "application/json"},
|
|
154
|
+
)
|
|
155
|
+
assert resp.status_code == 400
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_valid_append_only_no_persist_collection():
|
|
159
|
+
errors = validate_config(SyncConfig(version=1, collections=[_make_col(appendOnly=AppendOnlyConfig(type="by_timestamp", persist=False))]))
|
|
160
|
+
assert errors == []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_append_only_binary_collection_rejected():
|
|
164
|
+
col = _make_col(appendOnly=AppendOnlyConfig(type="by_timestamp", persist=False), allowedMimeTypes=["image/png"])
|
|
165
|
+
errors = validate_config(SyncConfig(version=1, collections=[col]))
|
|
166
|
+
assert any("appendOnly cannot be used with binary collections" in e for e in errors)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_append_only_pullonly_rejected():
|
|
170
|
+
col = _make_col(appendOnly=AppendOnlyConfig(type="by_timestamp", persist=False), pull_only=True)
|
|
171
|
+
errors = validate_config(SyncConfig(version=1, collections=[col]))
|
|
172
|
+
assert any("appendOnly cannot be used with pullOnly" in e for e in errors)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The queuing plugin's shutdown hook closes the queue during graceful shutdown."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import AsyncMock
|
|
5
|
+
|
|
6
|
+
from starfish_server.lifecycle import GracefulShutdown, GracefulShutdownOptions
|
|
7
|
+
from starfish_queuing import create_queuing_server_plugin
|
|
8
|
+
from starfish_queuing.base import AbstractQueue
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _SpyQueue(AbstractQueue):
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self.close_calls = 0
|
|
14
|
+
|
|
15
|
+
async def publish(self, subject: str, payload: bytes) -> None: # pragma: no cover
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
async def close(self) -> None:
|
|
19
|
+
self.close_calls += 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_graceful_shutdown_invokes_plugin_shutdown_closing_queue():
|
|
24
|
+
queue = _SpyQueue()
|
|
25
|
+
plugin = create_queuing_server_plugin(queue=queue, collections={})
|
|
26
|
+
|
|
27
|
+
gs = GracefulShutdown(GracefulShutdownOptions(plugins=[plugin], signals=[]))
|
|
28
|
+
await gs.shutdown()
|
|
29
|
+
|
|
30
|
+
assert queue.close_calls == 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.asyncio
|
|
34
|
+
async def test_plugin_shutdown_hook_calls_queue_close():
|
|
35
|
+
queue = AsyncMock(spec=AbstractQueue)
|
|
36
|
+
plugin = create_queuing_server_plugin(queue=queue, collections={})
|
|
37
|
+
|
|
38
|
+
assert plugin.shutdown is not None
|
|
39
|
+
await plugin.shutdown()
|
|
40
|
+
queue.close.assert_awaited_once()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Tests for in-memory and callback-based queue implementations."""
|
|
2
|
+
|
|
3
|
+
from starfish_queuing.base import AbstractQueue
|
|
4
|
+
from starfish_queuing.memory import MemoryQueue, CustomQueue
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def test_memory_queue_records_messages():
|
|
8
|
+
q = MemoryQueue()
|
|
9
|
+
await q.publish("posts", b'{"collection":"posts"}')
|
|
10
|
+
await q.publish("settings", b'{"collection":"settings"}')
|
|
11
|
+
|
|
12
|
+
assert len(q.messages) == 2
|
|
13
|
+
assert q.messages[0] == ("posts", b'{"collection":"posts"}')
|
|
14
|
+
assert q.messages[1] == ("settings", b'{"collection":"settings"}')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def test_memory_queue_starts_empty():
|
|
18
|
+
q = MemoryQueue()
|
|
19
|
+
assert q.messages == []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def test_custom_queue_sync_callback():
|
|
23
|
+
received: list[tuple[str, bytes]] = []
|
|
24
|
+
|
|
25
|
+
def on_publish(subject: str, payload: bytes) -> None:
|
|
26
|
+
received.append((subject, payload))
|
|
27
|
+
|
|
28
|
+
q = CustomQueue(on_publish=on_publish)
|
|
29
|
+
await q.publish("topic", b"data")
|
|
30
|
+
|
|
31
|
+
assert received == [("topic", b"data")]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def test_custom_queue_async_callback():
|
|
35
|
+
received: list[tuple[str, bytes]] = []
|
|
36
|
+
|
|
37
|
+
async def on_publish(subject: str, payload: bytes) -> None:
|
|
38
|
+
received.append((subject, payload))
|
|
39
|
+
|
|
40
|
+
q = CustomQueue(on_publish=on_publish)
|
|
41
|
+
await q.publish("topic", b"data")
|
|
42
|
+
|
|
43
|
+
assert received == [("topic", b"data")]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def test_custom_queue_no_callback_is_noop():
|
|
47
|
+
q = CustomQueue()
|
|
48
|
+
await q.publish("topic", b"data") # should not raise
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def test_abstract_queue_connect_close_are_noops():
|
|
52
|
+
"""Default connect() and close() do nothing — safe to call."""
|
|
53
|
+
q = MemoryQueue()
|
|
54
|
+
await q.connect()
|
|
55
|
+
await q.close()
|
|
56
|
+
assert isinstance(q, AbstractQueue)
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Integration tests — the queuing plugin publishes events on push through the FastAPI router."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
7
|
+
from httpx import AsyncClient, ASGITransport
|
|
8
|
+
|
|
9
|
+
from starfish_server.config.schema import SyncConfig, CollectionConfig
|
|
10
|
+
from starfish_server.router.route_builder import (
|
|
11
|
+
create_sync_router,
|
|
12
|
+
SyncRouterOptions,
|
|
13
|
+
AuthResult,
|
|
14
|
+
)
|
|
15
|
+
from starfish_queuing import (
|
|
16
|
+
MemoryQueue,
|
|
17
|
+
QueueConfig,
|
|
18
|
+
create_queuing_server_plugin,
|
|
19
|
+
)
|
|
20
|
+
from starfish_queuing.base import AbstractQueue
|
|
21
|
+
from starfish_queuing.publish import publish_change_event
|
|
22
|
+
from starfish_protocol.plugins import WriteEvent
|
|
23
|
+
|
|
24
|
+
from tests.helpers import MemoryObjectStore
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_app(
|
|
28
|
+
collections: list[CollectionConfig],
|
|
29
|
+
queue_collections: dict[str, QueueConfig] | None = None,
|
|
30
|
+
queue: AbstractQueue | None = None,
|
|
31
|
+
) -> tuple[FastAPI, MemoryObjectStore, AbstractQueue]:
|
|
32
|
+
store = MemoryObjectStore()
|
|
33
|
+
q = queue or MemoryQueue()
|
|
34
|
+
config = SyncConfig(version=1, collections=collections)
|
|
35
|
+
|
|
36
|
+
async def role_resolver(request: Request) -> AuthResult:
|
|
37
|
+
return AuthResult(identity="user-1", roles=["admin", "self"])
|
|
38
|
+
|
|
39
|
+
plugin = create_queuing_server_plugin(queue=q, collections=queue_collections or {})
|
|
40
|
+
router = create_sync_router(
|
|
41
|
+
SyncRouterOptions(
|
|
42
|
+
store=store, config=config, role_resolver=role_resolver, plugins=[plugin],
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
app = FastAPI()
|
|
46
|
+
app.include_router(router)
|
|
47
|
+
return app, store, q
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _col(name: str = "posts", storage_path: str = "posts/{postId}", **overrides) -> CollectionConfig:
|
|
51
|
+
return CollectionConfig(
|
|
52
|
+
name=name,
|
|
53
|
+
storagePath=storage_path,
|
|
54
|
+
readRoles=["public"],
|
|
55
|
+
writeRoles=["admin"],
|
|
56
|
+
encryption="none",
|
|
57
|
+
maxBodyBytes=65536,
|
|
58
|
+
**overrides,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _push(client: AsyncClient, path: str = "/push/posts/abc") -> dict:
|
|
63
|
+
resp = await client.post(
|
|
64
|
+
path,
|
|
65
|
+
json={"data": {"title": "Hello"}, "baseHash": None},
|
|
66
|
+
headers={"content-type": "application/json"},
|
|
67
|
+
)
|
|
68
|
+
assert resp.status_code == 200
|
|
69
|
+
return resp.json()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
async def test_push_publishes_queue_event():
|
|
74
|
+
app, store, q = _build_app([_col()], {"posts": QueueConfig()})
|
|
75
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
76
|
+
push_body = await _push(client)
|
|
77
|
+
|
|
78
|
+
assert len(q.messages) == 1
|
|
79
|
+
subject, payload = q.messages[0]
|
|
80
|
+
msg = json.loads(payload)
|
|
81
|
+
assert subject == "posts" # default topic = collection name
|
|
82
|
+
assert msg["collection"] == "posts"
|
|
83
|
+
assert msg["hash"] == push_body["hash"]
|
|
84
|
+
assert msg["timestamp"] == push_body["timestamp"]
|
|
85
|
+
assert "params" not in msg
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_push_collection_not_configured_no_event():
|
|
90
|
+
app, store, q = _build_app([_col()], {}) # "posts" not in plugin map
|
|
91
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
92
|
+
await _push(client)
|
|
93
|
+
|
|
94
|
+
assert len(q.messages) == 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.asyncio
|
|
98
|
+
async def test_custom_topic():
|
|
99
|
+
app, store, q = _build_app([_col()], {"posts": QueueConfig(topic="custom.topic")})
|
|
100
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
101
|
+
await _push(client)
|
|
102
|
+
|
|
103
|
+
assert q.messages[0][0] == "custom.topic"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.asyncio
|
|
107
|
+
async def test_include_params():
|
|
108
|
+
app, store, q = _build_app([_col()], {"posts": QueueConfig(include_params=True)})
|
|
109
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
110
|
+
await _push(client, "/push/posts/my-post-id")
|
|
111
|
+
|
|
112
|
+
msg = json.loads(q.messages[0][1])
|
|
113
|
+
assert msg["params"] == {"postId": "my-post-id"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.mark.asyncio
|
|
117
|
+
async def test_no_include_params_by_default():
|
|
118
|
+
app, store, q = _build_app([_col()], {"posts": QueueConfig()})
|
|
119
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
120
|
+
await _push(client)
|
|
121
|
+
|
|
122
|
+
msg = json.loads(q.messages[0][1])
|
|
123
|
+
assert "params" not in msg
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.asyncio
|
|
127
|
+
async def test_no_event_on_conflict():
|
|
128
|
+
"""Queue event should NOT be published when push returns 409 (hash mismatch)."""
|
|
129
|
+
app, store, q = _build_app([_col()], {"posts": QueueConfig()})
|
|
130
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
131
|
+
# First push succeeds
|
|
132
|
+
await _push(client)
|
|
133
|
+
q.messages.clear()
|
|
134
|
+
|
|
135
|
+
# Second push with wrong baseHash → 409
|
|
136
|
+
resp = await client.post(
|
|
137
|
+
"/push/posts/abc",
|
|
138
|
+
json={"data": {"title": "Updated"}, "baseHash": "wrong-hash"},
|
|
139
|
+
headers={"content-type": "application/json"},
|
|
140
|
+
)
|
|
141
|
+
assert resp.status_code == 409
|
|
142
|
+
|
|
143
|
+
assert len(q.messages) == 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.asyncio
|
|
147
|
+
async def test_include_body():
|
|
148
|
+
app, store, q = _build_app([_col()], {"posts": QueueConfig(include_body=True)})
|
|
149
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
150
|
+
await _push(client, "/push/posts/my-post-id")
|
|
151
|
+
|
|
152
|
+
msg = json.loads(q.messages[0][1])
|
|
153
|
+
assert msg["body"] == {"title": "Hello"}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.mark.asyncio
|
|
157
|
+
async def test_no_include_body_by_default():
|
|
158
|
+
app, store, q = _build_app([_col()], {"posts": QueueConfig()})
|
|
159
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
160
|
+
await _push(client)
|
|
161
|
+
|
|
162
|
+
msg = json.loads(q.messages[0][1])
|
|
163
|
+
assert "body" not in msg
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.asyncio
|
|
167
|
+
async def test_include_body_and_params_together():
|
|
168
|
+
app, store, q = _build_app(
|
|
169
|
+
[_col()], {"posts": QueueConfig(include_body=True, include_params=True)},
|
|
170
|
+
)
|
|
171
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
172
|
+
await _push(client, "/push/posts/post-42")
|
|
173
|
+
|
|
174
|
+
msg = json.loads(q.messages[0][1])
|
|
175
|
+
assert msg["body"] == {"title": "Hello"}
|
|
176
|
+
assert msg["params"] == {"postId": "post-42"}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_binary_collection_include_body_never_emits_body():
|
|
181
|
+
col = _col(
|
|
182
|
+
name="avatar",
|
|
183
|
+
storage_path="users/{userId}/avatar",
|
|
184
|
+
allowedMimeTypes=["image/png"],
|
|
185
|
+
)
|
|
186
|
+
app, store, q = _build_app([col], {"avatar": QueueConfig(include_body=True)})
|
|
187
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
188
|
+
resp = await client.post(
|
|
189
|
+
"/push/users/user-1/avatar",
|
|
190
|
+
content=b"\x89PNG",
|
|
191
|
+
headers={"content-type": "image/png"},
|
|
192
|
+
)
|
|
193
|
+
assert resp.status_code == 200
|
|
194
|
+
|
|
195
|
+
assert len(q.messages) == 1
|
|
196
|
+
msg = json.loads(q.messages[0][1])
|
|
197
|
+
assert "body" not in msg
|
|
198
|
+
assert msg["collection"] == "avatar"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@pytest.mark.asyncio
|
|
202
|
+
async def test_queue_failure_does_not_break_push():
|
|
203
|
+
"""A queue publish error must not propagate to the client."""
|
|
204
|
+
|
|
205
|
+
class FailingQueue(AbstractQueue):
|
|
206
|
+
async def publish(self, subject: str, payload: bytes) -> None:
|
|
207
|
+
raise RuntimeError("NATS connection lost")
|
|
208
|
+
|
|
209
|
+
app, store, q = _build_app([_col()], {"posts": QueueConfig()}, queue=FailingQueue())
|
|
210
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
211
|
+
push_body = await _push(client)
|
|
212
|
+
|
|
213
|
+
assert "hash" in push_body
|
|
214
|
+
assert "timestamp" in push_body
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@pytest.mark.asyncio
|
|
218
|
+
async def test_bundle_collection_include_body_emits_body():
|
|
219
|
+
col = _col(
|
|
220
|
+
name="prefs",
|
|
221
|
+
storage_path="users/{userId}/data",
|
|
222
|
+
bundle="userdata",
|
|
223
|
+
)
|
|
224
|
+
app, store, q = _build_app([col], {"prefs": QueueConfig(include_body=True)})
|
|
225
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
226
|
+
resp = await client.post(
|
|
227
|
+
"/push/users/user-1/data/prefs",
|
|
228
|
+
json={"data": {"theme": "dark"}, "baseHash": None},
|
|
229
|
+
headers={"content-type": "application/json"},
|
|
230
|
+
)
|
|
231
|
+
assert resp.status_code == 200
|
|
232
|
+
|
|
233
|
+
assert len(q.messages) == 1
|
|
234
|
+
msg = json.loads(q.messages[0][1])
|
|
235
|
+
assert msg["collection"] == "prefs"
|
|
236
|
+
assert msg["body"] == {"theme": "dark"}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.mark.asyncio
|
|
240
|
+
async def test_empty_topic_falls_back_to_collection_name():
|
|
241
|
+
# Cross-language divergence on a reachable, user-settable config field.
|
|
242
|
+
# Python uses `config.topic or event.collection` (publish.py), which coalesces
|
|
243
|
+
# an empty-string topic to the collection name — the safe behaviour, since an
|
|
244
|
+
# empty broker subject is a footgun. TS uses `cfg.topic ?? event.collection`,
|
|
245
|
+
# which keeps "" verbatim and publishes to subject "". This test pins the
|
|
246
|
+
# convergent behaviour; the TS side is pinned as it.fails in plugin.test.ts.
|
|
247
|
+
app, store, q = _build_app([_col()], {"posts": QueueConfig(topic="")})
|
|
248
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
249
|
+
await _push(client)
|
|
250
|
+
|
|
251
|
+
assert q.messages[0][0] == "posts"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@pytest.mark.asyncio
|
|
255
|
+
async def test_omits_params_when_storage_path_has_no_path_params():
|
|
256
|
+
# include_params gate is `if config.include_params and event.params:`, so an
|
|
257
|
+
# empty params map (no `{…}` placeholders in storage_path) publishes no params.
|
|
258
|
+
col = _col(name="config", storage_path="global/config")
|
|
259
|
+
app, store, q = _build_app([col], {"config": QueueConfig(include_params=True)})
|
|
260
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
261
|
+
resp = await client.post(
|
|
262
|
+
"/push/global/config",
|
|
263
|
+
json={"data": {"x": 1}, "baseHash": None},
|
|
264
|
+
headers={"content-type": "application/json"},
|
|
265
|
+
)
|
|
266
|
+
assert resp.status_code == 200
|
|
267
|
+
|
|
268
|
+
assert len(q.messages) == 1
|
|
269
|
+
msg = json.loads(q.messages[0][1])
|
|
270
|
+
assert "params" not in msg
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@pytest.mark.asyncio
|
|
274
|
+
async def test_preserves_unicode_in_topic_and_body():
|
|
275
|
+
# Path segments are charset-restricted (non-ASCII rejected at the door), so
|
|
276
|
+
# unicode is probed on the reachable surfaces: config topic and JSON body.
|
|
277
|
+
app, store, q = _build_app(
|
|
278
|
+
[_col()], {"posts": QueueConfig(include_body=True, topic="更新.notify")},
|
|
279
|
+
)
|
|
280
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
281
|
+
resp = await client.post(
|
|
282
|
+
"/push/posts/post-1",
|
|
283
|
+
json={"data": {"note": "Ñoño 🎉", "ключ": "значение"}, "baseHash": None},
|
|
284
|
+
headers={"content-type": "application/json"},
|
|
285
|
+
)
|
|
286
|
+
assert resp.status_code == 200
|
|
287
|
+
|
|
288
|
+
assert len(q.messages) == 1
|
|
289
|
+
subject, payload = q.messages[0]
|
|
290
|
+
assert subject == "更新.notify"
|
|
291
|
+
msg = json.loads(payload)
|
|
292
|
+
assert msg["body"]["note"] == "Ñoño 🎉"
|
|
293
|
+
assert msg["body"]["ключ"] == "значение"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@pytest.mark.asyncio
|
|
297
|
+
async def test_omits_a_none_body_handed_directly():
|
|
298
|
+
# The Python gate is `if event.body is not None`, so a None body is OMITTED.
|
|
299
|
+
# The server NEVER emits body=None as distinct from absent — route_builder.py
|
|
300
|
+
# sets WriteEvent.body only when the pushed data is a dict, otherwise leaves it
|
|
301
|
+
# None — so this is the absent case. TS's gate is `if (event.body !== undefined)`,
|
|
302
|
+
# which INCLUDES an explicit null body; the difference is benign because neither
|
|
303
|
+
# path is reached for a real document. Pinned (and flagged) so it's locked if
|
|
304
|
+
# WriteEvent population ever changes. See plugin.test.ts for the TS side.
|
|
305
|
+
q = MemoryQueue()
|
|
306
|
+
event = WriteEvent(collection="posts", hash="h", timestamp=1, params={}, body=None)
|
|
307
|
+
await publish_change_event(q, QueueConfig(include_body=True), event)
|
|
308
|
+
|
|
309
|
+
assert len(q.messages) == 1
|
|
310
|
+
msg = json.loads(q.messages[0][1])
|
|
311
|
+
assert "body" not in msg
|