|
| 1 | +defmodule Codebattle.Maintenance.Tasks.RecoverUserRatings do |
| 2 | + @moduledoc """ |
| 3 | + Rebuilds suspiciously low user ratings from persisted `user_games` history. |
| 4 | +
|
| 5 | + The task recalculates ratings from the first suspicious collapse onward: |
| 6 | + - it only considers users whose current rating is below a configurable threshold |
| 7 | + - it finds the first transition from a sane rating to a suspiciously low rating |
| 8 | + - it uses the last sane rating as the baseline |
| 9 | + - it replays `rating_diff` values from the corrupted row onward |
| 10 | + - it does nothing unless `recover/1` is called explicitly |
| 11 | + """ |
| 12 | + |
| 13 | + import Ecto.Query |
| 14 | + |
| 15 | + alias Codebattle.Repo |
| 16 | + alias Codebattle.User |
| 17 | + alias Codebattle.UserGame |
| 18 | + |
| 19 | + @default_threshold 100 |
| 20 | + @type recovery_plan_item :: %{ |
| 21 | + user_id: pos_integer(), |
| 22 | + current_rating: integer(), |
| 23 | + recovered_rating: integer(), |
| 24 | + baseline_rating: integer(), |
| 25 | + source_game_id: pos_integer(), |
| 26 | + source_inserted_at: NaiveDateTime.t(), |
| 27 | + games_count: non_neg_integer(), |
| 28 | + total_rating_diff: integer() |
| 29 | + } |
| 30 | + |
| 31 | + @spec plan(keyword()) :: [recovery_plan_item()] |
| 32 | + def plan(opts \\ []) do |
| 33 | + threshold = Keyword.get(opts, :threshold, @default_threshold) |
| 34 | + user_ids = Keyword.get(opts, :user_ids) |
| 35 | + |
| 36 | + threshold |
| 37 | + |> suspicious_users_query(user_ids) |
| 38 | + |> Repo.all() |
| 39 | + |> Enum.map(&build_recovery_plan(&1, threshold)) |
| 40 | + |> Enum.reject(&is_nil/1) |
| 41 | + end |
| 42 | + |
| 43 | + @spec recover(keyword()) :: [recovery_plan_item()] |
| 44 | + def recover(opts \\ []) do |
| 45 | + plans = plan(opts) |
| 46 | + |
| 47 | + Repo.transaction(fn -> |
| 48 | + Enum.each(plans, fn %{user_id: user_id, recovered_rating: recovered_rating} -> |
| 49 | + user_id |
| 50 | + |> User.get!() |
| 51 | + |> User.rating_changeset(%{rating: recovered_rating}) |
| 52 | + |> Repo.update!() |
| 53 | + end) |
| 54 | + end) |
| 55 | + |
| 56 | + plans |
| 57 | + end |
| 58 | + |
| 59 | + @spec build_recovery_plan(User.t(), integer()) :: recovery_plan_item() | nil |
| 60 | + def build_recovery_plan(%User{id: user_id, rating: current_rating}, threshold) do |
| 61 | + history = rating_history(user_id) |
| 62 | + |
| 63 | + with %{baseline_rating: baseline_rating, history_from_drop: history_from_drop} = drop <- |
| 64 | + find_suspicious_drop(history, threshold) do |
| 65 | + total_rating_diff = Enum.sum_by(history_from_drop, & &1.rating_diff) |
| 66 | + recovered_rating = baseline_rating + total_rating_diff |
| 67 | + |
| 68 | + if recovered_rating != current_rating do |
| 69 | + %{ |
| 70 | + user_id: user_id, |
| 71 | + current_rating: current_rating, |
| 72 | + recovered_rating: recovered_rating, |
| 73 | + baseline_rating: baseline_rating, |
| 74 | + source_game_id: drop.source_game_id, |
| 75 | + source_inserted_at: drop.source_inserted_at, |
| 76 | + games_count: length(history_from_drop), |
| 77 | + total_rating_diff: total_rating_diff |
| 78 | + } |
| 79 | + end |
| 80 | + end |
| 81 | + end |
| 82 | + |
| 83 | + defp suspicious_users_query(threshold, nil) do |
| 84 | + from(u in User, |
| 85 | + where: u.is_bot == false and not is_nil(u.rating) and u.rating < ^threshold |
| 86 | + ) |
| 87 | + end |
| 88 | + |
| 89 | + defp suspicious_users_query(threshold, user_ids) do |
| 90 | + from(u in User, |
| 91 | + where: u.is_bot == false and not is_nil(u.rating) and u.rating < ^threshold and u.id in ^user_ids |
| 92 | + ) |
| 93 | + end |
| 94 | + |
| 95 | + defp rating_history(user_id) do |
| 96 | + Repo.all( |
| 97 | + from(ug in UserGame, |
| 98 | + where: ug.user_id == ^user_id, |
| 99 | + order_by: [asc: ug.inserted_at, asc: ug.id], |
| 100 | + select: %{ |
| 101 | + game_id: ug.game_id, |
| 102 | + inserted_at: ug.inserted_at, |
| 103 | + rating: ug.rating, |
| 104 | + rating_diff: type(coalesce(ug.rating_diff, 0), :integer) |
| 105 | + } |
| 106 | + ) |
| 107 | + ) |
| 108 | + end |
| 109 | + |
| 110 | + defp find_suspicious_drop(history, threshold) do |
| 111 | + case history |
| 112 | + |> Enum.with_index() |
| 113 | + |> Enum.reduce_while(nil, fn {row, idx}, previous_row -> |
| 114 | + reduce_suspicious_drop(history, threshold, row, idx, previous_row) |
| 115 | + end) do |
| 116 | + %{baseline_rating: _} = drop -> drop |
| 117 | + _ -> nil |
| 118 | + end |
| 119 | + end |
| 120 | + |
| 121 | + defp reduce_suspicious_drop(history, threshold, row, idx, %{rating: previous_rating}) |
| 122 | + when previous_rating >= threshold and row.rating < threshold and previous_rating > row.rating do |
| 123 | + {:halt, |
| 124 | + %{ |
| 125 | + baseline_rating: previous_rating, |
| 126 | + source_game_id: row.game_id, |
| 127 | + source_inserted_at: row.inserted_at, |
| 128 | + history_from_drop: Enum.drop(history, idx) |
| 129 | + }} |
| 130 | + end |
| 131 | + |
| 132 | + defp reduce_suspicious_drop(_history, _threshold, row, _idx, _previous_row), do: {:cont, row} |
| 133 | +end |
0 commit comments