toro-queue 0.6.0__tar.gz → 0.6.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.6.0 → toro_queue-0.6.1}/.github/workflows/pr-check.yaml +3 -3
- {toro_queue-0.6.0 → toro_queue-0.6.1}/.github/workflows/release.yml +2 -2
- {toro_queue-0.6.0 → toro_queue-0.6.1}/PKG-INFO +1 -1
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/processing.md +4 -2
- {toro_queue-0.6.0 → toro_queue-0.6.1}/pyproject.toml +10 -2
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_worker_connection.py +28 -2
- {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/__init__.py +1 -1
- toro_queue-0.6.1/toro/_replies.py +31 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/connection.py +13 -9
- {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/job.py +4 -4
- {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/queue.py +7 -21
- {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/worker.py +7 -6
- toro_queue-0.6.1/uv.lock +508 -0
- toro_queue-0.6.0/uv.lock +0 -492
- {toro_queue-0.6.0 → toro_queue-0.6.1}/.gitignore +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/.pre-commit-config.yaml +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/.vscode/extensions.json +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/.vscode/settings.json +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/LICENSE +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/README.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/bench/bench.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/architecture.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/concepts.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/data-model.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/flows-design.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/flows.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/index.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/producing.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/reliability.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/scheduling.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/security.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/specs/global-concurrency.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/examples/README.md +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/examples/basic.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/examples/stalled.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/conftest.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_admin.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_admin_ordering.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_concurrency_stress.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_connection.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_fault_injection.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_finished_retention.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_flows.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_flows_edges.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_global_concurrency.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_introspection.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_invariants.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_leaks.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_metrics.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_processing.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_reliability.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_result_dispatcher.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_retries.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_roots.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_scheduler.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_shutdown.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_worker_resilience.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_workers.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/harness.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_active_list_cost.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_admin_scaling.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_enqueue_rtt.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_flows_load.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_load.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_promote_blocking.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_result_fanout.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_worker_concurrency.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_backoff.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_flow.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_histogram.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_job.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_job_options.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_keys.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_priority.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_scheduler.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_worker_options.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/errors.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/flow.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/keys.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/py.typed +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/scheduler.py +0 -0
- {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/scripts.py +0 -0
|
@@ -41,13 +41,13 @@ jobs:
|
|
|
41
41
|
--health-timeout 5s
|
|
42
42
|
--health-retries 5
|
|
43
43
|
steps:
|
|
44
|
-
- uses: actions/checkout@
|
|
44
|
+
- uses: actions/checkout@v7.0.1
|
|
45
45
|
with:
|
|
46
46
|
fetch-depth: 0 # full history improves Sonar new-code/blame relevancy
|
|
47
47
|
persist-credentials: false
|
|
48
48
|
|
|
49
49
|
- name: Install uv
|
|
50
|
-
uses: astral-sh/setup-uv@
|
|
50
|
+
uses: astral-sh/setup-uv@v10.1.0
|
|
51
51
|
with:
|
|
52
52
|
enable-cache: true
|
|
53
53
|
python-version: ${{ matrix.python-version }}
|
|
@@ -72,7 +72,7 @@ jobs:
|
|
|
72
72
|
# Config is passed inline; there is no sonar-project.properties.
|
|
73
73
|
- name: SonarCloud scan
|
|
74
74
|
if: matrix.python-version == '3.13' && env.SONAR_TOKEN
|
|
75
|
-
uses: SonarSource/sonarqube-scan-action@v8.2.
|
|
75
|
+
uses: SonarSource/sonarqube-scan-action@v8.2.2
|
|
76
76
|
env:
|
|
77
77
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
78
78
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
@@ -12,9 +12,9 @@ jobs:
|
|
|
12
12
|
build:
|
|
13
13
|
runs-on: ubuntu-latest
|
|
14
14
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
15
|
+
- uses: actions/checkout@v7.0.1
|
|
16
16
|
- name: Install uv
|
|
17
|
-
uses: astral-sh/setup-uv@
|
|
17
|
+
uses: astral-sh/setup-uv@v10.1.0
|
|
18
18
|
- name: Build sdist + wheel
|
|
19
19
|
run: uv build
|
|
20
20
|
- uses: actions/upload-artifact@v7.0.1
|
|
@@ -56,8 +56,10 @@ event loop - see [workers vs. slots](concepts.md). Two practical consequences:
|
|
|
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
58
|
you pass your own `connection`, size its pool accordingly, and give it a read
|
|
59
|
-
timeout (`socket_timeout`) above `block_timeout`.
|
|
60
|
-
|
|
59
|
+
timeout (`socket_timeout`) above `block_timeout`. A `Queue`'s connection
|
|
60
|
+
already has one for the default `block_timeout`, so sharing it with a worker
|
|
61
|
+
needs nothing. A client you build yourself gets redis-py's default of 5 s. The
|
|
62
|
+
worker keeps its blocking pop under whatever it finds, so a tighter read
|
|
61
63
|
timeout means idle slots re-poll sooner than `block_timeout` asks, with a
|
|
62
64
|
warning logged.
|
|
63
65
|
|
|
@@ -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.6.
|
|
5
|
+
version = "0.6.1"
|
|
6
6
|
description = "An async-first, Redis-backed job queue for Python."
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
requires-python = ">=3.10"
|
|
@@ -57,10 +57,13 @@ package = true
|
|
|
57
57
|
[tool.ruff]
|
|
58
58
|
line-length = 100
|
|
59
59
|
target-version = "py310"
|
|
60
|
+
# Ruff 0.16 formats Python blocks inside Markdown by default. The docs align
|
|
61
|
+
# trailing comments by hand as teaching annotations, which a formatter flattens.
|
|
62
|
+
extend-exclude = ["*.md"]
|
|
60
63
|
|
|
61
64
|
[tool.ruff.lint]
|
|
62
65
|
# Strictest sensible baseline: enable everything, then opt out deliberately. The
|
|
63
|
-
# ruff
|
|
66
|
+
# lockfile pins ruff, so new rules arrive only with a deliberate `uv lock --upgrade`.
|
|
64
67
|
select = ["ALL"]
|
|
65
68
|
ignore = [
|
|
66
69
|
"COM812", "ISC001", # conflict with the formatter (per Ruff docs)
|
|
@@ -75,6 +78,7 @@ ignore = [
|
|
|
75
78
|
"PLR0913", # Worker/Queue are config constructors - many kwargs is fine
|
|
76
79
|
"BLE001", "S110", "S112", # deliberate best-effort excepts in worker/redis cleanup paths
|
|
77
80
|
"TC001", "TC002", "TC003", # prefer normal runtime imports over TYPE_CHECKING blocks
|
|
81
|
+
"CPY001", # the LICENSE file covers the repo; no per-file notices
|
|
78
82
|
]
|
|
79
83
|
|
|
80
84
|
[tool.ruff.lint.per-file-ignores]
|
|
@@ -113,6 +117,10 @@ python-version = "3.10"
|
|
|
113
117
|
# Strictest ty: every rule, including the warn-by-default ones, fails the build.
|
|
114
118
|
[tool.ty.rules]
|
|
115
119
|
all = "error"
|
|
120
|
+
# Off by default in ty, and unsound for this codebase. It treats `self._running`
|
|
121
|
+
# as constant across an `await`, where stop() flips it from another task, and it
|
|
122
|
+
# flags the runtime guards on option shapes decoded from JSON stored in Redis.
|
|
123
|
+
redundant-condition-strict = "ignore"
|
|
116
124
|
|
|
117
125
|
[tool.ty.src]
|
|
118
126
|
include = ["toro"]
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"""Unit: the connection a Worker builds for itself must outlast its blocking pop."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
4
|
+
|
|
3
5
|
import pytest
|
|
4
6
|
import redis.asyncio as aioredis
|
|
5
7
|
from redis.asyncio.connection import Connection
|
|
6
8
|
|
|
7
|
-
from toro import Worker
|
|
8
|
-
from toro.connection import read_timeout
|
|
9
|
+
from toro import Queue, Worker
|
|
10
|
+
from toro.connection import DEFAULT_BLOCK_TIMEOUT, read_timeout
|
|
9
11
|
from toro.worker import pop_timeout
|
|
10
12
|
|
|
11
13
|
|
|
@@ -56,3 +58,27 @@ def test_a_caller_provided_connection_on_library_defaults_is_clamped():
|
|
|
56
58
|
assert w._pop_timeout == 30.0
|
|
57
59
|
else:
|
|
58
60
|
assert w._pop_timeout < library_default
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_a_queue_built_connection_hosts_a_default_worker_quietly(caplog):
|
|
64
|
+
"""Sharing the Queue's connection with a Worker is an ordinary setup. At the
|
|
65
|
+
default block_timeout it must neither shorten the pop nor log a warning: a
|
|
66
|
+
default configuration that warns teaches people to ignore warnings. redis-py's
|
|
67
|
+
guidance for blocking commands is a socket_timeout above the block window, so
|
|
68
|
+
every connection toro builds has to be sized for toro's own default pop."""
|
|
69
|
+
queue = Queue("q")
|
|
70
|
+
with caplog.at_level(logging.WARNING, logger="toro.worker"):
|
|
71
|
+
# spelled out: conftest shrinks the default block_timeout in tests
|
|
72
|
+
w = Worker("q", _noop, connection=queue.redis, block_timeout=DEFAULT_BLOCK_TIMEOUT)
|
|
73
|
+
assert w._pop_timeout == DEFAULT_BLOCK_TIMEOUT
|
|
74
|
+
assert [r.getMessage() for r in caplog.records] == []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_a_pop_longer_than_a_shared_connection_allows_still_warns(caplog):
|
|
78
|
+
"""The warning stays for the case that deserves one: asking for a pop the
|
|
79
|
+
connection in hand cannot hold."""
|
|
80
|
+
queue = Queue("q")
|
|
81
|
+
with caplog.at_level(logging.WARNING, logger="toro.worker"):
|
|
82
|
+
w = Worker("q", _noop, connection=queue.redis, block_timeout=60.0)
|
|
83
|
+
assert w._pop_timeout < 60.0
|
|
84
|
+
assert len(caplog.records) == 1
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The one place a raw Redis reply gets its Python type.
|
|
2
|
+
|
|
3
|
+
Every client runs with ``decode_responses`` on, so replies are ``str`` at runtime,
|
|
4
|
+
while redis-py types them as ``bytes | str``. These helpers name that boundary once.
|
|
5
|
+
They take ``Any`` on purpose: ``dict`` and ``list`` are invariant, so a cast straight
|
|
6
|
+
from redis-py's declared type is a cast between unrelated types, not a narrowing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _str_list(reply: Any) -> list[str]:
|
|
15
|
+
"""Type a Redis list/zset reply as list[str]."""
|
|
16
|
+
return cast("list[str]", reply)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _str_dict(reply: Any) -> dict[str, str]:
|
|
20
|
+
"""Type a Redis hash reply as dict[str, str]."""
|
|
21
|
+
return cast("dict[str, str]", reply)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hash_replies(reply: Any) -> list[dict[str, str]]:
|
|
25
|
+
"""Type a pipeline's list of hash replies as list[dict[str, str]]."""
|
|
26
|
+
return cast("list[dict[str, str]]", reply)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _scored(reply: Any) -> list[tuple[str, float]]:
|
|
30
|
+
"""Type a zset reply read WITHSCORES as list[(member, score)]."""
|
|
31
|
+
return cast("list[tuple[str, float]]", reply)
|
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
5
|
import redis.asyncio as aioredis
|
|
8
6
|
from redis.asyncio.retry import Retry
|
|
9
7
|
from redis.backoff import ExponentialBackoff
|
|
10
8
|
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
11
9
|
from redis.exceptions import TimeoutError as RedisTimeoutError
|
|
12
10
|
|
|
11
|
+
# How long an idle worker slot blocks on the marker by default. It lives here, not
|
|
12
|
+
# in worker.py, because every connection toro builds is sized for it: a Queue's
|
|
13
|
+
# connection is routinely shared with a Worker, and must hold that worker's pop.
|
|
14
|
+
DEFAULT_BLOCK_TIMEOUT = 5.0
|
|
15
|
+
|
|
13
16
|
# How far a connection's read timeout sits above the longest blocking pop it serves.
|
|
14
17
|
READ_MARGIN = 5.0
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
def connect(
|
|
18
|
-
url: str, *, max_connections: int = 50, blocking_timeout: float
|
|
21
|
+
url: str, *, max_connections: int = 50, blocking_timeout: float = DEFAULT_BLOCK_TIMEOUT
|
|
19
22
|
) -> aioredis.Redis:
|
|
20
23
|
"""Open a ``decode_responses`` client tuned for toro's long-lived connections.
|
|
21
24
|
|
|
@@ -38,20 +41,20 @@ def connect(
|
|
|
38
41
|
``blocking_timeout`` is the longest server-side block this client will issue.
|
|
39
42
|
The read timeout is set above it: a blocking pop has to come back before the
|
|
40
43
|
client gives up on the read, or it raises instead of timing out quietly.
|
|
41
|
-
redis-py
|
|
44
|
+
redis-py's own guidance for blocking commands is a ``socket_timeout`` larger
|
|
45
|
+
than the block window, and its default of 5s does not beat a 5s pop. The
|
|
46
|
+
default here fits a default worker, so a Queue's connection can be handed to
|
|
47
|
+
one as it is.
|
|
42
48
|
"""
|
|
43
|
-
extra: dict[str, Any] = {}
|
|
44
|
-
if blocking_timeout is not None:
|
|
45
|
-
extra["socket_timeout"] = blocking_timeout + READ_MARGIN
|
|
46
49
|
pool = aioredis.BlockingConnectionPool.from_url(
|
|
47
50
|
url,
|
|
48
51
|
max_connections=max_connections,
|
|
49
52
|
decode_responses=True,
|
|
50
53
|
health_check_interval=30,
|
|
51
54
|
socket_keepalive=True,
|
|
55
|
+
socket_timeout=blocking_timeout + READ_MARGIN,
|
|
52
56
|
retry=Retry(ExponentialBackoff(), retries=3),
|
|
53
57
|
retry_on_error=[RedisConnectionError, RedisTimeoutError],
|
|
54
|
-
**extra,
|
|
55
58
|
)
|
|
56
59
|
return aioredis.Redis(connection_pool=pool)
|
|
57
60
|
|
|
@@ -64,4 +67,5 @@ def read_timeout(client: aioredis.Redis) -> float | None:
|
|
|
64
67
|
The factory is what a pool subclass overrides, and building the object opens
|
|
65
68
|
nothing.
|
|
66
69
|
"""
|
|
67
|
-
|
|
70
|
+
timeout = client.connection_pool.make_connection().socket_timeout
|
|
71
|
+
return None if timeout is None else float(timeout)
|
|
@@ -8,6 +8,8 @@ from typing import Any, Literal, Protocol, TypeAlias, TypedDict, cast
|
|
|
8
8
|
|
|
9
9
|
from redis.asyncio import Redis
|
|
10
10
|
|
|
11
|
+
from ._replies import _str_dict
|
|
12
|
+
|
|
11
13
|
# The lifecycle states a job can be in (also the queryable states for get_jobs).
|
|
12
14
|
# `waiting-children` is the flow-parent park: enqueued, but runnable only once
|
|
13
15
|
# every child has settled.
|
|
@@ -177,9 +179,7 @@ class Job:
|
|
|
177
179
|
"""
|
|
178
180
|
if self._ctx is None:
|
|
179
181
|
raise RuntimeError("children_results() is only available inside a worker processor")
|
|
180
|
-
return decode_results(
|
|
181
|
-
cast("dict[str, str]", await self._ctx.redis.hgetall(self._ctx.results_key))
|
|
182
|
-
)
|
|
182
|
+
return decode_results(_str_dict(await self._ctx.redis.hgetall(self._ctx.results_key)))
|
|
183
183
|
|
|
184
184
|
async def failed_children(self) -> dict[str, str]:
|
|
185
185
|
"""Pull child id -> failure reason for children failed under
|
|
@@ -187,7 +187,7 @@ class Job:
|
|
|
187
187
|
"""
|
|
188
188
|
if self._ctx is None:
|
|
189
189
|
raise RuntimeError("failed_children() is only available inside a worker processor")
|
|
190
|
-
return
|
|
190
|
+
return _str_dict(await self._ctx.redis.hgetall(self._ctx.cfail_key))
|
|
191
191
|
|
|
192
192
|
@classmethod
|
|
193
193
|
def from_hash(cls, job_id: str, h: dict[str, str]) -> Job:
|
|
@@ -13,6 +13,7 @@ from redis.asyncio import Redis
|
|
|
13
13
|
from redis.asyncio.client import PubSub
|
|
14
14
|
|
|
15
15
|
from . import scripts
|
|
16
|
+
from ._replies import _hash_replies, _scored, _str_dict, _str_list
|
|
16
17
|
from .connection import connect
|
|
17
18
|
from .errors import JobFailedError
|
|
18
19
|
from .flow import MAX_FLOW_NODES, FlowChild, FlowView, count_nodes, node_options, to_tree
|
|
@@ -26,21 +27,6 @@ def _now_ms() -> int:
|
|
|
26
27
|
return int(time.time() * 1000)
|
|
27
28
|
|
|
28
29
|
|
|
29
|
-
def _str_list(reply: Any) -> list[str]:
|
|
30
|
-
"""Type a Redis list/zset reply (decode_responses is on) as list[str]."""
|
|
31
|
-
return cast("list[str]", reply)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _str_dict(reply: Any) -> dict[str, str]:
|
|
35
|
-
"""Type a Redis hash reply (decode_responses is on) as dict[str, str]."""
|
|
36
|
-
return cast("dict[str, str]", reply)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _hash_replies(reply: Any) -> list[dict[str, str]]:
|
|
40
|
-
"""Type a pipeline's list of hash replies as list[dict[str, str]]."""
|
|
41
|
-
return cast("list[dict[str, str]]", reply)
|
|
42
|
-
|
|
43
|
-
|
|
44
30
|
class MetricsPoint(TypedDict):
|
|
45
31
|
"""One minute of queue activity: counts + summed processing duration."""
|
|
46
32
|
|
|
@@ -556,10 +542,7 @@ class Queue:
|
|
|
556
542
|
|
|
557
543
|
async def schedulers(self) -> list[dict[str, Any]]:
|
|
558
544
|
"""List active schedulers (for the dashboard)."""
|
|
559
|
-
entries =
|
|
560
|
-
"list[tuple[str, float]]",
|
|
561
|
-
await self.redis.zrange(self.keys.repeat, 0, -1, withscores=True),
|
|
562
|
-
)
|
|
545
|
+
entries = _scored(await self.redis.zrange(self.keys.repeat, 0, -1, withscores=True))
|
|
563
546
|
if not entries:
|
|
564
547
|
return []
|
|
565
548
|
pipe = self.redis.pipeline(transaction=False) # read fan-out; no MULTI/EXEC needed
|
|
@@ -860,8 +843,11 @@ class Queue:
|
|
|
860
843
|
return self.keys.delayed, False
|
|
861
844
|
if state == "waiting-children":
|
|
862
845
|
return self.keys.waiting_children, False
|
|
863
|
-
|
|
864
|
-
|
|
846
|
+
# finished states read newest-first
|
|
847
|
+
if state == "completed":
|
|
848
|
+
return self.keys.completed, True
|
|
849
|
+
if state == "failed":
|
|
850
|
+
return self.keys.failed, True
|
|
865
851
|
raise ValueError(f"unknown state: {state}")
|
|
866
852
|
|
|
867
853
|
async def get_jobs_roots(
|
|
@@ -33,7 +33,8 @@ from typing import Any, TypedDict, cast
|
|
|
33
33
|
from redis.asyncio import Redis
|
|
34
34
|
|
|
35
35
|
from . import scripts
|
|
36
|
-
from .
|
|
36
|
+
from ._replies import _str_list
|
|
37
|
+
from .connection import DEFAULT_BLOCK_TIMEOUT, connect, read_timeout
|
|
37
38
|
from .job import Backoff, Job, JobContext, JobOptions
|
|
38
39
|
from .keys import Keys
|
|
39
40
|
from .scheduler import next_run
|
|
@@ -106,7 +107,7 @@ class Worker:
|
|
|
106
107
|
concurrency: int = 1,
|
|
107
108
|
rate_limit: RateLimit | None = None,
|
|
108
109
|
global_concurrency: int | None = None,
|
|
109
|
-
block_timeout: float =
|
|
110
|
+
block_timeout: float = DEFAULT_BLOCK_TIMEOUT,
|
|
110
111
|
lock_duration: int = 30000,
|
|
111
112
|
lock_renew_time: int | None = None,
|
|
112
113
|
renew_locks: bool = True,
|
|
@@ -385,7 +386,7 @@ class Worker:
|
|
|
385
386
|
fields = _pairs(res[0])
|
|
386
387
|
if not fields:
|
|
387
388
|
return None
|
|
388
|
-
return (res[1], fields)
|
|
389
|
+
return (str(res[1]), fields)
|
|
389
390
|
|
|
390
391
|
async def _handle(
|
|
391
392
|
self, loaded: tuple[str, dict[str, str]]
|
|
@@ -542,7 +543,7 @@ class Worker:
|
|
|
542
543
|
|
|
543
544
|
def _next_from(self, res: Any) -> tuple[str, dict[str, str]] | None:
|
|
544
545
|
if isinstance(res, (list, tuple)) and len(res) >= 3:
|
|
545
|
-
return (res[2], _pairs(res[1]))
|
|
546
|
+
return (str(res[2]), _pairs(res[1]))
|
|
546
547
|
return None
|
|
547
548
|
|
|
548
549
|
# ---- locks & recovery -------------------------------------------------
|
|
@@ -623,8 +624,8 @@ class Worker:
|
|
|
623
624
|
],
|
|
624
625
|
args=[self.max_stalled_count, _now_ms(), throttle, scripts.METRICS_RETENTION_MS],
|
|
625
626
|
)
|
|
626
|
-
failed =
|
|
627
|
-
recovered =
|
|
627
|
+
failed = _str_list(res[0]) if res else []
|
|
628
|
+
recovered = _str_list(res[1]) if res and len(res) > 1 else []
|
|
628
629
|
return failed, recovered
|
|
629
630
|
|
|
630
631
|
def _backoff_delay(self, job: Job) -> int:
|