Personal Access Token Expiration Notification email

What does this MR do?

It adds a worker (cron) that runs every day at 1AM that will notify the user that some of its tokens are about to be expired. Screenshot_2019-10-28_at_22.17.58

Database checklist

When adding migrations:

  • Updated db/schema.rb
  • Added a down method so the migration can be reverted
  • Added the output of the migration(s) to the MR body

Approximated numbers for GitLab.com on how many tokens do we have and on which state they are

explain select count(*) from personal_access_tokens where revoked = false AND expires_at >= '2019-11-04'

 Aggregate  (cost=4796.77..4796.78 rows=1 width=8) (actual time=30.555..30.556 rows=1 loops=1)
   Buffers: shared hit=13293
   ->  Index Only Scan using index_personal_access_tokens_on_expires_at_and_revoked_false on public.personal_access_tokens  (cost=0.43..4526.18 rows=108236 width=0) (actual time=0.055..22.774 rows=88994 loops=1)
         Index Cond: (personal_access_tokens.expires_at >= '2019-11-04'::date)
         Heap Fetches: 8234
         Buffers: shared hit=13293

explain select count(*) from personal_access_tokens where revoked = false AND expires_at IS NULL

 Aggregate  (cost=50235.68..50235.69 rows=1 width=8) (actual time=489.295..489.295 rows=1 loops=1)
   Buffers: shared dirtied=104 hit=1353 read=5178
   ->  Index Only Scan using index_personal_access_tokens_on_expires_at_and_revoked_false on public.personal_access_tokens  (cost=0.43..45612.86 rows=1849128 width=0) (actual time=0.274..337.365 rows=1894773 loops=1)
         Index Cond: (personal_access_tokens.expires_at IS NULL)
         Heap Fetches: 37608
         Buffers: shared dirtied=104 hit=1353 read=5178

So it looks like we have 88994 tokens that are not revoked with an expires_at in the future and 1894773 with no expires_at.

And as for the users with tokens that are expiring soon (note that expire_notification_delivered_at will help to reduce the extra notifications and the amount of notified users each day) for today Nov 4th we have the following

explain SELECT count(DISTINCT "users".*) FROM "users" INNER JOIN "personal_access_tokens" ON "personal_access_tokens"."user_id" = "users"."id" WHERE (revoked = false AND expires_at >= NOW() AND expires_at <= '2019-11-11 13:18:21.047503')

 Aggregate  (cost=1906.20..1906.21 rows=1 width=8) (actual time=954.845..954.845 rows=1 loops=1)
   Buffers: shared dirtied=3 hit=1264 read=869
   ->  Nested Loop  (cost=0.86..1905.40 rows=320 width=1664) (actual time=3.379..945.024 rows=423 loops=1)
         Buffers: shared dirtied=3 hit=1245 read=865
         ->  Index Scan using index_personal_access_tokens_on_expires_at_and_revoked_false on public.personal_access_tokens  (cost=0.43..480.20 rows=320 width=4) (actual time=0.211..5.181 rows=423 loops=1)
               Index Cond: ((personal_access_tokens.expires_at >= now()) AND (personal_access_tokens.expires_at <= '2019-11-11'::date))
               Buffers: shared dirtied=2 hit=414 read=4
         ->  Index Scan using users_pkey on public.users  (cost=0.43..4.44 rows=1 width=1668) (actual time=2.218..2.219 rows=1 loops=423)
               Index Cond: (users.id = personal_access_tokens.user_id)
               Buffers: shared dirtied=1 hit=831 read=861

We have for today around 423 that would have received the notification about their personal tokens expiring soon.

explain SELECT count(DISTINCT "users".*) FROM "users" INNER JOIN "personal_access_tokens" ON "personal_access_tokens"."user_id" = "users"."id" WHERE (revoked = false AND expires_at >= NOW() AND expires_at <= '2019-12-04')

 Aggregate  (cost=19335.76..19335.77 rows=1 width=8) (actual time=89.438..89.438 rows=1 loops=1)
   Buffers: shared hit=15709
   ->  Nested Loop  (cost=0.86..19327.40 rows=3345 width=1664) (actual time=0.078..37.259 rows=3161 loops=1)
         Buffers: shared hit=15686
         ->  Index Scan using index_personal_access_tokens_on_expires_at_and_revoked_false on public.personal_access_tokens  (cost=0.43..4854.70 rows=3345 width=4) (actual time=0.052..10.623 rows=3161 loops=1)
               Index Cond: ((personal_access_tokens.expires_at >= now()) AND (personal_access_tokens.expires_at <= '2019-12-04'::date))
               Buffers: shared hit=3023
         ->  Index Scan using users_pkey on public.users  (cost=0.43..4.32 rows=1 width=1668) (actual time=0.008..0.008 rows=1 loops=3161)
               Index Cond: (users.id = personal_access_tokens.user_id)
               Buffers: shared hit=12663

This result shows around 3.1k users for a month so after the first past of the worker, will be notifying around 100 users per day about their tokens

Query plans

 User.with_expiring_and_not_notified_personal_access_tokens(7.days.from_now).limit(1000)

Raw sql

SELECT "users".* FROM "users" 
  WHERE 
  (EXISTS 
    (SELECT 1 FROM "personal_access_tokens"
     WHERE 
        (personal_access_tokens.user_id = users.id) AND 
        (revoked = false AND 
         expire_notification_delivered = false AND 
         expires_at >= CURRENT_DATE AND 
         expires_at <= '2019-11-21 20:54:15.241017'))) LIMIT 1000

Plan with execution:

 Limit  (cost=42369.78..42378.28 rows=2 width=1645) (actual time=58.167..61.633 rows=408 loops=1)
   Buffers: shared hit=11048
   ->  Nested Loop  (cost=42369.78..42378.28 rows=2 width=1645) (actual time=58.165..61.589 rows=408 loops=1)
         Buffers: shared hit=11048
         ->  Unique  (cost=42369.35..42369.36 rows=2 width=4) (actual time=58.131..58.246 rows=408 loops=1)
               Buffers: shared hit=9412
               ->  Sort  (cost=42369.35..42369.36 rows=2 width=4) (actual time=58.130..58.181 rows=432 loops=1)
                     Sort Key: personal_access_tokens.user_id
                     Sort Method: quicksort  Memory: 45kB
                     Buffers: shared hit=9412
                     ->  Index Scan using index_personal_access_tokens_on_user_id_and_expires_at on public.personal_access_tokens  (cost=0.43..42369.34 rows=2 width=4) (actual time=0.487..57.921 rows=432 loops=1)
                           Index Cond: ((personal_access_tokens.expires_at >= ('now'::cstring)::date) AND (personal_access_tokens.expires_at <= '2019-11-21'::date))
                           Filter: ((NOT personal_access_tokens.revoked) AND (personal_access_tokens.expire_notification_delivered = false))
                           Rows Removed by Filter: 180
                           Buffers: shared hit=9409
         ->  Index Scan using users_pkey on public.users  (cost=0.43..4.45 rows=1 width=1645) (actual time=0.007..0.008 rows=1 loops=408)
               Index Cond: (users.id = personal_access_tokens.user_id)
               Buffers: shared hit=1636
Edited by Dan Jensen

Merge request reports

Loading
Loading