feat(streaming): make heartbeat interval configurable (#5017)

Co-authored-by: Wuong <26929475+Wuong@users.noreply.github.com>
This commit is contained in:
Wuong 2026-08-30 10:46:01 +08:00 committed by GitHub
parent 2f8d1cfc21
commit e12925458a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 173 additions and 27 deletions

View File

@ -328,7 +328,7 @@ Browser login uses `HttpOnly` session cookies. The login page offers a "keep me
DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser-facing scheme and origin behind a proxy. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header. Configure the outer trusted proxy to replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
> [!IMPORTANT]
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE and `/wait` consumers also refresh durable status on heartbeats as a fallback if terminal publication fails. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE, `/wait`, and internal stream consumers use `stream_bridge.heartbeat_interval_seconds` (default `15`) for idle liveness checks; changing it requires a Gateway restart. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
>
> Run cancellation may land on any Gateway worker. A non-owning worker now persists the interrupt or rollback request for the live owner, which observes it during lease renewal and performs the normal cancellation flow; load-balancer routing alone no longer produces a 409. The first accepted action wins even if a retry lands on the owner, and accepted cancellation competes atomically with owner completion. Dead owners still follow lease takeover and orphan recovery. Cancellation latency is therefore bounded by the lease heartbeat interval.
>

View File

@ -143,7 +143,7 @@ sequenceDiagram
关键组件:
- `runtime/runs/worker.py::run_agent` — 在 `asyncio.Task` 里跑 `agent.astream()`,把每个 chunk 通过 `serialize(chunk, mode=mode)` 转成 JSON`bridge.publish()`
- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。Memory 和 Redis 都只保留 `queue_maxsize` 条数据事件;游标早于保留水位线时返回 `StreamGap`不会从当前最早事件静默部分重放。Redis backend 会在每次 `publish()` / `publish_end()` 刷新 retained stream key TTL启动恢复与基于 worker lease 的周期恢复共用 Gateway stream terminalization 路径:`RunManager` 先将 orphan run 持久化为 `error` 并写入显式的 `stop_reason=orphan_recovered`,随后 Gateway 发布 `END_SENTINEL` 并安排 stream cleanup。周期扫描、逐行状态写入和 Gateway callback 作为一个受监督的 single-flight 后台 task 执行;慢任务不会堆积,也不会阻塞唯一的 lease heartbeat。shutdown 优先收敛活跃 run再处理恢复 task尚未执行的延迟 stream cleanup 会改为立即删除。只有 runtime `yield` 前、无并发请求的启动恢复会把最新受影响 thread 标记为 error周期恢复不做非原子的 thread 投影。store-only SSE 与 `/wait` consumer 不能把普通 durable terminal status 当成流已完成,否则可能跳过延迟发布的 error 等尾部事件;只有 `orphan_recovered` 信号能在 heartbeat 时触发 END fallback因为此时 producer 已被确认失联。TTL 仍是 Redis 内存和故障安全网,不是正常的 subscriber 终止机制。
- `runtime/stream_bridge` — 抽象 Queue。`publish/subscribe` 解耦生产者和消费者,支持 `Last-Event-ID` 重连、心跳、多订阅者 fan-out。`stream_bridge.heartbeat_interval_seconds`(默认 15 秒)是 bridge 实例的默认心跳周期,统一作用于 Gateway SSE、`/wait` 和内部 channel watcher显式传给 `subscribe()` 的值仍可覆盖单次订阅。Memory 和 Redis 都只保留 `queue_maxsize` 条数据事件;游标早于保留水位线时返回 `StreamGap`不会从当前最早事件静默部分重放。Redis backend 会在每次 `publish()` / `publish_end()` 刷新 retained stream key TTL启动恢复与基于 worker lease 的周期恢复共用 Gateway stream terminalization 路径:`RunManager` 先将 orphan run 持久化为 `error` 并写入显式的 `stop_reason=orphan_recovered`,随后 Gateway 发布 `END_SENTINEL` 并安排 stream cleanup。周期扫描、逐行状态写入和 Gateway callback 作为一个受监督的 single-flight 后台 task 执行;慢任务不会堆积,也不会阻塞唯一的 lease heartbeat。shutdown 优先收敛活跃 run再处理恢复 task尚未执行的延迟 stream cleanup 会改为立即删除。只有 runtime `yield` 前、无并发请求的启动恢复会把最新受影响 thread 标记为 error周期恢复不做非原子的 thread 投影。store-only SSE 与 `/wait` consumer 不能把普通 durable terminal status 当成流已完成,否则可能跳过延迟发布的 error 等尾部事件;只有 `orphan_recovered` 信号能在 heartbeat 时触发 END fallback因为此时 producer 已被确认失联。TTL 仍是 Redis 内存和故障安全网,不是正常的 subscriber 终止机制。
- `app/gateway/services.py::sse_consumer` — 从 bridge 订阅,格式化为 SSE wire 帧。
- `runtime/serialization.py::serialize` — mode-aware 序列化;`messages` mode 下 `serialize_messages_tuple``(chunk, metadata)` 转成 `[chunk.model_dump(), metadata]`

View File

@ -1,10 +1,12 @@
"""Configuration for stream bridge."""
from typing import Literal
from typing import Any, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
StreamBridgeType = Literal["memory", "redis"]
DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15.0
MAX_HEARTBEAT_INTERVAL_SECONDS = 86_400.0
class StreamBridgeConfig(BaseModel):
@ -23,14 +25,21 @@ class StreamBridgeConfig(BaseModel):
ge=1,
description="Maximum number of events retained per run (memory bridge queue size / redis stream MAXLEN).",
)
heartbeat_interval_seconds: float = Field(
default=DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
gt=0,
le=MAX_HEARTBEAT_INTERVAL_SECONDS,
allow_inf_nan=False,
description=("Idle seconds between stream heartbeats (maximum 86400). Applies to SSE clients, non-streaming wait requests, and internal stream subscribers."),
)
max_connections: int | None = Field(
default=None,
description=(
"Max Redis connections in the pool for the redis stream bridge. Each live SSE "
"client holds one connection blocked in XREAD ... BLOCK for up to heartbeat_interval "
"(15s), so hundreds of concurrent clients open hundreds of connections. Leave unset "
"for redis-py's default (effectively unbounded), or set a ceiling sized for peak "
"concurrent SSE clients. Only applies to the redis bridge."
"client holds one connection blocked in XREAD ... BLOCK for up to the configured "
"heartbeat_interval_seconds, so hundreds of concurrent clients open hundreds of "
"connections. Leave unset for redis-py's default (effectively unbounded), or set a "
"ceiling sized for peak concurrent SSE clients. Only applies to the redis bridge."
),
)
stream_ttl_seconds: int = Field(
@ -48,6 +57,14 @@ class StreamBridgeConfig(BaseModel):
description=("Seconds to wait after publishing an END marker for a recovered orphaned run before deleting the stream key. Gives reconnecting SSE clients time to drain the end signal. Only applies to the redis bridge."),
)
@field_validator("heartbeat_interval_seconds", mode="before")
@classmethod
def reject_boolean_heartbeat_interval(cls, value: Any) -> Any:
"""Reject booleans before Pydantic coerces them to floats."""
if isinstance(value, bool):
raise ValueError("heartbeat_interval_seconds must be a number, not a boolean")
return value
# Global configuration instance — None means no stream bridge is configured
# (falls back to memory with defaults).

