Skip to content

feat(realtime): wait for postgres changes, report channel errors, time out joins - #1446

Draft
sproctor wants to merge 4 commits into
supabase:masterfrom
sproctor:postgres-changes-wait
Draft

sproctor wants to merge 4 commits into
supabase:masterfrom
sproctor:postgres-changes-wait

Conversation

@sproctor

Copy link
Copy Markdown
Contributor

Four commits, each building and passing the realtime tests on its own, so they can be reviewed one at a time.

What changed

  1. A channel can wait for its postgres changes subscription. A channel is SUBSCRIBED as soon as the server accepts the join, which can be before the server has set up its postgres_changes subscription, or refused it. Like realtime-js's postgres_changes_options, postgresChangesOptions { wait = true } in the channel builder asks the server (supabase/realtime#2109) to hold the join until the subscription is set up. With it, SUBSCRIBED means changes are being received. If the subscription can't be set up, the server rejects the join and the channel retries like after any other channel error. timeout sets how long the server waits. The option is off by default, and servers without support ignore it.
  2. errorFlow() reports channel errors, like realtime-js's CHANNEL_ERROR. A RealtimeChannelError is emitted for a rejected join, with the server's reason (for example RealtimeDisabledForConfiguration: ... for a refused subscription), and for a phx_error. Before, the reason only reached the logs. A failed reply to an ordinary push isn't a channel error and isn't emitted.
  3. Unanswered joins time out, like realtime-js's TIMED_OUT. A join the server never answered used to wait forever. Each join attempt now times out after the new Realtime.Config.timeout (10 s, realtime-js's default). A timeout emits a TIMED_OUT error on errorFlow() and rejoins, and the rejoin counts toward maxAttempts. When the channel has postgres changes and waits for them, the timeout is extended past the server's wait, as in realtime-js: max(timeout, (options.timeout ?: 15 s) + 10 s). Rejected joins, channel errors and timeouts now share one path that reports the error, then rejoins or gives up.
  4. subscribe(blockUntilSubscribed = true) ends when the channel stops joining. It waited for SUBSCRIBED with no other way out, so it never returned once the channel gave up or was closed. It also hung if the channel subscribed and then closed before the wait started, because the wait missed SUBSCRIBED. Now it waits on the status together with why the channel stopped joining. Both are state, so a wait that starts late still sees the outcome. When the channel gives up or is closed, the call throws the new RealtimeChannelJoinException, which carries the error the channel gave up after (null for a close). A lost connection doesn't end the wait, because the channel rejoins when the client reconnects.

Behavior changes

  • A join that's never answered now times out after 10 s and retries, then gives up after maxAttempts (5 by default). Before, it waited forever.
  • subscribe(blockUntilSubscribed = true) throws RealtimeChannelJoinException in the cases where it used to hang.
  • errorFlow() is a new member of the RealtimeChannel interface, so classes outside the library that implement RealtimeChannel need to add it.

Differences from realtime-js

  • The timeout is set only in the config. realtime-js also takes one per call in subscribe(callback, timeout).
  • Rejoins keep supabase-kt's fixed rejoinDelay and maxAttempts. realtime-js (through Phoenix) backs off and retries forever.

Why

We hit the failure that wait is for in production. An app release subscribed to a table that wasn't in the supabase_realtime publication yet. The channel reported SUBSCRIBED, and the refusal arrived later as a system message that the app didn't check. With wait, the channel doesn't report SUBSCRIBED until the subscription is set up, and the reason for a refusal comes through errorFlow(). The timeout and the blocking subscribe fix close the cases where a channel would otherwise wait forever.

Testing

  • RealtimeChannelTest: the options are sent with the join (wait, and timeout in milliseconds), and nothing is sent by default.
  • RealtimeChannelRejoinTest, against ScriptedWebsocket:
    • Errors: a rejected join and a phx_error reach errorFlow(), with the reason for the rejected join. The error that makes the channel give up is emitted too. A failed reply to an ordinary push isn't emitted.
    • Timeouts: an unanswered join times out and is retried. Timed-out joins give up after maxAttempts. An answered join doesn't time out. A rejected join doesn't also time out while its rejoin is pending.
    • Join timeout values: a unit test covers the extended timeout with and without postgres changes, wait and a server timeout.
    • Blocking subscribe: it throws when the channel gives up (carrying the rejection) and when the channel is closed (with a null error). It can succeed on a later subscribe after giving up. It returns when the channel closes right after subscribing. It keeps waiting through a lost connection.
  • CallbackManagerTest: error callbacks.
  • I checked each test by breaking the code it guards and confirming the test failed. For example: no timeout cancel when a rejoin is scheduled, a blocking wait that ends on any UNSUBSCRIBED, and a close that isn't recorded.
  • ./gradlew :realtime-kt:jvmTest :realtime-kt:detekt pass. The JS, Wasm, LinuxX64 and common metadata targets compile.

Not changed here

  • A channel that gives up after maxAttempts is UNSUBSCRIBED, but it doesn't send a leave and isn't removed from subscriptions.
  • If the access token fails to load during a join, the channel retries forever.

🤖 Generated with Claude Code

sproctor and others added 4 commits October 29, 2026 18:04
A channel is SUBSCRIBED as soon as it joins, which can be before the server
has set up its postgres_changes subscription, or refused it. Like
realtime-js's postgres_changes_options, postgresChangesOptions { wait = true }
asks the server (Realtime with supabase/realtime#2109) to hold the join until
the subscription is set up, so SUBSCRIBED means changes are being received.
If it can't be set up, the server rejects the join and the channel retries
it like any other channel error. Off by default; servers without support
ignore it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Like realtime-js's CHANNEL_ERROR, errorFlow() emits a RealtimeChannelError
for a rejected join, with the server's reason (e.g. a postgres changes
subscription it refused for a channel that waits for it), and for a
phx_error. The reason used to reach only the logs. A failed reply to an
ordinary push isn't a channel error and isn't emitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A join the server never answered waited forever. Like realtime-js, each
join attempt now times out after Realtime.Config.timeout (10 s by default):
the channel emits a TIMED_OUT error on errorFlow() and rejoins, which counts
toward maxAttempts. For a channel that waits for its postgres changes
subscription, the timeout outlasts the server's wait, as in realtime-js.

Rejected joins, channel errors and timeouts now share one path that reports
the error, then rejoins or gives up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
subscribe(blockUntilSubscribed = true) waited for SUBSCRIBED with no other
way out, so it never returned once the channel gave up or was closed. It
also hung if the channel subscribed and closed before the wait started,
since the wait then missed SUBSCRIBED.

It now waits on the status together with why the channel stopped joining,
both state, so a late wait still sees the outcome. When the channel gives
up or is closed, it throws RealtimeChannelJoinException, with the error it
gave up after (null for a close). A lost connection doesn't end the wait,
since the channel rejoins when the client reconnects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants