Skip to content

Commit 327232b

Browse files
authored
Merge pull request #5338 from Agenta-AI/release/v0.105.2
[release] v0.105.2
2 parents 650d4ed + 58ce762 commit 327232b

129 files changed

Lines changed: 3274 additions & 3670 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/entrypoints/routers.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@
163163
from oss.src.apis.fastapi.triggers.router import TriggersRouter
164164
from oss.src.tasks.asyncio.triggers.dispatcher import TriggersDispatcher
165165
from oss.src.tasks.taskiq.triggers.worker import TriggersWorker
166-
from taskiq_redis import RedisStreamBroker
166+
from oss.src.tasks.taskiq.shared.broker import ProducerOnlyRedisStreamBroker
167167
from oss.src.apis.fastapi.shared.utils import SupportHeadersMiddleware
168168
from oss.src.dbs.postgres.mounts.dao import MountsDAO
169169
from oss.src.core.mounts.service import MountsService
@@ -685,7 +685,10 @@ async def lifespan(*args, **kwargs):
685685
# Producer side of the evaluations pipeline: enqueues onto queues:evaluations;
686686
# entrypoints/worker_queues.py consumes them.
687687
evaluations_worker = build_evaluations_worker(
688-
broker=build_evaluations_broker(consumer_group_name="api-evaluations-producer"),
688+
broker=build_evaluations_broker(
689+
consumer_group_name="api-evaluations-producer",
690+
producer_only=True,
691+
),
689692
tracing_service=tracing_service,
690693
simple_evaluators_service=simple_evaluators_service,
691694
testsets_service=testsets_service,
@@ -805,7 +808,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str:
805808

806809
# Producer side of the interactions pipeline: the respond route enqueues
807810
# `interactions.respond` tasks here; entrypoints/worker_queues.py consumes them.
808-
_interactions_broker = RedisStreamBroker(
811+
_interactions_broker = ProducerOnlyRedisStreamBroker(
809812
url=env.redis.uri_durable,
810813
queue_name="queues:interactions",
811814
consumer_group_name="api-interactions-producer",
@@ -826,7 +829,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str:
826829

827830
# Producer side of the inbound dispatch pipeline: the ingress route enqueues
828831
# `triggers.dispatch` tasks here; entrypoints/worker_queues.py consumes them.
829-
_triggers_broker = RedisStreamBroker(
832+
_triggers_broker = ProducerOnlyRedisStreamBroker(
830833
url=env.redis.uri_durable,
831834
queue_name="queues:triggers",
832835
consumer_group_name="api-triggers-producer",

api/entrypoints/worker_queues.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@
3232
from taskiq.receiver import Receiver
3333
from taskiq.cli.worker.run import shutdown_broker
3434

35-
from oss.src.tasks.taskiq.shared.broker import TrimOnAckRedisStreamBroker
35+
from oss.src.tasks.taskiq.shared.broker import (
36+
TrimOnAckRedisStreamBroker,
37+
prune_idle_consumers,
38+
stable_consumer_name,
39+
)
3640

3741
from oss.src.core.embeds.service import EmbedsService
3842
from oss.src.core.environments.service import EnvironmentsService
@@ -128,6 +132,7 @@ def _build_webhooks_broker() -> tuple[AsyncBroker, int]:
128132
url=env.redis.uri_durable,
129133
queue_name="queues:webhooks",
130134
consumer_group_name="worker-webhooks",
135+
consumer_name=stable_consumer_name("worker-webhooks"),
131136
maxlen=MAXLEN_QUEUES_WEBHOOKS,
132137
approximate=True,
133138
)
@@ -140,6 +145,7 @@ def _build_triggers_broker() -> tuple[AsyncBroker, int]:
140145
url=env.redis.uri_durable,
141146
queue_name="queues:triggers",
142147
consumer_group_name="worker-triggers",
148+
consumer_name=stable_consumer_name("worker-triggers"),
143149
maxlen=MAXLEN_QUEUES_TRIGGERS,
144150
approximate=True,
145151
)
@@ -180,6 +186,7 @@ def _build_interactions_broker() -> tuple[AsyncBroker, int]:
180186
url=env.redis.uri_durable,
181187
queue_name="queues:interactions",
182188
consumer_group_name="worker-interactions",
189+
consumer_name=stable_consumer_name("worker-interactions"),
183190
maxlen=MAXLEN_QUEUES_INTERACTIONS,
184191
approximate=True,
185192
)
@@ -315,6 +322,16 @@ async def _run_receiver(
315322
# process-forking/signal-handling parts (done once for the whole
316323
# process by main_async, not per-broker).
317324
broker.is_worker_process = True
325+
326+
removed = await prune_idle_consumers(
327+
url=env.redis.uri_durable,
328+
queue_name=broker.queue_name,
329+
consumer_group_name=broker.consumer_group_name,
330+
keep=broker.consumer_name,
331+
)
332+
if removed:
333+
log.info(f"[QUEUES] Pruned {removed} idle consumers on queue={name}")
334+
318335
executor = ThreadPoolExecutor()
319336
try:
320337
receiver = Receiver(

api/entrypoints/worker_streams.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818

1919
from redis.asyncio import Redis
2020

21-
from oss.src.tasks.taskiq.shared.broker import ProducerOnlyRedisStreamBroker
21+
from oss.src.tasks.taskiq.shared.broker import (
22+
ProducerOnlyRedisStreamBroker,
23+
prune_idle_consumers,
24+
)
2225

2326
from oss.src.core.events.service import EventsService
2427
from oss.src.core.secrets.services import VaultService
@@ -146,6 +149,18 @@ async def main_async() -> int:
146149

147150
for consumer in consumers:
148151
await consumer.create_consumer_group()
152+
removed = await prune_idle_consumers(
153+
url=env.redis.uri_durable,
154+
queue_name=consumer.stream_name,
155+
consumer_group_name=consumer.consumer_group,
156+
keep=consumer.consumer_name,
157+
)
158+
if removed:
159+
log.info(
160+
"[STREAMS] Pruned idle consumers",
161+
stream=consumer.stream_name,
162+
removed=removed,
163+
)
149164

150165
log.info("[STREAMS] Starting worker-streams", selected=streams)
151166

api/oss/src/core/evaluations/runtime/broker.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
from taskiq import AsyncBroker
99

10-
from oss.src.tasks.taskiq.shared.broker import TrimOnAckRedisStreamBroker
10+
from oss.src.tasks.taskiq.shared.broker import (
11+
ProducerOnlyMixin,
12+
TrimOnAckRedisStreamBroker,
13+
stable_consumer_name,
14+
)
1115
from oss.src.core.evaluations.runtime.runner import TaskiqEvaluationTaskRunner
1216
from oss.src.core.evaluations.service import EvaluationsService
1317
from oss.src.core.evaluators.service import SimpleEvaluatorsService
@@ -56,12 +60,28 @@ async def listen(self):
5660
)
5761

5862

59-
def build_evaluations_broker(*, consumer_group_name: str) -> AsyncBroker:
60-
"""Construct the durable evaluations broker (shared queue name/maxlen)."""
61-
return NoRedeliveryRedisStreamBroker(
63+
class ProducerOnlyEvaluationsBroker(ProducerOnlyMixin, NoRedeliveryRedisStreamBroker):
64+
"""Evaluations producer: `.kiq()`-only, so it never declares the group."""
65+
66+
67+
def build_evaluations_broker(
68+
*, consumer_group_name: str, producer_only: bool = False
69+
) -> AsyncBroker:
70+
"""Construct the durable evaluations broker (shared queue name/maxlen).
71+
72+
`producer_only=True` for the API-side enqueuer (never `.listen()`s), so it
73+
doesn't declare a phantom consumer group.
74+
"""
75+
broker_cls = (
76+
ProducerOnlyEvaluationsBroker
77+
if producer_only
78+
else NoRedeliveryRedisStreamBroker
79+
)
80+
return broker_cls(
6281
url=env.redis.uri_durable,
6382
queue_name="queues:evaluations",
6483
consumer_group_name=consumer_group_name,
84+
consumer_name=stable_consumer_name(consumer_group_name),
6585
maxlen=MAXLEN_QUEUES_EVALUATIONS,
6686
approximate=True,
6787
# socket_timeout must be >= xread_block / 1000 to avoid connection errors.

api/oss/src/tasks/asyncio/shared/consumer.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414
- max_delay_ms: 250ms - max wait time for batch accumulation when small batches arrive
1515
"""
1616

17-
import os
1817
import time
1918
import asyncio
2019
from typing import Dict, List, Optional, Tuple
2120

2221
from redis.asyncio import Redis
2322

23+
from oss.src.tasks.taskiq.shared.broker import stable_consumer_name
2424
from oss.src.utils.logging import get_module_logger
2525

2626
log = get_module_logger(__name__)
@@ -54,9 +54,10 @@ def __init__(
5454
self.stream_name = stream_name
5555
self.consumer_group = consumer_group
5656
self.metric_stream = consumer_group.removeprefix("worker-")
57-
# Per-pid consumer name — what makes horizontal scale-up safe: all
58-
# replicas share consumer_group, Redis fans out work across consumers.
59-
self.consumer_name = consumer_name or f"worker-{os.getpid()}"
57+
# Container-unique consumer name (PID is always 1 in a container, so it
58+
# can't distinguish replicas): all replicas share consumer_group and
59+
# Redis fans out work across the distinct per-container consumers.
60+
self.consumer_name = consumer_name or stable_consumer_name(consumer_group)
6061
self.max_batch_size = max_batch_size
6162
self.max_block_ms = max_block_ms
6263
self.max_batch_mb = max_batch_mb

api/oss/src/tasks/taskiq/shared/broker.py

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,85 @@
77
ack (a terminal state), so retries (re-kicked as new messages) and crash
88
redelivery (unacked, reclaimed via XAUTOCLAIM from the PEL) are unaffected.
99
10-
`ProducerOnlyRedisStreamBroker` skips consumer-group declaration on startup, for
11-
a broker used only to register tasks and `.kiq()` — never to `.listen()`. The
12-
base `startup()` unconditionally XGROUP-CREATEs a group that then sits at 0-0
13-
forever (0 consumers), reporting lag == XLEN permanently.
10+
`ProducerOnlyMixin` skips consumer-group declaration on startup, for a broker
11+
used only to register tasks and `.kiq()` — never to `.listen()`. The base
12+
`startup()` unconditionally XGROUP-CREATEs a group that then sits at 0-0 forever
13+
(0 consumers), reporting lag == XLEN permanently. Every producer broker (one per
14+
queue) must mix this in; only the consumer side declares the group.
15+
16+
`stable_consumer_name()` derives the consumer name from the container id
17+
(`HOSTNAME`), so it is unique per concurrent process — N replicas get N distinct
18+
names and never collide on one PEL — while being far more legible than the base
19+
broker's per-construction `uuid4()`. The name still rotates per redeploy (new
20+
container id), so pair it with `prune_idle_consumers()` at boot to bound registry
21+
growth: without pruning, each redeploy leaves a dead consumer behind and the
22+
group registry climbs to tens of thousands of idle (pending=0) entries.
1423
"""
1524

25+
import os
26+
import socket
1627
from typing import Awaitable, Callable
1728

1829
from redis.asyncio import Redis
1930
from taskiq_redis import RedisStreamBroker
2031
from taskiq_redis.redis_broker import BaseRedisBroker
2132

2233

34+
def stable_consumer_name(consumer_group_name: str) -> str:
35+
"""Per-container consumer name: unique per concurrent process (N-replica safe)."""
36+
host = os.environ.get("HOSTNAME") or socket.gethostname()
37+
return f"{consumer_group_name}@{host}"
38+
39+
40+
# 24h: a rolling deploy runs old+new replicas concurrently, so a live peer's
41+
# idle time never approaches this — only long-dead containers get pruned.
42+
PRUNE_MIN_IDLE_MS = 24 * 60 * 60 * 1000
43+
44+
45+
async def prune_idle_consumers(
46+
*,
47+
url: str,
48+
queue_name: str,
49+
consumer_group_name: str,
50+
min_idle_ms: int = PRUNE_MIN_IDLE_MS,
51+
keep: str | None = None,
52+
) -> int:
53+
"""DELCONSUMER every idle, empty (pending=0) consumer in the group.
54+
55+
Bounds registry growth from per-redeploy consumer-name rotation. Only removes
56+
consumers with pending=0 (DELCONSUMER on a consumer holding pending entries
57+
would silently orphan them) and idle >= `min_idle_ms`, and never `keep` (this
58+
process's own name). Best-effort: swallows errors so a prune failure never
59+
blocks worker startup.
60+
"""
61+
removed = 0
62+
try:
63+
async with Redis.from_url(url) as redis_conn:
64+
try:
65+
consumers = await redis_conn.xinfo_consumers(
66+
queue_name, consumer_group_name
67+
)
68+
except Exception:
69+
return 0
70+
for c in consumers:
71+
name = c["name"]
72+
name = name.decode() if isinstance(name, bytes) else name
73+
if name == keep:
74+
continue
75+
if int(c["pending"]) != 0 or int(c["idle"]) < min_idle_ms:
76+
continue
77+
try:
78+
await redis_conn.xgroup_delconsumer(
79+
queue_name, consumer_group_name, name
80+
)
81+
removed += 1
82+
except Exception:
83+
continue
84+
except Exception:
85+
return removed
86+
return removed
87+
88+
2389
class TrimOnAckRedisStreamBroker(RedisStreamBroker):
2490
def _ack_generator(self, id: str, queue_name: str) -> Callable[[], Awaitable[None]]:
2591
async def _ack() -> None:
@@ -30,6 +96,12 @@ async def _ack() -> None:
3096
return _ack
3197

3298

33-
class ProducerOnlyRedisStreamBroker(RedisStreamBroker):
99+
class ProducerOnlyMixin:
100+
"""Skip consumer-group declaration on startup — for `.kiq()`-only brokers."""
101+
34102
async def startup(self) -> None:
35103
await BaseRedisBroker.startup(self)
104+
105+
106+
class ProducerOnlyRedisStreamBroker(ProducerOnlyMixin, RedisStreamBroker):
107+
pass

api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "api"
3-
version = "0.105.1"
3+
version = "0.105.2"
44
description = "Agenta API"
55
requires-python = ">=3.11,<3.14"
66
authors = [

api/uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

clients/python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "agenta-client"
3-
version = "0.105.1"
3+
version = "0.105.2"
44
description = "Fern-generated Python client for the Agenta API."
55
requires-python = ">=3.11,<3.14"
66
authors = [

clients/python/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)