Skip to content

Commit 93936aa

Browse files
authored
Replace UserAlert's theft_alert_id and user_phone_id with alertable (#4032)
`user_alerts` had a column per referenced type, and the upcoming `b_param` kind would have added a fifth. `theft_alert_id` and `user_phone_id` become a polymorphic `alertable` pair. `bike_id` and `organization_id` stay — `unassigned_bike_org` and `theft_alert_without_photo` each carry two references, and `bike` is read as a `Bike` in `create_notification?`, `email_subject` and the admin filter. - **Reads fall back to the legacy columns**, so this ships ahead of the backfill. `for_alertable` matches the polymorphic pair *or* the old column — without it `update_phone_waiting_confirmation` would duplicate the 119k un-backfilled phone alerts rather than find them. `Backfills::UserAlertAlertableJob` copies them across; run it from the console, nothing schedules it. A follow-up drops the columns and the fallback with them. - **The backfill also deletes the duplicate phone alerts it would otherwise strand.** The 2,015 duplicate `(user_id, kind, user_phone_id)` groups in prod predate the uniqueness validation and are inert while `alertable_id` is blank — but backfilling arms the validation, and the survivor stops being able to save. It keeps the lowest id of each group, which is the row `find_or_build_by` already returns. - **The uniqueness validation keys off `UNIQ_KINDS`** instead of applying to whatever sits in the alertable slot, because `theft_alert_without_photo` duplicates are deliberate — `update_theft_alert_without_photo` scopes its finder to `active`, so a theft alert that loses its photo a second time gets a second alert. That's what most of prod's 122 duplicate `(user_id, kind, theft_alert_id)` groups are, and enforcing uniqueness would silently kill the re-alert. - **The admin table's Object column renders through `admin_path_for_object`**, so `b_param` will link itself with no further change.
1 parent 7c15f6e commit 93936aa

9 files changed

Lines changed: 297 additions & 13 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
module Backfills
4+
# theft_alert_id and user_phone_id were replaced by the alertable polymorphic pair.
5+
# UserAlert still reads the old columns as a fallback, so this can run after the deploy
6+
class UserAlertAlertableJob < ApplicationJob
7+
include Sidekiq::IterableJob
8+
9+
sidekiq_options queue: "low_priority", retry: false
10+
11+
# batch_size has to be passed - the enumerator hands in_batches an explicit `of: nil` without it
12+
def build_enumerator(cursor:)
13+
active_record_relations_enumerator(user_alerts, cursor:, batch_size: 1_000)
14+
end
15+
16+
# Backfilled rows drop out of the relation, but the cursor moves forward by id, so a resumed
17+
# run doesn't skip anything
18+
def each_iteration(batch)
19+
batch.where.not(theft_alert_id: nil)
20+
.update_all("alertable_type = 'TheftAlert', alertable_id = theft_alert_id")
21+
batch.where.not(user_phone_id: nil)
22+
.update_all("alertable_type = 'UserPhone', alertable_id = user_phone_id")
23+
end
24+
25+
# The duplicates predate the uniqueness validation and are inert while alertable_id is blank.
26+
# Backfilling arms the validation, which would leave the survivor unable to save
27+
def on_complete
28+
uniq_kind_alerts.where.not(id: lowest_of_each_group).delete_all
29+
end
30+
31+
private
32+
33+
def uniq_kind_alerts
34+
UserAlert.where(kind: UserAlert::UNIQ_KINDS).where.not(alertable_id: nil)
35+
end
36+
37+
# find_or_build_by returns the lowest id, so the rest are the rows nothing reads
38+
def lowest_of_each_group
39+
uniq_kind_alerts.group(:user_id, :kind, :alertable_type, :alertable_id).select("MIN(id)")
40+
end
41+
42+
def user_alerts
43+
base = UserAlert.where(alertable_id: nil)
44+
45+
base.where.not(theft_alert_id: nil).or(base.where.not(user_phone_id: nil))
46+
end
47+
end
48+
end

app/models/user_alert.rb

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
# Database name: primary
55
#
66
# id :bigint not null, primary key
7+
# alertable_type :string
78
# dismissed_at :datetime
89
# kind :integer
910
# message :text
1011
# resolved_at :datetime
1112
# created_at :datetime not null
1213
# updated_at :datetime not null
14+
# alertable_id :bigint
1315
# bike_id :bigint
1416
# organization_id :bigint
1517
# theft_alert_id :bigint
@@ -18,6 +20,7 @@
1820
#
1921
# Indexes
2022
#
23+
# index_user_alerts_on_alertable (alertable_type,alertable_id)
2124
# index_user_alerts_on_bike_id (bike_id)
2225
# index_user_alerts_on_theft_alert_id (theft_alert_id)
2326
# index_user_alerts_on_user_id (user_id)
@@ -31,17 +34,24 @@ class UserAlert < ApplicationRecord
3134
unassigned_bike_org: 3
3235
}.freeze
3336

37+
UNIQ_KINDS = %w[phone_waiting_confirmation].freeze
38+
39+
# Deprecated columns, still read until Backfills::UserAlertAlertableJob has run
40+
LEGACY_ALERTABLE_COLUMNS = {"TheftAlert" => :theft_alert_id, "UserPhone" => :user_phone_id}.freeze
41+
3442
enum :kind, KIND_ENUM
3543

3644
belongs_to :user
3745
belongs_to :bike
46+
belongs_to :organization
47+
belongs_to :alertable, polymorphic: true
3848
belongs_to :user_phone
3949
belongs_to :theft_alert
40-
belongs_to :organization
4150

4251
has_one :notification, as: :notifiable
4352

44-
validates :user_phone_id, uniqueness: {scope: %i[kind user_id]}, allow_blank: true
53+
validates :alertable_id, uniqueness: {scope: %i[alertable_type kind user_id]},
54+
allow_blank: true, if: :uniq_kind?
4555

4656
before_validation :set_calculated_attributes
4757

@@ -55,6 +65,16 @@ class UserAlert < ApplicationRecord
5565
scope :account, -> { where(kind: account_kinds) }
5666
scope :dismissable, -> { where(kind: dismissable_kinds) }
5767
scope :with_notification, -> { joins(:notification).where.not(notifications: {id: nil}) }
68+
# Also matches rows Backfills::UserAlertAlertableJob hasn't reached yet
69+
scope :for_alertable, ->(alertable) {
70+
next all if alertable.blank?
71+
72+
type = alertable.class.polymorphic_name
73+
matched = where(alertable_type: type, alertable_id: alertable.id)
74+
legacy_column = LEGACY_ALERTABLE_COLUMNS[type]
75+
76+
legacy_column ? matched.or(where(legacy_column => alertable.id)) : matched
77+
}
5878
scope :create_notification, -> {
5979
where(kind: notification_kinds, updated_at: notify_period)
6080
.left_joins(:notification).where(notifications: {id: nil})
@@ -103,13 +123,13 @@ def self.placement(kind)
103123
end
104124

105125
def self.find_or_build_by(attrs)
106-
where(attrs).first || new(attrs)
126+
where(attrs.except(:alertable)).for_alertable(attrs[:alertable]).first || new(attrs)
107127
end
108128

109129
def self.update_theft_alert_without_photo(user:, theft_alert:)
110130
# scope to just active, to alert if the theft alert once again has no image
111131
user_alert = UserAlert.active.find_or_build_by(kind: "theft_alert_without_photo",
112-
user_id: user.id, theft_alert_id: theft_alert.id)
132+
user_id: user.id, alertable: theft_alert)
113133
if theft_alert.missing_photo?
114134
user_alert.bike_id = theft_alert.bike&.id
115135
user_alert.save
@@ -141,7 +161,7 @@ def self.update_unassigned_bike_org(user:, organization:, bike:)
141161

142162
def self.update_phone_waiting_confirmation(user:, user_phone:)
143163
user_alert = UserAlert.find_or_build_by(kind: "phone_waiting_confirmation",
144-
user_id: user.id, user_phone_id: user_phone.id)
164+
user_id: user.id, alertable: user_phone)
145165
if user_phone.confirmed?
146166
# Don't create if phone is already confirmed
147167
user_alert.id.blank? || user_alert.resolve!
@@ -150,6 +170,11 @@ def self.update_phone_waiting_confirmation(user:, user_phone:)
150170
end
151171
end
152172

