Send-only broadcasts do not restart event sampling after idle shutdown
Maintainers usually reply within 1 day
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 54/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- elixir, postgresql
- Domain
- backend, observability
Research direction
Start with BroadcastHandler.increment_rate_counter/1 and trace how receiving channels wake event samplers; then read RateCounter.init/1 and its idle shutdown behavior. Run the reproduction with the repository test databases and confirm the sampler is absent after send-only traffic. Done means accepted broadcasts after the shutdown cache entry expires restart sampling using current tenant configuration and produce a sampled sum of 3.
Written by the indexing model from the issue text.
Description
Bug report
- I confirm this is a bug with Supabase, not with my own application.
- I searched issues, PRs, discussions, branches, and local history for this behavior.
Describe the bug
An existing WebSocket channel that sends Broadcast messages with self: false and no other listeners cannot restart its tenant events sampler after idle shutdown. Messages are acknowledged successfully and increment the raw counter, but no sampler consumes those increments or emits the corresponding channel event telemetry. Continued send-only traffic leaves the sampler absent even after its shutdown cache entry expires.
To reproduce
On upstream main f5f0dab9, set up the documented repository test databases. Save the following as /tmp/broadcast_idle_repro_test.exs and run mix test /tmp/broadcast_idle_repro_test.exs.
This uses a real channel and valid Broadcast messages. It sends the sampler's normal :idle_shutdown timer message while its sampled bucket is empty, avoiding a five-minute wait. It observes normal process termination and waits for the existing one-second shutdown cache grace to expire before resuming traffic.
defmodule BroadcastIdleReproductionTest do
use RealtimeWeb.ChannelCase, async: false
use Mimic
setup :set_mimic_from_context
alias Realtime.{RateCounter, Tenants}
alias RealtimeWeb.UserSocket
test "a send-only channel wakes its event sampler after ordinary idle shutdown" do
tenant = TestTenantDb.checkout_tenant(run_migrations: true)
jwt = Generators.generate_jwt_token(tenant)
{:ok, socket} =
connect(UserSocket, %{},
connect_info: %{
uri: URI.parse("https://#{tenant.external_id}.localhost:4000/socket/websocket"),
x_headers: [{"x-api-key", jwt}]
}
)
socket = subscribe_and_join!(socket, "realtime:idle", %{"config" => %{"broadcast" => %{"ack" => true}}})
args = Tenants.events_per_second_rate(tenant)
[{counter_pid, _}] = Registry.lookup(Realtime.Registry.Unique, {RateCounter, :rate_counter, args.id})
{:ok, %{sum: 0}} = RateCounterHelper.tick!(args)
monitor = Process.monitor(counter_pid)
send(counter_pid, :idle_shutdown)
assert_receive {:DOWN, ^monitor, :process, ^counter_pid, :normal}, 1000
assert_eventually RateCounter.find(args.id) == {:error, :not_found}, timeout: 3000
for i <- 1..3 do
ref = push(socket, "broadcast", %{"event" => "message", "payload" => %{"value" => i}})
assert_reply ref, :ok, %{}, 1000
end
Phoenix.Channel.Server.socket(socket.channel_pid)
assert {:ok, %RateCounter{}} = RateCounter.find(args.id)
assert {:ok, %RateCounter{sum: 3}} = RateCounterHelper.tick!(args)
end
end
Upstream fails at RateCounter.find(args.id) with {:error, :not_found}, after all three success acknowledgements. With the fix, the sampler exists and its sampled sum is 3.
Expected behavior
The first accepted Broadcast after the sampler's shutdown cache entry expires restarts event sampling. The resumed messages contribute to the tenant's sampled events and telemetry, including when the sender has no receiving clients.
Actual behavior
The sampler stays absent. All three acknowledged messages, and further send-only traffic, accumulate outside the sampling process. A later channel join or a receiving channel's counter check can restart it, but this supported send-only path cannot.
Root cause and impact
BroadcastHandler.increment_rate_counter/1 only calls GenCounter.add/1. The dispatcher wakes samplers through receiving channels. With self: false and no other listeners, there is no receiving channel to request that wake-up. RateCounter deliberately stops after five minutes with an empty bucket, and its transient supervisor does not restart a normal idle exit.
The fix must ensure the sampler exists before incrementing because RateCounter.init/1 resets the raw counter. Restart arguments must use current tenant configuration, preserving #2275. This differs from #2322's concurrent reset window and #2381's quota cache ordering.
The reproduced impact is missing tenant event sampling and telemetry. This report does not claim billing impact or add sender-side rate enforcement. The existing one-second shutdown cache grace is unchanged; this reproduction and proposed fix cover traffic after that entry expires.
System information
- macOS arm64.
- Upstream main
f5f0dab9. - Elixir 1.19.5 and OTP 28.5.0.8.
- PostgreSQL 17.11.
- Phoenix ChannelTest rather than a client SDK.
- Dominant language
- Elixir
- Stars
- 7.7k
- Forks
- 479
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 74
Getting set up
Starts the project's dev container in your browser, under your own GitHub account.
- Ships a Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from supabase/realtime
-
Bulk tenant health rejects exactly 500 refs, the documented maximumPossibly taken @hsusul claimed this 1 day ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 25/100
Maintainers usually reply within 1 day
-
Private Presence diffs delivered after track auto-enables Presence are not countedPossibly taken @hsusul claimed this 3 days ago. Open
Difficulty 3/5 1-2 days Newbie friendliness 76/100
Maintainers usually reply within 1 day
-
Publication refresh can remove a successful Postgres Changes subscription without retrying itPossibly taken @hsusul claimed this 3 days ago. Open
Difficulty 4/5 3-5 days Newbie friendliness 48/100
Maintainers usually reply within 1 day
-
ReplicationPoller never reaches recovery thresholds after repeated polling failuresPossibly taken @hsusul claimed this 4 days ago. Open
Difficulty 3/5 1-2 days Newbie friendliness 35/100
Maintainers usually reply within 1 day
-
Broadcast can omit a newly joined region until RegionRings reconcilesPossibly taken @hsusul claimed this 4 days ago. Open
Difficulty 3/5 1-2 days Newbie friendliness 68/100
Maintainers usually reply within 1 day
All issues in supabase/realtime
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
hexlet-codebattle/codebattle#2374 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
intellij-elixir/intellij-elixir#4585 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
mathieuprog/tz#44 ·
-
documentation
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
Maintainers usually reply within 1 day
-
easy elixir first-contributor first-contributors good first issue help wanted up-for-grabs
Difficulty 1/5 Under an hour Newbie friendliness 88/100
fork-commit-merge/fork-commit-merge#8454 ·
Maintainers usually reply within 1 day