Appwrite Native Push Broker by ArnabChatterjee20k · Pull Request #13372 · appwrite/appwrite · GitHub
Skip to content

Appwrite Native Push Broker - #13372

Merged
ArnabChatterjee20k merged 160 commits into
mainfrom
feat-mqtt-push-poc
Sep 18, 2026
Merged

ArnabChatterjee20k merged 160 commits into
mainfrom
feat-mqtt-push-poc

Conversation

@ArnabChatterjee20k

@ArnabChatterjee20k ArnabChatterjee20k commented Aug 27, 2026 •

Copy link
Copy Markdown
Member

MQTT push broker (QoS 1 with offline replay)

Summary

This adds an MQTT 5.0 push broker to Appwrite as a self hosted alternative to FCM. A device connects to the broker over MQTT, authenticates with an Appwrite JWT or session, subscribes to a topic, and receives push messages.

Publishing stays a server privilege. Messages are sent through the existing Messaging campaign API, never over MQTT from a client. There is no client PUBLISH handler.

Delivery is QoS 1 with offline replay. If a device is offline when a message is published, the broker stores the message and replays it on the next reconnect, up to a bounded window.

Where the code lives

  • app/mqtt.php is the broker process. It runs a Swoole server on port 1883 (internal only). It parses each packet, dispatches it to a handler, runs the keep-alive reaper, and listens on the pub/sub channel for fan out.
  • app/init/mqtt/connection.php holds connection auth. It registers authenticator (turns a CONNECT into an identity) and authorizer (the per subscribe block check).
  • src/Appwrite/Mqtt/Handlers/ has one class per packet type: Connect, Auth, Subscribe, Unsubscribe, Puback, Ping, Disconnect.
  • src/Appwrite/Mqtt/Connection.php is the per connection state, keyed by file descriptor.
  • src/Appwrite/Mqtt/SubscriptionStore.php is the topic trie that matches subscribers to a topic.
  • src/Appwrite/Mqtt/KeepAlive.php is the timing wheel that reaps silent connections.
  • src/Appwrite/Messaging/Adapter/Mqtt.php is the broker adapter. It holds the connection registry and fans a published message out to local subscribers.
  • src/Appwrite/Messaging/Adapter/Push/Appwrite.php is the push provider. It writes each message to the ledger and hands it to the broker.

Connect and subscribe flow

sequenceDiagram
    participant D as Device (MQTT client)
    participant B as Broker (app/mqtt.php)
    participant DB as Project database
    participant R as Redis (cursor cache)
    participant W as Messaging worker

    D->>B: CONNECT (auth method, credential, projectId)
    B->>DB: resolve project and user, verify credential
    DB-->>B: user (or empty)
    B-->>D: CONNACK (0x00 accepted, 0x87 rejected)

    D->>B: SUBSCRIBE (topic filter, requested QoS)
    B->>DB: topic exists and its subscribe roles allow the user
    B-->>D: SUBACK (granted QoS per filter, or 0x87)
    B->>R: read the client cursor for the topic
    B->>DB: read ledger rows after the cursor (capped at maxDepth)
    B-->>D: PUBLISH x N (offline replay, DUP set)

    Note over W,B: later, a campaign is published
    W->>DB: append to ledger, increment topic sequence
    W->>B: fan out on the mqtt pub/sub channel
    B-->>D: PUBLISH (live, QoS 1)
    D->>B: PUBACK
    B->>R: advance the cursor
Loading

How to publish and subscribe

Publish is a normal server side campaign. It targets a topic and routes through the Appwrite push provider.

curl -X POST https://<endpoint>/v1/messaging/messages/push \
  -H "content-type: application/json" \
  -H "x-appwrite-project: <projectId>" \
  -H "x-appwrite-key: <serverApiKey>" \
  -d '{
    "messageId": "unique()",
    "topics": ["<topicId>"],
    "title": "Match update",
    "body": "India needs 12 off 6"
  }'

Subscribe is an MQTT 5 client. It connects over TLS to the Traefik mqtts entrypoint on 8883 and passes the credential through MQTT 5 enhanced auth. The topic filter is the topic id.