173+
# Falls back to the legacy columns for rows Backfills::UserAlertAlertableJob hasn't reached
174+
def alertable
175+
super || theft_alert || user_phone
176+
end
177+
153178
def kind_humanized
154179
self.class.kind_humanized(kind)
155180
end
@@ -222,6 +247,12 @@ def email_subject
222247
end
223248
end
224249

250+
private
251+
252+
def uniq_kind?
253+
UNIQ_KINDS.include?(kind)
254+
end
255+
225256
def set_calculated_attributes
226257
self.message = nil if message.blank?
227258
end

app/views/admin/user_alerts/_table.html.haml

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,10 @@
4545
%small bike:
4646
= link_to user_alert.bike_id, admin_bike_path(user_alert.bike_id)
4747
= link_to "alerts", url_for(sortable_search_params.merge(search_bike_id: user_alert.bike_id)), class: "small gray-link"
48-
- if user_alert.user_phone.present?
48+
- if (alertable = user_alert.alertable)
4949
%span.d-block
50-
%small user_phone: #{user_alert.user_phone_id}
51-
- if user_alert.theft_alert.present?
52-
%span.d-block
53-
%small theft_alert:
54-
= link_to user_alert.theft_alert_id, admin_theft_alert_path(user_alert.theft_alert_id)
50+
%small #{alertable.class.polymorphic_name.underscore}:
51+
= link_to alertable.id, admin_path_for_object(alertable)
5552
- if user_alert.organization.present?
5653
%span.d-block
5754
%small organization:
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
class AddAlertableToUserAlerts < ActiveRecord::Migration[8.1]
2+
disable_ddl_transaction!
3+
4+
def change
5+
add_reference :user_alerts, :alertable, polymorphic: true,
6+
index: {algorithm: :concurrently}
7+
end
8+
end

db/primary_replica_structure.sql

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4198,7 +4198,9 @@ CREATE TABLE public.user_alerts (
41984198
resolved_at timestamp without time zone,
41994199
dismissed_at timestamp without time zone,
42004200
created_at timestamp without time zone NOT NULL,
4201-
updated_at timestamp without time zone NOT NULL
4201+
updated_at timestamp without time zone NOT NULL,
4202+
alertable_type character varying,
4203+
alertable_id bigint
42024204
);
42034205

42044206

@@ -7504,6 +7506,13 @@ CREATE INDEX index_theft_alerts_on_theft_alert_plan_id ON public.theft_alerts US
75047506
CREATE INDEX index_theft_alerts_on_user_id ON public.theft_alerts USING btree (user_id);
75057507

75067508

7509+
--
7510+
-- Name: index_user_alerts_on_alertable; Type: INDEX; Schema: public; Owner: -
7511+
--
7512+
7513+
CREATE INDEX index_user_alerts_on_alertable ON public.user_alerts USING btree (alertable_type, alertable_id);
7514+
7515+
75077516
--
75087517
-- Name: index_user_alerts_on_bike_id; Type: INDEX; Schema: public; Owner: -
75097518
--
@@ -7717,6 +7726,7 @@ ALTER TABLE ONLY public.bug_reports
77177726
SET search_path TO "$user", public;
77187727

77197728
INSERT INTO "schema_migrations" (version) VALUES
7729+
('20260804100000'),
77207730
('20260801100000'),
77217731
('20260731100009'),
77227732
('20260731100008'),

db/structure.sql

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4198,7 +4198,9 @@ CREATE TABLE public.user_alerts (
41984198
resolved_at timestamp without time zone,
41994199
dismissed_at timestamp without time zone,
42004200
created_at timestamp without time zone NOT NULL,
4201-
updated_at timestamp without time zone NOT NULL
4201+
updated_at timestamp without time zone NOT NULL,
4202+
alertable_type character varying,
4203+
alertable_id bigint
42024204
);
42034205

42044206

