Wire selectable persistence backend for cloud state stores

This commit is contained in:
Jacob Dubin
2026-05-17 07:15:12 -05:00
parent 888f472f69
commit 478a320581
9 changed files with 186 additions and 3 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}
} }

View 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>

View File

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

View File

@@ -0,0 +1,6 @@
namespace Jibo.Cloud.Infrastructure.Persistence;
public interface IPersistenceSnapshotStoreFactory
{
ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName, string? connectionString = null);
}

View File

@@ -0,0 +1,7 @@
namespace Jibo.Cloud.Infrastructure.Persistence;
public enum PersistenceBackendKind
{
File,
AzureSql
}

View File

@@ -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)
};
}
}

View File

@@ -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()
{ {