Hold mobile pushes for to-dos outside working hours

What does this MR do and why?

Outside a user's working hours, no push is sent for a new to-do.

  • Notifications::MobilePush::SendTodoNotificationsService asks the recipient's schedule before the rate limiter. Outside the window it does not push, counts skipped_outside_working_hours, and records the user on Gitlab::MobilePush::WorkingHoursHold, one Redis sorted set (Gitlab::Redis::SharedState) scored with the next window start. Recording again moves the user to the new time.
  • The check sits before the limiter on purpose: a skipped push must not spend the user's hourly budget, and the limiter's own "You have new to-dos" summary must stay quiet outside working hours too.
  • UserPreference#mobile_push_deliver_now? and #mobile_push_next_window_start hand the stored schedule to Gitlab::MobilePush::WorkingHours; off means always deliver. A schedule the server cannot evaluate delivers, and one with no computable next start is only counted.
  • Gitlab::MobilePush::Recipient gains working_hours_enabled?, deliver_now? and next_window_start, answered from the preference.
  • Todo.with_preloaded_user_and_push_subscriptions also preloads the user preference, so the check adds no query per recipient (covered by a query-count example).

Nothing sends the held summaries yet; the next two MRs do. Until D merges nobody can turn the switch on, so this is dark.

References

Split from !260440 (closed), as agreed with the reviewer there: seven merge requests of 200 to 300 lines each, cut where the code does not reference the other parts. Each one targets its parent branch and is retargeted to master when the parent merges.

Merge request Targets
A Extract the mobile push recipient checks and the APNs fan-out master
B1 Add a value object for mobile push working hours A
B2 Store mobile push working hours on the user preference B1
C1 (this MR) Hold mobile pushes for to-dos outside working hours B2
C2a Add the held summary that resumes one user's mobile pushes C1
C2b Send the mobile push summaries when working hours begin C2a
D Add the mobile push working hours to the user preferences API C2b

Merge order is the chain order: A, B1, B2, C1, C2a, C2b, then D. D is the switch: it targets C2b, so the API cannot reach master before the server can hold a push and send its summary. Any other order would open a window on GitLab.com where users who enabled the schedule have their pushes held but not summarized until C2b deploys.

Screenshots or screen recordings

No UI changes.

How to set up and validate locally

  1. With a registered device (see doc/api/mobile_push_subscriptions.md) and the mobile_push_notifications_dispatch and mobile_push_notifications feature flags on for your user, set a window that is closed right now, in a Rails console:
user = User.find_by!(username: 'root')
user.user_preference.update!(
  mobile_push_working_hours_enabled: true,
  mobile_push_working_hours_start: 3 * 60,    # 03:00
  mobile_push_working_hours_end: 4 * 60,      # 04:00
  mobile_push_working_days: %w[monday tuesday wednesday thursday friday],
  mobile_push_working_hours_time_zone: 'Europe/Berlin'
)
  1. From a second user, assign yourself an issue. No push arrives; log/sidekiq.log shows Todos::PushNotificationWorker with results.skipped_outside_working_hours: 1.

  2. The user is on the wake-up list, scored with the next 03:00 in Berlin:

    Gitlab::Redis::SharedState.with { |redis| redis.zrange('mobile_push:working_hours:resume', 0, -1, with_scores: true) }
  3. Widen the window to cover the current time and assign another issue: the push arrives.

Database

A skipped push writes to Redis only; nothing new is written to PostgreSQL. The one new read is the preference in the existing per-batch preload. Measured with EXPLAIN ANALYZE on a production clone (postgres.ai, gitlab-production-main) for the original merge request; the statements are unchanged. The id lists are synthetic.

  1. Per to-do batch, the preload Todo.with_preloaded_user_and_push_subscriptions gains the preference: SELECT "user_preferences".* FROM "user_preferences" WHERE "user_preferences"."user_id" IN (...) over the unique index_user_preferences_on_user_id. 10 ids: 16.312 ms, shared buffers 18 hit / 13 read.

    Plan
 Index Scan using index_user_preferences_on_user_id on public.user_preferences  (cost=0.44..27.22 rows=10 width=254) (actual time=9.326..14.750 rows=1 loops=1)
   Index Cond: (user_preferences.user_id = ANY ('{...}'::integer[]))
   Buffers: shared hit=18 read=13
   I/O Timings: read=14.652 write=0.000
Settings: seq_page_cost = '4', effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off', random_page_cost = '1.5'
Query ID: -7106013930098408959

Time: 16.312 ms
  - planning: 1.492 ms
  - execution: 14.820 ms
    - I/O read: 14.652 ms
    - I/O write: 0.000 ms

Shared buffers:
  - hits: 18 (~144.00 KiB) from the buffer pool
  - reads: 13 (~104.00 KiB) from the OS file cache, including disk I/O
  - dirtied: 0
  - writes: 0

MR acceptance checklist

Evaluated against the MR acceptance checklist.

🤖 Generated with Claude Code

Edited by Marcel van Remmerden

Merge request reports

Loading
Loading