View File

@ -1,3 +1,7 @@
### Stream Bridge Heartbeats
Memory and Redis bridges take their default idle heartbeat cadence from the startup-only `stream_bridge.heartbeat_interval_seconds` setting. Keep the default on the bridge instance so SSE, `/wait`, and internal subscribers stay aligned; an explicit `subscribe(..., heartbeat_interval=...)` remains a per-subscription override.
### Checkpoint Channel Modes (`full` / `delta`)
Checkpointer storage runs in one of two channel modes, selected by `checkpoint_channel_mode` in `config.yaml` (default `full`). `delta` mode adopts LangGraph 1.2's `DeltaChannel` for `messages`: checkpoints store a sentinel + per-step writes instead of the full message list, so storage/serde grows O(N) instead of O(N²) in turns. All checkpointer backends (memory/sqlite/postgres) serve both modes unchanged — the semantics live in the compiled graph's channel table, not in the saver.

View File

@ -21,7 +21,7 @@ from collections.abc import AsyncIterator
from deerflow.config.app_config import AppConfig
from deerflow.config.stream_bridge_config import StreamBridgeConfig, get_stream_bridge_config
from .base import StreamBridge
from .base import DEFAULT_HEARTBEAT_INTERVAL_SECONDS, StreamBridge
logger = logging.getLogger(__name__)
@ -58,8 +58,16 @@ async def make_stream_bridge(app_config: AppConfig | None = None) -> AsyncIterat
from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge
maxsize = config.queue_maxsize if config is not None else 256
bridge = MemoryStreamBridge(queue_maxsize=maxsize)
logger.info("Stream bridge initialised: memory (queue_maxsize=%d)", maxsize)
heartbeat_interval = config.heartbeat_interval_seconds if config is not None else DEFAULT_HEARTBEAT_INTERVAL_SECONDS
bridge = MemoryStreamBridge(
queue_maxsize=maxsize,
heartbeat_interval=heartbeat_interval,
)
logger.info(
"Stream bridge initialised: memory (queue_maxsize=%d, heartbeat_interval_seconds=%.1f)",
maxsize,
heartbeat_interval,
)
try:
yield bridge
finally:
@ -73,12 +81,14 @@ async def make_stream_bridge(app_config: AppConfig | None = None) -> AsyncIterat
bridge = RedisStreamBridge(
redis_url=redis_url,
queue_maxsize=config.queue_maxsize,
heartbeat_interval=config.heartbeat_interval_seconds,
max_connections=config.max_connections,
stream_ttl_seconds=config.stream_ttl_seconds,
)
logger.info(
"Stream bridge initialised: redis (queue_maxsize=%d, max_connections=%s, stream_ttl_seconds=%d)",
"Stream bridge initialised: redis (queue_maxsize=%d, heartbeat_interval_seconds=%.1f, max_connections=%s, stream_ttl_seconds=%d)",
config.queue_maxsize,
config.heartbeat_interval_seconds,
config.max_connections,
config.stream_ttl_seconds,
)