mosquitto_sub -V 5 -h <endpoint> -p 8883 --cafile <ca> \
  -t "<topicId>" -q 1 \
  -D CONNECT authentication-method appwrite-jwt \
  -D CONNECT authentication-data "<jwt>" \
  -D CONNECT user-property projectId "<projectId>"

The credential is a JWT (authentication-method: appwrite-jwt, data is the JWT string) or a session (authentication-method: appwrite-session, data is the session store blob, the same value a client holds as its session cookie).

What each packet handles

  • CONNECT. Parses the protocol level, keep-alive, clean-start flag, auth method and data, and the projectId user property. It resolves and verifies the user, rate limits the user, assigns a client id, and replies CONNACK.
  • SUBSCRIBE. Checks the account is not blocked, the topic exists, and the topic's subscribe roles include the user. It grants a QoS that is the requested value capped by the topic's configured QoS, stores the subscription in the trie, replies SUBACK, then replays any offline messages.
  • PUBLISH (server to broker, over pub/sub). Fans the message out to local subscribers at min(publish QoS, granted QoS) and tracks each QoS 1 delivery until its PUBACK.
  • PUBACK. Advances the client cursor to the highest contiguous acknowledged sequence, so a later ack cannot skip an earlier unacked message.
  • PINGREQ. Replies PINGRESP. Every inbound packet, including this one, refreshes the keep-alive deadline.
  • AUTH. Refreshes the credential for the same identity. It cannot switch the user or project.
  • DISCONNECT and transport close. Runs one cleanup path that removes the subscriptions, drops the connection from the keep-alive wheel, and balances the active-connection gauge.

Connection lifecycle

A connection is created on the first packet from a file descriptor. CONNECT authenticates it and marks it active. Once active, the broker schedules it in the keep-alive wheel.

The keep-alive wheel is a hashed timing wheel. Each connection sits in a bucket for its deadline second. A single timer runs every 20 seconds, drains the buckets that came due, and closes any client that has been silent for longer than keepAlive * 1.5. Client activity only moves a timestamp on the connection. It does not touch the wheel, so a busy client does not churn it.

Close is centralized. Whether the client sends DISCONNECT, the keep-alive reaper fires, or the socket drops, Swoole's onClose calls one method that unsubscribes the file descriptor, removes it from the wheel, and decrements the active gauge.

The whole packet lifecycle in onReceive is wrapped. A malformed packet or a handler failure is logged and closes only that connection. It never propagates and takes down the Swoole worker.

Offline replay: ledger, cursor, trie

Ledger. Every published message is appended to the appwritePushLedger collection with a per topic sequence number. The sequence comes from an atomic increment on topics.sequence. The increment and the insert run in one transaction, opened by locking the topic row, so two concurrent publishes cannot open a gap and a retry is idempotent on (messageId, topic).

Cursor. Each client has a cursor per topic in Redis, keyed by project, user, and client id. The cursor is the highest sequence the client has acknowledged. A PUBACK advances it. Nothing else does.

Replay. On SUBSCRIBE, the broker reads the ledger from cursor + 1 up to the current topic sequence, capped at a replay depth, and re-sends those messages with the DUP flag set. The depth comes from the user's org plan (getPlanForUser, self hosted default of 5 from _APP_MQTT_REPLAY_DEPTH). Replay is a QoS 1 guarantee, so a QoS 0 subscription is live only and is not replayed. A clean-start connection purges its cursor first.

Retention. The maintenance worker deletes ledger rows older than 7 days, which matches the maximum replay window.

Trie. SubscriptionStore is a tree keyed by project and topic segments. It resolves the set of file descriptors subscribed to a topic, each with its granted QoS, which is what the fan-out path iterates.

