diff --git a/docs/site/src/content/docs/host/configuration-guide/adonet-configuration.md b/docs/site/src/content/docs/host/configuration-guide/adonet-configuration.md index ff986167130..ef160a10ae7 100644 --- a/docs/site/src/content/docs/host/configuration-guide/adonet-configuration.md +++ b/docs/site/src/content/docs/host/configuration-guide/adonet-configuration.md @@ -1,7 +1,7 @@ --- title: ADO.NET database configuration description: Find Orleans ADO.NET schema scripts and provider invariants. -ms.date: 08/02/2026 +ms.date: 10/02/2026 ms.topic: reference --- @@ -44,6 +44,9 @@ Orleans keeps its ADO.NET schema scripts beside each provider's source. Run the - [Oracle](https://github.com/dotnet/orleans/blob/main/src/AdoNet/Orleans.Persistence.AdoNet/Oracle-Persistence.sql) - [SQLite](https://github.com/dotnet/orleans/blob/main/src/AdoNet/Orleans.Persistence.AdoNet/Sqlite-Persistence.sql) +The SQLite scripts are idempotent. Reapplying them refreshes persistence queries +while preserving grain state. + ## Reminders - [SQL Server](https://github.com/dotnet/orleans/blob/main/src/AdoNet/Orleans.Reminders.AdoNet/SQLServer-Reminders.sql) diff --git a/src/AdoNet/Orleans.Persistence.AdoNet/Orleans.Persistence.AdoNet.csproj b/src/AdoNet/Orleans.Persistence.AdoNet/Orleans.Persistence.AdoNet.csproj index f93434479a6..cf151bc2926 100644 --- a/src/AdoNet/Orleans.Persistence.AdoNet/Orleans.Persistence.AdoNet.csproj +++ b/src/AdoNet/Orleans.Persistence.AdoNet/Orleans.Persistence.AdoNet.csproj @@ -32,4 +32,3 @@ - diff --git a/src/AdoNet/Orleans.Persistence.AdoNet/README.md b/src/AdoNet/Orleans.Persistence.AdoNet/README.md index 13b44c4530d..4ac2ae2c506 100644 --- a/src/AdoNet/Orleans.Persistence.AdoNet/README.md +++ b/src/AdoNet/Orleans.Persistence.AdoNet/README.md @@ -92,7 +92,11 @@ Before using the ADO.NET provider, you need to set up the necessary database tab - [MySQL Scripts](https://github.com/dotnet/orleans/tree/main/src/AdoNet/Orleans.Persistence.AdoNet/MySQL-Persistence.sql) - [PostgreSQL Scripts](https://github.com/dotnet/orleans/tree/main/src/AdoNet/Orleans.Persistence.AdoNet/PostgreSQL-Persistence.sql) - [Oracle Scripts](https://github.com/dotnet/orleans/tree/main/src/AdoNet/Orleans.Persistence.AdoNet/Oracle-Persistence.sql) -- [SQLite Scripts](https://github.com/dotnet/orleans/tree/main/src/AdoNet/Orleans.Persistence.AdoNet/Sqlite-Persistence.sql) +- [SQLite Main Script](https://github.com/dotnet/orleans/blob/main/src/AdoNet/Shared/Sqlite-Main.sql) +- [SQLite Persistence Script](https://github.com/dotnet/orleans/blob/main/src/AdoNet/Orleans.Persistence.AdoNet/Sqlite-Persistence.sql) + +The SQLite scripts are idempotent: reapplying them refreshes the persistence +queries while preserving grain state. ## Documentation For more comprehensive documentation, please refer to: diff --git a/src/AdoNet/Orleans.Persistence.AdoNet/Sqlite-Persistence.sql b/src/AdoNet/Orleans.Persistence.AdoNet/Sqlite-Persistence.sql index e9c1ee67182..b7d12fe3ba6 100644 --- a/src/AdoNet/Orleans.Persistence.AdoNet/Sqlite-Persistence.sql +++ b/src/AdoNet/Orleans.Persistence.AdoNet/Sqlite-Persistence.sql @@ -43,7 +43,7 @@ -- -- 7. In the storage operations queries the columns need to be in the exact same order -- since the storage table operations support optionally streaming. -CREATE TABLE OrleansStorage +CREATE TABLE IF NOT EXISTS OrleansStorage ( -- These are for the book keeping. Orleans calculates -- these hashes (see RelationalStorageProvide implementation), @@ -73,56 +73,75 @@ CREATE TABLE OrleansStorage -- rows down to [0, n] relevant ones, n being the number of collided value pairs. ); -CREATE INDEX IX_OrleansStorage ON OrleansStorage(GrainIdHash, GrainTypeHash); +CREATE INDEX IF NOT EXISTS IX_OrleansStorage ON OrleansStorage(GrainIdHash, GrainTypeHash); -- Updates an existing grain state with optimistic concurrency control or inserts it if it does not exist. INSERT INTO OrleansQuery (QueryKey, QueryText) VALUES ('WriteToStorageKey', ' - BEGIN TRANSACTION; - - CREATE TEMP TABLE IF NOT EXISTS OrleansStorageWriteState + -- TEMP objects survive pooled connection reuse. Bump the suffix on both object names whenever the trigger body changes. + CREATE TEMP TABLE IF NOT EXISTS OrleansStorageWriteRequest_10_4 ( - TotalChangesBefore INT NOT NULL + GrainIdHash INT NOT NULL, + GrainIdN0 BIGINT NOT NULL, + GrainIdN1 BIGINT NOT NULL, + GrainTypeHash INT NOT NULL, + GrainTypeString NVARCHAR(512) NOT NULL, + GrainIdExtensionString NVARCHAR(512) NULL, + ServiceId NVARCHAR(150) NOT NULL, + PayloadBinary BLOB NULL, + GrainStateVersion INT NULL, + Applied INT NOT NULL DEFAULT 0 ); - DELETE FROM OrleansStorageWriteState; - INSERT INTO OrleansStorageWriteState (TotalChangesBefore) VALUES (total_changes() + 1); - UPDATE OrleansStorage - SET - PayloadBinary = @PayloadBinary, - ModifiedOn = datetime(''now''), - Version = Version + 1 - WHERE - GrainIdHash = @GrainIdHash AND GrainTypeHash = @GrainTypeHash - AND GrainIdN0 = @GrainIdN0 AND GrainIdN1 = @GrainIdN1 - AND GrainTypeString = @GrainTypeString - AND (GrainIdExtensionString = @GrainIdExtensionString OR (GrainIdExtensionString IS NULL AND @GrainIdExtensionString IS NULL)) - AND ServiceId = @ServiceId - AND Version = @GrainStateVersion; + CREATE TEMP TRIGGER IF NOT EXISTS OrleansStorageWriteApply_10_4 + AFTER INSERT ON OrleansStorageWriteRequest_10_4 + BEGIN + UPDATE OrleansStorage + SET + PayloadBinary = NEW.PayloadBinary, + ModifiedOn = datetime(''now''), + Version = Version + 1 + WHERE + GrainIdHash = NEW.GrainIdHash AND GrainTypeHash = NEW.GrainTypeHash + AND GrainIdN0 = NEW.GrainIdN0 AND GrainIdN1 = NEW.GrainIdN1 + AND GrainTypeString = NEW.GrainTypeString + AND (GrainIdExtensionString = NEW.GrainIdExtensionString OR (GrainIdExtensionString IS NULL AND NEW.GrainIdExtensionString IS NULL)) + AND ServiceId = NEW.ServiceId + AND Version = NEW.GrainStateVersion; - INSERT INTO OrleansStorage (GrainIdHash, GrainIdN0, GrainIdN1, GrainTypeHash, GrainTypeString, GrainIdExtensionString, ServiceId, PayloadBinary, ModifiedOn, Version) - SELECT @GrainIdHash, @GrainIdN0, @GrainIdN1, @GrainTypeHash, @GrainTypeString, @GrainIdExtensionString, @ServiceId, @PayloadBinary, datetime(''now''), 1 - WHERE changes() = 0 - AND @GrainStateVersion IS NULL - AND NOT EXISTS ( - SELECT 1 FROM OrleansStorage - WHERE GrainIdHash = @GrainIdHash AND GrainTypeHash = @GrainTypeHash - AND GrainIdN0 = @GrainIdN0 AND GrainIdN1 = @GrainIdN1 - AND GrainTypeString = @GrainTypeString - AND (GrainIdExtensionString = @GrainIdExtensionString OR (GrainIdExtensionString IS NULL AND @GrainIdExtensionString IS NULL)) - AND ServiceId = @ServiceId - ); + UPDATE OrleansStorageWriteRequest_10_4 SET Applied = changes() WHERE rowid = NEW.rowid; + + INSERT INTO OrleansStorage (GrainIdHash, GrainIdN0, GrainIdN1, GrainTypeHash, GrainTypeString, GrainIdExtensionString, ServiceId, PayloadBinary, ModifiedOn, Version) + SELECT NEW.GrainIdHash, NEW.GrainIdN0, NEW.GrainIdN1, NEW.GrainTypeHash, NEW.GrainTypeString, NEW.GrainIdExtensionString, NEW.ServiceId, NEW.PayloadBinary, datetime(''now''), 1 + WHERE NEW.GrainStateVersion IS NULL + AND NOT EXISTS ( + SELECT 1 FROM OrleansStorage + WHERE GrainIdHash = NEW.GrainIdHash AND GrainTypeHash = NEW.GrainTypeHash + AND GrainIdN0 = NEW.GrainIdN0 AND GrainIdN1 = NEW.GrainIdN1 + AND GrainTypeString = NEW.GrainTypeString + AND (GrainIdExtensionString = NEW.GrainIdExtensionString OR (GrainIdExtensionString IS NULL AND NEW.GrainIdExtensionString IS NULL)) + AND ServiceId = NEW.ServiceId + ); + + UPDATE OrleansStorageWriteRequest_10_4 SET Applied = Applied + changes(), PayloadBinary = NULL WHERE rowid = NEW.rowid; + END; + + DELETE FROM OrleansStorageWriteRequest_10_4; + + INSERT INTO OrleansStorageWriteRequest_10_4 (GrainIdHash, GrainIdN0, GrainIdN1, GrainTypeHash, GrainTypeString, GrainIdExtensionString, ServiceId, PayloadBinary, GrainStateVersion) + VALUES (@GrainIdHash, @GrainIdN0, @GrainIdN1, @GrainTypeHash, @GrainTypeString, @GrainIdExtensionString, @ServiceId, @PayloadBinary, @GrainStateVersion); SELECT CASE WHEN @GrainStateVersion IS NULL THEN 1 ELSE @GrainStateVersion + 1 END AS NewGrainStateVersion - WHERE total_changes() > (SELECT TotalChangesBefore FROM OrleansStorageWriteState LIMIT 1); + FROM OrleansStorageWriteRequest_10_4 + WHERE Applied > 0; SELECT @GrainStateVersion AS NewGrainStateVersion - WHERE total_changes() = (SELECT TotalChangesBefore FROM OrleansStorageWriteState LIMIT 1) + FROM OrleansStorageWriteRequest_10_4 + WHERE Applied = 0 AND @GrainStateVersion IS NOT NULL; - - COMMIT; -'); +') +ON CONFLICT(QueryKey) DO UPDATE SET QueryText = excluded.QueryText; -- Retrieves the binary payload and the current version of a specific grain state. INSERT INTO OrleansQuery (QueryKey, QueryText) VALUES @@ -139,7 +158,8 @@ INSERT INTO OrleansQuery (QueryKey, QueryText) VALUES AND (GrainIdExtensionString = @GrainIdExtensionString OR (GrainIdExtensionString IS NULL AND @GrainIdExtensionString IS NULL)) AND ServiceId = @ServiceId LIMIT 1; -'); +') +ON CONFLICT(QueryKey) DO UPDATE SET QueryText = excluded.QueryText; -- Clears the grain state by setting the payload to null and incrementing the version for consistency. INSERT INTO OrleansQuery (QueryKey, QueryText) VALUES @@ -163,4 +183,5 @@ INSERT INTO OrleansQuery (QueryKey, QueryText) VALUES SELECT @GrainStateVersion AS NewGrainStateVersion WHERE changes() = 0 AND @GrainStateVersion IS NOT NULL; -'); +') +ON CONFLICT(QueryKey) DO UPDATE SET QueryText = excluded.QueryText; diff --git a/src/AdoNet/Shared/Sqlite-Main.sql b/src/AdoNet/Shared/Sqlite-Main.sql index d1ff212448c..0840d4d9a22 100644 --- a/src/AdoNet/Shared/Sqlite-Main.sql +++ b/src/AdoNet/Shared/Sqlite-Main.sql @@ -27,7 +27,7 @@ Implementation notes: -- These settings improves throughput of the database by reducing locking by better separating readers from writers. -- SQL Server 2012 and newer can refer to itself as CURRENT. Older ones need a workaround. -CREATE TABLE OrleansQuery +CREATE TABLE IF NOT EXISTS OrleansQuery ( QueryKey TEXT PRIMARY KEY, QueryText TEXT NOT NULL diff --git a/test/Extensions/Orleans.AdoNet.Tests/Persistence/SqlitePersistenceQueryTests.cs b/test/Extensions/Orleans.AdoNet.Tests/Persistence/SqlitePersistenceQueryTests.cs index 5b442b31a9e..7c0ddf8d5f2 100644 --- a/test/Extensions/Orleans.AdoNet.Tests/Persistence/SqlitePersistenceQueryTests.cs +++ b/test/Extensions/Orleans.AdoNet.Tests/Persistence/SqlitePersistenceQueryTests.cs @@ -12,9 +12,147 @@ public sealed class SqlitePersistenceQueryTests : IDisposable public SqlitePersistenceQueryTests() { _connection.Open(); + InitializeDatabase(_connection); + } + + [Fact] + public void FailedWriteDoesNotPoisonPooledConnection() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"orleans-sqlite-write-failure-{Guid.NewGuid():N}.db"); + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = databasePath, + DefaultTimeout = 1, + Pooling = true + }.ToString(); + var independentConnectionString = new SqliteConnectionStringBuilder + { + DataSource = databasePath, + DefaultTimeout = 1, + Pooling = false + }.ToString(); + + try + { + using (var connection = new SqliteConnection(connectionString)) + { + connection.Open(); + InitializeDatabase(connection); + Assert.Equal([1], ExecuteQuery(connection, "WriteToStorageKey", null)); + } + + using (var blocker = new SqliteConnection(independentConnectionString)) + { + blocker.Open(); + using (var command = blocker.CreateCommand()) + { + command.CommandText = "BEGIN IMMEDIATE;"; + command.ExecuteNonQuery(); + } + + using (var connection = new SqliteConnection(connectionString)) + { + connection.Open(); + var exception = Assert.Throws(() => ExecuteQuery(connection, "WriteToStorageKey", 1)); + Assert.Equal(5, exception.SqliteErrorCode); + using var begin = connection.CreateCommand(); + begin.CommandText = "BEGIN; ROLLBACK;"; + begin.ExecuteNonQuery(); + } + + using var rollback = blocker.CreateCommand(); + rollback.CommandText = "ROLLBACK;"; + rollback.ExecuteNonQuery(); + } + + using (var connection = new SqliteConnection(connectionString)) + { + connection.Open(); + using (var begin = connection.CreateCommand()) + { + begin.CommandText = "BEGIN; ROLLBACK;"; + begin.ExecuteNonQuery(); + } + + Assert.Equal([2], ExecuteQuery(connection, "ClearStorageKey", 1)); + } + + AssertStoredState(independentConnectionString, 2, null); + + using (var connection = new SqliteConnection(connectionString)) + { + connection.Open(); + Assert.Equal([3], ExecuteQuery(connection, "WriteToStorageKey", 2)); + } + + AssertStoredState(independentConnectionString, 3, "updated"); + } + finally + { + using var connection = new SqliteConnection(connectionString); + SqliteConnection.ClearPool(connection); + File.Delete(databasePath); + } + } + + [Fact] + public void ConflictAfterSuccessfulWritePreservesVersionProgression() + { + Assert.Equal([1], ExecuteQuery("WriteToStorageKey", null)); + Assert.Equal([2], ExecuteQuery("WriteToStorageKey", 1)); + Assert.Equal([99], ExecuteQuery("WriteToStorageKey", 99)); + Assert.Equal([3], ExecuteQuery("WriteToStorageKey", 2)); + Assert.Equal([99], ExecuteQuery("WriteToStorageKey", 99)); + Assert.Equal([(3, "updated")], ReadRows()); + } + + [Fact] + public void ReapplyingScriptsInstallsCurrentQueriesAndPreservesState() + { + var expected = new Dictionary + { + ["WriteToStorageKey"] = GetQuery("WriteToStorageKey"), + ["ReadFromStorageKey"] = GetQuery("ReadFromStorageKey"), + ["ClearStorageKey"] = GetQuery("ClearStorageKey") + }; + InsertRow(7); + + using (var command = _connection.CreateCommand()) + { + command.CommandText = """ + UPDATE OrleansQuery + SET QueryText = 'outdated' + WHERE QueryKey IN ('WriteToStorageKey', 'ReadFromStorageKey', 'ClearStorageKey'); + """; + Assert.Equal(3, command.ExecuteNonQuery()); + } + + InitializeDatabase(_connection); + + Assert.Equal(expected["WriteToStorageKey"], GetQuery("WriteToStorageKey")); + Assert.Equal(expected["ReadFromStorageKey"], GetQuery("ReadFromStorageKey")); + Assert.Equal(expected["ClearStorageKey"], GetQuery("ClearStorageKey")); + Assert.Equal([(7, "original")], ReadRows()); + } + + private static void AssertStoredState(string connectionString, int version, string? payload) + { + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT Version, CAST(PayloadBinary AS TEXT) FROM OrleansStorage"; + using var reader = command.ExecuteReader(); + Assert.True(reader.Read()); + Assert.Equal(version, reader.GetInt32(0)); + Assert.Equal(payload, reader.IsDBNull(1) ? null : reader.GetString(1)); + Assert.False(reader.Read()); + } + + private static void InitializeDatabase(SqliteConnection connection) + { foreach (var script in new[] { "Sqlite-Main.sql", "Sqlite-Persistence.sql" }) { - using var command = _connection.CreateCommand(); + using var command = connection.CreateCommand(); command.CommandText = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, script)); command.ExecuteNonQuery(); } @@ -142,13 +280,12 @@ public void HashCollisionPreservesOtherIdentity(string queryKey, string identity } private List ExecuteQuery(string queryKey, int? version, string? extension = null) - { - using var lookup = _connection.CreateCommand(); - lookup.CommandText = "SELECT QueryText FROM OrleansQuery WHERE QueryKey = @QueryKey"; - lookup.Parameters.AddWithValue("QueryKey", queryKey); + => ExecuteQuery(_connection, queryKey, version, extension); - using var command = _connection.CreateCommand(); - command.CommandText = Assert.IsType(lookup.ExecuteScalar()); + private static List ExecuteQuery(SqliteConnection connection, string queryKey, int? version, string? extension = null) + { + using var command = connection.CreateCommand(); + command.CommandText = GetQuery(connection, queryKey); AddParameters(command, version, extension); using var reader = command.ExecuteReader(); @@ -167,6 +304,16 @@ private List ExecuteQuery(string queryKey, int? version, string? extension return versions; } + private string GetQuery(string queryKey) => GetQuery(_connection, queryKey); + + private static string GetQuery(SqliteConnection connection, string queryKey) + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT QueryText FROM OrleansQuery WHERE QueryKey = @QueryKey"; + command.Parameters.AddWithValue("QueryKey", queryKey); + return Assert.IsType(command.ExecuteScalar()); + } + private void InsertRow(int? version, string? extension = null, string? differentIdentityColumn = null) { using var command = _connection.CreateCommand();