edgesync 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- edgesync/__init__.py +61 -0
- edgesync/client.py +227 -0
- edgesync/config.py +70 -0
- edgesync/exceptions.py +51 -0
- edgesync/logging.py +16 -0
- edgesync/models/__init__.py +16 -0
- edgesync/models/delivery.py +69 -0
- edgesync/models/message.py +62 -0
- edgesync/models/receipt.py +21 -0
- edgesync/models/stats.py +27 -0
- edgesync/py.typed +0 -0
- edgesync/queue/__init__.py +5 -0
- edgesync/queue/manager.py +92 -0
- edgesync/retry/__init__.py +5 -0
- edgesync/retry/backoff.py +42 -0
- edgesync/retry/policy.py +56 -0
- edgesync/storage/__init__.py +6 -0
- edgesync/storage/base.py +109 -0
- edgesync/storage/migrations.py +70 -0
- edgesync/storage/sqlite.py +529 -0
- edgesync/transports/__init__.py +7 -0
- edgesync/transports/base.py +30 -0
- edgesync/transports/http.py +112 -0
- edgesync/transports/registry.py +52 -0
- edgesync/utils/__init__.py +1 -0
- edgesync/utils/clock.py +50 -0
- edgesync/utils/ids.py +20 -0
- edgesync/worker/__init__.py +5 -0
- edgesync/worker/lifecycle.py +62 -0
- edgesync/worker/scheduler.py +20 -0
- edgesync/worker/sync_worker.py +162 -0
- edgesync-0.2.0.dist-info/METADATA +155 -0
- edgesync-0.2.0.dist-info/RECORD +35 -0
- edgesync-0.2.0.dist-info/WHEEL +4 -0
- edgesync-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""SQLite durable storage backend.
|
|
2
|
+
|
|
3
|
+
Design notes (see docs/storage.md for the full writeup):
|
|
4
|
+
|
|
5
|
+
* A single ``aiosqlite`` connection is used per ``SQLiteStorage`` instance,
|
|
6
|
+
opened in autocommit mode (``isolation_level=None``). Single-statement
|
|
7
|
+
writes (claim, mark_delivered, schedule_retry, ...) commit atomically as
|
|
8
|
+
soon as they execute -- no explicit transaction needed. The one
|
|
9
|
+
compound read-then-write operation (``enqueue``'s capacity check) wraps
|
|
10
|
+
itself in an explicit ``BEGIN IMMEDIATE`` / ``COMMIT`` guarded by an
|
|
11
|
+
``asyncio.Lock`` so concurrent ``enqueue`` calls on the same connection
|
|
12
|
+
never try to open two transactions at once.
|
|
13
|
+
* ``PRAGMA journal_mode=WAL`` + ``PRAGMA synchronous=FULL`` by default:
|
|
14
|
+
WAL for concurrent readers during writes, and ``synchronous=FULL`` so a
|
|
15
|
+
commit is not reported successful until it is durable on disk -- this
|
|
16
|
+
library explicitly has to survive power loss, not just process crashes.
|
|
17
|
+
Callers that can tolerate a small durability/performance trade-off may
|
|
18
|
+
pass ``synchronous="NORMAL"``.
|
|
19
|
+
* Message ownership across workers is enforced with a lease fencing token
|
|
20
|
+
(``lease_id``): every completion write is qualified with
|
|
21
|
+
``WHERE id = ? AND lease_id = ? AND status = 'IN_FLIGHT'``, so a worker
|
|
22
|
+
whose lease already expired and was reclaimed by someone else can never
|
|
23
|
+
clobber the new owner's state.
|
|
24
|
+
* Delivered messages are removed from the ``messages`` table immediately
|
|
25
|
+
(a cumulative counter in ``counters`` tracks the all-time total) to keep
|
|
26
|
+
the on-disk queue bounded for storage-constrained edge devices.
|
|
27
|
+
Dead-lettered messages are retained until a caller retries or deletes
|
|
28
|
+
them explicitly.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
from datetime import datetime, timedelta, timezone
|
|
37
|
+
|
|
38
|
+
import aiosqlite
|
|
39
|
+
|
|
40
|
+
from edgesync.config import OverflowPolicy
|
|
41
|
+
from edgesync.exceptions import QueueFullError, StorageError
|
|
42
|
+
from edgesync.logging import logger
|
|
43
|
+
from edgesync.models.message import Message, MessageStatus
|
|
44
|
+
from edgesync.models.stats import QueueStats
|
|
45
|
+
from edgesync.storage.base import StorageBackend
|
|
46
|
+
from edgesync.storage.migrations import apply_migrations
|
|
47
|
+
from edgesync.utils.clock import Clock, SystemClock
|
|
48
|
+
from edgesync.utils.ids import generate_lease_id
|
|
49
|
+
|
|
50
|
+
_ACTIVE_STATUSES = (MessageStatus.PENDING.value, MessageStatus.IN_FLIGHT.value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_database_path(database: str) -> str:
|
|
54
|
+
if database == ":memory:":
|
|
55
|
+
return database
|
|
56
|
+
if database.startswith("sqlite:///"):
|
|
57
|
+
return database[len("sqlite:///") :]
|
|
58
|
+
if database.startswith("sqlite://"):
|
|
59
|
+
return database[len("sqlite://") :]
|
|
60
|
+
return database
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dt_to_str(value: datetime | None) -> str | None:
|
|
64
|
+
if value is None:
|
|
65
|
+
return None
|
|
66
|
+
return value.astimezone(timezone.utc).isoformat()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _str_to_dt(value: str | None) -> datetime | None:
|
|
70
|
+
if value is None:
|
|
71
|
+
return None
|
|
72
|
+
return datetime.fromisoformat(value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SQLiteStorage(StorageBackend):
|
|
76
|
+
"""A durable, single-file SQLite-backed queue."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
database: str,
|
|
81
|
+
*,
|
|
82
|
+
clock: Clock | None = None,
|
|
83
|
+
synchronous: str = "FULL",
|
|
84
|
+
) -> None:
|
|
85
|
+
self._path = _resolve_database_path(database)
|
|
86
|
+
self._clock = clock or SystemClock()
|
|
87
|
+
self._synchronous = synchronous
|
|
88
|
+
self._conn: aiosqlite.Connection | None = None
|
|
89
|
+
self._write_lock = asyncio.Lock()
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def _connection(self) -> aiosqlite.Connection:
|
|
93
|
+
if self._conn is None:
|
|
94
|
+
raise StorageError("SQLiteStorage has not been initialized; call initialize() first")
|
|
95
|
+
return self._conn
|
|
96
|
+
|
|
97
|
+
async def initialize(self) -> None:
|
|
98
|
+
if self._conn is not None:
|
|
99
|
+
return
|
|
100
|
+
directory = os.path.dirname(self._path)
|
|
101
|
+
if directory:
|
|
102
|
+
os.makedirs(directory, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
conn = await aiosqlite.connect(self._path, isolation_level=None)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
raise StorageError(f"failed to open database at {self._path!r}: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
conn.row_factory = aiosqlite.Row
|
|
110
|
+
await conn.execute("PRAGMA foreign_keys = ON")
|
|
111
|
+
await conn.execute("PRAGMA busy_timeout = 30000")
|
|
112
|
+
if self._path != ":memory:":
|
|
113
|
+
await conn.execute("PRAGMA journal_mode = WAL")
|
|
114
|
+
await conn.execute(f"PRAGMA synchronous = {self._synchronous}")
|
|
115
|
+
|
|
116
|
+
self._conn = conn
|
|
117
|
+
await apply_migrations(conn)
|
|
118
|
+
|
|
119
|
+
recovered = await self.recover_expired_leases()
|
|
120
|
+
if recovered:
|
|
121
|
+
logger.info("recovered %d message(s) with expired leases on startup", recovered)
|
|
122
|
+
|
|
123
|
+
async def close(self) -> None:
|
|
124
|
+
if self._conn is not None:
|
|
125
|
+
await self._conn.close()
|
|
126
|
+
self._conn = None
|
|
127
|
+
|
|
128
|
+
# -- enqueue ---------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
async def enqueue(
|
|
131
|
+
self,
|
|
132
|
+
message: Message,
|
|
133
|
+
*,
|
|
134
|
+
max_messages: int | None = None,
|
|
135
|
+
max_storage_bytes: int | None = None,
|
|
136
|
+
overflow_policy: OverflowPolicy = OverflowPolicy.REJECT_NEW,
|
|
137
|
+
) -> None:
|
|
138
|
+
conn = self._connection
|
|
139
|
+
async with self._write_lock:
|
|
140
|
+
await conn.execute("BEGIN IMMEDIATE")
|
|
141
|
+
try:
|
|
142
|
+
await self._enforce_capacity(
|
|
143
|
+
message,
|
|
144
|
+
max_messages=max_messages,
|
|
145
|
+
max_storage_bytes=max_storage_bytes,
|
|
146
|
+
overflow_policy=overflow_policy,
|
|
147
|
+
)
|
|
148
|
+
await conn.execute(
|
|
149
|
+
"""
|
|
150
|
+
INSERT INTO messages (
|
|
151
|
+
id, destination, payload, headers, metadata, priority,
|
|
152
|
+
status, attempts, created_at, updated_at, next_attempt_at,
|
|
153
|
+
last_attempt_at, delivered_at, last_error, lease_id,
|
|
154
|
+
lease_until, expires_at, size_bytes
|
|
155
|
+
) VALUES (
|
|
156
|
+
:id, :destination, :payload, :headers, :metadata, :priority,
|
|
157
|
+
:status, :attempts, :created_at, :updated_at, :next_attempt_at,
|
|
158
|
+
:last_attempt_at, :delivered_at, :last_error, :lease_id,
|
|
159
|
+
:lease_until, :expires_at, :size_bytes
|
|
160
|
+
)
|
|
161
|
+
""",
|
|
162
|
+
self._message_to_row(message),
|
|
163
|
+
)
|
|
164
|
+
await conn.execute("COMMIT")
|
|
165
|
+
except BaseException:
|
|
166
|
+
await conn.execute("ROLLBACK")
|
|
167
|
+
raise
|
|
168
|
+
|
|
169
|
+
async def _enforce_capacity(
|
|
170
|
+
self,
|
|
171
|
+
message: Message,
|
|
172
|
+
*,
|
|
173
|
+
max_messages: int | None,
|
|
174
|
+
max_storage_bytes: int | None,
|
|
175
|
+
overflow_policy: OverflowPolicy,
|
|
176
|
+
) -> None:
|
|
177
|
+
conn = self._connection
|
|
178
|
+
|
|
179
|
+
if max_storage_bytes is not None and message.size_bytes > max_storage_bytes:
|
|
180
|
+
raise QueueFullError(
|
|
181
|
+
f"message ({message.size_bytes} bytes) exceeds max_storage_bytes="
|
|
182
|
+
f"{max_storage_bytes} and can never be enqueued"
|
|
183
|
+
)
|
|
184
|
+
if max_messages is None and max_storage_bytes is None:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
max_iterations = 10_000 # safety bound against pathological loops
|
|
188
|
+
for _ in range(max_iterations):
|
|
189
|
+
cursor = await conn.execute(
|
|
190
|
+
"SELECT COUNT(*), COALESCE(SUM(size_bytes), 0) FROM messages "
|
|
191
|
+
f"WHERE status IN ('{_ACTIVE_STATUSES[0]}', '{_ACTIVE_STATUSES[1]}')"
|
|
192
|
+
)
|
|
193
|
+
row = await cursor.fetchone()
|
|
194
|
+
assert row is not None # COUNT(*)/SUM always return exactly one row
|
|
195
|
+
count, total_bytes = row[0], row[1]
|
|
196
|
+
|
|
197
|
+
over_count = max_messages is not None and count >= max_messages
|
|
198
|
+
over_bytes = (
|
|
199
|
+
max_storage_bytes is not None
|
|
200
|
+
and (total_bytes + message.size_bytes) > max_storage_bytes
|
|
201
|
+
)
|
|
202
|
+
if not over_count and not over_bytes:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
if overflow_policy is OverflowPolicy.REJECT_NEW:
|
|
206
|
+
raise QueueFullError(
|
|
207
|
+
f"queue is full (messages={count}, bytes={total_bytes}); "
|
|
208
|
+
"overflow_policy=reject_new"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
victim_id = await self._select_eviction_victim(overflow_policy)
|
|
212
|
+
if victim_id is None:
|
|
213
|
+
raise QueueFullError(
|
|
214
|
+
"queue is full and no evictable message is available "
|
|
215
|
+
"(all active messages are currently in flight)"
|
|
216
|
+
)
|
|
217
|
+
await self._evict(victim_id, overflow_policy)
|
|
218
|
+
|
|
219
|
+
raise QueueFullError("queue is full and could not be reduced below capacity")
|
|
220
|
+
|
|
221
|
+
async def _select_eviction_victim(self, overflow_policy: OverflowPolicy) -> str | None:
|
|
222
|
+
conn = self._connection
|
|
223
|
+
if overflow_policy in (OverflowPolicy.DROP_OLDEST, OverflowPolicy.DEAD_LETTER):
|
|
224
|
+
order_by = "created_at ASC"
|
|
225
|
+
elif overflow_policy is OverflowPolicy.DROP_LOWEST_PRIORITY:
|
|
226
|
+
order_by = "priority ASC, created_at ASC"
|
|
227
|
+
else:
|
|
228
|
+
return None
|
|
229
|
+
cursor = await conn.execute(
|
|
230
|
+
f"SELECT id FROM messages WHERE status = 'PENDING' ORDER BY {order_by} LIMIT 1"
|
|
231
|
+
)
|
|
232
|
+
row = await cursor.fetchone()
|
|
233
|
+
return row[0] if row else None
|
|
234
|
+
|
|
235
|
+
async def _evict(self, message_id: str, overflow_policy: OverflowPolicy) -> None:
|
|
236
|
+
conn = self._connection
|
|
237
|
+
now = _dt_to_str(self._clock.now())
|
|
238
|
+
if overflow_policy is OverflowPolicy.DEAD_LETTER:
|
|
239
|
+
logger.warning("queue full: moving message %s to dead-letter to make room", message_id)
|
|
240
|
+
await conn.execute(
|
|
241
|
+
"UPDATE messages SET status = 'DEAD_LETTER', last_error = :error, "
|
|
242
|
+
"updated_at = :now WHERE id = :id",
|
|
243
|
+
{"error": "evicted: queue capacity exceeded", "now": now, "id": message_id},
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
logger.warning(
|
|
247
|
+
"queue full: dropping message %s (overflow_policy=%s)",
|
|
248
|
+
message_id,
|
|
249
|
+
overflow_policy.value,
|
|
250
|
+
)
|
|
251
|
+
await conn.execute("DELETE FROM messages WHERE id = :id", {"id": message_id})
|
|
252
|
+
|
|
253
|
+
# -- claim / complete --------------------------------------------------
|
|
254
|
+
|
|
255
|
+
async def claim_batch(self, limit: int, lease_duration: float) -> list[Message]:
|
|
256
|
+
conn = self._connection
|
|
257
|
+
now = self._clock.now()
|
|
258
|
+
lease_id = generate_lease_id()
|
|
259
|
+
lease_until = now + timedelta(seconds=lease_duration)
|
|
260
|
+
|
|
261
|
+
async with self._write_lock:
|
|
262
|
+
await conn.execute("BEGIN IMMEDIATE")
|
|
263
|
+
try:
|
|
264
|
+
await conn.execute(
|
|
265
|
+
"""
|
|
266
|
+
UPDATE messages
|
|
267
|
+
SET status = 'IN_FLIGHT',
|
|
268
|
+
lease_id = :lease_id,
|
|
269
|
+
lease_until = :lease_until,
|
|
270
|
+
updated_at = :now
|
|
271
|
+
WHERE id IN (
|
|
272
|
+
SELECT id FROM messages
|
|
273
|
+
WHERE status = 'PENDING'
|
|
274
|
+
AND next_attempt_at <= :now
|
|
275
|
+
ORDER BY priority DESC, next_attempt_at ASC, created_at ASC
|
|
276
|
+
LIMIT :limit
|
|
277
|
+
)
|
|
278
|
+
""",
|
|
279
|
+
{
|
|
280
|
+
"lease_id": lease_id,
|
|
281
|
+
"lease_until": _dt_to_str(lease_until),
|
|
282
|
+
"now": _dt_to_str(now),
|
|
283
|
+
"limit": limit,
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
cursor = await conn.execute(
|
|
287
|
+
"SELECT * FROM messages WHERE lease_id = :lease_id", {"lease_id": lease_id}
|
|
288
|
+
)
|
|
289
|
+
rows = await cursor.fetchall()
|
|
290
|
+
await conn.execute("COMMIT")
|
|
291
|
+
except BaseException:
|
|
292
|
+
await conn.execute("ROLLBACK")
|
|
293
|
+
raise
|
|
294
|
+
|
|
295
|
+
messages = [self._row_to_message(row) for row in rows]
|
|
296
|
+
if messages:
|
|
297
|
+
logger.debug("claimed %d message(s) with lease %s", len(messages), lease_id)
|
|
298
|
+
return messages
|
|
299
|
+
|
|
300
|
+
async def mark_delivered(self, message_id: str, lease_id: str) -> None:
|
|
301
|
+
conn = self._connection
|
|
302
|
+
now = _dt_to_str(self._clock.now())
|
|
303
|
+
cursor = await conn.execute(
|
|
304
|
+
"UPDATE messages SET status = 'DELIVERED', delivered_at = :now, "
|
|
305
|
+
"updated_at = :now WHERE id = :id AND lease_id = :lease_id AND status = 'IN_FLIGHT'",
|
|
306
|
+
{"now": now, "id": message_id, "lease_id": lease_id},
|
|
307
|
+
)
|
|
308
|
+
if cursor.rowcount == 0:
|
|
309
|
+
logger.warning(
|
|
310
|
+
"mark_delivered: message %s no longer owned by lease %s (stale lease?)",
|
|
311
|
+
message_id,
|
|
312
|
+
lease_id,
|
|
313
|
+
)
|
|
314
|
+
return
|
|
315
|
+
await conn.execute("UPDATE counters SET value = value + 1 WHERE key = 'delivered_total'")
|
|
316
|
+
await conn.execute("DELETE FROM messages WHERE id = :id", {"id": message_id})
|
|
317
|
+
|
|
318
|
+
async def schedule_retry(
|
|
319
|
+
self,
|
|
320
|
+
message_id: str,
|
|
321
|
+
lease_id: str,
|
|
322
|
+
error: str,
|
|
323
|
+
next_attempt_at: datetime,
|
|
324
|
+
) -> None:
|
|
325
|
+
conn = self._connection
|
|
326
|
+
now = _dt_to_str(self._clock.now())
|
|
327
|
+
cursor = await conn.execute(
|
|
328
|
+
"""
|
|
329
|
+
UPDATE messages
|
|
330
|
+
SET status = 'PENDING',
|
|
331
|
+
attempts = attempts + 1,
|
|
332
|
+
last_error = :error,
|
|
333
|
+
last_attempt_at = :now,
|
|
334
|
+
next_attempt_at = :next_attempt_at,
|
|
335
|
+
lease_id = NULL,
|
|
336
|
+
lease_until = NULL,
|
|
337
|
+
updated_at = :now
|
|
338
|
+
WHERE id = :id AND lease_id = :lease_id AND status = 'IN_FLIGHT'
|
|
339
|
+
""",
|
|
340
|
+
{
|
|
341
|
+
"error": error[:2000],
|
|
342
|
+
"now": now,
|
|
343
|
+
"next_attempt_at": _dt_to_str(next_attempt_at),
|
|
344
|
+
"id": message_id,
|
|
345
|
+
"lease_id": lease_id,
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
if cursor.rowcount == 0:
|
|
349
|
+
logger.warning(
|
|
350
|
+
"schedule_retry: message %s no longer owned by lease %s (stale lease?)",
|
|
351
|
+
message_id,
|
|
352
|
+
lease_id,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
async def move_to_dead_letter(self, message_id: str, lease_id: str, error: str) -> None:
|
|
356
|
+
conn = self._connection
|
|
357
|
+
now = _dt_to_str(self._clock.now())
|
|
358
|
+
cursor = await conn.execute(
|
|
359
|
+
"""
|
|
360
|
+
UPDATE messages
|
|
361
|
+
SET status = 'DEAD_LETTER',
|
|
362
|
+
last_error = :error,
|
|
363
|
+
last_attempt_at = :now,
|
|
364
|
+
updated_at = :now,
|
|
365
|
+
lease_id = NULL,
|
|
366
|
+
lease_until = NULL
|
|
367
|
+
WHERE id = :id AND lease_id = :lease_id AND status = 'IN_FLIGHT'
|
|
368
|
+
""",
|
|
369
|
+
{"error": error[:2000], "now": now, "id": message_id, "lease_id": lease_id},
|
|
370
|
+
)
|
|
371
|
+
if cursor.rowcount == 0:
|
|
372
|
+
logger.warning(
|
|
373
|
+
"move_to_dead_letter: message %s no longer owned by lease %s (stale lease?)",
|
|
374
|
+
message_id,
|
|
375
|
+
lease_id,
|
|
376
|
+
)
|
|
377
|
+
else:
|
|
378
|
+
logger.warning("message %s moved to dead-letter: %s", message_id, error)
|
|
379
|
+
|
|
380
|
+
# -- recovery / expiry -------------------------------------------------
|
|
381
|
+
|
|
382
|
+
async def recover_expired_leases(self) -> int:
|
|
383
|
+
conn = self._connection
|
|
384
|
+
now = _dt_to_str(self._clock.now())
|
|
385
|
+
cursor = await conn.execute(
|
|
386
|
+
"""
|
|
387
|
+
UPDATE messages
|
|
388
|
+
SET status = 'PENDING', lease_id = NULL, lease_until = NULL, updated_at = :now
|
|
389
|
+
WHERE status = 'IN_FLIGHT' AND lease_until <= :now
|
|
390
|
+
""",
|
|
391
|
+
{"now": now},
|
|
392
|
+
)
|
|
393
|
+
return cursor.rowcount
|
|
394
|
+
|
|
395
|
+
async def expire_stale_messages(self) -> int:
|
|
396
|
+
conn = self._connection
|
|
397
|
+
now = _dt_to_str(self._clock.now())
|
|
398
|
+
cursor = await conn.execute(
|
|
399
|
+
"""
|
|
400
|
+
UPDATE messages
|
|
401
|
+
SET status = 'DEAD_LETTER', last_error = 'message expired', updated_at = :now
|
|
402
|
+
WHERE status = 'PENDING' AND expires_at IS NOT NULL AND expires_at <= :now
|
|
403
|
+
""",
|
|
404
|
+
{"now": now},
|
|
405
|
+
)
|
|
406
|
+
return cursor.rowcount
|
|
407
|
+
|
|
408
|
+
# -- stats / dead-letter management ------------------------------------
|
|
409
|
+
|
|
410
|
+
async def get_stats(self) -> QueueStats:
|
|
411
|
+
conn = self._connection
|
|
412
|
+
now = _dt_to_str(self._clock.now())
|
|
413
|
+
cursor = await conn.execute(
|
|
414
|
+
"""
|
|
415
|
+
SELECT
|
|
416
|
+
SUM(CASE WHEN status = 'PENDING' AND next_attempt_at <= :now THEN 1 ELSE 0 END),
|
|
417
|
+
SUM(CASE WHEN status = 'PENDING' AND next_attempt_at > :now THEN 1 ELSE 0 END),
|
|
418
|
+
SUM(CASE WHEN status = 'IN_FLIGHT' THEN 1 ELSE 0 END),
|
|
419
|
+
SUM(CASE WHEN status = 'DEAD_LETTER' THEN 1 ELSE 0 END)
|
|
420
|
+
FROM messages
|
|
421
|
+
""",
|
|
422
|
+
{"now": now},
|
|
423
|
+
)
|
|
424
|
+
row = await cursor.fetchone()
|
|
425
|
+
assert row is not None # aggregate SUM(...) always returns exactly one row
|
|
426
|
+
pending, retrying, in_flight, dead_letter = (value or 0 for value in row)
|
|
427
|
+
|
|
428
|
+
counter_cursor = await conn.execute(
|
|
429
|
+
"SELECT value FROM counters WHERE key = 'delivered_total'"
|
|
430
|
+
)
|
|
431
|
+
counter_row = await counter_cursor.fetchone()
|
|
432
|
+
delivered_total = counter_row[0] if counter_row else 0
|
|
433
|
+
|
|
434
|
+
storage_bytes = None
|
|
435
|
+
if self._path != ":memory:" and os.path.exists(self._path):
|
|
436
|
+
storage_bytes = os.path.getsize(self._path)
|
|
437
|
+
|
|
438
|
+
return QueueStats(
|
|
439
|
+
pending=pending,
|
|
440
|
+
in_flight=in_flight,
|
|
441
|
+
delivered=delivered_total,
|
|
442
|
+
dead_letter=dead_letter,
|
|
443
|
+
retrying=retrying,
|
|
444
|
+
storage_bytes=storage_bytes,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
async def get_message(self, message_id: str) -> Message | None:
|
|
448
|
+
conn = self._connection
|
|
449
|
+
cursor = await conn.execute("SELECT * FROM messages WHERE id = :id", {"id": message_id})
|
|
450
|
+
row = await cursor.fetchone()
|
|
451
|
+
return self._row_to_message(row) if row else None
|
|
452
|
+
|
|
453
|
+
async def list_dead_letters(self, limit: int = 100, offset: int = 0) -> list[Message]:
|
|
454
|
+
conn = self._connection
|
|
455
|
+
cursor = await conn.execute(
|
|
456
|
+
"SELECT * FROM messages WHERE status = 'DEAD_LETTER' "
|
|
457
|
+
"ORDER BY updated_at DESC LIMIT :limit OFFSET :offset",
|
|
458
|
+
{"limit": limit, "offset": offset},
|
|
459
|
+
)
|
|
460
|
+
rows = await cursor.fetchall()
|
|
461
|
+
return [self._row_to_message(row) for row in rows]
|
|
462
|
+
|
|
463
|
+
async def retry_dead_letter(self, message_id: str) -> None:
|
|
464
|
+
conn = self._connection
|
|
465
|
+
now = _dt_to_str(self._clock.now())
|
|
466
|
+
await conn.execute(
|
|
467
|
+
"""
|
|
468
|
+
UPDATE messages
|
|
469
|
+
SET status = 'PENDING', attempts = 0, last_error = NULL,
|
|
470
|
+
next_attempt_at = :now, updated_at = :now
|
|
471
|
+
WHERE id = :id AND status = 'DEAD_LETTER'
|
|
472
|
+
""",
|
|
473
|
+
{"now": now, "id": message_id},
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
async def delete_message(self, message_id: str) -> None:
|
|
477
|
+
conn = self._connection
|
|
478
|
+
await conn.execute("DELETE FROM messages WHERE id = :id", {"id": message_id})
|
|
479
|
+
|
|
480
|
+
# -- row <-> model mapping ----------------------------------------------
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def _message_to_row(message: Message) -> dict[str, object]:
|
|
484
|
+
return {
|
|
485
|
+
"id": message.id,
|
|
486
|
+
"destination": message.destination,
|
|
487
|
+
"payload": json.dumps(message.payload),
|
|
488
|
+
"headers": json.dumps(message.headers),
|
|
489
|
+
"metadata": json.dumps(message.metadata),
|
|
490
|
+
"priority": message.priority,
|
|
491
|
+
"status": message.status.value,
|
|
492
|
+
"attempts": message.attempts,
|
|
493
|
+
"created_at": _dt_to_str(message.created_at),
|
|
494
|
+
"updated_at": _dt_to_str(message.updated_at),
|
|
495
|
+
"next_attempt_at": _dt_to_str(message.next_attempt_at),
|
|
496
|
+
"last_attempt_at": _dt_to_str(message.last_attempt_at),
|
|
497
|
+
"delivered_at": _dt_to_str(message.delivered_at),
|
|
498
|
+
"last_error": message.last_error,
|
|
499
|
+
"lease_id": message.lease_id,
|
|
500
|
+
"lease_until": _dt_to_str(message.lease_until),
|
|
501
|
+
"expires_at": _dt_to_str(message.expires_at),
|
|
502
|
+
"size_bytes": message.size_bytes,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
@staticmethod
|
|
506
|
+
def _row_to_message(row: aiosqlite.Row) -> Message:
|
|
507
|
+
assert row["next_attempt_at"] is not None
|
|
508
|
+
assert row["created_at"] is not None
|
|
509
|
+
assert row["updated_at"] is not None
|
|
510
|
+
return Message(
|
|
511
|
+
id=row["id"],
|
|
512
|
+
destination=row["destination"],
|
|
513
|
+
payload=json.loads(row["payload"]),
|
|
514
|
+
headers=json.loads(row["headers"]),
|
|
515
|
+
metadata=json.loads(row["metadata"]),
|
|
516
|
+
priority=row["priority"],
|
|
517
|
+
status=MessageStatus(row["status"]),
|
|
518
|
+
attempts=row["attempts"],
|
|
519
|
+
created_at=_str_to_dt(row["created_at"]), # type: ignore[arg-type]
|
|
520
|
+
updated_at=_str_to_dt(row["updated_at"]), # type: ignore[arg-type]
|
|
521
|
+
next_attempt_at=_str_to_dt(row["next_attempt_at"]), # type: ignore[arg-type]
|
|
522
|
+
last_attempt_at=_str_to_dt(row["last_attempt_at"]),
|
|
523
|
+
delivered_at=_str_to_dt(row["delivered_at"]),
|
|
524
|
+
last_error=row["last_error"],
|
|
525
|
+
lease_id=row["lease_id"],
|
|
526
|
+
lease_until=_str_to_dt(row["lease_until"]),
|
|
527
|
+
expires_at=_str_to_dt(row["expires_at"]),
|
|
528
|
+
size_bytes=row["size_bytes"],
|
|
529
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The transport abstraction.
|
|
2
|
+
|
|
3
|
+
Transports are responsible only for attempting delivery of a single message
|
|
4
|
+
and classifying the outcome. They must never touch storage or retry
|
|
5
|
+
scheduling -- that is the worker's responsibility, driven by the
|
|
6
|
+
``DeliveryResult`` a transport returns.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
|
|
13
|
+
from edgesync.models.delivery import DeliveryResult
|
|
14
|
+
from edgesync.models.message import Message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Transport(ABC):
|
|
18
|
+
"""A destination EdgeSync can deliver messages to."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def start(self) -> None:
|
|
22
|
+
"""Acquire any resources needed for delivery (e.g. open a client)."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def deliver(self, message: Message) -> DeliveryResult:
|
|
26
|
+
"""Attempt to deliver a single message and classify the outcome."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def close(self) -> None:
|
|
30
|
+
"""Release resources acquired in :meth:`start`."""
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Production HTTP transport backed by ``httpx``.
|
|
2
|
+
|
|
3
|
+
A single ``httpx.AsyncClient`` is created in :meth:`start` and reused for
|
|
4
|
+
every delivery -- connections are pooled, not reopened per message. The
|
|
5
|
+
message ID is always sent via a configurable idempotency header (default
|
|
6
|
+
``Idempotency-Key``) so destinations can de-duplicate retried deliveries,
|
|
7
|
+
which is required for at-least-once delivery to be safe to build on.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from edgesync.models.delivery import DeliveryResult
|
|
17
|
+
from edgesync.models.message import Message
|
|
18
|
+
from edgesync.transports.base import Transport
|
|
19
|
+
|
|
20
|
+
DEFAULT_RETRYABLE_STATUS_CODES = frozenset({408, 425, 429, *range(500, 600)})
|
|
21
|
+
_RESPONSE_SNIPPET_LIMIT = 500
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HTTPTransport(Transport):
|
|
25
|
+
"""Deliver messages as JSON over HTTP."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
url: str,
|
|
30
|
+
*,
|
|
31
|
+
method: str = "POST",
|
|
32
|
+
timeout: float = 30.0,
|
|
33
|
+
headers: dict[str, str] | None = None,
|
|
34
|
+
idempotency_header: str = "Idempotency-Key",
|
|
35
|
+
success_status_codes: Iterable[int] | None = None,
|
|
36
|
+
retryable_status_codes: Iterable[int] = DEFAULT_RETRYABLE_STATUS_CODES,
|
|
37
|
+
verify: bool = True,
|
|
38
|
+
client: httpx.AsyncClient | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._url = url
|
|
41
|
+
self._method = method
|
|
42
|
+
self._timeout = timeout
|
|
43
|
+
self._default_headers = dict(headers or {})
|
|
44
|
+
self._idempotency_header = idempotency_header
|
|
45
|
+
self._success_status_codes = (
|
|
46
|
+
frozenset(success_status_codes)
|
|
47
|
+
if success_status_codes is not None
|
|
48
|
+
else None # None means "any 2xx"
|
|
49
|
+
)
|
|
50
|
+
self._retryable_status_codes = frozenset(retryable_status_codes)
|
|
51
|
+
self._verify = verify
|
|
52
|
+
self._external_client = client
|
|
53
|
+
self._client: httpx.AsyncClient | None = None
|
|
54
|
+
|
|
55
|
+
async def start(self) -> None:
|
|
56
|
+
if self._client is not None:
|
|
57
|
+
return
|
|
58
|
+
if self._external_client is not None:
|
|
59
|
+
self._client = self._external_client
|
|
60
|
+
else:
|
|
61
|
+
self._client = httpx.AsyncClient(
|
|
62
|
+
timeout=self._timeout,
|
|
63
|
+
verify=self._verify,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def close(self) -> None:
|
|
67
|
+
if self._client is not None and self._external_client is None:
|
|
68
|
+
await self._client.aclose()
|
|
69
|
+
self._client = None
|
|
70
|
+
|
|
71
|
+
async def deliver(self, message: Message) -> DeliveryResult:
|
|
72
|
+
if self._client is None:
|
|
73
|
+
raise RuntimeError("HTTPTransport.start() must be called before deliver()")
|
|
74
|
+
|
|
75
|
+
headers = {**self._default_headers, **message.headers}
|
|
76
|
+
headers[self._idempotency_header] = message.id
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
response = await self._client.request(
|
|
80
|
+
self._method,
|
|
81
|
+
self._url,
|
|
82
|
+
json=message.payload,
|
|
83
|
+
headers=headers,
|
|
84
|
+
timeout=self._timeout,
|
|
85
|
+
)
|
|
86
|
+
except httpx.TimeoutException as exc:
|
|
87
|
+
return DeliveryResult.retryable_failure(f"timeout: {exc}")
|
|
88
|
+
except httpx.ConnectError as exc:
|
|
89
|
+
return DeliveryResult.retryable_failure(f"connection failed: {exc}")
|
|
90
|
+
except httpx.TransportError as exc:
|
|
91
|
+
# Covers DNS failures, network errors, protocol errors, etc.
|
|
92
|
+
return DeliveryResult.retryable_failure(f"transport error: {exc}")
|
|
93
|
+
|
|
94
|
+
return self._classify(response)
|
|
95
|
+
|
|
96
|
+
def _classify(self, response: httpx.Response) -> DeliveryResult:
|
|
97
|
+
status = response.status_code
|
|
98
|
+
is_success = (
|
|
99
|
+
status in self._success_status_codes
|
|
100
|
+
if self._success_status_codes is not None
|
|
101
|
+
else 200 <= status < 300
|
|
102
|
+
)
|
|
103
|
+
if is_success:
|
|
104
|
+
return DeliveryResult.success_result(status_code=status)
|
|
105
|
+
|
|
106
|
+
snippet = response.text[:_RESPONSE_SNIPPET_LIMIT]
|
|
107
|
+
error = f"HTTP {status}"
|
|
108
|
+
if status in self._retryable_status_codes:
|
|
109
|
+
return DeliveryResult.retryable_failure(
|
|
110
|
+
error, status_code=status, response_snippet=snippet
|
|
111
|
+
)
|
|
112
|
+
return DeliveryResult.permanent_failure(error, status_code=status, response_snippet=snippet)
|