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.
Files changed (82) hide show
  1. {toro_queue-0.6.0 → toro_queue-0.6.1}/.github/workflows/pr-check.yaml +3 -3
  2. {toro_queue-0.6.0 → toro_queue-0.6.1}/.github/workflows/release.yml +2 -2
  3. {toro_queue-0.6.0 → toro_queue-0.6.1}/PKG-INFO +1 -1
  4. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/processing.md +4 -2
  5. {toro_queue-0.6.0 → toro_queue-0.6.1}/pyproject.toml +10 -2
  6. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_worker_connection.py +28 -2
  7. {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/__init__.py +1 -1
  8. toro_queue-0.6.1/toro/_replies.py +31 -0
  9. {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/connection.py +13 -9
  10. {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/job.py +4 -4
  11. {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/queue.py +7 -21
  12. {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/worker.py +7 -6
  13. toro_queue-0.6.1/uv.lock +508 -0
  14. toro_queue-0.6.0/uv.lock +0 -492
  15. {toro_queue-0.6.0 → toro_queue-0.6.1}/.gitignore +0 -0
  16. {toro_queue-0.6.0 → toro_queue-0.6.1}/.pre-commit-config.yaml +0 -0
  17. {toro_queue-0.6.0 → toro_queue-0.6.1}/.vscode/extensions.json +0 -0
  18. {toro_queue-0.6.0 → toro_queue-0.6.1}/.vscode/settings.json +0 -0
  19. {toro_queue-0.6.0 → toro_queue-0.6.1}/LICENSE +0 -0
  20. {toro_queue-0.6.0 → toro_queue-0.6.1}/README.md +0 -0
  21. {toro_queue-0.6.0 → toro_queue-0.6.1}/bench/bench.py +0 -0
  22. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/architecture.md +0 -0
  23. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/concepts.md +0 -0
  24. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/data-model.md +0 -0
  25. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/flows-design.md +0 -0
  26. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/flows.md +0 -0
  27. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/index.md +0 -0
  28. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/producing.md +0 -0
  29. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/reliability.md +0 -0
  30. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/scheduling.md +0 -0
  31. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/security.md +0 -0
  32. {toro_queue-0.6.0 → toro_queue-0.6.1}/docs/specs/global-concurrency.md +0 -0
  33. {toro_queue-0.6.0 → toro_queue-0.6.1}/examples/README.md +0 -0
  34. {toro_queue-0.6.0 → toro_queue-0.6.1}/examples/basic.py +0 -0
  35. {toro_queue-0.6.0 → toro_queue-0.6.1}/examples/stalled.py +0 -0
  36. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/conftest.py +0 -0
  37. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_admin.py +0 -0
  38. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_admin_ordering.py +0 -0
  39. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_concurrency_stress.py +0 -0
  40. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_connection.py +0 -0
  41. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_fault_injection.py +0 -0
  42. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_finished_retention.py +0 -0
  43. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_flows.py +0 -0
  44. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_flows_edges.py +0 -0
  45. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_global_concurrency.py +0 -0
  46. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_introspection.py +0 -0
  47. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_invariants.py +0 -0
  48. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_leaks.py +0 -0
  49. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_metrics.py +0 -0
  50. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_processing.py +0 -0
  51. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_reliability.py +0 -0
  52. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_result_dispatcher.py +0 -0
  53. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_retries.py +0 -0
  54. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_roots.py +0 -0
  55. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_scheduler.py +0 -0
  56. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_shutdown.py +0 -0
  57. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_worker_resilience.py +0 -0
  58. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/integration/test_workers.py +0 -0
  59. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/harness.py +0 -0
  60. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_active_list_cost.py +0 -0
  61. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_admin_scaling.py +0 -0
  62. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_enqueue_rtt.py +0 -0
  63. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_flows_load.py +0 -0
  64. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_load.py +0 -0
  65. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_promote_blocking.py +0 -0
  66. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_result_fanout.py +0 -0
  67. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/load/test_worker_concurrency.py +0 -0
  68. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_backoff.py +0 -0
  69. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_flow.py +0 -0
  70. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_histogram.py +0 -0
  71. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_job.py +0 -0
  72. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_job_options.py +0 -0
  73. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_keys.py +0 -0
  74. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_priority.py +0 -0
  75. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_scheduler.py +0 -0
  76. {toro_queue-0.6.0 → toro_queue-0.6.1}/tests/unit/test_worker_options.py +0 -0
  77. {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/errors.py +0 -0
  78. {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/flow.py +0 -0
  79. {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/keys.py +0 -0
  80. {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/py.typed +0 -0
  81. {toro_queue-0.6.0 → toro_queue-0.6.1}/toro/scheduler.py +0 -0
  82. {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@v6.0.3
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@v8.2.0
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.0
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@v6.0.3
15
+ - uses: actions/checkout@v7.0.1
16
16
  - name: Install uv
17
- uses: astral-sh/setup-uv@v8.2.0
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: toro-queue
3
- Version: 0.6.0
3
+ Version: 0.6.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
@@ -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`. redis-py 8 defaults it to 5 s.
60
- The worker keeps its blocking pop under whatever it finds, so a tighter read
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.0"
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 version is pinned (dev deps) so new rules can't surprise CI.
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
@@ -26,4 +26,4 @@ __all__ = [
26
26
  "ToroError",
27
27
  "Worker",
28
28
  ]
29
- __version__ = "0.6.0"
29
+ __version__ = "0.6.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 | None = None
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 8 defaults the read timeout to 5s, which a 5s pop does not beat.
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
- return client.connection_pool.make_connection().socket_timeout
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 cast("dict[str, str]", await self._ctx.redis.hgetall(self._ctx.cfail_key))
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 = cast(
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
- if state in ("completed", "failed"):
864
- return getattr(self.keys, state), True # finished states read newest-first
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 .connection import connect, read_timeout
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 = 5.0,
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 = list(res[0]) if res else []
627
- recovered = list(res[1]) if res and len(res) > 1 else []
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: