Skip to content

refactor(coordination): reliable Redis Streams await/notify (replace at-most-once pub/sub) - #43409

Merged
villebro merged 5 commits into
apache:gaq-to-gtffrom
villebro:villebro/coordination-streams
Aug 22, 2026
Merged

refactor(coordination): reliable Redis Streams await/notify (replace at-most-once pub/sub)#43409
villebro merged 5 commits into
apache:gaq-to-gtffrom
villebro:villebro/coordination-streams

Conversation

@villebro

Copy link
Copy Markdown
Member

Part of the GAQ→GTF epic — the coordination cleanup ("C") step, targeting the gaq-to-gtf feature branch.

SUMMARY

Moves the coordination service's await/notify off at-most-once pub/sub onto Redis Streams (guaranteed delivery), and migrates GTF task completion/abort onto it.

Why. Redis Pub/Sub is documented at-most-once / fire-and-forget — "if the subscriber is unable to handle the message (for example, due to an error or a network disconnect) the message is forever lost." To stay correct on top of that, the old wait_for_signal treated a published message as a mere nudge and re-read the task row every ~1s as a backstop — i.e. it degraded to polling even when Redis was available (and each poll loaded the full Task ORM). Redis's own recommendation for guaranteed delivery is Streams (persisted, replayable), which lets the wait be genuinely event-driven with no busy-poll.

What changed.

  • CoordinationService.notify(channel) appends a signal to the channel's Redis Stream (MAXLEN 1 + TTL). Guaranteed delivery: a waiter that reads slightly late, reconnects, or survives a failover still sees it.
  • wait_for_signal / listen_for_signal block on the stream (event-driven, no polling) when DISTRIBUTED_COORDINATION_CONFIG is set — capturing a baseline id first so no signal is missed (race-free). Without a backend they poll the caller's predicate exactly as before. The predicate stays the source of truth, so a duplicate/late signal is harmless.
  • TaskManager.publish_completion / publish_abort emit via notify (streams).
  • Pub/Sub (publish) is retained only as an explicit best-effort, at-most-once nudge — documented as such — and is no longer used for awaiting. SignalListener no longer wraps a subscription.
  • New backend primitives xread (blocking), stream_last_id (baseline), expire; and config DISTRIBUTED_COORDINATION_SIGNAL_TTL (default 24h) bounds signal-stream retention so streams for never-awaited tasks cannot accumulate.

Delivery model (documented). Streams for anything that must be delivered (task completion/abort — the typical case); pub/sub only for loss-tolerant nudges; DB polling as the no-backend fallback. Docs updated in the GTF developer guide and the cache admin guide.

BEFORE / AFTER

  • Before: backend present → wake on a lossy pub/sub message + re-read the full Task ORM every ~1s as a delivery backstop.
  • After: backend present → block on the stream, wake the instant the signal lands, zero polling; delivery guaranteed. No backend → reliable predicate polling (unchanged).

TESTING INSTRUCTIONS

  • Unit (green locally, 396 across the affected suites): tests/unit_tests/coordination/test_service.py (notify + stream wait + socket-timeout retry), tests/unit_tests/tasks/test_manager.py (completion/abort via streams, stream wait), tests/unit_tests/async_events/test_cache_backend.py (xread/stream_last_id/expire).
  • Manual (live Redis/Valkey): with DISTRIBUTED_COORDINATION_CONFIG set, run a sync join-and-wait / a task DAG and confirm dependents wake promptly with no per-second DB reads; confirm signal stream keys (gtf:complete:*, gtf:abort:*) carry a TTL and self-expire.

ADDITIONAL INFORMATION

  • Has associated issue
  • Required feature flags: GLOBAL_TASK_FRAMEWORK
  • Changes UI
  • Includes DB Migration
  • Introduces new feature or API (CoordinationService.notify; DISTRIBUTED_COORDINATION_SIGNAL_TTL config)
  • Removes existing feature or API

Note: the whole-project frontend type-check has pre-existing failures unrelated to this change.

Add xread (blocking, reliable delivery), stream_last_id (baseline capture for
race-free waits), and expire (bound signal-stream growth) to both
RedisCacheBackend and RedisSentinelCacheBackend. Foundation for moving GTF
wait/notify off at-most-once pub/sub onto Redis Streams.
…ub to nudges

Move the coordination service's await/notify off at-most-once pub/sub onto Redis
Streams (guaranteed delivery), and migrate GTF task completion/abort onto it.

- CoordinationService.notify() appends a signal to the channel's stream (MAXLEN 1
  + TTL) so a waiter that reads late, reconnects, or survives a failover still sees
  it. wait_for_signal/listen_for_signal now block on a stream (event-driven, no
  polling) when a coordination backend is set, capturing a baseline id first so no
  signal is missed; without a backend they poll the caller's predicate exactly as
  before. The predicate stays the source of truth, so duplicate/late signals are
  harmless. Pub/sub (publish) is retained only as an explicit best-effort,
  at-most-once nudge — documented as such — and is no longer used for awaiting.
- TaskManager.publish_completion/publish_abort emit via notify() (streams).
- SignalListener no longer wraps a pub/sub subscription.
- New config DISTRIBUTED_COORDINATION_SIGNAL_TTL (default 24h) bounds signal-stream
  retention so streams for never-awaited tasks cannot accumulate.

Unit tests updated (coordination + task manager) and green locally (390 passed).
…livery model

Add unit tests for RedisCacheBackend.xread/stream_last_id/expire (delegation,
None->[], bytes-id decode, empty->0-0). Document the coordination service's
reliable Streams-based signalling (event-driven when a backend is set, DB-poll
fallback otherwise) and the DISTRIBUTED_COORDINATION_SIGNAL_TTL retention config
in the GTF developer docs and the cache admin docs.
@bito-code-review

bito-code-review Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Bito Automatic Review Skipped - Branch Excluded

Bito didn't auto-review because the source or target branch is excluded from automatic reviews.
No action is needed if you didn't intend for the agent to review it. Otherwise, to manually trigger a review, type /review in a comment and save.
You can change the branch exclusion settings here, or contact your Bito workspace admin at evan@preset.io.

@github-actions github-actions Bot added the doc Namespace | Anything related to documentation label Aug 22, 2026
@netlify

netlify Bot commented Aug 22, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit c2b6546
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a890931350050000834cae7
😎 Deploy Preview https://deploy-preview-43409--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Describe the coordination notifications as event-driven over Redis Streams and
explain the mechanism (persisted entries → delivered after a late read/reconnect/
failover) without subjective 'reliable/guaranteed/instant' framing, in the docs
and docstrings. Also refresh stale pub/sub references in TaskManager now that
signalling rides streams.
@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.31818% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.88%. Comparing base (f3ae075) to head (6051274).
⚠️ Report is 1 commits behind head on gaq-to-gtf.

Files with missing lines Patch % Lines
superset/coordination/base.py 91.66% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           gaq-to-gtf   #43409   +/-   ##
===========================================
  Coverage       78.88%   78.88%           
===========================================
  Files            2882     2882           
  Lines          164678   164697   +19     
  Branches        38024    38027    +3     
===========================================
+ Hits           129899   129918   +19     
- Misses          32332    32333    +1     
+ Partials         2447     2446    -1     
Flag Coverage Δ
hive 38.11% <26.13%> (-0.01%) ⬇️
mysql 57.81% <38.63%> (-0.02%) ⬇️
postgres 57.85% <38.63%> (-0.02%) ⬇️
presto 40.04% <26.13%> (-0.01%) ⬇️
python 83.58% <94.31%> (+<0.01%) ⬆️
sqlite 57.54% <38.63%> (-0.02%) ⬇️
unit 73.60% <94.31%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@villebro
villebro marked this pull request as ready for review August 22, 2026 02:59
@dosubot dosubot Bot added change:backend Requires changing the backend global:async-query Related to Async Queries feature labels Aug 22, 2026
@villebro

Copy link
Copy Markdown
Member Author

/review

@bito-code-review

bito-code-review Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #42f078

Actionable Suggestions - 0
Additional Suggestions - 2
  • tests/unit_tests/coordination/test_service.py - 1
    • Magic number in test assertion · Line 177-177
      Use the constant `_SIGNAL_STREAM_MAXLEN` (defined in `base.py:47`) instead of the hardcoded `1` so this assertion stays in sync if the stream cap ever changes.
  • superset/tasks/manager.py - 1
    • Logging level inconsistent · Line 114-114
      `publish_abort` is best-effort with no observable correctness impact when Redis errors occur — listeners fall back to polling. Using warning instead of error prevents false alarm logs in production when transient Redis errors occur.
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/coordination/base.py - 4
Review Details
  • Files reviewed - 9 · Commit Range: cde9cec..c2b6546
    • docs/admin_docs/configuration/cache.mdx
    • superset/async_events/cache_backend.py
    • superset/config.py
    • superset/coordination/base.py
    • superset/coordination/types.py
    • superset/tasks/manager.py
    • tests/unit_tests/async_events/test_cache_backend.py
    • tests/unit_tests/coordination/test_service.py
    • tests/unit_tests/tasks/test_manager.py
  • Files skipped - 1
    • docs/developer_docs/extensions/tasks.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

…h; review nits

Address PR apache#43409 review: downgrade best-effort signal-failure logs to warning
(listeners/waiters fall back to polling), assert the MAXLEN constant instead of a
literal in the notify test, add a Sentinel-backend stream-helpers test, and add a
backend-path listen test — closing the codecov gaps on the Sentinel backend and
the listener stream loop.
@villebro
villebro merged commit c7fcebf into apache:gaq-to-gtf Aug 22, 2026
47 checks passed
Comment on lines +309 to +310
Each signal stream keeps only its latest entry and is given a TTL, so signal streams for tasks that
are never awaited do not accumulate in Redis/Valkey. Set the retention window with

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The retention description incorrectly states that each stream keeps only its latest entry. The coordination backend passes MAXLEN without disabling Redis-py's default approximate trimming, so Redis may retain more than one entry. Document this as an approximate bound or change the implementation to request exact trimming if retaining exactly one entry is required. [docstring mismatch]

Severity Level: Minor 🧹
- ⚠️ Redis streams may retain multiple signal entries.
- ⚠️ Documentation overstates stream trimming precision.
- ⚠️ Repeated notifications can use extra temporary Redis memory.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** docs/admin_docs/configuration/cache.mdx
**Line:** 309:310
**Comment:**
	*Docstring Mismatch: The retention description incorrectly states that each stream keeps only its latest entry. The coordination backend passes `MAXLEN` without disabling Redis-py's default approximate trimming, so Redis may retain more than one entry. Document this as an approximate bound or change the implementation to request exact trimming if retaining exactly one entry is required.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@bito-code-review

Copy link
Copy Markdown
Contributor

The flagged issue is correct. The documentation states that each stream keeps only its latest entry, but the implementation uses xadd with MAXLEN=1 (implied by the test backend.xadd.assert_called_once_with(..., '*', 1)), which in Redis-py defaults to approximate trimming (MAXLEN ~ 1). This means Redis may retain more than one entry.

To resolve this, you can update the documentation to reflect that the retention is approximate, or update the xadd call to use approximate=False if exact trimming is required. Given the context of signal streams, approximate trimming is likely acceptable, so updating the documentation is the recommended approach.

Would you like me to fetch all other comments on this PR to validate and implement fixes for them as well?

docs/admin_docs/configuration/cache.mdx

-Each signal stream keeps only its latest entry and is given a TTL, so signal streams for tasks that
-are never awaited do not accumulate in Redis/Valkey. Set the retention window with
+Each signal stream keeps its latest entry (approximately) and is given a TTL, so signal streams for
+tasks that are never awaited do not accumulate in Redis/Valkey. Set the retention window with

:returns: redis-py XREAD reply — ``[[stream, [(id, {field: value}), ...]]]``
— or an empty list when nothing arrived before the block elapsed
"""
return self._cache.xread(streams, count=count, block=block_ms) or []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The helper forwards a blocking XREAD duration that can exceed the configured Redis socket timeout. In deployments setting CACHE_REDIS_SOCKET_TIMEOUT below the one-second or five-second block interval, redis-py raises a socket timeout before the requested block completes; _read_stream treats that as an empty read, causing repeated predicate/database checks instead of event-driven waiting. Ensure the read connection's socket timeout is compatible with the blocking duration or use a dedicated coordination connection. [performance]

Severity Level: Major ⚠️
- ⚠️ Task completion waits repeatedly query the metastore.
- ⚠️ Abort listeners lose event-driven waiting.
- ⚠️ Redis traffic increases with short socket timeouts.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/async_events/cache_backend.py
**Line:** 178:178
**Comment:**
	*Performance: The helper forwards a blocking `XREAD` duration that can exceed the configured Redis socket timeout. In deployments setting `CACHE_REDIS_SOCKET_TIMEOUT` below the one-second or five-second block interval, redis-py raises a socket timeout before the requested block completes; `_read_stream` treats that as an empty read, causing repeated predicate/database checks instead of event-driven waiting. Ensure the read connection's socket timeout is compatible with the blocking duration or use a dedicated coordination connection.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +319 to +326
last_id = cls._read_stream(
backend,
channel,
last_id,
cls._bounded_block_ms(_STREAM_BLOCK_MS, remaining),
)
if (result := check()) is not None:
return result

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The deadline is checked before _read_stream but not after it returns. If the blocking read consumes the remaining timeout, the code still invokes the database predicate and can return a successful result after the requested timeout has expired; an expensive predicate can also extend the overrun. Check the deadline again before running check() after each read. [logic error]

Severity Level: Minor 🧹
- ⚠️ Completion waits may exceed their configured timeout.
- ⚠️ Callers receive success after timeout expiration.
- ⚠️ Database predicate latency increases the overrun.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/coordination/base.py
**Line:** 319:326
**Comment:**
	*Logic Error: The deadline is checked before `_read_stream` but not after it returns. If the blocking read consumes the remaining timeout, the code still invokes the database predicate and can return a successful result after the requested timeout has expired; an expensive predicate can also extend the overrun. Check the deadline again before running `check()` after each read.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +340 to +343
cls,
channel: str,
check: Callable[[], T | None],
deadline: float | None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: A Redis connection or failover error from stream_last_id occurs before the try block, so the listener thread exits without logging the failure or falling back to database polling. Move backend initialization into the protected error-handling path and preserve the listener's fallback behavior when Redis is temporarily unavailable. [resource leak]

Severity Level: Major ⚠️
- ❌ Background task abort listener exits during Redis startup failure.
- ⚠️ Aborted tasks may continue executing.
- ⚠️ Listener failure is not logged by its handler.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/coordination/base.py
**Line:** 340:343
**Comment:**
	*Resource Leak: A Redis connection or failover error from `stream_last_id` occurs before the `try` block, so the listener thread exits without logging the failure or falling back to database polling. Move backend initialization into the protected error-handling path and preserve the listener's fallback behavior when Redis is temporarily unavailable.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend doc Namespace | Anything related to documentation global:async-query Related to Async Queries feature size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant