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
edgesync/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""EdgeSync: reliable data delivery for unreliable networks.
|
|
2
|
+
|
|
3
|
+
from edgesync import EdgeSync
|
|
4
|
+
|
|
5
|
+
async with EdgeSync(
|
|
6
|
+
database="edgesync.db", endpoint="https://api.example.com/telemetry"
|
|
7
|
+
) as sync:
|
|
8
|
+
receipt = await sync.publish({"temperature": 28.5})
|
|
9
|
+
|
|
10
|
+
EdgeSync provides **at-least-once** delivery, not exactly-once. See
|
|
11
|
+
docs/reliability.md for the full guarantee.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import PackageNotFoundError
|
|
15
|
+
from importlib.metadata import version as _pkg_version
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from edgesync.client import DeadLetterQueue, EdgeSync
|
|
19
|
+
from edgesync.config import OverflowPolicy
|
|
20
|
+
from edgesync.exceptions import (
|
|
21
|
+
ConfigurationError,
|
|
22
|
+
EdgeSyncError,
|
|
23
|
+
EdgeSyncNotStartedError,
|
|
24
|
+
MessageNotFoundError,
|
|
25
|
+
QueueFullError,
|
|
26
|
+
SerializationError,
|
|
27
|
+
StorageError,
|
|
28
|
+
TransportError,
|
|
29
|
+
)
|
|
30
|
+
from edgesync.models.delivery import DeliveryOutcome, DeliveryResult
|
|
31
|
+
from edgesync.models.message import Message, MessageStatus
|
|
32
|
+
from edgesync.models.receipt import PublishReceipt
|
|
33
|
+
from edgesync.models.stats import QueueStats
|
|
34
|
+
from edgesync.retry.policy import RetryPolicy
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
__version__ = _pkg_version("edgesync")
|
|
38
|
+
except PackageNotFoundError: # pragma: no cover - only when run from an uninstalled checkout
|
|
39
|
+
__version__ = (Path(__file__).resolve().parent.parent / "VERSION").read_text().strip()
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"__version__",
|
|
43
|
+
"EdgeSync",
|
|
44
|
+
"DeadLetterQueue",
|
|
45
|
+
"OverflowPolicy",
|
|
46
|
+
"RetryPolicy",
|
|
47
|
+
"Message",
|
|
48
|
+
"MessageStatus",
|
|
49
|
+
"PublishReceipt",
|
|
50
|
+
"QueueStats",
|
|
51
|
+
"DeliveryOutcome",
|
|
52
|
+
"DeliveryResult",
|
|
53
|
+
"EdgeSyncError",
|
|
54
|
+
"ConfigurationError",
|
|
55
|
+
"StorageError",
|
|
56
|
+
"QueueFullError",
|
|
57
|
+
"TransportError",
|
|
58
|
+
"EdgeSyncNotStartedError",
|
|
59
|
+
"SerializationError",
|
|
60
|
+
"MessageNotFoundError",
|
|
61
|
+
]
|
edgesync/client.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""The public EdgeSync client -- the only class most users need.
|
|
2
|
+
|
|
3
|
+
``EdgeSync`` wires together the queue manager, storage backend, transport
|
|
4
|
+
registry, and background worker behind a small, stable surface:
|
|
5
|
+
|
|
6
|
+
async with EdgeSync(database=..., endpoint=...) as sync:
|
|
7
|
+
receipt = await sync.publish(data)
|
|
8
|
+
|
|
9
|
+
See docs/getting-started.md for a full walkthrough and docs/reliability.md
|
|
10
|
+
for the exact delivery guarantee this class provides.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from types import TracebackType
|
|
16
|
+
|
|
17
|
+
from edgesync.config import EdgeSyncConfig, OverflowPolicy
|
|
18
|
+
from edgesync.exceptions import ConfigurationError, EdgeSyncNotStartedError, MessageNotFoundError
|
|
19
|
+
from edgesync.logging import logger
|
|
20
|
+
from edgesync.models.message import Message, MessageStatus
|
|
21
|
+
from edgesync.models.receipt import PublishReceipt
|
|
22
|
+
from edgesync.models.stats import QueueStats
|
|
23
|
+
from edgesync.queue.manager import QueueManager
|
|
24
|
+
from edgesync.retry.policy import RetryPolicy
|
|
25
|
+
from edgesync.storage.base import StorageBackend
|
|
26
|
+
from edgesync.storage.sqlite import SQLiteStorage
|
|
27
|
+
from edgesync.transports.base import Transport
|
|
28
|
+
from edgesync.transports.http import HTTPTransport
|
|
29
|
+
from edgesync.transports.registry import TransportRegistry
|
|
30
|
+
from edgesync.utils.clock import Clock, SystemClock
|
|
31
|
+
from edgesync.worker.lifecycle import LifecycleGuard
|
|
32
|
+
from edgesync.worker.sync_worker import SyncWorker
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DeadLetterQueue:
|
|
36
|
+
"""Inspect and manage messages that exhausted retries or failed permanently.
|
|
37
|
+
|
|
38
|
+
Accessed via ``sync.dead_letters``. EdgeSync never deletes dead-lettered
|
|
39
|
+
messages on its own -- callers must explicitly ``retry`` or ``delete``
|
|
40
|
+
them.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, storage: StorageBackend) -> None:
|
|
44
|
+
self._storage = storage
|
|
45
|
+
|
|
46
|
+
async def list(self, limit: int = 100, offset: int = 0) -> list[Message]:
|
|
47
|
+
"""List dead-lettered messages, most recently failed first."""
|
|
48
|
+
return await self._storage.list_dead_letters(limit=limit, offset=offset)
|
|
49
|
+
|
|
50
|
+
async def retry(self, message_id: str) -> None:
|
|
51
|
+
"""Move a dead-lettered message back to PENDING for immediate retry."""
|
|
52
|
+
await self._require_dead_letter(message_id)
|
|
53
|
+
await self._storage.retry_dead_letter(message_id)
|
|
54
|
+
logger.info("dead-letter message %s requeued for retry", message_id)
|
|
55
|
+
|
|
56
|
+
async def delete(self, message_id: str) -> None:
|
|
57
|
+
"""Permanently delete a dead-lettered message."""
|
|
58
|
+
await self._require_dead_letter(message_id)
|
|
59
|
+
await self._storage.delete_message(message_id)
|
|
60
|
+
logger.info("dead-letter message %s deleted", message_id)
|
|
61
|
+
|
|
62
|
+
async def _require_dead_letter(self, message_id: str) -> None:
|
|
63
|
+
message = await self._storage.get_message(message_id)
|
|
64
|
+
if message is None or message.status is not MessageStatus.DEAD_LETTER:
|
|
65
|
+
raise MessageNotFoundError(f"no dead-letter message with id {message_id!r}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class EdgeSync:
|
|
69
|
+
"""Reliable, durable edge-to-cloud data delivery.
|
|
70
|
+
|
|
71
|
+
A message accepted by :meth:`publish` is durably persisted before the
|
|
72
|
+
call returns, and is only removed from the local queue once the
|
|
73
|
+
destination acknowledges it (or it is dead-lettered per the configured
|
|
74
|
+
retry policy). EdgeSync provides **at-least-once** delivery, not
|
|
75
|
+
exactly-once -- see docs/reliability.md.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
database: str,
|
|
82
|
+
endpoint: str | None = None,
|
|
83
|
+
destinations: dict[str, Transport] | None = None,
|
|
84
|
+
default_destination: str = "default",
|
|
85
|
+
batch_size: int = 50,
|
|
86
|
+
worker_concurrency: int = 5,
|
|
87
|
+
poll_interval: float = 1.0,
|
|
88
|
+
lease_duration: float = 60.0,
|
|
89
|
+
max_messages: int | None = 1_000_000,
|
|
90
|
+
max_storage_bytes: int | None = None,
|
|
91
|
+
overflow_policy: OverflowPolicy = OverflowPolicy.REJECT_NEW,
|
|
92
|
+
retry_policy: RetryPolicy | None = None,
|
|
93
|
+
shutdown_grace_period: float = 30.0,
|
|
94
|
+
idempotency_header: str = "Idempotency-Key",
|
|
95
|
+
http_timeout: float = 30.0,
|
|
96
|
+
clock: Clock | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
if endpoint is None and not destinations:
|
|
99
|
+
raise ConfigurationError(
|
|
100
|
+
"EdgeSync requires endpoint=... and/or destinations={...} "
|
|
101
|
+
"so publish() has somewhere to deliver messages"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
self._config = EdgeSyncConfig(
|
|
105
|
+
database=database,
|
|
106
|
+
endpoint=endpoint,
|
|
107
|
+
default_destination=default_destination,
|
|
108
|
+
batch_size=batch_size,
|
|
109
|
+
worker_concurrency=worker_concurrency,
|
|
110
|
+
poll_interval=poll_interval,
|
|
111
|
+
lease_duration=lease_duration,
|
|
112
|
+
max_messages=max_messages,
|
|
113
|
+
max_storage_bytes=max_storage_bytes,
|
|
114
|
+
overflow_policy=overflow_policy,
|
|
115
|
+
shutdown_grace_period=shutdown_grace_period,
|
|
116
|
+
idempotency_header=idempotency_header,
|
|
117
|
+
http_timeout=http_timeout,
|
|
118
|
+
retry_policy=retry_policy or RetryPolicy(),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self._clock = clock or SystemClock()
|
|
122
|
+
self._storage: StorageBackend = SQLiteStorage(database, clock=self._clock)
|
|
123
|
+
|
|
124
|
+
self._transports = TransportRegistry(default_destination)
|
|
125
|
+
if endpoint is not None:
|
|
126
|
+
self._transports.register(
|
|
127
|
+
default_destination,
|
|
128
|
+
HTTPTransport(
|
|
129
|
+
endpoint,
|
|
130
|
+
timeout=http_timeout,
|
|
131
|
+
idempotency_header=idempotency_header,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
for name, transport in (destinations or {}).items():
|
|
135
|
+
self._transports.register(name, transport)
|
|
136
|
+
|
|
137
|
+
self._queue = QueueManager(self._storage, self._config, self._clock)
|
|
138
|
+
self._worker = SyncWorker(
|
|
139
|
+
self._storage,
|
|
140
|
+
self._transports,
|
|
141
|
+
self._config.retry_policy,
|
|
142
|
+
batch_size=batch_size,
|
|
143
|
+
concurrency=worker_concurrency,
|
|
144
|
+
poll_interval=poll_interval,
|
|
145
|
+
lease_duration=lease_duration,
|
|
146
|
+
clock=self._clock,
|
|
147
|
+
)
|
|
148
|
+
self._lifecycle = LifecycleGuard("EdgeSync")
|
|
149
|
+
self.dead_letters = DeadLetterQueue(self._storage)
|
|
150
|
+
|
|
151
|
+
async def start(self) -> None:
|
|
152
|
+
"""Open durable storage, recover crash state, and start the worker.
|
|
153
|
+
|
|
154
|
+
Idempotent: calling ``start()`` while already started is a no-op.
|
|
155
|
+
"""
|
|
156
|
+
if not await self._lifecycle.begin_start():
|
|
157
|
+
logger.debug("EdgeSync.start() called while already started; ignoring")
|
|
158
|
+
return
|
|
159
|
+
await self._storage.initialize()
|
|
160
|
+
await self._transports.start_all()
|
|
161
|
+
await self._worker.start()
|
|
162
|
+
self._lifecycle.mark_running()
|
|
163
|
+
logger.info("EdgeSync started (database=%r)", self._config.database)
|
|
164
|
+
|
|
165
|
+
async def publish(
|
|
166
|
+
self,
|
|
167
|
+
data: object,
|
|
168
|
+
*,
|
|
169
|
+
destination: str | None = None,
|
|
170
|
+
headers: dict[str, str] | None = None,
|
|
171
|
+
priority: int = 0,
|
|
172
|
+
expires_in: float | None = None,
|
|
173
|
+
) -> PublishReceipt:
|
|
174
|
+
"""Durably queue ``data`` for delivery and return a receipt.
|
|
175
|
+
|
|
176
|
+
The returned receipt confirms local durability, not delivery -- the
|
|
177
|
+
message will be delivered by the background worker, with retries,
|
|
178
|
+
independent of whether the caller stays connected.
|
|
179
|
+
"""
|
|
180
|
+
self._require_started()
|
|
181
|
+
return await self._queue.publish(
|
|
182
|
+
data,
|
|
183
|
+
destination=destination,
|
|
184
|
+
headers=headers,
|
|
185
|
+
priority=priority,
|
|
186
|
+
expires_in=expires_in,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def stats(self) -> QueueStats:
|
|
190
|
+
"""Return a snapshot of the local queue's state."""
|
|
191
|
+
self._require_started()
|
|
192
|
+
return await self._storage.get_stats()
|
|
193
|
+
|
|
194
|
+
async def close(self, *, grace_period: float | None = None) -> None:
|
|
195
|
+
"""Gracefully stop the worker and release all resources.
|
|
196
|
+
|
|
197
|
+
In-flight deliveries get up to ``grace_period`` seconds (defaults to
|
|
198
|
+
``shutdown_grace_period``) to finish before being cancelled. No
|
|
199
|
+
queued message is ever discarded during shutdown.
|
|
200
|
+
"""
|
|
201
|
+
if not await self._lifecycle.begin_stop():
|
|
202
|
+
return
|
|
203
|
+
grace = grace_period if grace_period is not None else self._config.shutdown_grace_period
|
|
204
|
+
await self._worker.stop(grace)
|
|
205
|
+
await self._transports.close_all()
|
|
206
|
+
await self._storage.close()
|
|
207
|
+
self._lifecycle.mark_stopped()
|
|
208
|
+
logger.info("EdgeSync stopped")
|
|
209
|
+
|
|
210
|
+
def _require_started(self) -> None:
|
|
211
|
+
if not self._lifecycle.is_running:
|
|
212
|
+
raise EdgeSyncNotStartedError(
|
|
213
|
+
"EdgeSync.start() must be called (or use `async with EdgeSync(...)`) "
|
|
214
|
+
"before this operation"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def __aenter__(self) -> EdgeSync:
|
|
218
|
+
await self.start()
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
async def __aexit__(
|
|
222
|
+
self,
|
|
223
|
+
exc_type: type[BaseException] | None,
|
|
224
|
+
exc: BaseException | None,
|
|
225
|
+
tb: TracebackType | None,
|
|
226
|
+
) -> None:
|
|
227
|
+
await self.close()
|
edgesync/config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""EdgeSync configuration.
|
|
2
|
+
|
|
3
|
+
``EdgeSyncConfig`` is validated eagerly at construction time so
|
|
4
|
+
misconfiguration fails fast at startup rather than surfacing as a mysterious
|
|
5
|
+
runtime error deep in the worker loop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from edgesync.exceptions import ConfigurationError
|
|
14
|
+
from edgesync.retry.policy import RetryPolicy
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OverflowPolicy(str, Enum):
|
|
18
|
+
"""What to do when the queue is at capacity and a new message arrives.
|
|
19
|
+
|
|
20
|
+
The default is ``REJECT_NEW`` because EdgeSync must never silently drop
|
|
21
|
+
a message that was already accepted -- rejecting a *new* publish with a
|
|
22
|
+
clear exception is always safer than silently discarding queued data.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
REJECT_NEW = "reject_new"
|
|
26
|
+
DROP_OLDEST = "drop_oldest"
|
|
27
|
+
DROP_LOWEST_PRIORITY = "drop_lowest_priority"
|
|
28
|
+
DEAD_LETTER = "dead_letter"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class EdgeSyncConfig:
|
|
33
|
+
"""Validated, immutable configuration for an ``EdgeSync`` instance."""
|
|
34
|
+
|
|
35
|
+
database: str
|
|
36
|
+
endpoint: str | None = None
|
|
37
|
+
default_destination: str = "default"
|
|
38
|
+
batch_size: int = 50
|
|
39
|
+
worker_concurrency: int = 5
|
|
40
|
+
poll_interval: float = 1.0
|
|
41
|
+
lease_duration: float = 60.0
|
|
42
|
+
max_messages: int | None = 1_000_000
|
|
43
|
+
max_storage_bytes: int | None = None
|
|
44
|
+
overflow_policy: OverflowPolicy = OverflowPolicy.REJECT_NEW
|
|
45
|
+
shutdown_grace_period: float = 30.0
|
|
46
|
+
idempotency_header: str = "Idempotency-Key"
|
|
47
|
+
http_timeout: float = 30.0
|
|
48
|
+
retry_policy: RetryPolicy = field(default_factory=RetryPolicy)
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
if not self.database:
|
|
52
|
+
raise ConfigurationError("database must be a non-empty path or URL")
|
|
53
|
+
if self.batch_size <= 0:
|
|
54
|
+
raise ConfigurationError("batch_size must be > 0")
|
|
55
|
+
if self.worker_concurrency <= 0:
|
|
56
|
+
raise ConfigurationError("worker_concurrency must be > 0")
|
|
57
|
+
if self.poll_interval <= 0:
|
|
58
|
+
raise ConfigurationError("poll_interval must be > 0")
|
|
59
|
+
if self.lease_duration <= 0:
|
|
60
|
+
raise ConfigurationError("lease_duration must be > 0")
|
|
61
|
+
if self.max_messages is not None and self.max_messages <= 0:
|
|
62
|
+
raise ConfigurationError("max_messages must be > 0 or None")
|
|
63
|
+
if self.max_storage_bytes is not None and self.max_storage_bytes <= 0:
|
|
64
|
+
raise ConfigurationError("max_storage_bytes must be > 0 or None")
|
|
65
|
+
if self.shutdown_grace_period < 0:
|
|
66
|
+
raise ConfigurationError("shutdown_grace_period must be >= 0")
|
|
67
|
+
if self.http_timeout <= 0:
|
|
68
|
+
raise ConfigurationError("http_timeout must be > 0")
|
|
69
|
+
if not self.idempotency_header:
|
|
70
|
+
raise ConfigurationError("idempotency_header must be a non-empty string")
|
edgesync/exceptions.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Exception hierarchy for EdgeSync.
|
|
2
|
+
|
|
3
|
+
All exceptions raised by EdgeSync inherit from :class:`EdgeSyncError` so
|
|
4
|
+
callers can catch the entire family with a single ``except`` clause while
|
|
5
|
+
still being able to handle specific failure modes precisely.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EdgeSyncError(Exception):
|
|
12
|
+
"""Base class for all EdgeSync exceptions."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigurationError(EdgeSyncError):
|
|
16
|
+
"""Raised when EdgeSync is configured with invalid or unsafe values."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StorageError(EdgeSyncError):
|
|
20
|
+
"""Raised when a durable storage operation fails."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QueueFullError(EdgeSyncError):
|
|
24
|
+
"""Raised when a message cannot be accepted because the queue is full.
|
|
25
|
+
|
|
26
|
+
EdgeSync never silently drops a message that could not be accepted --
|
|
27
|
+
callers must handle this exception explicitly.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TransportError(EdgeSyncError):
|
|
32
|
+
"""Raised for unexpected transport-level failures.
|
|
33
|
+
|
|
34
|
+
Expected delivery failures (timeouts, 5xx responses, etc.) are reported
|
|
35
|
+
through :class:`~edgesync.models.delivery.DeliveryResult` and do not
|
|
36
|
+
raise. This exception is reserved for transport misconfiguration or
|
|
37
|
+
failures that occur outside of a single message delivery attempt (e.g.
|
|
38
|
+
``start()``/``close()`` failures).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EdgeSyncNotStartedError(EdgeSyncError):
|
|
43
|
+
"""Raised when an operation requires ``EdgeSync.start()`` to have run."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SerializationError(EdgeSyncError):
|
|
47
|
+
"""Raised when a message payload cannot be serialized to JSON."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MessageNotFoundError(EdgeSyncError):
|
|
51
|
+
"""Raised when an operation references a message ID that does not exist."""
|
edgesync/logging.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Logging setup.
|
|
2
|
+
|
|
3
|
+
EdgeSync never configures global logging (no ``basicConfig``, no handlers).
|
|
4
|
+
It only exposes a namespaced logger under ``edgesync`` and lets the host
|
|
5
|
+
application decide how to handle it, per standard library best practice.
|
|
6
|
+
|
|
7
|
+
Message payloads are never logged by default since they may contain
|
|
8
|
+
sensitive data -- only message IDs, destinations, and error summaries are.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("edgesync")
|
|
16
|
+
logger.addHandler(logging.NullHandler())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Data models shared across EdgeSync's storage, transport, and worker layers."""
|
|
2
|
+
|
|
3
|
+
from edgesync.models.delivery import DeliveryOutcome, DeliveryResult
|
|
4
|
+
from edgesync.models.message import JSONValue, Message, MessageStatus
|
|
5
|
+
from edgesync.models.receipt import PublishReceipt
|
|
6
|
+
from edgesync.models.stats import QueueStats
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DeliveryOutcome",
|
|
10
|
+
"DeliveryResult",
|
|
11
|
+
"JSONValue",
|
|
12
|
+
"Message",
|
|
13
|
+
"MessageStatus",
|
|
14
|
+
"PublishReceipt",
|
|
15
|
+
"QueueStats",
|
|
16
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Delivery outcome types returned by transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeliveryOutcome(str, Enum):
|
|
10
|
+
"""The result classification of a single delivery attempt."""
|
|
11
|
+
|
|
12
|
+
SUCCESS = "success"
|
|
13
|
+
RETRYABLE_FAILURE = "retryable_failure"
|
|
14
|
+
PERMANENT_FAILURE = "permanent_failure"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True, frozen=True)
|
|
18
|
+
class DeliveryResult:
|
|
19
|
+
"""The outcome of a transport's attempt to deliver one message.
|
|
20
|
+
|
|
21
|
+
Transports are responsible only for attempting delivery and classifying
|
|
22
|
+
the outcome. They never touch storage or retry scheduling -- that is the
|
|
23
|
+
worker's job, driven by this result.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
outcome: DeliveryOutcome
|
|
27
|
+
status_code: int | None = None
|
|
28
|
+
error: str | None = None
|
|
29
|
+
response_snippet: str | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def success(self) -> bool:
|
|
33
|
+
return self.outcome is DeliveryOutcome.SUCCESS
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def retryable(self) -> bool:
|
|
37
|
+
return self.outcome is DeliveryOutcome.RETRYABLE_FAILURE
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def success_result(cls, status_code: int | None = None) -> DeliveryResult:
|
|
41
|
+
return cls(outcome=DeliveryOutcome.SUCCESS, status_code=status_code)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def retryable_failure(
|
|
45
|
+
cls,
|
|
46
|
+
error: str,
|
|
47
|
+
status_code: int | None = None,
|
|
48
|
+
response_snippet: str | None = None,
|
|
49
|
+
) -> DeliveryResult:
|
|
50
|
+
return cls(
|
|
51
|
+
outcome=DeliveryOutcome.RETRYABLE_FAILURE,
|
|
52
|
+
status_code=status_code,
|
|
53
|
+
error=error,
|
|
54
|
+
response_snippet=response_snippet,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def permanent_failure(
|
|
59
|
+
cls,
|
|
60
|
+
error: str,
|
|
61
|
+
status_code: int | None = None,
|
|
62
|
+
response_snippet: str | None = None,
|
|
63
|
+
) -> DeliveryResult:
|
|
64
|
+
return cls(
|
|
65
|
+
outcome=DeliveryOutcome.PERMANENT_FAILURE,
|
|
66
|
+
status_code=status_code,
|
|
67
|
+
error=error,
|
|
68
|
+
response_snippet=response_snippet,
|
|
69
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The durable message model.
|
|
2
|
+
|
|
3
|
+
A ``Message`` is the unit of work EdgeSync persists, claims, delivers, and
|
|
4
|
+
retries. Every field maps directly to a column in the storage backend so the
|
|
5
|
+
in-memory representation and the durable record never diverge.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Union
|
|
14
|
+
|
|
15
|
+
# JSON-compatible payload type. EdgeSync only accepts JSON-serializable
|
|
16
|
+
# data -- see edgesync.queue.manager for the validation boundary.
|
|
17
|
+
JSONValue = Union["dict[str, JSONValue]", "list[JSONValue]", str, int, float, bool, None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MessageStatus(str, Enum):
|
|
21
|
+
"""The lifecycle state of a message in the durable queue.
|
|
22
|
+
|
|
23
|
+
PENDING -- eligible for delivery once ``next_attempt_at`` has passed.
|
|
24
|
+
IN_FLIGHT -- claimed by a worker and leased until ``lease_until``.
|
|
25
|
+
DELIVERED -- acknowledged by the destination; removed from the active
|
|
26
|
+
queue shortly after (see storage/sqlite.py).
|
|
27
|
+
DEAD_LETTER -- exhausted retries or failed permanently; retained until a
|
|
28
|
+
caller retries or deletes it via ``sync.dead_letters``.
|
|
29
|
+
|
|
30
|
+
There is no separate "FAILED" state: a retryable failure returns a
|
|
31
|
+
message directly to PENDING with an updated ``next_attempt_at``, which
|
|
32
|
+
keeps the state machine small and avoids an unreachable transient state.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
PENDING = "PENDING"
|
|
36
|
+
IN_FLIGHT = "IN_FLIGHT"
|
|
37
|
+
DELIVERED = "DELIVERED"
|
|
38
|
+
DEAD_LETTER = "DEAD_LETTER"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class Message:
|
|
43
|
+
"""A single unit of data queued for delivery to a destination."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
destination: str
|
|
47
|
+
payload: JSONValue
|
|
48
|
+
headers: dict[str, str]
|
|
49
|
+
priority: int
|
|
50
|
+
status: MessageStatus
|
|
51
|
+
attempts: int
|
|
52
|
+
created_at: datetime
|
|
53
|
+
updated_at: datetime
|
|
54
|
+
next_attempt_at: datetime
|
|
55
|
+
size_bytes: int
|
|
56
|
+
last_attempt_at: datetime | None = None
|
|
57
|
+
delivered_at: datetime | None = None
|
|
58
|
+
last_error: str | None = None
|
|
59
|
+
lease_id: str | None = None
|
|
60
|
+
lease_until: datetime | None = None
|
|
61
|
+
expires_at: datetime | None = None
|
|
62
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The receipt returned to callers by ``EdgeSync.publish()``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True, frozen=True)
|
|
10
|
+
class PublishReceipt:
|
|
11
|
+
"""Proof that a message was durably accepted into the local queue.
|
|
12
|
+
|
|
13
|
+
Receiving a receipt means the message has been committed to durable
|
|
14
|
+
storage -- it does **not** mean the message has reached the destination
|
|
15
|
+
yet. See docs/reliability.md for the full delivery guarantee.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
message_id: str
|
|
19
|
+
destination: str
|
|
20
|
+
accepted_at: datetime
|
|
21
|
+
priority: int
|
edgesync/models/stats.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Queue statistics returned by ``EdgeSync.stats()``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True, frozen=True)
|
|
9
|
+
class QueueStats:
|
|
10
|
+
"""A snapshot of the durable queue's state.
|
|
11
|
+
|
|
12
|
+
``delivered`` is a cumulative, all-time counter -- delivered messages
|
|
13
|
+
are removed from the active table shortly after acknowledgement to keep
|
|
14
|
+
the on-disk queue bounded, so this number is not a row count.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
pending: int
|
|
18
|
+
in_flight: int
|
|
19
|
+
delivered: int
|
|
20
|
+
dead_letter: int
|
|
21
|
+
retrying: int
|
|
22
|
+
storage_bytes: int | None = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def active(self) -> int:
|
|
26
|
+
"""Messages not yet delivered or dead-lettered."""
|
|
27
|
+
return self.pending + self.in_flight
|
edgesync/py.typed
ADDED
|
File without changes
|