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.
@@ -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})
File without changes
@@ -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
+ ...