diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f8e54da9d..3c72840715 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,6 +149,41 @@ jobs: path: | **/TestResults/* **/logs/* + test-efcore-sqlserver: + name: Microsoft Entity Framework Core SQL Server provider tests + runs-on: ubuntu-latest + strategy: + matrix: + provider: [ "EFCore-SqlServer" ] + services: + mssql: + image: mcr.microsoft.com/mssql/server:latest + ports: + - 1433:1433 + env: + ACCEPT_EULA: "Y" + MSSQL_PID: "Developer" + # [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="False positive")] + SA_PASSWORD: "yourStrong(!)Password" + steps: + - uses: actions/checkout@v2 + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: | + 3.1.x + 7.0.x + - name: Test + run: dotnet test --filter "Category=${{ matrix.provider }}&(Category=BVT|Category=SlowBVT|Category=Functional)" --blame-hang-timeout 10m --logger "trx" -- -parallel none -noshadow + - name: Archive Test Results + if: always() + uses: actions/upload-artifact@v3 + with: + name: test_output + retention-days: 1 + path: | + **/TestResults/* + **/logs/* test-sqlserver: name: Microsoft SQL Server provider tests runs-on: ubuntu-latest diff --git a/Directory.Packages.props b/Directory.Packages.props index be0fb3f5ab..fdc403e502 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -54,6 +54,7 @@ + diff --git a/Orleans.sln b/Orleans.sln index cbb8321f23..310a776594 100644 --- a/Orleans.sln +++ b/Orleans.sln @@ -246,6 +246,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Orleans.Persistence.EntityF EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Orleans.Reminders.EntityFrameworkCore.SqlServer", "src\EFCore\Orleans.Reminders.EntityFrameworkCore.SqlServer\Orleans.Reminders.EntityFrameworkCore.SqlServer.csproj", "{CC8ECC81-4160-47E9-B9D7-E578BAC424F7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Orleans.Clustering.EntityFrameworkCore.MySql", "src\EFCore\Orleans.Clustering.EntityFrameworkCore.MySql\Orleans.Clustering.EntityFrameworkCore.MySql.csproj", "{AA369C0C-9941-469E-BD5C-E5E5DB632431}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -652,6 +654,10 @@ Global {CC8ECC81-4160-47E9-B9D7-E578BAC424F7}.Debug|Any CPU.Build.0 = Debug|Any CPU {CC8ECC81-4160-47E9-B9D7-E578BAC424F7}.Release|Any CPU.ActiveCfg = Release|Any CPU {CC8ECC81-4160-47E9-B9D7-E578BAC424F7}.Release|Any CPU.Build.0 = Release|Any CPU + {AA369C0C-9941-469E-BD5C-E5E5DB632431}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA369C0C-9941-469E-BD5C-E5E5DB632431}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA369C0C-9941-469E-BD5C-E5E5DB632431}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA369C0C-9941-469E-BD5C-E5E5DB632431}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -771,6 +777,7 @@ Global {54F742D9-B721-4B2D-98F1-B2EDA8E8C70F} = {616ECAC3-2EA7-4819-9A05-EF4F4D8DDFA8} {95F5EC32-BEFC-4FBA-8BEA-857007AEB9C6} = {616ECAC3-2EA7-4819-9A05-EF4F4D8DDFA8} {CC8ECC81-4160-47E9-B9D7-E578BAC424F7} = {616ECAC3-2EA7-4819-9A05-EF4F4D8DDFA8} + {AA369C0C-9941-469E-BD5C-E5E5DB632431} = {616ECAC3-2EA7-4819-9A05-EF4F4D8DDFA8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7BFB3429-B5BB-4DB1-95B4-67D77A864952} diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Clustering.sql b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Clustering.sql new file mode 100644 index 0000000000..744b53c31f --- /dev/null +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Clustering.sql @@ -0,0 +1,63 @@ +CREATE TABLE IF NOT EXISTS `__EFMigrationsHistory` ( + `MigrationId` varchar(150) NOT NULL, + `ProductVersion` varchar(32) NOT NULL, + PRIMARY KEY (`MigrationId`) +); + +START TRANSACTION; + +IF NOT EXISTS(SELECT * FROM `__EFMigrationsHistory` WHERE `MigrationId` = '20231007024046_InitialClusteringSchema') +BEGIN + CREATE TABLE `Clusters` ( + `Id` varchar(255) NOT NULL, + `Timestamp` datetime(6) NOT NULL, + `Version` int NOT NULL, + `ETag` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`Id`) + ); +END; + +IF NOT EXISTS(SELECT * FROM `__EFMigrationsHistory` WHERE `MigrationId` = '20231007024046_InitialClusteringSchema') +BEGIN + CREATE TABLE `Silos` ( + `ClusterId` varchar(255) NOT NULL, + `Address` varchar(45) NOT NULL, + `Port` int NOT NULL, + `Generation` int NOT NULL, + `Name` varchar(150) NOT NULL, + `HostName` varchar(150) NOT NULL, + `Status` int NOT NULL, + `ProxyPort` int NULL, + `SuspectingTimes` longtext NULL, + `SuspectingSilos` longtext NULL, + `StartTime` datetime(6) NOT NULL, + `IAmAliveTime` datetime(6) NOT NULL, + `ETag` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`ClusterId`, `Address`, `Port`, `Generation`), + CONSTRAINT `FK_Silos_Clusters_ClusterId` FOREIGN KEY (`ClusterId`) REFERENCES `Clusters` (`Id`) ON DELETE CASCADE + ); +END; + +IF NOT EXISTS(SELECT * FROM `__EFMigrationsHistory` WHERE `MigrationId` = '20231007024046_InitialClusteringSchema') +BEGIN + CREATE INDEX `IDX_Silo_ClusterId` ON `Silos` (`ClusterId`); +END; + +IF NOT EXISTS(SELECT * FROM `__EFMigrationsHistory` WHERE `MigrationId` = '20231007024046_InitialClusteringSchema') +BEGIN + CREATE INDEX `IDX_Silo_ClusterId_Status` ON `Silos` (`ClusterId`, `Status`); +END; + +IF NOT EXISTS(SELECT * FROM `__EFMigrationsHistory` WHERE `MigrationId` = '20231007024046_InitialClusteringSchema') +BEGIN + CREATE INDEX `IDX_Silo_ClusterId_Status_IAmAlive` ON `Silos` (`ClusterId`, `Status`, `IAmAliveTime`); +END; + +IF NOT EXISTS(SELECT * FROM `__EFMigrationsHistory` WHERE `MigrationId` = '20231007024046_InitialClusteringSchema') +BEGIN + INSERT INTO `__EFMigrationsHistory` (`MigrationId`, `ProductVersion`) + VALUES ('20231007024046_InitialClusteringSchema', '7.0.11'); +END; + +COMMIT; + diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/Migrations/20231007024046_InitialClusteringSchema.Designer.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/Migrations/20231007024046_InitialClusteringSchema.Designer.cs new file mode 100644 index 0000000000..eb65442d71 --- /dev/null +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/Migrations/20231007024046_InitialClusteringSchema.Designer.cs @@ -0,0 +1,128 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Orleans.Clustering.EntityFrameworkCore.MySql.Data; + +#nullable disable + +namespace Orleans.Clustering.EntityFrameworkCore.MySql.Data.Migrations +{ + [DbContext(typeof(MySqlClusterDbContext))] + [Migration("20231007024046_InitialClusteringSchema")] + partial class InitialClusteringSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Orleans.Clustering.EntityFrameworkCore.Data.ClusterRecord", b => + { + b.Property("Id") + .HasColumnType("varchar(255)"); + + b.Property("ETag") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id") + .HasName("PK_Cluster"); + + b.ToTable("Clusters"); + }); + + modelBuilder.Entity("Orleans.Clustering.EntityFrameworkCore.Data.SiloRecord", b => + { + b.Property("ClusterId") + .HasColumnType("varchar(255)"); + + b.Property("Address") + .HasMaxLength(45) + .HasColumnType("varchar(45)"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("Generation") + .HasColumnType("int"); + + b.Property("ETag") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)"); + + b.Property("HostName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("IAmAliveTime") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("ProxyPort") + .HasColumnType("int"); + + b.Property("StartTime") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SuspectingSilos") + .HasColumnType("longtext"); + + b.Property("SuspectingTimes") + .HasColumnType("longtext"); + + b.HasKey("ClusterId", "Address", "Port", "Generation") + .HasName("PK_Silo"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IDX_Silo_ClusterId"); + + b.HasIndex("ClusterId", "Status") + .HasDatabaseName("IDX_Silo_ClusterId_Status"); + + b.HasIndex("ClusterId", "Status", "IAmAliveTime") + .HasDatabaseName("IDX_Silo_ClusterId_Status_IAmAlive"); + + b.ToTable("Silos"); + }); + + modelBuilder.Entity("Orleans.Clustering.EntityFrameworkCore.Data.SiloRecord", b => + { + b.HasOne("Orleans.Clustering.EntityFrameworkCore.Data.ClusterRecord", "Cluster") + .WithMany("Silos") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("Orleans.Clustering.EntityFrameworkCore.Data.ClusterRecord", b => + { + b.Navigation("Silos"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/Migrations/20231007024046_InitialClusteringSchema.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/Migrations/20231007024046_InitialClusteringSchema.cs new file mode 100644 index 0000000000..bf418fc620 --- /dev/null +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/Migrations/20231007024046_InitialClusteringSchema.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using MySql.EntityFrameworkCore.Metadata; + +#nullable disable + +namespace Orleans.Clustering.EntityFrameworkCore.MySql.Data.Migrations +{ + /// + public partial class InitialClusteringSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Clusters", + columns: table => new + { + Id = table.Column(type: "varchar(255)", nullable: false), + Timestamp = table.Column(type: "datetime(6)", nullable: false), + Version = table.Column(type: "int", nullable: false), + ETag = table.Column(type: "datetime(6)", rowVersion: true, nullable: false) + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.ComputedColumn) + }, + constraints: table => + { + table.PrimaryKey("PK_Cluster", x => x.Id); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Silos", + columns: table => new + { + ClusterId = table.Column(type: "varchar(255)", nullable: false), + Address = table.Column(type: "varchar(45)", maxLength: 45, nullable: false), + Port = table.Column(type: "int", nullable: false), + Generation = table.Column(type: "int", nullable: false), + Name = table.Column(type: "varchar(150)", maxLength: 150, nullable: false), + HostName = table.Column(type: "varchar(150)", maxLength: 150, nullable: false), + Status = table.Column(type: "int", nullable: false), + ProxyPort = table.Column(type: "int", nullable: true), + SuspectingTimes = table.Column(type: "longtext", nullable: true), + SuspectingSilos = table.Column(type: "longtext", nullable: true), + StartTime = table.Column(type: "datetime(6)", nullable: false), + IAmAliveTime = table.Column(type: "datetime(6)", nullable: false), + ETag = table.Column(type: "datetime(6)", rowVersion: true, nullable: false) + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.ComputedColumn) + }, + constraints: table => + { + table.PrimaryKey("PK_Silo", x => new { x.ClusterId, x.Address, x.Port, x.Generation }); + table.ForeignKey( + name: "FK_Silos_Clusters_ClusterId", + column: x => x.ClusterId, + principalTable: "Clusters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IDX_Silo_ClusterId", + table: "Silos", + column: "ClusterId"); + + migrationBuilder.CreateIndex( + name: "IDX_Silo_ClusterId_Status", + table: "Silos", + columns: new[] { "ClusterId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IDX_Silo_ClusterId_Status_IAmAlive", + table: "Silos", + columns: new[] { "ClusterId", "Status", "IAmAliveTime" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Silos"); + + migrationBuilder.DropTable( + name: "Clusters"); + } + } +} diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/Migrations/MySqlClusterDbContextModelSnapshot.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/Migrations/MySqlClusterDbContextModelSnapshot.cs new file mode 100644 index 0000000000..b43b333031 --- /dev/null +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/Migrations/MySqlClusterDbContextModelSnapshot.cs @@ -0,0 +1,125 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Orleans.Clustering.EntityFrameworkCore.MySql.Data; + +#nullable disable + +namespace Orleans.Clustering.EntityFrameworkCore.MySql.Data.Migrations +{ + [DbContext(typeof(MySqlClusterDbContext))] + partial class MySqlClusterDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Orleans.Clustering.EntityFrameworkCore.Data.ClusterRecord", b => + { + b.Property("Id") + .HasColumnType("varchar(255)"); + + b.Property("ETag") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id") + .HasName("PK_Cluster"); + + b.ToTable("Clusters"); + }); + + modelBuilder.Entity("Orleans.Clustering.EntityFrameworkCore.Data.SiloRecord", b => + { + b.Property("ClusterId") + .HasColumnType("varchar(255)"); + + b.Property("Address") + .HasMaxLength(45) + .HasColumnType("varchar(45)"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("Generation") + .HasColumnType("int"); + + b.Property("ETag") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)"); + + b.Property("HostName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("IAmAliveTime") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("ProxyPort") + .HasColumnType("int"); + + b.Property("StartTime") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SuspectingSilos") + .HasColumnType("longtext"); + + b.Property("SuspectingTimes") + .HasColumnType("longtext"); + + b.HasKey("ClusterId", "Address", "Port", "Generation") + .HasName("PK_Silo"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IDX_Silo_ClusterId"); + + b.HasIndex("ClusterId", "Status") + .HasDatabaseName("IDX_Silo_ClusterId_Status"); + + b.HasIndex("ClusterId", "Status", "IAmAliveTime") + .HasDatabaseName("IDX_Silo_ClusterId_Status_IAmAlive"); + + b.ToTable("Silos"); + }); + + modelBuilder.Entity("Orleans.Clustering.EntityFrameworkCore.Data.SiloRecord", b => + { + b.HasOne("Orleans.Clustering.EntityFrameworkCore.Data.ClusterRecord", "Cluster") + .WithMany("Silos") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("Orleans.Clustering.EntityFrameworkCore.Data.ClusterRecord", b => + { + b.Navigation("Silos"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/MySqlClusterDbContext.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/MySqlClusterDbContext.cs new file mode 100644 index 0000000000..5d2d541ae4 --- /dev/null +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/MySqlClusterDbContext.cs @@ -0,0 +1,88 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Orleans.Clustering.EntityFrameworkCore.Data; + +namespace Orleans.Clustering.EntityFrameworkCore.MySql.Data; + +public class MySqlClusterDbContext : ClusterDbContext +{ + public MySqlClusterDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity>(c => + { + c.HasKey(p => p.Id).HasName("PK_Cluster"); + c.Property(p => p.Timestamp).IsRequired(); + c.Property(p => p.Version).IsRequired(); + c.Property(p => p.ETag).IsRowVersion().IsConcurrencyToken(); + + c + .HasMany(p => p.Silos) + .WithOne(r => r.Cluster) + .HasForeignKey(r => r.ClusterId); + }); + + var listToStringConverter = new ValueConverter, string>( + v => string.Join(",", v), + v => v.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries).ToList()); + + + var listComparer = new ValueComparer>( + (c1, c2) => (c1 == null && c2 == null) || (c1 != null && c2 != null && c1.SequenceEqual(c2)), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => new List(c)); + + modelBuilder.Entity>(c => + { + c.HasKey(p => new {p.ClusterId, p.Address, p.Port, p.Generation}).HasName("PK_Silo"); + c.Property(p => p.Address).HasMaxLength(45).IsRequired(); + c.Property(p => p.Port).IsRequired(); + c.Property(p => p.Generation).IsRequired(); + c.Property(p => p.Name).HasMaxLength(150).IsRequired(); + c.Property(p => p.HostName).HasMaxLength(150).IsRequired(); + c.Property(p => p.Status).IsRequired(); + c.Property(p => p.ProxyPort).IsRequired(false); + c.Property(p => p.SuspectingTimes).IsRequired(false).HasConversion(listToStringConverter).Metadata.SetValueComparer(listComparer); + c.Property(p => p.SuspectingSilos).IsRequired(false).HasConversion(listToStringConverter).Metadata.SetValueComparer(listComparer); + c.Property(p => p.StartTime).IsRequired(); + c.Property(p => p.IAmAliveTime).IsRequired(); + c.Property(p => p.ETag).IsRowVersion().IsConcurrencyToken(); + + c + .HasOne(p => p.Cluster) + .WithMany(p => p.Silos) + .HasForeignKey(p => p.ClusterId); + + c.HasIndex(p => p.ClusterId).HasDatabaseName("IDX_Silo_ClusterId"); + c.HasIndex(p => new {p.ClusterId, p.Status}).HasDatabaseName("IDX_Silo_ClusterId_Status"); + c.HasIndex(p => new {p.ClusterId, p.Status, p.IAmAliveTime}).HasDatabaseName("IDX_Silo_ClusterId_Status_IAmAlive"); + }); + } + + // public override Task SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken()) + // { + // foreach (var entity in ChangeTracker.Entries().Where(e => e.State is EntityState.Modified or EntityState.Added)) + // { + // switch (entity.Entity) + // { + // case ClusterRecord clusterRecord: + // clusterRecord.ETag = Guid.NewGuid(); + // continue; + // case SiloRecord siloRecord: + // siloRecord.ETag = Guid.NewGuid(); + // continue; + // } + // } + // + // return base.SaveChangesAsync(cancellationToken); + // } +} \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/MySqlClusterDbContextFactory.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/MySqlClusterDbContextFactory.cs new file mode 100644 index 0000000000..3cb7406764 --- /dev/null +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Data/MySqlClusterDbContextFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Orleans.Clustering.EntityFrameworkCore.MySql.Data; + +public class MySqlClusterDbContextFactory: IDesignTimeDbContextFactory +{ + public MySqlClusterDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseMySQL("Data Source=db.db", opt => + { + opt.MigrationsHistoryTable("__EFMigrationsHistory"); + opt.MigrationsAssembly(typeof(MySqlClusterDbContext).Assembly.FullName); + }); + return new MySqlClusterDbContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/MySqlClusterETagConverter.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/MySqlClusterETagConverter.cs new file mode 100644 index 0000000000..1a15c0caac --- /dev/null +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/MySqlClusterETagConverter.cs @@ -0,0 +1,12 @@ +using System; +using System.Globalization; +using Orleans.Clustering.EntityFrameworkCore; + +namespace Orleans.Clustering; + +public class MySqlClusterETagConverter : IEFClusterETagConverter +{ + public DateTime ToDbETag(string etag) => DateTime.Parse(etag, CultureInfo.InvariantCulture); + + public string FromDbETag(DateTime etag) => etag.ToString(CultureInfo.InvariantCulture); +} \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/MySqlHostingExtensions.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/MySqlHostingExtensions.cs new file mode 100644 index 0000000000..3b9f84d84f --- /dev/null +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/MySqlHostingExtensions.cs @@ -0,0 +1,101 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Hosting; +using Orleans.Clustering.EntityFrameworkCore; +using Orleans.Clustering.EntityFrameworkCore.MySql.Data; + +namespace Orleans.Clustering; + +public static class MySqlHostingExtensions +{ + /// + /// Configures the silo to use Entity Framework Core for clustering with MySQL. + /// + /// + /// The silo builder. + /// + /// + /// The database configuration delegate. + /// + /// + /// The provided . + /// + public static ISiloBuilder UseEntityFrameworkCoreMySqlClustering( + this ISiloBuilder builder, + Action configureDatabase) + { + return builder + .ConfigureServices(services => + { + services.AddPooledDbContextFactory(configureDatabase); + }) + .UseEntityFrameworkCoreMySqlClustering(); + } + + /// + /// Configures the silo to use Entity Framework Core for clustering with MySQL. + /// This overload expects a to be registered already. + /// + /// + /// The silo builder. + /// + /// + /// The provided . + /// + public static ISiloBuilder UseEntityFrameworkCoreMySqlClustering(this ISiloBuilder builder) + { + return builder + .ConfigureServices(services => + { + services + .AddSingleton, MySqlClusterETagConverter>(); + }) + .UseEntityFrameworkCoreClustering(); + } + + /// + /// Configures the silo to use Entity Framework Core for clustering with MySQL. + /// + /// + /// The silo builder. + /// + /// + /// The database configuration delegate. + /// + /// + /// The provided . + /// + public static IClientBuilder UseEntityFrameworkCoreMySqlClustering( + this IClientBuilder builder, + Action configureDatabase) + { + return builder + .ConfigureServices(services => + { + services.AddPooledDbContextFactory(configureDatabase); + }) + .UseEntityFrameworkCoreMySqlClustering(); + } + + /// + /// Configures the silo to use Entity Framework Core for clustering with MySQL. + /// This overload expects a to be registered already. + /// + /// + /// The silo builder. + /// + /// + /// The provided . + /// + public static IClientBuilder UseEntityFrameworkCoreMySqlClustering( + this IClientBuilder builder) + { + return builder + .ConfigureServices(services => + { + services.AddSingleton, MySqlClusterETagConverter>(); + }) + .UseEntityFrameworkCoreClustering(); + } +} \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Orleans.Clustering.EntityFrameworkCore.MySql.csproj b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Orleans.Clustering.EntityFrameworkCore.MySql.csproj new file mode 100644 index 0000000000..f6c23e44cb --- /dev/null +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.MySql/Orleans.Clustering.EntityFrameworkCore.MySql.csproj @@ -0,0 +1,29 @@ + + + + Microsoft.Orleans.Clustering.EntityFrameworkCore.MySql + Microsoft Orleans Entity Framework Core (MySql) Clustering Provider + Microsoft Orleans clustering provider backed by Entity Framework Core for MySQL + $(PackageTags) Entity Framework Core SQL MySQL PostgreSQL Oracle + enable + $(DefaultTargetFrameworks) + + + + Orleans.Clustering.EntityFrameworkCore.MySql + Orleans.Clustering.EntityFrameworkCore.MySql + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/Data/SqlServerClusterDbContext.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/Data/SqlServerClusterDbContext.cs index 4624133157..e89488ac4e 100644 --- a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/Data/SqlServerClusterDbContext.cs +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/Data/SqlServerClusterDbContext.cs @@ -1,6 +1,6 @@ using System; -using System.Collections.Generic; using System.Linq; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -8,7 +8,7 @@ namespace Orleans.Clustering.EntityFrameworkCore.SqlServer.Data; -public sealed class SqlServerClusterDbContext : ClusterDbContext +public sealed class SqlServerClusterDbContext : ClusterDbContext { public SqlServerClusterDbContext(DbContextOptions options) : base(options) { @@ -16,7 +16,7 @@ public SqlServerClusterDbContext(DbContextOptions opt protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(c => + modelBuilder.Entity>(c => { c.HasKey(p => p.Id).IsClustered(false).HasName("PK_Cluster"); c.Property(p => p.Timestamp).IsRequired(); @@ -39,7 +39,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), c => new List(c)); - modelBuilder.Entity(c => + modelBuilder.Entity>(c => { c.HasKey(p => new {p.ClusterId, p.Address, p.Port, p.Generation}).IsClustered(false).HasName("PK_Silo"); c.Property(p => p.Address).HasMaxLength(45).IsRequired(); diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/Orleans.Clustering.EntityFrameworkCore.SqlServer.csproj b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/Orleans.Clustering.EntityFrameworkCore.SqlServer.csproj index ff2e5afd92..d71a4b121c 100644 --- a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/Orleans.Clustering.EntityFrameworkCore.SqlServer.csproj +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/Orleans.Clustering.EntityFrameworkCore.SqlServer.csproj @@ -14,6 +14,10 @@ Orleans.Clustering.EntityFrameworkCore.SqlServer + + + + diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/SqlServerClusterETagConverter.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/SqlServerClusterETagConverter.cs new file mode 100644 index 0000000000..599b4c7452 --- /dev/null +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/SqlServerClusterETagConverter.cs @@ -0,0 +1,10 @@ +using System; + +namespace Orleans.Clustering.EntityFrameworkCore.SqlServer; + +internal class SqlServerClusterETagConverter : IEFClusterETagConverter +{ + public byte[] ToDbETag(string etag) => BitConverter.GetBytes(ulong.Parse(etag)); + + public string FromDbETag(byte[] etag) => BitConverter.ToUInt64(etag).ToString(); +} \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/SqlServerHostingExtensions.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/SqlServerHostingExtensions.cs index ee898a5b3c..f8e6105007 100644 --- a/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/SqlServerHostingExtensions.cs +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore.SqlServer/SqlServerHostingExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Orleans.Hosting; using Orleans.Clustering.EntityFrameworkCore; +using Orleans.Clustering.EntityFrameworkCore.SqlServer; using Orleans.Clustering.EntityFrameworkCore.SqlServer.Data; namespace Orleans.Clustering; @@ -30,7 +31,7 @@ public static ISiloBuilder UseEntityFrameworkCoreSqlServerClustering( { services.AddPooledDbContextFactory(configureDatabase); }) - .UseEntityFrameworkCoreClustering(); + .UseEntityFrameworkCoreSqlServerClustering(); } /// @@ -48,8 +49,10 @@ public static ISiloBuilder UseEntityFrameworkCoreSqlServerClustering(this ISiloB return builder .ConfigureServices(services => { - services.AddSingleton>(); - }); + services + .AddSingleton, SqlServerClusterETagConverter>(); + }) + .UseEntityFrameworkCoreClustering(); } /// @@ -73,7 +76,7 @@ public static IClientBuilder UseEntityFrameworkCoreSqlServerClustering( { services.AddPooledDbContextFactory(configureDatabase); }) - .UseEntityFrameworkCoreClustering(); + .UseEntityFrameworkCoreSqlServerClustering(); } /// @@ -89,6 +92,11 @@ public static IClientBuilder UseEntityFrameworkCoreSqlServerClustering( public static IClientBuilder UseEntityFrameworkCoreSqlServerClustering( this IClientBuilder builder) { - return builder.UseEntityFrameworkCoreClustering(); + return builder + .ConfigureServices(services => + { + services.AddSingleton, SqlServerClusterETagConverter>(); + }) + .UseEntityFrameworkCoreClustering(); } } \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/ClusterDbContext.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/ClusterDbContext.cs index 69793a9ba2..41d0367e4f 100644 --- a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/ClusterDbContext.cs +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/ClusterDbContext.cs @@ -2,10 +2,10 @@ namespace Orleans.Clustering.EntityFrameworkCore.Data; -public class ClusterDbContext : DbContext where TDbContext : DbContext +public class ClusterDbContext : DbContext where TDbContext : DbContext { - public DbSet Clusters { get; set; } = default!; - public DbSet Silos { get; set; } = default!; + public DbSet> Clusters { get; set; } = default!; + public DbSet> Silos { get; set; } = default!; public ClusterDbContext(DbContextOptions options) : base(options) { @@ -13,7 +13,7 @@ public ClusterDbContext(DbContextOptions options) : base(options) protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(c => + modelBuilder.Entity>(c => { c.HasKey(p => p.Id); c.Property(p => p.Timestamp).IsRequired(); @@ -26,7 +26,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(r => r.ClusterId); }); - modelBuilder.Entity(c => + modelBuilder.Entity>(c => { c.HasKey(p => new {p.ClusterId, p.Address, p.Port, p.Generation}); c.Property(p => p.Address).HasMaxLength(45).IsRequired(); diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/ClusterRecord.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/ClusterRecord.cs index 468ff0fa32..a528e1f799 100644 --- a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/ClusterRecord.cs +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/ClusterRecord.cs @@ -3,11 +3,11 @@ namespace Orleans.Clustering.EntityFrameworkCore.Data; -public class ClusterRecord +public class ClusterRecord { public string Id { get; set; } = default!; public DateTimeOffset Timestamp { get; set; } public int Version { get; set; } - public byte[] ETag { get; set; } = Array.Empty(); - public List Silos { get; set; } = new(); + public TETag ETag { get; set; } = default!; + public List> Silos { get; set; } = new(); } \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/SiloRecord.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/SiloRecord.cs index 48eacd4601..691ca032ef 100644 --- a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/SiloRecord.cs +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Data/SiloRecord.cs @@ -4,7 +4,7 @@ namespace Orleans.Clustering.EntityFrameworkCore.Data; -public class SiloRecord +public class SiloRecord { public string ClusterId { get; set; } = default!; public string Address { get; set; } = default!; @@ -18,6 +18,6 @@ public class SiloRecord public List SuspectingSilos { get; set; } = new(); public DateTimeOffset StartTime { get; set; } public DateTimeOffset IAmAliveTime { get; set; } - public byte[] ETag { get; set; } = Array.Empty(); - public ClusterRecord Cluster { get; set; } = default!; + public TETag ETag { get; set; } = default!; + public ClusterRecord Cluster { get; set; } = default!; } \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFClusteringExtensions.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFClusteringExtensions.cs index 57aac11a57..4d1b2e57de 100644 --- a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFClusteringExtensions.cs +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFClusteringExtensions.cs @@ -22,21 +22,21 @@ public static class EFClusteringExtensions /// /// The provided . /// - public static ISiloBuilder UseEntityFrameworkCoreClustering( + public static ISiloBuilder UseEntityFrameworkCoreClustering( this ISiloBuilder builder, - Action configureDatabase) where TDbContext : ClusterDbContext + Action configureDatabase) where TDbContext : ClusterDbContext { return builder .ConfigureServices(services => { services.AddPooledDbContextFactory(configureDatabase); }) - .UseEntityFrameworkCoreClustering(); + .UseEntityFrameworkCoreClustering(); } /// /// Configures the silo to use Entity Framework Core for clustering. - /// This overload expects a to be registered already. + /// This overload expects a to be registered already. /// /// /// The silo builder. @@ -44,13 +44,14 @@ public static ISiloBuilder UseEntityFrameworkCoreClustering( /// /// The provided . /// - public static ISiloBuilder UseEntityFrameworkCoreClustering( - this ISiloBuilder builder) where TDbContext : ClusterDbContext + public static ISiloBuilder UseEntityFrameworkCoreClustering( + this ISiloBuilder builder) where TDbContext : ClusterDbContext { return builder .ConfigureServices(services => { - services.AddSingleton>(); + services + .AddSingleton>(); }); } @@ -66,21 +67,21 @@ public static ISiloBuilder UseEntityFrameworkCoreClustering( /// /// The provided . /// - public static IClientBuilder UseEntityFrameworkCoreClustering( + public static IClientBuilder UseEntityFrameworkCoreClustering( this IClientBuilder builder, - Action configureDatabase) where TDbContext : ClusterDbContext + Action configureDatabase) where TDbContext : ClusterDbContext { return builder .ConfigureServices(services => { services.AddPooledDbContextFactory(configureDatabase); }) - .UseEntityFrameworkCoreClustering(); + .UseEntityFrameworkCoreClustering(); } /// /// Configures the silo to use Entity Framework Core for clustering. - /// This overload expects a to be registered already. + /// This overload expects a to be registered already. /// /// /// The silo builder. @@ -88,13 +89,13 @@ public static IClientBuilder UseEntityFrameworkCoreClustering( /// /// The provided . /// - public static IClientBuilder UseEntityFrameworkCoreClustering( - this IClientBuilder builder) where TDbContext : ClusterDbContext + public static IClientBuilder UseEntityFrameworkCoreClustering( + this IClientBuilder builder) where TDbContext : ClusterDbContext { return builder .ConfigureServices(services => { - services.AddSingleton>(); + services.AddSingleton>(); }); } } \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFGatewayListProvider.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFGatewayListProvider.cs index e7e2973b9a..b439f7daf8 100644 --- a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFGatewayListProvider.cs +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFGatewayListProvider.cs @@ -13,7 +13,7 @@ namespace Orleans.Clustering.EntityFrameworkCore; -internal class EFGatewayListProvider : IGatewayListProvider where TDbContext : ClusterDbContext +internal class EFGatewayListProvider : IGatewayListProvider where TDbContext : ClusterDbContext { private readonly ILogger _logger; private readonly string _clusterId; @@ -29,7 +29,7 @@ public EFGatewayListProvider( IOptions gatewayOptions, IDbContextFactory dbContextFactory) { - this._logger = loggerFactory.CreateLogger>(); + this._logger = loggerFactory.CreateLogger>(); this._clusterId = clusterOptions.Value.ClusterId; this._dbContextFactory = dbContextFactory; this.MaxStaleness = gatewayOptions.Value.GatewayListRefreshPeriod; @@ -67,5 +67,5 @@ public async Task> GetGateways() } } - private static Uri ConvertToGatewayUri(SiloRecord record) => SiloAddress.New(new IPEndPoint(IPAddress.Parse(record.Address), record.ProxyPort!.Value), record.Generation).ToGatewayUri(); + private static Uri ConvertToGatewayUri(SiloRecord record) => SiloAddress.New(new IPEndPoint(IPAddress.Parse(record.Address), record.ProxyPort!.Value), record.Generation).ToGatewayUri(); } \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFMembershipTable.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFMembershipTable.cs index 169b3da380..fe17ec9882 100644 --- a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFMembershipTable.cs +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/EFMembershipTable.cs @@ -12,21 +12,24 @@ namespace Orleans.Clustering.EntityFrameworkCore; -internal class EFMembershipTable : IMembershipTable where TDbContext : ClusterDbContext +internal class EFMembershipTable : IMembershipTable where TDbContext : ClusterDbContext { private readonly ILogger _logger; private readonly string _clusterId; private readonly IDbContextFactory _dbContextFactory; - private SiloRecord? _self; + private readonly IEFClusterETagConverter _etagConverter; + private SiloRecord? _self; public EFMembershipTable( ILoggerFactory loggerFactory, IOptions clusterOptions, - IDbContextFactory dbContextFactory) + IDbContextFactory dbContextFactory, + IEFClusterETagConverter etagConverter) { - this._logger = loggerFactory.CreateLogger>(); + this._logger = loggerFactory.CreateLogger>(); this._clusterId = clusterOptions.Value.ClusterId; this._dbContextFactory = dbContextFactory; + this._etagConverter = etagConverter; } public async Task InitializeMembershipTable(bool tryInitTableVersion) @@ -44,7 +47,7 @@ public async Task InitializeMembershipTable(bool tryInitTableVersion) if (record is not null) return; - record = new ClusterRecord {Version = 0, Id = this._clusterId, Timestamp = DateTimeOffset.UtcNow}; + record = new ClusterRecord {Version = 0, Id = this._clusterId, Timestamp = DateTimeOffset.UtcNow}; ctx.Clusters.Add(record); await ctx.SaveChangesAsync().ConfigureAwait(false); @@ -97,7 +100,7 @@ public async Task CleanupDefunctSiloEntries(DateTimeOffset beforeDate) var silos = await ctx.Silos .Where(s => s.ClusterId == this._clusterId && - s.Status == SiloStatus.Dead && + s.Status != SiloStatus.Active && s.IAmAliveTime < beforeDate) .ToArrayAsync() .ConfigureAwait(false); @@ -122,7 +125,7 @@ public async Task ReadRow(SiloAddress key) { var ctx = this._dbContextFactory.CreateDbContext(); - var record = await ctx.Silos.Include(s => s.ClusterId).AsNoTracking() + var record = await ctx.Silos.Include(s => s.Cluster).AsNoTracking() .SingleOrDefaultAsync(s => s.ClusterId == this._clusterId && s.Address == key.Endpoint.Address.ToString() && @@ -137,10 +140,10 @@ public async Task ReadRow(SiloAddress key) var version = new TableVersion( record.Cluster.Version, - BitConverter.ToUInt64(record.ETag).ToString() + this._etagConverter.FromDbETag(record.Cluster.ETag) ); - var memEntries = new List> {Tuple.Create(ConvertRecord(record), BitConverter.ToUInt64(record.ETag).ToString())}; + var memEntries = new List> {Tuple.Create(ConvertRecord(record), this._etagConverter.FromDbETag(record.ETag))}; return new MembershipTableData(memEntries, version); } @@ -169,7 +172,7 @@ public async Task ReadAll() var version = new TableVersion( clusterRecord.Version, - BitConverter.ToUInt64(clusterRecord.ETag).ToString() + this._etagConverter.FromDbETag(clusterRecord.ETag) ); var memEntries = new List>(); @@ -178,7 +181,7 @@ public async Task ReadAll() try { var membershipEntry = ConvertRecord(siloRecord); - memEntries.Add(new Tuple(membershipEntry, BitConverter.ToUInt64(siloRecord.ETag).ToString())); + memEntries.Add(new Tuple(membershipEntry, this._etagConverter.FromDbETag(siloRecord.ETag))); } catch (Exception exc) { @@ -210,11 +213,12 @@ public async Task InsertRow(MembershipEntry entry, TableVersion tableVersi ctx.Clusters.Update(clusterRecord); ctx.Silos.Add(siloRecord); - await ctx.SaveChangesAsync().ConfigureAwait(false); + var affected =await ctx.SaveChangesAsync().ConfigureAwait(false); return true; } - catch (DbUpdateConcurrencyException) + catch (DbUpdateException exc) { + this._logger.LogWarning(exc, "Failure inserting entry for cluster {Cluster}", this._clusterId); return false; } catch (Exception exc) @@ -231,15 +235,14 @@ public async Task UpdateRow(MembershipEntry entry, string etag, TableVersi { var clusterRecord = this.ConvertToRecord(tableVersion); var siloRecord = this.ConvertToRecord(entry); - siloRecord.ClusterId = clusterRecord.Id; - siloRecord.ETag = BitConverter.GetBytes(ulong.Parse(etag)); + siloRecord.ETag = this._etagConverter.ToDbETag(etag); var ctx = this._dbContextFactory.CreateDbContext(); ctx.Clusters.Update(clusterRecord); ctx.Silos.Update(siloRecord); - await ctx.SaveChangesAsync().ConfigureAwait(false); + var affected = await ctx.SaveChangesAsync().ConfigureAwait(false); return true; } catch (DbUpdateConcurrencyException) @@ -294,7 +297,7 @@ public async Task UpdateIAmAlive(MembershipEntry entry) } } - private static MembershipEntry ConvertRecord(in SiloRecord record) + private static MembershipEntry ConvertRecord(in SiloRecord record) { var entry = new MembershipEntry { @@ -329,9 +332,9 @@ private static MembershipEntry ConvertRecord(in SiloRecord record) return entry; } - private SiloRecord ConvertToRecord(in MembershipEntry memEntry) + private SiloRecord ConvertToRecord(in MembershipEntry memEntry) { - var record = new SiloRecord + var record = new SiloRecord { ClusterId = this._clusterId, Address = memEntry.SiloAddress.Endpoint.Address.ToString(), @@ -359,8 +362,8 @@ private SiloRecord ConvertToRecord(in MembershipEntry memEntry) return record; } - private ClusterRecord ConvertToRecord(in TableVersion tableVersion) + private ClusterRecord ConvertToRecord(in TableVersion tableVersion) { - return new() {Id = this._clusterId, Version = tableVersion.Version, Timestamp = DateTimeOffset.UtcNow, ETag = BitConverter.GetBytes(ulong.Parse(tableVersion.VersionEtag))}; + return new() {Id = this._clusterId, Version = tableVersion.Version, Timestamp = DateTimeOffset.UtcNow, ETag = this._etagConverter.ToDbETag(tableVersion.VersionEtag)}; } } \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/IEFClusterETagConverter.cs b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/IEFClusterETagConverter.cs new file mode 100644 index 0000000000..a982357cda --- /dev/null +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/IEFClusterETagConverter.cs @@ -0,0 +1,8 @@ +namespace Orleans.Clustering.EntityFrameworkCore; + +public interface IEFClusterETagConverter +{ + TETag ToDbETag(string etag); + + string FromDbETag(TETag etag); +} \ No newline at end of file diff --git a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Orleans.Clustering.EntityFrameworkCore.csproj b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Orleans.Clustering.EntityFrameworkCore.csproj index 9ea83e0591..650952c12b 100644 --- a/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Orleans.Clustering.EntityFrameworkCore.csproj +++ b/src/EFCore/Orleans.Clustering.EntityFrameworkCore/Orleans.Clustering.EntityFrameworkCore.csproj @@ -11,6 +11,8 @@ + + diff --git a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/Data/SqlServerGrainDirectoryDbContext.cs b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/Data/SqlServerGrainDirectoryDbContext.cs index 873b7973a3..7225d4ae7b 100644 --- a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/Data/SqlServerGrainDirectoryDbContext.cs +++ b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/Data/SqlServerGrainDirectoryDbContext.cs @@ -3,7 +3,7 @@ namespace Orleans.GrainDirectory.EntityFrameworkCore.SqlServer.Data; -public class SqlServerGrainDirectoryDbContext : GrainDirectoryDbContext +public class SqlServerGrainDirectoryDbContext : GrainDirectoryDbContext { public SqlServerGrainDirectoryDbContext(DbContextOptions options) : base(options) { @@ -11,7 +11,7 @@ public SqlServerGrainDirectoryDbContext(DbContextOptions(c => + modelBuilder.Entity>(c => { c.HasKey(p => new {p.ClusterId, p.GrainId}).IsClustered(false).HasName("PK_Activations"); c.Property(p => p.ClusterId).IsRequired(); diff --git a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer.csproj b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer.csproj index 91bdd2a457..2e0c9d3fbe 100644 --- a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer.csproj +++ b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer.csproj @@ -14,6 +14,10 @@ Orleans.GrainDirectory.EntityFrameworkCore.SqlServer + + + + diff --git a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/SqlServerGrainDirectoryETagConverter.cs b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/SqlServerGrainDirectoryETagConverter.cs new file mode 100644 index 0000000000..fcc7afc2c4 --- /dev/null +++ b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/SqlServerGrainDirectoryETagConverter.cs @@ -0,0 +1,11 @@ +using System; +using Orleans.GrainDirectory.EntityFrameworkCore; + +namespace Orleans.GrainDirectory; + +internal class SqlServerGrainDirectoryETagConverter : IEFGrainDirectoryETagConverter +{ + public byte[] ToDbETag(string etag) => BitConverter.GetBytes(ulong.Parse(etag)); + + public string FromDbETag(byte[] etag) => BitConverter.ToUInt64(etag).ToString(); +} \ No newline at end of file diff --git a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/SqlServerHostingExtensions.cs b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/SqlServerHostingExtensions.cs index 97b1e360f9..fc9aa97c6c 100644 --- a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/SqlServerHostingExtensions.cs +++ b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore.SqlServer/SqlServerHostingExtensions.cs @@ -55,7 +55,8 @@ internal static IServiceCollection AddEntityFrameworkCoreSqlServerGrainDirectory string name) { services - .AddSingletonNamedService(name, (sp, _) => ActivatorUtilities.CreateInstance>(sp)) + .AddSingleton, SqlServerGrainDirectoryETagConverter>() + .AddSingletonNamedService(name, (sp, _) => ActivatorUtilities.CreateInstance>(sp)) .AddSingletonNamedService>(name, (s, n) => (ILifecycleParticipant)s.GetRequiredServiceByName(n)); return services; diff --git a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/Data/GrainActivationRecord.cs b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/Data/GrainActivationRecord.cs index 085cb4a4ae..fc7e5e62d5 100644 --- a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/Data/GrainActivationRecord.cs +++ b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/Data/GrainActivationRecord.cs @@ -1,13 +1,11 @@ -using System; - namespace Orleans.GrainDirectory.EntityFrameworkCore.Data; -public class GrainActivationRecord +public class GrainActivationRecord { public string ClusterId { get; set; } = default!; public string GrainId { get; set; } = default!; public string SiloAddress { get; set; } = default!; public string ActivationId { get; set; } = default!; public long MembershipVersion { get; set; } - public byte[] ETag { get; set; } = Array.Empty(); + public TETag ETag { get; set; } = default!; } \ No newline at end of file diff --git a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/Data/GrainDirectoryDbContext.cs b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/Data/GrainDirectoryDbContext.cs index dfa77ce2ec..62b0f40cff 100644 --- a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/Data/GrainDirectoryDbContext.cs +++ b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/Data/GrainDirectoryDbContext.cs @@ -2,9 +2,9 @@ namespace Orleans.GrainDirectory.EntityFrameworkCore.Data; -public class GrainDirectoryDbContext : DbContext where TDbContext : DbContext +public class GrainDirectoryDbContext : DbContext where TDbContext : DbContext { - public DbSet Activations { get; set; } = default!; + public DbSet> Activations { get; set; } = default!; public GrainDirectoryDbContext(DbContextOptions options) : base(options) { @@ -12,7 +12,7 @@ public GrainDirectoryDbContext(DbContextOptions options) : base(opti protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(c => + modelBuilder.Entity>(c => { c.HasKey(p => new {p.ClusterId, p.GrainId}); c.Property(p => p.ClusterId).IsRequired(); diff --git a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/EFCoreGrainDirectory.cs b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/EFCoreGrainDirectory.cs index 2610b8b8e0..d71fa96b8f 100644 --- a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/EFCoreGrainDirectory.cs +++ b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/EFCoreGrainDirectory.cs @@ -12,20 +12,23 @@ namespace Orleans.GrainDirectory.EntityFrameworkCore; -public class EFCoreGrainDirectory : IGrainDirectory, ILifecycleParticipant where TDbContext : GrainDirectoryDbContext +public class EFCoreGrainDirectory : IGrainDirectory, ILifecycleParticipant where TDbContext : GrainDirectoryDbContext { private readonly ILogger _logger; private readonly IDbContextFactory _dbContextFactory; + private readonly IEFGrainDirectoryETagConverter _eTagConverter; private readonly string _clusterId; public EFCoreGrainDirectory( ILoggerFactory loggerFactory, IDbContextFactory dbContextFactory, - IOptions clusterOptions) + IOptions clusterOptions, + IEFGrainDirectoryETagConverter eTagConverter) { - this._logger = loggerFactory.CreateLogger>(); + this._logger = loggerFactory.CreateLogger>(); this._dbContextFactory = dbContextFactory; this._clusterId = clusterOptions.Value.ClusterId; + this._eTagConverter = eTagConverter; } public Task Register(GrainAddress address) => this.Register(address, null); @@ -47,14 +50,14 @@ public EFCoreGrainDirectory( c.GrainId == grainIdStr) .ConfigureAwait(false); - var previousRecord = this.FromGrainAddress(previousAddress); + var previousEntry = this.FromGrainAddress(previousAddress); if (record is null) { ctx.Activations.Add(toRegister); await ctx.SaveChangesAsync().ConfigureAwait(false); } - else if (record.ActivationId != previousRecord.ActivationId || record.SiloAddress != previousRecord.SiloAddress) + else if (record.ActivationId != previousEntry.ActivationId || record.SiloAddress != previousEntry.SiloAddress) { return await Lookup(address.GrainId).ConfigureAwait(false); } @@ -65,7 +68,7 @@ public EFCoreGrainDirectory( ctx.Activations.Update(toRegister); await ctx.SaveChangesAsync().ConfigureAwait(false); - return address; + return this.ToGrainAddress(toRegister); } } else @@ -74,11 +77,10 @@ public EFCoreGrainDirectory( await ctx.SaveChangesAsync().ConfigureAwait(false); } } - catch (Exception exc) + catch { - this._logger.LogWarning(exc, "Unable to update Grain Directory"); - WrappedException.CreateAndRethrow(exc); - throw; + // Possible race condition? + return await Lookup(address.GrainId).ConfigureAwait(false); } return await Lookup(address.GrainId).ConfigureAwait(false); @@ -195,19 +197,19 @@ private Task InitializeIfNeeded(CancellationToken ct = default) public void Participate(ISiloLifecycle lifecycle) { - lifecycle.Subscribe(nameof(EFCoreGrainDirectory), ServiceLifecycleStage.RuntimeInitialize, InitializeIfNeeded); + lifecycle.Subscribe(nameof(EFCoreGrainDirectory), ServiceLifecycleStage.RuntimeInitialize, InitializeIfNeeded); } - public GrainAddress ToGrainAddress(GrainActivationRecord record) + public GrainAddress ToGrainAddress(GrainActivationRecord record) { return new GrainAddress {GrainId = GrainId.Parse(record.GrainId), SiloAddress = SiloAddress.FromParsableString(record.SiloAddress), ActivationId = ActivationId.FromParsableString(record.ActivationId), MembershipVersion = new MembershipVersion(record.MembershipVersion)}; } - private GrainActivationRecord FromGrainAddress(GrainAddress address) + private GrainActivationRecord FromGrainAddress(GrainAddress address) { ArgumentNullException.ThrowIfNull(address.SiloAddress); - return new GrainActivationRecord + return new GrainActivationRecord { ClusterId = this._clusterId, GrainId = address.GrainId.ToString(), diff --git a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/EFGrainDirectoryHostingExtension.cs b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/EFGrainDirectoryHostingExtension.cs index 9527563e23..1ea61e3c41 100644 --- a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/EFGrainDirectoryHostingExtension.cs +++ b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/EFGrainDirectoryHostingExtension.cs @@ -10,52 +10,52 @@ namespace Orleans.GrainDirectory; public static class EFGrainDirectoryHostingExtension { - public static ISiloBuilder UseEntityFrameworkCoreGrainDirectoryAsDefault( + public static ISiloBuilder UseEntityFrameworkCoreGrainDirectoryAsDefault( this ISiloBuilder builder, - Action configureDatabase) where TDbContext : GrainDirectoryDbContext + Action configureDatabase) where TDbContext : GrainDirectoryDbContext { - return builder.ConfigureServices(services => services.AddEntityFrameworkCoreGrainDirectory(GrainDirectoryAttribute.DEFAULT_GRAIN_DIRECTORY, configureDatabase)); + return builder.ConfigureServices(services => services.AddEntityFrameworkCoreGrainDirectory(GrainDirectoryAttribute.DEFAULT_GRAIN_DIRECTORY, configureDatabase)); } - public static ISiloBuilder UseEntityFrameworkCoreGrainDirectoryAsDefault( - this ISiloBuilder builder) where TDbContext : GrainDirectoryDbContext + public static ISiloBuilder UseEntityFrameworkCoreGrainDirectoryAsDefault( + this ISiloBuilder builder) where TDbContext : GrainDirectoryDbContext { - return builder.ConfigureServices(services => services.AddEntityFrameworkCoreGrainDirectory(GrainDirectoryAttribute.DEFAULT_GRAIN_DIRECTORY)); + return builder.ConfigureServices(services => services.AddEntityFrameworkCoreGrainDirectory(GrainDirectoryAttribute.DEFAULT_GRAIN_DIRECTORY)); } - public static ISiloBuilder AddEntityFrameworkCoreGrainDirectory( + public static ISiloBuilder AddEntityFrameworkCoreGrainDirectory( this ISiloBuilder builder, string name, - Action configureDatabase) where TDbContext : GrainDirectoryDbContext + Action configureDatabase) where TDbContext : GrainDirectoryDbContext { - return builder.ConfigureServices(services => services.AddEntityFrameworkCoreGrainDirectory(name, configureDatabase)); + return builder.ConfigureServices(services => services.AddEntityFrameworkCoreGrainDirectory(name, configureDatabase)); } - public static ISiloBuilder AddEntityFrameworkCoreGrainDirectory( + public static ISiloBuilder AddEntityFrameworkCoreGrainDirectory( this ISiloBuilder builder, - string name) where TDbContext : GrainDirectoryDbContext + string name) where TDbContext : GrainDirectoryDbContext { - return builder.ConfigureServices(services => services.AddEntityFrameworkCoreGrainDirectory(name)); + return builder.ConfigureServices(services => services.AddEntityFrameworkCoreGrainDirectory(name)); } - internal static IServiceCollection AddEntityFrameworkCoreGrainDirectory( + internal static IServiceCollection AddEntityFrameworkCoreGrainDirectory( this IServiceCollection services, string name, - Action configureDatabase) where TDbContext : GrainDirectoryDbContext + Action configureDatabase) where TDbContext : GrainDirectoryDbContext { services .AddPooledDbContextFactory(configureDatabase) - .AddEntityFrameworkCoreGrainDirectory(name); + .AddEntityFrameworkCoreGrainDirectory(name); return services; } - internal static IServiceCollection AddEntityFrameworkCoreGrainDirectory( + internal static IServiceCollection AddEntityFrameworkCoreGrainDirectory( this IServiceCollection services, - string name) where TDbContext : GrainDirectoryDbContext + string name) where TDbContext : GrainDirectoryDbContext { services - .AddSingletonNamedService(name, (sp, _) => ActivatorUtilities.CreateInstance>(sp)) + .AddSingletonNamedService(name, (sp, _) => ActivatorUtilities.CreateInstance>(sp)) .AddSingletonNamedService>(name, (s, n) => (ILifecycleParticipant)s.GetRequiredServiceByName(n)); return services; diff --git a/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/IEFGrainDirectoryETagConverter.cs b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/IEFGrainDirectoryETagConverter.cs new file mode 100644 index 0000000000..38fc1f49c1 --- /dev/null +++ b/src/EFCore/Orleans.GrainDirectory.EntityFrameworkCore/IEFGrainDirectoryETagConverter.cs @@ -0,0 +1,8 @@ +namespace Orleans.GrainDirectory.EntityFrameworkCore; + +public interface IEFGrainDirectoryETagConverter +{ + TETag ToDbETag(string etag); + + string FromDbETag(TETag etag); +} \ No newline at end of file diff --git a/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/Data/SqlServerGrainStateDbContext.cs b/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/Data/SqlServerGrainStateDbContext.cs index 81fb4643a6..65d732cf55 100644 --- a/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/Data/SqlServerGrainStateDbContext.cs +++ b/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/Data/SqlServerGrainStateDbContext.cs @@ -3,7 +3,7 @@ namespace Orleans.Persistence.EntityFrameworkCore.SqlServer.Data; -public class SqlServerGrainStateDbContext : GrainStateDbContext +public class SqlServerGrainStateDbContext : GrainStateDbContext { public SqlServerGrainStateDbContext(DbContextOptions options) : base(options) { @@ -11,7 +11,7 @@ public SqlServerGrainStateDbContext(DbContextOptions(c => + modelBuilder.Entity>(c => { c.HasKey(p => new {p.ServiceId, p.GrainType, p.StateType, p.GrainId}).IsClustered(false).HasName("PK_GrainState"); c.Property(p => p.ServiceId).HasMaxLength(280).IsRequired(); diff --git a/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/Orleans.Persistence.EntityFrameworkCore.SqlServer.csproj b/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/Orleans.Persistence.EntityFrameworkCore.SqlServer.csproj index 536f222633..6a8e36532b 100644 --- a/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/Orleans.Persistence.EntityFrameworkCore.SqlServer.csproj +++ b/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/Orleans.Persistence.EntityFrameworkCore.SqlServer.csproj @@ -14,6 +14,10 @@ Orleans.Persistence.EntityFrameworkCore.SqlServer + + + + diff --git a/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/SqlHostingExtensions.cs b/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/SqlHostingExtensions.cs index fad85ca631..9487c7a289 100644 --- a/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/SqlHostingExtensions.cs +++ b/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/SqlHostingExtensions.cs @@ -99,8 +99,9 @@ public static IServiceCollection AddEntityFrameworkCoreSqlServerGrainStorage( this IServiceCollection services, string name) { + services.AddSingleton, SqlServerGrainStateETagConverter>(); services.TryAddSingleton(sp => sp.GetServiceByName(ProviderConstants.DEFAULT_STORAGE_PROVIDER_NAME)); - return services.AddSingletonNamedService(name, EFStorageFactory.Create) + return services.AddSingletonNamedService(name, EFStorageFactory.Create) .AddSingletonNamedService(name, (s, n) => (ILifecycleParticipant)s.GetRequiredServiceByName(n)); } } \ No newline at end of file diff --git a/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/SqlServerGrainStateETagConverter.cs b/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/SqlServerGrainStateETagConverter.cs new file mode 100644 index 0000000000..77df42a6dd --- /dev/null +++ b/src/EFCore/Orleans.Persistence.EntityFrameworkCore.SqlServer/SqlServerGrainStateETagConverter.cs @@ -0,0 +1,11 @@ +using System; +using Orleans.Persistence.EntityFrameworkCore; + +namespace Orleans.Persistence; + +internal class SqlServerGrainStateETagConverter : IEFGrainStorageETagConverter +{ + public byte[] ToDbETag(string etag) => BitConverter.GetBytes(ulong.Parse(etag)); + + public string FromDbETag(byte[] etag) => BitConverter.ToUInt64(etag).ToString(); +} \ No newline at end of file diff --git a/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Data/GrainStateDbContext.cs b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Data/GrainStateDbContext.cs index 9dada2072c..8bfddc03eb 100644 --- a/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Data/GrainStateDbContext.cs +++ b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Data/GrainStateDbContext.cs @@ -2,9 +2,9 @@ namespace Orleans.Persistence.EntityFrameworkCore.Data; -public class GrainStateDbContext : DbContext where TDbContext : DbContext +public class GrainStateDbContext : DbContext where TDbContext : DbContext { - public DbSet GrainState { get; set; } = default!; + public DbSet> GrainState { get; set; } = default!; public GrainStateDbContext(DbContextOptions options) : base(options) { @@ -12,7 +12,7 @@ public GrainStateDbContext(DbContextOptions options) : base(options) protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(c => + modelBuilder.Entity>(c => { c.HasKey(p => new {p.ServiceId, p.GrainType, p.StateType, p.GrainId}); c.Property(p => p.ServiceId).HasMaxLength(280).IsRequired(); diff --git a/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Data/GrainStateRecord.cs b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Data/GrainStateRecord.cs index 4994866bd7..f473e9064d 100644 --- a/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Data/GrainStateRecord.cs +++ b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Data/GrainStateRecord.cs @@ -1,13 +1,11 @@ -using System; - namespace Orleans.Persistence.EntityFrameworkCore.Data; -public class GrainStateRecord +public class GrainStateRecord { public string ServiceId { get; set; } = default!; public string GrainType { get; set; } = default!; public string StateType { get; set; } = default!; public string GrainId { get; set; } = default!; public string? Data { get; set; } - public byte[] ETag { get; set; } = Array.Empty(); + public TETag ETag { get; set; } = default!; } \ No newline at end of file diff --git a/src/EFCore/Orleans.Persistence.EntityFrameworkCore/EFGrainStorage.cs b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/EFGrainStorage.cs index 0fb317e2cf..e61491dd97 100644 --- a/src/EFCore/Orleans.Persistence.EntityFrameworkCore/EFGrainStorage.cs +++ b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/EFGrainStorage.cs @@ -13,27 +13,30 @@ namespace Orleans.Persistence.EntityFrameworkCore; -internal class EFGrainStorage : IGrainStorage, ILifecycleParticipant where TDbContext : GrainStateDbContext +internal class EFGrainStorage : IGrainStorage, ILifecycleParticipant where TDbContext : GrainStateDbContext { + private const string ANY_ETAG = "*"; private readonly ILogger _logger; private readonly string _name; private readonly string _serviceId; private readonly IServiceProvider _serviceProvider; private readonly IDbContextFactory _dbContextFactory; + private readonly IEFGrainStorageETagConverter _eTagConverter; public EFGrainStorage( string name, ILoggerFactory loggerFactory, IOptions clusterOptions, + IDbContextFactory dbContextFactory, + IEFGrainStorageETagConverter eTagConverter, IServiceProvider serviceProvider) { this._serviceProvider = serviceProvider; this._name = name; this._serviceId = clusterOptions.Value.ServiceId; - this._logger = loggerFactory.CreateLogger>(); - - var dbContextFactory = this._serviceProvider.GetService>(); - this._dbContextFactory = dbContextFactory ?? throw new OrleansConfigurationException("There are no GrainStateDbContext registered"); + this._logger = loggerFactory.CreateLogger>(); + this._dbContextFactory = dbContextFactory; + this._eTagConverter = eTagConverter; } public async Task ReadStateAsync(string stateName, GrainId grainId, IGrainState grainState) @@ -63,7 +66,7 @@ public async Task ReadStateAsync(string stateName, GrainId grainId, IGrainSta grainState.State = !string.IsNullOrEmpty(record.Data) ? JsonSerializer.Deserialize(record.Data)! : Activator.CreateInstance(); grainState.RecordExists = true; - grainState.ETag = BitConverter.ToUInt64(record.ETag).ToString(); + grainState.ETag = this._eTagConverter.FromDbETag(record.ETag); } catch (Exception ex) { @@ -82,7 +85,7 @@ public async Task WriteStateAsync(string stateName, GrainId grainId, IGrainSt var ctx = this._dbContextFactory.CreateDbContext(); - var record = new GrainStateRecord + var record = new GrainStateRecord { ServiceId = this._serviceId, GrainType = grainType, @@ -91,26 +94,43 @@ public async Task WriteStateAsync(string stateName, GrainId grainId, IGrainSt Data = JsonSerializer.Serialize(grainState.State), }; - if (grainState.RecordExists) + if (string.IsNullOrWhiteSpace(grainState.ETag)) { - record.ETag = BitConverter.GetBytes(ulong.Parse(grainState.ETag)); - ctx.GrainState.Update(record); + ctx.GrainState.Add(record); + } + else if (grainState.ETag == ANY_ETAG) + { + var etag = await ctx.GrainState.AsNoTracking().Where(r => + r.ServiceId == this._serviceId && + r.GrainType == grainType && + r.StateType == stateName && + r.GrainId == id) + .Select(r => r.ETag) + .FirstOrDefaultAsync(); + + if (etag is not null) + { + record.ETag = etag; + } + + ctx.Update(record); } else { - ctx.GrainState.Add(record); + record.ETag = this._eTagConverter.ToDbETag(grainState.ETag); + ctx.GrainState.Update(record); } try { await ctx.SaveChangesAsync().ConfigureAwait(false); - grainState.ETag = BitConverter.ToUInt64(record.ETag).ToString(); + grainState.ETag = this._eTagConverter.FromDbETag(record.ETag); grainState.RecordExists = true; } catch (DbUpdateConcurrencyException ex) { var found = await ctx.GrainState.AsNoTracking().SingleOrDefaultAsync(r => r.StateType == grainType && r.GrainId == id).ConfigureAwait(false); - var foundETag = found is not null ? found.ETag.ToString() : ""; + var foundETag = found is not null ? found.ETag?.ToString() : ""; var isEx = new InconsistentStateException( $"Inconsistent state. Operation: Write | State: {stateName} | Grain: {grainType} | GrainId: {id}", @@ -118,7 +138,7 @@ public async Task WriteStateAsync(string stateName, GrainId grainId, IGrainSt this._logger.LogError(isEx, "Inconsistent state. Operation: {Operation} | State: {State} | Grain: {GrainType} | GrainId: {GrainId} | Expected ETag: {ExpectedETag} | Actual ETag: {ActualETag} ", - "Write", stateName, grainType, id, grainState.ETag, found?.ETag); + "Write", stateName, grainType, id, grainState.ETag, foundETag); throw isEx; } @@ -170,7 +190,7 @@ public async Task ClearStateAsync(string stateName, GrainId grainId, IGrainSt var found = await ctx.GrainState.AsNoTracking() .SingleOrDefaultAsync(r => r.StateType == stateName && r.GrainId == id).ConfigureAwait(false); - var foundETag = found is not null ? found.ETag.ToString() : ""; + var foundETag = found is not null ? found.ETag?.ToString() : ""; var isEx = new InconsistentStateException( $"Inconsistent state. Operation: Clear | State: {stateName} | GrainType: {grainType} | GrainId: {id}", @@ -178,7 +198,7 @@ public async Task ClearStateAsync(string stateName, GrainId grainId, IGrainSt this._logger.LogError(isEx, "Inconsistent state. Operation: {Operation} | State: {State} | GrainType: {GrainType} | GrainId: {GrainId} | Expected ETag: {ExpectedETag} | Actual ETag: {ActualETag} ", - "Clear", stateName, grainType, id, grainState.ETag, found?.ETag); + "Clear", stateName, grainType, id, grainState.ETag, foundETag); throw isEx; } @@ -199,8 +219,8 @@ public void Participate(ISiloLifecycle lifecycle) => internal static class EFStorageFactory { - public static IGrainStorage Create(IServiceProvider services, string name) where TDbContext : GrainStateDbContext + public static IGrainStorage Create(IServiceProvider services, string name) where TDbContext : GrainStateDbContext { - return ActivatorUtilities.CreateInstance>(services, name); + return ActivatorUtilities.CreateInstance>(services, name); } } \ No newline at end of file diff --git a/src/EFCore/Orleans.Persistence.EntityFrameworkCore/EFGrainStorageHostingExtensions.cs b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/EFGrainStorageHostingExtensions.cs index d09ce32e8b..9dfa70e757 100644 --- a/src/EFCore/Orleans.Persistence.EntityFrameworkCore/EFGrainStorageHostingExtensions.cs +++ b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/EFGrainStorageHostingExtensions.cs @@ -18,11 +18,11 @@ public static class EFGrainStorageHostingExtensions /// /// The silo builder. /// The storage provider name. - public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefault( + public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefault( this ISiloBuilder builder, - string name) where TDbContext : GrainStateDbContext + string name) where TDbContext : GrainStateDbContext { - builder.Services.AddEntityFrameworkCoreGrainStorage(name); + builder.Services.AddEntityFrameworkCoreGrainStorage(name); return builder; } @@ -32,12 +32,12 @@ public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefaultThe silo builder. /// The storage provider name. /// The delegate used to configure the provider. - public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefault( + public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefault( this ISiloBuilder builder, string name, - Action configureDatabase) where TDbContext : GrainStateDbContext + Action configureDatabase) where TDbContext : GrainStateDbContext { - builder.Services.AddEntityFrameworkCoreGrainStorage(name, configureDatabase); + builder.Services.AddEntityFrameworkCoreGrainStorage(name, configureDatabase); return builder; } @@ -45,10 +45,10 @@ public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefault /// The silo builder. - public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefault( - this ISiloBuilder builder) where TDbContext : GrainStateDbContext + public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefault( + this ISiloBuilder builder) where TDbContext : GrainStateDbContext { - builder.Services.AddEntityFrameworkCoreGrainStorageAsDefault(); + builder.Services.AddEntityFrameworkCoreGrainStorageAsDefault(); return builder; } @@ -56,10 +56,10 @@ public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefault /// The silo builder. - public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefault( - this ISiloBuilder builder, Action configureDatabase) where TDbContext : GrainStateDbContext + public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefault( + this ISiloBuilder builder, Action configureDatabase) where TDbContext : GrainStateDbContext { - builder.Services.AddEntityFrameworkCoreGrainStorageAsDefault(configureDatabase); + builder.Services.AddEntityFrameworkCoreGrainStorageAsDefault(configureDatabase); return builder; } @@ -67,10 +67,10 @@ public static ISiloBuilder AddEntityFrameworkCoreGrainStorageAsDefault /// The service collection. - public static IServiceCollection AddEntityFrameworkCoreGrainStorageAsDefault( - this IServiceCollection services) where TDbContext : GrainStateDbContext + public static IServiceCollection AddEntityFrameworkCoreGrainStorageAsDefault( + this IServiceCollection services) where TDbContext : GrainStateDbContext { - return services.AddEntityFrameworkCoreGrainStorage(ProviderConstants.DEFAULT_STORAGE_PROVIDER_NAME); + return services.AddEntityFrameworkCoreGrainStorage(ProviderConstants.DEFAULT_STORAGE_PROVIDER_NAME); } /// @@ -78,12 +78,12 @@ public static IServiceCollection AddEntityFrameworkCoreGrainStorageAsDefault /// The service collection. /// The delegate used to configure the provider. - public static IServiceCollection AddEntityFrameworkCoreGrainStorageAsDefault( + public static IServiceCollection AddEntityFrameworkCoreGrainStorageAsDefault( this IServiceCollection services, - Action configureDatabase) where TDbContext : GrainStateDbContext + Action configureDatabase) where TDbContext : GrainStateDbContext { return services - .AddEntityFrameworkCoreGrainStorage(ProviderConstants.DEFAULT_STORAGE_PROVIDER_NAME, configureDatabase); + .AddEntityFrameworkCoreGrainStorage(ProviderConstants.DEFAULT_STORAGE_PROVIDER_NAME, configureDatabase); } /// @@ -92,13 +92,13 @@ public static IServiceCollection AddEntityFrameworkCoreGrainStorageAsDefaultThe service collection. /// The storage provider name. /// The delegate used to configure the provider. - public static IServiceCollection AddEntityFrameworkCoreGrainStorage( + public static IServiceCollection AddEntityFrameworkCoreGrainStorage( this IServiceCollection services, string name, - Action configureDatabase) where TDbContext : GrainStateDbContext + Action configureDatabase) where TDbContext : GrainStateDbContext { services.AddPooledDbContextFactory(configureDatabase); - return services.AddEntityFrameworkCoreGrainStorage(name); + return services.AddEntityFrameworkCoreGrainStorage(name); } /// @@ -106,12 +106,12 @@ public static IServiceCollection AddEntityFrameworkCoreGrainStorage( /// /// The service collection. /// The storage provider name. - public static IServiceCollection AddEntityFrameworkCoreGrainStorage( + public static IServiceCollection AddEntityFrameworkCoreGrainStorage( this IServiceCollection services, - string name) where TDbContext : GrainStateDbContext + string name) where TDbContext : GrainStateDbContext { services.TryAddSingleton(sp => sp.GetServiceByName(ProviderConstants.DEFAULT_STORAGE_PROVIDER_NAME)); - return services.AddSingletonNamedService(name, EFStorageFactory.Create) + return services.AddSingletonNamedService(name, EFStorageFactory.Create) .AddSingletonNamedService(name, (s, n) => (ILifecycleParticipant)s.GetRequiredServiceByName(n)); } } \ No newline at end of file diff --git a/src/EFCore/Orleans.Persistence.EntityFrameworkCore/IEFGrainStorageETagConverter.cs b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/IEFGrainStorageETagConverter.cs new file mode 100644 index 0000000000..04b8211737 --- /dev/null +++ b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/IEFGrainStorageETagConverter.cs @@ -0,0 +1,8 @@ +namespace Orleans.Persistence.EntityFrameworkCore; + +public interface IEFGrainStorageETagConverter +{ + TETag ToDbETag(string etag); + + string FromDbETag(TETag etag); +} \ No newline at end of file diff --git a/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Orleans.Persistence.EntityFrameworkCore.csproj b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Orleans.Persistence.EntityFrameworkCore.csproj index 4a6146db7c..d835e74b8d 100644 --- a/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Orleans.Persistence.EntityFrameworkCore.csproj +++ b/src/EFCore/Orleans.Persistence.EntityFrameworkCore/Orleans.Persistence.EntityFrameworkCore.csproj @@ -11,6 +11,7 @@ + diff --git a/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/Data/SqlServerReminderDbContext.cs b/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/Data/SqlServerReminderDbContext.cs index e42180e88a..652ad08c50 100644 --- a/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/Data/SqlServerReminderDbContext.cs +++ b/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/Data/SqlServerReminderDbContext.cs @@ -3,7 +3,7 @@ namespace Orleans.Reminders.EntityFrameworkCore.SqlServer.Data; -public class SqlServerReminderDbContext : ReminderDbContext +public class SqlServerReminderDbContext : ReminderDbContext { public SqlServerReminderDbContext(DbContextOptions options) : base(options) { @@ -11,7 +11,7 @@ public SqlServerReminderDbContext(DbContextOptions o protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(c => + modelBuilder.Entity>(c => { c.HasKey(p => new {p.ServiceId, p.GrainId, p.Name}).HasName("PK_Reminders"); c.Property(p => p.ServiceId).IsRequired(); diff --git a/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/Orleans.Reminders.EntityFrameworkCore.SqlServer.csproj b/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/Orleans.Reminders.EntityFrameworkCore.SqlServer.csproj index ac28c44030..7df44f0227 100644 --- a/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/Orleans.Reminders.EntityFrameworkCore.SqlServer.csproj +++ b/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/Orleans.Reminders.EntityFrameworkCore.SqlServer.csproj @@ -14,6 +14,10 @@ Orleans.Reminders.EntityFrameworkCore.SqlServer + + + + diff --git a/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/SqlHostingExtensions.cs b/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/SqlHostingExtensions.cs index 7158408f28..a3a574809f 100644 --- a/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/SqlHostingExtensions.cs +++ b/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/SqlHostingExtensions.cs @@ -77,7 +77,8 @@ public static IServiceCollection UseEntityFrameworkCoreSqlServerReminderService( public static IServiceCollection UseEntityFrameworkCoreSqlServerReminderService(this IServiceCollection services) { services.AddReminders(); - services.AddSingleton>(); + services.AddSingleton, SqlServerReminderETagConverter>(); + services.AddSingleton>(); return services; } } \ No newline at end of file diff --git a/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/SqlServerReminderETagConverter.cs b/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/SqlServerReminderETagConverter.cs new file mode 100644 index 0000000000..4d070a8787 --- /dev/null +++ b/src/EFCore/Orleans.Reminders.EntityFrameworkCore.SqlServer/SqlServerReminderETagConverter.cs @@ -0,0 +1,11 @@ +using System; +using Orleans.Reminders.EntityFrameworkCore; + +namespace Orleans.Reminders; + +public class SqlServerReminderETagConverter : IEFReminderETagConverter +{ + public byte[] ToDbETag(string etag) => BitConverter.GetBytes(ulong.Parse(etag)); + + public string FromDbETag(byte[] etag) => BitConverter.ToUInt64(etag).ToString(); +} \ No newline at end of file diff --git a/src/EFCore/Orleans.Reminders.EntityFrameworkCore/Data/ReminderDbContext.cs b/src/EFCore/Orleans.Reminders.EntityFrameworkCore/Data/ReminderDbContext.cs index 5db4e6775f..0086d699b1 100644 --- a/src/EFCore/Orleans.Reminders.EntityFrameworkCore/Data/ReminderDbContext.cs +++ b/src/EFCore/Orleans.Reminders.EntityFrameworkCore/Data/ReminderDbContext.cs @@ -2,9 +2,9 @@ namespace Orleans.Reminders.EntityFrameworkCore.Data; -public class ReminderDbContext : DbContext where TDbContext : DbContext +public class ReminderDbContext : DbContext where TDbContext : DbContext { - public DbSet Reminders { get; set; } = default!; + public DbSet> Reminders { get; set; } = default!; public ReminderDbContext(DbContextOptions options) : base(options) { @@ -12,7 +12,7 @@ public ReminderDbContext(DbContextOptions options) : base(options) protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(c => + modelBuilder.Entity>(c => { c.HasKey(p => new {p.ServiceId, p.GrainId, p.Name}); c.Property(p => p.ServiceId).IsRequired(); diff --git a/src/EFCore/Orleans.Reminders.EntityFrameworkCore/Data/ReminderRecord.cs b/src/EFCore/Orleans.Reminders.EntityFrameworkCore/Data/ReminderRecord.cs index 083b926490..20b67f7c54 100644 --- a/src/EFCore/Orleans.Reminders.EntityFrameworkCore/Data/ReminderRecord.cs +++ b/src/EFCore/Orleans.Reminders.EntityFrameworkCore/Data/ReminderRecord.cs @@ -2,7 +2,7 @@ namespace Orleans.Reminders.EntityFrameworkCore.Data; -public class ReminderRecord +public class ReminderRecord { public string ServiceId { get; set; } = default!; public string GrainId { get; set; } = default!; @@ -10,5 +10,5 @@ public class ReminderRecord public DateTimeOffset StartAt { get; set; } public TimeSpan Period { get; set; } public uint GrainHash { get; set; } - public byte[] ETag { get; set; } = Array.Empty(); + public TETag ETag { get; set; } = default!; } \ No newline at end of file diff --git a/src/EFCore/Orleans.Reminders.EntityFrameworkCore/EFReminderHostingExtension.cs b/src/EFCore/Orleans.Reminders.EntityFrameworkCore/EFReminderHostingExtension.cs index 8a6da57385..bd586c2329 100644 --- a/src/EFCore/Orleans.Reminders.EntityFrameworkCore/EFReminderHostingExtension.cs +++ b/src/EFCore/Orleans.Reminders.EntityFrameworkCore/EFReminderHostingExtension.cs @@ -18,9 +18,9 @@ public static class EFReminderHostingExtension /// /// The provided , for chaining. /// - public static ISiloBuilder UseEntityFrameworkCoreReminderService(this ISiloBuilder builder) where TDbContext : ReminderDbContext + public static ISiloBuilder UseEntityFrameworkCoreReminderService(this ISiloBuilder builder) where TDbContext : ReminderDbContext { - builder.Services.UseEntityFrameworkCoreReminderService(); + builder.Services.UseEntityFrameworkCoreReminderService(); return builder; } @@ -36,9 +36,9 @@ public static ISiloBuilder UseEntityFrameworkCoreReminderService(thi /// /// The provided , for chaining. /// - public static ISiloBuilder UseEntityFrameworkCoreReminderService(this ISiloBuilder builder, Action configureDatabase) where TDbContext : ReminderDbContext + public static ISiloBuilder UseEntityFrameworkCoreReminderService(this ISiloBuilder builder, Action configureDatabase) where TDbContext : ReminderDbContext { - builder.Services.UseEntityFrameworkCoreReminderService(configureDatabase); + builder.Services.UseEntityFrameworkCoreReminderService(configureDatabase); return builder; } @@ -54,11 +54,11 @@ public static ISiloBuilder UseEntityFrameworkCoreReminderService(thi /// /// The provided , for chaining. /// - public static IServiceCollection UseEntityFrameworkCoreReminderService(this IServiceCollection services, Action configureDatabase) where TDbContext : ReminderDbContext + public static IServiceCollection UseEntityFrameworkCoreReminderService(this IServiceCollection services, Action configureDatabase) where TDbContext : ReminderDbContext { return services .AddPooledDbContextFactory(configureDatabase) - .UseEntityFrameworkCoreReminderService(); + .UseEntityFrameworkCoreReminderService(); } /// @@ -70,10 +70,10 @@ public static IServiceCollection UseEntityFrameworkCoreReminderService /// The provided , for chaining. /// - public static IServiceCollection UseEntityFrameworkCoreReminderService(this IServiceCollection services) where TDbContext : ReminderDbContext + public static IServiceCollection UseEntityFrameworkCoreReminderService(this IServiceCollection services) where TDbContext : ReminderDbContext { services.AddReminders(); - services.AddSingleton>(); + services.AddSingleton>(); return services; } } \ No newline at end of file diff --git a/src/EFCore/Orleans.Reminders.EntityFrameworkCore/EFReminderTable.cs b/src/EFCore/Orleans.Reminders.EntityFrameworkCore/EFReminderTable.cs index ac48dc490d..fc654047d6 100644 --- a/src/EFCore/Orleans.Reminders.EntityFrameworkCore/EFReminderTable.cs +++ b/src/EFCore/Orleans.Reminders.EntityFrameworkCore/EFReminderTable.cs @@ -10,20 +10,23 @@ namespace Orleans.Reminders.EntityFrameworkCore; -public class EFReminderTable : IReminderTable where TDbContext : ReminderDbContext +public class EFReminderTable : IReminderTable where TDbContext : ReminderDbContext { private readonly ILogger _logger; private readonly string _serviceId; private readonly IDbContextFactory _dbContextFactory; + private readonly IEFReminderETagConverter _eTagConverter; public EFReminderTable( ILoggerFactory loggerFactory, IOptions clusterOptions, - IDbContextFactory dbContextFactory) + IDbContextFactory dbContextFactory, + IEFReminderETagConverter eTagConverter) { - this._logger = loggerFactory.CreateLogger>(); + this._logger = loggerFactory.CreateLogger>(); this._serviceId = clusterOptions.Value.ServiceId; this._dbContextFactory = dbContextFactory; + this._eTagConverter = eTagConverter; } public Task Init() @@ -119,11 +122,34 @@ public async Task UpsertRow(ReminderEntry entry) var ctx = this._dbContextFactory.CreateDbContext(); - ctx.Reminders.Update(record); + if (string.IsNullOrWhiteSpace(entry.ETag)) + { + var foundRecord = await ctx.Reminders + .AsNoTracking() + .SingleOrDefaultAsync(r => + r.ServiceId == this._serviceId && + r.Name == entry.ReminderName && + r.GrainId == entry.GrainId.ToString()) + .ConfigureAwait(false); + + if (foundRecord is not null) + { + record.ETag = foundRecord.ETag; + ctx.Reminders.Update(record); + } + else + { + ctx.Reminders.Add(record); + } + } + else + { + ctx.Reminders.Update(record); + } await ctx.SaveChangesAsync().ConfigureAwait(false); - return BitConverter.ToUInt64(record.ETag).ToString(); + return this._eTagConverter.FromDbETag(record.ETag); } catch (Exception exc) { @@ -147,7 +173,7 @@ public async Task RemoveRow(GrainId grainId, string reminderName, string e if (record is null) return true; - record.ETag = BitConverter.GetBytes(ulong.Parse(eTag)); + record.ETag = this._eTagConverter.ToDbETag(eTag); ctx.Reminders.Remove(record); @@ -195,21 +221,27 @@ public async Task TestOnlyClearTable() } } - private ReminderRecord ConvertToRecord(ReminderEntry entry) + private ReminderRecord ConvertToRecord(ReminderEntry entry) { - return new ReminderRecord + var record = new ReminderRecord { ServiceId = this._serviceId, GrainHash = entry.GrainId.GetUniformHashCode(), GrainId = entry.GrainId.ToString(), Name = entry.ReminderName, Period = entry.Period, - StartAt = entry.StartAt, - ETag = BitConverter.GetBytes(ulong.Parse(entry.ETag)) + StartAt = entry.StartAt }; + + if (!string.IsNullOrWhiteSpace(entry.ETag)) + { + record.ETag = this._eTagConverter.ToDbETag(entry.ETag); + } + + return record; } - private ReminderEntry ConvertToEntity(ReminderRecord record) + private ReminderEntry ConvertToEntity(ReminderRecord record) { return new ReminderEntry { @@ -217,7 +249,7 @@ private ReminderEntry ConvertToEntity(ReminderRecord record) ReminderName = record.Name, Period = record.Period, StartAt = record.StartAt.UtcDateTime, - ETag = BitConverter.ToUInt64(record.ETag).ToString() + ETag = this._eTagConverter.FromDbETag(record.ETag) }; } } \ No newline at end of file diff --git a/src/EFCore/Orleans.Reminders.EntityFrameworkCore/IEFReminderETagConverter.cs b/src/EFCore/Orleans.Reminders.EntityFrameworkCore/IEFReminderETagConverter.cs new file mode 100644 index 0000000000..182992cbbb --- /dev/null +++ b/src/EFCore/Orleans.Reminders.EntityFrameworkCore/IEFReminderETagConverter.cs @@ -0,0 +1,8 @@ +namespace Orleans.Reminders.EntityFrameworkCore; + +public interface IEFReminderETagConverter +{ + TETag ToDbETag(string etag); + + string FromDbETag(TETag etag); +} \ No newline at end of file diff --git a/src/Orleans.Core/Properties/AssemblyInfo.cs b/src/Orleans.Core/Properties/AssemblyInfo.cs index 2e63df2f00..91d754f96e 100644 --- a/src/Orleans.Core/Properties/AssemblyInfo.cs +++ b/src/Orleans.Core/Properties/AssemblyInfo.cs @@ -16,6 +16,7 @@ [assembly: InternalsVisibleTo("Tester")] [assembly: InternalsVisibleTo("Tester.AzureUtils")] [assembly: InternalsVisibleTo("Tester.Cosmos")] +[assembly: InternalsVisibleTo("Tester.EFCore")] [assembly: InternalsVisibleTo("Tester.AdoNet")] [assembly: InternalsVisibleTo("Tester.Redis")] [assembly: InternalsVisibleTo("Tester.ZooKeeperUtils")] diff --git a/test/Extensions/Tester.EFCore/CollectionFixture.cs b/test/Extensions/Tester.EFCore/CollectionFixture.cs index 0434121f3d..7f00e5a1f4 100644 --- a/test/Extensions/Tester.EFCore/CollectionFixture.cs +++ b/test/Extensions/Tester.EFCore/CollectionFixture.cs @@ -1,5 +1,4 @@ using TestExtensions; -using Xunit; namespace Tester.EFCore; diff --git a/test/Extensions/Tester.EFCore/EFCoreFixture.cs b/test/Extensions/Tester.EFCore/EFCoreFixture.cs new file mode 100644 index 0000000000..ad7082e698 --- /dev/null +++ b/test/Extensions/Tester.EFCore/EFCoreFixture.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Clustering.EntityFrameworkCore.SqlServer; +using Orleans.Clustering.EntityFrameworkCore.SqlServer.Data; +using Orleans.Persistence; +using Orleans.Persistence.EntityFrameworkCore.SqlServer.Data; +using Orleans.Reminders; +using Orleans.Reminders.EntityFrameworkCore.SqlServer.Data; +using Orleans.TestingHost; +using TestExtensions; + +namespace Tester.EFCore; + +public class EFCoreFixture : BaseTestClusterFixture where TDbContext : DbContext +{ + protected override void CheckPreconditionsOrThrow() => EFCoreTestUtils.CheckSqlServer(); + + protected override void ConfigureTestCluster(TestClusterBuilder builder) + { + builder.Options.InitialSilosCount = 4; + builder.AddSiloBuilderConfigurator(); + } + + private class SiloConfigurator : ISiloConfigurator + { + public void Configure(ISiloBuilder hostBuilder) + { + var ctxTypeName = typeof(TDbContext).Name; + var cs = $"Server=localhost,1433;Database=OrleansTests.{ctxTypeName};User Id=sa;Password=yourStrong(!)Password;TrustServerCertificate=True"; + + hostBuilder.Services.AddPooledDbContextFactory(optionsBuilder => + { + optionsBuilder.UseSqlServer(cs, opt => + { + opt.MigrationsHistoryTable("__EFMigrationsHistory"); + opt.MigrationsAssembly(typeof(TDbContext).Assembly.FullName); + }); + }); + + switch (ctxTypeName) + { + case nameof(SqlServerClusterDbContext): + hostBuilder.Services.AddSingleton, SqlServerClusterETagConverter>(); + break; + case nameof(SqlServerGrainStateDbContext): + hostBuilder + .AddEntityFrameworkCoreSqlServerGrainStorage("GrainStorageForTest"); + break; + case nameof(SqlServerReminderDbContext): + hostBuilder + .UseEntityFrameworkCoreSqlServerReminderService(); + break; + } + + hostBuilder + .AddMemoryGrainStorage("MemoryStore"); + + var sp = new ServiceCollection() + .AddPooledDbContextFactory(optionsBuilder => + { + optionsBuilder.UseSqlServer(cs, opt => + { + opt.MigrationsHistoryTable("__EFMigrationsHistory"); + opt.MigrationsAssembly(typeof(TDbContext).Assembly.FullName); + }); + }).BuildServiceProvider(); + + var factory = sp.GetRequiredService>(); + + var ctx = factory.CreateDbContext(); + if (ctx.Database.GetPendingMigrations().Any()) + { + try + { + ctx.Database.Migrate(); + } + catch { } + } + } + } +} \ No newline at end of file diff --git a/test/Extensions/Tester.EFCore/EFCoreMySqlMembershipTableTests.cs b/test/Extensions/Tester.EFCore/EFCoreMySqlMembershipTableTests.cs new file mode 100644 index 0000000000..4fc3af4760 --- /dev/null +++ b/test/Extensions/Tester.EFCore/EFCoreMySqlMembershipTableTests.cs @@ -0,0 +1,132 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Orleans.Clustering; +using Orleans.Clustering.EntityFrameworkCore.MySql.Data; +using Orleans.Messaging; +using TestExtensions; +using UnitTests; +using UnitTests.MembershipTests; + +namespace Tester.EFCore; + +[TestCategory("Membership"), TestCategory("EFCore"), TestCategory("EFCore-MySql")] +public class EFCoreMySqlMembershipTableTests : MembershipTableTestsBase +{ + public EFCoreMySqlMembershipTableTests(ConnectionStringFixture fixture, TestEnvironmentFixture environment) : base(fixture, environment, CreateFilters()) + { + EFCoreTestUtils.CheckMySql(); + } + + private static LoggerFilterOptions CreateFilters() + { + var filters = new LoggerFilterOptions(); + filters.AddFilter(nameof(EFCoreMySqlMembershipTableTests), LogLevel.Trace); + return filters; + } + + protected override IMembershipTable CreateMembershipTable(ILogger logger) + { + return new EFMembershipTable(this.loggerFactory, this._clusterOptions, this.GetFactory(), new MySqlClusterETagConverter()); + } + + protected override Task GetConnectionString() + { + var cs = "Server=localhost;Database=OrleansTests.Membership;Uid=root;Pwd=yourStrong(!)Password;"; + return Task.FromResult(cs); + } + + protected override IGatewayListProvider CreateGatewayListProvider(ILogger logger) + { + return new EFGatewayListProvider(this.loggerFactory, this._clusterOptions, this._gatewayOptions, this.GetFactory()); + } + + private IDbContextFactory GetFactory() + { + var sp = new ServiceCollection() + .AddPooledDbContextFactory(optionsBuilder => + { + optionsBuilder + .LogTo(Console.WriteLine, new[] {DbLoggerCategory.Database.Command.Name}, LogLevel.Information) + .EnableSensitiveDataLogging(); + optionsBuilder.UseMySQL(this.connectionString, opt => + { + opt.MigrationsHistoryTable("__EFMigrationsHistory"); + opt.MigrationsAssembly(typeof(MySqlClusterDbContext).Assembly.FullName); + }); + }).BuildServiceProvider(); + + var factory = sp.GetRequiredService>(); + + var ctx = factory.CreateDbContext(); + if (ctx.Database.GetPendingMigrations().Any()) + { + try + { + ctx.Database.Migrate(); + } + catch { } + } + + return factory; + } + + [SkippableFact] + public void MembershipTable_MySql_Init() + { + } + + [SkippableFact] + public async Task MembershipTable_MySql_GetGateways() + { + await MembershipTable_GetGateways(); + } + + [SkippableFact] + public async Task MembershipTable_MySql_ReadAll_EmptyTable() + { + await MembershipTable_ReadAll_EmptyTable(); + } + + [SkippableFact] + public async Task MembershipTable_MySql_InsertRow() + { + await MembershipTable_InsertRow(); + } + + [SkippableFact] + public async Task MembershipTable_MySql_ReadRow_Insert_Read() + { + await MembershipTable_ReadRow_Insert_Read(); + } + + [SkippableFact] + public async Task MembershipTable_MySql_ReadAll_Insert_ReadAll() + { + await MembershipTable_ReadAll_Insert_ReadAll(); + } + + [SkippableFact] + public async Task MembershipTable_MySql_UpdateRow() + { + await MembershipTable_UpdateRow(); + } + + [SkippableFact] + public async Task MembershipTable_MySql_UpdateRowInParallel() + { + await MembershipTable_UpdateRowInParallel(); + } + + [SkippableFact] + public async Task MembershipTable_MySql_UpdateIAmAlive() + { + await MembershipTable_UpdateIAmAlive(); + } + + [SkippableFact] + public async Task MembershipTableMySqlSql_CleanupDefunctSiloEntries() + { + await MembershipTable_CleanupDefunctSiloEntries(); + } +} \ No newline at end of file diff --git a/test/Extensions/Tester.EFCore/EFCoreSqlServerGrainDirectoryTests.cs b/test/Extensions/Tester.EFCore/EFCoreSqlServerGrainDirectoryTests.cs new file mode 100644 index 0000000000..e5d01b2b41 --- /dev/null +++ b/test/Extensions/Tester.EFCore/EFCoreSqlServerGrainDirectoryTests.cs @@ -0,0 +1,111 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.GrainDirectory; +using Orleans.GrainDirectory.EntityFrameworkCore.SqlServer.Data; +using Orleans.Runtime; +using Orleans.TestingHost.Utils; +using Tester.Directories; +using Xunit.Abstractions; + +namespace Tester.EFCore; + +[TestCategory("Reminders"), TestCategory("EFCore"), TestCategory("EFCore-SqlServer")] +public class EFCoreSqlServerGrainDirectoryTests : GrainDirectoryTests> +{ + public EFCoreSqlServerGrainDirectoryTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { + } + + protected override EFCoreGrainDirectory GetGrainDirectory() + { + EFCoreTestUtils.CheckSqlServer(); + + var clusterOptions = new ClusterOptions + { + ClusterId = Guid.NewGuid().ToString("N"), + ServiceId = Guid.NewGuid().ToString("N"), + }; + + var loggerFactory = TestingUtils.CreateDefaultLoggerFactory("EFCoreSqlServerGrainDirectoryTests.log"); + + var cs = "Server=localhost,1433;Database=OrleansTests.GrainDirectory;User Id=sa;Password=yourStrong(!)Password;TrustServerCertificate=True"; + var sp = new ServiceCollection() + .AddPooledDbContextFactory(optionsBuilder => + { + optionsBuilder.UseSqlServer(cs, opt => + { + opt.MigrationsHistoryTable("__EFMigrationsHistory"); + opt.MigrationsAssembly(typeof(SqlServerGrainDirectoryDbContext).Assembly.FullName); + }); + }).BuildServiceProvider(); + + var factory = sp.GetRequiredService>(); + + var ctx = factory.CreateDbContext(); + if (ctx.Database.GetPendingMigrations().Any()) + { + try + { + ctx.Database.Migrate(); + } + catch { } + } + + var directory = new EFCoreGrainDirectory(loggerFactory, factory, Options.Create(clusterOptions), new SqlServerGrainDirectoryETagConverter()); + + return directory; + } + + [SkippableFact] + public async Task UnregisterMany() + { + const int N = 25; + const int R = 4; + + // Create and insert N entries + var addresses = new List(); + for (var i = 0; i < N; i++) + { + var addr = new GrainAddress + { + ActivationId = ActivationId.NewId(), + GrainId = GrainId.Parse("user/someraondomuser_" + Guid.NewGuid().ToString("N")), + SiloAddress = SiloAddress.FromParsableString("10.0.23.12:1000@5678"), + MembershipVersion = new MembershipVersion(51) + }; + addresses.Add(addr); + await this.grainDirectory.Register(addr, previousAddress: null); + } + + // Modify the Rth entry locally, to simulate another activation tentative by another silo + var ra = addresses[R]; + var oldActivation = ra.ActivationId; + addresses[R] = new() + { + GrainId = ra.GrainId, + SiloAddress = ra.SiloAddress, + MembershipVersion = ra.MembershipVersion, + ActivationId = ActivationId.NewId() + }; + + // Batch unregister + await this.grainDirectory.UnregisterMany(addresses); + + // Now we should only find the old Rth entry + for (int i = 0; i < N; i++) + { + if (i == R) + { + var addr = await this.grainDirectory.Lookup(addresses[i].GrainId); + Assert.NotNull(addr); + Assert.Equal(oldActivation, addr.ActivationId); + } + else + { + Assert.Null(await this.grainDirectory.Lookup(addresses[i].GrainId)); + } + } + } +} \ No newline at end of file diff --git a/test/Extensions/Tester.EFCore/EFCoreSqlServerMembershipTableTests.cs b/test/Extensions/Tester.EFCore/EFCoreSqlServerMembershipTableTests.cs new file mode 100644 index 0000000000..e8e00165ce --- /dev/null +++ b/test/Extensions/Tester.EFCore/EFCoreSqlServerMembershipTableTests.cs @@ -0,0 +1,131 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Orleans.Clustering.EntityFrameworkCore.SqlServer; +using Orleans.Messaging; +using Orleans.Clustering.EntityFrameworkCore.SqlServer.Data; +using TestExtensions; +using UnitTests; +using UnitTests.MembershipTests; + +namespace Tester.EFCore; + +[TestCategory("Membership"), TestCategory("EFCore"), TestCategory("EFCore-SqlServer")] +public class EFCoreSqlServerMembershipTableTests : MembershipTableTestsBase +{ + private readonly IEFClusterETagConverter _converter = new SqlServerClusterETagConverter(); + public EFCoreSqlServerMembershipTableTests(ConnectionStringFixture fixture, TestEnvironmentFixture environment) : base(fixture, environment, CreateFilters()) + { + EFCoreTestUtils.CheckSqlServer(); + } + + private static LoggerFilterOptions CreateFilters() + { + var filters = new LoggerFilterOptions(); + filters.AddFilter(nameof(EFCoreSqlServerMembershipTableTests), LogLevel.Trace); + return filters; + } + + protected override IMembershipTable CreateMembershipTable(ILogger logger) + { + return new EFMembershipTable(this.loggerFactory, this._clusterOptions, this.GetFactory(), this._converter); + } + + protected override Task GetConnectionString() + { + var cs = "Server=localhost,1433;Database=OrleansTests.Membership;User Id=sa;Password=yourStrong(!)Password;TrustServerCertificate=True"; + return Task.FromResult(cs); + } + + protected override IGatewayListProvider CreateGatewayListProvider(ILogger logger) + { + return new EFGatewayListProvider(this.loggerFactory, this._clusterOptions, this._gatewayOptions, this.GetFactory()); + } + + private IDbContextFactory GetFactory() + { + var sp = new ServiceCollection() + .AddSingleton, SqlServerClusterETagConverter>() + .AddPooledDbContextFactory(optionsBuilder => + { + optionsBuilder.UseSqlServer(this.connectionString, opt => + { + opt.MigrationsHistoryTable("__EFMigrationsHistory"); + opt.MigrationsAssembly(typeof(SqlServerClusterDbContext).Assembly.FullName); + }); + }).BuildServiceProvider(); + + var factory = sp.GetRequiredService>(); + + var ctx = factory.CreateDbContext(); + if (ctx.Database.GetPendingMigrations().Any()) + { + try + { + ctx.Database.Migrate(); + } + catch { } + } + + return factory; + } + + [SkippableFact] + public void MembershipTable_SqlServer_Init() + { + } + + [SkippableFact] + public async Task MembershipTable_SqlServer_GetGateways() + { + await MembershipTable_GetGateways(); + } + + [SkippableFact] + public async Task MembershipTable_SqlServer_ReadAll_EmptyTable() + { + await MembershipTable_ReadAll_EmptyTable(); + } + + [SkippableFact] + public async Task MembershipTable_SqlServer_InsertRow() + { + await MembershipTable_InsertRow(); + } + + [SkippableFact] + public async Task MembershipTable_SqlServer_ReadRow_Insert_Read() + { + await MembershipTable_ReadRow_Insert_Read(); + } + + [SkippableFact] + public async Task MembershipTable_SqlServer_ReadAll_Insert_ReadAll() + { + await MembershipTable_ReadAll_Insert_ReadAll(); + } + + [SkippableFact] + public async Task MembershipTable_SqlServer_UpdateRow() + { + await MembershipTable_UpdateRow(); + } + + [SkippableFact] + public async Task MembershipTable_SqlServer_UpdateRowInParallel() + { + await MembershipTable_UpdateRowInParallel(); + } + + [SkippableFact] + public async Task MembershipTable_SqlServer_UpdateIAmAlive() + { + await MembershipTable_UpdateIAmAlive(); + } + + [SkippableFact] + public async Task MembershipTableSqlServerSql_CleanupDefunctSiloEntries() + { + await MembershipTable_CleanupDefunctSiloEntries(); + } +} \ No newline at end of file diff --git a/test/Extensions/Tester.EFCore/EFCoreTestUtils.cs b/test/Extensions/Tester.EFCore/EFCoreTestUtils.cs new file mode 100644 index 0000000000..01cc8abcdb --- /dev/null +++ b/test/Extensions/Tester.EFCore/EFCoreTestUtils.cs @@ -0,0 +1,30 @@ +using System.Net.Sockets; + +namespace Tester.EFCore; + +public static class EFCoreTestUtils +{ + public static void CheckSqlServer() => IsPortOpen("localhost", 1433); + public static void CheckMySql() => IsPortOpen("localhost", 3306); + + private static bool IsPortOpen(string host, int port) + { + using var client = new TcpClient(); + try + { + var result = client.BeginConnect(host, port, null, null); + var success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromMilliseconds(300)); + if (!success) + { + throw new TimeoutException("Connection timed out."); + } + + client.EndConnect(result); + return true; + } + catch + { + throw new SkipException(); + } + } +} \ No newline at end of file diff --git a/test/Extensions/Tester.EFCore/PersistenceGrainTests_EFCoreSqlServerGrainStorage.cs b/test/Extensions/Tester.EFCore/PersistenceGrainTests_EFCoreSqlServerGrainStorage.cs new file mode 100644 index 0000000000..d5047f74e7 --- /dev/null +++ b/test/Extensions/Tester.EFCore/PersistenceGrainTests_EFCoreSqlServerGrainStorage.cs @@ -0,0 +1,46 @@ +using Orleans.Persistence.EntityFrameworkCore.SqlServer.Data; +using TestExtensions; +using TestExtensions.Runners; +using Xunit.Abstractions; + +namespace Tester.EFCore; + +[TestCategory("Persistence"), TestCategory("EFCore"), TestCategory("EFCore-SqlServer")] +public class PersistenceGrainTests_EFCoreSqlServerGrainStorage : OrleansTestingBase, IClassFixture> +{ + private readonly GrainPersistenceTestsRunner _runner; + + public PersistenceGrainTests_EFCoreSqlServerGrainStorage( + ITestOutputHelper output, EFCoreFixture fixture, string grainNamespace = "UnitTests.Grains") + { + fixture.EnsurePreconditionsMet(); + this._runner = new GrainPersistenceTestsRunner(output, fixture, grainNamespace); + } + + [SkippableFact, TestCategory("Functional")] + public async Task Grain_EFCoreSqlServerGrainStorage_Delete() => await _runner.Grain_GrainStorage_Delete(); + + [SkippableFact, TestCategory("Functional")] + public async Task Grain_EFCoreSqlServerGrainStorage_Read() => await _runner.Grain_GrainStorage_Read(); + + [SkippableFact, TestCategory("Functional")] + public async Task Grain_GuidKey_EFCoreSqlServerGrainStorage_Read_Write() => await _runner.Grain_GuidKey_GrainStorage_Read_Write(); + + [SkippableFact, TestCategory("Functional")] + public async Task Grain_LongKey_EFCoreSqlServerGrainStorage_Read_Write() => await _runner.Grain_LongKey_GrainStorage_Read_Write(); + + [SkippableFact, TestCategory("Functional")] + public async Task Grain_LongKeyExtended_EFCoreSqlServerGrainStorage_Read_Write() => await _runner.Grain_LongKeyExtended_GrainStorage_Read_Write(); + + [SkippableFact, TestCategory("Functional")] + public async Task Grain_GuidKeyExtended_EFCoreSqlServerGrainStorage_Read_Write() => await _runner.Grain_GuidKeyExtended_GrainStorage_Read_Write(); + + [SkippableFact, TestCategory("Functional")] + public async Task Grain_Generic_EFCoreSqlServerGrainStorage_Read_Write() => await _runner.Grain_Generic_GrainStorage_Read_Write(); + + [SkippableFact, TestCategory("Functional")] + public async Task Grain_Generic_EFCoreSqlServerGrainStorage_DiffTypes() => await _runner.Grain_Generic_GrainStorage_DiffTypes(); + + [SkippableFact, TestCategory("Functional")] + public async Task Grain_EFCoreSqlServerGrainStorage_SiloRestart() => await _runner.Grain_GrainStorage_SiloRestart(); +} \ No newline at end of file diff --git a/test/Extensions/Tester.EFCore/PersistenceProviderTests_EFCoreSqlServer.cs b/test/Extensions/Tester.EFCore/PersistenceProviderTests_EFCoreSqlServer.cs new file mode 100644 index 0000000000..b42690e0c6 --- /dev/null +++ b/test/Extensions/Tester.EFCore/PersistenceProviderTests_EFCoreSqlServer.cs @@ -0,0 +1,276 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Persistence; +using Orleans.Persistence.EntityFrameworkCore.SqlServer.Data; +using Orleans.Providers; +using Orleans.Runtime; +using Orleans.Storage; +using TestExtensions; +using UnitTests.Persistence; +using Xunit.Abstractions; + +namespace Tester.EFCore; + +[Collection(TestEnvironmentFixture.DefaultCollection)] +[TestCategory("Persistence"), TestCategory("EFCore"), TestCategory("EFCore-SqlServer")] +public class PersistenceProviderTests_EFCoreSqlServer +{ + private readonly IProviderRuntime _providerRuntime; + private readonly ITestOutputHelper _output; + private readonly TestEnvironmentFixture _fixture; + private readonly string _clusterId; + private readonly string _serviceId; + + public PersistenceProviderTests_EFCoreSqlServer( + ITestOutputHelper output, + TestEnvironmentFixture fixture) + { + EFCoreTestUtils.CheckSqlServer(); + + this._output = output; + this._fixture = fixture; + this._providerRuntime = new ClientProviderRuntime( + this._fixture.InternalGrainFactory, + this._fixture.Services, + this._fixture.Services.GetRequiredService()); + this._clusterId = Guid.NewGuid().ToString("N"); + this._serviceId = Guid.NewGuid().ToString("N"); + } + + private async Task> InitializeStorage() + { + var clusterOptions = Options.Create(new ClusterOptions {ClusterId = _clusterId, ServiceId = _serviceId}); + var loggerFactory = this._providerRuntime.ServiceProvider.GetRequiredService(); + var lifecycle = ActivatorUtilities.CreateInstance(this._providerRuntime.ServiceProvider); + + var cs = "Server=localhost,1433;Database=OrleansTests.Generic;User Id=sa;Password=yourStrong(!)Password;TrustServerCertificate=True"; + + var sp = new ServiceCollection() + .AddPooledDbContextFactory(optionsBuilder => + { + optionsBuilder.UseSqlServer(cs, opt => + { + opt.MigrationsHistoryTable("__EFMigrationsHistory"); + opt.MigrationsAssembly(typeof(SqlServerGrainStateDbContext).Assembly.FullName); + }); + }).BuildServiceProvider(); + + var factory = sp.GetRequiredService>(); + + var ctx = factory.CreateDbContext(); + if ((await ctx.Database.GetPendingMigrationsAsync()).Any()) + { + try + { + await ctx.Database.MigrateAsync(); + } + catch { } + } + + var store = new EFGrainStorage("TestStorage", + loggerFactory, + clusterOptions, + factory, + new SqlServerGrainStateETagConverter(), + this._providerRuntime.ServiceProvider); + + store.Participate(lifecycle); + + await lifecycle.OnStart(); + + return store; + } + + [SkippableFact, TestCategory("Functional")] + public async Task PersistenceProvider_Read() + { + const string testName = nameof(PersistenceProvider_Read); + + var store = await InitializeStorage(); + await Test_PersistenceProvider_Read(testName, store, null, grainId: GrainId.Create("TestGrain", Guid.NewGuid().ToString())); + } + + [SkippableTheory, TestCategory("Functional")] + [InlineData(null)] + [InlineData(15 * 64 * 1024 - 256)] + [InlineData(15 * 32 * 1024 - 256)] + public async Task PersistenceProvider_WriteRead(int? stringLength) + { + var testName = string.Format("{0}({1} = {2})", + nameof(PersistenceProvider_WriteRead), + nameof(stringLength), stringLength == null ? "default" : stringLength.ToString()); + + var grainState = TestStoreGrainState.NewRandomState(stringLength); + + var store = await InitializeStorage(); + + await Test_PersistenceProvider_WriteRead(testName, store, grainState, GrainId.Create("TestGrain", Guid.NewGuid().ToString())); + } + + [SkippableTheory, TestCategory("Functional")] + [InlineData(null)] + [InlineData(15 * 64 * 1024 - 256)] + [InlineData(15 * 32 * 1024 - 256)] + public async Task PersistenceProvider_WriteClearRead(int? stringLength) + { + var testName = string.Format("{0}({1} = {2})", + nameof(PersistenceProvider_WriteClearRead), + nameof(stringLength), stringLength == null ? "default" : stringLength.ToString()); + + var grainState = TestStoreGrainState.NewRandomState(stringLength); + + var store = await InitializeStorage(); + + await Test_PersistenceProvider_WriteClearRead(testName, store, grainState); + } + + [SkippableTheory, TestCategory("Functional")] + [InlineData(null)] + [InlineData(15 * 32 * 1024 - 256)] + public async Task PersistenceProvider_ChangeReadFormat(int? stringLength) + { + var testName = string.Format("{0}({1} = {2})", + nameof(PersistenceProvider_ChangeReadFormat), + nameof(stringLength), stringLength == null ? "default" : stringLength.ToString()); + + var grainState = TestStoreGrainState.NewRandomState(stringLength); + var grainId = GrainId.Create("TestGrain", Guid.NewGuid().ToString()); + + var store = await InitializeStorage(); + + grainState = await Test_PersistenceProvider_WriteRead(testName, store, grainState, grainId); + + store = await InitializeStorage(); + + await Test_PersistenceProvider_Read(testName, store, grainState, grainId); + } + + [SkippableTheory, TestCategory("Functional")] + [InlineData(null)] + [InlineData(15 * 32 * 1024 - 256)] + public async Task PersistenceProvider_ChangeWriteFormat(int? stringLength) + { + var testName = string.Format("{0}({1}={2})", + nameof(PersistenceProvider_ChangeWriteFormat), + nameof(stringLength), stringLength == null ? "default" : stringLength.ToString()); + + var grainState = TestStoreGrainState.NewRandomState(stringLength); + + var grainId = GrainId.Create("TestGrain", Guid.NewGuid().ToString()); + + var store = await InitializeStorage(); + + await Test_PersistenceProvider_WriteRead(testName, store, grainState, grainId); + + grainState = TestStoreGrainState.NewRandomState(stringLength); + grainState.ETag = "*"; + + store = await InitializeStorage(); + + await Test_PersistenceProvider_WriteRead(testName, store, grainState, grainId); + } + + private async Task Test_PersistenceProvider_Read(string grainTypeName, IGrainStorage store, GrainState grainState, GrainId grainId) + { + grainState ??= new GrainState(new TestStoreGrainState()); + + var storedGrainState = new GrainState(new TestStoreGrainState()); + + var sw = new Stopwatch(); + sw.Start(); + + await store.ReadStateAsync(grainTypeName, grainId, storedGrainState); + + var readTime = sw.Elapsed; + this._output.WriteLine("{0} - Read time = {1}", store.GetType().FullName, readTime); + + var storedState = storedGrainState.State; + Assert.Equal(grainState.State.A, storedState.A); + Assert.Equal(grainState.State.B, storedState.B); + Assert.Equal(grainState.State.C, storedState.C); + } + + private async Task> Test_PersistenceProvider_WriteRead(string grainTypeName, + IGrainStorage store, GrainState grainState, GrainId grainId) + { + grainState ??= TestStoreGrainState.NewRandomState(); + + var sw = new Stopwatch(); + sw.Start(); + + await store.WriteStateAsync(grainTypeName, grainId, grainState); + + var writeTime = sw.Elapsed; + sw.Restart(); + + var storedGrainState = new GrainState {State = new TestStoreGrainState()}; + await store.ReadStateAsync(grainTypeName, grainId, storedGrainState); + var readTime = sw.Elapsed; + this._output.WriteLine("{0} - Write time = {1} Read time = {2}", store.GetType().FullName, writeTime, readTime); + Assert.Equal(grainState.State.A, storedGrainState.State.A); + Assert.Equal(grainState.State.B, storedGrainState.State.B); + Assert.Equal(grainState.State.C, storedGrainState.State.C); + + return storedGrainState; + } + + private async Task Test_PersistenceProvider_WriteClearRead(string grainTypeName, + IGrainStorage store, GrainState grainState = null, GrainId grainId = default) + { + grainId = this._fixture.InternalGrainFactory.GetGrain(grainId.IsDefault ? LegacyGrainId.NewId().ToGrainId() : grainId).GetGrainId(); + + grainState ??= TestStoreGrainState.NewRandomState(); + + var sw = new Stopwatch(); + sw.Start(); + + await store.WriteStateAsync(grainTypeName, grainId, grainState); + + var writeTime = sw.Elapsed; + sw.Restart(); + + await store.ClearStateAsync(grainTypeName, grainId, grainState); + + var storedGrainState = new GrainState {State = new TestStoreGrainState()}; + await store.ReadStateAsync(grainTypeName, grainId, storedGrainState); + var readTime = sw.Elapsed; + this._output.WriteLine("{0} - Write time = {1} Read time = {2}", store.GetType().FullName, writeTime, readTime); + Assert.NotNull(storedGrainState.State); + Assert.Equal(default, storedGrainState.State.A); + Assert.Equal(default, storedGrainState.State.B); + Assert.Equal(default, storedGrainState.State.C); + } + + public class TestStoreGrainStateWithCustomJsonProperties + { + [JsonPropertyName("s")] public string String { get; set; } + + internal static GrainState NewRandomState(int? aPropertyLength = null) => + new() + { + State = new TestStoreGrainStateWithCustomJsonProperties + { + String = aPropertyLength == null + ? Random.Shared.Next().ToString(CultureInfo.InvariantCulture) + : GenerateRandomDigitString(aPropertyLength.Value) + } + }; + + private static string GenerateRandomDigitString(int stringLength) + { + var characters = new char[stringLength]; + for (var i = 0; i < stringLength; ++i) + { + characters[i] = (char)Random.Shared.Next('0', '9' + 1); + } + + return new string(characters); + } + } +} \ No newline at end of file diff --git a/test/Extensions/Tester.EFCore/ReminderTests_EFCoreSqlServer.cs b/test/Extensions/Tester.EFCore/ReminderTests_EFCoreSqlServer.cs new file mode 100644 index 0000000000..f819dfd295 --- /dev/null +++ b/test/Extensions/Tester.EFCore/ReminderTests_EFCoreSqlServer.cs @@ -0,0 +1,299 @@ +using Microsoft.Extensions.Logging; +using Orleans.Runtime; +using Orleans.Internal; +using Orleans.Reminders.EntityFrameworkCore.SqlServer.Data; +using UnitTests.TimerTests; +using UnitTests.GrainInterfaces; + +namespace Tester.EFCore; + +[TestCategory("Reminders"), TestCategory("EFCore"), TestCategory("EFCore-SqlServer")] +public class ReminderTests_EFCoreSqlServer : ReminderTests_Base, IClassFixture> +{ + public ReminderTests_EFCoreSqlServer(EFCoreFixture fixture) : base(fixture) + { + EFCoreTestUtils.CheckSqlServer(); + } + + // Basic tests + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_Basic_StopByRef() + { + await Test_Reminders_Basic_StopByRef(); + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_Basic_ListOps() + { + await Test_Reminders_Basic_ListOps(); + } + + // Single join tests ... multi grain, multi reminders + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_1J_MultiGrainMultiReminders() + { + await Test_Reminders_1J_MultiGrainMultiReminders(); + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_ReminderNotFound() + { + await Test_Reminders_ReminderNotFound(); + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_Basic() + { + // start up a test grain and get the period that it's programmed to use. + var grain = GrainFactory.GetGrain(Guid.NewGuid()); + var period = await grain.GetReminderPeriod(DR); + // start up the 'DR' reminder and wait for two ticks to pass. + await grain.StartReminder(DR); + Thread.Sleep(period.Multiply(2) + LEEWAY); // giving some leeway + // retrieve the value of the counter-- it should match the sequence number which is the number of periods + // we've waited. + var last = await grain.GetCounter(DR); + Assert.Equal(2, last); + // stop the timer and wait for a whole period. + await grain.StopReminder(DR); + Thread.Sleep(period.Multiply(1) + LEEWAY); // giving some leeway + // the counter should not have changed. + var curr = await grain.GetCounter(DR); + Assert.Equal(last, curr); + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_Basic_Restart() + { + var grain = GrainFactory.GetGrain(Guid.NewGuid()); + var period = await grain.GetReminderPeriod(DR); + await grain.StartReminder(DR); + Thread.Sleep(period.Multiply(2) + LEEWAY); // giving some leeway + var last = await grain.GetCounter(DR); + Assert.Equal(2, last); + + await grain.StopReminder(DR); + var sleepFor = period.Multiply(1) + LEEWAY; + Thread.Sleep(sleepFor); // giving some leeway + var curr = await grain.GetCounter(DR); + Assert.Equal(last, curr); + AssertIsInRange(curr, last, last + 1, grain, DR, sleepFor); + + // start the same reminder again + await grain.StartReminder(DR); + sleepFor = period.Multiply(2) + LEEWAY; + Thread.Sleep(sleepFor); // giving some leeway + curr = await grain.GetCounter(DR); + AssertIsInRange(curr, 2, 3, grain, DR, sleepFor); + await grain.StopReminder(DR); // cleanup + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_MultipleReminders() + { + var grain = GrainFactory.GetGrain(Guid.NewGuid()); + await PerGrainMultiReminderTest(grain); + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_2J_MultiGrainMultiReminders() + { + var g1 = GrainFactory.GetGrain(Guid.NewGuid()); + var g2 = GrainFactory.GetGrain(Guid.NewGuid()); + var g3 = GrainFactory.GetGrain(Guid.NewGuid()); + var g4 = GrainFactory.GetGrain(Guid.NewGuid()); + var g5 = GrainFactory.GetGrain(Guid.NewGuid()); + + var period = await g1.GetReminderPeriod(DR); + + Task[] tasks = {Task.Run(() => PerGrainMultiReminderTestChurn(g1)), Task.Run(() => PerGrainMultiReminderTestChurn(g2)), Task.Run(() => PerGrainMultiReminderTestChurn(g3)), Task.Run(() => PerGrainMultiReminderTestChurn(g4)), Task.Run(() => PerGrainMultiReminderTestChurn(g5)),}; + + await Task.Delay(period.Multiply(5)); + + // start two extra silos ... although it will take it a while before they stabilize + log.LogInformation("Starting 2 extra silos"); + + await HostedCluster.StartAdditionalSilosAsync(2, true); + await HostedCluster.WaitForLivenessToStabilizeAsync(); + + //Block until all tasks complete. + await Task.WhenAll(tasks).WithTimeout(ENDWAIT); + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_MultiGrainMultiReminders() + { + var g1 = GrainFactory.GetGrain(Guid.NewGuid()); + var g2 = GrainFactory.GetGrain(Guid.NewGuid()); + var g3 = GrainFactory.GetGrain(Guid.NewGuid()); + var g4 = GrainFactory.GetGrain(Guid.NewGuid()); + var g5 = GrainFactory.GetGrain(Guid.NewGuid()); + + Task[] tasks = {Task.Run(() => PerGrainMultiReminderTest(g1)), Task.Run(() => PerGrainMultiReminderTest(g2)), Task.Run(() => PerGrainMultiReminderTest(g3)), Task.Run(() => PerGrainMultiReminderTest(g4)), Task.Run(() => PerGrainMultiReminderTest(g5)),}; + + //Block until all tasks complete. + await Task.WhenAll(tasks).WithTimeout(ENDWAIT); + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_1F_Basic() + { + var g1 = GrainFactory.GetGrain(Guid.NewGuid()); + + var period = await g1.GetReminderPeriod(DR); + + var test = Task.Run(async () => + { + await PerGrainFailureTest(g1); + return true; + }); + + Thread.Sleep(period.Multiply(failAfter)); + // stop the secondary silo + log.LogInformation("Stopping secondary silo"); + await HostedCluster.StopSiloAsync(HostedCluster.SecondarySilos.First()); + + await test; // Block until test completes. + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_2F_MultiGrain() + { + var silos = await HostedCluster.StartAdditionalSilosAsync(2, true); + + var g1 = GrainFactory.GetGrain(Guid.NewGuid()); + var g2 = GrainFactory.GetGrain(Guid.NewGuid()); + var g3 = GrainFactory.GetGrain(Guid.NewGuid()); + var g4 = GrainFactory.GetGrain(Guid.NewGuid()); + var g5 = GrainFactory.GetGrain(Guid.NewGuid()); + + var period = await g1.GetReminderPeriod(DR); + + Task[] tasks = {Task.Run(() => PerGrainFailureTest(g1)), Task.Run(() => PerGrainFailureTest(g2)), Task.Run(() => PerGrainFailureTest(g3)), Task.Run(() => PerGrainFailureTest(g4)), Task.Run(() => PerGrainFailureTest(g5)),}; + + Thread.Sleep(period.Multiply(failAfter)); + + // stop a couple of silos + log.LogInformation("Stopping 2 silos"); + var i = Random.Shared.Next(silos.Count); + await HostedCluster.StopSiloAsync(silos[i]); + silos.RemoveAt(i); + await HostedCluster.StopSiloAsync(silos[Random.Shared.Next(silos.Count)]); + + await Task.WhenAll(tasks).WithTimeout(ENDWAIT); // Block until all tasks complete. + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_1F1J_MultiGrain() + { + var silos = await HostedCluster.StartAdditionalSilosAsync(1); + await HostedCluster.WaitForLivenessToStabilizeAsync(); + + var g1 = GrainFactory.GetGrain(Guid.NewGuid()); + var g2 = GrainFactory.GetGrain(Guid.NewGuid()); + var g3 = GrainFactory.GetGrain(Guid.NewGuid()); + var g4 = GrainFactory.GetGrain(Guid.NewGuid()); + var g5 = GrainFactory.GetGrain(Guid.NewGuid()); + + var period = await g1.GetReminderPeriod(DR); + + Task[] tasks = {Task.Run(() => PerGrainFailureTest(g1)), Task.Run(() => PerGrainFailureTest(g2)), Task.Run(() => PerGrainFailureTest(g3)), Task.Run(() => PerGrainFailureTest(g4)), Task.Run(() => PerGrainFailureTest(g5)),}; + + Thread.Sleep(period.Multiply(failAfter)); + + var siloToKill = silos[Random.Shared.Next(silos.Count)]; + // stop a silo and join a new one in parallel + log.LogInformation("Stopping a silo and joining a silo"); + Task t1 = Task.Factory.StartNew(async () => await HostedCluster.StopSiloAsync(siloToKill)); + var t2 = HostedCluster.StartAdditionalSilosAsync(1, true).ContinueWith(t => + { + t.GetAwaiter().GetResult(); + }); + await Task.WhenAll(new[] {t1, t2}).WithTimeout(ENDWAIT); + + await Task.WhenAll(tasks).WithTimeout(ENDWAIT); // Block until all tasks complete. + log.LogInformation("\n\n\nReminderTest_1F1J_MultiGrain passed OK.\n\n\n"); + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_RegisterSameReminderTwice() + { + var grain = GrainFactory.GetGrain(Guid.NewGuid()); + var promise1 = grain.StartReminder(DR); + var promise2 = grain.StartReminder(DR); + Task[] tasks = {promise1, promise2}; + await Task.WhenAll(tasks).WithTimeout(TimeSpan.FromSeconds(15)); + //Assert.NotEqual(promise1.Result, promise2.Result); + // TODO: write tests where period of a reminder is changed + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_GT_Basic() + { + var g1 = GrainFactory.GetGrain(Guid.NewGuid()); + var g2 = GrainFactory.GetGrain(Guid.NewGuid()); + var period = await g1.GetReminderPeriod(DR); // using same period + + await g1.StartReminder(DR); + Thread.Sleep(period.Multiply(2) + LEEWAY); // giving some leeway + await g2.StartReminder(DR); + Thread.Sleep(period.Multiply(2) + LEEWAY); // giving some leeway + var last1 = await g1.GetCounter(DR); + Assert.Equal(4, last1); + var last2 = await g2.GetCounter(DR); + Assert.Equal(2, last2); // CopyGrain fault + + await g1.StopReminder(DR); + Thread.Sleep(period.Multiply(2) + LEEWAY); // giving some leeway + await g2.StopReminder(DR); + var curr1 = await g1.GetCounter(DR); + Assert.Equal(last1, curr1); + var curr2 = await g2.GetCounter(DR); + Assert.Equal(4, curr2); // CopyGrain fault + } + + [SkippableFact(Skip = "https://github.com/dotnet/orleans/issues/4319"), TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_GT_1F1J_MultiGrain() + { + var silos = await HostedCluster.StartAdditionalSilosAsync(1); + await HostedCluster.WaitForLivenessToStabilizeAsync(); + + var g1 = GrainFactory.GetGrain(Guid.NewGuid()); + var g2 = GrainFactory.GetGrain(Guid.NewGuid()); + var g3 = GrainFactory.GetGrain(Guid.NewGuid()); + var g4 = GrainFactory.GetGrain(Guid.NewGuid()); + + var period = await g1.GetReminderPeriod(DR); + + Task[] tasks = {Task.Run(() => PerGrainFailureTest(g1)), Task.Run(() => PerGrainFailureTest(g2)), Task.Run(() => PerCopyGrainFailureTest(g3)), Task.Run(() => PerCopyGrainFailureTest(g4)),}; + + Thread.Sleep(period.Multiply(failAfter)); + + var siloToKill = silos[Random.Shared.Next(silos.Count)]; + // stop a silo and join a new one in parallel + log.LogInformation("Stopping a silo and joining a silo"); + var t1 = Task.Run(async () => await HostedCluster.StopSiloAsync(siloToKill)); + Task t2 = Task.Run(async () => await HostedCluster.StartAdditionalSilosAsync(1)); + await Task.WhenAll(new[] {t1, t2}).WithTimeout(ENDWAIT); + + await Task.WhenAll(tasks).WithTimeout(ENDWAIT); // Block until all tasks complete. + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_Wrong_LowerThanAllowedPeriod() + { + var grain = GrainFactory.GetGrain(Guid.NewGuid()); + await Assert.ThrowsAsync(() => + grain.StartReminder(DR, TimeSpan.FromMilliseconds(3000), true)); + } + + [SkippableFact, TestCategory("Functional")] + public async Task Rem_EFCoreSqlServer_Wrong_Grain() + { + var grain = GrainFactory.GetGrain(0); + + await Assert.ThrowsAsync(() => + grain.StartReminder(DR)); + } +} \ No newline at end of file diff --git a/test/Extensions/Tester.EFCore/ReminderTests_EFCoreSqlServer_Standalone.cs b/test/Extensions/Tester.EFCore/ReminderTests_EFCoreSqlServer_Standalone.cs new file mode 100644 index 0000000000..6b6bf5dbe2 --- /dev/null +++ b/test/Extensions/Tester.EFCore/ReminderTests_EFCoreSqlServer_Standalone.cs @@ -0,0 +1,171 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Internal; +using Orleans.Reminders; +using Orleans.Reminders.EntityFrameworkCore.SqlServer.Data; +using Orleans.Runtime; +using Orleans.TestingHost.Utils; +using TestExtensions; +using Xunit.Abstractions; + +namespace Tester.EFCore; + +[Collection(TestEnvironmentFixture.DefaultCollection)] +[TestCategory("Reminders"), TestCategory("EFCore"), TestCategory("EFCore-SqlServer")] +public class ReminderTests_EFCoreSqlServer_Standalone +{ + private readonly ITestOutputHelper _output; + private readonly TestEnvironmentFixture _fixture; + private readonly string _serviceId; + private readonly ILogger _log; + private readonly ILoggerFactory _loggerFactory; + + public ReminderTests_EFCoreSqlServer_Standalone(ITestOutputHelper output, TestEnvironmentFixture fixture) + { + EFCoreTestUtils.CheckSqlServer(); + + _output = output; + _fixture = fixture; + _loggerFactory = TestingUtils.CreateDefaultLoggerFactory($"{GetType().Name}.log"); + _log = _loggerFactory.CreateLogger(); + + _serviceId = Guid.NewGuid().ToString(); + + TestUtils.ConfigureClientThreadPoolSettingsForStorageTests(1000); + } + + [SkippableFact, TestCategory("Reminders"), TestCategory("Performance")] + public async Task Reminders_AzureTable_InsertRate() + { + IReminderTable table = this.GetReminderTable("TMSLocalTesting"); + await table.Init(); + + await TestTableInsertRate(table, 10); + await TestTableInsertRate(table, 500); + } + + [SkippableFact, TestCategory("Reminders")] + public async Task Reminders_AzureTable_InsertNewRowAndReadBack() + { + var clusterId = NewClusterId(); + IReminderTable table = this.GetReminderTable(clusterId); + await table.Init(); + + ReminderEntry[] rows = (await GetAllRows(table)).ToArray(); + Assert.Empty(rows); // "The reminder table (sid={0}, did={1}) was not empty.", ServiceId, clusterId); + + ReminderEntry expected = NewReminderEntry(); + await table.UpsertRow(expected); + rows = (await GetAllRows(table)).ToArray(); + + Assert.Single(rows); // "The reminder table (sid={0}, did={1}) did not contain the correct number of rows (1).", ServiceId, clusterId); + ReminderEntry actual = rows[0]; + Assert.Equal(expected.GrainId, actual.GrainId); // "The newly inserted reminder table (sid={0}, did={1}) row did not contain the expected grain reference.", ServiceId, clusterId); + Assert.Equal(expected.ReminderName, actual.ReminderName); // "The newly inserted reminder table (sid={0}, did={1}) row did not have the expected reminder name.", ServiceId, clusterId); + Assert.Equal(expected.Period, actual.Period); // "The newly inserted reminder table (sid={0}, did={1}) row did not have the expected period.", ServiceId, clusterId); + // the following assertion fails but i don't know why yet-- the timestamps appear identical in the error message. it's not really a priority to hunt down the reason, however, because i have high confidence it is working well enough for the moment. + /*Assert.Equal(expected.StartAt, actual.StartAt); // "The newly inserted reminder table (sid={0}, did={1}) row did not contain the correct start time.", ServiceId, clusterId);*/ + Assert.False(string.IsNullOrWhiteSpace(actual.ETag), $"The newly inserted reminder table (sid={_serviceId}, did={clusterId}) row contains an invalid etag."); + } + + private async Task TestTableInsertRate(IReminderTable reminderTable, double numOfInserts) + { + DateTime startedAt = DateTime.UtcNow; + + try + { + List> promises = new List>(); + for (int i = 0; i < numOfInserts; i++) + { + //"177BF46E-D06D-44C0-943B-C12F26DF5373" + string s = string.Format("177BF46E-D06D-44C0-943B-C12F26D{0:d5}", i); + + var e = new ReminderEntry + { + //GrainId = LegacyGrainId.GetGrainId(new Guid(s)), + GrainId = _fixture.InternalGrainFactory.GetGrain(LegacyGrainId.NewId()).GetGrainId(), + ReminderName = "MY_REMINDER_" + i, + Period = TimeSpan.FromSeconds(5), + StartAt = DateTime.UtcNow + }; + + int capture = i; + Task promise = Task.Run(async () => + { + await reminderTable.UpsertRow(e); + _output.WriteLine("Done " + capture); + return true; + }); + promises.Add(promise); + _log.LogInformation("Started {Capture}", capture); + } + _log.LogInformation("Started all, now waiting..."); + await Task.WhenAll(promises).WithTimeout(TimeSpan.FromSeconds(500)); + } + catch (Exception exc) + { + _log.LogInformation(exc, "Exception caught"); + } + TimeSpan dur = DateTime.UtcNow - startedAt; + _log.LogInformation( + "Inserted {InsertCount} rows in {Duration}, i.e., {Rate} upserts/sec", + numOfInserts, + dur, + (numOfInserts / dur.TotalSeconds).ToString("f2")); + } + + private ReminderEntry NewReminderEntry() + { + Guid guid = Guid.NewGuid(); + return new ReminderEntry + { + GrainId = _fixture.InternalGrainFactory.GetGrain(LegacyGrainId.NewId()).GetGrainId(), + ReminderName = string.Format("TestReminder.{0}", guid), + Period = TimeSpan.FromSeconds(5), + StartAt = DateTime.UtcNow + }; + } + + private EFReminderTable GetReminderTable(string clusterId) + { + var cs = "Server=localhost,1433;Database=OrleansTests.Reminders;User Id=sa;Password=yourStrong(!)Password;TrustServerCertificate=True"; + var sp = new ServiceCollection() + .AddPooledDbContextFactory(optionsBuilder => + { + optionsBuilder.UseSqlServer(cs, opt => + { + opt.MigrationsHistoryTable("__EFMigrationsHistory"); + opt.MigrationsAssembly(typeof(SqlServerReminderDbContext).Assembly.FullName); + }); + }).BuildServiceProvider(); + + var factory = sp.GetRequiredService>(); + + var ctx = factory.CreateDbContext(); + if (ctx.Database.GetPendingMigrations().Any()) + { + try + { + ctx.Database.Migrate(); + } + catch { } + } + + var clusterOptions = Options.Create(new ClusterOptions { ClusterId = clusterId, ServiceId = _serviceId }); + return new EFReminderTable(this._loggerFactory, clusterOptions, factory, new SqlServerReminderETagConverter()); + } + + private string NewClusterId() + { + return string.Format("ReminderTest.{0}", Guid.NewGuid()); + } + + private async Task> GetAllRows(IReminderTable table) + { + ReminderTableData data = await table.ReadRows(0, 0xffffffff); + return data.Reminders; + } +} \ No newline at end of file diff --git a/test/Extensions/Tester.EFCore/Tester.EFCore.csproj b/test/Extensions/Tester.EFCore/Tester.EFCore.csproj index 54db532755..0ac1c102b7 100644 --- a/test/Extensions/Tester.EFCore/Tester.EFCore.csproj +++ b/test/Extensions/Tester.EFCore/Tester.EFCore.csproj @@ -12,6 +12,11 @@ + + + + + diff --git a/test/Extensions/Tester.EFCore/Usings.cs b/test/Extensions/Tester.EFCore/Usings.cs new file mode 100644 index 0000000000..472f817a28 --- /dev/null +++ b/test/Extensions/Tester.EFCore/Usings.cs @@ -0,0 +1,5 @@ +global using Xunit; +global using Orleans.Clustering.EntityFrameworkCore; +global using Orleans.Persistence.EntityFrameworkCore; +global using Orleans.Reminders.EntityFrameworkCore; +global using Orleans.GrainDirectory.EntityFrameworkCore; \ No newline at end of file diff --git a/test/TestInfrastructure/TestExtensions/Properties/AssemblyInfo.cs b/test/TestInfrastructure/TestExtensions/Properties/AssemblyInfo.cs index d344dc5fe5..e6a32cfd5a 100644 --- a/test/TestInfrastructure/TestExtensions/Properties/AssemblyInfo.cs +++ b/test/TestInfrastructure/TestExtensions/Properties/AssemblyInfo.cs @@ -4,6 +4,7 @@ [assembly: InternalsVisibleTo("NonSilo.Tests")] [assembly: InternalsVisibleTo("Tester.AzureUtils")] [assembly: InternalsVisibleTo("Tester.Cosmos")] +[assembly: InternalsVisibleTo("Tester.EFCore")] [assembly: InternalsVisibleTo("Tester.AdoNet")] [assembly: InternalsVisibleTo("Tester.Redis")] [assembly: InternalsVisibleTo("AWSUtils.Tests")] diff --git a/test/TesterInternal/Properties/AssemblyInfo.cs b/test/TesterInternal/Properties/AssemblyInfo.cs index e7e5ada4df..dfa3f952de 100644 --- a/test/TesterInternal/Properties/AssemblyInfo.cs +++ b/test/TesterInternal/Properties/AssemblyInfo.cs @@ -5,6 +5,7 @@ [assembly: InternalsVisibleTo("Tester.AzureUtils")] [assembly: InternalsVisibleTo("Tester.Cosmos")] +[assembly: InternalsVisibleTo("Tester.EFCore")] [assembly: InternalsVisibleTo("Tester.AdoNet")] [assembly: InternalsVisibleTo("Tester.Redis")] [assembly: InternalsVisibleTo("AWSUtils.Tests")]