Add persistence seams for durable state adapters
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace Jibo.Cloud.Application.Abstractions;
|
||||
|
||||
public interface ICloudStateStore
|
||||
{
|
||||
PersistenceStateInfo GetPersistenceStateInfo();
|
||||
void LoadPersistedState();
|
||||
void SavePersistedState();
|
||||
AccountProfile GetAccount();
|
||||
DeviceRegistration GetRobot();
|
||||
RobotProfile GetRobotProfile();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user