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.
@@ -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,5 @@
1
+ """Retry policy and exponential backoff with jitter."""
2
+
3
+ from edgesync.retry.policy import RetryPolicy
4
+
5
+ __all__ = ["RetryPolicy"]
@@ -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)
@@ -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
@@ -0,0 +1,6 @@
1
+ """Durable storage backends for EdgeSync's queue."""
2
+
3
+ from edgesync.storage.base import StorageBackend
4
+ from edgesync.storage.sqlite import SQLiteStorage
5
+
6
+ __all__ = ["StorageBackend", "SQLiteStorage"]
@@ -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}")