jqueue 0.1.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.
- jqueue/__init__.py +95 -0
- jqueue/adapters/__init__.py +0 -0
- jqueue/adapters/storage/__init__.py +0 -0
- jqueue/adapters/storage/filesystem.py +108 -0
- jqueue/adapters/storage/gcs.py +130 -0
- jqueue/adapters/storage/memory.py +62 -0
- jqueue/adapters/storage/s3.py +135 -0
- jqueue/core/__init__.py +0 -0
- jqueue/core/broker.py +109 -0
- jqueue/core/codec.py +42 -0
- jqueue/core/direct.py +170 -0
- jqueue/core/group_commit.py +263 -0
- jqueue/core/heartbeat.py +90 -0
- jqueue/domain/__init__.py +0 -0
- jqueue/domain/errors.py +46 -0
- jqueue/domain/models.py +177 -0
- jqueue/ports/__init__.py +0 -0
- jqueue/ports/storage.py +81 -0
- jqueue-0.1.0.dist-info/METADATA +712 -0
- jqueue-0.1.0.dist-info/RECORD +22 -0
- jqueue-0.1.0.dist-info/WHEEL +4 -0
- jqueue-0.1.0.dist-info/licenses/LICENSE +21 -0
jqueue/domain/models.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Domain models for jqueue — backed by Pydantic v2.
|
|
3
|
+
|
|
4
|
+
Pydantic handles:
|
|
5
|
+
- JSON serialization / deserialization (via codec.py)
|
|
6
|
+
- bytes ↔ base64 encoding in JSON mode
|
|
7
|
+
- datetime parsing (ISO-8601 with timezone)
|
|
8
|
+
- field validation and type coercion
|
|
9
|
+
|
|
10
|
+
All models are frozen (immutable). Mutations return new instances via
|
|
11
|
+
model_copy(update=...), following a functional-update style.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JobStatus(StrEnum):
|
|
23
|
+
"""Lifecycle states for a queued job."""
|
|
24
|
+
|
|
25
|
+
QUEUED = "queued"
|
|
26
|
+
IN_PROGRESS = "in_progress"
|
|
27
|
+
DEAD = "dead"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Job(BaseModel):
|
|
31
|
+
"""
|
|
32
|
+
A single unit of work stored in the queue.
|
|
33
|
+
|
|
34
|
+
id — stable identifier, assigned at enqueue time
|
|
35
|
+
entrypoint — logical name used to route the job to a handler
|
|
36
|
+
payload — arbitrary bytes (serialised as base64 in JSON)
|
|
37
|
+
status — current lifecycle state
|
|
38
|
+
priority — lower value = higher priority (default 0)
|
|
39
|
+
created_at — UTC timestamp set at enqueue time
|
|
40
|
+
heartbeat_at — UTC timestamp of last worker heartbeat (None when QUEUED)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(frozen=True)
|
|
44
|
+
|
|
45
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
46
|
+
entrypoint: str
|
|
47
|
+
payload: bytes
|
|
48
|
+
status: JobStatus = JobStatus.QUEUED
|
|
49
|
+
priority: int = 0
|
|
50
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
51
|
+
heartbeat_at: datetime | None = None
|
|
52
|
+
|
|
53
|
+
@field_validator("payload", mode="before")
|
|
54
|
+
@classmethod
|
|
55
|
+
def _decode_payload(cls, v: str | bytes) -> bytes:
|
|
56
|
+
"""Accept base64 strings from JSON; pass bytes through unchanged."""
|
|
57
|
+
match v:
|
|
58
|
+
case bytes():
|
|
59
|
+
return v
|
|
60
|
+
case str():
|
|
61
|
+
return base64.b64decode(v)
|
|
62
|
+
case _:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"payload must be bytes or a base64-encoded str, "
|
|
65
|
+
f"got {type(v).__name__}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@field_serializer("payload")
|
|
69
|
+
def _encode_payload(self, v: bytes) -> str:
|
|
70
|
+
"""Encode bytes as base64 ASCII for JSON serialisation."""
|
|
71
|
+
return base64.b64encode(v).decode("ascii")
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def new(cls, entrypoint: str, payload: bytes, priority: int = 0) -> "Job":
|
|
75
|
+
"""Factory — assigns a fresh UUID and sets status to QUEUED."""
|
|
76
|
+
return cls(entrypoint=entrypoint, payload=payload, priority=priority)
|
|
77
|
+
|
|
78
|
+
def with_status(self, status: JobStatus) -> "Job":
|
|
79
|
+
"""Return a new Job with an updated status."""
|
|
80
|
+
return self.model_copy(update={"status": status})
|
|
81
|
+
|
|
82
|
+
def with_heartbeat(self, ts: datetime | None) -> "Job":
|
|
83
|
+
"""Return a new Job with an updated heartbeat timestamp."""
|
|
84
|
+
return self.model_copy(update={"heartbeat_at": ts})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class QueueState(BaseModel):
|
|
88
|
+
"""
|
|
89
|
+
The complete, authoritative state of the queue.
|
|
90
|
+
|
|
91
|
+
This is exactly what lives in the JSON file on object storage.
|
|
92
|
+
Pure value type — all mutations return new instances.
|
|
93
|
+
|
|
94
|
+
jobs — ordered sequence of jobs; order is preserved across ser/de
|
|
95
|
+
version — monotonically increasing counter, incremented on every CAS write
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(frozen=True)
|
|
99
|
+
|
|
100
|
+
jobs: tuple[Job, ...] = ()
|
|
101
|
+
version: int = 0
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ #
|
|
104
|
+
# Query helpers #
|
|
105
|
+
# ------------------------------------------------------------------ #
|
|
106
|
+
|
|
107
|
+
def queued_jobs(self, entrypoint: str | None = None) -> tuple[Job, ...]:
|
|
108
|
+
"""All QUEUED jobs sorted by (priority, created_at), optionally filtered."""
|
|
109
|
+
candidates = (j for j in self.jobs if j.status == JobStatus.QUEUED)
|
|
110
|
+
if entrypoint is not None:
|
|
111
|
+
candidates = (j for j in candidates if j.entrypoint == entrypoint)
|
|
112
|
+
return tuple(sorted(candidates, key=lambda j: (j.priority, j.created_at)))
|
|
113
|
+
|
|
114
|
+
def in_progress_jobs(self) -> tuple[Job, ...]:
|
|
115
|
+
"""All IN_PROGRESS jobs."""
|
|
116
|
+
return tuple(j for j in self.jobs if j.status == JobStatus.IN_PROGRESS)
|
|
117
|
+
|
|
118
|
+
def find(self, job_id: str) -> Job | None:
|
|
119
|
+
"""Return the job with the given id, or None if absent."""
|
|
120
|
+
return next((j for j in self.jobs if j.id == job_id), None)
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------ #
|
|
123
|
+
# Mutation helpers — each returns a new QueueState #
|
|
124
|
+
# ------------------------------------------------------------------ #
|
|
125
|
+
|
|
126
|
+
def with_job_added(self, job: Job) -> "QueueState":
|
|
127
|
+
"""Append a job and increment version."""
|
|
128
|
+
return self.model_copy(
|
|
129
|
+
update={"jobs": self.jobs + (job,), "version": self.version + 1}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def with_job_replaced(self, updated: Job) -> "QueueState":
|
|
133
|
+
"""Replace the job with the same id. Raises JobNotFoundError if absent."""
|
|
134
|
+
from jqueue.domain.errors import JobNotFoundError
|
|
135
|
+
|
|
136
|
+
found = False
|
|
137
|
+
new_jobs: list[Job] = []
|
|
138
|
+
for j in self.jobs:
|
|
139
|
+
if j.id == updated.id:
|
|
140
|
+
new_jobs.append(updated)
|
|
141
|
+
found = True
|
|
142
|
+
else:
|
|
143
|
+
new_jobs.append(j)
|
|
144
|
+
if not found:
|
|
145
|
+
raise JobNotFoundError(updated.id)
|
|
146
|
+
return self.model_copy(
|
|
147
|
+
update={"jobs": tuple(new_jobs), "version": self.version + 1}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def with_job_removed(self, job_id: str) -> "QueueState":
|
|
151
|
+
"""Remove a job by id. Raises JobNotFoundError if absent."""
|
|
152
|
+
from jqueue.domain.errors import JobNotFoundError
|
|
153
|
+
|
|
154
|
+
original_len = len(self.jobs)
|
|
155
|
+
new_jobs = tuple(j for j in self.jobs if j.id != job_id)
|
|
156
|
+
if len(new_jobs) == original_len:
|
|
157
|
+
raise JobNotFoundError(job_id)
|
|
158
|
+
return self.model_copy(update={"jobs": new_jobs, "version": self.version + 1})
|
|
159
|
+
|
|
160
|
+
def requeue_stale(self, cutoff: datetime) -> "QueueState":
|
|
161
|
+
"""
|
|
162
|
+
Reset any IN_PROGRESS job whose heartbeat_at is before `cutoff` back to QUEUED.
|
|
163
|
+
|
|
164
|
+
Returns self unchanged when no jobs are stale.
|
|
165
|
+
"""
|
|
166
|
+
new_jobs = tuple(
|
|
167
|
+
j.with_status(JobStatus.QUEUED).with_heartbeat(None)
|
|
168
|
+
if (
|
|
169
|
+
j.status == JobStatus.IN_PROGRESS
|
|
170
|
+
and (j.heartbeat_at is None or j.heartbeat_at < cutoff)
|
|
171
|
+
)
|
|
172
|
+
else j
|
|
173
|
+
for j in self.jobs
|
|
174
|
+
)
|
|
175
|
+
if new_jobs == self.jobs:
|
|
176
|
+
return self
|
|
177
|
+
return self.model_copy(update={"jobs": new_jobs, "version": self.version + 1})
|
jqueue/ports/__init__.py
ADDED
|
File without changes
|
jqueue/ports/storage.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ObjectStoragePort — the single port in jqueue.
|
|
3
|
+
|
|
4
|
+
Any object satisfying this structural Protocol can act as the storage backend.
|
|
5
|
+
No base class or registration is required — Python's structural subtyping
|
|
6
|
+
(duck typing + Protocol) is sufficient.
|
|
7
|
+
|
|
8
|
+
CAS write contract
|
|
9
|
+
------------------
|
|
10
|
+
write(content, if_match=None)
|
|
11
|
+
- if if_match is None → unconditional put (used for the very first write)
|
|
12
|
+
- if if_match is given → conditional put
|
|
13
|
+
succeeds → storage returns the new etag (opaque str)
|
|
14
|
+
fails → raises CASConflictError
|
|
15
|
+
|
|
16
|
+
read()
|
|
17
|
+
- Returns (content_bytes, etag_string)
|
|
18
|
+
- If the object does not exist, returns (b"", None)
|
|
19
|
+
(the caller treats this as an empty queue)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Protocol, runtime_checkable
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class ObjectStoragePort(Protocol):
|
|
29
|
+
"""
|
|
30
|
+
Minimal interface required by jqueue core.
|
|
31
|
+
|
|
32
|
+
Two async methods. The etag returned by read() must be passed back
|
|
33
|
+
as if_match on the next write() to achieve compare-and-set semantics.
|
|
34
|
+
|
|
35
|
+
Implementing adapters (built-in):
|
|
36
|
+
- InMemoryStorage — asyncio.Lock-based, for testing
|
|
37
|
+
- LocalFileSystemStorage — fcntl.flock-based, POSIX single-machine
|
|
38
|
+
- S3Storage — AWS S3 If-Match conditional write (aioboto3)
|
|
39
|
+
- GCSStorage — GCS if_generation_match (google-cloud-storage)
|
|
40
|
+
|
|
41
|
+
Custom adapters need only implement these two methods with the contract
|
|
42
|
+
described in their docstrings.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
async def read(self) -> tuple[bytes, str | None]:
|
|
46
|
+
"""
|
|
47
|
+
Read the current state object.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
content : bytes
|
|
52
|
+
Raw bytes. Empty bytes (b"") if the object does not exist yet.
|
|
53
|
+
etag : str | None
|
|
54
|
+
Opaque version token. Pass this to write() as if_match.
|
|
55
|
+
None if the object does not exist.
|
|
56
|
+
"""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
async def write(
|
|
60
|
+
self,
|
|
61
|
+
content: bytes,
|
|
62
|
+
if_match: str | None = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Atomically write the state object.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
content : new object body
|
|
70
|
+
if_match : etag from the previous read(), or None for unconditional write
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
str : new etag for the written object
|
|
75
|
+
|
|
76
|
+
Raises
|
|
77
|
+
------
|
|
78
|
+
CASConflictError if if_match is provided but does not match the current etag
|
|
79
|
+
StorageError for any other I/O failure
|
|
80
|
+
"""
|
|
81
|
+
...
|