smallage 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.
smallage/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ """Distributed task queue on Redis Streams with first-class Litestar integration.
2
+
3
+ Delivery is at-least-once: handlers must be idempotent.
4
+ """
5
+
6
+ from importlib.metadata import version as _version
7
+
8
+ from smallage.core import (
9
+ BrokerHandler,
10
+ Codec,
11
+ CollectingEnqueuer,
12
+ ConfigurationError,
13
+ CronJob,
14
+ DeferredEnqueuer,
15
+ EagerEnqueuer,
16
+ Enqueuer,
17
+ Envelope,
18
+ FilePayloadStore,
19
+ JsonCodec,
20
+ MalformedEnvelope,
21
+ PayloadMissing,
22
+ PayloadStore,
23
+ PayloadTooLarge,
24
+ Pending,
25
+ Record,
26
+ RedisResultStore,
27
+ RedisScheduler,
28
+ RedisStreamsTransport,
29
+ ResultStore,
30
+ RetryPolicy,
31
+ Scheduler,
32
+ SmallageError,
33
+ StreamTransport,
34
+ TaskHandler,
35
+ TaskResult,
36
+ UnknownTask,
37
+ WorkerConfig,
38
+ WorkerStats,
39
+ current_enqueuer,
40
+ dlq_key,
41
+ from_fields,
42
+ run,
43
+ run_with_signals,
44
+ to_fields,
45
+ worker_running,
46
+ )
47
+
48
+ __version__ = _version("smallage")
49
+ """Read from the installed metadata: pyproject is the only place it is written."""
50
+
51
+ __all__ = [
52
+ "BrokerHandler",
53
+ "Codec",
54
+ "CollectingEnqueuer",
55
+ "ConfigurationError",
56
+ "CronJob",
57
+ "DeferredEnqueuer",
58
+ "EagerEnqueuer",
59
+ "Enqueuer",
60
+ "Envelope",
61
+ "FilePayloadStore",
62
+ "JsonCodec",
63
+ "MalformedEnvelope",
64
+ "PayloadMissing",
65
+ "PayloadStore",
66
+ "PayloadTooLarge",
67
+ "Pending",
68
+ "Record",
69
+ "RedisResultStore",
70
+ "RedisScheduler",
71
+ "RedisStreamsTransport",
72
+ "ResultStore",
73
+ "RetryPolicy",
74
+ "Scheduler",
75
+ "SmallageError",
76
+ "StreamTransport",
77
+ "TaskHandler",
78
+ "TaskResult",
79
+ "UnknownTask",
80
+ "WorkerConfig",
81
+ "WorkerStats",
82
+ "__version__",
83
+ "current_enqueuer",
84
+ "dlq_key",
85
+ "from_fields",
86
+ "run",
87
+ "run_with_signals",
88
+ "to_fields",
89
+ "worker_running",
90
+ ]
@@ -0,0 +1,87 @@
1
+ """Transport, worker and scheduling core. Importing Litestar from here is forbidden."""
2
+
3
+ from smallage.core.cron import CronJob
4
+ from smallage.core.deferred import DeferredEnqueuer, current_enqueuer
5
+ from smallage.core.envelope import (
6
+ ENVELOPE_VERSION,
7
+ Envelope,
8
+ JsonCodec,
9
+ Pending,
10
+ Record,
11
+ TaskResult,
12
+ from_fields,
13
+ to_fields,
14
+ )
15
+ from smallage.core.errors import (
16
+ ConfigurationError,
17
+ MalformedEnvelope,
18
+ PayloadTooLarge,
19
+ SmallageError,
20
+ )
21
+ from smallage.core.keys import dlq_key
22
+ from smallage.core.payloads import FilePayloadStore, PayloadMissing
23
+ from smallage.core.protocols import (
24
+ BrokerHandler,
25
+ Codec,
26
+ Enqueuer,
27
+ PayloadStore,
28
+ ResultStore,
29
+ Scheduler,
30
+ Sleeper,
31
+ StreamTransport,
32
+ TaskHandler,
33
+ )
34
+ from smallage.core.results import RedisResultStore
35
+ from smallage.core.retry import RetryPolicy
36
+ from smallage.core.scheduler import RedisScheduler
37
+ from smallage.core.stats import WorkerStats
38
+ from smallage.core.testing import (
39
+ CollectingEnqueuer,
40
+ EagerEnqueuer,
41
+ UnknownTask,
42
+ worker_running,
43
+ )
44
+ from smallage.core.transport import RedisStreamsTransport
45
+ from smallage.core.worker import WorkerConfig, run, run_with_signals
46
+
47
+ __all__ = [
48
+ "ENVELOPE_VERSION",
49
+ "BrokerHandler",
50
+ "Codec",
51
+ "CollectingEnqueuer",
52
+ "ConfigurationError",
53
+ "CronJob",
54
+ "DeferredEnqueuer",
55
+ "EagerEnqueuer",
56
+ "Enqueuer",
57
+ "Envelope",
58
+ "FilePayloadStore",
59
+ "JsonCodec",
60
+ "MalformedEnvelope",
61
+ "PayloadMissing",
62
+ "PayloadStore",
63
+ "PayloadTooLarge",
64
+ "Pending",
65
+ "Record",
66
+ "RedisResultStore",
67
+ "RedisScheduler",
68
+ "RedisStreamsTransport",
69
+ "ResultStore",
70
+ "RetryPolicy",
71
+ "Scheduler",
72
+ "Sleeper",
73
+ "SmallageError",
74
+ "StreamTransport",
75
+ "TaskHandler",
76
+ "TaskResult",
77
+ "UnknownTask",
78
+ "WorkerConfig",
79
+ "WorkerStats",
80
+ "current_enqueuer",
81
+ "dlq_key",
82
+ "from_fields",
83
+ "run",
84
+ "run_with_signals",
85
+ "to_fields",
86
+ "worker_running",
87
+ ]
@@ -0,0 +1,18 @@
1
+ """Reading settings off a redis-py client, whatever shape it is.
2
+
3
+ A standalone client keeps them behind a connection pool and a cluster client
4
+ keeps them on itself. Both are accepted: the hash-tagged key schema exists for
5
+ cluster mode and would be pointless if a cluster client could not be handed in.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ def connection_kwarg(client: Any, name: str) -> Any:
14
+ pool = getattr(client, "connection_pool", None)
15
+ if pool is not None:
16
+ kwargs: dict[str, Any] = pool.connection_kwargs
17
+ return kwargs.get(name)
18
+ return getattr(client, "connection_kwargs", {}).get(name)
smallage/core/cron.py ADDED
@@ -0,0 +1,87 @@
1
+ """Cron expressions, resolved in a real time zone.
2
+
3
+ Occurrences are always strictly after the moment asked about, so a job cannot
4
+ fire twice for the same minute no matter how often the leader looks.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
10
+
11
+ from cronsim import CronSim, CronSimError
12
+
13
+ from smallage.core.envelope import Envelope
14
+ from smallage.core.errors import ConfigurationError
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class CronJob:
19
+ name: str
20
+ expression: str
21
+ task: str
22
+ payload: bytes = b"{}"
23
+ """Encoded arguments. An empty object, not null: it decodes into the task's
24
+ argument struct, and a task taking nothing still has one."""
25
+ queue: str = "default"
26
+ timezone: str = "UTC"
27
+
28
+ def __post_init__(self) -> None:
29
+ if not self.name or ":" in self.name:
30
+ raise ConfigurationError(
31
+ f"cron job name must be non-empty and free of ':', got {self.name!r}"
32
+ )
33
+ try:
34
+ zone = ZoneInfo(self.timezone)
35
+ except (ZoneInfoNotFoundError, ValueError) as exc:
36
+ raise ConfigurationError(
37
+ f"cron job {self.name!r} has an unknown timezone {self.timezone!r}"
38
+ ) from exc
39
+ try:
40
+ CronSim(self.expression, datetime.now(zone))
41
+ except CronSimError as exc:
42
+ raise ConfigurationError(
43
+ f"cron job {self.name!r} has an invalid expression "
44
+ f"{self.expression!r}: {exc}"
45
+ ) from exc
46
+
47
+
48
+ def next_fire_ms(job: CronJob, after_ms: int) -> int | None:
49
+ """Next occurrence strictly after ``after_ms``, or None if there is none.
50
+
51
+ Resolving in the job's own zone is what makes daylight saving behave: a time
52
+ that does not exist on the spring-forward day moves to the following instant
53
+ rather than being skipped, and one that happens twice in autumn fires once.
54
+ """
55
+ zone = ZoneInfo(job.timezone)
56
+ after = datetime.fromtimestamp(after_ms / 1000, zone)
57
+ try:
58
+ occurrence = next(CronSim(job.expression, after))
59
+ except StopIteration:
60
+ # Expressions that can never match are refused when the job is built, so
61
+ # this is unreachable in practice. It stays because an escaping
62
+ # StopIteration inside a coroutine surfaces as a bare RuntimeError.
63
+ return None
64
+ return int(occurrence.timestamp() * 1000)
65
+
66
+
67
+ def occurrence_id(job: CronJob, fire_ms: int) -> str:
68
+ """Identify an occurrence by job and instant.
69
+
70
+ Two leaders computing the same occurrence produce the same id, so scheduling
71
+ it twice is a no-op rather than a duplicate job.
72
+ """
73
+ return f"cron:{job.name}:{fire_ms}"
74
+
75
+
76
+ def occurrence_envelope(job: CronJob, fire_ms: int) -> Envelope:
77
+ """Build the entry for one occurrence.
78
+
79
+ ``enqueued_at`` is the instant the job was due, not the instant it reached
80
+ the stream, so a run delayed by an outage can be recognised as late.
81
+ """
82
+ return Envelope(
83
+ id=occurrence_id(job, fire_ms),
84
+ task=job.task,
85
+ payload=job.payload,
86
+ enqueued_at=fire_ms,
87
+ )
@@ -0,0 +1,81 @@
1
+ """Publishing jobs only once the transaction that justified them has committed.
2
+
3
+ A handler that writes a row and enqueues a job before ``COMMIT`` has a race it
4
+ cannot win: the worker is fast enough to read the row before it exists, and if
5
+ the transaction rolls back the job has already run against data that was never
6
+ written.
7
+
8
+ Buffering here and flushing from an ``after_commit`` hook covers that. It is not
9
+ a transactional outbox -- a crash between commit and flush loses the job -- but
10
+ it removes the ordering hazard, which is the one that bites in practice. Work
11
+ that must survive that crash needs an outbox in the same database.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import AsyncGenerator
17
+ from contextlib import asynccontextmanager
18
+ from contextvars import ContextVar
19
+
20
+ from smallage.core.envelope import Envelope
21
+ from smallage.core.protocols import Enqueuer
22
+
23
+ current_enqueuer: ContextVar[Enqueuer | None] = ContextVar(
24
+ "smallage_enqueuer", default=None
25
+ )
26
+ """The enqueuer in force for this unit of work, if one was bound.
27
+
28
+ Set it and every enqueue inside the block routes through it, which is what lets
29
+ publication know about the transaction without every call site being told.
30
+ """
31
+
32
+
33
+ class DeferredEnqueuer:
34
+ """Holds jobs until something says the transaction went through.
35
+
36
+ Wrap the real enqueuer per unit of work, call ``flush`` from the commit hook
37
+ and ``discard`` from the rollback one.
38
+ """
39
+
40
+ def __init__(self, target: Enqueuer) -> None:
41
+ self.target = target
42
+ self._pending: list[tuple[Envelope, str]] = []
43
+
44
+ @property
45
+ def pending(self) -> tuple[tuple[Envelope, str], ...]:
46
+ return tuple(self._pending)
47
+
48
+ async def enqueue(self, envelope: Envelope, *, queue: str) -> bytes:
49
+ """Record the job. Nothing reaches Redis until ``flush``."""
50
+ self._pending.append((envelope, queue))
51
+ return envelope.id.encode()
52
+
53
+ async def flush(self) -> list[bytes]:
54
+ """Publish everything buffered, oldest first, and forget it.
55
+
56
+ The buffer is cleared before publishing: a failure part way through must
57
+ not leave jobs that would be published twice by the next flush.
58
+ """
59
+ buffered, self._pending = self._pending, []
60
+ return [
61
+ await self.target.enqueue(envelope, queue=queue)
62
+ for envelope, queue in buffered
63
+ ]
64
+
65
+ def discard(self) -> None:
66
+ """Drop everything buffered. For the rollback path."""
67
+ self._pending.clear()
68
+
69
+ @asynccontextmanager
70
+ async def active(self) -> AsyncGenerator[DeferredEnqueuer]:
71
+ """Route every enqueue in this block through the buffer.
72
+
73
+ Binding only. Whether the block ends in ``flush`` or ``discard`` is the
74
+ caller's decision, because only the caller knows how the transaction
75
+ ended.
76
+ """
77
+ token = current_enqueuer.set(self)
78
+ try:
79
+ yield self
80
+ finally:
81
+ current_enqueuer.reset(token)
@@ -0,0 +1,138 @@
1
+ """The wire types: what a job looks like in Redis, and what it leaves behind.
2
+
3
+ A stream entry is a flat hash. ``traceparent`` sits beside ``payload`` rather than
4
+ inside it, so restoring the span context never requires decoding the payload — and
5
+ broker mode, which reads foreign payloads, can ignore the payload entirely.
6
+
7
+ Adding a field after the first release means parsing two record formats at once,
8
+ so every field a later milestone needs is written from the start.
9
+ """
10
+
11
+ from collections.abc import Mapping
12
+
13
+ import msgspec
14
+
15
+ from smallage.core.errors import MalformedEnvelope
16
+
17
+ ENVELOPE_VERSION = b"1"
18
+
19
+ _REQUIRED = (b"v", b"id", b"task", b"payload", b"enqueued_at", b"attempt")
20
+
21
+
22
+ class Envelope(msgspec.Struct, frozen=True, kw_only=True):
23
+ id: str
24
+ task: str
25
+ payload: bytes
26
+ enqueued_at: int
27
+ attempt: int = 0
28
+ payload_ref: str | None = None
29
+ traceparent: str | None = None
30
+ tracestate: str | None = None
31
+ dedup: str | None = None
32
+ """Application-chosen key. At most one job per key runs within its window."""
33
+ result_ttl_ms: int | None = None
34
+ """Keep the outcome for this long. Absent means nobody is waiting for it."""
35
+ history: tuple[str, ...] = ()
36
+ """One compact line per failed attempt, oldest first.
37
+
38
+ Bounded by the retry policy, and each line is truncated, so a job that keeps
39
+ failing cannot grow its own entry without limit.
40
+ """
41
+
42
+
43
+ class JsonCodec:
44
+ """Default payload codec. The plugin layer substitutes the app's msgspec pair."""
45
+
46
+ def encode(self, value: object, /) -> bytes:
47
+ return msgspec.json.encode(value)
48
+
49
+ def decode(self, raw: bytes, /) -> object:
50
+ return msgspec.json.decode(raw)
51
+
52
+
53
+ def to_fields(envelope: Envelope) -> dict[bytes, bytes]:
54
+ fields = {
55
+ b"v": ENVELOPE_VERSION,
56
+ b"id": envelope.id.encode(),
57
+ b"task": envelope.task.encode(),
58
+ b"payload": envelope.payload,
59
+ b"enqueued_at": str(envelope.enqueued_at).encode(),
60
+ b"attempt": str(envelope.attempt).encode(),
61
+ }
62
+ optional = {
63
+ b"payload_ref": envelope.payload_ref,
64
+ b"traceparent": envelope.traceparent,
65
+ b"tracestate": envelope.tracestate,
66
+ b"dedup": envelope.dedup,
67
+ }
68
+ if envelope.result_ttl_ms is not None:
69
+ fields[b"result_ttl_ms"] = str(envelope.result_ttl_ms).encode()
70
+ # Redis has no null: an unset field is written as no field at all.
71
+ fields.update({k: v.encode() for k, v in optional.items() if v is not None})
72
+ if envelope.history:
73
+ fields[b"history"] = "\n".join(envelope.history).encode()
74
+ return fields
75
+
76
+
77
+ def from_fields(fields: Mapping[bytes, bytes]) -> Envelope:
78
+ for name in _REQUIRED:
79
+ if name not in fields:
80
+ raise MalformedEnvelope(f"stream entry is missing field {name.decode()!r}")
81
+ if fields[b"v"] != ENVELOPE_VERSION:
82
+ raise MalformedEnvelope(
83
+ f"unsupported envelope version {fields[b'v']!r}, "
84
+ f"expected {ENVELOPE_VERSION!r}"
85
+ )
86
+ optional = {
87
+ name: fields[key].decode()
88
+ for name, key in (
89
+ ("payload_ref", b"payload_ref"),
90
+ ("traceparent", b"traceparent"),
91
+ ("tracestate", b"tracestate"),
92
+ ("dedup", b"dedup"),
93
+ )
94
+ if key in fields
95
+ }
96
+ # Unknown fields are ignored on purpose: a newer producer must not break an
97
+ # older worker during a rolling deploy.
98
+ raw_ttl = fields.get(b"result_ttl_ms")
99
+ raw_history = fields.get(b"history")
100
+ return Envelope(
101
+ result_ttl_ms=int(raw_ttl) if raw_ttl is not None else None,
102
+ history=tuple(raw_history.decode().split("\n")) if raw_history else (),
103
+ id=fields[b"id"].decode(),
104
+ task=fields[b"task"].decode(),
105
+ payload=fields[b"payload"],
106
+ enqueued_at=int(fields[b"enqueued_at"]),
107
+ attempt=int(fields[b"attempt"]),
108
+ **optional,
109
+ )
110
+
111
+
112
+ class Record(msgspec.Struct, frozen=True):
113
+ """A stream entry as Redis returns it.
114
+
115
+ Broker mode consumes foreign payloads and never builds an ``Envelope``, so the
116
+ transport hands back raw records and decoding is a separate step in the worker.
117
+ """
118
+
119
+ stream: str
120
+ entry_id: bytes
121
+ fields: dict[bytes, bytes]
122
+
123
+
124
+ class Pending(msgspec.Struct, frozen=True):
125
+ """One unacknowledged entry, as ``XPENDING`` describes it."""
126
+
127
+ stream: str
128
+ entry_id: bytes
129
+ consumer: str
130
+ times_delivered: int
131
+
132
+
133
+ class TaskResult(msgspec.Struct, frozen=True):
134
+ """What a finished job left behind, for whoever asked to be told."""
135
+
136
+ ok: bool
137
+ value: bytes = b""
138
+ error: str = ""
@@ -0,0 +1,17 @@
1
+ """Exception hierarchy of the public API."""
2
+
3
+
4
+ class SmallageError(Exception):
5
+ """Base of every error this library raises."""
6
+
7
+
8
+ class ConfigurationError(SmallageError):
9
+ """Invalid configuration. Raised at construction, never from the worker loop."""
10
+
11
+
12
+ class MalformedEnvelope(SmallageError):
13
+ """A stream entry is missing a field the envelope requires."""
14
+
15
+
16
+ class PayloadTooLarge(SmallageError):
17
+ """Encoded arguments exceed the configured limit for an inline payload."""
smallage/core/keys.py ADDED
@@ -0,0 +1,81 @@
1
+ """Redis key schema.
2
+
3
+ Every key of a namespace carries the same literal ``{ns}`` hash tag, so they all
4
+ land in one Cluster slot: multi-key ``XREADGROUP`` and the Lua scripts require it.
5
+ The schema cannot be changed after the first release without a data migration.
6
+ """
7
+
8
+ import zlib
9
+
10
+ from smallage.core.errors import ConfigurationError
11
+
12
+ _FORBIDDEN = ("{", "}", ":")
13
+
14
+
15
+ def _validate(name: str, value: str) -> str:
16
+ if not value:
17
+ raise ConfigurationError(f"{name} must not be empty")
18
+ for char in _FORBIDDEN:
19
+ if char in value:
20
+ raise ConfigurationError(f"{name} must not contain {char!r}, got {value!r}")
21
+ return value
22
+
23
+
24
+ def validate_namespace(namespace: str) -> str:
25
+ return _validate("namespace", namespace)
26
+
27
+
28
+ def validate_queue(queue: str) -> str:
29
+ return _validate("queue", queue)
30
+
31
+
32
+ def stream_key(namespace: str, queue: str, shard: int) -> str:
33
+ return f"{{{namespace}}}:q:{queue}:{shard}"
34
+
35
+
36
+ def stream_keys(namespace: str, queue: str, shards: int) -> list[str]:
37
+ if shards < 1:
38
+ raise ConfigurationError(f"shards must be at least 1, got {shards}")
39
+ return [stream_key(namespace, queue, shard) for shard in range(shards)]
40
+
41
+
42
+ def stream_for(namespace: str, queue: str, shards: int, routing_key: str) -> str:
43
+ """Pick a shard deterministically, so one routing key always lands in one stream."""
44
+ streams = stream_keys(namespace, queue, shards)
45
+ return streams[zlib.crc32(routing_key.encode()) % len(streams)]
46
+
47
+
48
+ def alive_key(namespace: str, entry_id: bytes | str) -> str:
49
+ if isinstance(entry_id, bytes):
50
+ entry_id = entry_id.decode("ascii")
51
+ return f"{{{namespace}}}:alive:{entry_id}"
52
+
53
+
54
+ def dedup_key(namespace: str, key: str) -> str:
55
+ return f"{{{namespace}}}:dedup:{key}"
56
+
57
+
58
+ def sched_key(namespace: str) -> str:
59
+ return f"{{{namespace}}}:sched"
60
+
61
+
62
+ def sched_job_key(namespace: str, scheduled_id: str) -> str:
63
+ return f"{{{namespace}}}:sched:job:{scheduled_id}"
64
+
65
+
66
+ def leader_key(namespace: str) -> str:
67
+ return f"{{{namespace}}}:leader"
68
+
69
+
70
+ def result_key(namespace: str, job_id: str) -> str:
71
+ return f"{{{namespace}}}:result:{job_id}"
72
+
73
+
74
+ def result_wait_key(namespace: str, job_id: str) -> str:
75
+ return f"{{{namespace}}}:result:wait:{job_id}"
76
+
77
+
78
+ def dlq_key(namespace: str) -> str:
79
+ """Exported: reading the dead letter queue is an ordinary thing to want, and
80
+ an application should not have to rebuild the key schema to do it."""
81
+ return f"{{{namespace}}}:dlq"
@@ -0,0 +1,66 @@
1
+ """Somewhere for arguments too large to keep in a stream.
2
+
3
+ Redis holds the whole stream in memory, so a payload measured in megabytes is a
4
+ direct route to an OOM kill. Above a threshold the arguments go here and the
5
+ record carries a reference instead.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import anyio
13
+
14
+ from smallage.core.errors import ConfigurationError, SmallageError
15
+
16
+ SCHEME = "file://"
17
+
18
+
19
+ class PayloadMissing(SmallageError):
20
+ """A record references a payload the store no longer has."""
21
+
22
+
23
+ class FilePayloadStore:
24
+ """Payloads as files under one directory.
25
+
26
+ Every worker must see the same directory -- a network volume, or a single
27
+ host. A path local to one pod means the job runs wherever the file happens to
28
+ be and fails everywhere else, so the reference records the path and reading
29
+ it back from the wrong place says exactly that.
30
+ """
31
+
32
+ def __init__(self, root: Path | str) -> None:
33
+ self.root = Path(root)
34
+ if not self.root.is_absolute():
35
+ # A relative path resolves against each process's working directory,
36
+ # which is rarely the same one twice.
37
+ raise ConfigurationError(f"root must be absolute, got {self.root}")
38
+ self.root.mkdir(parents=True, exist_ok=True)
39
+
40
+ def _path(self, job_id: str) -> Path:
41
+ if "/" in job_id or job_id in {"", ".", ".."}:
42
+ raise ConfigurationError(f"job id is not a usable file name: {job_id!r}")
43
+ return self.root / f"{job_id}.payload"
44
+
45
+ async def put(self, job_id: str, data: bytes) -> str:
46
+ path = self._path(job_id)
47
+ # Write beside it and rename: a reader must never see half a payload,
48
+ # and rename within a directory is atomic.
49
+ staging = path.with_suffix(".partial")
50
+ await anyio.Path(staging).write_bytes(data)
51
+ await anyio.Path(staging).rename(path)
52
+ return f"{SCHEME}{path}"
53
+
54
+ async def get(self, reference: str) -> bytes:
55
+ path = Path(reference.removeprefix(SCHEME))
56
+ try:
57
+ return await anyio.Path(path).read_bytes()
58
+ except FileNotFoundError as exc:
59
+ raise PayloadMissing(
60
+ f"no payload at {reference}; the store is not the one that wrote "
61
+ "it, or it has been cleaned up"
62
+ ) from exc
63
+
64
+ async def discard(self, reference: str) -> None:
65
+ """Remove a payload once its job is finished with it."""
66
+ await anyio.Path(Path(reference.removeprefix(SCHEME))).unlink(missing_ok=True)