rrq 0.5.0__py3-none-any.whl → 0.7.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.
rrq/store.py CHANGED
@@ -4,12 +4,14 @@ with the Redis backend for storing and managing RRQ job data and queues.
4
4
 
5
5
  import json
6
6
  import logging
7
- from datetime import UTC, datetime
7
+ from datetime import timezone, datetime, timedelta
8
8
  from typing import Any, Optional
9
9
 
10
10
  from redis.asyncio import Redis as AsyncRedis
11
+ from redis.exceptions import RedisError
11
12
 
12
13
  from .constants import (
14
+ CONNECTION_POOL_MAX_CONNECTIONS,
13
15
  DEFAULT_DLQ_RESULT_TTL_SECONDS,
14
16
  JOB_KEY_PREFIX,
15
17
  LOCK_KEY_PREFIX,
@@ -27,6 +29,11 @@ class JobStore:
27
29
 
28
30
  Handles serialization/deserialization, key management, and atomic operations
29
31
  related to jobs, queues, locks, and worker health.
32
+
33
+ Transaction Usage Guidelines:
34
+ - Use transaction=True for write operations that must be atomic (job updates, DLQ moves)
35
+ - Use transaction=False for read-only batch operations (health checks, queue size queries)
36
+ - All async context managers (async with) properly handle cleanup even on exceptions
30
37
  """
31
38
 
32
39
  def __init__(self, settings: RRQSettings):
@@ -37,8 +44,13 @@ class JobStore:
37
44
  """
38
45
  self.settings = settings
39
46
  self.redis = AsyncRedis.from_url(
40
- settings.redis_dsn, decode_responses=False
41
- ) # Work with bytes initially
47
+ settings.redis_dsn,
48
+ decode_responses=False,
49
+ max_connections=CONNECTION_POOL_MAX_CONNECTIONS,
50
+ retry_on_timeout=True,
51
+ socket_keepalive=True,
52
+ socket_keepalive_options={},
53
+ )
42
54
 
43
55
  # LUA scripts for atomic operations
44
56
  self._atomic_lock_and_remove_script = """
@@ -87,37 +99,6 @@ class JobStore:
87
99
  """Closes the Redis connection pool associated with this store."""
88
100
  await self.redis.aclose()
89
101
 
90
- async def _serialize_job_field(self, value: Any) -> bytes:
91
- """Serializes a single field value for storing in a Redis hash."""
92
- # Pydantic models are dumped to dict, then JSON string, then bytes.
93
- # Basic types are JSON dumped directly.
94
- if hasattr(value, "model_dump_json"): # For Pydantic sub-models if any
95
- return value.model_dump_json().encode("utf-8")
96
- if isinstance(value, dict | list) or (
97
- hasattr(value, "__dict__") and not callable(value)
98
- ):
99
- # Fallback for other dict-like or list-like objects, and simple custom objects
100
- try:
101
- # Use Pydantic-aware JSON dumping if possible
102
- if hasattr(value, "model_dump"):
103
- value = value.model_dump(mode="json")
104
- return json.dumps(value, default=str).encode(
105
- "utf-8"
106
- ) # default=str for datetimes etc.
107
- except TypeError:
108
- return str(value).encode("utf-8") # Last resort
109
- return str(value).encode("utf-8") # For simple types like int, str, bool
110
-
111
- async def _deserialize_job_field(self, value_bytes: bytes) -> Any:
112
- """Deserializes a single field value from Redis bytes."""
113
- try:
114
- # Attempt to parse as JSON first, as most complex types will be stored this way.
115
- return json.loads(value_bytes.decode("utf-8"))
116
- except (json.JSONDecodeError, UnicodeDecodeError):
117
- # If it fails, it might be a simple string that wasn't JSON encoded (e.g. status enums)
118
- # or a raw byte representation that needs specific handling (not covered here yet)
119
- return value_bytes.decode("utf-8") # Fallback to string
120
-
121
102
  async def save_job_definition(self, job: Job) -> None:
122
103
  """Saves the complete job definition as a Redis hash.
123
104
 
@@ -241,6 +222,29 @@ class JobStore:
241
222
  )
242
223
  return None
243
224
 
225
+ async def get_job_data_dict(self, job_id: str) -> Optional[dict[str, str]]:
226
+ """Retrieves raw job data from Redis as a decoded dictionary.
227
+
228
+ This method provides a lightweight way to get job data for CLI commands
229
+ without the overhead of full Job object reconstruction and validation.
230
+
231
+ Args:
232
+ job_id: The unique ID of the job to retrieve.
233
+
234
+ Returns:
235
+ Dict with decoded string keys and values, or None if job not found.
236
+ """
237
+ job_key = f"{JOB_KEY_PREFIX}{job_id}"
238
+ job_data_raw_bytes = await self.redis.hgetall(job_key)
239
+
240
+ if not job_data_raw_bytes:
241
+ return None
242
+
243
+ # Decode all keys and values from bytes to str
244
+ return {
245
+ k.decode("utf-8"): v.decode("utf-8") for k, v in job_data_raw_bytes.items()
246
+ }
247
+
244
248
  async def add_job_to_queue(
245
249
  self, queue_name: str, job_id: str, score: float
246
250
  ) -> None:
@@ -290,7 +294,7 @@ class JobStore:
290
294
  if count <= 0:
291
295
  return []
292
296
  queue_key = self._format_queue_key(queue_name)
293
- now_ms = int(datetime.now(UTC).timestamp() * 1000)
297
+ now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
294
298
  # Fetch jobs with score from -inf up to current time, limit by count
295
299
  job_ids_bytes = await self.redis.zrangebyscore(
296
300
  queue_key, min=float("-inf"), max=float(now_ms), start=0, num=count
@@ -481,13 +485,22 @@ class JobStore:
481
485
  "completion_time": completion_time.isoformat().encode("utf-8"),
482
486
  }
483
487
 
484
- # Use pipeline for atomicity
488
+ # Use pipeline with transaction=True for atomic write operations
489
+ # This ensures all commands succeed or none do (ACID properties)
485
490
  async with self.redis.pipeline(transaction=True) as pipe:
486
- pipe.hset(job_key, mapping=update_data)
487
- pipe.lpush(dlq_redis_key, job_id.encode("utf-8"))
488
- pipe.expire(job_key, DEFAULT_DLQ_RESULT_TTL_SECONDS)
489
- results = await pipe.execute()
490
- logger.info(f"Moved job {job_id} to DLQ '{dlq_redis_key}'. Results: {results}")
491
+ try:
492
+ pipe.hset(job_key, mapping=update_data)
493
+ pipe.lpush(dlq_redis_key, job_id.encode("utf-8"))
494
+ pipe.expire(job_key, DEFAULT_DLQ_RESULT_TTL_SECONDS)
495
+ results = await pipe.execute()
496
+ logger.info(
497
+ f"Moved job {job_id} to DLQ '{dlq_redis_key}'. Results: {results}"
498
+ )
499
+ except RedisError as e:
500
+ logger.error(
501
+ f"Failed to move job {job_id} to DLQ '{dlq_redis_key}': {e}"
502
+ )
503
+ raise
491
504
 
