2022-07-20 18:18:57 +00:00
// 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.Linq ;
2022-09-09 07:12:18 +00:00
using System.Threading ;
2022-07-20 18:18:57 +00:00
using System.Threading.Tasks ;
2022-09-08 13:06:44 +00:00
using Newtonsoft.Json ;
2022-07-20 18:18:57 +00:00
using osu.Framework.Allocation ;
using osu.Framework.Bindables ;
using osu.Framework.Graphics ;
using osu.Framework.Logging ;
using osu.Game.Beatmaps ;
2024-01-08 21:07:33 +00:00
using osu.Game.Extensions ;
2023-07-24 18:15:17 +00:00
using osu.Game.Online.API ;
2023-06-26 13:19:01 +00:00
using osu.Game.Overlays ;
using osu.Game.Overlays.Notifications ;
2022-07-20 18:18:57 +00:00
using osu.Game.Rulesets ;
2022-09-08 13:06:44 +00:00
using osu.Game.Scoring ;
2023-06-28 06:04:13 +00:00
using osu.Game.Scoring.Legacy ;
2022-07-20 18:55:05 +00:00
using osu.Game.Screens.Play ;
2022-07-20 18:18:57 +00:00
2024-01-22 11:48:29 +00:00
namespace osu.Game.Database
2022-07-20 18:18:57 +00:00
{
2023-07-26 07:07:45 +00:00
/// <summary>
/// Performs background updating of data stores at startup.
/// </summary>
public partial class BackgroundDataStoreProcessor : Component
2022-07-20 18:18:57 +00:00
{
2024-01-08 21:07:33 +00:00
protected Task ProcessingTask { get ; private set ; } = null ! ;
2024-01-08 20:51:01 +00:00
2022-07-20 18:18:57 +00:00
[Resolved]
private RulesetStore rulesetStore { get ; set ; } = null ! ;
2023-06-28 06:04:13 +00:00
[Resolved]
private BeatmapManager beatmapManager { get ; set ; } = null ! ;
2022-09-08 13:06:44 +00:00
[Resolved]
private ScoreManager scoreManager { get ; set ; } = null ! ;
2022-07-20 18:18:57 +00:00
[Resolved]
private RealmAccess realmAccess { get ; set ; } = null ! ;
[Resolved]
private BeatmapUpdater beatmapUpdater { get ; set ; } = null ! ;
[Resolved]
private IBindable < WorkingBeatmap > gameBeatmap { get ; set ; } = null ! ;
2022-07-20 18:55:05 +00:00
[Resolved]
private ILocalUserPlayInfo ? localUserPlayInfo { get ; set ; }
2023-06-26 13:19:01 +00:00
[Resolved]
private INotificationOverlay ? notificationOverlay { get ; set ; }
2023-07-24 18:15:17 +00:00
[Resolved]
private IAPIProvider api { get ; set ; } = null ! ;
2022-07-21 09:15:21 +00:00
protected virtual int TimeToSleepDuringGameplay = > 30000 ;
2022-07-20 18:18:57 +00:00
protected override void LoadComplete ( )
{
base . LoadComplete ( ) ;
2024-01-08 20:51:01 +00:00
ProcessingTask = Task . Factory . StartNew ( ( ) = >
2022-07-20 18:18:57 +00:00
{
2023-07-26 07:07:45 +00:00
Logger . Log ( "Beginning background data store processing.." ) ;
2022-07-20 18:18:57 +00:00
checkForOutdatedStarRatings ( ) ;
2022-09-09 07:12:18 +00:00
processBeatmapSetsWithMissingMetrics ( ) ;
2023-12-18 09:34:55 +00:00
// Note that the previous method will also update these on a fresh run.
2023-12-09 12:57:34 +00:00
processBeatmapsWithMissingObjectCounts ( ) ;
2022-09-09 07:12:18 +00:00
processScoresWithMissingStatistics ( ) ;
2023-06-26 13:19:01 +00:00
convertLegacyTotalScoreToStandardised ( ) ;
2024-01-22 11:55:17 +00:00
upgradeScoreRanks ( ) ;
2023-06-29 08:16:33 +00:00
} , TaskCreationOptions . LongRunning ) . ContinueWith ( t = >
2022-07-21 15:14:30 +00:00
{
if ( t . Exception ? . InnerException is ObjectDisposedException )
{
Logger . Log ( "Finished background aborted during shutdown" ) ;
return ;
}
2023-07-26 07:07:45 +00:00
Logger . Log ( "Finished background data store processing!" ) ;
2022-07-20 18:18:57 +00:00
} ) ;
}
/// <summary>
/// Check whether the databased difficulty calculation version matches the latest ruleset provided version.
/// If it doesn't, clear out any existing difficulties so they can be incrementally recalculated.
/// </summary>
private void checkForOutdatedStarRatings ( )
{
foreach ( var ruleset in rulesetStore . AvailableRulesets )
{
// beatmap being passed in is arbitrary here. just needs to be non-null.
int currentVersion = ruleset . CreateInstance ( ) . CreateDifficultyCalculator ( gameBeatmap . Value ) . Version ;
if ( ruleset . LastAppliedDifficultyVersion < currentVersion )
{
Logger . Log ( $"Resetting star ratings for {ruleset.Name} (difficulty calculation version updated from {ruleset.LastAppliedDifficultyVersion} to {currentVersion})" ) ;
int countReset = 0 ;
realmAccess . Write ( r = >
{
foreach ( var b in r . All < BeatmapInfo > ( ) )
{
if ( b . Ruleset . ShortName = = ruleset . ShortName )
{
2022-07-21 08:39:07 +00:00
b . StarRating = - 1 ;
2022-07-20 18:18:57 +00:00
countReset + + ;
}
}
2023-07-06 04:37:42 +00:00
r . Find < RulesetInfo > ( ruleset . ShortName ) ! . LastAppliedDifficultyVersion = currentVersion ;
2022-07-20 18:18:57 +00:00
} ) ;
Logger . Log ( $"Finished resetting {countReset} beatmap sets for {ruleset.Name}" ) ;
}
}
}
2022-09-09 07:12:18 +00:00
private void processBeatmapSetsWithMissingMetrics ( )
2022-07-20 18:18:57 +00:00
{
HashSet < Guid > beatmapSetIds = new HashSet < Guid > ( ) ;
2022-07-20 18:55:05 +00:00
Logger . Log ( "Querying for beatmap sets to reprocess..." ) ;
2022-07-20 18:18:57 +00:00
realmAccess . Run ( r = >
{
2023-07-24 18:15:17 +00:00
// BeatmapProcessor is responsible for both online and local processing.
// In the case a user isn't logged in, it won't update LastOnlineUpdate and therefore re-queue,
// causing overhead from the non-online processing to redundantly run every startup.
//
// We may eventually consider making the Process call more specific (or avoid this in any number
// of other possible ways), but for now avoid queueing if the user isn't logged in at startup.
if ( api . IsLoggedIn )
{
2023-12-13 06:28:54 +00:00
foreach ( var b in r . All < BeatmapInfo > ( ) . Where ( b = > ( b . StarRating < 0 | | ( b . OnlineID > 0 & & b . LastOnlineUpdate = = null ) ) & & b . BeatmapSet ! = null ) )
beatmapSetIds . Add ( b . BeatmapSet ! . ID ) ;
2023-07-24 18:15:17 +00:00
}
else
2022-07-20 18:18:57 +00:00
{
2023-12-13 06:28:54 +00:00
foreach ( var b in r . All < BeatmapInfo > ( ) . Where ( b = > b . StarRating < 0 & & b . BeatmapSet ! = null ) )
beatmapSetIds . Add ( b . BeatmapSet ! . ID ) ;
2022-07-20 18:18:57 +00:00
}
} ) ;
2023-12-18 09:22:40 +00:00
if ( beatmapSetIds . Count = = 0 )
return ;
2022-07-20 18:55:05 +00:00
Logger . Log ( $"Found {beatmapSetIds.Count} beatmap sets which require reprocessing." ) ;
2023-12-18 09:22:40 +00:00
// Technically this is doing more than just star ratings, but easier for the end user to understand.
2023-12-18 15:01:09 +00:00
var notification = showProgressNotification ( beatmapSetIds . Count , "Reprocessing star rating for beatmaps" , "beatmaps' star ratings have been updated" ) ;
2023-12-18 09:22:40 +00:00
int processedCount = 0 ;
int failedCount = 0 ;
2022-07-20 18:55:05 +00:00
2022-07-20 18:18:57 +00:00
foreach ( var id in beatmapSetIds )
{
2023-12-18 09:22:40 +00:00
if ( notification ? . State = = ProgressNotificationState . Cancelled )
break ;
updateNotificationProgress ( notification , processedCount , beatmapSetIds . Count ) ;
2023-07-04 06:35:09 +00:00
sleepIfRequired ( ) ;
2022-07-20 18:55:05 +00:00
2022-07-20 18:18:57 +00:00
realmAccess . Run ( r = >
{
var set = r . Find < BeatmapSetInfo > ( id ) ;
if ( set ! = null )
{
2022-07-23 10:21:12 +00:00
try
{
beatmapUpdater . Process ( set ) ;
2023-12-18 09:22:40 +00:00
+ + processedCount ;
2022-07-23 10:21:12 +00:00
}
catch ( Exception e )
{
Logger . Log ( $"Background processing failed on {set}: {e}" ) ;
2023-12-18 09:22:40 +00:00
+ + failedCount ;
2022-07-23 10:21:12 +00:00
}
2022-07-20 18:18:57 +00:00
}
} ) ;
}
2023-12-18 09:22:40 +00:00
completeNotification ( notification , processedCount , beatmapSetIds . Count , failedCount ) ;
2022-07-20 18:18:57 +00:00
}
2022-09-08 13:06:44 +00:00
2023-12-09 12:57:34 +00:00
private void processBeatmapsWithMissingObjectCounts ( )
{
Logger . Log ( "Querying for beatmaps with missing hitobject counts to reprocess..." ) ;
2023-12-13 08:33:24 +00:00
HashSet < Guid > beatmapIds = new HashSet < Guid > ( ) ;
realmAccess . Run ( r = >
{
2023-12-19 09:20:02 +00:00
foreach ( var b in r . All < BeatmapInfo > ( ) . Where ( b = > b . TotalObjectCount < 0 | | b . EndTimeObjectCount < 0 ) )
2023-12-13 08:33:24 +00:00
beatmapIds . Add ( b . ID ) ;
} ) ;
2023-12-09 12:57:34 +00:00
2023-12-18 09:22:40 +00:00
if ( beatmapIds . Count = = 0 )
return ;
Logger . Log ( $"Found {beatmapIds.Count} beatmaps which require statistics population." ) ;
2023-12-18 15:01:09 +00:00
var notification = showProgressNotification ( beatmapIds . Count , "Populating missing statistics for beatmaps" , "beatmaps have been populated with missing statistics" ) ;
2023-12-09 12:57:34 +00:00
2023-12-18 09:22:40 +00:00
int processedCount = 0 ;
int failedCount = 0 ;
2023-12-09 12:57:34 +00:00
foreach ( var id in beatmapIds )
{
2023-12-18 09:22:40 +00:00
if ( notification ? . State = = ProgressNotificationState . Cancelled )
break ;
updateNotificationProgress ( notification , processedCount , beatmapIds . Count ) ;
2023-12-09 12:57:34 +00:00
sleepIfRequired ( ) ;
realmAccess . Run ( r = >
{
var beatmap = r . Find < BeatmapInfo > ( id ) ;
if ( beatmap ! = null )
{
try
{
beatmapUpdater . ProcessObjectCounts ( beatmap ) ;
2023-12-18 09:22:40 +00:00
+ + processedCount ;
2023-12-09 12:57:34 +00:00
}
catch ( Exception e )
{
Logger . Log ( $"Background processing failed on {beatmap}: {e}" ) ;
2023-12-18 09:22:40 +00:00
+ + failedCount ;
2023-12-09 12:57:34 +00:00
}
}
} ) ;
}
2023-12-18 09:22:40 +00:00
completeNotification ( notification , processedCount , beatmapIds . Count , failedCount ) ;
2023-12-09 12:57:34 +00:00
}
2022-09-09 07:12:18 +00:00
private void processScoresWithMissingStatistics ( )
2022-09-08 13:06:44 +00:00
{
HashSet < Guid > scoreIds = new HashSet < Guid > ( ) ;
Logger . Log ( "Querying for scores to reprocess..." ) ;
realmAccess . Run ( r = >
{
2023-08-21 10:36:22 +00:00
foreach ( var score in r . All < ScoreInfo > ( ) . Where ( s = > ! s . BackgroundReprocessingFailed ) )
2022-09-08 13:06:44 +00:00
{
2023-07-06 03:29:03 +00:00
if ( score . BeatmapInfo ! = null
& & score . Statistics . Sum ( kvp = > kvp . Value ) > 0
& & score . MaximumStatistics . Sum ( kvp = > kvp . Value ) = = 0 )
{
2022-09-08 13:06:44 +00:00
scoreIds . Add ( score . ID ) ;
2023-07-06 03:29:03 +00:00
}
2022-09-08 13:06:44 +00:00
}
} ) ;
2023-12-18 09:22:40 +00:00
if ( scoreIds . Count = = 0 )
return ;
Logger . Log ( $"Found {scoreIds.Count} scores which require statistics population." ) ;
2023-12-18 15:01:09 +00:00
var notification = showProgressNotification ( scoreIds . Count , "Populating missing statistics for scores" , "scores have been populated with missing statistics" ) ;
2023-12-18 09:22:40 +00:00
int processedCount = 0 ;
int failedCount = 0 ;
2022-09-08 13:06:44 +00:00
foreach ( var id in scoreIds )
{
2023-12-18 09:22:40 +00:00
if ( notification ? . State = = ProgressNotificationState . Cancelled )
break ;
updateNotificationProgress ( notification , processedCount , scoreIds . Count ) ;
2023-07-04 06:35:09 +00:00
sleepIfRequired ( ) ;
2022-09-08 13:06:44 +00:00
try
{
var score = scoreManager . Query ( s = > s . ID = = id ) ;
2022-09-09 04:57:01 +00:00
scoreManager . PopulateMaximumStatistics ( score ) ;
2022-09-08 13:06:44 +00:00
// Can't use async overload because we're not on the update thread.
// ReSharper disable once MethodHasAsyncOverload
realmAccess . Write ( r = >
{
2023-07-06 04:37:42 +00:00
r . Find < ScoreInfo > ( id ) ! . MaximumStatisticsJson = JsonConvert . SerializeObject ( score . MaximumStatistics ) ;
2022-09-08 13:06:44 +00:00
} ) ;
2023-12-18 09:22:40 +00:00
+ + processedCount ;
2022-09-08 13:06:44 +00:00
}
2023-07-04 09:21:22 +00:00
catch ( ObjectDisposedException )
{
throw ;
}
2022-09-08 13:06:44 +00:00
catch ( Exception e )
{
Logger . Log ( @ $"Failed to populate maximum statistics for {id}: {e}" ) ;
2023-08-21 10:36:22 +00:00
realmAccess . Write ( r = > r . Find < ScoreInfo > ( id ) ! . BackgroundReprocessingFailed = true ) ;
2023-12-18 09:22:40 +00:00
+ + failedCount ;
2022-09-08 13:06:44 +00:00
}
}
2023-12-18 09:22:40 +00:00
completeNotification ( notification , processedCount , scoreIds . Count , failedCount ) ;
2022-09-08 13:06:44 +00:00
}
2023-06-26 13:19:01 +00:00
private void convertLegacyTotalScoreToStandardised ( )
{
Logger . Log ( "Querying for scores that need total score conversion..." ) ;
2024-01-08 21:07:33 +00:00
HashSet < Guid > scoreIds = realmAccess . Run ( r = > new HashSet < Guid > (
r . All < ScoreInfo > ( )
. Where ( s = > ! s . BackgroundReprocessingFailed
& & s . BeatmapInfo ! = null
& & s . IsLegacyScore
& & s . TotalScoreVersion < LegacyScoreEncoder . LATEST_VERSION )
. AsEnumerable ( )
// must be done after materialisation, as realm doesn't want to support
// nested property predicates
. Where ( s = > s . Ruleset . IsLegacyRuleset ( ) )
. Select ( s = > s . ID ) ) ) ;
2023-06-26 13:19:01 +00:00
Logger . Log ( $"Found {scoreIds.Count} scores which require total score conversion." ) ;
2023-07-04 06:35:09 +00:00
if ( scoreIds . Count = = 0 )
return ;
2023-12-18 15:01:09 +00:00
var notification = showProgressNotification ( scoreIds . Count , "Upgrading scores to new scoring algorithm" , "scores have been upgraded to the new scoring algorithm" ) ;
2023-06-26 13:19:01 +00:00
2023-07-04 06:42:04 +00:00
int processedCount = 0 ;
2023-07-04 09:34:53 +00:00
int failedCount = 0 ;
2023-06-26 13:19:01 +00:00
foreach ( var id in scoreIds )
{
2023-12-18 09:17:47 +00:00
if ( notification ? . State = = ProgressNotificationState . Cancelled )
2023-07-04 09:22:10 +00:00
break ;
2023-12-18 09:17:47 +00:00
updateNotificationProgress ( notification , processedCount , scoreIds . Count ) ;
2023-07-04 06:42:04 +00:00
2023-07-04 06:35:09 +00:00
sleepIfRequired ( ) ;
2023-06-26 13:19:01 +00:00
try
{
// Can't use async overload because we're not on the update thread.
// ReSharper disable once MethodHasAsyncOverload
realmAccess . Write ( r = >
{
2023-07-06 04:37:42 +00:00
ScoreInfo s = r . Find < ScoreInfo > ( id ) ! ;
2024-01-22 19:23:37 +00:00
StandardisedScoreMigrationTools . UpdateFromLegacy ( s , beatmapManager . GetWorkingBeatmap ( s . BeatmapInfo ) ) ;
2023-07-04 11:02:25 +00:00
s . TotalScoreVersion = LegacyScoreEncoder . LATEST_VERSION ;
2023-06-26 13:19:01 +00:00
} ) ;
2023-07-04 06:42:04 +00:00
+ + processedCount ;
2023-06-26 13:19:01 +00:00
}
2023-07-04 09:21:22 +00:00
catch ( ObjectDisposedException )
{
throw ;
}
2023-06-26 13:19:01 +00:00
catch ( Exception e )
{
Logger . Log ( $"Failed to convert total score for {id}: {e}" ) ;
2023-08-21 10:36:22 +00:00
realmAccess . Write ( r = > r . Find < ScoreInfo > ( id ) ! . BackgroundReprocessingFailed = true ) ;
2023-07-04 09:34:53 +00:00
+ + failedCount ;
2023-06-26 13:19:01 +00:00
}
}
2023-12-18 09:17:47 +00:00
completeNotification ( notification , processedCount , scoreIds . Count , failedCount ) ;
}
2024-01-22 11:55:17 +00:00
private void upgradeScoreRanks ( )
{
Logger . Log ( "Querying for scores that need rank upgrades..." ) ;
HashSet < Guid > scoreIds = realmAccess . Run ( r = > new HashSet < Guid > (
r . All < ScoreInfo > ( )
. Where ( s = > s . TotalScoreVersion < LegacyScoreEncoder . LATEST_VERSION )
2024-01-23 11:59:35 +00:00
. AsEnumerable ( )
// must be done after materialisation, as realm doesn't support
// filtering on nested property predicates or projection via `.Select()`
. Where ( s = > s . Ruleset . IsLegacyRuleset ( ) )
2024-01-22 11:55:17 +00:00
. Select ( s = > s . ID ) ) ) ;
Logger . Log ( $"Found {scoreIds.Count} scores which require rank upgrades." ) ;
if ( scoreIds . Count = = 0 )
return ;
var notification = showProgressNotification ( scoreIds . Count , "Adjusting ranks of scores" , "scores now have more correct ranks" ) ;
int processedCount = 0 ;
int failedCount = 0 ;
foreach ( var id in scoreIds )
{
if ( notification ? . State = = ProgressNotificationState . Cancelled )
break ;
updateNotificationProgress ( notification , processedCount , scoreIds . Count ) ;
sleepIfRequired ( ) ;
try
{
// Can't use async overload because we're not on the update thread.
// ReSharper disable once MethodHasAsyncOverload
realmAccess . Write ( r = >
{
ScoreInfo s = r . Find < ScoreInfo > ( id ) ! ;
2024-01-22 19:23:37 +00:00
s . Rank = StandardisedScoreMigrationTools . ComputeRank ( s ) ;
2024-01-22 20:34:28 +00:00
s . TotalScoreVersion = LegacyScoreEncoder . LATEST_VERSION ;
2024-01-22 11:55:17 +00:00
} ) ;
+ + processedCount ;
}
catch ( ObjectDisposedException )
{
throw ;
}
catch ( Exception e )
{
Logger . Log ( $"Failed to update rank score {id}: {e}" ) ;
realmAccess . Write ( r = > r . Find < ScoreInfo > ( id ) ! . BackgroundReprocessingFailed = true ) ;
+ + failedCount ;
}
}
completeNotification ( notification , processedCount , scoreIds . Count , failedCount ) ;
}
2023-12-18 09:17:47 +00:00
private void updateNotificationProgress ( ProgressNotification ? notification , int processedCount , int totalCount )
{
if ( notification = = null )
return ;
notification . Text = notification . Text . ToString ( ) . Split ( '(' ) . First ( ) . TrimEnd ( ) + $" ({processedCount} of {totalCount})" ;
notification . Progress = ( float ) processedCount / totalCount ;
2023-12-18 09:22:40 +00:00
2023-12-18 09:41:36 +00:00
if ( processedCount % 100 = = 0 )
Logger . Log ( notification . Text . ToString ( ) ) ;
2023-12-18 09:17:47 +00:00
}
private void completeNotification ( ProgressNotification ? notification , int processedCount , int totalCount , int? failedCount = null )
{
if ( notification = = null )
return ;
if ( processedCount = = totalCount )
2023-06-26 13:19:01 +00:00
{
2023-12-18 09:17:47 +00:00
notification . CompletionText = $"{processedCount} {notification.CompletionText}" ;
2023-07-04 06:42:04 +00:00
notification . Progress = 1 ;
notification . State = ProgressNotificationState . Completed ;
}
else
{
2023-12-18 09:17:47 +00:00
notification . Text = $"{processedCount} of {totalCount} {notification.CompletionText}" ;
2023-07-04 09:34:53 +00:00
// We may have arrived here due to user cancellation or completion with failures.
if ( failedCount > 0 )
2023-12-18 09:17:47 +00:00
notification . Text + = $" Check logs for issues with {failedCount} failed items." ;
2023-07-04 09:34:53 +00:00
2023-07-04 06:42:04 +00:00
notification . State = ProgressNotificationState . Cancelled ;
2023-06-26 13:19:01 +00:00
}
}
2023-07-04 06:35:09 +00:00
2023-12-18 15:01:09 +00:00
private ProgressNotification ? showProgressNotification ( int totalCount , string running , string completed )
2023-12-18 09:17:47 +00:00
{
if ( notificationOverlay = = null )
return null ;
2023-12-18 15:01:09 +00:00
if ( totalCount < 10 )
return null ;
2023-12-18 09:17:47 +00:00
ProgressNotification notification = new ProgressNotification
{
Text = running ,
CompletionText = completed ,
State = ProgressNotificationState . Active
} ;
notificationOverlay ? . Post ( notification ) ;
return notification ;
}
2023-07-04 06:35:09 +00:00
private void sleepIfRequired ( )
{
while ( localUserPlayInfo ? . IsPlaying . Value = = true )
{
Logger . Log ( "Background processing sleeping due to active gameplay..." ) ;
Thread . Sleep ( TimeToSleepDuringGameplay ) ;
}
}
2022-07-20 18:18:57 +00:00
}
}