queue-max 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,340 @@
1
+ """Job data model for the Robusta Queue library.
2
+
3
+ Represents a task in the queue with full lifecycle tracking,
4
+ including retry logic, error handling, and metadata.
5
+ """
6
+
7
+ import json
8
+ import traceback
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timedelta, timezone
11
+ from enum import Enum
12
+ from typing import Any, Dict, List, Optional, Union
13
+ from uuid import uuid4
14
+
15
+ class JobStatus(Enum):
16
+ """Possible job statuses."""
17
+ PENDING = "pending"
18
+ PROCESSING = "processing"
19
+ COMPLETED = "completed"
20
+ FAILED = "failed"
21
+ CANCELLED = "cancelled"
22
+ SCHEDULED = "scheduled"
23
+
24
+ class JobPriority(Enum):
25
+ """Job priority levels."""
26
+ LOW = 0
27
+ MEDIUM = 1
28
+ HIGH = 2
29
+
30
+ @classmethod
31
+ def from_int(cls, value: int) -> "JobPriority":
32
+ """Convert integer to JobPriority."""
33
+ for priority in cls:
34
+ if priority.value == value:
35
+ return priority
36
+ return cls.MEDIUM
37
+
38
+ @dataclass
39
+ class JobResult:
40
+ """Result of a job execution."""
41
+ success: bool
42
+ result: Any = None
43
+ error: Optional[Exception] = None
44
+ execution_time: float = 0.0
45
+ worker_id: Optional[str] = None
46
+ completed_at: Optional[str] = None
47
+
48
+ def _now_iso() -> str:
49
+ """Get current UTC timestamp in ISO format."""
50
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
51
+
52
+ @dataclass
53
+ class Job:
54
+ """Represents a job in the queue.
55
+
56
+ Attributes:
57
+ id: Unique identifier for the job.
58
+ pagina_id: Optional ID used for consistent sharding.
59
+ payload: JSON-serializable dictionary with job data.
60
+ status: Current status.
61
+ priority: Job priority (0=low, 1=medium, 2=high).
62
+ tentativas: Number of attempts made so far.
63
+ max_tentativas: Maximum number of retry attempts.
64
+ retry_delay: Base delay in seconds for exponential backoff.
65
+ last_error: Last error message.
66
+ error_type: Type/class of the last error.
67
+ error_stack: Stack trace of the last error.
68
+ worker_id: ID of the worker processing this job.
69
+ heartbeat: Last activity timestamp (ISO UTC).
70
+ created_at: Creation timestamp (ISO UTC).
71
+ started_at: When processing started (ISO UTC).
72
+ completed_at: When job completed/failed (ISO UTC).
73
+ next_retry_at: Scheduled next retry timestamp (ISO UTC).
74
+ shard_id: Shard this job belongs to.
75
+ tags: List of tags for categorization.
76
+ metadata: Additional metadata dictionary.
77
+ parent_job_id: Optional parent job ID for dependency chains.
78
+ timeout_seconds: Maximum execution time in seconds.
79
+ progress: Progress percentage (0-100) for long-running jobs.
80
+ """
81
+
82
+ id: int
83
+ payload: Dict[str, Any]
84
+ pagina_id: Optional[int] = None
85
+ status: Union[JobStatus, str] = JobStatus.PENDING
86
+ priority: Union[JobPriority, int] = JobPriority.MEDIUM
87
+ tentativas: int = 0
88
+ max_tentativas: int = 3
89
+ retry_delay: int = 60
90
+ last_error: Optional[str] = None
91
+ error_type: Optional[str] = None
92
+ error_stack: Optional[str] = None
93
+ worker_id: Optional[str] = None
94
+ heartbeat: Optional[str] = None
95
+ created_at: Optional[str] = None
96
+ started_at: Optional[str] = None
97
+ completed_at: Optional[str] = None
98
+ next_retry_at: Optional[str] = None
99
+ shard_id: int = 0
100
+ tags: List[str] = field(default_factory=list)
101
+ metadata: Dict[str, Any] = field(default_factory=dict)
102
+ parent_job_id: Optional[int] = None
103
+ timeout_seconds: Optional[int] = None
104
+ progress: float = 0.0
105
+
106
+ def __post_init__(self):
107
+ """Initialize timestamps and normalize enums."""
108
+ if isinstance(self.status, str):
109
+ try:
110
+ self.status = JobStatus(self.status)
111
+ except ValueError:
112
+ self.status = JobStatus.PENDING
113
+ if isinstance(self.priority, int):
114
+ self.priority = JobPriority.from_int(self.priority)
115
+ if self.created_at is None:
116
+ self.created_at = _now_iso()
117
+
118
+ @property
119
+ def status_str(self) -> str:
120
+ return self.status.value if isinstance(self.status, JobStatus) else str(self.status)
121
+
122
+ @property
123
+ def priority_int(self) -> int:
124
+ return self.priority.value if isinstance(self.priority, JobPriority) else int(self.priority)
125
+
126
+ @property
127
+ def is_pending(self) -> bool:
128
+ return self.status == JobStatus.PENDING
129
+
130
+ @property
131
+ def is_processing(self) -> bool:
132
+ return self.status == JobStatus.PROCESSING
133
+
134
+ @property
135
+ def is_completed(self) -> bool:
136
+ return self.status == JobStatus.COMPLETED
137
+
138
+ @property
139
+ def is_failed(self) -> bool:
140
+ return self.status == JobStatus.FAILED
141
+
142
+ @property
143
+ def is_cancelled(self) -> bool:
144
+ return self.status == JobStatus.CANCELLED
145
+
146
+ @property
147
+ def is_terminal(self) -> bool:
148
+ return self.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED)
149
+
150
+ @property
151
+ def can_retry(self) -> bool:
152
+ return self.tentativas < self.max_tentativas and self.status == JobStatus.FAILED
153
+
154
+ @property
155
+ def remaining_retries(self) -> int:
156
+ return max(0, self.max_tentativas - self.tentativas)
157
+
158
+ @property
159
+ def age_seconds(self) -> Optional[float]:
160
+ if self.created_at:
161
+ try:
162
+ created = datetime.fromisoformat(self.created_at.replace("Z", "+00:00"))
163
+ return (datetime.now(timezone.utc) - created).total_seconds()
164
+ except (ValueError, TypeError):
165
+ return None
166
+ return None
167
+
168
+ @property
169
+ def processing_time_seconds(self) -> Optional[float]:
170
+ if self.started_at and self.completed_at:
171
+ try:
172
+ start = datetime.fromisoformat(self.started_at.replace("Z", "+00:00"))
173
+ end = datetime.fromisoformat(self.completed_at.replace("Z", "+00:00"))
174
+ return (end - start).total_seconds()
175
+ except (ValueError, TypeError):
176
+ return None
177
+ return None
178
+
179
+ def mark_processing(self, worker_id: str) -> None:
180
+ """Mark job as being processed by a worker."""
181
+ self.status = JobStatus.PROCESSING
182
+ self.worker_id = worker_id
183
+ self.started_at = _now_iso()
184
+ self.heartbeat = self.started_at
185
+ self.progress = 0.0
186
+
187
+ def mark_completed(self, result: Any = None) -> None:
188
+ """Mark job as completed with optional result."""
189
+ self.status = JobStatus.COMPLETED
190
+ self.completed_at = _now_iso()
191
+ self.progress = 100.0
192
+ self.last_error = None
193
+ self.error_type = None
194
+ self.error_stack = None
195
+ if result is not None:
196
+ self.metadata["result"] = result
197
+
198
+ def mark_failed(self, error: Exception, permanent: bool = False) -> None:
199
+ """Mark job as failed, scheduling retry if applicable."""
200
+ self.status = JobStatus.FAILED
201
+ self.completed_at = _now_iso()
202
+ self.last_error = str(error)
203
+ self.error_type = type(error).__name__
204
+ self.error_stack = "".join(
205
+ traceback.format_exception(type(error), error, error.__traceback__)
206
+ )
207
+ if not permanent and self.can_retry:
208
+ self.status = JobStatus.PENDING
209
+ self.tentativas += 1
210
+ self._schedule_retry()
211
+
212
+ def mark_cancelled(self) -> None:
213
+ """Mark job as cancelled."""
214
+ self.status = JobStatus.CANCELLED
215
+ self.completed_at = _now_iso()
216
+
217
+ def update_progress(self, progress: float) -> None:
218
+ """Update job progress (0-100)."""
219
+ self.progress = max(0, min(100, progress))
220
+ self.heartbeat = _now_iso()
221
+
222
+ def _schedule_retry(self) -> None:
223
+ """Schedule retry with exponential backoff and jitter."""
224
+ import random
225
+ delay = float(self.retry_delay) * (2 ** (self.tentativas - 1))
226
+ jitter = delay * 0.2
227
+ delay = delay + (random.random() * jitter * 2 - jitter)
228
+ delay = min(delay, 3600)
229
+ next_retry = datetime.now(timezone.utc) + timedelta(seconds=delay)
230
+ self.next_retry_at = next_retry.isoformat(timespec="milliseconds").replace("+00:00", "Z")
231
+
232
+ def to_dict(self) -> Dict[str, Any]:
233
+ """Convert job to a dictionary for serialization."""
234
+ return {
235
+ "id": self.id,
236
+ "pagina_id": self.pagina_id,
237
+ "payload": self.payload,
238
+ "status": self.status_str,
239
+ "priority": self.priority_int,
240
+ "tentativas": self.tentativas,
241
+ "max_tentativas": self.max_tentativas,
242
+ "retry_delay": self.retry_delay,
243
+ "last_error": self.last_error,
244
+ "error_type": self.error_type,
245
+ "error_stack": self.error_stack,
246
+ "worker_id": self.worker_id,
247
+ "heartbeat": self.heartbeat,
248
+ "created_at": self.created_at,
249
+ "started_at": self.started_at,
250
+ "completed_at": self.completed_at,
251
+ "next_retry_at": self.next_retry_at,
252
+ "shard_id": self.shard_id,
253
+ "tags": self.tags,
254
+ "metadata": self.metadata,
255
+ "parent_job_id": self.parent_job_id,
256
+ "timeout_seconds": self.timeout_seconds,
257
+ "progress": self.progress,
258
+ }
259
+
260
+ @classmethod
261
+ def from_row(cls, row: dict, shard_id: int = 0) -> "Job":
262
+ """Create a Job from a database row dict."""
263
+ raw = row.get("payload")
264
+ if isinstance(raw, str):
265
+ try:
266
+ payload = json.loads(raw)
267
+ except (json.JSONDecodeError, TypeError):
268
+ payload = {"raw": raw}
269
+ else:
270
+ payload = raw or {}
271
+
272
+ tags = row.get("tags", [])
273
+ if isinstance(tags, str):
274
+ try:
275
+ tags = json.loads(tags)
276
+ except (json.JSONDecodeError, TypeError):
277
+ tags = []
278
+
279
+ metadata = row.get("metadata", {})
280
+ if isinstance(metadata, str):
281
+ try:
282
+ metadata = json.loads(metadata)
283
+ except (json.JSONDecodeError, TypeError):
284
+ metadata = {}
285
+
286
+ return cls(
287
+ id=row["id"],
288
+ pagina_id=row.get("pagina_id"),
289
+ payload=payload,
290
+ status=row.get("status", "pending"),
291
+ priority=row.get("priority", 1),
292
+ tentativas=row.get("tentativas", 0),
293
+ max_tentativas=row.get("max_tentativas", 3),
294
+ retry_delay=row.get("retry_delay", 60),
295
+ last_error=row.get("last_error"),
296
+ error_type=row.get("error_type"),
297
+ error_stack=row.get("error_stack"),
298
+ worker_id=row.get("worker_id"),
299
+ heartbeat=row.get("heartbeat"),
300
+ created_at=row.get("created_at"),
301
+ started_at=row.get("started_at"),
302
+ completed_at=row.get("completed_at"),
303
+ next_retry_at=row.get("next_retry_at"),
304
+ shard_id=shard_id,
305
+ tags=tags,
306
+ metadata=metadata,
307
+ parent_job_id=row.get("parent_job_id"),
308
+ timeout_seconds=row.get("timeout_seconds"),
309
+ progress=float(row.get("progress", 0)),
310
+ )
311
+
312
+ @classmethod
313
+ def create(
314
+ cls,
315
+ payload: Dict[str, Any],
316
+ pagina_id: Optional[int] = None,
317
+ priority: Union[JobPriority, int] = JobPriority.MEDIUM,
318
+ max_retries: int = 3,
319
+ retry_delay: int = 60,
320
+ tags: Optional[List[str]] = None,
321
+ metadata: Optional[Dict[str, Any]] = None,
322
+ parent_job_id: Optional[int] = None,
323
+ timeout_seconds: Optional[int] = None,
324
+ ) -> "Job":
325
+ """Create a new job with default values (id=0 until assigned by DB)."""
326
+ return cls(
327
+ id=0,
328
+ payload=payload,
329
+ pagina_id=pagina_id,
330
+ priority=priority,
331
+ max_tentativas=max_retries,
332
+ retry_delay=retry_delay,
333
+ tags=tags or [],
334
+ metadata=metadata or {},
335
+ parent_job_id=parent_job_id,
336
+ timeout_seconds=timeout_seconds,
337
+ )
338
+
339
+ def __repr__(self) -> str:
340
+ return f"Job(id={self.id}, status={self.status_str}, priority={self.priority_int})"
queue_max/py.typed ADDED
File without changes
@@ -0,0 +1,23 @@
1
+ """Utility modules for Robusta Queue."""
2
+
3
+ from queue_max.utils.helpers import (
4
+ backoff_delay,
5
+ determine_shard,
6
+ get_env_int,
7
+ is_retryable_error,
8
+ now_iso,
9
+ parse_iso,
10
+ validate_payload,
11
+ validate_priority,
12
+ )
13
+
14
+ __all__ = [
15
+ "now_iso",
16
+ "parse_iso",
17
+ "validate_payload",
18
+ "validate_priority",
19
+ "get_env_int",
20
+ "backoff_delay",
21
+ "determine_shard",
22
+ "is_retryable_error",
23
+ ]
@@ -0,0 +1,156 @@
1
+ """Utility helpers for Robusta Queue."""
2
+
3
+ import json
4
+ import os
5
+ import random
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Dict, Optional
8
+
9
+
10
+ def now_iso() -> str:
11
+ """Return current UTC timestamp in ISO format."""
12
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
13
+
14
+
15
+ def parse_iso(value: Optional[str]) -> Optional[datetime]:
16
+ """Parse an ISO timestamp string to datetime."""
17
+ if not value:
18
+ return None
19
+ try:
20
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
21
+ except (ValueError, AttributeError):
22
+ return None
23
+
24
+
25
+ def validate_payload(payload: Any) -> Dict[str, Any]:
26
+ """Validate and sanitize a job payload.
27
+
28
+ Args:
29
+ payload: The payload to validate.
30
+
31
+ Returns:
32
+ The validated payload as a dict.
33
+
34
+ Raises:
35
+ ValueError: If payload is not a dict, too large, or too deep.
36
+ """
37
+ if not isinstance(payload, dict):
38
+ raise ValueError(f"Payload must be a dict, got {type(payload).__name__}")
39
+
40
+ try:
41
+ raw = json.dumps(payload)
42
+ except (TypeError, ValueError) as e:
43
+ raise ValueError(f"Payload must be JSON-serializable: {e}")
44
+ if len(raw) > 10 * 1024 * 1024:
45
+ raise ValueError(f"Payload too large: {len(raw)} bytes (max 10MB)")
46
+
47
+ def _check_depth(obj, depth=0):
48
+ if depth > 20:
49
+ raise ValueError("Payload nesting exceeds 20 levels")
50
+ if isinstance(obj, dict):
51
+ for v in obj.values():
52
+ _check_depth(v, depth + 1)
53
+ elif isinstance(obj, (list, tuple)):
54
+ for v in obj:
55
+ _check_depth(v, depth + 1)
56
+
57
+ _check_depth(payload)
58
+ return payload
59
+
60
+
61
+ def validate_priority(priority: int) -> int:
62
+ """Validate priority value.
63
+
64
+ Args:
65
+ priority: Priority value (0, 1, or 2).
66
+
67
+ Returns:
68
+ The validated priority.
69
+
70
+ Raises:
71
+ ValueError: If priority is not 0, 1, or 2.
72
+ """
73
+ if priority not in (0, 1, 2):
74
+ raise ValueError(f"Priority must be 0, 1, or 2, got {priority}")
75
+ return priority
76
+
77
+
78
+ def get_env_int(name: str, default: int) -> int:
79
+ """Get an integer from an environment variable with a fallback default."""
80
+ try:
81
+ return int(os.environ.get(name, default))
82
+ except (ValueError, TypeError):
83
+ return default
84
+
85
+
86
+ def backoff_delay(tentativa: int, base_delay: int = 60, max_delay: int = 3600) -> float:
87
+ """Calculate exponential backoff delay with jitter.
88
+
89
+ Formula: delay = base * 2^(tentativa - 1)
90
+ Jitter: +-20%
91
+ Cap: max_delay (default 3600s = 1 hour)
92
+
93
+ Args:
94
+ tentativa: Current attempt number (1-based).
95
+ base_delay: Base delay in seconds (default: 60).
96
+ max_delay: Maximum delay in seconds (default: 3600).
97
+
98
+ Returns:
99
+ Delay in seconds with jitter applied.
100
+ """
101
+ delay = base_delay * (2 ** (tentativa - 1))
102
+ jitter = delay * 0.2
103
+ delay = delay + random.uniform(-jitter, jitter)
104
+ return min(delay, max_delay)
105
+
106
+
107
+ def determine_shard(
108
+ pagina_id: Optional[int],
109
+ num_shards: int,
110
+ ) -> int:
111
+ """Determine which shard a job should go to.
112
+
113
+ If pagina_id is provided, shard = pagina_id % num_shards for consistency.
114
+ Otherwise, a random shard is chosen.
115
+
116
+ Args:
117
+ pagina_id: Optional ID for consistent sharding.
118
+ num_shards: Total number of shards.
119
+
120
+ Returns:
121
+ Shard ID (0 to num_shards - 1).
122
+ """
123
+ if pagina_id is not None:
124
+ return pagina_id % num_shards
125
+ return random.randint(0, num_shards - 1)
126
+
127
+
128
+ def is_retryable_error(error: Exception) -> bool:
129
+ """Determine if an error should trigger a retry.
130
+
131
+ 4xx errors (client) are permanent failures.
132
+ 5xx errors (server), 429 (rate limit), timeouts, and connection errors are retryable.
133
+
134
+ Args:
135
+ error: The exception to evaluate.
136
+
137
+ Returns:
138
+ True if the job should be retried, False for permanent failure.
139
+ """
140
+ import re
141
+
142
+ error_str = str(error).lower()
143
+ error_type = type(error).__name__.lower()
144
+
145
+ # 4xx status codes (except 429) are permanent client errors
146
+ http_code_match = re.search(r"\b(4\d\d)\b", error_str)
147
+ if http_code_match and http_code_match.group(1) != "429":
148
+ return False
149
+
150
+ # Connection, timeout, rate-limit, and server errors are retryable
151
+ retryable_keywords = ["timeout", "connection", "temporary", "retryable", "500", "502", "503", "429"]
152
+ for keyword in retryable_keywords:
153
+ if keyword in error_str or keyword in error_type:
154
+ return True
155
+
156
+ return True