View File

@ -8,10 +8,13 @@ architecture.
from __future__ import annotations
import abc
import math
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
from deerflow.config.stream_bridge_config import DEFAULT_HEARTBEAT_INTERVAL_SECONDS, MAX_HEARTBEAT_INTERVAL_SECONDS
@dataclass(frozen=True)
class StreamEvent:
@ -56,6 +59,25 @@ class StreamBridge(abc.ABC):
supports_cross_process: bool = False
def __init__(self, *, heartbeat_interval: float = DEFAULT_HEARTBEAT_INTERVAL_SECONDS) -> None:
self._heartbeat_interval = self._validate_heartbeat_interval(heartbeat_interval)
@property
def heartbeat_interval(self) -> float:
"""Default number of idle seconds between subscriber heartbeats."""
return self._heartbeat_interval
def _resolve_heartbeat_interval(self, heartbeat_interval: float | None) -> float:
if heartbeat_interval is None:
return self._heartbeat_interval
return self._validate_heartbeat_interval(heartbeat_interval)
@staticmethod
def _validate_heartbeat_interval(heartbeat_interval: float) -> float:
if isinstance(heartbeat_interval, bool) or not isinstance(heartbeat_interval, (int, float)) or not math.isfinite(heartbeat_interval) or heartbeat_interval <= 0 or heartbeat_interval > MAX_HEARTBEAT_INTERVAL_SECONDS:
raise ValueError(f"heartbeat_interval must be a positive finite number no greater than {MAX_HEARTBEAT_INTERVAL_SECONDS:g} seconds")
return float(heartbeat_interval)
@abc.abstractmethod
async def publish(self, run_id: str, event: str, data: Any) -> None:
"""Enqueue a single event for *run_id* (producer side)."""
@ -70,14 +92,15 @@ class StreamBridge(abc.ABC):
run_id: str,
*,
last_event_id: str | None = None,
heartbeat_interval: float = 15.0,
heartbeat_interval: float | None = None,
) -> AsyncIterator[StreamItem]:
"""Async iterator that yields events for *run_id* (consumer side).
Yields :data:`HEARTBEAT_SENTINEL` when no event arrives within
*heartbeat_interval* seconds. Yields :data:`END_SENTINEL` once
the producer calls :meth:`publish_end`. Yields :class:`StreamGap` and
stops when the subscriber has fallen behind retained history.
*heartbeat_interval* seconds, or the bridge's configured default when
omitted. Yields :data:`END_SENTINEL` once the producer calls
:meth:`publish_end`. Yields :class:`StreamGap` and stops when the
subscriber has fallen behind retained history.
"""
@abc.abstractmethod

