mirror of https://github.com/Squidex/squidex.git
17 changed files with 365 additions and 8 deletions
@ -0,0 +1,29 @@ |
|||
// ==========================================================================
|
|||
// MongoMigrationEntity.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using MongoDB.Bson; |
|||
using MongoDB.Bson.Serialization.Attributes; |
|||
|
|||
namespace Squidex.Infrastructure.Migrations |
|||
{ |
|||
public sealed class MongoMigrationEntity |
|||
{ |
|||
[BsonId] |
|||
[BsonElement] |
|||
[BsonRepresentation(BsonType.String)] |
|||
public string Id { get; set; } |
|||
|
|||
[BsonElement] |
|||
[BsonRequired] |
|||
public bool IsLocked { get; set; } |
|||
|
|||
[BsonElement] |
|||
[BsonRequired] |
|||
public int Version { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
// ==========================================================================
|
|||
// MongoMigrationStatus.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks; |
|||
using MongoDB.Driver; |
|||
using Squidex.Infrastructure.MongoDb; |
|||
|
|||
namespace Squidex.Infrastructure.Migrations |
|||
{ |
|||
public sealed class MongoMigrationStatus : MongoRepositoryBase<MongoMigrationEntity>, IMigrationStatus |
|||
{ |
|||
private const string DefaultId = "Default"; |
|||
|
|||
public MongoMigrationStatus(IMongoDatabase database) |
|||
: base(database) |
|||
{ |
|||
} |
|||
|
|||
public override void Connect() |
|||
{ |
|||
base.Connect(); |
|||
} |
|||
|
|||
public async Task<int> GetVersionAsync() |
|||
{ |
|||
var entity = await Collection.Find(x => x.Id == DefaultId).FirstOrDefaultAsync(); |
|||
|
|||
if (entity == null) |
|||
{ |
|||
try |
|||
{ |
|||
await Collection.InsertOneAsync(new MongoMigrationEntity { Id = DefaultId }); |
|||
} |
|||
catch (MongoWriteException ex) |
|||
{ |
|||
if (ex.WriteError.Category != ServerErrorCategory.DuplicateKey) |
|||
{ |
|||
throw; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return entity?.Version ?? 0; |
|||
} |
|||
|
|||
public async Task<bool> TryLockAsync() |
|||
{ |
|||
var entity = await Collection.FindOneAndUpdateAsync(x => x.Id == DefaultId, Update.Set(x => x.IsLocked, true)); |
|||
|
|||
return entity?.IsLocked == false; |
|||
} |
|||
|
|||
public Task UnlockAsync(int newVersion) |
|||
{ |
|||
return Collection.UpdateOneAsync(x => x.Id == DefaultId, |
|||
Update |
|||
.Set(x => x.IsLocked, false) |
|||
.Set(x => x.Version, newVersion)); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// IMigration.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Squidex.Infrastructure.Migrations |
|||
{ |
|||
public interface IMigration |
|||
{ |
|||
int FromVersion { get; } |
|||
|
|||
int ToVersion { get; } |
|||
|
|||
Task UpdateAsync(); |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
// ==========================================================================
|
|||
// IMigrationState.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Squidex.Infrastructure.Migrations |
|||
{ |
|||
public interface IMigrationStatus |
|||
{ |
|||
Task<int> GetVersionAsync(); |
|||
|
|||
Task<bool> TryLockAsync(); |
|||
|
|||
Task UnlockAsync(int newVersion); |
|||
} |
|||
} |
|||
@ -0,0 +1,100 @@ |
|||
// ==========================================================================
|
|||
// Migrator.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Infrastructure.Log; |
|||
|
|||
namespace Squidex.Infrastructure.Migrations |
|||
{ |
|||
public sealed class Migrator |
|||
{ |
|||
private readonly IMigrationStatus migrationStatus; |
|||
private readonly IEnumerable<IMigration> migrations; |
|||
private readonly ISemanticLog log; |
|||
|
|||
public Migrator(IMigrationStatus migrationStatus, IEnumerable<IMigration> migrations, ISemanticLog log) |
|||
{ |
|||
Guard.NotNull(migrationStatus, nameof(migrationStatus)); |
|||
Guard.NotNull(migrations, nameof(migrations)); |
|||
Guard.NotNull(log, nameof(log)); |
|||
|
|||
this.migrationStatus = migrationStatus; |
|||
this.migrations = migrations.OrderByDescending(x => x.ToVersion).ToList(); |
|||
|
|||
this.log = log; |
|||
} |
|||
|
|||
public async Task MigrateAsync() |
|||
{ |
|||
var version = await migrationStatus.GetVersionAsync(); |
|||
|
|||
var lastMigrator = migrations.FirstOrDefault(); |
|||
|
|||
if (lastMigrator != null && lastMigrator.ToVersion != version) |
|||
{ |
|||
while (!await migrationStatus.TryLockAsync()) |
|||
{ |
|||
log.LogInformation(w => w |
|||
.WriteProperty("action", "Migrate") |
|||
.WriteProperty("mesage", "Waiting 5sec to acquire lock.")); |
|||
|
|||
await Task.Delay(5000); |
|||
} |
|||
|
|||
try |
|||
{ |
|||
var migrationPath = FindMigratorPath(version, lastMigrator.ToVersion).ToList(); |
|||
|
|||
foreach (var migrator in migrationPath) |
|||
{ |
|||
using (log.MeasureInformation(w => w |
|||
.WriteProperty("action", "Migration") |
|||
.WriteProperty("migrator", migrator.GetType().ToString()))) |
|||
{ |
|||
await migrator.UpdateAsync(); |
|||
|
|||
version = migrator.ToVersion; |
|||
} |
|||
} |
|||
} |
|||
finally |
|||
{ |
|||
await migrationStatus.UnlockAsync(version); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private IEnumerable<IMigration> FindMigratorPath(int fromVersion, int toVersion) |
|||
{ |
|||
var addedMigrators = new HashSet<IMigration>(); |
|||
|
|||
while (true) |
|||
{ |
|||
var bestMigrator = migrations.Where(x => x.FromVersion < x.ToVersion).FirstOrDefault(x => x.FromVersion == fromVersion); |
|||
|
|||
if (bestMigrator != null && addedMigrators.Add(bestMigrator)) |
|||
{ |
|||
fromVersion = bestMigrator.ToVersion; |
|||
|
|||
yield return bestMigrator; |
|||
} |
|||
else if (fromVersion != toVersion) |
|||
{ |
|||
throw new InvalidOperationException($"There is no migration path from {fromVersion} to {toVersion}."); |
|||
} |
|||
else |
|||
{ |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,111 @@ |
|||
// ==========================================================================
|
|||
// MigratorTests.cs
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex Group
|
|||
// All rights reserved.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using FakeItEasy; |
|||
using Squidex.Infrastructure.Log; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Infrastructure.Migrations |
|||
{ |
|||
public sealed class MigratorTests |
|||
{ |
|||
private readonly IMigrationStatus status = A.Fake<IMigrationStatus>(); |
|||
|
|||
public MigratorTests() |
|||
{ |
|||
A.CallTo(() => status.GetVersionAsync()).Returns(0); |
|||
A.CallTo(() => status.TryLockAsync()).Returns(true); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_migrate_step_by_step() |
|||
{ |
|||
var migrator_0_1 = BuildMigration(0, 1); |
|||
var migrator_1_2 = BuildMigration(1, 2); |
|||
var migrator_2_3 = BuildMigration(2, 3); |
|||
|
|||
var migrator = new Migrator(status, new[] { migrator_0_1, migrator_1_2, migrator_2_3 }, A.Fake<ISemanticLog>()); |
|||
|
|||
await migrator.MigrateAsync(); |
|||
|
|||
A.CallTo(() => migrator_0_1.UpdateAsync()).MustHaveHappened(); |
|||
A.CallTo(() => migrator_1_2.UpdateAsync()).MustHaveHappened(); |
|||
A.CallTo(() => migrator_2_3.UpdateAsync()).MustHaveHappened(); |
|||
|
|||
A.CallTo(() => status.UnlockAsync(3)).MustHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_unlock_when_failed() |
|||
{ |
|||
var migrator_0_1 = BuildMigration(0, 1); |
|||
var migrator_1_2 = BuildMigration(1, 2); |
|||
var migrator_2_3 = BuildMigration(2, 3); |
|||
|
|||
var migrator = new Migrator(status, new[] { migrator_0_1, migrator_1_2, migrator_2_3 }, A.Fake<ISemanticLog>()); |
|||
|
|||
A.CallTo(() => migrator_1_2.UpdateAsync()).Throws(new ArgumentException()); |
|||
|
|||
await Assert.ThrowsAsync<ArgumentException>(migrator.MigrateAsync); |
|||
|
|||
A.CallTo(() => migrator_0_1.UpdateAsync()).MustHaveHappened(); |
|||
A.CallTo(() => migrator_1_2.UpdateAsync()).MustHaveHappened(); |
|||
A.CallTo(() => migrator_2_3.UpdateAsync()).MustNotHaveHappened(); |
|||
|
|||
A.CallTo(() => status.UnlockAsync(1)).MustHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_migrate_with_fastest_path() |
|||
{ |
|||
var migrator_0_1 = BuildMigration(0, 1); |
|||
var migrator_0_2 = BuildMigration(0, 2); |
|||
var migrator_1_2 = BuildMigration(1, 2); |
|||
var migrator_2_3 = BuildMigration(2, 3); |
|||
|
|||
var migrator = new Migrator(status, new[] { migrator_0_1, migrator_0_2, migrator_1_2, migrator_2_3 }, A.Fake<ISemanticLog>()); |
|||
|
|||
await migrator.MigrateAsync(); |
|||
|
|||
A.CallTo(() => migrator_0_2.UpdateAsync()).MustHaveHappened(); |
|||
A.CallTo(() => migrator_0_1.UpdateAsync()).MustNotHaveHappened(); |
|||
A.CallTo(() => migrator_1_2.UpdateAsync()).MustNotHaveHappened(); |
|||
A.CallTo(() => migrator_2_3.UpdateAsync()).MustHaveHappened(); |
|||
|
|||
A.CallTo(() => status.UnlockAsync(3)).MustHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_throw_if_no_path_found() |
|||
{ |
|||
var migrator_0_1 = BuildMigration(0, 1); |
|||
var migrator_2_3 = BuildMigration(2, 3); |
|||
|
|||
var migrator = new Migrator(status, new[] { migrator_0_1, migrator_2_3 }, A.Fake<ISemanticLog>()); |
|||
|
|||
await Assert.ThrowsAsync<InvalidOperationException>(migrator.MigrateAsync); |
|||
|
|||
A.CallTo(() => migrator_0_1.UpdateAsync()).MustNotHaveHappened(); |
|||
A.CallTo(() => migrator_2_3.UpdateAsync()).MustNotHaveHappened(); |
|||
|
|||
A.CallTo(() => status.UnlockAsync(0)).MustHaveHappened(); |
|||
} |
|||
|
|||
private IMigration BuildMigration(int fromVersion, int toVersion) |
|||
{ |
|||
var migration = A.Fake<IMigration>(); |
|||
|
|||
A.CallTo(() => migration.FromVersion).Returns(fromVersion); |
|||
A.CallTo(() => migration.ToVersion).Returns(toVersion); |
|||
|
|||
return migration; |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue