Add persistence seams for durable state adapters

This commit is contained in:
Jacob Dubin
2026-05-17 00:47:36 -05:00
parent 5d57095ce5
commit d37521281e
8 changed files with 571 additions and 87 deletions

View File

@@ -888,7 +888,8 @@ For `1.0.19`:
8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation 8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation
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`
- next implementation pass should tighten the store contracts around account/loop/device/person scoping and record versioning - 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
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

@@ -94,6 +94,7 @@ The goal is to port these in small batches, capture the source-backed phrasing w
- implement memory-ready schemas and repository contracts for user facts (names, birthdays, personal dates, preferences) with strict tenant scoping - implement memory-ready schemas and repository contracts for user facts (names, birthdays, personal dates, preferences) with strict tenant scoping
- 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
### 6. Multi-Server Sync Path ### 6. Multi-Server Sync Path

View File

@@ -4,6 +4,9 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface ICloudStateStore public interface ICloudStateStore
{ {
PersistenceStateInfo GetPersistenceStateInfo();
void LoadPersistedState();
void SavePersistedState();
AccountProfile GetAccount(); AccountProfile GetAccount();
DeviceRegistration GetRobot(); DeviceRegistration GetRobot();
RobotProfile GetRobotProfile(); RobotProfile GetRobotProfile();

View File

@@ -2,6 +2,9 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface IPersonalMemoryStore public interface IPersonalMemoryStore
{ {
PersistenceStateInfo GetPersistenceStateInfo();
void LoadPersistedState();
void SavePersistedState();
void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText); void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText);
string? GetBirthday(PersonalMemoryTenantScope tenantScope); string? GetBirthday(PersonalMemoryTenantScope tenantScope);
void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value); void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value);
@@ -20,6 +23,12 @@ public interface IPersonalMemoryStore
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId, string? PersonId = null); public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId, string? PersonId = null);
public sealed record PersistenceStateInfo(
string SchemaVersion,
long Revision,
DateTimeOffset? LastLoadedUtc = null,
DateTimeOffset? LastSavedUtc = null);
public enum PersonalAffinity public enum PersonalAffinity
{ {
Like, Like,

View File

@@ -54,8 +54,10 @@ public static class ServiceCollectionExtensions
services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>(); services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>();
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"] var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); ?? 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<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));
services.AddSingleton<IPersonalMemoryStore, InMemoryPersonalMemoryStore>(); services.AddSingleton<IPersonalMemoryStore>(_ => new InMemoryPersonalMemoryStore(personalMemoryPersistencePath));
services.AddSingleton<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>(); services.AddSingleton<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>();
services.AddSingleton<JiboExperienceContentCache>(); services.AddSingleton<JiboExperienceContentCache>();
services.AddSingleton<IJiboRandomizer, DefaultJiboRandomizer>(); services.AddSingleton<IJiboRandomizer, DefaultJiboRandomizer>();

View File

@@ -9,10 +9,11 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
{ {
private static readonly JsonSerializerOptions PersistenceJsonOptions = new() private static readonly JsonSerializerOptions PersistenceJsonOptions = new()
{ {
WriteIndented = true WriteIndented = true,
PropertyNameCaseInsensitive = true
}; };
private readonly AccountProfile _account = new(); private AccountProfile _account = new();
private readonly ConcurrentDictionary<string, DeviceRegistration> _devices = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, DeviceRegistration> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, CloudSession> _sessionsByToken = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, CloudSession> _sessionsByToken = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase);
@@ -26,6 +27,9 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
private readonly List<PersonRecord> _people; private readonly List<PersonRecord> _people;
private DeviceRegistration _robot; private DeviceRegistration _robot;
private RobotProfile _robotProfile; private RobotProfile _robotProfile;
private long _revision;
private DateTimeOffset? _lastLoadedUtc;
private DateTimeOffset? _lastSavedUtc;
public InMemoryCloudStateStore(string? persistencePath = null) public InMemoryCloudStateStore(string? persistencePath = null)
{ {
@@ -86,7 +90,149 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
]; ];
_updates = []; _updates = [];
LoadPersistentState(); LoadPersistedState();
}
public PersistenceStateInfo GetPersistenceStateInfo()
{
return new PersistenceStateInfo(
SchemaVersion: CurrentSchemaVersion,
Revision: Interlocked.Read(ref _revision),
LastLoadedUtc: _lastLoadedUtc,
LastSavedUtc: _lastSavedUtc);
}
public void LoadPersistedState()
{
if (string.IsNullOrWhiteSpace(_persistencePath) || !File.Exists(_persistencePath))
{
return;
}
try
{
var snapshot = JsonSerializer.Deserialize<PersistentStateSnapshot>(File.ReadAllText(_persistencePath), PersistenceJsonOptions);
if (snapshot is null)
{
return;
}
_account = snapshot.Account ?? _account;
_robot = snapshot.Robot ?? _robot;
_robotProfile = snapshot.RobotProfile ?? _robotProfile;
_devices.Clear();
foreach (var device in snapshot.Devices ?? [])
{
_devices[device.DeviceId] = device;
}
if (_devices.IsEmpty || !_devices.ContainsKey(_robot.DeviceId))
{
_devices[_robot.DeviceId] = _robot;
}
_sessionsByToken.Clear();
foreach (var session in snapshot.Sessions ?? [])
{
if (!string.IsNullOrWhiteSpace(session.Token))
{
_sessionsByToken[session.Token] = session.ToRecord();
}
}
_symmetricKeys.Clear();
foreach (var pair in snapshot.SymmetricKeys ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase))
{
_symmetricKeys[pair.Key] = pair.Value;
}
_keyRequests.Clear();
foreach (var keyRequest in snapshot.KeyRequests ?? [])
{
_keyRequests[keyRequest.RequestId] = keyRequest;
}
_updates.Clear();
_updates.AddRange(snapshot.Updates ?? []);
_media.Clear();
_media.AddRange(snapshot.Media ?? []);
_backups.Clear();
_backups.AddRange(snapshot.Backups ?? []);
_loops.Clear();
_loops.AddRange(snapshot.Loops ?? []);
_people.Clear();
_people.AddRange(snapshot.People ?? []);
if (_robotProfile is null || !string.Equals(_robotProfile.RobotId, _robot.RobotId, StringComparison.OrdinalIgnoreCase))
{
_robotProfile = new RobotProfile
{
RobotId = _robot.RobotId,
Payload = new Dictionary<string, object?>
{
["SSID"] = "my-ssid",
["connectedAt"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
["platform"] = _robot.FirmwareVersion ?? "12.10.0",
["serialNumber"] = _robot.DeviceId
},
UpdatedUtc = DateTimeOffset.UtcNow
};
}
Interlocked.Exchange(ref _revision, snapshot.Revision);
_lastLoadedUtc = snapshot.LastLoadedUtc ?? DateTimeOffset.UtcNow;
_lastSavedUtc = snapshot.LastSavedUtc;
}
catch
{
// Ignore corrupt state and continue with the in-memory defaults.
}
}
public void SavePersistedState()
{
if (string.IsNullOrWhiteSpace(_persistencePath))
{
return;
}
lock (_syncRoot)
{
var directory = Path.GetDirectoryName(_persistencePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var now = DateTimeOffset.UtcNow;
var snapshot = new PersistentStateSnapshot
{
SchemaVersion = CurrentSchemaVersion,
Revision = Interlocked.Read(ref _revision),
LastLoadedUtc = _lastLoadedUtc,
LastSavedUtc = now,
Account = _account,
Robot = _robot,
RobotProfile = _robotProfile,
Devices = _devices.Values.ToArray(),
Sessions = _sessionsByToken.Values.Select(MapSessionSnapshot).ToArray(),
SymmetricKeys = _symmetricKeys.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase),
KeyRequests = _keyRequests.Values.ToArray(),
Updates = _updates.ToArray(),
Media = _media.ToArray(),
Backups = _backups.ToArray(),
Loops = _loops.ToArray(),
People = _people.ToArray()
};
File.WriteAllText(_persistencePath, JsonSerializer.Serialize(snapshot, PersistenceJsonOptions));
_lastSavedUtc = now;
}
} }
public AccountProfile GetAccount() => _account; public AccountProfile GetAccount() => _account;
@@ -97,7 +243,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion) public DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion)
{ {
return _devices.AddOrUpdate( var device = _devices.AddOrUpdate(
deviceId, deviceId,
_ => new DeviceRegistration _ => new DeviceRegistration
{ {
@@ -114,8 +260,11 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
FriendlyName = current.FriendlyName, FriendlyName = current.FriendlyName,
FirmwareVersion = firmwareVersion ?? current.FirmwareVersion, FirmwareVersion = firmwareVersion ?? current.FirmwareVersion,
ApplicationVersion = applicationVersion ?? current.ApplicationVersion, ApplicationVersion = applicationVersion ?? current.ApplicationVersion,
HostMappings = current.HostMappings HostMappings = new Dictionary<string, string>(current.HostMappings, StringComparer.OrdinalIgnoreCase)
}); });
TouchState();
return device;
} }
public string IssueHubToken() public string IssueHubToken()
@@ -130,6 +279,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
Metadata = BuildSessionMetadata(_account.AccountId, _robot.DeviceId, ResolveDefaultLoopId()) Metadata = BuildSessionMetadata(_account.AccountId, _robot.DeviceId, ResolveDefaultLoopId())
}; };
TouchState();
return token; return token;
} }
@@ -145,6 +295,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
Metadata = BuildSessionMetadata(_account.AccountId, deviceId, ResolveDefaultLoopId()) Metadata = BuildSessionMetadata(_account.AccountId, deviceId, ResolveDefaultLoopId())
}; };
TouchState();
return token; return token;
} }
@@ -166,6 +317,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
if (!string.IsNullOrWhiteSpace(token)) if (!string.IsNullOrWhiteSpace(token))
{ {
_sessionsByToken[token] = session; _sessionsByToken[token] = session;
TouchState();
} }
return session; return session;
@@ -210,7 +362,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
}; };
_updates.Add(update); _updates.Add(update);
PersistState(); TouchState();
return update; return update;
} }
@@ -218,6 +370,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
{ {
var existing = _updates.FirstOrDefault(update => update.UpdateId == updateId); var existing = _updates.FirstOrDefault(update => update.UpdateId == updateId);
if (existing is null) if (existing is null)
{
return new UpdateManifest return new UpdateManifest
{ {
UpdateId = updateId ?? "unknown-update", UpdateId = updateId ?? "unknown-update",
@@ -226,11 +379,11 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
ShaHash = "missing", ShaHash = "missing",
Subsystem = "unknown" Subsystem = "unknown"
}; };
}
_updates.Remove(existing); _updates.Remove(existing);
PersistState(); TouchState();
return existing; return existing;
} }
public IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null, long? before = null) public IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null, long? before = null)
@@ -278,7 +431,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
if (replacements.Count > 0) if (replacements.Count > 0)
{ {
PersistState(); TouchState();
} }
return replacements; return replacements;
@@ -308,7 +461,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
_media.Add(item); _media.Add(item);
} }
PersistState(); TouchState();
return item; return item;
} }
@@ -318,7 +471,19 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public string GetOrCreateSymmetricKey(string loopId) public string GetOrCreateSymmetricKey(string loopId)
{ {
return _symmetricKeys.GetOrAdd(loopId, key => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"open-jibo-symmetric-key:{key}"))); if (_symmetricKeys.TryGetValue(loopId, out var existing))
{
return existing;
}
var key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"open-jibo-symmetric-key:{loopId}"));
if (_symmetricKeys.TryAdd(loopId, key))
{
TouchState();
return key;
}
return _symmetricKeys[loopId];
} }
public KeyRequestRecord CreateKeyRequest(string loopId, string publicKey) public KeyRequestRecord CreateKeyRequest(string loopId, string publicKey)
@@ -331,6 +496,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
}; };
_keyRequests[record.RequestId] = record; _keyRequests[record.RequestId] = record;
TouchState();
return record; return record;
} }
@@ -390,77 +556,25 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
}, },
UpdatedUtc = DateTimeOffset.UtcNow UpdatedUtc = DateTimeOffset.UtcNow
}; };
PersistState(); TouchState();
} }
private void LoadPersistentState() private void TouchState()
{ {
if (string.IsNullOrWhiteSpace(_persistencePath) || !File.Exists(_persistencePath)) Interlocked.Increment(ref _revision);
{ SavePersistedState();
return;
}
try
{
var snapshot = JsonSerializer.Deserialize<PersistentStateSnapshot>(File.ReadAllText(_persistencePath), PersistenceJsonOptions);
if (snapshot is null)
{
return;
}
_updates.Clear();
_updates.AddRange(snapshot.Updates ?? []);
_media.Clear();
_media.AddRange(snapshot.Media ?? []);
_backups.Clear();
_backups.AddRange(snapshot.Backups ?? []);
}
catch
{
// Ignore corrupt state and continue with the in-memory defaults.
}
} }
private void PersistState() private static string ResolveDefaultLoopId(IReadOnlyList<LoopRecord> loops, AccountProfile account)
{ {
if (string.IsNullOrWhiteSpace(_persistencePath)) return loops.FirstOrDefault(loop => string.Equals(loop.OwnerAccountId, account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId
{ ?? loops.FirstOrDefault()?.LoopId
return; ?? "openjibo-default-loop";
}
lock (_syncRoot)
{
var directory = Path.GetDirectoryName(_persistencePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var snapshot = new PersistentStateSnapshot
{
Updates = _updates.ToArray(),
Media = _media.ToArray(),
Backups = _backups.ToArray()
};
File.WriteAllText(_persistencePath, JsonSerializer.Serialize(snapshot, PersistenceJsonOptions));
}
}
private sealed class PersistentStateSnapshot
{
public UpdateManifest[]? Updates { get; init; }
public MediaRecord[]? Media { get; init; }
public BackupRecord[]? Backups { get; init; }
} }
private string ResolveDefaultLoopId() private string ResolveDefaultLoopId()
{ {
return _loops.FirstOrDefault(loop => string.Equals(loop.OwnerAccountId, _account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId return ResolveDefaultLoopId(_loops, _account);
?? _loops.FirstOrDefault()?.LoopId
?? "openjibo-default-loop";
} }
private static IDictionary<string, object?> BuildSessionMetadata(string accountId, string? deviceId, string loopId) private static IDictionary<string, object?> BuildSessionMetadata(string accountId, string? deviceId, string loopId)
@@ -472,4 +586,92 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
["deviceId"] = deviceId ["deviceId"] = deviceId
}; };
} }
private static CloudSessionSnapshot MapSessionSnapshot(CloudSession session)
{
return new CloudSessionSnapshot
{
SessionId = session.SessionId,
Kind = session.Kind,
AccountId = session.AccountId,
DeviceId = session.DeviceId,
Token = session.Token,
HostName = session.HostName,
Path = session.Path,
CreatedUtc = session.CreatedUtc,
LastSeenUtc = session.LastSeenUtc,
FollowUpExpiresUtc = session.FollowUpExpiresUtc,
LastMessageType = session.LastMessageType,
LastListenType = session.LastListenType,
LastIntent = session.LastIntent,
LastTranscript = session.LastTranscript,
LastTransId = session.LastTransId,
Metadata = session.Metadata
};
}
private const string CurrentSchemaVersion = "1";
private sealed class PersistentStateSnapshot
{
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
public long Revision { get; init; }
public DateTimeOffset? LastLoadedUtc { get; init; }
public DateTimeOffset? LastSavedUtc { get; init; }
public AccountProfile? Account { get; init; }
public DeviceRegistration? Robot { get; init; }
public RobotProfile? RobotProfile { get; init; }
public DeviceRegistration[]? Devices { get; init; }
public CloudSessionSnapshot[]? Sessions { get; init; }
public Dictionary<string, string>? SymmetricKeys { get; init; }
public KeyRequestRecord[]? KeyRequests { get; init; }
public UpdateManifest[]? Updates { get; init; }
public MediaRecord[]? Media { get; init; }
public BackupRecord[]? Backups { get; init; }
public LoopRecord[]? Loops { get; init; }
public PersonRecord[]? People { get; init; }
}
private sealed class CloudSessionSnapshot
{
public string SessionId { get; init; } = Guid.NewGuid().ToString("N");
public string Kind { get; init; } = "http";
public string? AccountId { get; init; }
public string? DeviceId { get; init; }
public string? Token { get; init; }
public string? HostName { get; init; }
public string? Path { get; init; }
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset LastSeenUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset? FollowUpExpiresUtc { get; init; }
public string? LastMessageType { get; init; }
public string? LastListenType { get; init; }
public string? LastIntent { get; init; }
public string? LastTranscript { get; init; }
public string? LastTransId { get; init; }
public IDictionary<string, object?> Metadata { get; init; } = new Dictionary<string, object?>();
public CloudSession ToRecord()
{
return new CloudSession
{
SessionId = SessionId,
Kind = Kind,
AccountId = AccountId,
DeviceId = DeviceId,
Token = Token,
HostName = HostName,
Path = Path,
CreatedUtc = CreatedUtc,
LastSeenUtc = LastSeenUtc,
FollowUpExpiresUtc = FollowUpExpiresUtc,
LastMessageType = LastMessageType,
LastListenType = LastListenType,
LastIntent = LastIntent,
LastTranscript = LastTranscript,
LastTransId = LastTransId,
Metadata = new Dictionary<string, object?>(Metadata, StringComparer.OrdinalIgnoreCase)
};
}
}
} }

View File

@@ -1,17 +1,119 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Abstractions;
namespace Jibo.Cloud.Infrastructure.Persistence; namespace Jibo.Cloud.Infrastructure.Persistence;
public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
{ {
private static readonly JsonSerializerOptions PersistenceJsonOptions = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
private readonly ConcurrentDictionary<string, TenantMemoryRecord> _tenantMemory = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, TenantMemoryRecord> _tenantMemory = new(StringComparer.OrdinalIgnoreCase);
private readonly string? _persistencePath;
private readonly Lock _syncRoot = new();
private long _revision;
private DateTimeOffset? _lastLoadedUtc;
private DateTimeOffset? _lastSavedUtc;
public InMemoryPersonalMemoryStore(string? persistencePath = null)
{
_persistencePath = persistencePath;
LoadPersistedState();
}
public PersistenceStateInfo GetPersistenceStateInfo()
{
return new PersistenceStateInfo(
SchemaVersion: CurrentSchemaVersion,
Revision: Interlocked.Read(ref _revision),
LastLoadedUtc: _lastLoadedUtc,
LastSavedUtc: _lastSavedUtc);
}
public void LoadPersistedState()
{
if (string.IsNullOrWhiteSpace(_persistencePath) || !File.Exists(_persistencePath))
{
return;
}
try
{
var snapshot = JsonSerializer.Deserialize<PersistentStateSnapshot>(File.ReadAllText(_persistencePath), PersistenceJsonOptions);
if (snapshot is null)
{
return;
}
_tenantMemory.Clear();
foreach (var tenant in snapshot.Tenants ?? [])
{
_tenantMemory[tenant.TenantKey] = tenant.ToRecord();
}
Interlocked.Exchange(ref _revision, snapshot.Revision);
_lastLoadedUtc = snapshot.LastLoadedUtc ?? DateTimeOffset.UtcNow;
_lastSavedUtc = snapshot.LastSavedUtc;
}
catch
{
// Ignore corrupt state and continue with the in-memory defaults.
}
}
public void SavePersistedState()
{
if (string.IsNullOrWhiteSpace(_persistencePath))
{
return;
}
lock (_syncRoot)
{
var directory = Path.GetDirectoryName(_persistencePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var now = DateTimeOffset.UtcNow;
var snapshot = new PersistentStateSnapshot
{
SchemaVersion = CurrentSchemaVersion,
Revision = Interlocked.Read(ref _revision),
LastLoadedUtc = _lastLoadedUtc,
LastSavedUtc = now,
Tenants = _tenantMemory
.Select(pair => new TenantMemorySnapshot
{
TenantKey = pair.Key,
Birthday = pair.Value.Birthday,
Name = pair.Value.Name,
Preferences = pair.Value.Preferences.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase),
ImportantDates = pair.Value.ImportantDates.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase),
Affinities = pair.Value.Affinities.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase),
Lists = pair.Value.Lists.ToDictionary(
entry => entry.Key,
entry => entry.Value.ToArray(),
StringComparer.OrdinalIgnoreCase)
})
.ToArray()
};
File.WriteAllText(_persistencePath, JsonSerializer.Serialize(snapshot, PersistenceJsonOptions));
_lastSavedUtc = now;
}
}
public void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText) public void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText)
{ {
var key = BuildTenantKey(tenantScope); var record = GetOrCreateTenantRecord(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
record.Birthday = birthdayText; record.Birthday = birthdayText;
TouchState();
} }
public string? GetBirthday(PersonalMemoryTenantScope tenantScope) public string? GetBirthday(PersonalMemoryTenantScope tenantScope)
@@ -22,9 +124,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
public void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value) public void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value)
{ {
var key = BuildTenantKey(tenantScope); var record = GetOrCreateTenantRecord(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
record.Preferences[NormalizeCategory(category)] = value; record.Preferences[NormalizeCategory(category)] = value;
TouchState();
} }
public string? GetPreference(PersonalMemoryTenantScope tenantScope, string category) public string? GetPreference(PersonalMemoryTenantScope tenantScope, string category)
@@ -38,9 +140,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
public void SetName(PersonalMemoryTenantScope tenantScope, string name) public void SetName(PersonalMemoryTenantScope tenantScope, string name)
{ {
var key = BuildTenantKey(tenantScope); var record = GetOrCreateTenantRecord(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
record.Name = name; record.Name = name;
TouchState();
} }
public string? GetName(PersonalMemoryTenantScope tenantScope) public string? GetName(PersonalMemoryTenantScope tenantScope)
@@ -51,9 +153,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
public void SetImportantDate(PersonalMemoryTenantScope tenantScope, string label, string value) public void SetImportantDate(PersonalMemoryTenantScope tenantScope, string label, string value)
{ {
var key = BuildTenantKey(tenantScope); var record = GetOrCreateTenantRecord(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
record.ImportantDates[NormalizeCategory(label)] = value; record.ImportantDates[NormalizeCategory(label)] = value;
TouchState();
} }
public string? GetImportantDate(PersonalMemoryTenantScope tenantScope, string label) public string? GetImportantDate(PersonalMemoryTenantScope tenantScope, string label)
@@ -67,9 +169,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
public void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity) public void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity)
{ {
var key = BuildTenantKey(tenantScope); var record = GetOrCreateTenantRecord(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
record.Affinities[NormalizeCategory(item)] = affinity; record.Affinities[NormalizeCategory(item)] = affinity;
TouchState();
} }
public PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item) public PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item)
@@ -101,8 +203,8 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
return; return;
} }
var key = BuildTenantKey(tenantScope); var record = GetOrCreateTenantRecord(tenantScope);
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord()); var changed = false;
lock (record.SyncRoot) lock (record.SyncRoot)
{ {
var list = record.Lists.GetOrAdd(normalizedListName, static _ => []); var list = record.Lists.GetOrAdd(normalizedListName, static _ => []);
@@ -112,6 +214,12 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
} }
list.Add(normalizedItem); list.Add(normalizedItem);
changed = true;
}
if (changed)
{
TouchState();
} }
} }
@@ -140,10 +248,28 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
return; return;
} }
var changed = false;
lock (record.SyncRoot) lock (record.SyncRoot)
{ {
record.Lists.TryRemove(NormalizeCategory(listName), out _); changed = record.Lists.TryRemove(NormalizeCategory(listName), out _);
} }
if (changed)
{
TouchState();
}
}
private TenantMemoryRecord GetOrCreateTenantRecord(PersonalMemoryTenantScope tenantScope)
{
var key = BuildTenantKey(tenantScope);
return _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
}
private void TouchState()
{
Interlocked.Increment(ref _revision);
SavePersistedState();
} }
private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope) private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope)
@@ -158,6 +284,8 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
return category.Trim().ToLowerInvariant(); return category.Trim().ToLowerInvariant();
} }
private const string CurrentSchemaVersion = "1";
private sealed class TenantMemoryRecord private sealed class TenantMemoryRecord
{ {
public string? Birthday { get; set; } public string? Birthday { get; set; }
@@ -168,4 +296,55 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
public ConcurrentDictionary<string, List<string>> Lists { get; } = new(StringComparer.OrdinalIgnoreCase); public ConcurrentDictionary<string, List<string>> Lists { get; } = new(StringComparer.OrdinalIgnoreCase);
public object SyncRoot { get; } = new(); public object SyncRoot { get; } = new();
} }
private sealed class PersistentStateSnapshot
{
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
public long Revision { get; init; }
public DateTimeOffset? LastLoadedUtc { get; init; }
public DateTimeOffset? LastSavedUtc { get; init; }
public TenantMemorySnapshot[]? Tenants { get; init; }
}
private sealed class TenantMemorySnapshot
{
public string TenantKey { get; init; } = string.Empty;
public string? Birthday { get; init; }
public string? Name { get; init; }
public IDictionary<string, string> Preferences { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> ImportantDates { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, PersonalAffinity> Affinities { get; init; } = new Dictionary<string, PersonalAffinity>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string[]> Lists { get; init; } = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
public TenantMemoryRecord ToRecord()
{
var record = new TenantMemoryRecord
{
Birthday = Birthday,
Name = Name
};
foreach (var preference in Preferences)
{
record.Preferences[preference.Key] = preference.Value;
}
foreach (var date in ImportantDates)
{
record.ImportantDates[date.Key] = date.Value;
}
foreach (var affinity in Affinities)
{
record.Affinities[affinity.Key] = affinity.Value;
}
foreach (var list in Lists)
{
record.Lists[list.Key] = [.. list.Value];
}
return record;
}
}
} }

View File

@@ -0,0 +1,87 @@
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Infrastructure.Persistence;
namespace Jibo.Cloud.Tests.Infrastructure;
public sealed class PersistenceStoreTests
{
[Fact]
public void PersonalMemoryStore_RoundTripsStateAndRevision()
{
var persistencePath = Path.Combine(Path.GetTempPath(), $"openjibo-personal-memory-{Guid.NewGuid():N}.json");
try
{
var scope = new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a", "person-a");
var firstStore = new InMemoryPersonalMemoryStore(persistencePath);
firstStore.SetName(scope, "Jibo Friend");
firstStore.SetBirthday(scope, "May 17");
firstStore.SetPreference(scope, "color", "blue");
firstStore.SetImportantDate(scope, "anniversary", "June 1");
firstStore.SetAffinity(scope, "pizza", PersonalAffinity.Like);
firstStore.AddListItem(scope, "groceries", "milk");
firstStore.SavePersistedState();
var firstInfo = firstStore.GetPersistenceStateInfo();
Assert.Equal("1", firstInfo.SchemaVersion);
Assert.True(firstInfo.Revision > 0);
Assert.NotNull(firstInfo.LastSavedUtc);
var secondStore = new InMemoryPersonalMemoryStore(persistencePath);
var secondInfo = secondStore.GetPersistenceStateInfo();
Assert.Equal(firstInfo.Revision, secondInfo.Revision);
Assert.Equal("Jibo Friend", secondStore.GetName(scope));
Assert.Equal("May 17", secondStore.GetBirthday(scope));
Assert.Equal("blue", secondStore.GetPreference(scope, "color"));
Assert.Equal("June 1", secondStore.GetImportantDate(scope, "anniversary"));
Assert.Equal(PersonalAffinity.Like, secondStore.GetAffinity(scope, "pizza"));
Assert.Contains("milk", secondStore.GetListItems(scope, "groceries"));
}
finally
{
if (File.Exists(persistencePath))
{
File.Delete(persistencePath);
}
}
}
[Fact]
public void CloudStateStore_RoundTripsTopologyAndContentState()
{
var persistencePath = Path.Combine(Path.GetTempPath(), $"openjibo-cloud-state-{Guid.NewGuid():N}.json");
try
{
var firstStore = new InMemoryCloudStateStore(persistencePath);
var update = firstStore.CreateUpdate("1.0.0", "1.0.1", "Bug fix", null, 42, "robot", null, null);
var media = firstStore.CreateMedia("openjibo-default-loop", "persisted-photo", "image", "photo-ref", false, new Dictionary<string, object?> { ["note"] = "roundtrip" });
var sessionToken = firstStore.IssueRobotToken("robot-123");
var device = firstStore.GetOrCreateDevice("robot-123", "3.2.1", "4.5.6");
firstStore.SavePersistedState();
var firstInfo = firstStore.GetPersistenceStateInfo();
Assert.Equal("1", firstInfo.SchemaVersion);
Assert.True(firstInfo.Revision > 0);
Assert.NotNull(firstInfo.LastSavedUtc);
var secondStore = new InMemoryCloudStateStore(persistencePath);
var secondInfo = secondStore.GetPersistenceStateInfo();
Assert.Equal(firstInfo.Revision, secondInfo.Revision);
Assert.Contains(secondStore.ListUpdates("robot"), item => item.UpdateId == update.UpdateId);
Assert.Contains(secondStore.ListMedia(), item => item.Path == media.Path);
Assert.NotNull(secondStore.FindSessionByToken(sessionToken));
Assert.Equal("3.2.1", secondStore.GetOrCreateDevice(device.DeviceId, null, null).FirmwareVersion);
Assert.NotEmpty(secondStore.GetPeople());
Assert.NotEmpty(secondStore.GetLoops());
}
finally
{
if (File.Exists(persistencePath))
{
File.Delete(persistencePath);
}
}
}
}