diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index f422318..5061b49 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -888,7 +888,8 @@ For `1.0.19`: 8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation 9. Durable memory persistence path (multi-tenant backing store) - 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 11. STT upgrade and noise screening 12. Hosted capture/storage plan / indexing for group testing diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index ca4551d..916fd7f 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -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 - 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 ### 6. Multi-Server Sync Path diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs index c5b88fb..d6e0432 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs @@ -4,6 +4,9 @@ namespace Jibo.Cloud.Application.Abstractions; public interface ICloudStateStore { + PersistenceStateInfo GetPersistenceStateInfo(); + void LoadPersistedState(); + void SavePersistedState(); AccountProfile GetAccount(); DeviceRegistration GetRobot(); RobotProfile GetRobotProfile(); diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs index 169a1c1..1f5eee0 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IPersonalMemoryStore.cs @@ -2,6 +2,9 @@ namespace Jibo.Cloud.Application.Abstractions; public interface IPersonalMemoryStore { + PersistenceStateInfo GetPersistenceStateInfo(); + void LoadPersistedState(); + void SavePersistedState(); void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText); string? GetBirthday(PersonalMemoryTenantScope tenantScope); 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 PersistenceStateInfo( + string SchemaVersion, + long Revision, + DateTimeOffset? LastLoadedUtc = null, + DateTimeOffset? LastSavedUtc = null); + public enum PersonalAffinity { Like, diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index f08b3ec..8e52b20 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -54,8 +54,10 @@ public static class ServiceCollectionExtensions services.AddHttpClient(); var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"] ?? 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(_ => new InMemoryCloudStateStore(statePersistencePath)); - services.AddSingleton(); + services.AddSingleton(_ => new InMemoryPersonalMemoryStore(personalMemoryPersistencePath)); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs index d419412..0aa3021 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs @@ -9,10 +9,11 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore { private static readonly JsonSerializerOptions PersistenceJsonOptions = new() { - WriteIndented = true + WriteIndented = true, + PropertyNameCaseInsensitive = true }; - private readonly AccountProfile _account = new(); + private AccountProfile _account = new(); private readonly ConcurrentDictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _sessionsByToken = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _symmetricKeys = new(StringComparer.OrdinalIgnoreCase); @@ -26,6 +27,9 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore private readonly List _people; private DeviceRegistration _robot; private RobotProfile _robotProfile; + private long _revision; + private DateTimeOffset? _lastLoadedUtc; + private DateTimeOffset? _lastSavedUtc; public InMemoryCloudStateStore(string? persistencePath = null) { @@ -86,7 +90,149 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore ]; _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(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(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 + { + ["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; @@ -97,7 +243,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore public DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion) { - return _devices.AddOrUpdate( + var device = _devices.AddOrUpdate( deviceId, _ => new DeviceRegistration { @@ -114,8 +260,11 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore FriendlyName = current.FriendlyName, FirmwareVersion = firmwareVersion ?? current.FirmwareVersion, ApplicationVersion = applicationVersion ?? current.ApplicationVersion, - HostMappings = current.HostMappings + HostMappings = new Dictionary(current.HostMappings, StringComparer.OrdinalIgnoreCase) }); + + TouchState(); + return device; } public string IssueHubToken() @@ -130,6 +279,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore Metadata = BuildSessionMetadata(_account.AccountId, _robot.DeviceId, ResolveDefaultLoopId()) }; + TouchState(); return token; } @@ -145,6 +295,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore Metadata = BuildSessionMetadata(_account.AccountId, deviceId, ResolveDefaultLoopId()) }; + TouchState(); return token; } @@ -166,6 +317,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore if (!string.IsNullOrWhiteSpace(token)) { _sessionsByToken[token] = session; + TouchState(); } return session; @@ -210,7 +362,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore }; _updates.Add(update); - PersistState(); + TouchState(); return update; } @@ -218,6 +370,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore { var existing = _updates.FirstOrDefault(update => update.UpdateId == updateId); if (existing is null) + { return new UpdateManifest { UpdateId = updateId ?? "unknown-update", @@ -226,11 +379,11 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore ShaHash = "missing", Subsystem = "unknown" }; + } _updates.Remove(existing); - PersistState(); + TouchState(); return existing; - } public IReadOnlyList ListMedia(IReadOnlyList? loopIds = null, long? after = null, long? before = null) @@ -278,7 +431,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore if (replacements.Count > 0) { - PersistState(); + TouchState(); } return replacements; @@ -308,7 +461,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore _media.Add(item); } - PersistState(); + TouchState(); return item; } @@ -318,7 +471,19 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore 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) @@ -331,6 +496,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore }; _keyRequests[record.RequestId] = record; + TouchState(); return record; } @@ -390,77 +556,25 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore }, UpdatedUtc = DateTimeOffset.UtcNow }; - PersistState(); + TouchState(); } - private void LoadPersistentState() + private void TouchState() { - if (string.IsNullOrWhiteSpace(_persistencePath) || !File.Exists(_persistencePath)) - { - return; - } - - try - { - var snapshot = JsonSerializer.Deserialize(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. - } + Interlocked.Increment(ref _revision); + SavePersistedState(); } - private void PersistState() + private static string ResolveDefaultLoopId(IReadOnlyList loops, AccountProfile account) { - if (string.IsNullOrWhiteSpace(_persistencePath)) - { - return; - } - - 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; } + return loops.FirstOrDefault(loop => string.Equals(loop.OwnerAccountId, account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId + ?? loops.FirstOrDefault()?.LoopId + ?? "openjibo-default-loop"; } private string ResolveDefaultLoopId() { - return _loops.FirstOrDefault(loop => string.Equals(loop.OwnerAccountId, _account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId - ?? _loops.FirstOrDefault()?.LoopId - ?? "openjibo-default-loop"; + return ResolveDefaultLoopId(_loops, _account); } private static IDictionary BuildSessionMetadata(string accountId, string? deviceId, string loopId) @@ -472,4 +586,92 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore ["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? 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 Metadata { get; init; } = new Dictionary(); + + 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(Metadata, StringComparer.OrdinalIgnoreCase) + }; + } + } } diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs index 6326f85..e53aeb2 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs @@ -1,17 +1,119 @@ using System.Collections.Concurrent; +using System.Text.Json; using Jibo.Cloud.Application.Abstractions; namespace Jibo.Cloud.Infrastructure.Persistence; public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore { + private static readonly JsonSerializerOptions PersistenceJsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + private readonly ConcurrentDictionary _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(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) { - var key = BuildTenantKey(tenantScope); - var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord()); + var record = GetOrCreateTenantRecord(tenantScope); record.Birthday = birthdayText; + TouchState(); } public string? GetBirthday(PersonalMemoryTenantScope tenantScope) @@ -22,9 +124,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore public void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value) { - var key = BuildTenantKey(tenantScope); - var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord()); + var record = GetOrCreateTenantRecord(tenantScope); record.Preferences[NormalizeCategory(category)] = value; + TouchState(); } public string? GetPreference(PersonalMemoryTenantScope tenantScope, string category) @@ -38,9 +140,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore public void SetName(PersonalMemoryTenantScope tenantScope, string name) { - var key = BuildTenantKey(tenantScope); - var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord()); + var record = GetOrCreateTenantRecord(tenantScope); record.Name = name; + TouchState(); } public string? GetName(PersonalMemoryTenantScope tenantScope) @@ -51,9 +153,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore public void SetImportantDate(PersonalMemoryTenantScope tenantScope, string label, string value) { - var key = BuildTenantKey(tenantScope); - var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord()); + var record = GetOrCreateTenantRecord(tenantScope); record.ImportantDates[NormalizeCategory(label)] = value; + TouchState(); } 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) { - var key = BuildTenantKey(tenantScope); - var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord()); + var record = GetOrCreateTenantRecord(tenantScope); record.Affinities[NormalizeCategory(item)] = affinity; + TouchState(); } public PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item) @@ -101,8 +203,8 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore return; } - var key = BuildTenantKey(tenantScope); - var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord()); + var record = GetOrCreateTenantRecord(tenantScope); + var changed = false; lock (record.SyncRoot) { var list = record.Lists.GetOrAdd(normalizedListName, static _ => []); @@ -112,6 +214,12 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore } list.Add(normalizedItem); + changed = true; + } + + if (changed) + { + TouchState(); } } @@ -140,10 +248,28 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore return; } + var changed = false; 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) @@ -158,6 +284,8 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore return category.Trim().ToLowerInvariant(); } + private const string CurrentSchemaVersion = "1"; + private sealed class TenantMemoryRecord { public string? Birthday { get; set; } @@ -168,4 +296,55 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore public ConcurrentDictionary> Lists { get; } = new(StringComparer.OrdinalIgnoreCase); 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 Preferences { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public IDictionary ImportantDates { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public IDictionary Affinities { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public IDictionary Lists { get; init; } = new Dictionary(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; + } + } } diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs new file mode 100644 index 0000000..10bc26a --- /dev/null +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs @@ -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 { ["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); + } + } + } +}