fix(messaging): replace a session's push target when its device token rotates by HarshMN2345 · Pull Request #13836 · appwrite/appwrite · GitHub
Skip to content

fix(messaging): replace a session's push target when its device token rotates - #13836

Merged
HarshMN2345 merged 7 commits into
mainfrom
fix/9402-duplicate-push-notifications
Sep 23, 2026
Merged

HarshMN2345 merged 7 commits into
mainfrom
fix/9402-duplicate-push-notifications

Conversation

@HarshMN2345

@HarshMN2345 HarshMN2345 commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Addresses #9402, where one scheduled push message is delivered twice to the same device.

targets has a unique index on identifier, so two targets can never share a token. A device receiving one message twice therefore has to be reachable through two different tokens — which is what a token rotation leaves behind. POST /v1/account/targets/push stores sessionId/sessionInternalId on every target but never checked whether that session already had one, so a client that re-registers with a fresh ID.unique() when the token changes (rather than calling updatePushTarget with the stored ID) got a second target while the superseded one stayed live. FCM keeps delivering to a replaced token for a while, so the message lands twice. The timing in the report fits: the reporter's setRemindersForNewSession runs on app open, exactly when re-registration happens.

Registering a push token now reconciles every push target the session holds for that provider:

  • The target that already holds the incoming token is reused, otherwise the oldest one is, and its identifier is replaced in place. Updating rather than recreating keeps the device's topic subscriptions working across a rotation, and preferring the current holder is what keeps re-registration working on a session that already carries duplicates — updating a different row to a token a sibling already holds collides on the unique identifier index and returns a permanent user_target_already_exists.
  • Every remaining target holds a token the client has just told us it no longer uses, so it is flagged expired. That is the same flag the messaging worker already writes when a provider reports a dead token, and the maintenance sweep it feeds releases the target's subscriptions and the topic counters those hold — which a plain delete here would have orphaned.
  • Scoped to sessionInternalId + push + the same provider, so a session that deliberately registers under two different push providers is untouched, and server-created targets (no session) never match.
  • The Duplicate catch covers the update path, so claiming a token owned by another user still returns user_target_already_exists, and nothing is expired on that failure.

Because the reconciliation runs on every registration, an account that already accumulated duplicate targets from past rotations collapses to one live token the next time the app opens. No migration, and nothing is deleted outside the existing sweep.

The expired flag had no effect on delivery

sendBatch() flags a target when a provider reports its token as dead, but streamRecipients() never filtered on it, so Appwrite kept sending to tokens it already knew were gone until maintenance removed the rows. That is load-bearing here — without it, retiring a superseded target changes nothing for up to a full sweep interval, and the reporter's function passes target IDs read straight from users.get().

The filter is applied in PHP rather than as a query. expired was added with a bare ADD COLUMN and no default, so every row predating it reads null; Query::equal('expired', [false]) would have silently dropped every legacy target out of delivery.

Semantics change worth a maintainer opinion

createPushTarget now behaves as an upsert per session:

  • A caller passing an explicit targetId can get back a different $id when that session already holds a target. The alternative — expiring the old target and creating a new one — preserves create's semantics but drops the device off its topic subscriptions. I picked subscription continuity; say the word if you'd rather have the other.
  • Re-registering an unchanged token now returns 201 with the existing target instead of 409. That makes the endpoint idempotent, which is what a client retrying registration on every app open wants, but it is a visible contract change.
  • The route still emits users.[userId].targets.[targetId].create and audits target.create on the update path, since route labels are static.

What this does not cover

  • Case 2 in the report is unexplained. The reporter says cron first, then setRemindersForNewSession, produces one notification. Under the mechanism above that path should duplicate exactly like Cases 1 and 3. I don't have an account for the difference, though it stops being load-bearing once a session can only ever resolve to one live token.
  • streamRecipients deduplicates identifiers only within a page, so a device reachable through two topics — or through a topic and the same message's users/targets list — still receives that message once per source. Fixing that needs a different streaming design, not a patch, so it is left out; happy to file it separately.

