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();