Security

  • The broker publishes no plaintext port. It is reachable from outside only through the Traefik mqtts entrypoint on 8883 with TLS, the same way realtime is reachable only through wss. Inside the network it listens on 1883.
  • Publishing is server only. There is no client PUBLISH path.
  • SUBSCRIBE enforces the topic's subscribe roles, so a user cannot subscribe to a topic they are not allowed on.
  • CONNECT is rate limited per user (128 per minute), gated by _APP_OPTIONS_ABUSE. It is keyed on userId for now because the infra does not surface the client IP to the broker.
  • A reauth cannot switch the user or project. A mismatch drops the connection.

Telemetry

New instruments cover auth duration, connection duration, message size, bytes in and out, reauth outcomes, and PUBACK counts, alongside the existing connection and delivery counters.

Tests and CI

  • Unit tests cover the timing wheel, the connection state, and the subscription trie.
  • End to end tests cover CONNECT auth (JWT and session, rejection, blocked user), the SUBSCRIBE ACL and QoS capping, campaign fan out, offline replay (including non contiguous acks and the depth cap), clean start, and keep-alive (a silent client is reaped, an active one survives).
  • A dedicated Tests / E2E / MQTT CI job boots the broker and runs the mqtt group. The general service lanes exclude that group so the broker dependent tests only run where the broker is guaranteed up.

Connectivity (broker topology)

Clients reach the broker two ways, both TLS-only and terminated at Traefik (the broker itself stays plaintext on the internal Docker network, mirroring how realtime's wss is terminated at the edge). The transport is addressed by host + dedicated port — there is no URL path — so a single push endpoint (e.g. push.cloud.appwrite.io) serves both native MQTT and MQTT-over-WebSocket:

Transport Entrypoint / port Router → broker
mqtts:// (native MQTT) appwrite_mqtt :8883 TCP, HostSNI(*), tls :1883
wss:// (MQTT over WebSocket) appwrite_mqtt_ws :8084 HTTP catch-all on that port, tls :8083
  • Native app/device clients use mqtts://<host>:8883; browsers use wss://<host>:8084 (MQTT 5.0 framed in WebSocket binary frames).
  • The reusable JWT/session credential in the CONNECT frame never crosses the network in the clear — neither transport is exposed over a plaintext port.
  • Ports are overridable via _APP_MQTT_PORT (8883) and _APP_MQTT_WSS_PORT (8084); the broker's internal WebSocket listener is _APP_MQTT_WS_PORT (8083).

…ement

Introduce an injected onConnect authenticator so the broker stays free of any
Appwrite dependency (extractable to utopia-php/mqtt). CONNECT resolves an
identity via the authenticator and rejects with CONNACK 0x87 on failure; the
AUTH packet re-verifies on reauth and enforces the projectId matches the one
pinned at CONNECT, so a connection cannot switch tenants. Fold the CONNECT and
AUTH property blocks into one reader, and name the response/reason-code bytes as
constants instead of raw hex.
The MQTT analog of app/init/realtime/connection.php, without the HTTP surface
(origin/CSRF, cookies, dev keys, impersonation). Resolves the project from the
CONNECT projectId and verifies the session secret via Store::decode +
User::sessionVerify — the same transport-agnostic domain logic HTTP uses.
Mirror the realtime process bootstrap (init, DB pool helpers, coroutine runtime)
and register the container-backed authenticator on the broker. The authenticator
builds a child container from the CONNECT projectId and session secret, resolves
project and user, and returns the identity (or empty to reject).
getConsoleDB() returns a Database with its own Authorization instance, so
$authorization->skip() was toggling a different object than the one the read
checks against — the permission-filtered projects lookup came back empty.
Bind the same Authorization before skipping, matching the user resolver.
Thread the CONNECT Authentication Method through the authenticator seam
(projectId, authMethod, credential) so the resolver can pick how to read the
credential. On "appwrite-jwt" the user factory decodes the HS256 JWT with
_APP_OPENSSL_KEY_V1 and checks the session is still active; otherwise it
Store-decodes the session blob and runs sessionVerify. Reauth passes the method
through the same way. Rename the connection credential from sessionSecret.
…and add blocked user connection rejection test
…e API

Collapse the per-packet handlers, Dispatcher and Response into a single
Appwrite\Mqtt\Handler that implements the library Handler interface; the
Server now owns framing, dispatch, encoding, keep-alive and the subscription
store. Resolve broker resources (cache, console/project DB, redis, plan)
through the container instead of global functions. Honour each topic's
expiry as the replay-cursor TTL (PUBACK preserves it with a 0-ttl save
rather than shrinking it to the default hour), and clamp delivery QoS to the
subscriber's granted QoS. Drop the adapter unit test whose registry/store
behaviour now lives in the library.
Comment thread app/mqtt.php
}, ['register']);
}

