diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index 5061b49..587ee77 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -889,7 +889,8 @@ For `1.0.19`: 9. Durable memory persistence path (multi-tenant backing store) - reference design captured in `docs/persistence-architecture.md` - store contracts are now tightened around account/loop/device/person scoping, revision tracking, and explicit load/save boundaries - - next implementation pass should split the in-memory adapters from the eventual Azure-backed adapters while keeping the application seam stable + - the backend seam is now selectable, with file-backed local persistence as default and an Azure SQL slot wired for future deployment when credentials are available + - next implementation pass should supply the real Azure SQL connection string / deployment wiring and validate the live round-trip in Azure 10. Update, backup, and restore proof 11. STT upgrade and noise screening 12. Hosted capture/storage plan / indexing for group testing diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index 916fd7f..44fede4 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -95,6 +95,7 @@ The goal is to port these in small batches, capture the source-backed phrasing w - seed person-aware state keys now so future interactions can scope to account + loop + device + person without another shape change - keep stateful interaction flows repository-backed instead of embedding more ad hoc metadata in the websocket layer - the store seam now exposes revision metadata plus explicit load/save boundaries so durable adapters can drop in later without changing behavior code +- the backend seam is now selectable, with file-backed local persistence as the default and an Azure SQL slot wired for future deployment wiring ### 6. Multi-Server Sync Path diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index 8e52b20..4cafbcf 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -56,8 +56,21 @@ public static class ServiceCollectionExtensions ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); var personalMemoryPersistencePath = configuration?["OpenJibo:PersonalMemory:PersistencePath"] ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "personal-memory.json"); - services.AddSingleton(_ => new InMemoryCloudStateStore(statePersistencePath)); - services.AddSingleton(_ => new InMemoryPersonalMemoryStore(personalMemoryPersistencePath)); + var stateBackendKind = ParseBackendKind(configuration?["OpenJibo:State:Backend"]); + var personalMemoryBackendKind = ParseBackendKind(configuration?["OpenJibo:PersonalMemory:Backend"]); + var stateConnectionString = configuration?["OpenJibo:State:ConnectionString"] ?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_SQL_CONNECTION_STRING"); + var personalMemoryConnectionString = configuration?["OpenJibo:PersonalMemory:ConnectionString"] ?? Environment.GetEnvironmentVariable("OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING"); + services.AddSingleton(); + services.AddSingleton(provider => + { + var snapshotFactory = provider.GetRequiredService(); + return new InMemoryCloudStateStore(snapshotFactory.Create(statePersistencePath, stateBackendKind, "cloud-state", stateConnectionString)); + }); + services.AddSingleton(provider => + { + var snapshotFactory = provider.GetRequiredService(); + return new InMemoryPersonalMemoryStore(snapshotFactory.Create(personalMemoryPersistencePath, personalMemoryBackendKind, "personal-memory", personalMemoryConnectionString)); + }); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -78,4 +91,11 @@ public static class ServiceCollectionExtensions return services; } + + private static PersistenceBackendKind ParseBackendKind(string? value) + { + return Enum.TryParse(value, ignoreCase: true, out var backendKind) + ? backendKind + : PersistenceBackendKind.File; + } } diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Jibo.Cloud.Infrastructure.csproj b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Jibo.Cloud.Infrastructure.csproj index c6fab4e..b3bdced 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Jibo.Cloud.Infrastructure.csproj +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Jibo.Cloud.Infrastructure.csproj @@ -24,6 +24,10 @@ + + + + net10.0 enable diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/AzureSqlSnapshotStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/AzureSqlSnapshotStore.cs new file mode 100644 index 0000000..75f1c96 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/AzureSqlSnapshotStore.cs @@ -0,0 +1,101 @@ +using System.Data; +using System.Text.Json; +using Microsoft.Data.SqlClient; + +namespace Jibo.Cloud.Infrastructure.Persistence; + +internal sealed class AzureSqlSnapshotStore : ISnapshotStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + private readonly string _connectionString; + private readonly string _snapshotName; + private readonly string _tableName; + + public AzureSqlSnapshotStore(string connectionString, string snapshotName, string tableName = "PersistenceSnapshots") + { + _connectionString = string.IsNullOrWhiteSpace(connectionString) + ? throw new InvalidOperationException("Azure SQL persistence requires a connection string.") + : connectionString; + _snapshotName = string.IsNullOrWhiteSpace(snapshotName) + ? throw new ArgumentException("A snapshot name is required for Azure SQL persistence.", nameof(snapshotName)) + : snapshotName; + _tableName = string.IsNullOrWhiteSpace(tableName) ? "PersistenceSnapshots" : tableName; + } + + public TSnapshot? Load() where TSnapshot : class + { + using var connection = new SqlConnection(_connectionString); + connection.Open(); + EnsureTable(connection); + + using var command = connection.CreateCommand(); + command.CommandText = $""" + SELECT SnapshotJson + FROM dbo.[{_tableName}] + WHERE SnapshotName = @snapshotName + """; + command.Parameters.Add(new SqlParameter("@snapshotName", SqlDbType.NVarChar, 200) { Value = _snapshotName }); + + var result = command.ExecuteScalar(); + if (result is not string json || string.IsNullOrWhiteSpace(json)) + { + return default; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch + { + return default; + } + } + + public void Save(TSnapshot snapshot) where TSnapshot : class + { + using var connection = new SqlConnection(_connectionString); + connection.Open(); + EnsureTable(connection); + + var json = JsonSerializer.Serialize(snapshot, JsonOptions); + + using var command = connection.CreateCommand(); + command.CommandText = $""" + MERGE dbo.[{_tableName}] AS target + USING (SELECT @snapshotName AS SnapshotName) AS source + ON target.SnapshotName = source.SnapshotName + WHEN MATCHED THEN + UPDATE SET SnapshotJson = @snapshotJson, + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (SnapshotName, SnapshotJson, CreatedUtc, UpdatedUtc) + VALUES (@snapshotName, @snapshotJson, SYSUTCDATETIME(), SYSUTCDATETIME()); + """; + command.Parameters.Add(new SqlParameter("@snapshotName", SqlDbType.NVarChar, 200) { Value = _snapshotName }); + command.Parameters.Add(new SqlParameter("@snapshotJson", SqlDbType.NVarChar, -1) { Value = json }); + command.ExecuteNonQuery(); + } + + private void EnsureTable(SqlConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = $""" + IF OBJECT_ID(N'dbo.[{_tableName}]', N'U') IS NULL + BEGIN + CREATE TABLE dbo.[{_tableName}] ( + SnapshotName nvarchar(200) NOT NULL CONSTRAINT PK_{_tableName}_SnapshotName PRIMARY KEY, + SnapshotJson nvarchar(max) NOT NULL, + CreatedUtc datetimeoffset NOT NULL, + UpdatedUtc datetimeoffset NOT NULL + ); + END + """; + command.ExecuteNonQuery(); + } +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/IPersistenceSnapshotStoreFactory.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/IPersistenceSnapshotStoreFactory.cs new file mode 100644 index 0000000..2811651 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/IPersistenceSnapshotStoreFactory.cs @@ -0,0 +1,6 @@ +namespace Jibo.Cloud.Infrastructure.Persistence; + +public interface IPersistenceSnapshotStoreFactory +{ + ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName, string? connectionString = null); +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceBackendKind.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceBackendKind.cs new file mode 100644 index 0000000..fd5c47a --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceBackendKind.cs @@ -0,0 +1,7 @@ +namespace Jibo.Cloud.Infrastructure.Persistence; + +public enum PersistenceBackendKind +{ + File, + AzureSql +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceSnapshotStoreFactory.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceSnapshotStoreFactory.cs new file mode 100644 index 0000000..aac9a58 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/PersistenceSnapshotStoreFactory.cs @@ -0,0 +1,24 @@ +using System.Text.Json; + +namespace Jibo.Cloud.Infrastructure.Persistence; + +public sealed class PersistenceSnapshotStoreFactory : IPersistenceSnapshotStoreFactory +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + public ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName, string? connectionString = null) + { + return backendKind switch + { + PersistenceBackendKind.File => new JsonFileSnapshotStore(persistencePath, JsonOptions), + PersistenceBackendKind.AzureSql => new AzureSqlSnapshotStore( + connectionString ?? throw new InvalidOperationException("Azure SQL persistence requires a connection string."), + snapshotName), + _ => new JsonFileSnapshotStore(persistencePath, JsonOptions) + }; + } +} diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs index f25b6a8..7ba6924 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs @@ -5,6 +5,25 @@ namespace Jibo.Cloud.Tests.Infrastructure; public sealed class PersistenceStoreTests { + [Fact] + public void SnapshotStoreFactory_DefaultsToFileBackend() + { + var factory = new PersistenceSnapshotStoreFactory(); + + var store = factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"), PersistenceBackendKind.File, "sample-snapshot"); + + Assert.Equal("JsonFileSnapshotStore", store.GetType().Name); + } + + [Fact] + public void SnapshotStoreFactory_AzureBackendIsExplicitlyUnavailable() + { + var factory = new PersistenceSnapshotStoreFactory(); + + Assert.Throws(() => + factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"), PersistenceBackendKind.AzureSql, "sample-snapshot")); + } + [Fact] public void PersonalMemoryStore_CanUseAlternateSnapshotBackend() {