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

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

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

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