Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add command for applying simple mod multiplier changes #246

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using JetBrains.Annotations;
using McMaster.Extensions.CommandLineUtils;
using MySqlConnector;
using osu.Server.QueueProcessor;

namespace osu.Server.Queues.ScoreStatisticsProcessor.Commands.Score
{
[Command("change-mod-multiplier", Description = "Changes a mod's multiplier globally by adjusting all relevant scores' totals.")]
public class ChangeModMultiplierCommand
{
[Option(CommandOptionType.SingleValue, Template = "-r|--ruleset-id", Description = "Required. The ID of the ruleset for the mod whose multiplier is being adjusted.")]
public int? RulesetId { get; set; }

[Option(CommandOptionType.SingleValue, Template = "-m|--mod", Description = "Required. The acronym of the mod whose multiplier is being adjusted.")]
public string? ModAcronym { get; set; }

[Option(CommandOptionType.SingleValue, Template = "--old", Description = "Required. The old multiplier of the mod being adjusted.")]
public double? OldMultiplier { get; set; }

[Option(CommandOptionType.SingleValue, Template = "--new", Description = "Required. The new multiplier of the mod being adjusted.")]
public double? NewMultiplier { get; set; }

[Option(CommandOptionType.SingleValue, Template = "--start-id", Description = "The ID of the `scores` table row to start processing from.")]
public ulong? StartId { get; set; }

[Option(CommandOptionType.SingleOrNoValue, Template = "--batch-size", Description = "The maximum number of scores to fetch in each batch.")]
public int BatchSize { get; set; } = 5000;

[Option(CommandOptionType.SingleOrNoValue, Template = "--dry-run", Description = "Do not actually change any score totals, just display what would be done.")]
public bool DryRun { get; set; }

private readonly StringBuilder sqlBuffer = new StringBuilder();
private readonly ElasticQueuePusher elasticQueueProcessor = new ElasticQueuePusher();
private readonly HashSet<ElasticQueuePusher.ElasticScoreItem> elasticItems = new HashSet<ElasticQueuePusher.ElasticScoreItem>();

[UsedImplicitly]
public async Task<int> OnExecuteAsync(CommandLineApplication app, CancellationToken cancellationToken)
{
if (RulesetId == null
|| string.IsNullOrEmpty(ModAcronym)
|| OldMultiplier == null
|| NewMultiplier == null)
{
await Console.Error.WriteLineAsync("One or more required parameters is missing.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda weird to have optional arguments which are required (usually I'd use standard arguments in place of this, but I guess your goal is to have things named?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I didn't want to have them purely positional to prevent screw-ups from swapping them or something.

app.ShowHelp(false);
return 1;
}

if (NewMultiplier.Value == OldMultiplier.Value)
{
Console.WriteLine("New and old multipliers are equal - there is nothing to do.");
return 0;
}

ArgumentOutOfRangeException.ThrowIfNegativeOrZero(OldMultiplier.Value, nameof(OldMultiplier));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(NewMultiplier.Value, nameof(NewMultiplier));

Console.WriteLine();
Console.WriteLine($"Changing multiplier of mod {ModAcronym} in ruleset {RulesetId} from {OldMultiplier} to {NewMultiplier}");
Console.WriteLine($"Indexing to elasticsearch queue(s) {elasticQueueProcessor.ActiveQueues}");

if (DryRun)
Console.WriteLine("RUNNING IN DRY RUN MODE.");

Thread.Sleep(5000);

ulong lastId = StartId ?? 0;
int converted = 0;
int skipped = 0;

using var conn = DatabaseAccess.GetConnection();

while (!cancellationToken.IsCancellationRequested)
{
var scoresToAdjust = (await conn.QueryAsync<ScoreToAdjust>(
"SELECT `id`, `total_score` "
+ "FROM `scores` "
+ "WHERE `id` BETWEEN @lastId AND (@lastId + @batchSize - 1) "
+ "AND `ruleset_id` = @rulesetId "
+ "AND JSON_SEARCH(`data`, 'one', @modAcronym, NULL, '$.mods[*].acronym') IS NOT NULL",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't sure whether to do this sql-side or c#-side. I think this should be somewhat painless because EXPLAIN pegs this as a where filter over a range PK lookup so it shouldn't be that slow.

new
{
lastId = lastId,
batchSize = BatchSize,
rulesetId = RulesetId,
modAcronym = ModAcronym,
})).ToArray();

foreach (var score in scoresToAdjust)
{
uint oldTotal = score.total_score;
score.total_score = (uint)(score.total_score / OldMultiplier * NewMultiplier);
Console.WriteLine($"{score.id}: total score change from {oldTotal} to {score.total_score}");

sqlBuffer.AppendLine($"UPDATE `scores` SET `total_score` = {score.total_score} WHERE `id` = {score.id};");
elasticItems.Add(new ElasticQueuePusher.ElasticScoreItem { ScoreId = (long?)score.id });
}

flush(conn);

lastId += (ulong)BatchSize;
converted += scoresToAdjust.Length;
skipped += BatchSize - scoresToAdjust.Length;

Console.WriteLine($"Processed up to ID {lastId} ({converted} converted {skipped} skipped)");

if (lastId > await conn.QuerySingleAsync<ulong>("SELECT MAX(`id`) FROM `scores`"))
{
Console.WriteLine("All done!");
break;
}
}

flush(conn, force: true);
return 0;
}

private void flush(MySqlConnection conn, bool force = false)
{
int bufferLength = sqlBuffer.Length;

if (bufferLength == 0)
return;

if (bufferLength > 1024 || force)
{
if (!DryRun)
{
Console.WriteLine();
Console.WriteLine($"Flushing sql batch ({bufferLength:N0} bytes)");
conn.Execute(sqlBuffer.ToString());

if (elasticItems.Count > 0)
{
elasticQueueProcessor.PushToQueue(elasticItems.ToList());
Console.WriteLine($"Queued {elasticItems.Count} items for indexing");
}
}

elasticItems.Clear();
sqlBuffer.Clear();
}
}

[SuppressMessage("ReSharper", "InconsistentNaming")]
private class ScoreToAdjust
{
public ulong id;

Check warning on line 159 in osu.Server.Queues.ScoreStatisticsProcessor/Commands/Score/ChangeModMultiplierCommand.cs

View workflow job for this annotation

GitHub Actions / Test

Field 'ChangeModMultiplierCommand.ScoreToAdjust.id' is never assigned to, and will always have its default value 0
public uint total_score;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using McMaster.Extensions.CommandLineUtils;
using osu.Server.Queues.ScoreStatisticsProcessor.Commands.Score;

namespace osu.Server.Queues.ScoreStatisticsProcessor.Commands
{
[Command(Name = "score", Description = "Runs batch processing on score totals for scores and users.")]
[Subcommand(typeof(ChangeModMultiplierCommand))]
public class ScoreCommands
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have probably just put the command in maintenance, given there's already similar score repair commands in there?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the time of writing I wasn't sure if there were going to be any others, for e.g. user total recalculations. But I think it's pretty clear to me that it's not required anymore, will move.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
[UsedImplicitly]
public Task<int> OnExecuteAsync(CommandLineApplication app, CancellationToken _)
{
app.ShowHelp(false);
return Task.FromResult(1);
}
}
}
1 change: 1 addition & 0 deletions osu.Server.Queues.ScoreStatisticsProcessor/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace osu.Server.Queues.ScoreStatisticsProcessor
[Subcommand(typeof(QueueCommands))]
[Subcommand(typeof(PerformanceCommands))]
[Subcommand(typeof(MaintenanceCommands))]
[Subcommand(typeof(ScoreCommands))]
public class Program
{
private static readonly CancellationTokenSource cts = new CancellationTokenSource();
Expand Down