Skip to content

Commit c279c97

Browse files
danielmillerpclaude
andcommitted
fix(streams): cut SSE error-log volume and add Redis pool headroom
The task-event SSE endpoint holds one blocking XREAD connection per connected client. When concurrent streams exceed the Redis connection pool size, every client's read fails on each cycle. Combined with plain-text logging that splits each traceback into one log entry per line, and a flat 1s retry, a sustained pool stall becomes a log-ingestion firehose. Changes: - Streaming error path: emit a full traceback only on the first failure of a stream; repeats log a single compact line. Replace the flat 1s retry with capped exponential backoff (1->2->4->8->16->30s), reset on a healthy read. Stops a tight per-client loop from hammering Redis and flooding logs, independent of log format. - Default to structured JSON logs in all deployed environments (ENVIRONMENT != "development"). JSON keeps a multi-line traceback as a single log entry (newlines live inside the quoted exc_info field) instead of one cluster-log entry per line. Local development keeps plain text for readable console output. The JSON formatter already existed but was gated behind Datadog configuration. - Bump the in-code REDIS_MAX_CONNECTIONS default (50 -> 200) for peak-concurrency headroom. This is a mitigation, not a fix: connections still scale 1:1 with clients. The durable fix is a shared per-pod reader that fans out to in-process queues. Deployed environments set the real cap via the REDIS_MAX_CONNECTIONS env var. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8c060f1 commit c279c97

3 files changed

Lines changed: 50 additions & 8 deletions

File tree

agentex/src/config/environment_variables.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,13 @@ class EnvironmentVariables(BaseModel):
9696
MONGODB_DATABASE_NAME: str | None = "agentex"
9797
MONGODB_MAX_POOL_SIZE: int = 50
9898
MONGODB_MIN_POOL_SIZE: int = 5
99-
REDIS_MAX_CONNECTIONS: int = 50 # Increased for SSE streaming
99+
# SSE streaming currently holds one blocking XREAD connection per connected
100+
# client, so the pool needs headroom for peak concurrent streams per pod.
101+
# NOTE: this is only the in-code default — deployed environments override it
102+
# via the REDIS_MAX_CONNECTIONS env var, which is the real cap. Bumping this
103+
# buys headroom but does NOT change the 1-connection-per-client scaling; the
104+
# durable fix is a shared per-pod reader that fans out to in-process queues.
105+
REDIS_MAX_CONNECTIONS: int = 200
100106
REDIS_CONNECTION_TIMEOUT: int = 60 # Connection timeout in seconds
101107
REDIS_SOCKET_TIMEOUT: int = 30 # Socket timeout in seconds
102108
REDIS_STREAM_MAXLEN: int = (

agentex/src/domain/use_cases/streams_use_case.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ async def stream_task_events(
114114
ping_interval = float(
115115
self.environment_variables.SSE_KEEPALIVE_PING_INTERVAL
116116
) # Configurable keepalive ping interval
117+
# Track consecutive read failures so we can back off and avoid a
118+
# tight error loop. When the Redis pool is exhausted, every connected
119+
# client's read fails on each cycle; without backoff + single-line
120+
# logging this turns into a log-ingestion firehose (one multi-line
121+
# traceback per client per cycle).
122+
consecutive_errors = 0
117123
try:
118124
# Application-level control loop
119125
while True:
@@ -133,6 +139,10 @@ async def stream_task_events(
133139
last_message_time = asyncio.get_running_loop().time()
134140
await asyncio.sleep(0.02)
135141

142+
# A read cycle completed without raising — the stream is
143+
# healthy again, so reset the backoff/error counter.
144+
consecutive_errors = 0
145+
136146
# If we didn't get any messages, add a small pause
137147
# to prevent tight loops and send keepalive ping if needed
138148
if message_count == 0:
@@ -151,13 +161,28 @@ async def stream_task_events(
151161
)
152162
raise
153163
except Exception as e:
154-
logger.error(
155-
f"Error processing events for task {task_id}: {e}",
156-
exc_info=True,
157-
)
164+
consecutive_errors += 1
165+
# Single-line logging on the hot error path. A full
166+
# multi-line traceback is emitted only once per stream (the
167+
# first failure) so we keep one diagnostic copy without
168+
# multiplying log volume by ~20x per failed read cycle.
169+
# Subsequent repeats log a single compact line.
170+
if consecutive_errors == 1:
171+
logger.error(
172+
f"Error processing events for task {task_id}: {e}",
173+
exc_info=True,
174+
)
175+
else:
176+
logger.error(
177+
f"Error processing events for task {task_id} "
178+
f"(repeat #{consecutive_errors}): {e}"
179+
)
158180
yield f"data: {TaskStreamErrorEventEntity(type='error', message=str(e)).model_dump_json()}\n\n"
159-
# Add a small delay before continuing
160-
await asyncio.sleep(1)
181+
# Exponential backoff (capped) so a sustained failure (e.g.
182+
# Redis pool exhaustion) doesn't spin a tight per-client
183+
# loop hammering Redis and flooding logs.
184+
backoff = min(2.0 ** min(consecutive_errors - 1, 5), 30.0)
185+
await asyncio.sleep(backoff)
161186

162187
except asyncio.CancelledError:
163188
# Just exit the generator on cancellation

agentex/src/utils/logging.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@
1414
# Check if Datadog is configured
1515
_is_datadog_configured = bool(os.environ.get("DD_AGENT_HOST"))
1616

17+
# Emit structured JSON logs in all deployed environments. JSON keeps a
18+
# multi-line traceback (exc_info=True) as a single log entry — the newlines
19+
# live inside the quoted `exc_info` field — instead of fanning out into one
20+
# cluster-log entry per traceback line. Splitting tracebacks per line was a
21+
# primary multiplier behind a log-ingestion spike on a plain-text-logging
22+
# cluster. Local development keeps plain text for readable console output;
23+
# JSON is the default everywhere else (including when ENVIRONMENT is unset)
24+
# so a deployed cluster can never silently fall back to per-line tracebacks.
25+
_is_local_dev = os.environ.get("ENVIRONMENT", "").lower() == "development"
26+
_use_json_logs = not _is_local_dev
27+
1728
# Include Datadog trace IDs only when Datadog is configured
1829
if _is_datadog_configured:
1930
LOG_FORMAT: str = (
@@ -134,7 +145,7 @@ def make_logger(name: str) -> logging.Logger:
134145

135146
logger = logging.getLogger(name)
136147
stream_handler = logging.StreamHandler()
137-
if _is_datadog_configured:
148+
if _use_json_logs:
138149
stream_handler.setFormatter(CustomJSONFormatter())
139150
else:
140151
stream_handler.setFormatter(logging.Formatter(LOG_FORMAT))

0 commit comments

Comments
 (0)