Skip to content

Commit b19ac23

Browse files
authored
Ability to properly backroll migration jobs (#1062)
Makes the `WorkerManager` automatically remove job states from DB if the manager doesn't have a matching job class, as long as the state belongs to a `Refresh`-class job. This way, if an instance admin wants to backroll their instance to a Refresh version which doesn't have a certain migration job (which was previously already executed due to them running the newer version first), and then they want to update their instance again, the new job will migrate all entities again, which means that it'll also catch all entities uploaded or updated after the rollback, but before the update. This does mean that such jobs could try to migrate already migrated entities, however the jobs themselves should have to ensure that no issues will happen because of that, e.g. by simply skipping entities which don't need a migration (TODO until next release). This also adds a few workarounds to weird test-exclusive issues I've found when writing unit tests for this, where EF would reasonlessly try to also insert the user and their stats when trying to insert a different entity (`GameLevel` or `Event` mostly) into the database, while having their user reference set.
2 parents ef7a165 + aeeeece commit b19ac23

6 files changed

Lines changed: 260 additions & 16 deletions

File tree

Refresh.Database/GameDatabaseContext.Levels.cs

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public GameLevel AddLevel(ISerializedPublishLevel createInfo, TokenGame game, Ga
5252
EnforceMinMaxPlayers = createInfo.EnforceMinMaxPlayers,
5353
SameScreenGame = createInfo.SameScreenGame,
5454
BackgroundGuid = createInfo.BackgroundGuid,
55-
Publisher = publisher,
55+
PublisherUserId = publisher.UserId,
5656
GameVersion = game,
5757
PublishDate = timestamp,
5858
UpdateDate = timestamp,
@@ -63,22 +63,19 @@ public GameLevel AddLevel(ISerializedPublishLevel createInfo, TokenGame game, Ga
6363

6464
this.SaveChanges();
6565

66-
this.CreateRevisionForLevel(level, level.Publisher);
67-
this.GameLevelStatistics.Add(level.Statistics = new GameLevelStatistics
68-
{
69-
LevelId = level.LevelId,
70-
});
71-
72-
this.SaveChanges();
73-
74-
if (level.Publisher != null)
66+
this.WriteEnsuringStatistics(publisher, () =>
7567
{
76-
this.WriteEnsuringStatistics(level.Publisher, () =>
68+
this.GameLevelStatistics.Add(level.Statistics = new GameLevelStatistics
7769
{
78-
level.Publisher.Statistics!.LevelCount++;
70+
LevelId = level.LevelId,
7971
});
80-
}
8172

73+
this.CreateRevisionForLevel(level, level.Publisher);
74+
publisher.Statistics!.LevelCount++;
75+
});
76+
77+
level.Publisher = publisher;
78+
this.Entry(level.Publisher).State = EntityState.Unchanged;
8279
return level;
8380
}
8481

Refresh.Database/GameDatabaseContext.Workers.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ public bool MarkWorkerContacted(int id)
5050
return JsonConvert.DeserializeObject(state.State, type);
5151
}
5252

53+
public IQueryable<string> GetAllJobIds(WorkerClass? workerClass)
54+
{
55+
IQueryable<PersistentJobState> states = this.JobStates;
56+
if (workerClass != null) states = states.Where(s => s.Class == workerClass);
57+
return states.Select(s => s.JobId);
58+
}
59+
5360
public void UpdateOrCreateJobState(string jobId, object state, WorkerClass workerClass)
5461
{
5562
PersistentJobState? jobState = this.JobStates.FirstOrDefault(s => s.JobId == jobId && s.Class == workerClass);
@@ -58,6 +65,7 @@ public void UpdateOrCreateJobState(string jobId, object state, WorkerClass worke
5865
jobState = new PersistentJobState
5966
{
6067
JobId = jobId,
68+
Class = workerClass,
6169
};
6270

6371
this.JobStates.Add(jobState);
@@ -66,4 +74,10 @@ public void UpdateOrCreateJobState(string jobId, object state, WorkerClass worke
6674
jobState.State = JsonConvert.SerializeObject(state, Formatting.None);
6775
this.SaveChanges();
6876
}
77+
78+
public void RemoveJobState(string jobId, bool save = true)
79+
{
80+
this.JobStates.RemoveRange(s => s.JobId == jobId);
81+
if (save) this.SaveChanges();
82+
}
6983
}

Refresh.Workers/WorkerManager.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public void AddJob(WorkerJob worker)
4242
this._jobs.Add(worker);
4343
}
4444

45-
private void RunWorkCycle()
45+
public void RunWorkCycle()
4646
{
4747
WorkContext context = new()
4848
{
@@ -99,12 +99,31 @@ private void RunWorkCycle()
9999
}
100100
}
101101

102+
public void RemoveUnusedJobStates()
103+
{
104+
GameDatabaseContext database = this._databaseProvider.GetContext();
105+
string[] jobsFromWorkerManager = this._jobs.Select(j => j.GetType().Name).ToArray();
106+
string[] jobsFromDatabase = database.GetAllJobIds(WorkerClass.Refresh).ToArray();
107+
108+
foreach (string jobId in jobsFromDatabase)
109+
{
110+
if (!jobsFromWorkerManager.Any(j => j == jobId))
111+
{
112+
this._logger.LogInfo(RefreshContext.Worker, $"Removing job state for {jobId} because it doesn't exist in RefreshWorkerManager (likely from a newer/different Refresh build).");
113+
database.RemoveJobState(jobId, false);
114+
}
115+
}
116+
database.SaveChanges();
117+
}
118+
102119
public void Start()
103120
{
104121
this._logger.LogDebug(RefreshContext.Startup, "Starting the worker thread");
105122
this._threadShouldRun = true;
106123
Thread thread = new(() =>
107124
{
125+
this.RemoveUnusedJobStates();
126+
108127
while (this._threadShouldRun)
109128
{
110129
try

RefreshTests.GameServer/TestContext.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
using Refresh.Database.Models.Playlists;
1818
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request;
1919
using Refresh.Database.Models.Photos;
20+
using Microsoft.EntityFrameworkCore;
2021

2122
namespace RefreshTests.GameServer;
2223

@@ -111,6 +112,7 @@ public GameUser CreateUser(string? username = null, GameUserRole role = GameUser
111112
GameUser user = this.Database.CreateUser(username, $"{username}@{username}.local");
112113
if (role != GameUserRole.User) this.Database.SetUserRole(user, role);
113114
if (verifyEmail) this.Database.VerifyUserEmail(user);
115+
this.Database.Entry(user).State = EntityState.Unchanged;
114116

115117
return user;
116118
}
@@ -237,11 +239,16 @@ public WorkContext GetWorkContext()
237239
{
238240
Database = this.Database,
239241
Logger = this.Server.Value.Logger,
240-
DataStore = (IDataStore)this.GetService<StorageService>()
241-
.AddParameterToEndpoint(null!, new BunkumParameterInfo(typeof(IDataStore), ""), null!)!,
242+
DataStore = this.GetDataStore(),
242243
};
243244
}
244245

246+
public IDataStore GetDataStore()
247+
{
248+
return (IDataStore)this.GetService<StorageService>()
249+
.AddParameterToEndpoint(null!, new BunkumParameterInfo(typeof(IDataStore), ""), null!)!;
250+
}
251+
245252
public void Dispose()
246253
{
247254
this.Database.Dispose();

RefreshTests.GameServer/Tests/Levels/UploadTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,33 @@ public void CanCreateLevelDirectly()
2424
Assert.That(context.Database.GetLevelById(1), Is.Not.Null);
2525
}
2626

27+
[Test]
28+
public void CanCreateMultipleLevelsWhileRefreshing()
29+
{
30+
using TestContext context = this.GetServer(false);
31+
GameUser user = context.CreateUser();
32+
33+
context.Database.Refresh();
34+
GameLevel level1 = context.CreateLevel(user);
35+
context.Database.Refresh();
36+
GameLevel level2 = context.CreateLevel(user);
37+
context.Database.Refresh();
38+
GameLevel level3 = context.CreateLevel(user);
39+
context.Database.Refresh();
40+
}
41+
42+
[Test]
43+
public void LevelReferencesAreSetAfterCreation()
44+
{
45+
using TestContext context = this.GetServer(false);
46+
GameUser user = context.CreateUser();
47+
48+
context.Database.Refresh();
49+
GameLevel level = context.CreateLevel(user);
50+
Assert.That(level.Publisher, Is.Not.Null);
51+
Assert.That(level.Statistics, Is.Not.Null);
52+
}
53+
2754
[Test]
2855
public void CanUpdateLevel()
2956
{
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
using Bunkum.Core.Storage;
2+
using Refresh.Database.Models.Levels;
3+
using Refresh.Database.Models.Users;
4+
using Refresh.Database.Models.Workers;
5+
using Refresh.Workers;
6+
using Refresh.Workers.State;
7+
8+
namespace RefreshTests.GameServer.Tests.Workers;
9+
10+
public class JobStateTests : GameServerTest
11+
{
12+
[Test]
13+
public void RemovesJobStateIfJobDoesntExist()
14+
{
15+
using TestContext context = this.GetServer();
16+
IDataStore dataStore = context.GetDataStore();
17+
WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider);
18+
19+
context.Database.UpdateOrCreateJobState(typeof(TestMigrationJob).Name, new MigrationJobState(), WorkerClass.Refresh);
20+
Assert.That(context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Refresh), Is.Not.Null);
21+
22+
manager.RemoveUnusedJobStates();
23+
manager.RunWorkCycle();
24+
context.Database.Refresh();
25+
26+
object? stateObject = context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Refresh);
27+
Assert.That(stateObject, Is.Null);
28+
}
29+
30+
[Test]
31+
[TestCase(true)]
32+
[TestCase(false)]
33+
public void DoesNotRemoveJobStateIfJobExists(bool uploadLevel)
34+
{
35+
using TestContext context = this.GetServer();
36+
IDataStore dataStore = context.GetDataStore();
37+
WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider);
38+
TestMigrationJob job = new();
39+
manager.AddJob(job);
40+
41+
context.Database.UpdateOrCreateJobState(typeof(TestMigrationJob).Name, new MigrationJobState()
42+
{
43+
Total = uploadLevel ? 1 : 0, // Since we're creating the state manually, instead of having WorkerManager do it
44+
}, WorkerClass.Refresh);
45+
Assert.That(context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Refresh), Is.Not.Null);
46+
47+
if (uploadLevel)
48+
{
49+
context.CreateLevel(context.CreateUser());
50+
Assert.That(context.Database.GetTotalLevelCount(), Is.EqualTo(1));
51+
52+
}
53+
manager.RemoveUnusedJobStates();
54+
manager.RunWorkCycle();
55+
context.Database.Refresh();
56+
57+
object? stateObject = context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Refresh);
58+
Assert.That(stateObject, Is.Not.Null);
59+
60+
MigrationJobState jobState = (MigrationJobState)stateObject!;
61+
Assert.That(jobState.Processed, Is.EqualTo(uploadLevel ? 1 : 0));
62+
Assert.That(jobState.Total, Is.EqualTo(uploadLevel ? 1 : 0));
63+
Assert.That(jobState.Complete, Is.True);
64+
}
65+
66+
[Test]
67+
public void DoesNotRemoveJobStateIfNotRefreshClass()
68+
{
69+
using TestContext context = this.GetServer();
70+
IDataStore dataStore = context.GetDataStore();
71+
WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider);
72+
73+
context.Database.UpdateOrCreateJobState(typeof(TestMigrationJob).Name, new MigrationJobState(), WorkerClass.Craftworld);
74+
Assert.That(context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Craftworld), Is.Not.Null);
75+
76+
manager.RemoveUnusedJobStates();
77+
manager.RunWorkCycle();
78+
context.Database.Refresh();
79+
80+
object? stateObject = context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Craftworld);
81+
Assert.That(stateObject, Is.Not.Null);
82+
83+
MigrationJobState jobState = (MigrationJobState)stateObject!;
84+
Assert.That(jobState.Complete, Is.True);
85+
}
86+
87+
[Test]
88+
public void ReExecutesMigrationJobAfterRollbackAndReupdate()
89+
{
90+
using TestContext context = this.GetServer();
91+
IDataStore dataStore = context.GetDataStore();
92+
WorkerManager manager = new(Logger, dataStore, context.DatabaseProvider);
93+
TestMigrationJob job = new();
94+
manager.AddJob(job);
95+
96+
GameUser user = context.CreateUser();
97+
GameLevel firstLevel = context.CreateLevel(user);
98+
99+
// migrate first level
100+
manager.RemoveUnusedJobStates();
101+
manager.RunWorkCycle();
102+
context.Database.Refresh();
103+
104+
object? stateObject = context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Refresh);
105+
Assert.That(stateObject, Is.Not.Null);
106+
107+
MigrationJobState jobState = (MigrationJobState)stateObject!;
108+
Assert.That(jobState.Complete, Is.True);
109+
Assert.That(jobState.Processed, Is.EqualTo(1));
110+
111+
GameLevel? firstLevelMigrated = context.Database.GetLevelById(firstLevel.LevelId);
112+
Assert.That(firstLevelMigrated, Is.Not.Null);
113+
Assert.That(firstLevelMigrated!.Title, Does.EndWith(" test"));
114+
115+
// We can ignore the fact that this level's title won't end on " test", since when we add a real migration job for a certain entity,
116+
// we also adjust that entity's creation/update methods in order to apply whatever change we want to new entities aswell.
117+
// We don't do it here, and it doesn't matter in this test.
118+
context.Database.Refresh();
119+
GameLevel secondLevel = context.CreateLevel(user);
120+
// Should skip job because it's still "complete", so the new level won't be migrated.
121+
manager.RunWorkCycle();
122+
context.Database.Refresh();
123+
124+
GameLevel? secondLevelFromDb = context.Database.GetLevelById(secondLevel.LevelId);
125+
Assert.That(secondLevelFromDb, Is.Not.Null);
126+
Assert.That(secondLevelFromDb!.Title, Does.Not.EndWith(" test"));
127+
128+
stateObject = context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Refresh);
129+
Assert.That(stateObject, Is.Not.Null);
130+
131+
jobState = (MigrationJobState)stateObject!;
132+
Assert.That(jobState.Complete, Is.True);
133+
Assert.That(jobState.Processed, Is.EqualTo(1));
134+
135+
// Simulate a roll-back, meaning the job wouldn't be in the WorkerManager anymore, so no migrations will happen, and the job state would be
136+
// auto-removed by WorkerManager.Start() in real cases.
137+
context.Database.Refresh();
138+
manager = new(Logger, dataStore, context.DatabaseProvider);
139+
GameLevel thirdLevel = context.CreateLevel(user);
140+
141+
manager.RemoveUnusedJobStates();
142+
manager.RunWorkCycle();
143+
context.Database.Refresh();
144+
145+
// No new levels were migrated, and the job state was removed
146+
stateObject = context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Refresh);
147+
Assert.That(stateObject, Is.Null);
148+
149+
GameLevel? secondLevelMigrated = context.Database.GetLevelById(secondLevel.LevelId);
150+
Assert.That(secondLevelMigrated, Is.Not.Null);
151+
Assert.That(secondLevelMigrated!.Title, Does.Not.EndWith(" test"));
152+
153+
GameLevel? thirdLevelMigrated = context.Database.GetLevelById(thirdLevel.LevelId);
154+
Assert.That(thirdLevelMigrated, Is.Not.Null);
155+
Assert.That(thirdLevelMigrated!.Title, Does.Not.EndWith(" test"));
156+
157+
// Now simulate a re-update, where the job is in the WorkerManager again
158+
manager = new(Logger, dataStore, context.DatabaseProvider);
159+
job = new();
160+
manager.AddJob(job);
161+
manager.RemoveUnusedJobStates();
162+
manager.RunWorkCycle();
163+
context.Database.Refresh();
164+
165+
stateObject = context.Database.GetJobState(typeof(TestMigrationJob).Name, typeof(MigrationJobState), WorkerClass.Refresh);
166+
Assert.That(stateObject, Is.Not.Null);
167+
168+
jobState = (MigrationJobState)stateObject!;
169+
Assert.That(jobState.Complete, Is.True);
170+
Assert.That(jobState.Processed, Is.EqualTo(3));
171+
172+
secondLevelMigrated = context.Database.GetLevelById(secondLevel.LevelId);
173+
Assert.That(secondLevelMigrated, Is.Not.Null);
174+
Assert.That(secondLevelMigrated!.Title, Does.EndWith(" test"));
175+
176+
thirdLevelMigrated = context.Database.GetLevelById(thirdLevel.LevelId);
177+
Assert.That(thirdLevelMigrated, Is.Not.Null);
178+
Assert.That(thirdLevelMigrated!.Title, Does.EndWith(" test"));
179+
}
180+
}

0 commit comments

Comments
 (0)