fix(realtime): limit subscriptions per connection by HarshMN2345 · Pull Request #14527 · appwrite/appwrite · GitHub
Skip to content

fix(realtime): limit subscriptions per connection - #14527

Open
HarshMN2345 wants to merge 9 commits into
mainfrom
fix-realtime-subscription-limit
Open

HarshMN2345 wants to merge 9 commits into
mainfrom
fix-realtime-subscription-limit

Conversation

@HarshMN2345

@HarshMN2345 HarshMN2345 commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

A realtime connection can now hold at most 100 subscriptions and 100 distinct channels (APP_LIMIT_REALTIME_SUBSCRIPTIONS, APP_LIMIT_REALTIME_CHANNELS). Both caps are checked on the URL handshake and on subscribe messages, before any state changes. Going over sends a 1008 policy-violation error and closes the connection, the same as other subscribe policy violations. The SDKs don't reconnect after a 1008.

The channel cap is needed too: a client can reuse one subscription ID with new channels, which grows the subscription tree without adding to the subscription count. The caps are constants, like the other realtime limits (message size, message and connect rates).

This builds on #14440. I removed the per-connection subscription index, the 4096-character query limit (stricter than REST; queries are already bounded by the 64 KB message size), the two env vars, and its tests.

Verified:

  • pint --test on the touched files
  • PHPStan on Subscribe.php
  • A scratch script that calls the Subscribe handler directly. A 101st subscription is rejected. Updating an existing ID at the cap is still allowed. A 101-item batch is rejected and leaves nothing behind. Reusing an ID on a 101st channel is rejected.

Closes #14430

Related: #14440 (merged into this branch)

jackeddisciple and others added 8 commits October 13, 2026 17:55
One connection could keep adding subscriptions until the realtime worker ran out of memory and dropped every other client with it. Connections now stop at 100 subscriptions and 100 channels (both configurable), a subscription's queries can be at most 4096 chars, and the subscription count is tracked as we go instead of rebuilding the whole metadata array on every message.

Fixes #14430
…on-limits

fix(realtime): limit subscriptions per connection
…tion-limit

# Conflicts:
#	tests/e2e/Services/Realtime/RealtimeCustomClientTest.php
#	tests/unit/Messaging/MessagingTest.php
A 4096-character total per subscription is stricter than REST, which allows 4096 per query. Subscription queries stay bounded by the 64 KB message size.
Count a batch's distinct subscription IDs and channels against what the connection already holds, instead of keeping a second per-connection subscription index that every subscribe, unsubscribe and re-auth path has to maintain. Both counts are bounded by the caps, so reading the tree stays cheap.
Other realtime limits (message size, message and connect rate) are fixed values, so the subscription and channel caps follow them instead of adding two environment variables.
@tenki-reviewer

tenki-reviewer Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

@hansi-codes

hansi-codes Bot commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

🟡 Tier B · Needs changes before merging

Concurrent tail subscription batches can bypass the new per-connection cap.

This change adds fixed per-connection limits for realtime subscriptions and distinct channels, enforcing them during URL handshakes and subscribe messages. It also adds end-to-end coverage for requests at and above those limits.

Verdict New comments Fixed Still open
🛑 Changes requested 1 0 0
Finding Where
🟠 Make the cap check safe across concurrent tail subscriptions src/Appwrite/Realtime/Message/Handlers/Subscribe.php:88
Fix with agent prompt
### Issue 1
src/Appwrite/Realtime/Message/Handlers/Subscribe.php:88-95
**Make the cap check safe across concurrent tail subscriptions**

Two concurrent `subscribe` messages can both pass these checks against the same pre-update state, then yield while the handler authorizes `console.tail.*` channels through the database before either batch mutates the tree. For example, with 99 existing subscriptions, two one-subscription tail batches can each pass at 100 and then leave 101 subscriptions, bypassing the limit.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
📂 Walkthrough · 5
File Change
app/init/constants.php Defines the subscription and distinct-channel limits.
app/realtime.php Rejects URL handshakes that exceed either limit.
src/Appwrite/Realtime/Message/Handlers/Subscribe.php Preflights each subscribe batch against the connection's current subscriptions and channels.
tests/e2e/Services/Realtime/RealtimeCustomClientQueryTestWithMessage.php Covers subscription and channel limits on subscribe messages.
tests/e2e/Services/Realtime/RealtimeCustomClientTest.php Covers subscription and channel limits during the URL handshake.

Reviewed a5a8bfa · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

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.

🟡 Tier B · 1 blocking finding to address. Summary

Comment on lines +88 to +95
if (\count(\array_unique($subscriptionIds)) > APP_LIMIT_REALTIME_SUBSCRIPTIONS) {
throw new Exception(
Exception::REALTIME_POLICY_VIOLATION,
'Too many subscriptions. A connection can have at most ' . APP_LIMIT_REALTIME_SUBSCRIPTIONS . ' subscriptions.'
);
}

