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,92 @@
|
|
|
1
|
+
"""Publish-side queue orchestration.
|
|
2
|
+
|
|
3
|
+
``QueueManager`` is the only thing that turns raw caller data into a durable
|
|
4
|
+
``Message``. It owns payload validation and ID/size/timestamp computation,
|
|
5
|
+
then delegates persistence (including capacity enforcement) to the storage
|
|
6
|
+
backend. ``publish()`` only returns once ``storage.enqueue`` has confirmed
|
|
7
|
+
the message is durably committed -- never before.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
|
|
15
|
+
from edgesync.config import EdgeSyncConfig
|
|
16
|
+
from edgesync.exceptions import SerializationError
|
|
17
|
+
from edgesync.logging import logger
|
|
18
|
+
from edgesync.models.message import Message, MessageStatus
|
|
19
|
+
from edgesync.models.receipt import PublishReceipt
|
|
20
|
+
from edgesync.storage.base import StorageBackend
|
|
21
|
+
from edgesync.utils.clock import Clock
|
|
22
|
+
from edgesync.utils.ids import generate_message_id
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class QueueManager:
|
|
26
|
+
"""Validates and durably enqueues outgoing messages."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, storage: StorageBackend, config: EdgeSyncConfig, clock: Clock) -> None:
|
|
29
|
+
self._storage = storage
|
|
30
|
+
self._config = config
|
|
31
|
+
self._clock = clock
|
|
32
|
+
|
|
33
|
+
async def publish(
|
|
34
|
+
self,
|
|
35
|
+
data: object,
|
|
36
|
+
*,
|
|
37
|
+
destination: str | None = None,
|
|
38
|
+
headers: dict[str, str] | None = None,
|
|
39
|
+
priority: int = 0,
|
|
40
|
+
expires_in: float | None = None,
|
|
41
|
+
) -> PublishReceipt:
|
|
42
|
+
try:
|
|
43
|
+
serialized = json.dumps(data)
|
|
44
|
+
except (TypeError, ValueError) as exc:
|
|
45
|
+
raise SerializationError(f"publish() payload is not JSON-serializable: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
payload = json.loads(serialized)
|
|
48
|
+
headers_dict = dict(headers or {})
|
|
49
|
+
size_bytes = len(serialized.encode("utf-8")) + sum(
|
|
50
|
+
len(k.encode("utf-8")) + len(v.encode("utf-8")) for k, v in headers_dict.items()
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
now = self._clock.now()
|
|
54
|
+
expires_at = now + timedelta(seconds=expires_in) if expires_in is not None else None
|
|
55
|
+
resolved_destination = destination or self._config.default_destination
|
|
56
|
+
|
|
57
|
+
message = Message(
|
|
58
|
+
id=generate_message_id(),
|
|
59
|
+
destination=resolved_destination,
|
|
60
|
+
payload=payload,
|
|
61
|
+
headers=headers_dict,
|
|
62
|
+
priority=priority,
|
|
63
|
+
status=MessageStatus.PENDING,
|
|
64
|
+
attempts=0,
|
|
65
|
+
created_at=now,
|
|
66
|
+
updated_at=now,
|
|
67
|
+
next_attempt_at=now,
|
|
68
|
+
size_bytes=size_bytes,
|
|
69
|
+
expires_at=expires_at,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
await self._storage.enqueue(
|
|
73
|
+
message,
|
|
74
|
+
max_messages=self._config.max_messages,
|
|
75
|
+
max_storage_bytes=self._config.max_storage_bytes,
|
|
76
|
+
overflow_policy=self._config.overflow_policy,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
logger.info(
|
|
80
|
+
"message %s queued (destination=%r, priority=%d, bytes=%d)",
|
|
81
|
+
message.id,
|
|
82
|
+
resolved_destination,
|
|
83
|
+
priority,
|
|
84
|
+
size_bytes,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return PublishReceipt(
|
|
88
|
+
message_id=message.id,
|
|
89
|
+
destination=resolved_destination,
|
|
90
|
+
accepted_at=now,
|
|
91
|
+
priority=priority,
|
|
92
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Exponential backoff with full jitter.
|
|
2
|
+
|
|
3
|
+
``attempts`` is the number of failed delivery attempts recorded for a
|
|
4
|
+
message so far (i.e. the value *after* incrementing on the failure that is
|
|
5
|
+
being scheduled for retry). This keeps the mapping intuitive:
|
|
6
|
+
|
|
7
|
+
attempts=1 -> initial_delay (first retry)
|
|
8
|
+
attempts=2 -> initial_delay * multiplier
|
|
9
|
+
attempts=3 -> initial_delay * multiplier**2
|
|
10
|
+
...
|
|
11
|
+
capped at max_delay
|
|
12
|
+
|
|
13
|
+
Jitter uses the "full jitter" strategy (delay = uniform(0, computed_delay)),
|
|
14
|
+
which spreads out retries and avoids synchronized retry storms across many
|
|
15
|
+
edge devices reconnecting at once.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import random
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_delay(
|
|
24
|
+
attempts: int,
|
|
25
|
+
*,
|
|
26
|
+
initial_delay: float,
|
|
27
|
+
max_delay: float,
|
|
28
|
+
multiplier: float,
|
|
29
|
+
jitter: bool,
|
|
30
|
+
rng: random.Random | None = None,
|
|
31
|
+
) -> float:
|
|
32
|
+
if attempts < 1:
|
|
33
|
+
return 0.0
|
|
34
|
+
|
|
35
|
+
raw_delay = initial_delay * (multiplier ** (attempts - 1))
|
|
36
|
+
delay = min(raw_delay, max_delay)
|
|
37
|
+
|
|
38
|
+
if jitter:
|
|
39
|
+
generator = rng or random
|
|
40
|
+
delay = generator.uniform(0.0, delay)
|
|
41
|
+
|
|
42
|
+
return max(delay, 0.0)
|
edgesync/retry/policy.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Configurable retry policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from edgesync.exceptions import ConfigurationError
|
|
9
|
+
from edgesync.retry.backoff import compute_delay
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class RetryPolicy:
|
|
14
|
+
"""Controls delivery retry timing.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
initial_delay: Delay, in seconds, before the first retry.
|
|
18
|
+
max_delay: Upper bound on any single retry delay.
|
|
19
|
+
multiplier: Exponential growth factor applied per attempt.
|
|
20
|
+
jitter: Whether to randomize each delay (full jitter) to avoid
|
|
21
|
+
synchronized retry storms.
|
|
22
|
+
max_attempts: Maximum number of delivery attempts before a message
|
|
23
|
+
is moved to the dead-letter queue. ``None`` means retry
|
|
24
|
+
indefinitely.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
initial_delay: float = 1.0
|
|
28
|
+
max_delay: float = 300.0
|
|
29
|
+
multiplier: float = 2.0
|
|
30
|
+
jitter: bool = True
|
|
31
|
+
max_attempts: int | None = None
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if self.initial_delay <= 0:
|
|
35
|
+
raise ConfigurationError("retry_policy.initial_delay must be > 0")
|
|
36
|
+
if self.max_delay < self.initial_delay:
|
|
37
|
+
raise ConfigurationError("retry_policy.max_delay must be >= initial_delay")
|
|
38
|
+
if self.multiplier < 1:
|
|
39
|
+
raise ConfigurationError("retry_policy.multiplier must be >= 1")
|
|
40
|
+
if self.max_attempts is not None and self.max_attempts < 1:
|
|
41
|
+
raise ConfigurationError("retry_policy.max_attempts must be >= 1 or None")
|
|
42
|
+
|
|
43
|
+
def next_delay(self, attempts: int, *, rng: random.Random | None = None) -> float:
|
|
44
|
+
"""Return the delay, in seconds, before the next retry attempt."""
|
|
45
|
+
return compute_delay(
|
|
46
|
+
attempts,
|
|
47
|
+
initial_delay=self.initial_delay,
|
|
48
|
+
max_delay=self.max_delay,
|
|
49
|
+
multiplier=self.multiplier,
|
|
50
|
+
jitter=self.jitter,
|
|
51
|
+
rng=rng,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def is_exhausted(self, attempts: int) -> bool:
|
|
55
|
+
"""Return True once ``attempts`` has reached ``max_attempts``."""
|
|
56
|
+
return self.max_attempts is not None and attempts >= self.max_attempts
|
edgesync/storage/base.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The storage abstraction.
|
|
2
|
+
|
|
3
|
+
The synchronization worker and public client depend only on this interface,
|
|
4
|
+
never on SQLite directly, so alternative durable backends can be added later
|
|
5
|
+
without touching worker or client code.
|
|
6
|
+
|
|
7
|
+
Every mutating method that operates on a claimed message accepts the
|
|
8
|
+
``lease_id`` returned by :meth:`StorageBackend.claim_batch` and uses it as a
|
|
9
|
+
fencing token: an update only applies if the message is still owned by that
|
|
10
|
+
lease. This is what makes storage-level ownership safe across concurrent
|
|
11
|
+
workers, not just in-memory locks.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
from edgesync.config import OverflowPolicy
|
|
20
|
+
from edgesync.models.message import Message
|
|
21
|
+
from edgesync.models.stats import QueueStats
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StorageBackend(ABC):
|
|
25
|
+
"""Durable storage contract for the EdgeSync queue."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def initialize(self) -> None:
|
|
29
|
+
"""Open the backend, apply migrations, and recover expired leases."""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def enqueue(
|
|
33
|
+
self,
|
|
34
|
+
message: Message,
|
|
35
|
+
*,
|
|
36
|
+
max_messages: int | None = None,
|
|
37
|
+
max_storage_bytes: int | None = None,
|
|
38
|
+
overflow_policy: OverflowPolicy = OverflowPolicy.REJECT_NEW,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Durably persist ``message``.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
QueueFullError: if the queue is at capacity and ``overflow_policy``
|
|
44
|
+
is ``REJECT_NEW`` (or eviction cannot free enough capacity).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
async def claim_batch(self, limit: int, lease_duration: float) -> list[Message]:
|
|
49
|
+
"""Atomically claim up to ``limit`` eligible PENDING messages.
|
|
50
|
+
|
|
51
|
+
Claimed messages transition to IN_FLIGHT with a lease that expires
|
|
52
|
+
after ``lease_duration`` seconds unless completed first.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
async def mark_delivered(self, message_id: str, lease_id: str) -> None:
|
|
57
|
+
"""Mark a claimed message as successfully delivered and remove it."""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
async def schedule_retry(
|
|
61
|
+
self,
|
|
62
|
+
message_id: str,
|
|
63
|
+
lease_id: str,
|
|
64
|
+
error: str,
|
|
65
|
+
next_attempt_at: datetime,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Return a claimed message to PENDING for a future retry."""
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
async def move_to_dead_letter(self, message_id: str, lease_id: str, error: str) -> None:
|
|
71
|
+
"""Move a claimed message to the dead-letter queue."""
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
async def recover_expired_leases(self) -> int:
|
|
75
|
+
"""Return abandoned IN_FLIGHT messages (expired lease) to PENDING.
|
|
76
|
+
|
|
77
|
+
Returns the number of messages recovered.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
async def expire_stale_messages(self) -> int:
|
|
82
|
+
"""Move PENDING messages past their ``expires_at`` to DEAD_LETTER.
|
|
83
|
+
|
|
84
|
+
Returns the number of messages expired.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
async def get_stats(self) -> QueueStats:
|
|
89
|
+
"""Return a snapshot of queue statistics."""
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
async def get_message(self, message_id: str) -> Message | None:
|
|
93
|
+
"""Fetch a single message by ID, regardless of status."""
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
async def list_dead_letters(self, limit: int = 100, offset: int = 0) -> list[Message]:
|
|
97
|
+
"""List messages currently in the dead-letter queue."""
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
async def retry_dead_letter(self, message_id: str) -> None:
|
|
101
|
+
"""Move a dead-lettered message back to PENDING for immediate retry."""
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
async def delete_message(self, message_id: str) -> None:
|
|
105
|
+
"""Permanently delete a message (e.g. from the dead-letter queue)."""
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
async def close(self) -> None:
|
|
109
|
+
"""Release all resources held by the backend."""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Schema migrations for the SQLite backend.
|
|
2
|
+
|
|
3
|
+
Migrations are applied sequentially against ``PRAGMA user_version``. Each
|
|
4
|
+
migration's SQL is idempotent (``IF NOT EXISTS``) so re-running migrations
|
|
5
|
+
against an already-migrated database is a safe no-op.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import aiosqlite
|
|
11
|
+
|
|
12
|
+
_MIGRATION_V1 = """
|
|
13
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
destination TEXT NOT NULL,
|
|
16
|
+
payload TEXT NOT NULL,
|
|
17
|
+
headers TEXT NOT NULL DEFAULT '{}',
|
|
18
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
19
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
20
|
+
status TEXT NOT NULL,
|
|
21
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
created_at TEXT NOT NULL,
|
|
23
|
+
updated_at TEXT NOT NULL,
|
|
24
|
+
next_attempt_at TEXT NOT NULL,
|
|
25
|
+
last_attempt_at TEXT,
|
|
26
|
+
delivered_at TEXT,
|
|
27
|
+
last_error TEXT,
|
|
28
|
+
lease_id TEXT,
|
|
29
|
+
lease_until TEXT,
|
|
30
|
+
expires_at TEXT,
|
|
31
|
+
size_bytes INTEGER NOT NULL DEFAULT 0
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_messages_claim
|
|
35
|
+
ON messages (status, priority DESC, next_attempt_at ASC, created_at ASC);
|
|
36
|
+
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_messages_lease
|
|
38
|
+
ON messages (status, lease_until);
|
|
39
|
+
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_messages_status
|
|
41
|
+
ON messages (status);
|
|
42
|
+
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_messages_expires
|
|
44
|
+
ON messages (status, expires_at);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS counters (
|
|
47
|
+
key TEXT PRIMARY KEY,
|
|
48
|
+
value INTEGER NOT NULL DEFAULT 0
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
INSERT OR IGNORE INTO counters (key, value) VALUES ('delivered_total', 0);
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
# Ordered list of (version, sql) migrations. Append new migrations here --
|
|
55
|
+
# never edit an already-released migration's SQL.
|
|
56
|
+
MIGRATIONS: list[tuple[int, str]] = [
|
|
57
|
+
(1, _MIGRATION_V1),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def apply_migrations(conn: aiosqlite.Connection) -> None:
|
|
62
|
+
"""Bring the database schema up to the latest known version."""
|
|
63
|
+
cursor = await conn.execute("PRAGMA user_version")
|
|
64
|
+
row = await cursor.fetchone()
|
|
65
|
+
current_version = row[0] if row else 0
|
|
66
|
+
|
|
67
|
+
for version, sql in MIGRATIONS:
|
|
68
|
+
if version > current_version:
|
|
69
|
+
await conn.executescript(sql)
|
|
70
|
+
await conn.execute(f"PRAGMA user_version = {version}")
|