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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+
2
+ [dev]
3
+ pytest>=7.0
4
+ pytest-asyncio>=0.21
@@ -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"]