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.
- {toro_queue-0.5.0 → toro_queue-0.5.1}/PKG-INFO +2 -2
- {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/processing.md +5 -1
- {toro_queue-0.5.0 → toro_queue-0.5.1}/pyproject.toml +1 -1
- toro_queue-0.5.1/tests/integration/test_shutdown.py +39 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_worker_resilience.py +19 -0
- toro_queue-0.5.1/tests/unit/test_worker_connection.py +58 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/__init__.py +1 -1
- {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/connection.py +28 -1
- {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/worker.py +32 -5
- {toro_queue-0.5.0 → toro_queue-0.5.1}/uv.lock +1 -1
- {toro_queue-0.5.0 → toro_queue-0.5.1}/.github/workflows/pr-check.yaml +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/.github/workflows/release.yml +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/.gitignore +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/.pre-commit-config.yaml +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/.vscode/extensions.json +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/.vscode/settings.json +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/LICENSE +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/README.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/bench/bench.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/architecture.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/concepts.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/data-model.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/flows-design.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/flows.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/index.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/producing.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/reliability.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/scheduling.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/docs/security.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/examples/README.md +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/examples/basic.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/examples/stalled.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/conftest.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_admin.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_admin_ordering.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_concurrency_stress.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_connection.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_fault_injection.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_finished_retention.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_flows.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_flows_edges.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_introspection.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_invariants.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_leaks.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_metrics.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_processing.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_reliability.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_result_dispatcher.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_retries.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_roots.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_scheduler.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/integration/test_workers.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/harness.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_active_list_cost.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_admin_scaling.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_enqueue_rtt.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_flows_load.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_load.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_promote_blocking.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_result_fanout.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/load/test_worker_concurrency.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_backoff.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_flow.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_histogram.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_job.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_job_options.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_keys.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_priority.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/tests/unit/test_scheduler.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/errors.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/flow.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/job.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/keys.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/py.typed +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/queue.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/scheduler.py +0 -0
- {toro_queue-0.5.0 → toro_queue-0.5.1}/toro/scripts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: toro-queue
|
|
3
|
-
Version: 0.5.
|
|
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.
|
|
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
|
|
@@ -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(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|