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)
|
9. Durable memory persistence path (multi-tenant backing store)
|
||||||
- reference design captured in `docs/persistence-architecture.md`
|
- 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
|
- 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
|
10. Update, backup, and restore proof
|
||||||
11. STT upgrade and noise screening
|
11. STT upgrade and noise screening
|
||||||
12. Hosted capture/storage plan / indexing for group testing
|
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
|
- 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
|
- 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 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
|
### 6. Multi-Server Sync Path
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,21 @@ public static class ServiceCollectionExtensions
|
|||||||
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
|
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
|
||||||
var personalMemoryPersistencePath = configuration?["OpenJibo:PersonalMemory:PersistencePath"]
|
var personalMemoryPersistencePath = configuration?["OpenJibo:PersonalMemory:PersistencePath"]
|
||||||
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "personal-memory.json");
|
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "personal-memory.json");
|
||||||
services.AddSingleton<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));
|
var stateBackendKind = ParseBackendKind(configuration?["OpenJibo:State:Backend"]);
|
||||||
services.AddSingleton<IPersonalMemoryStore>(_ => new InMemoryPersonalMemoryStore(personalMemoryPersistencePath));
|
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<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>();
|
||||||
services.AddSingleton<JiboExperienceContentCache>();
|
services.AddSingleton<JiboExperienceContentCache>();
|
||||||
services.AddSingleton<IJiboRandomizer, DefaultJiboRandomizer>();
|
services.AddSingleton<IJiboRandomizer, DefaultJiboRandomizer>();
|
||||||
@@ -78,4 +91,11 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
return services;
|
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>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<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
|
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]
|
[Fact]
|
||||||
public void PersonalMemoryStore_CanUseAlternateSnapshotBackend()
|
public void PersonalMemoryStore_CanUseAlternateSnapshotBackend()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user