View File

@ -10,7 +10,7 @@ from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from typing import Any
from .base import END_SENTINEL, HEARTBEAT_SENTINEL, StreamBridge, StreamEvent, StreamGap, StreamItem
from .base import DEFAULT_HEARTBEAT_INTERVAL_SECONDS, END_SENTINEL, HEARTBEAT_SENTINEL, StreamBridge, StreamEvent, StreamGap, StreamItem
logger = logging.getLogger(__name__)
_MEMORY_STREAM_ID_RE = re.compile(r"\d+-(\d+)")
@ -31,7 +31,8 @@ class MemoryStreamBridge(StreamBridge):
and reconnecting clients can replay buffered events from ``Last-Event-ID``.
"""
def __init__(self, *, queue_maxsize: int = 256) -> None:
def __init__(self, *, queue_maxsize: int = 256, heartbeat_interval: float = DEFAULT_HEARTBEAT_INTERVAL_SECONDS) -> None:
super().__init__(heartbeat_interval=heartbeat_interval)
self._maxsize = max(1, queue_maxsize)
self._streams: dict[str, _RunStream] = {}
self._counters: dict[str, int] = {}
@ -126,8 +127,9 @@ class MemoryStreamBridge(StreamBridge):
run_id: str,
*,
last_event_id: str | None = None,
heartbeat_interval: float = 15.0,
heartbeat_interval: float | None = None,
) -> AsyncIterator[StreamItem]:
heartbeat_interval = self._resolve_heartbeat_interval(heartbeat_interval)
stream = self._get_or_create_stream(run_id)
async with stream.condition:
start = self._resolve_start_offset(stream, last_event_id)

View File

@ -27,7 +27,7 @@ except ImportError: # pragma: no cover - only hit when the optional extra is mi
"Or switch to stream_bridge.type: memory in config.yaml for single-process deployment."
) from None
from .base import END_SENTINEL, HEARTBEAT_SENTINEL, StreamBridge, StreamEvent, StreamGap, StreamItem
from .base import DEFAULT_HEARTBEAT_INTERVAL_SECONDS, END_SENTINEL, HEARTBEAT_SENTINEL, StreamBridge, StreamEvent, StreamGap, StreamItem
logger = logging.getLogger(__name__)
@ -64,10 +64,12 @@ class RedisStreamBridge(StreamBridge):
redis_url: str,
queue_maxsize: int = 256,
key_prefix: str = "deerflow:stream_bridge",
heartbeat_interval: float = DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
max_connections: int | None = None,
stream_ttl_seconds: int | None = 86400,
client: Redis | None = None,
) -> None:
super().__init__(heartbeat_interval=heartbeat_interval)
self._redis_url = redis_url
self._maxsize = max(1, queue_maxsize)
self._key_prefix = key_prefix.rstrip(":")
@ -227,8 +229,9 @@ class RedisStreamBridge(StreamBridge):
run_id: str,
*,
last_event_id: str | None = None,
heartbeat_interval: float = 15.0,
heartbeat_interval: float | None = None,
) -> AsyncIterator[StreamItem]:
heartbeat_interval = self._resolve_heartbeat_interval(heartbeat_interval)
key = self._stream_key(run_id)
stream_id = await self._resolve_start_stream_id(key, last_event_id)
gap_detection_enabled = last_event_id is not None and self._parse_stream_id(last_event_id) is not None

View File

@ -10,7 +10,7 @@ import anyio
import pytest
from pydantic import ValidationError
from deerflow.config.stream_bridge_config import StreamBridgeConfig, set_stream_bridge_config
from deerflow.config.stream_bridge_config import MAX_HEARTBEAT_INTERVAL_SECONDS, StreamBridgeConfig, set_stream_bridge_config
from deerflow.runtime import END_SENTINEL, HEARTBEAT_SENTINEL, MemoryStreamBridge, StreamGap, make_stream_bridge
# RedisStreamBridge is no longer re-exported from deerflow.runtime (redis is an
@ -214,6 +214,19 @@ async def test_heartbeat(bridge: MemoryStreamBridge):
assert received[0] is HEARTBEAT_SENTINEL
@pytest.mark.anyio
async def test_memory_bridge_uses_configured_default_heartbeat():
"""A subscriber may omit its override and inherit the bridge setting."""
bridge = MemoryStreamBridge(queue_maxsize=256, heartbeat_interval=0.01)
run_id = "run-configured-heartbeat"
bridge._get_or_create_stream(run_id)
entry = await asyncio.wait_for(anext(bridge.subscribe(run_id)), timeout=1.0)
assert entry is HEARTBEAT_SENTINEL
assert bridge.heartbeat_interval == 0.01
@pytest.mark.anyio
async def test_cleanup(bridge: MemoryStreamBridge):
"""After cleanup, the run's stream/event log is removed."""
@ -947,11 +960,77 @@ async def test_redis_blocking_wakeup_error_gives_up_after_max_retries():
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"heartbeat_interval",
[
True,
False,
0,
-1,
float("inf"),
float("-inf"),
float("nan"),
MAX_HEARTBEAT_INTERVAL_SECONDS + 1,
],
)
def test_stream_bridge_config_rejects_invalid_heartbeat_interval(heartbeat_interval):
with pytest.raises(ValidationError):
StreamBridgeConfig(heartbeat_interval_seconds=heartbeat_interval)
def test_stream_bridge_config_accepts_numeric_heartbeat_string():
config = StreamBridgeConfig(heartbeat_interval_seconds="2.5")
assert config.heartbeat_interval_seconds == 2.5
@pytest.mark.parametrize("heartbeat_interval", [True, MAX_HEARTBEAT_INTERVAL_SECONDS + 1])
def test_memory_bridge_rejects_invalid_default_heartbeat(heartbeat_interval):
with pytest.raises(ValueError, match="heartbeat_interval"):
MemoryStreamBridge(heartbeat_interval=heartbeat_interval)
@pytest.mark.anyio
async def test_redis_bridge_rejects_oversized_subscription_heartbeat_before_io():
fake = _FakeRedis()
async def fail_if_called(*_args, **_kwargs):
pytest.fail("Redis I/O must not start for an invalid heartbeat interval")
fake.xrange = fail_if_called
bridge = RedisStreamBridge(redis_url="redis://fake", client=fake)
with pytest.raises(ValueError, match="heartbeat_interval"):
await anext(
bridge.subscribe(
"redis-run-oversized-heartbeat",
heartbeat_interval=MAX_HEARTBEAT_INTERVAL_SECONDS + 1,
)
)
@pytest.mark.anyio
async def test_make_stream_bridge_defaults():
"""make_stream_bridge() with no config yields a MemoryStreamBridge."""
async with make_stream_bridge() as bridge:
assert isinstance(bridge, MemoryStreamBridge)
assert bridge.heartbeat_interval == 15.0
@pytest.mark.anyio
async def test_make_stream_bridge_passes_memory_heartbeat():
set_stream_bridge_config(
StreamBridgeConfig(
type="memory",
heartbeat_interval_seconds=2.5,
)
)
try:
async with make_stream_bridge() as bridge:
assert isinstance(bridge, MemoryStreamBridge)
assert bridge.heartbeat_interval == 2.5
finally:
set_stream_bridge_config(None)
# ---------------------------------------------------------------------------
@ -1161,6 +1240,7 @@ async def test_make_stream_bridge_passes_redis_options(monkeypatch):
StreamBridgeConfig(
type="redis",
redis_url="redis://fake:6379/0",
heartbeat_interval_seconds=2.5,
max_connections=50,
stream_ttl_seconds=42,
)
@ -1168,6 +1248,7 @@ async def test_make_stream_bridge_passes_redis_options(monkeypatch):
try:
async with make_stream_bridge() as bridge:
assert isinstance(bridge, RedisStreamBridge)
assert bridge.heartbeat_interval == 2.5
assert bridge._stream_ttl_seconds == 42
assert captured["max_connections"] == 50
assert captured["decode_responses"] is True

