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 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
@@ -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
@@ -0,0 +1,5 @@
1
+ """Publish-side orchestration: validates, builds, and durably enqueues messages."""
2
+
3
+ from edgesync.queue.manager import QueueManager
4
+
5
+ __all__ = ["QueueManager"]