Add working hours for mobile push notifications
What does this MR do and why?
Before: mobile push notifications for new to-dos arrive at any hour.
After: a user can set working hours for mobile push notifications. Outside them, no push is sent for a new to-do. When the working hours begin, the user gets one push, "You have new to-dos", with the pending to-do count as badge. Inside them, nothing changes. The setting belongs to the user, so every registered phone follows it.
What changes:
PUT /user/preferencesaccepts five new attributes:mobile_push_working_hours_enabled,mobile_push_working_hours_startand_end(minutes after midnight),mobile_push_working_days(weekday names, default Monday to Friday) andmobile_push_working_hours_time_zone(IANA name).GETreturns them. A failed validation returns the message in the 400 response.- The settings are stored in one new jsonb column,
user_preferences.mobile_push_working_hours, throughjsonb_accessor, and validated with a JSON schema. The schedule math (window open now? when does it open next?) lives inGitlab::MobilePush::WorkingHours. Notifications::MobilePush::SendTodoNotificationsServicechecks the schedule before the rate limiter. Outside the window it does not push, countsskipped_outside_working_hours, and puts the user on a Redis sorted set scored with the next window start (Gitlab::MobilePush::WorkingHoursHold).Notifications::MobilePush::WorkingHoursResumeWorkerruns every five minutes and callsSendWorkingHoursSummariesService. The service loads the users whose time has come, removes each one from the set right before pushing, checks again that the user is active, both push flags are on, the schedule is still on, the window is open, phones are registered and at least one to-do is still pending, then sends the existingSummaryPayload. The checks and the per-phone send loop are shared with the per-todo service throughGitlab::MobilePush::RecipientandGitlab::MobilePush::Sender. If the window has closed again in the meantime, or every push fails, the user goes back on the set for the next window start. A dead token or missing APNs credentials are not retried. A run stops after three minutes; what is left waits for the next run. The worker logs a count per outcome.- A schedule the server cannot evaluate (unknown zone, missing time, start equal to end, no day) delivers rather than mutes. The end is exclusive, so 09:00 to 17:00 stops at 16:59. An end before the start means the window crosses midnight.
- No new feature flag. Nothing happens until a user turns the setting on, and the existing
mobile_push_notifications_dispatchandmobile_push_notificationsflags stop pushes and summaries alike. The worker can be stopped with the built-indrop_sidekiq_jobs_Notifications::MobilePush::WorkingHoursResumeWorkerflag.
Verification: RuboCop, vale and markdownlint are clean on the changed files. Green locally: spec/lib/gitlab/mobile_push/working_hours_spec.rb (18 examples, fast_spec_helper), spec/models/user_preference_spec.rb, the two service specs, the worker spec, the hold, recipient and sender specs (184 examples together), and the preferences examples of spec/requests/api/users_spec.rb (22). bin/rake gitlab:openapi:v3:generate leaves doc/api/openapi/openapi_v3.yaml unchanged.
How to set up and validate locally
-
With a registered device (see
doc/api/mobile_push_subscriptions.md) and the two push flags on for your user, set a window that opens a few minutes from now, in your zone:curl --request PUT --header "PRIVATE-TOKEN: <token>" "http://gdk.test:3000/api/v4/user/preferences" \ --data "mobile_push_working_hours_enabled=true&mobile_push_working_hours_start=600&mobile_push_working_hours_end=1020&mobile_push_working_days[]=monday&mobile_push_working_days[]=tuesday&mobile_push_working_hours_time_zone=Europe/Berlin" -
From a second user, assign yourself an issue. No push arrives;
log/sidekiq.logshowsTodos::PushNotificationWorkerwithresults.skipped_outside_working_hours: 1. -
Wait for the window to open (the cron runs every five minutes). One push arrives, "You have new to-dos", with the pending badge; the worker logs
delivered: 1. -
curl --header "PRIVATE-TOKEN: <token>" "http://gdk.test:3000/api/v4/user/preferences"returns the five fields. An unknown zone onPUTreturns 400 with "Mobile push working hours time zone is not a valid time zone".
Database
The migration adds one jsonb column to user_preferences (table_size: small) with DEFAULT '{}' NOT NULL, the same shape as the existing orbit_settings column on this table. No index, no constraint, no data migration. On a Database Lab clone of production the ALTER TABLE ... ADD COLUMN took 42.4 ms. The db:gitlabcom-database-testing job ran the same statement under its earlier version number: job 17135546775.
Queries this merge request adds, with EXPLAIN ANALYZE on a production clone (postgres.ai, gitlab-production-main). The id lists are synthetic; the plans use 100 ids where the service sends at most 1,000 per run, and each plan is one index lookup per id.
-
Per to-do batch, the preload
Todo.with_preloaded_user_and_push_subscriptionsgains the preference:SELECT "user_preferences".* FROM "user_preferences" WHERE "user_preferences"."user_id" IN (...)over the uniqueindex_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-
Per summary run, the users:
SELECT "users".* FROM "users" WHERE "users"."id" IN (...)overusers_pkey. 100 ids: 199.518 ms, shared buffers 175 hit / 189 read.Plan
Index Scan using users_pkey on public.users (cost=0.44..317.56 rows=100 width=1684) (actual time=3.429..194.692 rows=85 loops=1)
Index Cond: (users.id = ANY ('{...}'::integer[]))
Buffers: shared hit=175 read=189 dirtied=1
WAL: records=1 fpi=1 bytes=7565
I/O Timings: read=193.263 write=0.000
Settings: random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off'
Query ID: -7675186630567900545
Time: 199.518 ms
- planning: 4.708 ms
- execution: 194.810 ms
- I/O read: 193.263 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 175 (~1.40 MiB) from the buffer pool
- reads: 189 (~1.50 MiB) from the OS file cache, including disk I/O
- dirtied: 1 (~8.00 KiB)
- writes: 0-
Per summary run, their preferences, the same statement as 1. 100 ids: 162.793 ms, shared buffers 180 hit / 131 read.
Plan
Index Scan using index_user_preferences_on_user_id on public.user_preferences (cost=0.44..267.01 rows=100 width=254) (actual time=6.621..161.168 rows=32 loops=1)
Index Cond: (user_preferences.user_id = ANY ('{...}'::integer[]))
Buffers: shared hit=180 read=131 dirtied=2
WAL: records=2 fpi=2 bytes=15862
I/O Timings: read=160.168 write=0.000
Settings: jit = 'off', random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB', work_mem = '230MB'
Query ID: 9213701634584423068
Time: 162.793 ms
- planning: 1.536 ms
- execution: 161.257 ms
- I/O read: 160.168 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 180 (~1.40 MiB) from the buffer pool
- reads: 131 (~1.00 MiB) from the OS file cache, including disk I/O
- dirtied: 2 (~16.00 KiB)
- writes: 0-
Per summary run, their phones:
SELECT "mobile_device_push_subscriptions".* FROM "mobile_device_push_subscriptions" WHERE "mobile_device_push_subscriptions"."user_id" IN (...)overindex_mobile_device_push_subscriptions_on_user_id. 100 ids: 1.490 ms, shared buffers 0 hit / 1 read.Plan
Index Scan using index_mobile_device_push_subscriptions_on_user_id on public.mobile_device_push_subscriptions (cost=0.14..6.62 rows=27 width=289) (actual time=0.691..0.692 rows=0 loops=1)
Index Cond: (mobile_device_push_subscriptions.user_id = ANY ('{12345,24690,37035,49380,61725,74070,86415,98760,111105,123450,135795,148140,160485,172830,185175,197520,209865,222210,234555,246900,259245,271590,283935,296280,308625,320970,333315,345660,358005,370350,382695,395040,407385,419730,432075,444420,456765,469110,481455,493800,506145,518490,530835,543180,555525,567870,580215,592560,604905,617250,629595,641940,654285,666630,678975,691320,703665,716010,728355,740700,753045,765390,777735,790080,802425,814770,827115,839460,851805,864150,876495,888840,901185,913530,925875,938220,950565,962910,975255,987600,999945,1012290,1024635,1036980,1049325,1061670,1074015,1086360,1098705,1111050,1123395,1135740,1148085,1160430,1172775,1185120,1197465,1209810,1222155,1234500}'::bigint[]))
Buffers: shared read=1
I/O Timings: read=0.657 write=0.000
Settings: effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off', random_page_cost = '1.5', seq_page_cost = '4'
Query ID: -4473189939347494887
Time: 1.490 ms
- planning: 0.742 ms
- execution: 0.748 ms
- I/O read: 0.657 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 0 from the buffer pool
- reads: 1 (~8.00 KiB) from the OS file cache, including disk I/O
- dirtied: 0
- writes: 0-
Saving the settings through the existing
UserPreferences::UpdateService:UPDATE "user_preferences" SET "mobile_push_working_hours" = $1, "updated_at" = $2 WHERE "user_preferences"."id" = $3over the primary key, run on the clone after adding the column: 26.460 ms, shared buffers 27 hit / 25 read.Plan
Update on public.user_preferences (cost=0.44..3.46 rows=0 width=0) (actual time=25.581..25.581 rows=0 loops=1)
Buffers: shared hit=27 read=25 dirtied=9
WAL: records=8 fpi=8 bytes=61590
I/O Timings: read=25.010 write=0.000
-> Index Scan using user_preferences_pkey on public.user_preferences (cost=0.44..3.46 rows=1 width=46) (actual time=4.114..4.117 rows=1 loops=1)
Index Cond: (user_preferences.id = 12345)
Buffers: shared read=4
I/O Timings: read=4.060 write=0.000
Settings: seq_page_cost = '4', effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off', random_page_cost = '1.5'
Query ID: -1220253474535494047
Time: 26.460 ms
- planning: 0.805 ms
- execution: 25.655 ms
- I/O read: 25.010 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 27 (~216.00 KiB) from the buffer pool
- reads: 25 (~200.00 KiB) from the OS file cache, including disk I/O
- dirtied: 9 (~72.00 KiB)
- writes: 0-
On a dead device token, the existing per-row
DELETE FROM "mobile_device_push_subscriptions" WHERE "mobile_device_push_subscriptions"."id" = $1, now inGitlab::MobilePush::Sender: 1.361 ms, shared buffers 3 hit / 1 read.Plan
Delete on public.mobile_device_push_subscriptions (cost=0.14..3.16 rows=0 width=0) (actual time=0.754..0.755 rows=0 loops=1)
Buffers: shared hit=3 read=1
I/O Timings: read=0.710 write=0.000
-> Index Scan using mobile_device_push_subscriptions_pkey on public.mobile_device_push_subscriptions (cost=0.14..3.16 rows=1 width=6) (actual time=0.752..0.753 rows=0 loops=1)
Index Cond: (mobile_device_push_subscriptions.id = 1)
Buffers: shared hit=3 read=1
I/O Timings: read=0.710 write=0.000
Settings: effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off', random_page_cost = '1.5', seq_page_cost = '4'
Query ID: 7132805274627337975
Time: 1.361 ms
- planning: 0.517 ms
- execution: 0.844 ms
- I/O read: 0.710 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 3 (~24.00 KiB) from the buffer pool
- reads: 1 (~8.00 KiB) from the OS file cache, including disk I/O
- dirtied: 0
- writes: 0The wake-up list is in Redis (Gitlab::Redis::SharedState), so a skipped push writes nothing to PostgreSQL. The new User.preload_user_preference_and_push_subscriptions scope only preloads; it adds no condition.
MR acceptance checklist
Evaluated against the MR acceptance checklist.