if (\count(\array_unique($channels)) > APP_LIMIT_REALTIME_CHANNELS) {

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.

Make the cap check safe across concurrent tail subscriptions

Two concurrent subscribe messages can both pass these checks against the same pre-update state, then yield while the handler authorizes console.tail.* channels through the database before either batch mutates the tree. For example, with 99 existing subscriptions, two one-subscription tail batches can each pass at 100 and then leave 101 subscriptions, bypassing the limit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Appwrite/Realtime/Message/Handlers/Subscribe.php
Line: 88-95

Comment:
**Make the cap check safe across concurrent tail subscriptions**

Two concurrent `subscribe` messages can both pass these checks against the same pre-update state, then yield while the handler authorizes `console.tail.*` channels through the database before either batch mutates the tree. For example, with 99 existing subscriptions, two one-subscription tail batches can each pass at 100 and then leave 101 subscriptions, bypassing the limit.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟠 Major · concurrency · Reply if this doesn't apply.

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Adds subscription/channel limits to realtime connections, but the batch limit check races concurrent subscribe frames under Swoole and can be exceeded.

Key findings

Comment on lines +81 to +100
$subscriptionIds = \array_keys($existing);
$channels = $realtime->connections[$connectionId]['channels'] ?? [];
foreach ($parsedPayloads as $parsedPayload) {
$subscriptionIds[] = $parsedPayload['subscriptionId'];
$channels = [...$channels, ...$parsedPayload['convertedChannels']];
}

if (\count(\array_unique($subscriptionIds)) > APP_LIMIT_REALTIME_SUBSCRIPTIONS) {
throw new Exception(
Exception::REALTIME_POLICY_VIOLATION,
'Too many subscriptions. A connection can have at most ' . APP_LIMIT_REALTIME_SUBSCRIPTIONS . ' subscriptions.'
);
}

if (\count(\array_unique($channels)) > APP_LIMIT_REALTIME_CHANNELS) {
throw new Exception(
Exception::REALTIME_POLICY_VIOLATION,
'Too many channels. A connection can subscribe to at most ' . APP_LIMIT_REALTIME_CHANNELS . ' channels.'
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 bug · high

Batch limit check races concurrent subscribe frames

The whole-batch limit check snapshots existing subscriptions and connection channels (src/Appwrite/Realtime/Message/Handlers/Subscribe.php:81), but the state-mutating $realtime->subscribe() calls happen later at src/Appwrite/Realtime/Message/Handlers/Subscribe.php:160 with coroutine yields (the console-DB tail authorization reads) in between. Swoole runs each websocket frame in its own coroutine and concurrent messages on one connection interleave, as documented at app/realtime.php:1233, so two back-to-back subscribe frames both validate against the same pre-batch counts and both apply. A client can thereby exceed APP_LIMIT_REALTIME_SUBSCRIPTIONS and APP_LIMIT_REALTIME_CHANNELS on a single connection, defeating the memory-bound limit this change introduces.

📋 Prompt for AI Agents

In src/Appwrite/Realtime/Message/Handlers/Subscribe.php, the limit checks at lines 81-100 validate against a snapshot taken before the handler's DB awaits (tail authorization reads around line 134), while the apply loop runs at lines 160-169 after those yields. Because Swoole runs each websocket frame in its own coroutine and concurrent frames on one connection interleave (documented at app/realtime.php), two concurrent subscribe batches can both pass the check and both apply, exceeding APP_LIMIT_REALTIME_SUBSCRIPTIONS and APP_LIMIT_REALTIME_CHANNELS. Add per-connection mutual exclusion: acquire a per-connectionId lock (e.g. a Swoole channel/mutex keyed by connectionId, registered in the message container) at the top of the handler and release it in a finally block after the subscribe loop and tail registration, so snapshot, yields, and apply are serialized per connection. Alternatively enforce the caps inside Realtime::subscribe (src/Appwrite/Messaging/Adapter/Realtime.php) against live tree state so the invariant holds regardless of caller interleaving.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → fix-realtime-subscription-limit (after).

Metric Before After Change
🚀 Requests/sec 205.69 212.63 ⚪ +3.4%
⏱️ Latency P50 85.36 ms 81.6 ms ⚪ -4.4%
⏱️ Latency P95 193.56 ms 187.55 ms ⚪ -3.1%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 81.6 187.55 13,566 212.63 -6.02
Account 153.82 296.37 714 11.85 +9.56
TablesDB 79.83 146.38 7,378 118.67 -6.85
Storage 73.54 163.47 3,570 58.79 -19.14
Functions 115.63 218.33 1,904 31.94 +0.37

Top API waits (after)

API request Max wait (ms)
account.prefs.update 460.07
functions.variables.delete 444.85
account.name.update 419.69
functions.variables.update 403.52
functions.delete 403.22

@github-actions

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Bug Report: Unbounded realtime subscriptions per connection crash the realtime worker and disconnect all its clients

2 participants