about summary refs log tree commit diff
path: root/db/migrate/20180528141303_fix_accounts_unique_index.rb
blob: 0b39f71079f3e441e808a81f5a3f726cd9353d3c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
class FixAccountsUniqueIndex < ActiveRecord::Migration[5.2]
  class Account < ApplicationRecord
    # Dummy class, to make migration possible across version changes
    has_one :user, inverse_of: :account

    def local?
      domain.nil?
    end

    def acct
      local? ? username : "#{username}@#{domain}"
    end
  end

  class StreamEntry < ApplicationRecord
    # Dummy class, to make migration possible across version changes
    belongs_to :account, inverse_of: :stream_entries
  end

  class Status < ApplicationRecord
    # Dummy class, to make migration possible across version changes
    belongs_to :account
  end

  class Mention < ApplicationRecord
    # Dummy class, to make migration possible across version changes
    belongs_to :account
  end

  class StatusPin < ApplicationRecord
    # Dummy class, to make migration possible across version changes
    belongs_to :account
  end

  disable_ddl_transaction!

  def up
    if $stdout.isatty
      say ''
      say 'WARNING: This migration may take a *long* time for large instances'
      say 'It will *not* lock tables for any significant time, but it may run'
      say 'for a very long time. We will pause for 10 seconds to allow you to'
      say 'interrupt this migration if you are not ready.'
      say ''
      say 'This migration will irreversibly delete user accounts with duplicate'
      say 'usernames. You may use the `rake mastodon:maintenance:find_duplicate_usernames`'
      say 'task to manually deal with such accounts before running this migration.'

      10.downto(1) do |i|
        say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true
        sleep 1
      end
    end

    duplicates = Account.connection.select_all('SELECT string_agg(id::text, \',\') AS ids FROM accounts GROUP BY lower(username), lower(domain) HAVING count(*) > 1').to_ary

    duplicates.each do |row|
      deduplicate_account!(row['ids'].split(','))
    end

    remove_index :accounts, name: 'index_accounts_on_username_and_domain_lower' if index_name_exists?(:accounts, 'index_accounts_on_username_and_domain_lower')
    safety_assured { execute 'CREATE UNIQUE INDEX CONCURRENTLY index_accounts_on_username_and_domain_lower ON accounts (lower(username), lower(domain))' }
    remove_index :accounts, name: 'index_accounts_on_username_and_domain' if index_name_exists?(:accounts, 'index_accounts_on_username_and_domain')
  end

  def down
    raise ActiveRecord::IrreversibleMigration
  end

  private

  def deduplicate_account!(account_ids)
    accounts          = Account.where(id: account_ids).to_a
    accounts          = accounts.first.local? ? accounts.sort_by(&:created_at) : accounts.sort_by(&:updated_at).reverse
    reference_account = accounts.shift

    say_with_time "Deduplicating @#{reference_account.acct} (#{accounts.size} duplicates)..." do
      accounts.each do |other_account|
        if other_account.public_key == reference_account.public_key
          # The accounts definitely point to the same resource, so
          # it's safe to re-attribute content and relationships
          merge_accounts!(reference_account, other_account)
        elsif other_account.local?
          # Since domain is in the GROUP BY clause, both accounts
          # are always either going to be local or not local, so only
          # one check is needed. Since we cannot support two users with
          # the same username locally, one has to go. 😢
          other_account.user&.destroy
        end

        other_account.destroy
      end
    end
  end

  def merge_accounts!(main_account, duplicate_account)
    [Status, Mention, StatusPin, StreamEntry].each do |klass|
      klass.where(account_id: duplicate_account.id).in_batches.update_all(account_id: main_account.id)
    end

    # Since it's the same remote resource, the remote resource likely
    # already believes we are following/blocking, so it's safe to
    # re-attribute the relationships too. However, during the presence
    # of the index bug users could have *also* followed the reference
    # account already, therefore mass update will not work and we need
    # to check for (and skip past) uniqueness errors
    [Favourite, Follow, FollowRequest, Block, Mute].each do |klass|
      klass.where(account_id: duplicate_account.id).find_each do |record|
        record.update_attribute(:account_id, main_account.id)
      rescue ActiveRecord::RecordNotUnique
        next
      end
    end

    [Follow, FollowRequest, Block, Mute].each do |klass|
      klass.where(target_account_id: duplicate_account.id).find_each do |record|
        record.update_attribute(:target_account_id, main_account.id)
      rescue ActiveRecord::RecordNotUnique
        next
      end
    end
  end
end