starfish-outbox 3.0.0a20__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_outbox-3.0.0a20/PKG-INFO +48 -0
- starfish_outbox-3.0.0a20/README.md +38 -0
- starfish_outbox-3.0.0a20/pyproject.toml +21 -0
- starfish_outbox-3.0.0a20/setup.cfg +4 -0
- starfish_outbox-3.0.0a20/starfish_outbox/__init__.py +29 -0
- starfish_outbox-3.0.0a20/starfish_outbox/drain.py +47 -0
- starfish_outbox-3.0.0a20/starfish_outbox/queue.py +167 -0
- starfish_outbox-3.0.0a20/starfish_outbox.egg-info/PKG-INFO +48 -0
- starfish_outbox-3.0.0a20/starfish_outbox.egg-info/SOURCES.txt +11 -0
- starfish_outbox-3.0.0a20/starfish_outbox.egg-info/dependency_links.txt +1 -0
- starfish_outbox-3.0.0a20/starfish_outbox.egg-info/requires.txt +4 -0
- starfish_outbox-3.0.0a20/starfish_outbox.egg-info/top_level.txt +1 -0
- starfish_outbox-3.0.0a20/tests/test_outbox.py +122 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: starfish-outbox
|
|
3
|
+
Version: 3.0.0a20
|
|
4
|
+
Summary: Starfish durable offline write-queue — dedup-by-id, claim, retry, reconnect-drain, persisted per identity, framework-free
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
10
|
+
|
|
11
|
+
# starfish-outbox
|
|
12
|
+
|
|
13
|
+
A durable, per-identity **offline write-queue** — the client-side complement to the
|
|
14
|
+
server-side `queuing` extension.
|
|
15
|
+
|
|
16
|
+
Generic over the queued item: you own what an item is, supply its dedup `id`, and a
|
|
17
|
+
`send` that performs the real write. The queue handles persistence (write-through to a
|
|
18
|
+
`LocalCache`), dedup-by-id, single-shot claim (no double-send), attempt counting with
|
|
19
|
+
auto-retry-then-fail, crash-safe recovery of stuck `sending` entries, and subscriptions.
|
|
20
|
+
`drain_outbox` is connectivity-agnostic — you trigger it on whatever reconnect signal you have.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install starfish-outbox
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from starfish_outbox import OutboxQueue, drain_outbox
|
|
32
|
+
|
|
33
|
+
queue: OutboxQueue = OutboxQueue(local_cache) # local_cache: async get_item / set_item / remove_item
|
|
34
|
+
await queue.hydrate(f"outbox.{user_id}")
|
|
35
|
+
|
|
36
|
+
# Enqueue a write while offline (the id is yours to thread into the eventual write):
|
|
37
|
+
await queue.enqueue(write_id, {"path": path, "body": body})
|
|
38
|
+
|
|
39
|
+
# Drain on reconnect:
|
|
40
|
+
async def send(entry):
|
|
41
|
+
await client.push(entry.item["path"], entry.item["body"], base_hash_for(entry.item["path"]))
|
|
42
|
+
|
|
43
|
+
await drain_outbox(queue, send, max_attempts=5)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Mutating methods (`enqueue` / `claim` / `remove` / `retry` / …) are `async` because each
|
|
47
|
+
writes through to the cache; `get` / `pending` / `subscribe` are synchronous. JSON-compatible
|
|
48
|
+
on disk with the TypeScript `@drakkar.software/starfish-outbox`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# starfish-outbox
|
|
2
|
+
|
|
3
|
+
A durable, per-identity **offline write-queue** — the client-side complement to the
|
|
4
|
+
server-side `queuing` extension.
|
|
5
|
+
|
|
6
|
+
Generic over the queued item: you own what an item is, supply its dedup `id`, and a
|
|
7
|
+
`send` that performs the real write. The queue handles persistence (write-through to a
|
|
8
|
+
`LocalCache`), dedup-by-id, single-shot claim (no double-send), attempt counting with
|
|
9
|
+
auto-retry-then-fail, crash-safe recovery of stuck `sending` entries, and subscriptions.
|
|
10
|
+
`drain_outbox` is connectivity-agnostic — you trigger it on whatever reconnect signal you have.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pip install starfish-outbox
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from starfish_outbox import OutboxQueue, drain_outbox
|
|
22
|
+
|
|
23
|
+
queue: OutboxQueue = OutboxQueue(local_cache) # local_cache: async get_item / set_item / remove_item
|
|
24
|
+
await queue.hydrate(f"outbox.{user_id}")
|
|
25
|
+
|
|
26
|
+
# Enqueue a write while offline (the id is yours to thread into the eventual write):
|
|
27
|
+
await queue.enqueue(write_id, {"path": path, "body": body})
|
|
28
|
+
|
|
29
|
+
# Drain on reconnect:
|
|
30
|
+
async def send(entry):
|
|
31
|
+
await client.push(entry.item["path"], entry.item["body"], base_hash_for(entry.item["path"]))
|
|
32
|
+
|
|
33
|
+
await drain_outbox(queue, send, max_attempts=5)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Mutating methods (`enqueue` / `claim` / `remove` / `retry` / …) are `async` because each
|
|
37
|
+
writes through to the cache; `get` / `pending` / `subscribe` are synchronous. JSON-compatible
|
|
38
|
+
on disk with the TypeScript `@drakkar.software/starfish-outbox`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "starfish-outbox"
|
|
7
|
+
version = "3.0.0a20"
|
|
8
|
+
description = "Starfish durable offline write-queue — dedup-by-id, claim, retry, reconnect-drain, persisted per identity, framework-free"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
dev = [
|
|
15
|
+
"pytest>=7.0",
|
|
16
|
+
"pytest-asyncio>=0.21",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[tool.pytest.ini_options]
|
|
20
|
+
asyncio_mode = "auto"
|
|
21
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""``starfish-outbox`` — a durable, per-identity offline **write-queue** (the
|
|
2
|
+
client-side complement to the server-side ``queuing`` extension).
|
|
3
|
+
|
|
4
|
+
Generic over the queued item: the caller owns
|
|
5
|
+
what an item is, supplies its dedup ``id``, and a ``send`` that performs the real
|
|
6
|
+
write. The queue handles persistence (write-through to a ``LocalCache``),
|
|
7
|
+
dedup-by-id, single-shot claim, attempt counting with auto-retry-then-fail,
|
|
8
|
+
crash-safe ``sending`` recovery, and subscriptions. :func:`drain_outbox` is
|
|
9
|
+
connectivity-agnostic — the caller triggers it on whatever reconnect signal it has.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from starfish_outbox.queue import (
|
|
13
|
+
LocalCache,
|
|
14
|
+
OutboxEntry,
|
|
15
|
+
OutboxQueue,
|
|
16
|
+
OutboxStatus,
|
|
17
|
+
reset_sending_to_queued,
|
|
18
|
+
)
|
|
19
|
+
from starfish_outbox.drain import DrainResult, drain_outbox
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"OutboxQueue",
|
|
23
|
+
"OutboxEntry",
|
|
24
|
+
"OutboxStatus",
|
|
25
|
+
"LocalCache",
|
|
26
|
+
"reset_sending_to_queued",
|
|
27
|
+
"drain_outbox",
|
|
28
|
+
"DrainResult",
|
|
29
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Drain driver for an :class:`OutboxQueue`. Connectivity-agnostic by design: the
|
|
2
|
+
caller decides *when* to drain (typically on a reconnect signal), keeping the queue
|
|
3
|
+
free of any platform/connectivity dependency."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Awaitable, Callable, TypeVar
|
|
9
|
+
|
|
10
|
+
from .queue import OutboxEntry, OutboxQueue
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DrainResult:
|
|
17
|
+
sent: int
|
|
18
|
+
failed: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def drain_outbox(
|
|
22
|
+
queue: OutboxQueue,
|
|
23
|
+
send: Callable[[OutboxEntry], Awaitable[None]],
|
|
24
|
+
*,
|
|
25
|
+
max_attempts: int = 5,
|
|
26
|
+
) -> DrainResult:
|
|
27
|
+
"""One drain pass: for each ``queued`` entry (oldest first), claim it, run
|
|
28
|
+
``send``, then ``remove`` on success or ``record_failure`` on raise. ``claim``
|
|
29
|
+
is single-shot, so concurrent drains never double-send. ``failed`` entries are
|
|
30
|
+
skipped (awaiting a manual ``retry``)."""
|
|
31
|
+
sent = 0
|
|
32
|
+
failed = 0
|
|
33
|
+
queued = [e for e in queue.get() if e.status == "queued"]
|
|
34
|
+
for entry in queued:
|
|
35
|
+
if not await queue.claim(entry.id):
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
await send(entry)
|
|
39
|
+
await queue.remove(entry.id)
|
|
40
|
+
sent += 1
|
|
41
|
+
except Exception:
|
|
42
|
+
before = next((e for e in queue.get() if e.id == entry.id), None)
|
|
43
|
+
await queue.record_failure(entry.id, max_attempts)
|
|
44
|
+
after = next((e for e in queue.get() if e.id == entry.id), None)
|
|
45
|
+
if after is not None and after.status == "failed" and (before is None or before.status != "failed"):
|
|
46
|
+
failed += 1
|
|
47
|
+
return DrainResult(sent=sent, failed=failed)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""A durable, per-identity offline **write-queue** — the client-side complement to
|
|
2
|
+
the server-side ``queuing`` extension. It queues opaque items and the caller owns *what*
|
|
3
|
+
a queued item is and *how* it is sent.
|
|
4
|
+
|
|
5
|
+
Invariants:
|
|
6
|
+
|
|
7
|
+
- **Dedup by id.** ``enqueue(id, …)`` ignores an id already queued.
|
|
8
|
+
- **Removed only on confirmed success** (see :func:`drain_outbox`).
|
|
9
|
+
- **Persisted per identity.** Write-through to a :class:`LocalCache` under the key
|
|
10
|
+
bound by :meth:`OutboxQueue.hydrate` — so mutating methods are ``async``.
|
|
11
|
+
- **Crash-safe claim.** A stuck ``sending`` entry resets to ``queued`` on the next
|
|
12
|
+
``hydrate``; ``claim`` is single-shot so concurrent drains never double-send.
|
|
13
|
+
|
|
14
|
+
JSON-compatible on disk with the TypeScript ``@drakkar.software/starfish-outbox``
|
|
15
|
+
(entry keys ``id`` / ``item`` / ``status`` / ``attempts`` / ``enqueuedAt``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Callable, Generic, Optional, Protocol, TypeVar
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
OutboxStatus = str # "queued" | "sending" | "failed"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LocalCache(Protocol):
|
|
31
|
+
"""Minimal async key/value cache (localStorage / AsyncStorage wrapper)."""
|
|
32
|
+
|
|
33
|
+
async def get_item(self, key: str) -> Optional[str]: ...
|
|
34
|
+
async def set_item(self, key: str, value: str) -> None: ...
|
|
35
|
+
async def remove_item(self, key: str) -> None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class OutboxEntry(Generic[T]):
|
|
40
|
+
"""One queued write: the caller's opaque ``item`` plus queue bookkeeping."""
|
|
41
|
+
|
|
42
|
+
id: str
|
|
43
|
+
item: T
|
|
44
|
+
status: OutboxStatus
|
|
45
|
+
attempts: int
|
|
46
|
+
enqueued_at: int
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"id": self.id,
|
|
51
|
+
"item": self.item,
|
|
52
|
+
"status": self.status,
|
|
53
|
+
"attempts": self.attempts,
|
|
54
|
+
"enqueuedAt": self.enqueued_at,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, d: dict[str, Any]) -> "OutboxEntry[Any]":
|
|
59
|
+
return cls(
|
|
60
|
+
id=d["id"],
|
|
61
|
+
item=d["item"],
|
|
62
|
+
status=d.get("status", "queued"),
|
|
63
|
+
attempts=int(d.get("attempts", 0)),
|
|
64
|
+
enqueued_at=int(d.get("enqueuedAt", 0)),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def reset_sending_to_queued(items: list[OutboxEntry[T]]) -> list[OutboxEntry[T]]:
|
|
69
|
+
"""Reset entries stuck ``sending`` (claimed but never resolved) to ``queued``."""
|
|
70
|
+
for it in items:
|
|
71
|
+
if it.status == "sending":
|
|
72
|
+
it.status = "queued"
|
|
73
|
+
return items
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class OutboxQueue(Generic[T]):
|
|
77
|
+
"""See module docstring. Mutators are ``async`` because each writes through to
|
|
78
|
+
the cache; ``get`` / ``pending`` / ``subscribe`` are synchronous."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, cache: LocalCache) -> None:
|
|
81
|
+
self._cache = cache
|
|
82
|
+
self._items: list[OutboxEntry[T]] = []
|
|
83
|
+
self._cache_key: Optional[str] = None
|
|
84
|
+
self._listeners: set[Callable[[], None]] = set()
|
|
85
|
+
|
|
86
|
+
def get(self) -> list[OutboxEntry[T]]:
|
|
87
|
+
return self._items
|
|
88
|
+
|
|
89
|
+
def subscribe(self, listener: Callable[[], None]) -> Callable[[], None]:
|
|
90
|
+
self._listeners.add(listener)
|
|
91
|
+
return lambda: self._listeners.discard(listener)
|
|
92
|
+
|
|
93
|
+
def _notify(self) -> None:
|
|
94
|
+
for listener in list(self._listeners):
|
|
95
|
+
listener()
|
|
96
|
+
|
|
97
|
+
async def _commit(self, items: list[OutboxEntry[T]]) -> None:
|
|
98
|
+
self._items = items
|
|
99
|
+
if self._cache_key is not None:
|
|
100
|
+
try:
|
|
101
|
+
await self._cache.set_item(self._cache_key, json.dumps([e.to_dict() for e in items]))
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
self._notify()
|
|
105
|
+
|
|
106
|
+
async def hydrate(self, cache_key: str) -> None:
|
|
107
|
+
self._cache_key = cache_key
|
|
108
|
+
loaded: list[OutboxEntry[T]] = []
|
|
109
|
+
try:
|
|
110
|
+
raw = await self._cache.get_item(cache_key)
|
|
111
|
+
if raw:
|
|
112
|
+
parsed = json.loads(raw)
|
|
113
|
+
if isinstance(parsed, list):
|
|
114
|
+
loaded = [OutboxEntry.from_dict(d) for d in parsed]
|
|
115
|
+
except Exception:
|
|
116
|
+
loaded = []
|
|
117
|
+
self._items = reset_sending_to_queued(loaded)
|
|
118
|
+
self._notify()
|
|
119
|
+
|
|
120
|
+
def clear(self) -> None:
|
|
121
|
+
self._cache_key = None
|
|
122
|
+
self._items = []
|
|
123
|
+
self._notify()
|
|
124
|
+
|
|
125
|
+
async def enqueue(self, id: str, item: T, ts: Optional[int] = None) -> None:
|
|
126
|
+
if any(i.id == id for i in self._items):
|
|
127
|
+
return # dedup by id
|
|
128
|
+
entry = OutboxEntry(id=id, item=item, status="queued", attempts=0, enqueued_at=ts or int(time.time() * 1000))
|
|
129
|
+
await self._commit([*self._items, entry])
|
|
130
|
+
|
|
131
|
+
async def claim(self, id: str) -> bool:
|
|
132
|
+
it = next((i for i in self._items if i.id == id), None)
|
|
133
|
+
if it is None or it.status == "sending":
|
|
134
|
+
return False
|
|
135
|
+
await self._commit([
|
|
136
|
+
(OutboxEntry(i.id, i.item, "sending", i.attempts, i.enqueued_at) if i.id == id else i)
|
|
137
|
+
for i in self._items
|
|
138
|
+
])
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
async def remove(self, id: str) -> None:
|
|
142
|
+
await self._commit([i for i in self._items if i.id != id])
|
|
143
|
+
|
|
144
|
+
async def record_failure(self, id: str, max_attempts: int) -> None:
|
|
145
|
+
def updated(i: OutboxEntry[T]) -> OutboxEntry[T]:
|
|
146
|
+
if i.id != id:
|
|
147
|
+
return i
|
|
148
|
+
attempts = i.attempts + 1
|
|
149
|
+
status = "failed" if attempts >= max_attempts else "queued"
|
|
150
|
+
return OutboxEntry(i.id, i.item, status, attempts, i.enqueued_at)
|
|
151
|
+
|
|
152
|
+
await self._commit([updated(i) for i in self._items])
|
|
153
|
+
|
|
154
|
+
async def mark_failed(self, id: str) -> None:
|
|
155
|
+
await self._commit([
|
|
156
|
+
(OutboxEntry(i.id, i.item, "failed", i.attempts + 1, i.enqueued_at) if i.id == id else i)
|
|
157
|
+
for i in self._items
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
async def retry(self, id: str) -> None:
|
|
161
|
+
await self._commit([
|
|
162
|
+
(OutboxEntry(i.id, i.item, "queued", i.attempts, i.enqueued_at) if i.id == id else i)
|
|
163
|
+
for i in self._items
|
|
164
|
+
])
|
|
165
|
+
|
|
166
|
+
def pending(self, predicate: Optional[Callable[[T], bool]] = None) -> list[OutboxEntry[T]]:
|
|
167
|
+
return [i for i in self._items if (predicate(i.item) if predicate else True)]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: starfish-outbox
|
|
3
|
+
Version: 3.0.0a20
|
|
4
|
+
Summary: Starfish durable offline write-queue — dedup-by-id, claim, retry, reconnect-drain, persisted per identity, framework-free
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
10
|
+
|
|
11
|
+
# starfish-outbox
|
|
12
|
+
|
|
13
|
+
A durable, per-identity **offline write-queue** — the client-side complement to the
|
|
14
|
+
server-side `queuing` extension.
|
|
15
|
+
|
|
16
|
+
Generic over the queued item: you own what an item is, supply its dedup `id`, and a
|
|
17
|
+
`send` that performs the real write. The queue handles persistence (write-through to a
|
|
18
|
+
`LocalCache`), dedup-by-id, single-shot claim (no double-send), attempt counting with
|
|
19
|
+
auto-retry-then-fail, crash-safe recovery of stuck `sending` entries, and subscriptions.
|
|
20
|
+
`drain_outbox` is connectivity-agnostic — you trigger it on whatever reconnect signal you have.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install starfish-outbox
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from starfish_outbox import OutboxQueue, drain_outbox
|
|
32
|
+
|
|
33
|
+
queue: OutboxQueue = OutboxQueue(local_cache) # local_cache: async get_item / set_item / remove_item
|
|
34
|
+
await queue.hydrate(f"outbox.{user_id}")
|
|
35
|
+
|
|
36
|
+
# Enqueue a write while offline (the id is yours to thread into the eventual write):
|
|
37
|
+
await queue.enqueue(write_id, {"path": path, "body": body})
|
|
38
|
+
|
|
39
|
+
# Drain on reconnect:
|
|
40
|
+
async def send(entry):
|
|
41
|
+
await client.push(entry.item["path"], entry.item["body"], base_hash_for(entry.item["path"]))
|
|
42
|
+
|
|
43
|
+
await drain_outbox(queue, send, max_attempts=5)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Mutating methods (`enqueue` / `claim` / `remove` / `retry` / …) are `async` because each
|
|
47
|
+
writes through to the cache; `get` / `pending` / `subscribe` are synchronous. JSON-compatible
|
|
48
|
+
on disk with the TypeScript `@drakkar.software/starfish-outbox`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
starfish_outbox/__init__.py
|
|
4
|
+
starfish_outbox/drain.py
|
|
5
|
+
starfish_outbox/queue.py
|
|
6
|
+
starfish_outbox.egg-info/PKG-INFO
|
|
7
|
+
starfish_outbox.egg-info/SOURCES.txt
|
|
8
|
+
starfish_outbox.egg-info/dependency_links.txt
|
|
9
|
+
starfish_outbox.egg-info/requires.txt
|
|
10
|
+
starfish_outbox.egg-info/top_level.txt
|
|
11
|
+
tests/test_outbox.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
starfish_outbox
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Durable write-queue: dedup, persistence + crash-safe hydrate, single-shot claim,
|
|
2
|
+
drain success/failure with auto-retry-then-fail, predicate filtering."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from starfish_outbox import OutboxQueue, drain_outbox
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MemCache:
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self.map: dict[str, str] = {}
|
|
15
|
+
|
|
16
|
+
async def get_item(self, key: str) -> Optional[str]:
|
|
17
|
+
return self.map.get(key)
|
|
18
|
+
|
|
19
|
+
async def set_item(self, key: str, value: str) -> None:
|
|
20
|
+
self.map[key] = value
|
|
21
|
+
|
|
22
|
+
async def remove_item(self, key: str) -> None:
|
|
23
|
+
self.map.pop(key, None)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_enqueue_dedup_and_persist() -> None:
|
|
27
|
+
cache = MemCache()
|
|
28
|
+
q: OutboxQueue = OutboxQueue(cache)
|
|
29
|
+
await q.hydrate("outbox.me")
|
|
30
|
+
await q.enqueue("m1", {"room": "r1", "text": "hi"})
|
|
31
|
+
await q.enqueue("m1", {"room": "r1", "text": "dup"}) # ignored
|
|
32
|
+
await q.enqueue("m2", {"room": "r2", "text": "yo"})
|
|
33
|
+
assert [e.id for e in q.get()] == ["m1", "m2"]
|
|
34
|
+
persisted = json.loads(cache.map["outbox.me"])
|
|
35
|
+
assert len(persisted) == 2
|
|
36
|
+
assert persisted[0]["item"]["text"] == "hi"
|
|
37
|
+
assert "enqueuedAt" in persisted[0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def test_reset_sending_on_hydrate() -> None:
|
|
41
|
+
cache = MemCache()
|
|
42
|
+
cache.map["outbox.me"] = json.dumps(
|
|
43
|
+
[{"id": "m1", "item": {"text": "x"}, "status": "sending", "attempts": 1, "enqueuedAt": 1}]
|
|
44
|
+
)
|
|
45
|
+
q: OutboxQueue = OutboxQueue(cache)
|
|
46
|
+
await q.hydrate("outbox.me")
|
|
47
|
+
assert q.get()[0].status == "queued"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def test_claim_single_shot() -> None:
|
|
51
|
+
cache = MemCache()
|
|
52
|
+
q: OutboxQueue = OutboxQueue(cache)
|
|
53
|
+
await q.hydrate("outbox.me")
|
|
54
|
+
await q.enqueue("m1", {"text": "x"})
|
|
55
|
+
assert await q.claim("m1") is True
|
|
56
|
+
assert await q.claim("m1") is False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def test_drain_sends_oldest_first_and_removes() -> None:
|
|
60
|
+
cache = MemCache()
|
|
61
|
+
q: OutboxQueue = OutboxQueue(cache)
|
|
62
|
+
await q.hydrate("outbox.me")
|
|
63
|
+
await q.enqueue("m1", {"text": "a"})
|
|
64
|
+
await q.enqueue("m2", {"text": "b"})
|
|
65
|
+
order: list[str] = []
|
|
66
|
+
|
|
67
|
+
async def send(entry):
|
|
68
|
+
order.append(entry.item["text"])
|
|
69
|
+
|
|
70
|
+
res = await drain_outbox(q, send)
|
|
71
|
+
assert order == ["a", "b"]
|
|
72
|
+
assert (res.sent, res.failed) == (2, 0)
|
|
73
|
+
assert q.get() == []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def test_auto_retry_then_failed() -> None:
|
|
77
|
+
cache = MemCache()
|
|
78
|
+
q: OutboxQueue = OutboxQueue(cache)
|
|
79
|
+
await q.hydrate("outbox.me")
|
|
80
|
+
await q.enqueue("m1", {"text": "x"})
|
|
81
|
+
|
|
82
|
+
async def send(_entry):
|
|
83
|
+
raise RuntimeError("offline")
|
|
84
|
+
|
|
85
|
+
r1 = await drain_outbox(q, send, max_attempts=2)
|
|
86
|
+
assert (r1.sent, r1.failed) == (0, 0)
|
|
87
|
+
assert q.get()[0].status == "queued" and q.get()[0].attempts == 1
|
|
88
|
+
r2 = await drain_outbox(q, send, max_attempts=2)
|
|
89
|
+
assert (r2.sent, r2.failed) == (0, 1)
|
|
90
|
+
assert q.get()[0].status == "failed"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def test_failed_skipped_until_retry() -> None:
|
|
94
|
+
cache = MemCache()
|
|
95
|
+
q: OutboxQueue = OutboxQueue(cache)
|
|
96
|
+
await q.hydrate("outbox.me")
|
|
97
|
+
await q.enqueue("m1", {"text": "x"})
|
|
98
|
+
|
|
99
|
+
async def fail(_e):
|
|
100
|
+
raise RuntimeError("x")
|
|
101
|
+
|
|
102
|
+
await drain_outbox(q, fail, max_attempts=1)
|
|
103
|
+
assert q.get()[0].status == "failed"
|
|
104
|
+
|
|
105
|
+
async def ok(_e):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
r1 = await drain_outbox(q, ok)
|
|
109
|
+
assert r1.sent == 0
|
|
110
|
+
await q.retry("m1")
|
|
111
|
+
r2 = await drain_outbox(q, ok)
|
|
112
|
+
assert r2.sent == 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def test_pending_predicate() -> None:
|
|
116
|
+
cache = MemCache()
|
|
117
|
+
q: OutboxQueue = OutboxQueue(cache)
|
|
118
|
+
await q.hydrate("outbox.me")
|
|
119
|
+
await q.enqueue("m1", {"room": "r1"})
|
|
120
|
+
await q.enqueue("m2", {"room": "r2"})
|
|
121
|
+
await q.enqueue("m3", {"room": "r1"})
|
|
122
|
+
assert [e.id for e in q.pending(lambda m: m["room"] == "r1")] == ["m1", "m3"]
|