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 +90 -0
- smallage/core/__init__.py +87 -0
- smallage/core/clients.py +18 -0
- smallage/core/cron.py +87 -0
- smallage/core/deferred.py +81 -0
- smallage/core/envelope.py +138 -0
- smallage/core/errors.py +17 -0
- smallage/core/keys.py +81 -0
- smallage/core/payloads.py +66 -0
- smallage/core/protocols.py +116 -0
- smallage/core/results.py +76 -0
- smallage/core/retry.py +93 -0
- smallage/core/scheduler.py +138 -0
- smallage/core/scripts/__init__.py +60 -0
- smallage/core/scripts/ack.lua +31 -0
- smallage/core/scripts/promote.lua +43 -0
- smallage/core/scripts/reclaim.lua +30 -0
- smallage/core/scripts/release_leader.lua +11 -0
- smallage/core/scripts/renew_leader.lua +15 -0
- smallage/core/stats.py +43 -0
- smallage/core/testing.py +117 -0
- smallage/core/transport.py +372 -0
- smallage/core/worker.py +656 -0
- smallage/litestar/__init__.py +17 -0
- smallage/litestar/cli.py +103 -0
- smallage/litestar/config.py +85 -0
- smallage/litestar/di.py +134 -0
- smallage/litestar/health.py +40 -0
- smallage/litestar/plugin.py +187 -0
- smallage/litestar/registry.py +321 -0
- smallage/litestar/tracing.py +29 -0
- smallage/py.typed +0 -0
- smallage-0.2.0.dist-info/METADATA +162 -0
- smallage-0.2.0.dist-info/RECORD +36 -0
- smallage-0.2.0.dist-info/WHEEL +4 -0
- smallage-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|
smallage/core/clients.py
ADDED
|
@@ -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 = ""
|
smallage/core/errors.py
ADDED
|
@@ -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)
|