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

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

View File

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

View File

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

View File

@@ -54,8 +54,10 @@ public static class ServiceCollectionExtensions
services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>();
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<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));
services.AddSingleton<IPersonalMemoryStore, InMemoryPersonalMemoryStore>();
services.AddSingleton<IPersonalMemoryStore>(_ => new InMemoryPersonalMemoryStore(personalMemoryPersistencePath));
services.AddSingleton<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>();
services.AddSingleton<JiboExperienceContentCache>();
services.AddSingleton<IJiboRandomizer, DefaultJiboRandomizer>();

View File

@@ -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<string, DeviceRegistration> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, CloudSession> _sessionsByToken = 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 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<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;
@@ -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<string, string>(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<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)
{
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<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.
}
Interlocked.Increment(ref _revision);
SavePersistedState();
}
private void PersistState()
private static string ResolveDefaultLoopId(IReadOnlyList<LoopRecord> 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<string, object?> 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<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.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<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)
{
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<string, List<string>> 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<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);
}
}
}
}