fix(api): prevent dropped workflow_started events in Redis Streams - #40964
fix(api): prevent dropped workflow_started events in Redis Streams#40964zl86790 wants to merge 10 commits into
Conversation
Pyrefly Type Coverage
|
Pyrefly Diffbase → PR--- /tmp/pyrefly_base.txt 2026-08-19 09:02:40.754971977 +0000
+++ /tmp/pyrefly_pr.txt 2026-08-19 09:02:27.721953129 +0000
@@ -8087,35 +8087,39 @@
ERROR Argument `FakeRedisClient` is not assignable to parameter `client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis._subscription.RedisSubscriptionBase.__init__` [bad-argument-type]
--> tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py:1127:24
ERROR Class member `FailExpireRedis.expire` overrides a member in a parent class but is missing an `@override` decorator [missing-override-decorator]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:75:9
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:81:9
+ERROR Class member `FailXrevrangeRedis.xrevrange` overrides a member in a parent class but is missing an `@override` decorator [missing-override-decorator]
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:86:9
ERROR Argument `FakeStreamsRedis` is not assignable to parameter `redis_client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel.StreamsBroadcastChannel.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:147:36
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:158:36
ERROR Argument `FakeStreamsRedis` is not assignable to parameter `redis_client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel.StreamsBroadcastChannel.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:173:41
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:184:41
ERROR Argument `FakeStreamsRedis` is not assignable to parameter `redis_client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel.StreamsBroadcastChannel.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:180:41
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:191:41
ERROR Argument `FailExpireRedis` is not assignable to parameter `redis_client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel.StreamsBroadcastChannel.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:193:43
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:204:43
+ERROR Argument `FailXrevrangeRedis` is not assignable to parameter `redis_client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel.StreamsBroadcastChannel.__init__` [bad-argument-type]
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:259:43
ERROR Argument `FakeStreamsRedis` is not assignable to parameter `redis_client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel.StreamsBroadcastChannel.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:244:43
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:339:43
ERROR Argument `TestStreamsSubscription.test_listener_normalizes_supported_payloads_and_ignores_unsupported_shapes.OneShotRedis` is not assignable to parameter `client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel._StreamsSubscription.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:269:45
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:364:45
ERROR Argument `object` is not assignable to parameter `o` with type `Buffer | Iterable[SupportsIndex] | SupportsBytes | SupportsIndex` in function `bytes.__new__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:277:35
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:372:35
ERROR Argument `TestStreamsSubscription.test_listener_ignores_close_signal_from_another_subscription.OneShotRedis` is not assignable to parameter `client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel._StreamsSubscription.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:302:45
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:397:45
ERROR Argument `FakeStreamsRedis` is not assignable to parameter `client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel._StreamsSubscription.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:346:45
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:441:45
ERROR Argument `FakeStreamsRedis` is not assignable to parameter `client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel._StreamsSubscription.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:354:45
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:449:45
ERROR Argument `FakeStreamsRedis` is not assignable to parameter `client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel._StreamsSubscription.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:363:45
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:458:45
ERROR Argument `FakeStreamsRedis` is not assignable to parameter `client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel._StreamsSubscription.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:371:45
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:466:45
ERROR Argument `BlockingRedis` is not assignable to parameter `client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel._StreamsSubscription.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:391:45
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:486:45
ERROR Argument `FakeStreamsRedis` is not assignable to parameter `client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel._StreamsSubscription.__init__` [bad-argument-type]
- --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:409:45
+ --> tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py:504:45
ERROR Class member `TestCronCompatibility.setUp` overrides a member in a parent class but is missing an `@override` decorator [missing-override-decorator]
--> tests/unit_tests/libs/test_cron_compatibility.py:21:9
ERROR Class member `TestTimezoneCompatibility.setUp` overrides a member in a parent class but is missing an `@override` decorator [missing-override-decorator]
@@ -8561,15 +8565,15 @@
ERROR Argument `Literal['app']` is not assignable to parameter `value` with type `ApiTokenType | SQLCoreOperations[ApiTokenType]` in function `sqlalchemy.orm.base.Mapped.__set__` [bad-argument-type]
--> tests/unit_tests/services/test_api_token_service.py:44:26
ERROR Class member `_TrackingRateLimit.exit` overrides a member in a parent class but is missing an `@override` decorator [missing-override-decorator]
- --> tests/unit_tests/services/test_app_generate_service.py:574:17
+ --> tests/unit_tests/services/test_app_generate_service.py:614:17
ERROR Class member `_TrackingRateLimit.exit` overrides a member in a parent class but is missing an `@override` decorator [missing-override-decorator]
- --> tests/unit_tests/services/test_app_generate_service.py:608:17
+ --> tests/unit_tests/services/test_app_generate_service.py:648:17
ERROR Class member `_TrackingRateLimit.exit` overrides a member in a parent class but is missing an `@override` decorator [missing-override-decorator]
- --> tests/unit_tests/services/test_app_generate_service.py:640:17
+ --> tests/unit_tests/services/test_app_generate_service.py:680:17
ERROR Argument `_FakeStreams` is not assignable to parameter `redis_client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.streams_channel.StreamsBroadcastChannel.__init__` [bad-argument-type]
- --> tests/unit_tests/services/test_app_generate_service_streaming_integration.py:96:36
+ --> tests/unit_tests/services/test_app_generate_service_streaming_integration.py:108:36
ERROR Argument `_FakeRedisClient` is not assignable to parameter `redis_client` with type `Redis[Unknown] | RedisCluster[Unknown]` in function `libs.broadcast_channel.redis.pubsub_channel.BroadcastChannel.__init__` [bad-argument-type]
- --> tests/unit_tests/services/test_app_generate_service_streaming_integration.py:116:34
+ --> tests/unit_tests/services/test_app_generate_service_streaming_integration.py:129:34
ERROR Argument `str | None` is not assignable to parameter `tenant_id` with type `str` in function `services.app_service.AppService.create_app` [bad-argument-type]
--> tests/unit_tests/services/test_app_service.py:118:17
ERROR Argument `str | None` is not assignable to parameter `tenant_id` with type `str` in function `services.app_service.AppService.create_app` [bad-argument-type]
|
| channel_type = dify_config.PUBSUB_REDIS_CHANNEL_TYPE | ||
| if channel_type == "streams": | ||
| # With Redis Streams, we can safely start right away; consumers can read past events. | ||
| checkpoint = getattr(topic, "checkpoint", None) |
There was a problem hiding this comment.
we could check the type of topic instead of using getattr
There was a problem hiding this comment.
Updated to check StreamsTopic explicitly instead of using getattr. Thanks :P
There was a problem hiding this comment.
Pull request overview
This PR fixes a race in the Redis Streams event transport where workflow_started (and early node events) can be published by a fast Celery worker before the SSE listener issues its first XREAD, causing the SSE client to miss those events when the subscription starts from $. The fix introduces a synchronous stream checkpoint captured before dispatch, then resumes consumption from that checkpoint once the SSE subscriber actually begins reading.
Changes:
- Add
StreamsTopic.checkpoint()andStreamsTopic.subscribe_from()so subscribers can resume from an explicitly captured stream position rather than lazily resolving$. - Capture a streams checkpoint in
AppGenerateService._build_streaming_task_on_subscribe()before dispatching the background task, and plumbstart_idintoretrieve_events()/stream_topic_events(). - Expand and correct unit/integration tests (including improving the fake Redis Streams
$semantics) to reproduce and prevent the dispatch-before-subscribe ordering regression.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| api/services/app_generate_service.py | Captures a streams checkpoint before dispatch and passes start_id through streaming generators. |
| api/libs/broadcast_channel/redis/streams_channel.py | Adds checkpoint() / subscribe_from() and supports explicit start_id in the subscription listener. |
| api/core/app/apps/streaming_utils.py | Adds start_id parameter and uses subscribe_from(start_id) when provided. |
| api/core/app/apps/message_based_app_generator.py | Threads start_id into stream_topic_events() for message-based streaming. |
| api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py | Adds unit tests for checkpointing + subscribe-from behavior and error propagation. |
| api/tests/unit_tests/services/test_app_generate_service.py | Updates unit tests to new (on_subscribe, start_id) return and adds checkpoint ordering/error tests. |
| api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py | Adds regression coverage for dispatch-before-subscribe ordering and fixes fake Redis $ behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
@QuantumGhost Could you review this stability enhancement on Redis message streaming? |
|
AI disclosure: This comment was drafted with Codex using GPT-5.6-sol. I have reviewed it and am responsible for the content. I agree with capturing the stream position before dispatching the Celery task, but I think the Redis Streams cursor/checkpoint details should remain inside the broadcast-channel abstraction. After some thought, I would propose capturing the The suggested fix is like the following: class _StreamsSubscription(Subscription):
def __init__(self, client: Redis | RedisCluster, key: str):
self._client = client
self._key = key
self._start_id: bytes | str | None = None
...
def _resolve_start_id(self) -> bytes | str:
entries = self._client.xrevrange(self._key, count=1)
if not entries:
return "0-0"
entry_id, _fields = entries[0]
return entry_id
def _start_if_needed(self) -> None:
"""This method must be called with `_lock` held."""
if self._listener is not None or self._closed:
return
# Fix the logical read boundary synchronously before `__enter__`
# returns and before the producer can be started.
self._start_id = self._resolve_start_id()
self._listener = threading.Thread(
target=self._listen,
name=f"redis-streams-sub-{self._key}",
daemon=True,
)
self._listener.start()
def _listen(self) -> None:
start_id = self._start_id
assert start_id is not None
last_id = start_id
...This approach minimizes change scope, avoids leaking unnecessary concepts into application services. The race currently looks like this: |
|
hi @QuantumGhost, |
#40948
Summary
When using Redis Streams as the event transport (
EVENT_BUS_REDIS_CHANNEL_TYPE=streams), theworkflow_startedevent (and sometimes the first few node events) can silently disappear on concurrent streaming runs. The events are actually in Redis — the SSE subscriber just never sees them.The root cause: the workflow execution task gets dispatched to Celery right away, before the SSE response has even started streaming. The Redis Streams subscription only gets set up later, and it starts reading from
$(only new messages). If the worker publishesworkflow_startedbefore that subscription exists, it's gone for good. This shows up much more often under concurrent load, which matches what's reported in the issue (55% loss with 8 concurrent requests).Reverting
$back to0-0isn't an option — that was changed in #34030 specifically to stop replaying staleworkflow_pausedevents on reconnect (#34040), and reverting it would bring that bug straight back.Fix: grab a checkpoint (the current tail of the stream) synchronously, before dispatching the task. The subscriber then resumes from that exact point once it actually connects, no matter how late that happens. If reading the checkpoint itself fails, we let it fail loudly instead of quietly falling back to something unsafe.
Pub/sub and sharded transports aren't affected — they already gate task start on a real subscription with a fallback timer, so this failure mode doesn't apply to them.
Fixes #40948
Tests
checkpoint()/subscribe_from()methods on the streams channel: reading the last entry id, empty-stream case, checkpoint errors propagating instead of being swallowed, and that a subscriber resuming from a checkpoint picks up events published before it actually starts listening (while still not replaying anything from before the checkpoint).workflow_startedandworkflow_finished— before the subscriber even callsretrieve_events(), matching the real ordering. Also fixed the fake Redis Streams test double, which didn't model$semantics at all and had been masking this bug.ruff check— clean.make type-checklocally, worth double-checking in CI.Checklist
make lint && make type-check(backend) andcd web && pnpm exec vp staged(frontend) to appease the lint gods