Test Plan

  • testCreatePushTargetReplacesRotatedToken: re-registering a rotated token resolves to the same target, and GET /account shows the rotated identifier present and the pre-rotation one gone — the persisted state is the actual regression. Also covers re-registering an unchanged token returning the same target rather than a conflict.
  • testUpdatePushTarget sourced its competing identifier from a second push target on the same session, which is precisely the state this change removes; it now creates that target through the server-side users API, keeping the user_target_already_exists assertion intact. Flagging explicitly that this PR edits an existing test.
  • Not covered by e2e: nothing in the public API can set expired to true, so the recipient filter has no end-to-end test, and the push delivery suites skip without _APP_MESSAGE_PUSH_TEST_DSN. The retirement path is likewise only reachable on a stack that already carries duplicate targets, so a fresh CI stack never exercises it.
  • pint, phpstan and rector clean. CI is the first execution of the e2e tests.

Related PRs and Issues

Addresses #9402.

Have you read the Contributing Guidelines on issues?

Yes.

… rotates

A session is one device install, and an install holds one push token at a
time. Clients that do not keep the target ID around re-register through
POST /account/targets/push when the messaging service rotates the device
token, which created a second target while the superseded one stayed live.
Both targets then resolve to the same device, so a single message was
delivered to it twice.

Re-registering now replaces the session's push target for that provider
instead of adding another one, so the device keeps exactly one live token
along with the topic subscriptions attached to that target.

The update-target test took its competing identifier from a second push
target on the same session, which that invariant no longer allows; it now
creates that target through the server-side users API instead.

Fixes #9402
The docblock restated the commit message at length, and the new test
re-derived from GET /account what the create response already proves:
returning the same target id is what says no second live token was left
behind.
@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Comment thread app/controllers/api/account.php Outdated
Comment thread tests/e2e/Services/Account/AccountCustomClientTest.php
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → fix/9402-duplicate-push-notifications (after).

Metric Before After Change
🚀 Requests/sec 203.69 204.71 ⚪ +0.5%
⏱️ Latency P50 85.94 ms 84.94 ms ⚪ -1.2%
⏱️ Latency P95 193.38 ms 197.15 ms ⚪ +1.9%
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 84.94 197.15 12,882 204.71 +3.77
Account 161.24 304.29 678 11.26 -2.21
TablesDB 81.81 146.77 7,006 113.31 -10.79
Storage 78.75 173.74 3,390 56.76 +4.38
Functions 120.9 243.01 1,808 30.99 +18.57

Top API waits (after)

API request Max wait (ms)
account.prefs.update 487.86
account.name.update 410.53
functions.create 396.16
account.get 358.1
functions.variables.update 341.27

@HarshMN2345
HarshMN2345 marked this pull request as ready for review September 23, 2026 06:12
Replacing one target left every other target the session had accumulated
from earlier rotations delivering, so accounts already carrying duplicates
kept receiving them. Reconcile the whole set instead: reuse the target that
already holds the incoming token, or else the oldest, and flag the rest
expired for the maintenance sweep, which releases their subscriptions and
the topic counters those hold.

Preferring the holder also keeps re-registration working once a session has
several targets. Updating a different row to a token a sibling already holds
collides on the unique identifier index, which left the client with a
permanent user_target_already_exists.
sendBatch() flags a target when a provider reports its token as dead, but
nothing consumed the flag: recipients resolved every row, so sends kept
going to tokens we already knew were gone until the next maintenance sweep
removed them.

Filtered in PHP rather than in the query because the attribute was added
with a bare ADD COLUMN, so rows predating it read null and an equality
filter on false would drop every legacy target from delivery.
Three multi-line blocks in one handler ran heavier than the file around
them, and each restated ground the one above it had already covered.
@HarshMN2345
HarshMN2345 merged commit e01ecac into main Sep 23, 2026
7 checks passed
@HarshMN2345
HarshMN2345 deleted the fix/9402-duplicate-push-notifications branch September 23, 2026 11:07
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