toro-queue 0.5.0__tar.gz → 0.5.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 (77) hide show
  1. {toro_queue-0.5.0 → toro_queue-0.5.1}/PKG-INFO +2 -2
  2. {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/processing.md +5 -1
  3. {toro_queue-0.5.0 → toro_queue-0.5.1}/pyproject.toml +1 -1
  4. toro_queue-0.5.1/tests/integration/test_shutdown.py +39 -0
  5. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_worker_resilience.py +19 -0
  6. toro_queue-0.5.1/tests/unit/test_worker_connection.py +58 -0
  7. {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/__init__.py +1 -1
  8. {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/connection.py +28 -1
  9. {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/worker.py +32 -5
  10. {toro_queue-0.5.0 → toro_queue-0.5.1}/uv.lock +1 -1
  11. {toro_queue-0.5.0 → toro_queue-0.5.1}/.github/workflows/pr-check.yaml +0 -0
  12. {toro_queue-0.5.0 → toro_queue-0.5.1}/.github/workflows/release.yml +0 -0
  13. {toro_queue-0.5.0 → toro_queue-0.5.1}/.gitignore +0 -0
  14. {toro_queue-0.5.0 → toro_queue-0.5.1}/.pre-commit-config.yaml +0 -0
  15. {toro_queue-0.5.0 → toro_queue-0.5.1}/.vscode/extensions.json +0 -0
  16. {toro_queue-0.5.0 → toro_queue-0.5.1}/.vscode/settings.json +0 -0
  17. {toro_queue-0.5.0 → toro_queue-0.5.1}/LICENSE +0 -0
  18. {toro_queue-0.5.0 → toro_queue-0.5.1}/README.md +0 -0
  19. {toro_queue-0.5.0 → toro_queue-0.5.1}/bench/bench.py +0 -0
  20. {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/architecture.md +0 -0
  21. {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/concepts.md +0 -0
  22. {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/data-model.md +0 -0
  23. {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/flows-design.md +0 -0
  24. {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/flows.md +0 -0
  25. {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/index.md +0 -0
  26. {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/producing.md +0 -0
  27. {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/reliability.md +0 -0
  28. {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/scheduling.md +0 -0
  29. {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/security.md +0 -0
  30. {toro_queue-0.5.0 → toro_queue-0.5.1}/examples/README.md +0 -0
  31. {toro_queue-0.5.0 → toro_queue-0.5.1}/examples/basic.py +0 -0
  32. {toro_queue-0.5.0 → toro_queue-0.5.1}/examples/stalled.py +0 -0
  33. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/conftest.py +0 -0
  34. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_admin.py +0 -0
  35. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_admin_ordering.py +0 -0
  36. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_concurrency_stress.py +0 -0
  37. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_connection.py +0 -0
  38. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_fault_injection.py +0 -0
  39. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_finished_retention.py +0 -0
  40. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_flows.py +0 -0
  41. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_flows_edges.py +0 -0
  42. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_introspection.py +0 -0
  43. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_invariants.py +0 -0
  44. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_leaks.py +0 -0
  45. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_metrics.py +0 -0
  46. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_processing.py +0 -0
  47. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_reliability.py +0 -0
  48. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_result_dispatcher.py +0 -0
  49. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_retries.py +0 -0
  50. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_roots.py +0 -0
  51. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_scheduler.py +0 -0
  52. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_workers.py +0 -0
  53. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/harness.py +0 -0
  54. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_active_list_cost.py +0 -0
  55. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_admin_scaling.py +0 -0
  56. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_enqueue_rtt.py +0 -0
  57. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_flows_load.py +0 -0
  58. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_load.py +0 -0
  59. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_promote_blocking.py +0 -0
  60. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_result_fanout.py +0 -0
  61. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_worker_concurrency.py +0 -0
  62. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_backoff.py +0 -0
  63. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_flow.py +0 -0
  64. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_histogram.py +0 -0
  65. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_job.py +0 -0
  66. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_job_options.py +0 -0
  67. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_keys.py +0 -0
  68. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_priority.py +0 -0
  69. {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_scheduler.py +0 -0
  70. {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/errors.py +0 -0
  71. {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/flow.py +0 -0
  72. {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/job.py +0 -0
  73. {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/keys.py +0 -0
  74. {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/py.typed +0 -0
  75. {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/queue.py +0 -0
  76. {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/scheduler.py +0 -0
  77. {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/scripts.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: toro-queue
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: An async-first, Redis-backed job queue for Python.
5
5
  Project-URL: Homepage, https://github.com/ilovepixelart/toro
6
6
  Project-URL: Repository, https://github.com/ilovepixelart/toro
@@ -55,7 +55,11 @@ event loop - see [workers vs. slots](concepts.md). Two practical consequences:
55
55
  treated as stalled ([Reliability](reliability.md)).
56
56
  - **Connections scale with concurrency.** Each idle slot parks a (blocking-pop)
57
57
  connection, so the worker sizes its own pool to `concurrency + headroom`. If
58
- you pass your own `connection`, size its pool accordingly.
58
+ you pass your own `connection`, size its pool accordingly, and give it a read
59
+ timeout (`socket_timeout`) above `block_timeout`. redis-py 8 defaults it to 5 s.
60
+ The worker keeps its blocking pop under whatever it finds, so a tighter read
61
+ timeout means idle slots re-poll sooner than `block_timeout` asks, with a
62
+ warning logged.
59
63
 
60
64
  A busy slot doesn't return to the blocking wait between jobs: the finish call
61
65
  also claims the next job in the same round trip (fetch-next - see
@@ -2,7 +2,7 @@
2
2
  # PyPI distribution name - plain `toro` is taken (abandoned). The import package
3
3
  # stays `toro` (e.g. `pip install toro-queue` then `import toro`).
4
4
  name = "toro-queue"
5
- version = "0.5.0"
5
+ version = "0.5.1"
6
6
  description = "An async-first, Redis-backed job queue for Python."
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.10"
@@ -0,0 +1,39 @@
1
+ """Integration: shutdown must not abandon a job the worker has already claimed."""
2
+
3
+ import asyncio
4
+
5
+ from toro import Worker
6
+
7
+ PREFIX = "torotest"
8
+
9
+
10
+ async def test_stop_during_a_finish_round_trip_keeps_the_claimed_job(q, run_until):
11
+ """The fetch flag is read BEFORE the finish script runs. If stop() lands while
12
+ that round trip is in flight, the script has already claimed the next job. It
13
+ is in flight like any other and must be processed, not dropped: dropped, it
14
+ sits locked in `active` until its lock expires and the sweep comes round."""
15
+ await q.add("a", {})
16
+ await q.add("b", {})
17
+ done: list[str] = []
18
+
19
+ async def proc(job):
20
+ done.append(job.name)
21
+
22
+ w = Worker(q.name, proc, prefix=PREFIX, stalled_interval=0)
23
+ real_finish = w._finish_completed
24
+
25
+ async def finish_then_stop(job, result):
26
+ nxt = await real_finish(job, result) # fetch was "1": the script claimed `b`
27
+ w._running = False # stop() lands while that round trip was in flight
28
+ return nxt
29
+
30
+ w._finish_completed = finish_then_stop
31
+ task = asyncio.create_task(w.run())
32
+ try:
33
+ assert await run_until(lambda: "a" in done)
34
+ await asyncio.sleep(0.3) # room for the loop to either process `b` or drop it
35
+ assert done == ["a", "b"]
36
+ assert await q.redis.llen(q.keys.active) == 0
37
+ finally:
38
+ await w.stop(grace_period=1)
39
+ task.cancel()
@@ -6,6 +6,8 @@ Redis error between the blocking pop and the finish.
6
6
  import asyncio
7
7
  import time
8
8
 
9
+ import redis.asyncio as aioredis
10
+
9
11
 
10
12
  async def test_raising_event_callback_does_not_kill_the_slot(q, run_worker, run_until):
11
13
  done = []
@@ -80,3 +82,20 @@ async def test_corrupt_job_hash_does_not_kill_the_slot(q, run_worker, run_until)
80
82
  await q.add("good", {})
81
83
  assert await run_until(lambda: len(done) >= 1, timeout=10.0), "slot died on corrupt data"
82
84
  assert done == ["good"]
85
+
86
+
87
+ async def test_idle_repoll_survives_a_read_timeout_shorter_than_the_pop(q, run_worker, run_until):
88
+ """The blocking pop must come back before the connection's read timeout, or it
89
+ raises instead of timing out quietly and the loop never reaches the claim: a
90
+ job whose wake was missed is then stranded for good. A caller-provided
91
+ connection can carry any read timeout, so the worker has to stay under it."""
92
+ await q.add("stranded", {})
93
+ await q.redis.delete(q.keys.marker) # the missed wake: work waiting, no marker
94
+ done = []
95
+
96
+ async def proc(job):
97
+ done.append(job.id)
98
+
99
+ conn = aioredis.from_url("redis://localhost:6379", socket_timeout=0.4, decode_responses=True)
100
+ async with run_worker(q, proc, connection=conn, block_timeout=1.0, stalled_interval=0):
101
+ assert await run_until(lambda: done, timeout=3.0), "the idle re-poll never claimed it"
@@ -0,0 +1,58 @@
1
+ """Unit: the connection a Worker builds for itself must outlast its blocking pop."""
2
+
3
+ import pytest
4
+ import redis.asyncio as aioredis
5
+ from redis.asyncio.connection import Connection
6
+
7
+ from toro import Worker
8
+ from toro.connection import read_timeout
9
+ from toro.worker import pop_timeout
10
+
11
+
12
+ async def _noop(job):
13
+ return None
14
+
15
+
16
+ def test_read_timeout_exceeds_the_blocking_pop_at_defaults():
17
+ """redis-py 8 defaults socket_timeout to 5s, the same as block_timeout. Equal is
18
+ not enough: the client gives up on the read as the server answers it. The value
19
+ is spelled out because conftest shrinks the default block_timeout in tests."""
20
+ w = Worker("q", _noop, block_timeout=5.0)
21
+ timeout = read_timeout(w.redis)
22
+ assert timeout is None or timeout > w.block_timeout
23
+
24
+
25
+ def test_read_timeout_follows_a_longer_block_timeout():
26
+ w = Worker("q", _noop, block_timeout=30.0)
27
+ timeout = read_timeout(w.redis)
28
+ assert timeout is None or timeout > 30.0
29
+
30
+
31
+ @pytest.mark.parametrize(
32
+ ("read_timeout_s", "block_timeout", "expected"),
33
+ [
34
+ (None, 5.0, 5.0), # no read timeout: nothing to stay under
35
+ (10.0, 5.0, 5.0), # fits with room
36
+ (35.0, 30.0, 30.0), # a worker-built connection: block_timeout + margin
37
+ (5.0, 5.0, 4.0), # redis-py 8 defaults on a caller-provided connection
38
+ (0.4, 1.0, 0.2), # tiny read timeout: half of it, never zero or negative
39
+ ],
40
+ )
41
+ def test_pop_timeout_stays_under_the_read_timeout(read_timeout_s, block_timeout, expected):
42
+ assert pop_timeout(read_timeout_s, block_timeout) == expected
43
+
44
+
45
+ def test_a_caller_provided_connection_on_library_defaults_is_clamped():
46
+ """The common real case: a client built with no socket_timeout at all. The
47
+ library default applies but never shows up in the pool's kwargs. The oracle is
48
+ redis-py's own Connection, not toro's helper, so a helper that misses the
49
+ default cannot simply agree with itself."""
50
+ library_default = Connection().socket_timeout
51
+ conn = aioredis.from_url("redis://localhost:6379", decode_responses=True)
52
+ assert read_timeout(conn) == library_default
53
+
54
+ w = Worker("q", _noop, connection=conn, block_timeout=30.0)
55
+ if library_default is None:
56
+ assert w._pop_timeout == 30.0
57
+ else:
58
+ assert w._pop_timeout < library_default
@@ -26,4 +26,4 @@ __all__ = [
26
26
  "ToroError",
27
27
  "Worker",
28
28
  ]
29
- __version__ = "0.5.0"
29
+ __version__ = "0.5.1"
@@ -2,14 +2,21 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import Any
6
+
5
7
  import redis.asyncio as aioredis
6
8
  from redis.asyncio.retry import Retry
7
9
  from redis.backoff import ExponentialBackoff
8
10
  from redis.exceptions import ConnectionError as RedisConnectionError
9
11
  from redis.exceptions import TimeoutError as RedisTimeoutError
10
12
 
13
+ # How far a connection's read timeout sits above the longest blocking pop it serves.
14
+ READ_MARGIN = 5.0
15
+
11
16
 
12
- def connect(url: str, *, max_connections: int = 50) -> aioredis.Redis:
17
+ def connect(
18
+ url: str, *, max_connections: int = 50, blocking_timeout: float | None = None
19
+ ) -> aioredis.Redis:
13
20
  """Open a ``decode_responses`` client tuned for toro's long-lived connections.
14
21
 
15
22
  Connections that sit idle for a while (a worker's blocking pop, the result()
@@ -27,7 +34,15 @@ def connect(url: str, *, max_connections: int = 50) -> aioredis.Redis:
27
34
  ``max_connections`` must exceed the count of connections held LONG-term: a
28
35
  worker parks one per process loop inside BZPOPMIN, so it sizes the pool from
29
36
  its concurrency (measured: concurrency=100 on a 50-pool starves and errors).
37
+
38
+ ``blocking_timeout`` is the longest server-side block this client will issue.
39
+ The read timeout is set above it: a blocking pop has to come back before the
40
+ client gives up on the read, or it raises instead of timing out quietly.
41
+ redis-py 8 defaults the read timeout to 5s, which a 5s pop does not beat.
30
42
  """
43
+ extra: dict[str, Any] = {}
44
+ if blocking_timeout is not None:
45
+ extra["socket_timeout"] = blocking_timeout + READ_MARGIN
31
46
  pool = aioredis.BlockingConnectionPool.from_url(
32
47
  url,
33
48
  max_connections=max_connections,
@@ -36,5 +51,17 @@ def connect(url: str, *, max_connections: int = 50) -> aioredis.Redis:
36
51
  socket_keepalive=True,
37
52
  retry=Retry(ExponentialBackoff(), retries=3),
38
53
  retry_on_error=[RedisConnectionError, RedisTimeoutError],
54
+ **extra,
39
55
  )
40
56
  return aioredis.Redis(connection_pool=pool)
57
+
58
+
59
+ def read_timeout(client: aioredis.Redis) -> float | None:
60
+ """Return the read timeout a connection from this client's pool will really use.
61
+
62
+ Read off a connection from the pool's own factory, so the library default
63
+ counts: it is absent from ``connection_kwargs`` unless the caller passed it.
64
+ The factory is what a pool subclass overrides, and building the object opens
65
+ nothing.
66
+ """
67
+ return client.connection_pool.make_connection().socket_timeout
@@ -33,7 +33,7 @@ from typing import Any, TypedDict, cast
33
33
  from redis.asyncio import Redis
34
34
 
35
35
  from . import scripts
36
- from .connection import connect
36
+ from .connection import connect, read_timeout
37
37
  from .job import Backoff, Job, JobContext, JobOptions
38
38
  from .keys import Keys
39
39
  from .scheduler import next_run
@@ -64,6 +64,19 @@ def _pairs(flat: list[str] | None) -> dict[str, str]:
64
64
  return dict(zip(it, it, strict=False))
65
65
 
66
66
 
67
+ def pop_timeout(read_timeout_s: float | None, block_timeout: float) -> float:
68
+ """How long the blocking pop may block: `block_timeout`, held under the
69
+ connection's read timeout. A pop that outlasts the read raises instead of
70
+ timing out quietly, the loop never reaches the claim, and a job whose wake was
71
+ missed is stranded. A connection the worker built has room by construction; a
72
+ caller-provided one can carry any read timeout.
73
+ """
74
+ if read_timeout_s is None:
75
+ return block_timeout
76
+ ceiling = max(read_timeout_s - 1.0, read_timeout_s / 2)
77
+ return min(block_timeout, ceiling)
78
+
79
+
67
80
  def compute_backoff(backoff: Backoff, attempts_made: int) -> int:
68
81
  """Delay (ms) before the next attempt. `backoff` is None/0, an int (fixed ms),
69
82
  or {"type": "fixed"|"exponential", "delay": ms}. Exponential doubles per attempt.
@@ -107,7 +120,9 @@ class Worker:
107
120
  # Each process loop PARKS a connection inside BZPOPMIN, so the pool must
108
121
  # exceed the concurrency or loops starve waiting for connections. A
109
122
  # caller-provided connection must be sized accordingly by the caller.
110
- self.redis = connection or connect(url, max_connections=max(50, concurrency + 10))
123
+ self.redis = connection or connect(
124
+ url, max_connections=max(50, concurrency + 10), blocking_timeout=block_timeout
125
+ )
111
126
  self.concurrency = concurrency
112
127
  # Queue-wide rate limit, shared by all workers via one token bucket in Redis.
113
128
  # `{"max": N, "duration": ms}` = at most N jobs per duration. All workers on a
@@ -119,6 +134,14 @@ class Worker:
119
134
  self.rl_max = int(rate_limit["max"]) if rate_limit else 0
120
135
  self.rl_duration = int(rate_limit["duration"]) if rate_limit else 0
121
136
  self.block_timeout = block_timeout
137
+ self._pop_timeout = pop_timeout(read_timeout(self.redis), block_timeout)
138
+ if self._pop_timeout < block_timeout:
139
+ logger.warning(
140
+ "block_timeout %.2fs does not fit under the connection's read timeout; "
141
+ "idle workers re-poll every %.2fs instead",
142
+ block_timeout,
143
+ self._pop_timeout,
144
+ )
122
145
 
123
146
  # Reliability knobs.
124
147
  self.token = uuid.uuid4().hex
@@ -282,12 +305,16 @@ class Worker:
282
305
  # The marker only wakes us; the real claim is the atomic
283
306
  # MOVE_TO_ACTIVE below. A timeout (None) is fine - we still try
284
307
  # to acquire, so a missed marker can never strand a job.
285
- await self.redis.bzpopmin(self.keys.marker, self.block_timeout)
308
+ await self.redis.bzpopmin(self.keys.marker, self._pop_timeout)
286
309
  if not self._running:
287
310
  break # shutting down - don't claim a new job
288
311
  loaded = await self._acquire()
289
- # Keep processing as long as each finish hands us the next job.
290
- while loaded is not None and self._running:
312
+ # Keep processing as long as each finish hands us the next job. No
313
+ # `_running` check here: a job in hand is already claimed, and stop()
314
+ # can land during the very round trip that claimed it. Dropped, it
315
+ # would sit locked in `active` until the sweep. Shutdown ends the
316
+ # chain by itself: a stopping worker finishes with fetch=0.
317
+ while loaded is not None:
291
318
  loaded = await self._handle(loaded)
292
319
  except asyncio.CancelledError:
293
320
  raise
@@ -424,7 +424,7 @@ wheels = [
424
424
 
425
425
  [[package]]
426
426
  name = "toro-queue"
427
- version = "0.5.0"
427
+ version = "0.5.1"
428
428
  source = { editable = "." }
429
429
  dependencies = [
430
430
  { name = "croniter" },
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
File without changes