rrq 0.5.0__py3-none-any.whl → 0.7.1__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/cli.py +39 -64
- rrq/cli_commands/__init__.py +1 -0
- rrq/cli_commands/base.py +102 -0
- rrq/cli_commands/commands/__init__.py +1 -0
- rrq/cli_commands/commands/debug.py +551 -0
- rrq/cli_commands/commands/dlq.py +853 -0
- rrq/cli_commands/commands/jobs.py +516 -0
- rrq/cli_commands/commands/monitor.py +776 -0
- rrq/cli_commands/commands/queues.py +539 -0
- rrq/cli_commands/utils.py +161 -0
- rrq/client.py +39 -35
- rrq/constants.py +10 -0
- rrq/cron.py +67 -8
- rrq/hooks.py +217 -0
- rrq/job.py +5 -5
- rrq/registry.py +0 -3
- rrq/settings.py +13 -1
- rrq/store.py +211 -53
- rrq/worker.py +6 -6
- {rrq-0.5.0.dist-info → rrq-0.7.1.dist-info}/METADATA +209 -25
- rrq-0.7.1.dist-info/RECORD +26 -0
- {rrq-0.5.0.dist-info → rrq-0.7.1.dist-info}/WHEEL +1 -1
- rrq-0.5.0.dist-info/RECORD +0 -16
- {rrq-0.5.0.dist-info → rrq-0.7.1.dist-info}/entry_points.txt +0 -0
- {rrq-0.5.0.dist-info → rrq-0.7.1.dist-info}/licenses/LICENSE +0 -0
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
|
|
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,
|
|
41
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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."
|