@@ -7504,6 +7506,13 @@ CREATE INDEX index_theft_alerts_on_theft_alert_plan_id ON public.theft_alerts US
75047506
CREATE INDEX index_theft_alerts_on_user_id ON public.theft_alerts USING btree (user_id);
75057507

75067508

7509+
--
7510+
-- Name: index_user_alerts_on_alertable; Type: INDEX; Schema: public; Owner: -
7511+
--
7512+
7513+
CREATE INDEX index_user_alerts_on_alertable ON public.user_alerts USING btree (alertable_type, alertable_id);
7514+
7515+
75077516
--
75087517
-- Name: index_user_alerts_on_bike_id; Type: INDEX; Schema: public; Owner: -
75097518
--
@@ -7717,6 +7726,7 @@ ALTER TABLE ONLY public.bug_reports
77177726
SET search_path TO "$user", public;
77187727

77197728
INSERT INTO "schema_migrations" (version) VALUES
7729+
('20260804100000'),
77207730
('20260801100000'),
77217731
('20260731100009'),
77227732
('20260731100008'),
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
require "rails_helper"
2+
3+
RSpec.describe Backfills::UserAlertAlertableJob, type: :job do
4+
describe "perform" do
5+
let(:user) { FactoryBot.create(:user_confirmed) }
6+
let(:user_phone) { FactoryBot.create(:user_phone, user:) }
7+
let(:theft_alert) { FactoryBot.create(:theft_alert) }
8+
let!(:legacy_phone) do
9+
FactoryBot.create(:user_alert, user:, kind: "phone_waiting_confirmation", user_phone:)
10+
end
11+
let!(:legacy_theft_alert) do
12+
FactoryBot.create(:user_alert, user:, kind: "theft_alert_without_photo", theft_alert:)
13+
end
14+
let!(:bike_alert) { FactoryBot.create(:user_alert_stolen_bike_without_location) }
15+
16+
it "copies the legacy columns into alertable" do
17+
expect(legacy_phone.alertable_id).to be_blank
18+
expect(legacy_theft_alert.alertable_id).to be_blank
19+
updated_at = legacy_phone.updated_at
20+
21+
Sidekiq::Testing.inline! { described_class.perform_async }
22+
23+
expect(legacy_phone.reload.alertable).to eq user_phone
24+
expect(legacy_phone.alertable_type).to eq "UserPhone"
25+
# A bumped updated_at would put every backfilled row back in create_notification
26+
expect(legacy_phone.updated_at).to be_within(0.001).of(updated_at)
27+
expect(legacy_theft_alert.reload.alertable).to eq theft_alert
28+
expect(legacy_theft_alert.alertable_type).to eq "TheftAlert"
29+
# bike_id isn't part of alertable
30+
expect(bike_alert.reload.alertable_id).to be_blank
31+
end
32+
33+
# Duplicates predate the uniqueness validation, which the backfill arms
34+
context "duplicates" do
35+
let!(:duplicate_phone) do
36+
FactoryBot.create(:user_alert, user:, kind: "phone_waiting_confirmation", user_phone:)
37+
end
38+
let!(:duplicate_theft_alert) do
39+
FactoryBot.create(:user_alert, user:, kind: "theft_alert_without_photo", theft_alert:)
40+
end
41+
let!(:other_user_phone_alert) do
42+
FactoryBot.create(:user_alert, kind: "phone_waiting_confirmation", user_phone:)
43+
end
44+
45+
it "deletes the duplicate uniq_kind alerts" do
46+
Sidekiq::Testing.inline! { described_class.perform_async }
47+
48+
expect(UserAlert.where(id: duplicate_phone.id).count).to eq 0
49+
expect(legacy_phone.reload.alertable).to eq user_phone
50+
expect(legacy_phone).to be_valid
51+
# Only the lowest id of a group is kept, and only for uniq_kinds
52+
expect(other_user_phone_alert.reload.user_id).to_not eq user.id
53+
expect(other_user_phone_alert.alertable).to eq user_phone
54+
expect(duplicate_theft_alert.reload.alertable).to eq theft_alert
55+
end
56+
end
57+
end
58+
end

0 commit comments

Comments
 (0)