Send a badge-only mobile push when the pending to-do count changes
What does this MR do and why?
Every mobile push stamps the user's pending to-do count on the iOS app badge (Gitlab::MobilePush::Payload#badge), but nothing moves the badge when the count drops: a to-do marked done on the web or through the API, resolved by a merge or a close, restored, snoozed, or whose target was deleted leaves the badge at the last alert's number for as long as the app stays closed. The per-user push rate limit makes it worse, since further alerts are suppressed for the window.
This MR sends a badge-only APNs notification (no alert, no sound, priority 5) whenever a user's pending count changes without a new to-do:
TodoService#enqueue_badge_sync(user_ids)is called fromresolve_todo,resolve_todos,restore_todo,restore_todos,resolve_todos_with_attributes_for_target,destroy_target(note deletion),Todos::Destroy::DestroyedIssuableService(issue, work item and merge request deletion), the EEduo_core_access_grantedbulk insert, and fromTodos::SnoozingService(snoozing changes the pending count, since the pending scope isnot_snoozed, but refreshed no cache before). It filters to users with a registered device (in batches of 100 ids) and runs after commit, likeenqueue_push_notifications.Todos::BadgeSyncWorkerruns one job per user, delayed 5 s and deduplicated including scheduled jobs (reschedule_once), so a bulk resolve collapses into one push carrying the settled count.Notifications::MobilePush::SendBadgeUpdateServiceskips the same recipients as the alert pushes (Gitlab::MobilePush::Recipient: inactive user, no registered device), evicts dead tokens through the sharedGitlab::MobilePush::Sender, and sends aGitlab::MobilePush::BadgePayloadwhose badge is the forcedtodos_pending_count, which also corrects the stale cache after a snooze.Gitlab::MobilePush::ApnsClient#build_notificationomitsalertandsoundand sets priority 5 for a payload without alert text. Alert payloads are unchanged.
Gated by the existing mobile_push_notifications_dispatch flag plus a new mobile_push_badge_sync wip flag (default off).
The GitLab mobile app needs no change: iOS applies a badge-only notification without invoking the app or its notification service extension. The app side of this (re-reading the count on foreground and after its own mutations) landed in the app separately; this MR is the half that keeps the badge right while the app is closed.
Related: #607603 (closed) (mobile push rollout).
Web UI impact
None visible. Web renders nothing from these pushes. The forced count read means the nav's to-do count becomes accurate a few seconds after a snooze for users with a registered device; for everyone else the existing cache behaviour is unchanged.
How to set up and validate locally
- Enable the flags:
Feature.enable(:mobile_push_notifications_dispatch); Feature.enable(:mobile_push_badge_sync). - Register a device for
userviaPOST /api/v4/user/push_subscriptions(orcreate(:mobile_device_push_subscription, user: user)in a console). - With Sidekiq running, mark one of the user's to-dos done, snooze one, or close an issue carrying their to-do.
- About 5 s later
Todos::BadgeSyncWorkerruns; without APNs credentials ingitlab.ymlthe client logsAPNs credentials are not configured, skipping mobile push notificationandmobile_push_badge_updates_total{result="skipped_not_configured"}increments. With credentials, the device's badge changes to the new count with no banner.
Specs: spec/workers/todos/badge_sync_worker_spec.rb, spec/services/notifications/mobile_push/send_badge_update_service_spec.rb, spec/lib/gitlab/mobile_push/badge_payload_spec.rb, plus new examples in the ApnsClient, TodoService (CE + EE) and SnoozingService specs.
Database
No migrations and no new indexes. The MR adds call sites for queries that already exist on master; the one new query shape is the forced pending count read from the worker (query 3). Plans were taken on gitlab-production-main with the Postgres.ai CLI (postgresai joe explain, command ids given per query; they show under Joe instances for the account that ran them). Literal ids are replaced with placeholders.
1. Bulk snooze: distinct user ids of the updated to-dos
New call site of Todo.distinct_user_ids in Todos::SnoozingService#enqueue_badge_sync_for, run from the todoSnoozeMany and todoUnSnoozeMany mutations only when both flags are on (TodoService.badge_sync_enabled?). The mutations cap the list at 100 ids and scope the to-dos to the current user.
SELECT DISTINCT "todos"."user_id" FROM "todos" WHERE "todos"."id" IN (<48 ids>)Command 544853. Time 175.8 ms (cold cache, 172.7 ms I/O read), shared buffers hit 72, read 104.
Plan
Unique (cost=140.00..140.24 rows=48 width=4) (actual time=173.669..173.679 rows=1 loops=1)
Buffers: shared hit=72 read=104 dirtied=5
-> Sort (cost=140.00..140.12 rows=48 width=4) (actual time=173.666..173.669 rows=37 loops=1)
Sort Key: todos.user_id
Sort Method: quicksort Memory: 25kB
-> Index Scan using todos_pkey on public.todos (cost=0.57..138.65 rows=48 width=4) (actual time=8.920..173.624 rows=37 loops=1)
Index Cond: (todos.id = ANY ('{<48 ids>}'::integer[]))
Buffers: shared hit=69 read=104 dirtied=52. Users among the affected ids that have a registered device
Existing method Notifications::MobileDevicePushSubscription.subscribed_user_ids (already used by enqueue_push_notifications). New callers through TodoService#enqueue_badge_sync: resolve_todo(s), restore_todo(s), resolve_todos_with_attributes_for_target, destroy_target, Todos::Destroy::DestroyedIssuableService (one call per batch of 100 deleted to-dos), the EE duo_core_access_granted path and the snooze paths. Runs after commit, only when both flags are on. The IN list is batched by 100 ids (TodoService::BATCH_SIZE), since the target resolve path passes every user with a to-do on the target and the Duo Core path up to 1,000.
SELECT DISTINCT "mobile_device_push_subscriptions"."user_id"
FROM "mobile_device_push_subscriptions"
WHERE "mobile_device_push_subscriptions"."user_id" IN (<50 ids>)
LIMIT 50Command 544855. Time 3.6 ms, shared buffers read 4.
Plan
Limit (cost=0.14..6.67 rows=23 width=8) (actual time=2.478..3.035 rows=1 loops=1)
Buffers: shared read=4 dirtied=2
-> Unique (cost=0.14..6.67 rows=23 width=8) (actual time=2.476..3.032 rows=1 loops=1)
-> Index Only Scan using index_mobile_device_push_subscriptions_on_user_id on public.mobile_device_push_subscriptions (cost=0.14..6.60 rows=26 width=8) (actual time=2.475..3.026 rows=3 loops=1)
Index Cond: (mobile_device_push_subscriptions.user_id = ANY ('{<50 ids>}'::bigint[]))
Heap Fetches: 43. Forced pending to-do count (the badge number)
Existing query behind User#todos_pending_count (the nav count), now also read with force: true from Gitlab::MobilePush::BadgePayload#badge inside Todos::BadgeSyncWorker. Memoized per payload, so one read per user per job; the worker deduplicates per user including scheduled jobs, so at most one read per user per 5 s window. Cost scales with the user's pending count through the partial index.
SELECT COUNT(*)
FROM "todos"
LEFT JOIN banned_users ON todos.author_id = banned_users.user_id
WHERE "todos"."user_id" = <user_id>
AND "todos"."state" = 'pending'
AND "banned_users"."user_id" IS NULL
AND "todos"."state" = 'pending'
AND ("todos"."snoozed_until" <= now() OR "todos"."snoozed_until" IS NULL)Command 544857. Time 46.6 ms (42.7 ms I/O read), shared buffers hit 145, read 40, for a user with 44 pending to-dos.
Plan
Aggregate (cost=403.90..403.91 rows=1 width=8) (actual time=43.546..43.547 rows=1 loops=1)
Buffers: shared hit=145 read=40 dirtied=4
-> Nested Loop Anti Join (cost=0.99..403.90 rows=1 width=0) (actual time=5.917..43.527 rows=44 loops=1)
-> Index Scan using index_todos_on_user_id_and_id_pending on public.todos (cost=0.56..165.84 rows=121 width=4) (actual time=4.595..17.897 rows=44 loops=1)
Index Cond: (todos.user_id = <user_id>)
Filter: ((todos.snoozed_until <= now()) OR (todos.snoozed_until IS NULL))
Buffers: shared hit=38 read=12 dirtied=4
-> Index Only Scan using banned_users_pkey on public.banned_users (cost=0.43..1.96 rows=1 width=8) (actual time=0.581..0.581 rows=0 loops=44)
Index Cond: (banned_users.user_id = todos.author_id)
Heap Fetches: 0
Buffers: shared hit=107 read=284. Worker: load the user
User.find_by_id in Notifications::MobilePush::SendBadgeUpdateService#execute, once per job.
SELECT "users".* FROM find_users_by_id(<user_id>) AS users WHERE ("users"."id" IS NOT NULL) LIMIT 1Command 544859. Time 85.8 ms (cold cache, 73.8 ms I/O read), shared buffers hit 1083, read 273.
Plan
Limit (cost=0.00..0.01 rows=1 width=4) (actual time=85.062..85.063 rows=1 loops=1)
Buffers: shared hit=1083 read=273 dirtied=19
-> Function Scan on public.find_users_by_id users (cost=0.00..0.01 rows=1 width=4) (actual time=85.060..85.061 rows=1 loops=1)
Function Call: find_users_by_id('<user_id>'::bigint)
Filter: (users.id IS NOT NULL)5. Worker: the user's registered devices
Gitlab::MobilePush::Recipient#subscriptions, the same read the alert push path does.
SELECT "mobile_device_push_subscriptions".* FROM "mobile_device_push_subscriptions" WHERE "mobile_device_push_subscriptions"."user_id" = <user_id>Command 544861. Time 0.7 ms, shared buffers hit 6.
Plan
Index Scan using index_mobile_device_push_subscriptions_on_user_id on public.mobile_device_push_subscriptions (cost=0.14..4.69 rows=3 width=291) (actual time=0.033..0.035 rows=3 loops=1)
Index Cond: (mobile_device_push_subscriptions.user_id = <user_id>)
Buffers: shared hit=66. Worker: evict a dead device token
subscription.destroy on an APNs :bad_token result, the same eviction Gitlab::MobilePush::Sender does for alert pushes. Plan only (not executed).
DELETE FROM "mobile_device_push_subscriptions" WHERE "mobile_device_push_subscriptions"."id" = <id>Command 544863.
Plan
Delete on mobile_device_push_subscriptions (cost=0.14..3.16 rows=0 width=0)
-> Index Scan using mobile_device_push_subscriptions_pkey on mobile_device_push_subscriptions (cost=0.14..3.16 rows=1 width=6)
Index Cond: (id = <id>)MR acceptance checklist
This checklist encourages us to confirm any changes have been analyzed to reduce risks in quality, performance, reliability, security, and maintainability.
- I have evaluated the MR acceptance checklist for this MR.