Wire selectable persistence backend for cloud state stores
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));
|
||||
services.AddSingleton<IPersonalMemoryStore>(_ => 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<IPersistenceSnapshotStoreFactory, PersistenceSnapshotStoreFactory>();
|
||||
services.AddSingleton<ICloudStateStore>(provider =>
|
||||
{
|
||||
var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>();
|
||||
return new InMemoryCloudStateStore(snapshotFactory.Create(statePersistencePath, stateBackendKind, "cloud-state", stateConnectionString));
|
||||
});
|
||||
services.AddSingleton<IPersonalMemoryStore>(provider =>
|
||||
{
|
||||
var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>();
|
||||
return new InMemoryPersonalMemoryStore(snapshotFactory.Create(personalMemoryPersistencePath, personalMemoryBackendKind, "personal-memory", personalMemoryConnectionString));
|
||||
});
|
||||
services.AddSingleton<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>();
|
||||
services.AddSingleton<JiboExperienceContentCache>();
|
||||
services.AddSingleton<IJiboRandomizer, DefaultJiboRandomizer>();
|
||||
@@ -78,4 +91,11 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static PersistenceBackendKind ParseBackendKind(string? value)
|
||||
{
|
||||
return Enum.TryParse<PersistenceBackendKind>(value, ignoreCase: true, out var backendKind)
|
||||
? backendKind
|
||||
: PersistenceBackendKind.File;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -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<TSnapshot>() 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<TSnapshot>(json, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public void Save<TSnapshot>(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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Jibo.Cloud.Infrastructure.Persistence;
|
||||
|
||||
public interface IPersistenceSnapshotStoreFactory
|
||||
{
|
||||
ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName, string? connectionString = null);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Jibo.Cloud.Infrastructure.Persistence;
|
||||
|
||||
public enum PersistenceBackendKind
|
||||
{
|
||||
File,
|
||||
AzureSql
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<InvalidOperationException>(() =>
|
||||
factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"), PersistenceBackendKind.AzureSql, "sample-snapshot"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PersonalMemoryStore_CanUseAlternateSnapshotBackend()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user