Skip to content

feat(Push): add Appwrite Push (MQTT 5) adapter - #129

Open
deepshekhardas wants to merge 1 commit into
utopia-php:mainfrom
deepshekhardas:fix/122-appwrite-push-mqtt5
Open

feat(Push): add Appwrite Push (MQTT 5) adapter#129
deepshekhardas wants to merge 1 commit into
utopia-php:mainfrom
deepshekhardas:fix/122-appwrite-push-mqtt5

Conversation

@deepshekhardas

Copy link
Copy Markdown

Port of PR #122 by abnegate.

Adds Appwrite Push - a self-hosted, low-power alternative to FCM/APNS that publishes notifications over MQTT 5 to per-device topics.

Changes:

  • New MQTT 5 control-packet codec (Helpers/MQTT) - pure PHP, no extra dependency
  • New Appwrite Push adapter for MQTT 5 publishing
  • Fake broker for integration testing
  • Unit and integration tests

@greptile-apps

greptile-appsBot commented Jun 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces a self-hosted push adapter for Appwrite that publishes MQTT 5 notifications to per-device topics over a pipelined QoS-1 connection, together with a pure-PHP MQTT 5 codec and a Swoole-based fake broker for integration testing.

  • Helpers/MQTT.php: Codec covers CONNECT, CONNACK, PUBLISH, PUBACK, SUBSCRIBE, PING, and DISCONNECT encode/decode. The readProperties switch is missing cases for several property IDs that are both defined as constants and emitted by encodeConnack (ASSIGNED_CLIENT_ID 0x12, MAXIMUM_QOS 0x24, RETAIN_AVAILABLE 0x25, WILDCARD_SUBSCRIPTION_AVAILABLE 0x28, SHARED_SUBSCRIPTION_AVAILABLE 0x2A); hitting any of these in a real-broker CONNACK triggers an early return that silently discards all subsequent properties, including receiveMaximum.
  • Adapter/Push/Appwrite.php: Pipelined PUBLISH/PUBACK fan-out is well-structured, but several connection-lifecycle bugs are still present: $readBuffer is never reset between calls, $receiveMaximum decreases monotonically across object reuse, rtrim instead of trim can produce a malformed socket URL, and tokens not yet sent when readPacket throws are not recorded in the response.
  • Tests: Integration tests exercise CONNECT/PUBLISH/PUBACK flow, token rejection by reason code, and large-batch pipelining against the FakeBroker; unit tests cover the MQTT codec round-trips comprehensively.

Confidence Score: 3/5

  • The MQTT codec and publisher contain multiple correctness bugs that affect real-broker compatibility and connection-reuse reliability; the PR needs another round of fixes before merging.
  • The readProperties switch is missing cases for property IDs that brokers commonly include in CONNACK (e.g. WILDCARD_SUBSCRIPTION_AVAILABLE, SHARED_SUBSCRIPTION_AVAILABLE, plus the already-flagged ASSIGNED_CLIENT_ID, MAXIMUM_QOS, RETAIN_AVAILABLE). Any one of these appearing before receiveMaximum silently discards the flow-control window negotiation. On top of that, previously flagged issues — readBuffer not reset between connections, receiveMaximum drifting down across object reuse, rtrim producing a malformed socket URL, and unsent tokens vanishing from the response on socket error — are all still present in the current code. Together these make the adapter unreliable against real brokers and fragile under any form of object reuse.
  • Both src/Utopia/Messaging/Helpers/MQTT.php (readProperties early-return) and src/Utopia/Messaging/Adapter/Push/Appwrite.php (connection lifecycle) need attention before this is production-ready.

Important Files Changed

FilenameOverview
src/Utopia/Messaging/Helpers/MQTT.phpPure-PHP MQTT 5 codec — encode/decode coverage is solid for the happy path, but readProperties silently drops all properties that follow any unrecognised identifier (the default case immediately returns). Several property IDs defined as constants in this class and emitted by encodeConnack (ASSIGNED_CLIENT_ID 0x12, MAXIMUM_QOS 0x24, RETAIN_AVAILABLE 0x25, WILDCARD_SUBSCRIPTION_AVAILABLE 0x28, SHARED_SUBSCRIPTION_AVAILABLE 0x2A) have no case in the switch, so a real broker that includes any of them before receiveMaximum in a CONNACK will cause the flow-control window to be silently read as the default 256.
src/Utopia/Messaging/Adapter/Push/Appwrite.phpMQTT 5 publisher adapter with a pipelined PUBLISH/PUBACK loop. Several lifecycle and error-handling issues have been flagged in earlier review rounds: readBuffer is never reset between process() calls; receiveMaximum decreases monotonically across reuses; rtrim instead of trim can produce a malformed socket URL; and tokens not yet sent when readPacket throws are silently absent from the response. These bugs are still present in the current diff.
tests/Messaging/Adapter/Push/AppwriteTest.phpIntegration test that spawns a FakeBroker subprocess on an ephemeral port and exercises CONNECT/PUBLISH/PUBACK flow, token rejection, and pipelining. The $stateFile temp file written in startBroker is never deleted, leaving a stray JSON file in the system temp directory after each run. The result-ordering assertion in testReportsExpiredTokenOnBrokerReasonCode assumes PUBACKs arrive in send order, which holds for the fake broker but could be fragile against a reordering broker.
tests/Messaging/Adapter/Push/FakeBroker.phpSwoole-based TCP server that decodes MQTT CONNECT/PUBLISH/DISCONNECT and emits a capture JSON file. The 15-second self-destruct timer is safe given the test's 3-second startup deadline and typical test runtime. Correctly rejects tokens via PUBACK reason 0x10.
tests/Messaging/Helpers/MQTTTest.phpUnit tests for the MQTT codec covering encode/decode round-trips for CONNECT, PUBLISH, CONNACK, PUBACK, PING pairs, partial-buffer handling, multi-packet coalescing, and guard-clause rejection. Coverage of the happy path is thorough; there are no tests exercising the readProperties early-return path for unrecognised property identifiers.

