baqueue 1.2.0__tar.gz → 1.2.1__tar.gz

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.
Files changed (38) hide show
  1. {baqueue-1.2.0/baqueue.egg-info → baqueue-1.2.1}/PKG-INFO +1 -1
  2. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/__init__.py +1 -1
  3. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/drivers/redis_driver.py +107 -40
  4. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/worker.py +11 -2
  5. {baqueue-1.2.0 → baqueue-1.2.1/baqueue.egg-info}/PKG-INFO +1 -1
  6. {baqueue-1.2.0 → baqueue-1.2.1}/LICENSE +0 -0
  7. {baqueue-1.2.0 → baqueue-1.2.1}/MANIFEST.in +0 -0
  8. {baqueue-1.2.0 → baqueue-1.2.1}/README.md +0 -0
  9. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/balancer.py +0 -0
  10. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/batch.py +0 -0
  11. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/cli.py +0 -0
  12. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/config.py +0 -0
  13. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/dashboard/__init__.py +0 -0
  14. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/dashboard/api.py +0 -0
  15. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/dashboard/server.py +0 -0
  16. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/dashboard/static/app.js +0 -0
  17. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/dashboard/static/index.html +0 -0
  18. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/dashboard/static/style.css +0 -0
  19. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/drivers/__init__.py +0 -0
  20. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/drivers/base.py +0 -0
  21. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/drivers/memory_driver.py +0 -0
  22. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/drivers/postgres_driver.py +0 -0
  23. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/drivers/sqlite_driver.py +0 -0
  24. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/events.py +0 -0
  25. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/job.py +0 -0
  26. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/pruner.py +0 -0
  27. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/queue.py +0 -0
  28. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/retry.py +0 -0
  29. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/scheduler.py +0 -0
  30. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/serializer.py +0 -0
  31. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue/supervisor.py +0 -0
  32. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue.egg-info/SOURCES.txt +0 -0
  33. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue.egg-info/dependency_links.txt +0 -0
  34. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue.egg-info/entry_points.txt +0 -0
  35. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue.egg-info/requires.txt +0 -0
  36. {baqueue-1.2.0 → baqueue-1.2.1}/baqueue.egg-info/top_level.txt +0 -0
  37. {baqueue-1.2.0 → baqueue-1.2.1}/pyproject.toml +0 -0
  38. {baqueue-1.2.0 → baqueue-1.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: baqueue
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: A powerful Python queue management package inspired by Laravel Horizon
5
5
  Author: Basalam, BaQueue Contributors
6
6
  License: MIT
@@ -7,7 +7,7 @@ from baqueue.batch import Batch
7
7
  from baqueue.events import EventBus
8
8
  from baqueue.retry import BackoffStrategy
9
9
 
10
- __version__ = "1.2.0"
10
+ __version__ = "1.2.1"
11
11
 
12
12
  __all__ = [
13
13
  "BaQueueConfig",
@@ -15,6 +15,7 @@ logger = logging.getLogger("baqueue.redis")
15
15
  # the job hash is gone, so we can't read its status — we ZREM from every global
16
16
  # status index to be sure the stale id is cleared.
17
17
  _ALL_STATUSES = ("pending", "processing", "completed", "failed", "cancelled")
18
+ _MAX_STALE_POP_ATTEMPTS = 100
18
19
 
19
20
 
20
21
  class RedisDriver(BaseDriver):
@@ -202,28 +203,110 @@ class RedisDriver(BaseDriver):
202
203
  await self._with_disk_full_recovery(_do)
203
204
  return ids
204
205
 
206
+ async def _claim_pending_job(self, job_id: str) -> JobPayload | None:
207
+ from redis.exceptions import WatchError
208
+
209
+ job_key = self._key("job", job_id)
210
+
211
+ async def _attempt() -> JobPayload | None:
212
+ pipe = self._redis.pipeline()
213
+ try:
214
+ await pipe.watch(job_key)
215
+ raw = await pipe.hget(job_key, "data")
216
+ if not raw:
217
+ return None
218
+
219
+ payload = JobPayload.from_json(raw)
220
+ if payload.status != "pending":
221
+ return None
222
+ if payload.delay_until is not None and payload.delay_until > _now_ts():
223
+ return None
224
+
225
+ payload.status = "processing"
226
+ now = _now_ts()
227
+ payload.started_at = now
228
+ payload.updated_at = now
229
+ payload.attempts += 1
230
+
231
+ pipe.multi()
232
+ pipe.hset(job_key, mapping={"data": payload.to_json()})
233
+ self._index_status_change(
234
+ pipe,
235
+ payload.id,
236
+ payload.queue,
237
+ "pending",
238
+ "processing",
239
+ payload.created_at,
240
+ )
241
+ await pipe.execute()
242
+ return payload
243
+ finally:
244
+ await pipe.reset()
245
+
246
+ for _ in range(5):
247
+ try:
248
+ return await self._with_disk_full_recovery(_attempt)
249
+ except WatchError:
250
+ continue
251
+ return None
252
+
205
253
  async def pop(self, queue: str) -> JobPayload | None:
206
- job_id = await self._redis.lpop(self._key("queue", queue))
207
- if not job_id:
208
- return None
209
- raw = await self._redis.hget(self._key("job", job_id), "data")
210
- if not raw:
211
- return None
212
- payload = JobPayload.from_json(raw)
213
- old_status = payload.status
214
- payload.status = "processing"
215
- now = _now_ts()
216
- payload.started_at = now
217
- payload.updated_at = now
218
- payload.attempts += 1
254
+ for _ in range(_MAX_STALE_POP_ATTEMPTS):
255
+ job_id = await self._redis.lpop(self._key("queue", queue))
256
+ if not job_id:
257
+ return None
258
+ payload = await self._claim_pending_job(job_id)
259
+ if payload is not None:
260
+ return payload
261
+ return None
262
+
263
+ async def _promote_delayed_job(self, job_id: str, now: float) -> JobPayload | None:
264
+ from redis.exceptions import WatchError
219
265
 
220
- async def _do():
266
+ job_key = self._key("job", job_id)
267
+
268
+ async def _attempt() -> JobPayload | None:
221
269
  pipe = self._redis.pipeline()
222
- pipe.hset(self._key("job", payload.id), mapping={"data": payload.to_json()})
223
- self._index_status_change(pipe, payload.id, payload.queue, old_status, payload.status, payload.created_at)
224
- await pipe.execute()
225
- await self._with_disk_full_recovery(_do)
226
- return payload
270
+ try:
271
+ await pipe.watch(job_key)
272
+ raw = await pipe.hget(job_key, "data")
273
+ if not raw:
274
+ pipe.multi()
275
+ pipe.zrem(self._key("delayed"), job_id)
276
+ await pipe.execute()
277
+ return None
278
+
279
+ payload = JobPayload.from_json(raw)
280
+ if payload.status != "pending" or payload.delay_until is None:
281
+ pipe.multi()
282
+ pipe.zrem(self._key("delayed"), job_id)
283
+ await pipe.execute()
284
+ return None
285
+ if payload.delay_until > now:
286
+ pipe.multi()
287
+ pipe.zadd(self._key("delayed"), {job_id: payload.delay_until})
288
+ await pipe.execute()
289
+ return None
290
+
291
+ payload.delay_until = None
292
+ payload.updated_at = now
293
+
294
+ pipe.multi()
295
+ pipe.hset(job_key, mapping={"data": payload.to_json()})
296
+ pipe.zrem(self._key("delayed"), job_id)
297
+ pipe.lrem(self._key("queue", payload.queue), 0, job_id)
298
+ pipe.rpush(self._key("queue", payload.queue), job_id)
299
+ await pipe.execute()
300
+ return payload
301
+ finally:
302
+ await pipe.reset()
303
+
304
+ for _ in range(5):
305
+ try:
306
+ return await self._with_disk_full_recovery(_attempt)
307
+ except WatchError:
308
+ continue
309
+ return None
227
310
 
228
311
  async def pop_delayed(self) -> list[JobPayload]:
229
312
  now = _now_ts()
@@ -231,27 +314,10 @@ class RedisDriver(BaseDriver):
231
314
  if not job_ids:
232
315
  return []
233
316
 
234
- async def _remove_delayed():
235
- pipe = self._redis.pipeline()
236
- for job_id in job_ids:
237
- pipe.zrem(self._key("delayed"), job_id)
238
- await pipe.execute()
239
- await self._with_disk_full_recovery(_remove_delayed)
240
-
241
317
  moved: list[JobPayload] = []
242
318
  for job_id in job_ids:
243
- raw = await self._redis.hget(self._key("job", job_id), "data")
244
- if raw:
245
- payload = JobPayload.from_json(raw)
246
- payload.delay_until = None
247
- payload.updated_at = _now_ts()
248
-
249
- async def _move(payload=payload):
250
- pipe = self._redis.pipeline()
251
- pipe.hset(self._key("job", payload.id), mapping={"data": payload.to_json()})
252
- pipe.rpush(self._key("queue", payload.queue), payload.id)
253
- await pipe.execute()
254
- await self._with_disk_full_recovery(_move)
319
+ payload = await self._promote_delayed_job(job_id, now)
320
+ if payload is not None:
255
321
  moved.append(payload)
256
322
  return moved
257
323
 
@@ -285,6 +351,7 @@ class RedisDriver(BaseDriver):
285
351
 
286
352
  async def _do():
287
353
  pipe = self._redis.pipeline()
354
+ pipe.lrem(self._key("queue", payload.queue), 0, payload.id)
288
355
  if delay > 0:
289
356
  payload.delay_until = _now_ts() + delay
290
357
  pipe.hset(self._key("job", payload.id), mapping={"data": payload.to_json()})
@@ -402,8 +469,8 @@ class RedisDriver(BaseDriver):
402
469
  now = _now_ts()
403
470
  # Only a job actually sitting in the delayed ZSET needs to be moved into
404
471
  # its ready list. A pending job that is already ready (delay_until None or
405
- # in the past) must NOT be re-pushed, or Redis pop — which does not
406
- # re-check status — would process it twice.
472
+ # in the past) must NOT be re-pushed, or it would leave duplicate ready
473
+ # entries that every worker has to discard later.
407
474
  was_scheduled = payload.delay_until is not None and payload.delay_until > now
408
475
  payload.delay_until = None
409
476
  payload.updated_at = now
@@ -44,6 +44,7 @@ class Worker:
44
44
  self._current_job: JobPayload | None = None
45
45
  self._jobs_processed = 0
46
46
  self._jobs_failed = 0
47
+ self._queue_cursor = 0
47
48
 
48
49
  @property
49
50
  def is_running(self) -> bool:
@@ -84,10 +85,18 @@ class Worker:
84
85
  self._running = False
85
86
 
86
87
  async def _fetch_next(self) -> JobPayload | None:
87
- for queue in self.queues:
88
- job = await self.driver.pop(queue)
88
+ queue_count = len(self.queues)
89
+ if queue_count == 0:
90
+ return None
91
+
92
+ for offset in range(queue_count):
93
+ index = (self._queue_cursor + offset) % queue_count
94
+ job = await self.driver.pop(self.queues[index])
89
95
  if job:
96
+ self._queue_cursor = (index + 1) % queue_count
90
97
  return job
98
+
99
+ self._queue_cursor = (self._queue_cursor + 1) % queue_count
91
100
  return None
92
101
 
93
102
  @staticmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: baqueue
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: A powerful Python queue management package inspired by Laravel Horizon
5
5
  Author: Basalam, BaQueue Contributors
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes