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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,11 @@
1
+ starfish-protocol
2
+
3
+ [dev]
4
+ pytest>=7.0
5
+ pytest-asyncio>=0.21
6
+ respx>=0.23.1
7
+ nats-py>=2.0
8
+ starfish-server
9
+
10
+ [nats]
11
+ nats-py>=2.0
@@ -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