gradientmail 0.0.1__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.
gradientmail/lease.py ADDED
@@ -0,0 +1,44 @@
1
+ """Learner liveness: a lease per learner, renewed by beats and judged on the observer's clock.
2
+
3
+ A learner's lease is the newest of the create-once beat objects under ``leases/{learner}/``
4
+ (``protocol`` module). The learner renews it every ``lease_every`` seconds while it runs and
5
+ deletes it when it closes. An observer lists the leases on every poll and remembers, per
6
+ learner, the newest beat and the moment on its own clock when it first saw that beat: a
7
+ learner is live while its beat has changed within ``lease_timeout`` seconds, and gone the
8
+ moment its lease leaves the listing. No wall clock crosses machines, so a lease written by a
9
+ node with a skewed clock reads like any other; the cost is that an observer trusts a lease it
10
+ has just met for one timeout before doubting it.
11
+ """
12
+
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass, field, replace
15
+
16
+ __all__ = ["Liveness"]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Liveness:
21
+ """Which learners hold a live lease, as one observer has seen it.
22
+
23
+ ``seen`` maps a learner to its newest beat and when this observer first saw that beat.
24
+ """
25
+
26
+ timeout: float
27
+ seen: Mapping[str, tuple[int, float]] = field(default_factory=dict)
28
+
29
+ def observed(self, leases: Mapping[str, int], now: float) -> "Liveness":
30
+ """Fold one listing of the leases, taken at ``now``."""
31
+
32
+ def since(learner: str, beat: int) -> float:
33
+ known = self.seen.get(learner)
34
+ return known[1] if known is not None and known[0] == beat else now
35
+
36
+ return replace(
37
+ self, seen={learner: (beat, since(learner, beat)) for learner, beat in leases.items()}
38
+ )
39
+
40
+ def live(self, now: float) -> frozenset[str]:
41
+ """The learners whose beat has changed within ``timeout`` seconds of ``now``."""
42
+ return frozenset(
43
+ learner for learner, (_, since) in self.seen.items() if now - since < self.timeout
44
+ )
@@ -0,0 +1,57 @@
1
+ """Transport: four operations over immutable, create-once objects.
2
+
3
+ An object is written at most once (``put_if_absent``), read back by key (``get``), discovered
4
+ by prefix in UTF-8 byte order (``list``) and removed idempotently (``delete``). Nothing here
5
+ knows about rounds or snapshots; ``gradientmail.protocol`` builds the exchange on top.
6
+ """
7
+
8
+ import logging
9
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
10
+ from typing import Protocol, runtime_checkable
11
+
12
+ from gradientmail.clock import Clock
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ class MailboxUnavailable(Exception):
18
+ """Transient transport failure (timeouts, dropped connections); callers retry."""
19
+
20
+
21
+ @runtime_checkable
22
+ class Mailbox(Protocol):
23
+ async def put_if_absent(self, key: str, data: bytes) -> bool:
24
+ """Create ``key`` holding ``data``; True iff this call created it.
25
+
26
+ An existing object is left untouched and False is returned.
27
+ """
28
+ ...
29
+
30
+ async def get(self, key: str) -> bytes | None:
31
+ """The bytes under ``key``, or None when no such object exists."""
32
+ ...
33
+
34
+ async def list(self, prefix: str, *, after: str = "") -> Sequence[str]:
35
+ """Every key under ``prefix`` greater than ``after``, in UTF-8 byte order, all pages."""
36
+ ...
37
+
38
+ async def delete(self, keys: Iterable[str]) -> None:
39
+ """Remove ``keys`` in any number; missing keys are not an error."""
40
+ ...
41
+
42
+
43
+ async def retrying[T](
44
+ op: Callable[[], Awaitable[T]], *, clock: Clock, max_delay: float = 30.0
45
+ ) -> T:
46
+ """Run ``op`` until it returns, sleeping through ``clock`` after each ``MailboxUnavailable``.
47
+
48
+ Delays start at 0.5 s and double up to ``max_delay``. Any other exception propagates at once.
49
+ """
50
+ delay = min(0.5, max_delay)
51
+ while True:
52
+ try:
53
+ return await op()
54
+ except MailboxUnavailable as exc:
55
+ log.warning("mailbox unavailable: %s; retrying in %.1f s", exc, delay)
56
+ await clock.sleep(delay)
57
+ delay = min(delay * 2, max_delay)
@@ -0,0 +1,39 @@
1
+ """In-memory Mailbox for development, tests and the Phase 2 example."""
2
+
3
+ import threading
4
+ from collections.abc import Iterable, Sequence
5
+
6
+ from gradientmail.mailbox import Mailbox
7
+
8
+
9
+ class MemoryMailbox(Mailbox):
10
+ """A dict behind a ``threading.Lock``.
11
+
12
+ The lock is not an asyncio primitive because one instance is shared between event loops
13
+ running on different threads (a ``Session``'s background loop and a ``Syncer``).
14
+ """
15
+
16
+ def __init__(self) -> None:
17
+ self._store: dict[str, bytes] = {}
18
+ self._lock = threading.Lock()
19
+
20
+ async def put_if_absent(self, key: str, data: bytes) -> bool:
21
+ with self._lock:
22
+ if key in self._store:
23
+ return False
24
+ self._store[key] = data
25
+ return True
26
+
27
+ async def get(self, key: str) -> bytes | None:
28
+ with self._lock:
29
+ return self._store.get(key)
30
+
31
+ async def list(self, prefix: str, *, after: str = "") -> Sequence[str]:
32
+ # Code point order is UTF-8 byte order, so str comparison matches S3's listing order.
33
+ with self._lock:
34
+ return sorted(k for k in self._store if k.startswith(prefix) and k > after)
35
+
36
+ async def delete(self, keys: Iterable[str]) -> None:
37
+ with self._lock:
38
+ for key in keys:
39
+ self._store.pop(key, None)
@@ -0,0 +1,210 @@
1
+ """S3-compatible object storage as a ``Mailbox``: AWS S3, Cloudflare R2, Hugging Face Storage
2
+ Buckets, MinIO, moto.
3
+
4
+ Consistency assumptions
5
+ -----------------------
6
+ Three properties of the store carry the whole protocol: strong read-after-write and
7
+ list-after-write (AWS general-purpose buckets since 2020-12, R2, MinIO on local xfs, zfs or
8
+ btrfs, moto), an atomic ``If-None-Match: *`` on PUT (AWS since 2024-08, R2, MinIO since
9
+ 2024-04, moto since 5.0.15) and ``ListObjectsV2`` in UTF-8 byte order. ``StartAfter`` is sent
10
+ but not relied on: Hugging Face's gateway accepts and ignores it, so ``list`` cuts at ``after``
11
+ itself and a poll there lists the whole prefix. Nothing else is needed because every object is
12
+ written once, payload keys carry the sha256 of their bytes and a record is written after the
13
+ payloads it points at.
14
+
15
+ Hugging Face Storage Buckets are reached at ``https://s3.hf.co/<namespace>`` with
16
+ ``request_checksums="when_required"`` and S3 credentials generated from a Hub token; a PUT there
17
+ takes about half a second. Verified with the contract and training suites in October 2026.
18
+
19
+ Not assumed: FIFO delivery, exactly-once, atomicity across keys, server clocks or ETag-as-MD5.
20
+ Without multi-key atomicity a listing may show a payload before its record, so readers follow
21
+ records and never list ``blobs/``.
22
+
23
+ Unsupported: S3 Express directory buckets (unordered listing, no ``StartAfter``), R2 behind a
24
+ caching custom domain (a cached read breaks read-after-write), MinIO on NFS (not a supported
25
+ MinIO backend).
26
+
27
+ Failure modes
28
+ -------------
29
+ A refused connection, a connect or read timeout and a connection closed mid-request become
30
+ ``MailboxUnavailable`` once botocore's standard retry mode has made its five attempts; the
31
+ protocol layer then retries with backoff for as long as the outage lasts. A 412
32
+ ``PreconditionFailed`` is the create-once contract (``put_if_absent`` returns False). A 409
33
+ ``ConditionalRequestConflict`` means another conditional write on the same key was in flight
34
+ and the outcome is unknown; the put is retried three times, then raised as
35
+ ``MailboxUnavailable``. Every other ``ClientError`` (a missing bucket, access denied,
36
+ throttling that outlasts botocore's retries) propagates unchanged: on a learner it is raised
37
+ from the ``Session`` constructor while joining and from the next ``step()`` afterwards, on the
38
+ syncer from ``run()``. A payload above 5 GiB raises
39
+ ``ValueError`` instead of switching to multipart uploads; choose more fragments. A bulk delete
40
+ reports per-key failures in a 200 response; the first one is raised as a ``ClientError``. On
41
+ AWS and R2 a LIST costs 12.5 times a GET, and every learner and the syncer issue one LIST per
42
+ poll, so ``poll_interval`` is the cost lever.
43
+
44
+ Every call runs in ``asyncio.to_thread``; a ``threading.BoundedSemaphore`` acquired inside the
45
+ thread keeps the connection pool from being oversubscribed. No asyncio primitive lives on the
46
+ instance, so one mailbox can be driven from several event loops in turn.
47
+ """
48
+
49
+ import asyncio
50
+ import threading
51
+ from collections.abc import Callable, Iterable, Sequence
52
+ from functools import partial
53
+ from itertools import batched
54
+ from typing import TYPE_CHECKING, Literal
55
+
56
+ import boto3.session
57
+ from botocore.config import Config
58
+ from botocore.exceptions import (
59
+ ClientError,
60
+ ConnectionClosedError,
61
+ ConnectTimeoutError,
62
+ EndpointConnectionError,
63
+ ReadTimeoutError,
64
+ )
65
+
66
+ from gradientmail.mailbox import Mailbox, MailboxUnavailable
67
+
68
+ if TYPE_CHECKING:
69
+ from mypy_boto3_s3 import S3Client
70
+
71
+ MAX_OBJECT_BYTES = 5 * 2**30
72
+ """Largest single PUT S3 accepts; larger payloads need more fragments, not multipart uploads."""
73
+
74
+
75
+ class S3Mailbox(Mailbox):
76
+ """Keys live under ``prefix`` in ``bucket``; credentials come from the boto3 default chain.
77
+
78
+ Parameters
79
+ ----------
80
+ endpoint_url
81
+ None for AWS; the service URL for R2, MinIO or any other S3-compatible store.
82
+ region
83
+ Signing region; None lets boto3 resolve it from the environment.
84
+ max_concurrency
85
+ Requests in flight at once, which is also the size of the connection pool.
86
+ request_checksums
87
+ ``"when_supported"`` asks the store to verify a SHA-256 of every uploaded object;
88
+ ``"when_required"`` sends only the checksums the protocol mandates, for stores that
89
+ reject flexible checksums (Hugging Face Storage Buckets).
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ bucket: str,
95
+ prefix: str,
96
+ *,
97
+ endpoint_url: str | None = None,
98
+ region: str | None = None,
99
+ max_concurrency: int = 8,
100
+ request_checksums: Literal["when_supported", "when_required"] = "when_supported",
101
+ ) -> None:
102
+ self._bucket = bucket
103
+ self._prefix = f"{prefix.strip('/')}/" if prefix.strip("/") else ""
104
+ self._slots = threading.BoundedSemaphore(max_concurrency)
105
+ self._client: S3Client = boto3.session.Session().client(
106
+ "s3",
107
+ endpoint_url=endpoint_url,
108
+ region_name=region,
109
+ config=Config(
110
+ retries={"mode": "standard", "total_max_attempts": 5},
111
+ max_pool_connections=max_concurrency,
112
+ connect_timeout=10,
113
+ read_timeout=120,
114
+ s3={"addressing_style": "path"},
115
+ request_checksum_calculation=request_checksums,
116
+ response_checksum_validation=request_checksums,
117
+ ),
118
+ )
119
+ put_object = partial(self._client.put_object, Bucket=bucket, IfNoneMatch="*")
120
+ self._put_object = (
121
+ partial(put_object, ChecksumAlgorithm="SHA256")
122
+ if request_checksums == "when_supported"
123
+ else put_object
124
+ )
125
+
126
+ async def put_if_absent(self, key: str, data: bytes) -> bool:
127
+ if len(data) > MAX_OBJECT_BYTES:
128
+ raise ValueError(
129
+ f"{key}: {len(data)} bytes exceeds the 5 GiB single-object limit; "
130
+ "use more fragments"
131
+ )
132
+
133
+ def put() -> bool:
134
+ # A 409 ConditionalRequestConflict means another conditional write on the same
135
+ # key was in progress; the outcome is unknown until one of them has landed.
136
+ for _ in range(3):
137
+ try:
138
+ self._put_object(Key=self._prefix + key, Body=data)
139
+ except ClientError as exc:
140
+ code = exc.response.get("Error", {}).get("Code")
141
+ if code == "PreconditionFailed":
142
+ return False
143
+ if code != "ConditionalRequestConflict":
144
+ raise
145
+ else:
146
+ return True
147
+ raise MailboxUnavailable(f"{key}: conditional write conflicted three times")
148
+
149
+ return await self._call(put)
150
+
151
+ async def get(self, key: str) -> bytes | None:
152
+ def get() -> bytes | None:
153
+ try:
154
+ response = self._client.get_object(Bucket=self._bucket, Key=self._prefix + key)
155
+ except self._client.exceptions.NoSuchKey:
156
+ return None
157
+ return response["Body"].read()
158
+
159
+ return await self._call(get)
160
+
161
+ async def list(self, prefix: str, *, after: str = "") -> Sequence[str]:
162
+ def list_keys() -> Sequence[str]:
163
+ paginator = self._client.get_paginator("list_objects_v2")
164
+ full_prefix = self._prefix + prefix
165
+ pages = (
166
+ paginator.paginate(
167
+ Bucket=self._bucket, Prefix=full_prefix, StartAfter=self._prefix + after
168
+ )
169
+ if after
170
+ else paginator.paginate(Bucket=self._bucket, Prefix=full_prefix)
171
+ )
172
+ # Hugging Face's gateway accepts StartAfter and ignores it, so the cut is made here
173
+ # as well; on stores that honour it the comparison never drops anything.
174
+ return tuple(
175
+ stripped
176
+ for page in pages
177
+ for obj in page.get("Contents", ())
178
+ if (key := obj.get("Key")) is not None
179
+ and (stripped := key[len(self._prefix) :]) > after
180
+ )
181
+
182
+ return await self._call(list_keys)
183
+
184
+ async def delete(self, keys: Iterable[str]) -> None:
185
+ def delete_chunk(chunk: Sequence[str]) -> None:
186
+ response = self._client.delete_objects(
187
+ Bucket=self._bucket,
188
+ Delete={"Objects": [{"Key": self._prefix + key} for key in chunk], "Quiet": True},
189
+ )
190
+ # Per-key failures arrive in a 200 response, not as an exception.
191
+ if errors := response.get("Errors"):
192
+ raise ClientError({"Error": errors[0]}, "DeleteObjects")
193
+
194
+ for chunk in batched(keys, 1000):
195
+ await self._call(partial(delete_chunk, chunk))
196
+
197
+ async def _call[T](self, op: Callable[[], T]) -> T:
198
+ def guarded() -> T:
199
+ with self._slots:
200
+ try:
201
+ return op()
202
+ except (
203
+ EndpointConnectionError,
204
+ ConnectTimeoutError,
205
+ ReadTimeoutError,
206
+ ConnectionClosedError,
207
+ ) as exc:
208
+ raise MailboxUnavailable(str(exc)) from exc
209
+
210
+ return await asyncio.to_thread(guarded)
@@ -0,0 +1,4 @@
1
+ from gradientmail.patterns.barrier import Barrier
2
+ from gradientmail.patterns.shared_queue import shared_queue
3
+
4
+ __all__ = ["Barrier", "shared_queue"]
@@ -0,0 +1,67 @@
1
+ """Hold nodes at a named point until a number of them have arrived."""
2
+
3
+ import asyncio
4
+ from functools import partial
5
+
6
+ from gradientmail.clock import Clock, MonotonicClock
7
+ from gradientmail.mailbox import Mailbox, retrying
8
+
9
+ __all__ = ["Barrier"]
10
+
11
+
12
+ class Barrier:
13
+ """A rendezvous through the mailbox: ``wait(name, parties=n)`` returns once ``n`` nodes arrived.
14
+
15
+ Nodes share a run-scoped mailbox, with a distinct learner ID per node. An arrival is the
16
+ create-once object ``barriers/{name}/{learner_id}``; ``wait`` creates this node's and lists
17
+ the prefix every ``poll_interval`` seconds until it holds ``parties`` keys or more. Arrivals
18
+ are never removed: a name serves once per run, a loop names each barrier (``f"epoch-{n}"``)
19
+ and a node arriving after the barrier opened passes at once. Transient mailbox failures are
20
+ retried. ``clock`` is the time source for polling and the timeout.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ mailbox: Mailbox,
27
+ learner_id: str,
28
+ poll_interval: float = 1.0,
29
+ clock: Clock | None = None,
30
+ ) -> None:
31
+ self._mailbox = mailbox
32
+ self._learner_id = learner_id
33
+ self._poll_interval = poll_interval
34
+ self._clock = clock if clock is not None else MonotonicClock()
35
+
36
+ def wait(self, name: str, *, parties: int, timeout: float | None = None) -> frozenset[str]:
37
+ """Record this node's arrival at ``name`` and block until ``parties`` nodes have arrived.
38
+
39
+ ``parties`` is a count the caller fixes; it need not match the algorithm's quorum.
40
+ Returns the learner IDs that had arrived when the barrier opened. Raises ``TimeoutError``
41
+ after ``timeout`` seconds without opening; the arrival stays recorded, so calling again
42
+ resumes the wait. Calling again once the barrier is open returns at once.
43
+ """
44
+ if parties < 1:
45
+ raise ValueError("parties must be positive")
46
+ return asyncio.run(self._wait(name, parties, timeout))
47
+
48
+ async def _wait(self, name: str, parties: int, timeout: float | None) -> frozenset[str]:
49
+ prefix = f"barriers/{name}/"
50
+ deadline = None if timeout is None else self._clock.now() + timeout
51
+ await retrying(
52
+ partial(self._mailbox.put_if_absent, f"{prefix}{self._learner_id}", b""),
53
+ clock=self._clock,
54
+ )
55
+ while True:
56
+ arrived = await retrying(partial(self._mailbox.list, prefix), clock=self._clock)
57
+ if len(arrived) >= parties:
58
+ return frozenset(key.removeprefix(prefix) for key in arrived)
59
+ delay = self._poll_interval
60
+ if deadline is not None:
61
+ remaining = deadline - self._clock.now()
62
+ if remaining <= 0:
63
+ raise TimeoutError(
64
+ f"{len(arrived)} of {parties} nodes arrived at {name!r} within {timeout} s"
65
+ )
66
+ delay = min(delay, remaining)
67
+ await self._clock.sleep(delay)
@@ -0,0 +1,104 @@
1
+ """Distribute work through mailbox claims and prefetch its loaded items."""
2
+
3
+ import asyncio
4
+ from collections import deque
5
+ from collections.abc import Callable, Generator, Iterator, Mapping
6
+ from contextlib import closing, contextmanager
7
+ from threading import Condition, Thread
8
+
9
+ from gradientmail.mailbox import Mailbox
10
+
11
+ __all__ = ["shared_queue"]
12
+
13
+
14
+ @contextmanager
15
+ def shared_queue[Work, Batch](
16
+ units: Mapping[str, Work],
17
+ *,
18
+ mailbox: Mailbox,
19
+ learner_id: str,
20
+ load: Callable[[Work], Iterator[Batch]],
21
+ prefetch: int = 8,
22
+ ) -> Iterator[Iterator[Batch]]:
23
+ """Reserve units in mapping order and load their items on a background thread.
24
+
25
+ Nodes share unit identifiers and a run-scoped mailbox, with a distinct learner ID
26
+ per node. Values stay local; reservations use ``units/{identifier}`` keys. Exclusive
27
+ ownership requires atomic ``put_if_absent``. Claims remain after completion or failure.
28
+
29
+ At most ``prefetch`` loaded items wait for consumption. Loader and mailbox failures
30
+ propagate after buffered items. Exhaustion means no unclaimed work remains, not that
31
+ other nodes have finished. Exiting stops prefetch and joins the thread, waiting for
32
+ any current mailbox or loader operation to return.
33
+ """
34
+ if prefetch < 1:
35
+ raise ValueError("prefetch must be positive")
36
+
37
+ owner = learner_id.encode()
38
+ condition = Condition()
39
+ pending = deque[Batch]()
40
+ stopped = False
41
+ finished = False
42
+ failure: BaseException | None = None
43
+
44
+ async def claim(key: str) -> bool:
45
+ return await mailbox.put_if_absent(key, owner) and await mailbox.get(key) == owner
46
+
47
+ def loaded() -> Generator[Batch, None, None]:
48
+ with asyncio.Runner() as runner:
49
+ while True:
50
+ taken = set(runner.run(mailbox.list("units/")))
51
+ for identifier, work in units.items():
52
+ key = f"units/{identifier}"
53
+ if key not in taken and runner.run(claim(key)):
54
+ yield from load(work)
55
+ break
56
+ else:
57
+ return
58
+
59
+ def produce() -> None:
60
+ nonlocal finished, failure
61
+ try:
62
+ with closing(loaded()) as source:
63
+ while True:
64
+ with condition:
65
+ condition.wait_for(lambda: stopped or len(pending) < prefetch)
66
+ if stopped:
67
+ return
68
+ batch = next(source)
69
+ with condition:
70
+ pending.append(batch)
71
+ condition.notify_all()
72
+ except StopIteration:
73
+ pass
74
+ except BaseException as error:
75
+ failure = error
76
+ finally:
77
+ with condition:
78
+ finished = True
79
+ condition.notify_all()
80
+
81
+ def consume() -> Iterator[Batch]:
82
+ while True:
83
+ with condition:
84
+ condition.wait_for(lambda: stopped or pending or finished)
85
+ if stopped:
86
+ return
87
+ if not pending:
88
+ if failure is not None:
89
+ raise failure
90
+ return
91
+ batch = pending.popleft()
92
+ condition.notify_all()
93
+ yield batch
94
+
95
+ thread = Thread(target=produce, name=f"shared-queue-{learner_id}")
96
+ thread.start()
97
+ try:
98
+ yield consume()
99
+ finally:
100
+ with condition:
101
+ stopped = True
102
+ condition.notify_all()
103
+ thread.join()
104
+ pending.clear()