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
|
8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation
|
||||||
9. Durable memory persistence path (multi-tenant backing store)
|
9. Durable memory persistence path (multi-tenant backing store)
|
||||||
- reference design captured in `docs/persistence-architecture.md`
|
- reference design captured in `docs/persistence-architecture.md`
|
||||||
- next implementation pass should tighten the store contracts around account/loop/device/person scoping and record versioning
|
- store contracts are now tightened around account/loop/device/person scoping, revision tracking, and explicit load/save boundaries
|
||||||
|
- next implementation pass should split the in-memory adapters from the eventual Azure-backed adapters while keeping the application seam stable
|
||||||
10. Update, backup, and restore proof
|
10. Update, backup, and restore proof
|
||||||
11. STT upgrade and noise screening
|
11. STT upgrade and noise screening
|
||||||
12. Hosted capture/storage plan / indexing for group testing
|
12. Hosted capture/storage plan / indexing for group testing
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ The goal is to port these in small batches, capture the source-backed phrasing w
|
|||||||
- implement memory-ready schemas and repository contracts for user facts (names, birthdays, personal dates, preferences) with strict tenant scoping
|
- implement memory-ready schemas and repository contracts for user facts (names, birthdays, personal dates, preferences) with strict tenant scoping
|
||||||
- seed person-aware state keys now so future interactions can scope to account + loop + device + person without another shape change
|
- seed person-aware state keys now so future interactions can scope to account + loop + device + person without another shape change
|
||||||
- keep stateful interaction flows repository-backed instead of embedding more ad hoc metadata in the websocket layer
|
- keep stateful interaction flows repository-backed instead of embedding more ad hoc metadata in the websocket layer
|
||||||
|
- the store seam now exposes revision metadata plus explicit load/save boundaries so durable adapters can drop in later without changing behavior code
|
||||||
|
|
||||||
### 6. Multi-Server Sync Path
|
### 6. Multi-Server Sync Path
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ namespace Jibo.Cloud.Application.Abstractions;
|
|||||||
|
|
||||||
public interface ICloudStateStore
|
public interface ICloudStateStore
|
||||||
{
|
{
|
||||||
|
PersistenceStateInfo GetPersistenceStateInfo();
|
||||||
|
void LoadPersistedState();
|
||||||
|
void SavePersistedState();
|
||||||
AccountProfile GetAccount();
|
AccountProfile GetAccount();
|
||||||
DeviceRegistration GetRobot();
|
DeviceRegistration GetRobot();
|
||||||
RobotProfile GetRobotProfile();
|
RobotProfile GetRobotProfile();
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ namespace Jibo.Cloud.Application.Abstractions;
|
|||||||
|
|
||||||
public interface IPersonalMemoryStore
|
public interface IPersonalMemoryStore
|
||||||
{
|
{
|
||||||
|
PersistenceStateInfo GetPersistenceStateInfo();
|
||||||
|
void LoadPersistedState();
|
||||||
|
void SavePersistedState();
|
||||||
void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText);
|
void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText);
|
||||||
string? GetBirthday(PersonalMemoryTenantScope tenantScope);
|
string? GetBirthday(PersonalMemoryTenantScope tenantScope);
|
||||||
void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value);
|
void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value);
|
||||||
@@ -20,6 +23,12 @@ public interface IPersonalMemoryStore
|
|||||||
|
|
||||||
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId, string? PersonId = null);
|
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId, string? PersonId = null);
|
||||||
|
|
||||||
|
public sealed record PersistenceStateInfo(
|
||||||
|
string SchemaVersion,
|
||||||
|
long Revision,
|
||||||
|
DateTimeOffset? LastLoadedUtc = null,
|
||||||
|
DateTimeOffset? LastSavedUtc = null);
|
||||||
|
|
||||||
public enum PersonalAffinity
|
public enum PersonalAffinity
|
||||||
{
|
{
|
||||||
Like,
|
Like,
|
||||||
|
|||||||
@@ -54,8 +54,10 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>();
|
services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>();
|
||||||
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
|
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
|
||||||
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
|
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
|
||||||
|
var personalMemoryPersistencePath = configuration?["OpenJibo:PersonalMemory:PersistencePath"]
|
||||||
|
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "personal-memory.json");
|
||||||
services.AddSingleton<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));
|
services.AddSingleton<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));
|
||||||
services.AddSingleton<IPersonalMemoryStore, InMemoryPersonalMemoryStore>();
|
services.AddSingleton<IPersonalMemoryStore>(_ => new InMemoryPersonalMemoryStore(personalMemoryPersistencePath));
|
||||||
services.AddSingleton<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>();
|
services.AddSingleton<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>();
|
||||||
services.AddSingleton<JiboExperienceContentCache>();
|
services.AddSingleton<JiboExperienceContentCache>();
|
||||||
services.AddSingleton<IJiboRandomizer, DefaultJiboRandomizer>();
|
services.AddSingleton<IJiboRandomizer, DefaultJiboRandomizer>();
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions PersistenceJsonOptions = new()
|
private static readonly JsonSerializerOptions PersistenceJsonOptions = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true
|
WriteIndented = true,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly AccountProfile _account = new();
|
private AccountProfile _account = new();
|
||||||
private readonly ConcurrentDictionary<string, DeviceRegistration> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, DeviceRegistration> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly ConcurrentDictionary<string, CloudSession> _sessionsByToken = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, CloudSession> _sessionsByToken = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -26,6 +27,9 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
private readonly List<PersonRecord> _people;
|
private readonly List<PersonRecord> _people;
|
||||||
private DeviceRegistration _robot;
|
private DeviceRegistration _robot;
|
||||||
private RobotProfile _robotProfile;
|
private RobotProfile _robotProfile;
|
||||||
|
private long _revision;
|
||||||
|
private DateTimeOffset? _lastLoadedUtc;
|
||||||
|
private DateTimeOffset? _lastSavedUtc;
|
||||||
|
|
||||||
public InMemoryCloudStateStore(string? persistencePath = null)
|
public InMemoryCloudStateStore(string? persistencePath = null)
|
||||||
{
|
{
|
||||||
@@ -86,7 +90,149 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
];
|
];
|
||||||
|
|
||||||
_updates = [];
|
_updates = [];
|
||||||
LoadPersistentState();
|
LoadPersistedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PersistenceStateInfo GetPersistenceStateInfo()
|
||||||
|
{
|
||||||
|
return new PersistenceStateInfo(
|
||||||
|
SchemaVersion: CurrentSchemaVersion,
|
||||||
|
Revision: Interlocked.Read(ref _revision),
|
||||||
|
LastLoadedUtc: _lastLoadedUtc,
|
||||||
|
LastSavedUtc: _lastSavedUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadPersistedState()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_persistencePath) || !File.Exists(_persistencePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = JsonSerializer.Deserialize<PersistentStateSnapshot>(File.ReadAllText(_persistencePath), PersistenceJsonOptions);
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_account = snapshot.Account ?? _account;
|
||||||
|
_robot = snapshot.Robot ?? _robot;
|
||||||
|
_robotProfile = snapshot.RobotProfile ?? _robotProfile;
|
||||||
|
|
||||||
|
_devices.Clear();
|
||||||
|
foreach (var device in snapshot.Devices ?? [])
|
||||||
|
{
|
||||||
|
_devices[device.DeviceId] = device;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_devices.IsEmpty || !_devices.ContainsKey(_robot.DeviceId))
|
||||||
|
{
|
||||||
|
_devices[_robot.DeviceId] = _robot;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sessionsByToken.Clear();
|
||||||
|
foreach (var session in snapshot.Sessions ?? [])
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.Token))
|
||||||
|
{
|
||||||
|
_sessionsByToken[session.Token] = session.ToRecord();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_symmetricKeys.Clear();
|
||||||
|
foreach (var pair in snapshot.SymmetricKeys ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_symmetricKeys[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
_keyRequests.Clear();
|
||||||
|
foreach (var keyRequest in snapshot.KeyRequests ?? [])
|
||||||
|
{
|
||||||
|
_keyRequests[keyRequest.RequestId] = keyRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updates.Clear();
|
||||||
|
_updates.AddRange(snapshot.Updates ?? []);
|
||||||
|
|
||||||
|
_media.Clear();
|
||||||
|
_media.AddRange(snapshot.Media ?? []);
|
||||||
|
|
||||||
|
_backups.Clear();
|
||||||
|
_backups.AddRange(snapshot.Backups ?? []);
|
||||||
|
|
||||||
|
_loops.Clear();
|
||||||
|
_loops.AddRange(snapshot.Loops ?? []);
|
||||||
|
|
||||||
|
_people.Clear();
|
||||||
|
_people.AddRange(snapshot.People ?? []);
|
||||||
|
|
||||||
|
if (_robotProfile is null || !string.Equals(_robotProfile.RobotId, _robot.RobotId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_robotProfile = new RobotProfile
|
||||||
|
{
|
||||||
|
RobotId = _robot.RobotId,
|
||||||
|
Payload = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["SSID"] = "my-ssid",
|
||||||
|
["connectedAt"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
["platform"] = _robot.FirmwareVersion ?? "12.10.0",
|
||||||
|
["serialNumber"] = _robot.DeviceId
|
||||||
|
},
|
||||||
|
UpdatedUtc = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Exchange(ref _revision, snapshot.Revision);
|
||||||
|
_lastLoadedUtc = snapshot.LastLoadedUtc ?? DateTimeOffset.UtcNow;
|
||||||
|
_lastSavedUtc = snapshot.LastSavedUtc;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore corrupt state and continue with the in-memory defaults.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SavePersistedState()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_persistencePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(_persistencePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var snapshot = new PersistentStateSnapshot
|
||||||
|
{
|
||||||
|
SchemaVersion = CurrentSchemaVersion,
|
||||||
|
Revision = Interlocked.Read(ref _revision),
|
||||||
|
LastLoadedUtc = _lastLoadedUtc,
|
||||||
|
LastSavedUtc = now,
|
||||||
|
Account = _account,
|
||||||
|
Robot = _robot,
|
||||||
|
RobotProfile = _robotProfile,
|
||||||
|
Devices = _devices.Values.ToArray(),
|
||||||
|
Sessions = _sessionsByToken.Values.Select(MapSessionSnapshot).ToArray(),
|
||||||
|
SymmetricKeys = _symmetricKeys.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase),
|
||||||
|
KeyRequests = _keyRequests.Values.ToArray(),
|
||||||
|
Updates = _updates.ToArray(),
|
||||||
|
Media = _media.ToArray(),
|
||||||
|
Backups = _backups.ToArray(),
|
||||||
|
Loops = _loops.ToArray(),
|
||||||
|
People = _people.ToArray()
|
||||||
|
};
|
||||||
|
|
||||||
|
File.WriteAllText(_persistencePath, JsonSerializer.Serialize(snapshot, PersistenceJsonOptions));
|
||||||
|
_lastSavedUtc = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public AccountProfile GetAccount() => _account;
|
public AccountProfile GetAccount() => _account;
|
||||||
@@ -97,7 +243,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
|
|
||||||
public DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion)
|
public DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion)
|
||||||
{
|
{
|
||||||
return _devices.AddOrUpdate(
|
var device = _devices.AddOrUpdate(
|
||||||
deviceId,
|
deviceId,
|
||||||
_ => new DeviceRegistration
|
_ => new DeviceRegistration
|
||||||
{
|
{
|
||||||
@@ -114,8 +260,11 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
FriendlyName = current.FriendlyName,
|
FriendlyName = current.FriendlyName,
|
||||||
FirmwareVersion = firmwareVersion ?? current.FirmwareVersion,
|
FirmwareVersion = firmwareVersion ?? current.FirmwareVersion,
|
||||||
ApplicationVersion = applicationVersion ?? current.ApplicationVersion,
|
ApplicationVersion = applicationVersion ?? current.ApplicationVersion,
|
||||||
HostMappings = current.HostMappings
|
HostMappings = new Dictionary<string, string>(current.HostMappings, StringComparer.OrdinalIgnoreCase)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
TouchState();
|
||||||
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string IssueHubToken()
|
public string IssueHubToken()
|
||||||
@@ -130,6 +279,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
Metadata = BuildSessionMetadata(_account.AccountId, _robot.DeviceId, ResolveDefaultLoopId())
|
Metadata = BuildSessionMetadata(_account.AccountId, _robot.DeviceId, ResolveDefaultLoopId())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
TouchState();
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +295,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
Metadata = BuildSessionMetadata(_account.AccountId, deviceId, ResolveDefaultLoopId())
|
Metadata = BuildSessionMetadata(_account.AccountId, deviceId, ResolveDefaultLoopId())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
TouchState();
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +317,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
if (!string.IsNullOrWhiteSpace(token))
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
{
|
{
|
||||||
_sessionsByToken[token] = session;
|
_sessionsByToken[token] = session;
|
||||||
|
TouchState();
|
||||||
}
|
}
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
@@ -210,7 +362,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
};
|
};
|
||||||
|
|
||||||
_updates.Add(update);
|
_updates.Add(update);
|
||||||
PersistState();
|
TouchState();
|
||||||
return update;
|
return update;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +370,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
{
|
{
|
||||||
var existing = _updates.FirstOrDefault(update => update.UpdateId == updateId);
|
var existing = _updates.FirstOrDefault(update => update.UpdateId == updateId);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
|
{
|
||||||
return new UpdateManifest
|
return new UpdateManifest
|
||||||
{
|
{
|
||||||
UpdateId = updateId ?? "unknown-update",
|
UpdateId = updateId ?? "unknown-update",
|
||||||
@@ -226,11 +379,11 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
ShaHash = "missing",
|
ShaHash = "missing",
|
||||||
Subsystem = "unknown"
|
Subsystem = "unknown"
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
_updates.Remove(existing);
|
_updates.Remove(existing);
|
||||||
PersistState();
|
TouchState();
|
||||||
return existing;
|
return existing;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null, long? before = null)
|
public IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null, long? before = null)
|
||||||
@@ -278,7 +431,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
|
|
||||||
if (replacements.Count > 0)
|
if (replacements.Count > 0)
|
||||||
{
|
{
|
||||||
PersistState();
|
TouchState();
|
||||||
}
|
}
|
||||||
|
|
||||||
return replacements;
|
return replacements;
|
||||||
@@ -308,7 +461,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
_media.Add(item);
|
_media.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
PersistState();
|
TouchState();
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +471,19 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
|
|
||||||
public string GetOrCreateSymmetricKey(string loopId)
|
public string GetOrCreateSymmetricKey(string loopId)
|
||||||
{
|
{
|
||||||
return _symmetricKeys.GetOrAdd(loopId, key => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"open-jibo-symmetric-key:{key}")));
|
if (_symmetricKeys.TryGetValue(loopId, out var existing))
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"open-jibo-symmetric-key:{loopId}"));
|
||||||
|
if (_symmetricKeys.TryAdd(loopId, key))
|
||||||
|
{
|
||||||
|
TouchState();
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _symmetricKeys[loopId];
|
||||||
}
|
}
|
||||||
|
|
||||||
public KeyRequestRecord CreateKeyRequest(string loopId, string publicKey)
|
public KeyRequestRecord CreateKeyRequest(string loopId, string publicKey)
|
||||||
@@ -331,6 +496,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
};
|
};
|
||||||
|
|
||||||
_keyRequests[record.RequestId] = record;
|
_keyRequests[record.RequestId] = record;
|
||||||
|
TouchState();
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,77 +556,25 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
},
|
},
|
||||||
UpdatedUtc = DateTimeOffset.UtcNow
|
UpdatedUtc = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
PersistState();
|
TouchState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadPersistentState()
|
private void TouchState()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_persistencePath) || !File.Exists(_persistencePath))
|
Interlocked.Increment(ref _revision);
|
||||||
{
|
SavePersistedState();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var snapshot = JsonSerializer.Deserialize<PersistentStateSnapshot>(File.ReadAllText(_persistencePath), PersistenceJsonOptions);
|
|
||||||
if (snapshot is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_updates.Clear();
|
|
||||||
_updates.AddRange(snapshot.Updates ?? []);
|
|
||||||
|
|
||||||
_media.Clear();
|
|
||||||
_media.AddRange(snapshot.Media ?? []);
|
|
||||||
|
|
||||||
_backups.Clear();
|
|
||||||
_backups.AddRange(snapshot.Backups ?? []);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Ignore corrupt state and continue with the in-memory defaults.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PersistState()
|
private static string ResolveDefaultLoopId(IReadOnlyList<LoopRecord> loops, AccountProfile account)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_persistencePath))
|
return loops.FirstOrDefault(loop => string.Equals(loop.OwnerAccountId, account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId
|
||||||
{
|
?? loops.FirstOrDefault()?.LoopId
|
||||||
return;
|
?? "openjibo-default-loop";
|
||||||
}
|
|
||||||
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
var directory = Path.GetDirectoryName(_persistencePath);
|
|
||||||
if (!string.IsNullOrWhiteSpace(directory))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
var snapshot = new PersistentStateSnapshot
|
|
||||||
{
|
|
||||||
Updates = _updates.ToArray(),
|
|
||||||
Media = _media.ToArray(),
|
|
||||||
Backups = _backups.ToArray()
|
|
||||||
};
|
|
||||||
|
|
||||||
File.WriteAllText(_persistencePath, JsonSerializer.Serialize(snapshot, PersistenceJsonOptions));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class PersistentStateSnapshot
|
|
||||||
{
|
|
||||||
public UpdateManifest[]? Updates { get; init; }
|
|
||||||
public MediaRecord[]? Media { get; init; }
|
|
||||||
public BackupRecord[]? Backups { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ResolveDefaultLoopId()
|
private string ResolveDefaultLoopId()
|
||||||
{
|
{
|
||||||
return _loops.FirstOrDefault(loop => string.Equals(loop.OwnerAccountId, _account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId
|
return ResolveDefaultLoopId(_loops, _account);
|
||||||
?? _loops.FirstOrDefault()?.LoopId
|
|
||||||
?? "openjibo-default-loop";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IDictionary<string, object?> BuildSessionMetadata(string accountId, string? deviceId, string loopId)
|
private static IDictionary<string, object?> BuildSessionMetadata(string accountId, string? deviceId, string loopId)
|
||||||
@@ -472,4 +586,92 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
["deviceId"] = deviceId
|
["deviceId"] = deviceId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static CloudSessionSnapshot MapSessionSnapshot(CloudSession session)
|
||||||
|
{
|
||||||
|
return new CloudSessionSnapshot
|
||||||
|
{
|
||||||
|
SessionId = session.SessionId,
|
||||||
|
Kind = session.Kind,
|
||||||
|
AccountId = session.AccountId,
|
||||||
|
DeviceId = session.DeviceId,
|
||||||
|
Token = session.Token,
|
||||||
|
HostName = session.HostName,
|
||||||
|
Path = session.Path,
|
||||||
|
CreatedUtc = session.CreatedUtc,
|
||||||
|
LastSeenUtc = session.LastSeenUtc,
|
||||||
|
FollowUpExpiresUtc = session.FollowUpExpiresUtc,
|
||||||
|
LastMessageType = session.LastMessageType,
|
||||||
|
LastListenType = session.LastListenType,
|
||||||
|
LastIntent = session.LastIntent,
|
||||||
|
LastTranscript = session.LastTranscript,
|
||||||
|
LastTransId = session.LastTransId,
|
||||||
|
Metadata = session.Metadata
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string CurrentSchemaVersion = "1";
|
||||||
|
|
||||||
|
private sealed class PersistentStateSnapshot
|
||||||
|
{
|
||||||
|
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
public long Revision { get; init; }
|
||||||
|
public DateTimeOffset? LastLoadedUtc { get; init; }
|
||||||
|
public DateTimeOffset? LastSavedUtc { get; init; }
|
||||||
|
public AccountProfile? Account { get; init; }
|
||||||
|
public DeviceRegistration? Robot { get; init; }
|
||||||
|
public RobotProfile? RobotProfile { get; init; }
|
||||||
|
public DeviceRegistration[]? Devices { get; init; }
|
||||||
|
public CloudSessionSnapshot[]? Sessions { get; init; }
|
||||||
|
public Dictionary<string, string>? SymmetricKeys { get; init; }
|
||||||
|
public KeyRequestRecord[]? KeyRequests { get; init; }
|
||||||
|
public UpdateManifest[]? Updates { get; init; }
|
||||||
|
public MediaRecord[]? Media { get; init; }
|
||||||
|
public BackupRecord[]? Backups { get; init; }
|
||||||
|
public LoopRecord[]? Loops { get; init; }
|
||||||
|
public PersonRecord[]? People { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CloudSessionSnapshot
|
||||||
|
{
|
||||||
|
public string SessionId { get; init; } = Guid.NewGuid().ToString("N");
|
||||||
|
public string Kind { get; init; } = "http";
|
||||||
|
public string? AccountId { get; init; }
|
||||||
|
public string? DeviceId { get; init; }
|
||||||
|
public string? Token { get; init; }
|
||||||
|
public string? HostName { get; init; }
|
||||||
|
public string? Path { get; init; }
|
||||||
|
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset LastSeenUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset? FollowUpExpiresUtc { get; init; }
|
||||||
|
public string? LastMessageType { get; init; }
|
||||||
|
public string? LastListenType { get; init; }
|
||||||
|
public string? LastIntent { get; init; }
|
||||||
|
public string? LastTranscript { get; init; }
|
||||||
|
public string? LastTransId { get; init; }
|
||||||
|
public IDictionary<string, object?> Metadata { get; init; } = new Dictionary<string, object?>();
|
||||||
|
|
||||||
|
public CloudSession ToRecord()
|
||||||
|
{
|
||||||
|
return new CloudSession
|
||||||
|
{
|
||||||
|
SessionId = SessionId,
|
||||||
|
Kind = Kind,
|
||||||
|
AccountId = AccountId,
|
||||||
|
DeviceId = DeviceId,
|
||||||
|
Token = Token,
|
||||||
|
HostName = HostName,
|
||||||
|
Path = Path,
|
||||||
|
CreatedUtc = CreatedUtc,
|
||||||
|
LastSeenUtc = LastSeenUtc,
|
||||||
|
FollowUpExpiresUtc = FollowUpExpiresUtc,
|
||||||
|
LastMessageType = LastMessageType,
|
||||||
|
LastListenType = LastListenType,
|
||||||
|
LastIntent = LastIntent,
|
||||||
|
LastTranscript = LastTranscript,
|
||||||
|
LastTransId = LastTransId,
|
||||||
|
Metadata = new Dictionary<string, object?>(Metadata, StringComparer.OrdinalIgnoreCase)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,119 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
using Jibo.Cloud.Application.Abstractions;
|
using Jibo.Cloud.Application.Abstractions;
|
||||||
|
|
||||||
namespace Jibo.Cloud.Infrastructure.Persistence;
|
namespace Jibo.Cloud.Infrastructure.Persistence;
|
||||||
|
|
||||||
public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions PersistenceJsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, TenantMemoryRecord> _tenantMemory = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, TenantMemoryRecord> _tenantMemory = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly string? _persistencePath;
|
||||||
|
private readonly Lock _syncRoot = new();
|
||||||
|
private long _revision;
|
||||||
|
private DateTimeOffset? _lastLoadedUtc;
|
||||||
|
private DateTimeOffset? _lastSavedUtc;
|
||||||
|
|
||||||
|
public InMemoryPersonalMemoryStore(string? persistencePath = null)
|
||||||
|
{
|
||||||
|
_persistencePath = persistencePath;
|
||||||
|
LoadPersistedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PersistenceStateInfo GetPersistenceStateInfo()
|
||||||
|
{
|
||||||
|
return new PersistenceStateInfo(
|
||||||
|
SchemaVersion: CurrentSchemaVersion,
|
||||||
|
Revision: Interlocked.Read(ref _revision),
|
||||||
|
LastLoadedUtc: _lastLoadedUtc,
|
||||||
|
LastSavedUtc: _lastSavedUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadPersistedState()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_persistencePath) || !File.Exists(_persistencePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = JsonSerializer.Deserialize<PersistentStateSnapshot>(File.ReadAllText(_persistencePath), PersistenceJsonOptions);
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tenantMemory.Clear();
|
||||||
|
foreach (var tenant in snapshot.Tenants ?? [])
|
||||||
|
{
|
||||||
|
_tenantMemory[tenant.TenantKey] = tenant.ToRecord();
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Exchange(ref _revision, snapshot.Revision);
|
||||||
|
_lastLoadedUtc = snapshot.LastLoadedUtc ?? DateTimeOffset.UtcNow;
|
||||||
|
_lastSavedUtc = snapshot.LastSavedUtc;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore corrupt state and continue with the in-memory defaults.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SavePersistedState()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_persistencePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(_persistencePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var snapshot = new PersistentStateSnapshot
|
||||||
|
{
|
||||||
|
SchemaVersion = CurrentSchemaVersion,
|
||||||
|
Revision = Interlocked.Read(ref _revision),
|
||||||
|
LastLoadedUtc = _lastLoadedUtc,
|
||||||
|
LastSavedUtc = now,
|
||||||
|
Tenants = _tenantMemory
|
||||||
|
.Select(pair => new TenantMemorySnapshot
|
||||||
|
{
|
||||||
|
TenantKey = pair.Key,
|
||||||
|
Birthday = pair.Value.Birthday,
|
||||||
|
Name = pair.Value.Name,
|
||||||
|
Preferences = pair.Value.Preferences.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase),
|
||||||
|
ImportantDates = pair.Value.ImportantDates.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase),
|
||||||
|
Affinities = pair.Value.Affinities.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase),
|
||||||
|
Lists = pair.Value.Lists.ToDictionary(
|
||||||
|
entry => entry.Key,
|
||||||
|
entry => entry.Value.ToArray(),
|
||||||
|
StringComparer.OrdinalIgnoreCase)
|
||||||
|
})
|
||||||
|
.ToArray()
|
||||||
|
};
|
||||||
|
|
||||||
|
File.WriteAllText(_persistencePath, JsonSerializer.Serialize(snapshot, PersistenceJsonOptions));
|
||||||
|
_lastSavedUtc = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText)
|
public void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText)
|
||||||
{
|
{
|
||||||
var key = BuildTenantKey(tenantScope);
|
var record = GetOrCreateTenantRecord(tenantScope);
|
||||||
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
|
||||||
record.Birthday = birthdayText;
|
record.Birthday = birthdayText;
|
||||||
|
TouchState();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? GetBirthday(PersonalMemoryTenantScope tenantScope)
|
public string? GetBirthday(PersonalMemoryTenantScope tenantScope)
|
||||||
@@ -22,9 +124,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
|||||||
|
|
||||||
public void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value)
|
public void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value)
|
||||||
{
|
{
|
||||||
var key = BuildTenantKey(tenantScope);
|
var record = GetOrCreateTenantRecord(tenantScope);
|
||||||
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
|
||||||
record.Preferences[NormalizeCategory(category)] = value;
|
record.Preferences[NormalizeCategory(category)] = value;
|
||||||
|
TouchState();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? GetPreference(PersonalMemoryTenantScope tenantScope, string category)
|
public string? GetPreference(PersonalMemoryTenantScope tenantScope, string category)
|
||||||
@@ -38,9 +140,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
|||||||
|
|
||||||
public void SetName(PersonalMemoryTenantScope tenantScope, string name)
|
public void SetName(PersonalMemoryTenantScope tenantScope, string name)
|
||||||
{
|
{
|
||||||
var key = BuildTenantKey(tenantScope);
|
var record = GetOrCreateTenantRecord(tenantScope);
|
||||||
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
|
||||||
record.Name = name;
|
record.Name = name;
|
||||||
|
TouchState();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? GetName(PersonalMemoryTenantScope tenantScope)
|
public string? GetName(PersonalMemoryTenantScope tenantScope)
|
||||||
@@ -51,9 +153,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
|||||||
|
|
||||||
public void SetImportantDate(PersonalMemoryTenantScope tenantScope, string label, string value)
|
public void SetImportantDate(PersonalMemoryTenantScope tenantScope, string label, string value)
|
||||||
{
|
{
|
||||||
var key = BuildTenantKey(tenantScope);
|
var record = GetOrCreateTenantRecord(tenantScope);
|
||||||
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
|
||||||
record.ImportantDates[NormalizeCategory(label)] = value;
|
record.ImportantDates[NormalizeCategory(label)] = value;
|
||||||
|
TouchState();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? GetImportantDate(PersonalMemoryTenantScope tenantScope, string label)
|
public string? GetImportantDate(PersonalMemoryTenantScope tenantScope, string label)
|
||||||
@@ -67,9 +169,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
|||||||
|
|
||||||
public void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity)
|
public void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity)
|
||||||
{
|
{
|
||||||
var key = BuildTenantKey(tenantScope);
|
var record = GetOrCreateTenantRecord(tenantScope);
|
||||||
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
|
||||||
record.Affinities[NormalizeCategory(item)] = affinity;
|
record.Affinities[NormalizeCategory(item)] = affinity;
|
||||||
|
TouchState();
|
||||||
}
|
}
|
||||||
|
|
||||||
public PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item)
|
public PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item)
|
||||||
@@ -101,8 +203,8 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var key = BuildTenantKey(tenantScope);
|
var record = GetOrCreateTenantRecord(tenantScope);
|
||||||
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
var changed = false;
|
||||||
lock (record.SyncRoot)
|
lock (record.SyncRoot)
|
||||||
{
|
{
|
||||||
var list = record.Lists.GetOrAdd(normalizedListName, static _ => []);
|
var list = record.Lists.GetOrAdd(normalizedListName, static _ => []);
|
||||||
@@ -112,6 +214,12 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
list.Add(normalizedItem);
|
list.Add(normalizedItem);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
TouchState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +248,28 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
lock (record.SyncRoot)
|
lock (record.SyncRoot)
|
||||||
{
|
{
|
||||||
record.Lists.TryRemove(NormalizeCategory(listName), out _);
|
changed = record.Lists.TryRemove(NormalizeCategory(listName), out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
TouchState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TenantMemoryRecord GetOrCreateTenantRecord(PersonalMemoryTenantScope tenantScope)
|
||||||
|
{
|
||||||
|
var key = BuildTenantKey(tenantScope);
|
||||||
|
return _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TouchState()
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _revision);
|
||||||
|
SavePersistedState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope)
|
private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope)
|
||||||
@@ -158,6 +284,8 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
|||||||
return category.Trim().ToLowerInvariant();
|
return category.Trim().ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const string CurrentSchemaVersion = "1";
|
||||||
|
|
||||||
private sealed class TenantMemoryRecord
|
private sealed class TenantMemoryRecord
|
||||||
{
|
{
|
||||||
public string? Birthday { get; set; }
|
public string? Birthday { get; set; }
|
||||||
@@ -168,4 +296,55 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
|||||||
public ConcurrentDictionary<string, List<string>> Lists { get; } = new(StringComparer.OrdinalIgnoreCase);
|
public ConcurrentDictionary<string, List<string>> Lists { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
public object SyncRoot { get; } = new();
|
public object SyncRoot { get; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class PersistentStateSnapshot
|
||||||
|
{
|
||||||
|
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
public long Revision { get; init; }
|
||||||
|
public DateTimeOffset? LastLoadedUtc { get; init; }
|
||||||
|
public DateTimeOffset? LastSavedUtc { get; init; }
|
||||||
|
public TenantMemorySnapshot[]? Tenants { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TenantMemorySnapshot
|
||||||
|
{
|
||||||
|
public string TenantKey { get; init; } = string.Empty;
|
||||||
|
public string? Birthday { get; init; }
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public IDictionary<string, string> Preferences { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public IDictionary<string, string> ImportantDates { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public IDictionary<string, PersonalAffinity> Affinities { get; init; } = new Dictionary<string, PersonalAffinity>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public IDictionary<string, string[]> Lists { get; init; } = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public TenantMemoryRecord ToRecord()
|
||||||
|
{
|
||||||
|
var record = new TenantMemoryRecord
|
||||||
|
{
|
||||||
|
Birthday = Birthday,
|
||||||
|
Name = Name
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var preference in Preferences)
|
||||||
|
{
|
||||||
|
record.Preferences[preference.Key] = preference.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var date in ImportantDates)
|
||||||
|
{
|
||||||
|
record.ImportantDates[date.Key] = date.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var affinity in Affinities)
|
||||||
|
{
|
||||||
|
record.Affinities[affinity.Key] = affinity.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var list in Lists)
|
||||||
|
{
|
||||||
|
record.Lists[list.Key] = [.. list.Value];
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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