492
505
  async def requeue_dlq(
493
506
  self,
@@ -516,7 +529,7 @@ class JobStore:
516
529
  break
517
530
  job_id = job_id_bytes.decode("utf-8")
518
531
  # Use current time for re-enqueue score
519
- now_ms = int(datetime.now(UTC).timestamp() * 1000)
532
+ now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
520
533
  await self.add_job_to_queue(
521
534
  self._format_queue_key(target_queue),
522
535
  job_id,
@@ -624,7 +637,7 @@ class JobStore:
624
637
  0 means persist indefinitely. < 0 means leave existing TTL.
625
638
  """
626
639
  job_key = f"{JOB_KEY_PREFIX}{job_id}"
627
- completion_time = datetime.now(UTC)
640
+ completion_time = datetime.now(timezone.utc)
628
641
 
629
642
  # Serialize result to JSON string
630
643
  try:
@@ -646,17 +659,22 @@ class JobStore:
646
659
  "status": JobStatus.COMPLETED.value.encode("utf-8"),
647
660
  }
648
661
 
649
- # Use pipeline for atomicity of update + expire
662
+ # Use pipeline with transaction=True to atomically update and set TTL
663
+ # This prevents partial updates where result is saved but TTL isn't set
650
664
  async with self.redis.pipeline(transaction=True) as pipe:
651
- pipe.hset(job_key, mapping=update_data)
652
- if ttl_seconds > 0:
653
- pipe.expire(job_key, ttl_seconds)
654
- elif ttl_seconds == 0:
655
- pipe.persist(job_key)
656
- results = await pipe.execute()
657
- logger.debug(
658
- f"Saved result for job {job_id}. Status set to COMPLETED. TTL={ttl_seconds}. Results: {results}"
659
- )
665
+ try:
666
+ pipe.hset(job_key, mapping=update_data)
667
+ if ttl_seconds > 0:
668
+ pipe.expire(job_key, ttl_seconds)
669
+ elif ttl_seconds == 0:
670
+ pipe.persist(job_key)
671
+ results = await pipe.execute()
672
+ logger.debug(
673
+ f"Saved result for job {job_id}. Status set to COMPLETED. TTL={ttl_seconds}. Results: {results}"
674
+ )
675
+ except RedisError as e:
676
+ logger.error(f"Failed to save result for job {job_id}: {e}")
677
+ raise
660
678
 
661
679
  async def set_worker_health(
662
680
  self, worker_id: str, data: dict[str, Any], ttl_seconds: int
@@ -692,6 +710,8 @@ class JobStore:
692
710
  """
693
711
  health_key = f"rrq:health:worker:{worker_id}"
694
712
 
713
+ # Use pipeline with transaction=False for read-only batch operations
714
+ # No atomicity needed as we're only reading, this improves performance
695
715
  async with self.redis.pipeline(transaction=False) as pipe:
696
716
  pipe.get(health_key)
697
717
  pipe.ttl(health_key)
@@ -721,3 +741,141 @@ class JobStore:
721
741
  f"Retrieved health data for worker {worker_id}: TTL={final_ttl}, Data keys={list(health_data.keys()) if health_data else None}"
722
742
  )
723
743
  return health_data, final_ttl
744
+
745
+ async def get_job(self, job_id: str) -> Optional[dict[str, Any]]:
746
+ """Get simplified job data for monitoring/CLI purposes.
747
+
748
+ Returns a dictionary with basic job information, or None if job not found.
749
+ This is more lightweight than get_job_definition which returns full Job objects.
750
+ """
751
+ job_key = f"{JOB_KEY_PREFIX}{job_id}"
752
+ job_data = await self.redis.hgetall(job_key)
753
+
754
+ if not job_data:
755
+ return None
756
+
757
+ # Convert bytes to strings and return simplified dict
758
+ return {k.decode("utf-8"): v.decode("utf-8") for k, v in job_data.items()}
759
+
760
+ # Hybrid monitoring optimization methods
761
+ async def register_active_queue(self, queue_name: str) -> None:
762
+ """Register a queue as active in the monitoring registry"""
763
+ from .constants import ACTIVE_QUEUES_SET
764
+
765
+ timestamp = datetime.now(timezone.utc).timestamp()
766
+ await self.redis.zadd(ACTIVE_QUEUES_SET, {queue_name: timestamp})
767
+
768
+ async def register_active_worker(self, worker_id: str) -> None:
769
+ """Register a worker as active in the monitoring registry"""
770
+ from .constants import ACTIVE_WORKERS_SET
771
+
772
+ timestamp = datetime.now(timezone.utc).timestamp()
773
+ await self.redis.zadd(ACTIVE_WORKERS_SET, {worker_id: timestamp})
774
+
775
+ async def get_active_queues(self, max_age_seconds: int = 300) -> list[str]:
776
+ """Get list of recently active queues"""
777
+ from .constants import ACTIVE_QUEUES_SET
778
+
779
+ cutoff_time = datetime.now(timezone.utc).timestamp() - max_age_seconds
780
+
781
+ # Remove stale entries and get active ones
782
+ await self.redis.zremrangebyscore(ACTIVE_QUEUES_SET, 0, cutoff_time)
783
+ active_queues = await self.redis.zrange(ACTIVE_QUEUES_SET, 0, -1)
784
+
785
+ return [q.decode("utf-8") if isinstance(q, bytes) else q for q in active_queues]
786
+
787
+ async def get_active_workers(self, max_age_seconds: int = 60) -> list[str]:
788
+ """Get list of recently active workers"""
789
+ from .constants import ACTIVE_WORKERS_SET
790
+
791
+ cutoff_time = datetime.now(timezone.utc).timestamp() - max_age_seconds
792
+
793
+ # Remove stale entries and get active ones
794
+ await self.redis.zremrangebyscore(ACTIVE_WORKERS_SET, 0, cutoff_time)
795
+ active_workers = await self.redis.zrange(ACTIVE_WORKERS_SET, 0, -1)
796
+
797
+ return [
798
+ w.decode("utf-8") if isinstance(w, bytes) else w for w in active_workers
799
+ ]
800
+
801
+ async def publish_monitor_event(self, event_type: str, data: dict) -> None:
802
+ """Publish a monitoring event to the Redis stream"""
803
+ from .constants import MONITOR_EVENTS_STREAM
804
+
805
+ event_data = {
806
+ "event_type": event_type,
807
+ "timestamp": datetime.now(timezone.utc).timestamp(),
808
+ **data,
809
+ }
810
+
811
+ # Add to stream with max length to prevent unbounded growth
812
+ await self.redis.xadd(
813
+ MONITOR_EVENTS_STREAM, event_data, maxlen=1000, approximate=True
814
+ )
815
+
816
+ async def consume_monitor_events(
817
+ self, last_id: str = "0", count: int = 100, block: int = 50
818
+ ) -> list:
819
+ """Consume monitoring events from Redis stream"""
820
+ from .constants import MONITOR_EVENTS_STREAM
821
+
822
+ try:
823
+ events = await self.redis.xread(
824
+ {MONITOR_EVENTS_STREAM: last_id}, count=count, block=block
825
+ )
826
+ return events
827
+ except Exception:
828
+ # Handle timeout or other Redis errors gracefully
829
+ return []
830
+
831
+ async def get_lock_ttl(self, unique_key: str) -> int:
832
+ lock_key = f"{UNIQUE_JOB_LOCK_PREFIX}{unique_key}"
833
+ ttl = await self.redis.ttl(lock_key)
834
+ try:
835
+ ttl_int = int(ttl)
836
+ except (TypeError, ValueError):
837
+ ttl_int = 0
838
+ return ttl_int if ttl_int and ttl_int > 0 else 0
839
+
840
+ async def get_last_process_time(self, unique_key: str) -> Optional[datetime]:
841
+ key = f"last_process:{unique_key}"
842
+ timestamp = await self.redis.get(key)
843
+ return datetime.fromtimestamp(float(timestamp), timezone.utc) if timestamp else None
844
+
845
+ async def set_last_process_time(self, unique_key: str, timestamp: datetime) -> None:
846
+ key = f"last_process:{unique_key}"
847
+ # Add TTL to auto-expire the marker; independent of app specifics
848
+ ttl_seconds = max(60, int(self.settings.expected_job_ttl) * 2)
849
+ await self.redis.set(key, timestamp.timestamp(), ex=ttl_seconds)
850
+
851
+ async def get_unique_lock_holder(self, unique_key: str) -> Optional[str]:
852
+ """Return the job_id currently holding the unique lock, if any."""
853
+ lock_key = f"{UNIQUE_JOB_LOCK_PREFIX}{unique_key}"
854
+ value = await self.redis.get(lock_key)
855
+ return value.decode("utf-8") if value else None
856
+
857
+ async def defer_job(self, job: Job, defer_by: timedelta) -> None:
858
+ target_queue = job.queue_name or self.settings.default_queue_name
859
+ queue_key = self._format_queue_key(target_queue)
860
+ # Use milliseconds since epoch to be consistent with queue scores
861
+ score_ms = int((datetime.now(timezone.utc) + defer_by).timestamp() * 1000)
862
+ await self.redis.zadd(queue_key, {job.id.encode("utf-8"): float(score_ms)})
863
+ # Note: job was already removed from queue during acquisition.
864
+
865
+ async def batch_get_queue_sizes(self, queue_names: list[str]) -> dict[str, int]:
866
+ """Efficiently get sizes for multiple queues using pipeline"""
867
+ from .constants import QUEUE_KEY_PREFIX
868
+
869
+ if not queue_names:
870
+ return {}
871
+
872
+ # Use pipeline with transaction=False for read-only batch operations
873
+ # No atomicity needed as we're only reading, this improves performance
874
+ async with self.redis.pipeline(transaction=False) as pipe:
875
+ for queue_name in queue_names:
876
+ queue_key = f"{QUEUE_KEY_PREFIX}{queue_name}"
877
+ pipe.zcard(queue_key)
878
+
879
+ sizes = await pipe.execute()
880
+
881
+ return dict(zip(queue_names, sizes))
rrq/worker.py CHANGED
@@ -12,7 +12,7 @@ import signal
12
12
  import time
13
13
  import uuid
14
14
  from contextlib import suppress
15
- from datetime import UTC, datetime
15
+ from datetime import timezone, datetime
16
16
  from typing import (
17
17
  Any,
18
18
  Optional,
@@ -661,7 +661,7 @@ class RRQWorker:
661
661
  """Moves a job to the Dead Letter Queue (DLQ) and releases its unique lock if present."""
662
662
 
663
663
  dlq_name = self.settings.default_dlq_name # Or derive from original queue_name
664
- completion_time = datetime.now(UTC)
664
+ completion_time = datetime.now(timezone.utc)
665
665
  try:
666
666
  await self.job_store.move_job_to_dlq(
667
667
  job_id=job.id,
@@ -809,7 +809,7 @@ class RRQWorker:
809
809
  try:
810
810
  health_data = {
811
811
  "worker_id": self.worker_id,
812
- "timestamp": datetime.now(UTC).isoformat(),
812
+ "timestamp": datetime.now(timezone.utc).isoformat(),
813
813
  "status": self.status,
814
814
  "active_jobs": len(self._running_tasks),
815
815
  "concurrency_limit": self.settings.worker_concurrency,
@@ -855,7 +855,7 @@ class RRQWorker:
855
855
 
856
856
  async def _maybe_enqueue_cron_jobs(self) -> None:
857
857
  """Enqueue cron jobs that are due to run."""
858
- now = datetime.now(UTC)
858
+ now = datetime.now(timezone.utc)
859
859
  for cj in self.cron_jobs:
860
860
  if cj.due(now):
861
861
  unique_key = f"cron:{cj.function_name}" if cj.unique else None
@@ -974,7 +974,7 @@ class RRQWorker:
974
974
  )
975
975
  try:
976
976
  job.status = JobStatus.PENDING
977
- job.next_scheduled_run_time = datetime.now(UTC) # Re-queue immediately
977
+ job.next_scheduled_run_time = datetime.now(timezone.utc) # Re-queue immediately
978
978
  job.last_error = "Job execution interrupted by worker shutdown. Re-queued."
979
979
  # Do not increment retries for shutdown interruption
980
980
 
@@ -995,7 +995,7 @@ class RRQWorker:
995
995
  job.id,
996
996
  self.settings.default_dlq_name,
997
997
  f"Failed to re-queue during cancellation: {e_requeue}",
998
- datetime.now(UTC),
998
+ datetime.now(timezone.utc),
999
999
  )
1000
1000
  logger.info(
1001
1001
  f"Successfully moved job {job.id} to DLQ due to re-queueing failure."