View File

@ -15,7 +15,7 @@
# ============================================================================
# Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 37
config_version: 38
# ============================================================================
# Logging
@ -2334,12 +2334,18 @@ run_ownership:
# type: memory # single-process only
# queue_maxsize: 256 # data events retained per run (min: 1). A reconnect
# # older than this window receives SSE `gap`.
# heartbeat_interval_seconds: 15 # idle interval (max: 86400) between
# # heartbeats sent to SSE, wait, and internal
# # stream consumers.
#
# stream_bridge:
# type: redis # recommended for Docker / multi-worker gateway
# redis_url: redis://redis:6379/0
# queue_maxsize: 256 # data events retained per run (Redis MAXLEN, min: 1).
# # Older valid cursors receive SSE `gap`.
# heartbeat_interval_seconds: 15 # idle interval (max: 86400) between
# # heartbeats sent to SSE, wait, and internal
# # stream consumers.
# stream_ttl_seconds: 86400 # rolling TTL for retained stream buffers.
# # Refreshed on each publish/publish_end; set 0
# # to disable. This is not a run timeout.
@ -2348,8 +2354,8 @@ run_ownership:
# # before deleting the stream key.
# max_connections: 100 # optional pool ceiling. Each live SSE client
# # holds one connection blocked in XREAD ... BLOCK
# # for up to heartbeat_interval (15s), so hundreds
# # of concurrent clients open hundreds of
# # for up to the configured heartbeat interval, so
# # hundreds of concurrent clients open hundreds of
# # connections. Unset = redis-py default (unbounded).
#
# NOTE: the redis bridge is fail-hard in v1. Redis.from_url is lazy, so a down

View File

@ -124,7 +124,7 @@ they resolve from the `secrets` map):
```yaml
config: |
config_version: 37
config_version: 38
models:
- name: gpt-4
use: langchain_openai:ChatOpenAI

View File

@ -243,7 +243,7 @@ ingress:
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
# inline literal secret values here. The default enables provisioner sandbox.
config: |
config_version: 37
config_version: 38
log_level: info
models: []