$container->set('getCache', fn () => function () use ($register): Cache {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

got the suggestion that we need them per coroutine context thats why not doing DI directly rather injecting them in the child container

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why? cache is pooled no?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Because of the same reason as othere. This is per coroutine and we are saving in the coroutine context

*
* @param iterable<array{0: Connection, 1: int}> $subscribers
*/
public function onPublish(Publish $publish, Connection $connection, iterable $subscribers): void

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

not needed the publish as its getting done internally via the workers here

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

maybe it'd be more elegant to give it a optional implemenation then?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not optional cause
No optional method possible in the php interfaces. Then we have to either create a default onPublish method or create a separate PublishHandler to have the onPublish. Feeling a bit confusing

publish is a valid spec in mqtt and should be added but we are blocking it and taking no action to block for user. Messaging worker is doing the publish via the pubsub.

…thorizer

The DI refactor moved the broker's DB/cache resolvers onto the container but
left the authorizer closure calling the removed global getProjectDB(), so every
SUBSCRIBE threw "Call to undefined function getProjectDB()" before the SUBACK
(CONNECT was unaffected as it only uses the authenticator).
Wire a request Authorization onto the broker's project/console DB handles and skip
on that same instance: the DB enforces permissions against the instance set via
setAuthorization, and topics can carry empty permissions, so the previous skip on
a stale/unwired Authorization silently filtered every topic and denied all grants.
onSubscribe now establishes the Authorization after the authorizer runs (it mutates
the shared handle). Align the SUBACK denial reason code assertions to the library's
0x80. Removes the temporary diagnostics.
Add a WebSocket listener (:8083) alongside raw TCP on the broker, and route
ws(s)://<host>/push to it through Traefik (TLS terminated at the edge, mirroring
mqtts and realtime's wss). Covers it with an e2e that speaks MQTT 5.0 over a
WebSocket binary frame end to end: enhanced-auth CONNECT and an ACL-checked
SUBSCRIBE granted at the topic's QoS.
…o PUBACK)

A subscription to a QoS-0 topic (grant capped from the client's QoS-1 request) is
delivered at QoS 0 and the client sends no PUBACK. Expose the received QoS from the
subscriber to lock the QoS-0/QoS-1 segregation in place.
Comment thread docker-compose.yml Outdated
Drop the plaintext port-80 ws router; the CONNECT frame carries a reusable JWT/session
credential, so route /push only through the websecure entrypoint (wss), matching the
TLS-only mqtts TCP router. Addresses the credentials-over-plaintext review finding.
…t a /push path

Give the WebSocket carrier its own TLS entrypoint (:8084, mqtt-wss) alongside the
mqtts TCP entrypoint (:8883), each a catch-all on its port. The transport is now
identified by host+port rather than a URL path, so one push endpoint (e.g.
push.cloud.appwrite.io) serves both mqtts:// and wss:// and it no longer shares
:443/paths with the API or realtime. Still TLS-only.
The WS transport is addressed by its dedicated TLS port, not a URL path, so the client
upgrades at / rather than /push.
Comment thread docker-compose.override.yml Outdated
The dev override replaces Traefik's command wholesale; it only re-declared the mqtts
:8883 entrypoint, so the new mqtt-wss :8084 entrypoint was missing and wss was
unreachable in the dev stack (the :8084 port publish survives via ports concatenation
but had no entrypoint behind it). Re-declare it alongside mqtts.
Comment thread app/mqtt.php Outdated

go(function () use ($server, $mqtt, $register): void {
$attempts = 0;
while ($attempts < 300) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why 300 attempts? That seems like a crazy number

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Its taken from the realtime. Reducing it a bit. Will adjust this in future after few testings around

Comment thread .github/workflows/ci.yml Outdated
echo "=== Appwrite Logs ==="
docker compose logs --timestamps

e2e_mqtt:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

possible to make it just an additional entry in the service matrix?

use Utopia\DI\Container;
use Utopia\System\System;

return function (Container $container): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

these run per connection, but I don't see the connection actually being used to vary the result? why not make them singletons?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The result does vary but per coroutine, not per argument. That variation is hidden inside Coroutine::getContext(), not in the signature
If we have a singleton then the coroutines would start sharing them can break tenancy similar to what happened in the realtime which lead to adding of the coroutine context.

getProjectDB($project) does vary by argument
getPlanForUser does make sense to be in the connection

Comment thread app/mqtt.php
}, ['register']);
}

$container->set('getCache', fn () => function () use ($register): Cache {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why? cache is pooled no?

Comment on lines +55 to +73
public function send(string $projectId, array $payload, array $events, array $channels, array $roles, array $options = []): void
{
$message = $options['payload'] ?? '';
$qos = $options['qos'] ?? 0;
$sequence = (int) ($options['sequence'] ?? 0);

$this->messageSize->record(\strlen($message));

foreach ($channels as $topic) {
$this->messagesPublished->add(1, ['qos' => $qos]);
$this->pubsub->publish(self::CHANNEL, (string) json_encode([
'project' => $projectId,
'topic' => $topic,
'qos' => $qos,
'sequence' => $sequence,
'payload' => base64_encode($message),
]));
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not a blocker

does feel like something of this could live in utopia mqtt, pubsub/state adapter (redis/inmemory) + sender

Comment on lines +75 to +89
/**
* Part of the messaging-adapter contract but unused for MQTT: subscriptions are owned by the
* broker (utopia-php/mqtt Server records them from the SUBACK the Handler returns).
*
* @param array<int, string> $roles
* @param array<int, string> $channels
* @param array<int, mixed> $queryGroup
*/
public function subscribe(string $projectId, mixed $identifier, string $subscriptionId, array $roles, array $channels, array $queryGroup = []): void
{
}

public function unsubscribe(mixed $identifier): void
{
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is a big signal that the design is trying to fit square in a round role, empty method overrides.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is basically a message publisher. And There is nothing to subscribe unlike the FCM/APNS adapter here and just getting used for publishing in the messaging worker which follows a strict interface

use Utopia\Span\Span;
use Utopia\System\System;

class Handler implements MqttHandler

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This class looks so much better 👍

To go futher, the next step would be, are any parts are common to all MQTT server impelemenations? if they are move to utopia

*
* @param iterable<array{0: Connection, 1: int}> $subscribers
*/
public function onPublish(Publish $publish, Connection $connection, iterable $subscribers): void

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

maybe it'd be more elegant to give it a optional implemenation then?

Comment thread src/Appwrite/Mqtt/Handler.php Outdated
Comment on lines +277 to +278

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this di is a little bit awkward tbh

I'd aim for a structure that allows you to get the actually things you need

cache and planForUser

directly as parametesrs

The handler no longer service-locates getCache/getPlanForUser off the
container by string key; both arrive as typed constructor params. The cache
stays a resolver closure because the pool-backed cache can only be built
inside a coroutine, so it is resolved lazily per coroutine as before.
The counter resets on a healthy ping, so this caps consecutive failures at
~2 minutes before the delivery bridge gives up.
Drop the standalone e2e_mqtt job; add Mqtt to e2e_service with processes: 1
so the session/replay tests keep running serially against the one broker.
@ArnabChatterjee20k ArnabChatterjee20k changed the title Feat mqtt push Appwrite Native Push Broker Sep 18, 2026
@ArnabChatterjee20k
ArnabChatterjee20k merged commit 7fd39aa into main Sep 18, 2026
53 checks passed
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.

2 participants