{{ message }}
Repository navigation
fix(messaging): replace a session's push target when its device token rotates - #13836
Merged
Merged
Conversation
… 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.
Contributor
✨ Benchmark resultsComparing
Per-scenario breakdown & investigation detailsMetrics below reflect the current branch (after). Δ P95 compares against the base.
Top API waits (after) |
HarshMN2345
marked this pull request as draft
September 22, 2026 13:33
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.
ArnabChatterjee20k
approved these changes
Sep 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What does this PR do?
Addresses #9402, where one scheduled push message is delivered twice to the same device.
targetshas a unique index onidentifier, 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/pushstoressessionId/sessionInternalIdon every target but never checked whether that session already had one, so a client that re-registers with a freshID.unique()when the token changes (rather than callingupdatePushTargetwith 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'ssetRemindersForNewSessionruns on app open, exactly when re-registration happens.Registering a push token now reconciles every push target the session holds for that provider:
user_target_already_exists.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.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.Duplicatecatch covers the update path, so claiming a token owned by another user still returnsuser_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
expiredflag had no effect on deliverysendBatch()flags a target when a provider reports its token as dead, butstreamRecipients()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 fromusers.get().The filter is applied in PHP rather than as a query.
expiredwas added with a bareADD COLUMNand no default, so every row predating it readsnull;Query::equal('expired', [false])would have silently dropped every legacy target out of delivery.Semantics change worth a maintainer opinion
createPushTargetnow behaves as an upsert per session:targetIdcan get back a different$idwhen 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.201with the existing target instead of409. That makes the endpoint idempotent, which is what a client retrying registration on every app open wants, but it is a visible contract change.users.[userId].targets.[targetId].createand auditstarget.createon the update path, since route labels are static.What this does not cover
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.streamRecipientsdeduplicates identifiers only within a page, so a device reachable through two topics — or through a topic and the same message'susers/targetslist — 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, andGET /accountshows 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.testUpdatePushTargetsourced 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 theuser_target_already_existsassertion intact. Flagging explicitly that this PR edits an existing test.expiredto 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,phpstanandrectorclean. CI is the first execution of the e2e tests.Related PRs and Issues
Addresses #9402.
Have you read the Contributing Guidelines on issues?
Yes.