account_statuses_cleanup_policy.rb 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. # frozen_string_literal: true
  2. # == Schema Information
  3. #
  4. # Table name: account_statuses_cleanup_policies
  5. #
  6. # id :bigint(8) not null, primary key
  7. # account_id :bigint(8) not null
  8. # enabled :boolean default(TRUE), not null
  9. # min_status_age :integer default(1209600), not null
  10. # keep_direct :boolean default(TRUE), not null
  11. # keep_pinned :boolean default(TRUE), not null
  12. # keep_polls :boolean default(FALSE), not null
  13. # keep_media :boolean default(FALSE), not null
  14. # keep_self_fav :boolean default(TRUE), not null
  15. # keep_self_bookmark :boolean default(TRUE), not null
  16. # keep_local :boolean default(TRUE), not null
  17. # min_favs :integer
  18. # min_reblogs :integer
  19. # created_at :datetime not null
  20. # updated_at :datetime not null
  21. #
  22. class AccountStatusesCleanupPolicy < ApplicationRecord
  23. include Redisable
  24. ALLOWED_MIN_STATUS_AGE = [
  25. 1.week.seconds,
  26. 2.weeks.seconds,
  27. 1.month.seconds,
  28. 2.months.seconds,
  29. 3.months.seconds,
  30. 6.months.seconds,
  31. 1.year.seconds,
  32. 2.years.seconds,
  33. ].freeze
  34. EXCEPTION_BOOLS = %w(keep_direct keep_pinned keep_polls keep_media keep_self_fav keep_self_bookmark).freeze
  35. EXCEPTION_THRESHOLDS = %w(min_favs min_reblogs).freeze
  36. # Depending on the cleanup policy, the query to discover the next
  37. # statuses to delete my get expensive if the account has a lot of old
  38. # statuses otherwise excluded from deletion by the other exceptions.
  39. #
  40. # Therefore, `EARLY_SEARCH_CUTOFF` is meant to be the maximum number of
  41. # old statuses to be considered for deletion prior to checking exceptions.
  42. #
  43. # This is used in `compute_cutoff_id` to provide a `max_id` to
  44. # `statuses_to_delete`.
  45. EARLY_SEARCH_CUTOFF = 5_000
  46. belongs_to :account
  47. validates :min_status_age, inclusion: { in: ALLOWED_MIN_STATUS_AGE }
  48. validates :min_favs, numericality: { greater_than_or_equal_to: 1, allow_nil: true }
  49. validates :min_reblogs, numericality: { greater_than_or_equal_to: 1, allow_nil: true }
  50. validate :validate_local_account
  51. before_save :update_last_inspected
  52. def statuses_to_delete(limit = 50, max_id = nil, min_id = nil)
  53. scope = account.statuses
  54. scope.merge!(old_enough_scope(max_id))
  55. scope = scope.where(Status.arel_table[:id].gteq(min_id)) if min_id.present?
  56. scope.merge!(without_popular_scope) unless min_favs.nil? && min_reblogs.nil?
  57. scope.merge!(without_direct_scope) if keep_direct?
  58. scope.merge!(without_pinned_scope) if keep_pinned?
  59. scope.merge!(without_poll_scope) if keep_polls?
  60. scope.merge!(without_media_scope) if keep_media?
  61. scope.merge!(without_self_fav_scope) if keep_self_fav?
  62. scope.merge!(without_local_scope) if keep_local?
  63. scope.merge!(without_self_bookmark_scope) if keep_self_bookmark?
  64. scope.reorder(id: :asc).limit(limit)
  65. end
  66. # This computes a toot id such that:
  67. # - the toot would be old enough to be candidate for deletion
  68. # - there are at most EARLY_SEARCH_CUTOFF toots between the last inspected toot and this one
  69. #
  70. # The idea is to limit expensive SQL queries when an account has lots of toots excluded from
  71. # deletion, while not starting anew on each run.
  72. def compute_cutoff_id
  73. min_id = last_inspected || 0
  74. max_id = Mastodon::Snowflake.id_at(min_status_age.seconds.ago, with_random: false)
  75. subquery = account.statuses.where(Status.arel_table[:id].gteq(min_id)).where(Status.arel_table[:id].lteq(max_id))
  76. subquery = subquery.select(:id).reorder(id: :asc).limit(EARLY_SEARCH_CUTOFF)
  77. # We're textually interpolating a subquery here as ActiveRecord seem to not provide
  78. # a way to apply the limit to the subquery
  79. Status.connection.execute("SELECT MAX(id) FROM (#{subquery.to_sql}) t").values.first.first
  80. end
  81. # The most important thing about `last_inspected` is that any toot older than it is guaranteed
  82. # not to be kept by the policy regardless of its age.
  83. def record_last_inspected(last_id)
  84. redis.set("account_cleanup:#{account.id}", last_id, ex: 1.week.seconds)
  85. end
  86. def last_inspected
  87. redis.get("account_cleanup:#{account.id}")&.to_i
  88. end
  89. def invalidate_last_inspected(status, action)
  90. last_value = last_inspected
  91. return if last_value.nil? || status.id > last_value || status.account_id != account_id
  92. case action
  93. when :unbookmark
  94. return unless keep_self_bookmark?
  95. when :unfav
  96. return unless keep_self_fav?
  97. when :unpin
  98. return unless keep_pinned?
  99. end
  100. record_last_inspected(status.id)
  101. end
  102. private
  103. def update_last_inspected
  104. if EXCEPTION_BOOLS.map { |name| attribute_change_to_be_saved(name) }.compact.include?([true, false])
  105. # Policy has been widened in such a way that any previously-inspected status
  106. # may need to be deleted, so we'll have to start again.
  107. redis.del("account_cleanup:#{account.id}")
  108. end
  109. if EXCEPTION_THRESHOLDS.map { |name| attribute_change_to_be_saved(name) }.compact.any? { |old, new| old.present? && (new.nil? || new > old) }
  110. redis.del("account_cleanup:#{account.id}")
  111. end
  112. end
  113. def validate_local_account
  114. errors.add(:account, :invalid) unless account&.local?
  115. end
  116. def without_direct_scope
  117. Status.where.not(visibility: :direct)
  118. end
  119. def old_enough_scope(max_id = nil)
  120. # Filtering on `id` rather than `min_status_age` ago will treat
  121. # non-snowflake statuses as older than they really are, but Mastodon
  122. # has switched to snowflake IDs significantly over 2 years ago anyway.
  123. snowflake_id = Mastodon::Snowflake.id_at(min_status_age.seconds.ago, with_random: false)
  124. if max_id.nil? || snowflake_id < max_id
  125. max_id = snowflake_id
  126. end
  127. Status.where(Status.arel_table[:id].lteq(max_id))
  128. end
  129. def without_local_scope
  130. Status.where(local_only: false)
  131. end
  132. def without_self_fav_scope
  133. Status.where('NOT EXISTS (SELECT * FROM favourites fav WHERE fav.account_id = statuses.account_id AND fav.status_id = statuses.id)')
  134. end
  135. def without_self_bookmark_scope
  136. Status.where('NOT EXISTS (SELECT * FROM bookmarks bookmark WHERE bookmark.account_id = statuses.account_id AND bookmark.status_id = statuses.id)')
  137. end
  138. def without_pinned_scope
  139. Status.where('NOT EXISTS (SELECT * FROM status_pins pin WHERE pin.account_id = statuses.account_id AND pin.status_id = statuses.id)')
  140. end
  141. def without_media_scope
  142. Status.where('NOT EXISTS (SELECT * FROM media_attachments media WHERE media.status_id = statuses.id)')
  143. end
  144. def without_poll_scope
  145. Status.where(poll_id: nil)
  146. end
  147. def without_popular_scope
  148. scope = Status.left_joins(:status_stat)
  149. scope = scope.where('COALESCE(status_stats.reblogs_count, 0) < ?', min_reblogs) unless min_reblogs.nil?
  150. scope = scope.where('COALESCE(status_stats.favourites_count, 0) < ?', min_favs) unless min_favs.nil?
  151. scope
  152. end
  153. end