Reviews (8): Last reviewed commit: "feat(Push): add Appwrite Push (MQTT 5) a..." | Re-trigger Greptile

}

public function getMaxMessagesPerRequest(): int
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1readBuffer not cleared between process() calls

$this->readBuffer is never reset at the start of each connection. If the adapter instance is reused (e.g., send() is called twice), or if the broker sends an extra packet after the last PUBACK (e.g., a PINGREQ that landed in the buffer just before disconnect), that residual data persists into the next call. On the next invocation readPacket() would immediately return the leftover packet as if it were the new connection's CONNACK, causing handshake() to throw "Broker did not respond with CONNACK" even on a healthy connection.

Add $this->readBuffer = ''; at the start of connect() or at the top of process() to isolate each connection's read state.


private function resolveEndpoint(): string
{
$endpoint = \rtrim($this->endpoint);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2rtrim strips only trailing whitespace, so a leading space in the configured endpoint (e.g., " broker.example.com") would produce a malformed URL like tls:// broker.example.com:8883 that stream_socket_client rejects. Use trim to strip both ends.

Suggested change
$endpoint = \rtrim($this->endpoint);
$endpoint = \trim($this->endpoint);

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +381 to +384
$packet = MQTT::decodePacket($this->readBuffer);
if ($packet !== null) {
return $packet;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2receiveMaximum decreases monotonically across process() calls

$this->receiveMaximum is instance state that is only ever updated via min() in handshake(). If the adapter is reused across multiple send() calls and the broker advertises a low receiveMaximum (say 10) on the first call, subsequent connections — even to a different broker endpoint — will be throttled to that minimum permanently for the lifetime of the object. Resetting it to the class-default (or to 65535) at the start of each connect() would make each connection's window independent.

@deepshekhardas

Copy link
Copy Markdown
Author

Following up - this PR has been open for 1 month. Let me know if any changes are needed or if the implementation approach needs adjustment.

Comment on lines +151 to +156
} catch (\Throwable $error) {
foreach ($inflight as $token) {
$response->addResult($token, $error->getMessage());
}
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1Unsent tokens silently absent from response on socket failure

When readPacket throws (broker timeout, EOF, or disconnect) only the currently-inflight tokens are recorded as failures. Any tokens with index $cursor through $total - 1 that have not yet been sent are never added to $response, so they are invisible to the caller — they appear neither as successes nor failures. With a 5,000-token fan-out and a broker that closes the socket after the first window, ~4,744 tokens would silently vanish from the returned results array.

The fix is to also record the unsent remainder before returning:

} catch (\Throwable $error) {
foreach ($inflightas$token) {
$response->addResult($token, $error->getMessage());
}
// Mark tokens that were never attempted.for ($i = $cursor; $i < $total; $i++) {
$response->addResult($tokens[$i], $error->getMessage());
}
return;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Utopia/Messaging/Adapter/Push/Appwrite.php
Line: 151-156
Comment:
**Unsent tokens silently absent from response on socket failure**
When `readPacket` throws (broker timeout, EOF, or disconnect) only the currently-inflight tokens are recorded as failures. Any tokens with index `$cursor` through `$total - 1` that have not yet been sent are never added to `$response`, so they are invisible to the caller — they appear neither as successes nor failures. With a 5,000-token fan-out and a broker that closes the socket after the first window, ~4,744 tokens would silently vanish from the returned results array.
The fix is to also record the unsent remainder before returning:
```php
} catch (\Throwable $error) {
foreach ($inflight as $token) {
$response->addResult($token, $error->getMessage());
}
// Mark tokens that were never attempted.
for ($i = $cursor; $i < $total; $i++) {
$response->addResult($tokens[$i], $error->getMessage());
}
return;
}
```---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude CodeFix in Codex

Based on PR utopia-php#122 by abnegate. Adds Appwrite Push - a self-hosted MQTT 5 based push notification adapter with minimal MQTT 5 control-packet codec.
Sign up for freeto 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.

1 participant

@deepshekhardas