Hydrate only Aetheria daemon working state
This commit is contained in:
parent
f28dbd5a74
commit
b4b16867ee
9 changed files with 341 additions and 15 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using System.Globalization;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Aetheria.State.Documents;
|
||||
|
|
@ -40,12 +41,32 @@ public sealed class AetheriaYmirPersistenceCoordinator : IDisposable
|
|||
AetheriaYmirWorldPhysics physics,
|
||||
AetheriaRuntimeDaemonFrameDocument? frame)
|
||||
{
|
||||
var trace = string.Equals(
|
||||
Environment.GetEnvironmentVariable("AETHERIA_TRACE_STARTUP_PHASES"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
var phase = Stopwatch.StartNew();
|
||||
void Trace(string name)
|
||||
{
|
||||
if (trace)
|
||||
Console.WriteLine($"Aetheria Ymir restore phase {name} took {phase.Elapsed.TotalMilliseconds:0.###}ms.");
|
||||
phase.Restart();
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
ArgumentNullException.ThrowIfNull(physics);
|
||||
var registry = CultMesh.CreateCultCacheDocumentRegistry(
|
||||
typeof(AetheriaYmirPersistenceMarkerDocument),
|
||||
typeof(AetheriaYmirJournalChunkDocument),
|
||||
typeof(AetheriaYmirResumeDocument));
|
||||
var ownedSchemaIds = registry.AllDescriptors
|
||||
.Select(descriptor => descriptor.SchemaId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var currentResumeKeys = frame?.Run == null || string.IsNullOrWhiteSpace(frame.Run.RunId)
|
||||
? new HashSet<string>(StringComparer.Ordinal)
|
||||
: (frame.Run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
|
||||
.Where(zone => zone != null)
|
||||
.Select(zone => ResumeRecordKey(frame.Run.RunId, zone.ZoneIndex, frame.FrameId))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var cache = await CultCacheMessagePack.OpenAsync(
|
||||
PrivateStatePath(node.StatePath),
|
||||
new CultCacheOpenOptions
|
||||
|
|
@ -53,9 +74,14 @@ public sealed class AetheriaYmirPersistenceCoordinator : IDisposable
|
|||
Registry = registry,
|
||||
PullOnOpen = true,
|
||||
UseDirectoryStore = true,
|
||||
DirectoryStoreHydrationFilter = record =>
|
||||
ownedSchemaIds.Contains(record.SchemaId) &&
|
||||
(string.Equals(record.Key, AetheriaYmirPersistenceMarkerDocument.RecordKey, StringComparison.Ordinal) ||
|
||||
currentResumeKeys.Contains(record.Key)),
|
||||
FlushOnDispose = true,
|
||||
StoreFlushOnDispose = true
|
||||
}).ConfigureAwait(false);
|
||||
Trace("open-private-cache");
|
||||
try
|
||||
{
|
||||
var activated = cache.Get<AetheriaYmirPersistenceMarkerDocument>(
|
||||
|
|
@ -72,14 +98,24 @@ public sealed class AetheriaYmirPersistenceCoordinator : IDisposable
|
|||
.Select(zone => cache.Get<AetheriaYmirResumeDocument>(
|
||||
new CultRecordKey(ResumeRecordKey(frame.Run.RunId, zone.ZoneIndex, frame.FrameId))))
|
||||
.ToArray();
|
||||
Trace("resolve-resumes");
|
||||
if (resumes.All(value => value == null) && !activated)
|
||||
return coordinator;
|
||||
if (resumes.Any(value => value == null))
|
||||
throw new InvalidOperationException(
|
||||
$"Aetheria frame {frame.FrameId} has incomplete daemon-private Ymir resume state.");
|
||||
|
||||
var journalRanges = zones.Zip(resumes, (zone, resume) =>
|
||||
JournalRanges(frame.Run.RunId, zone.ZoneIndex, resume!))
|
||||
.SelectMany(value => value)
|
||||
.ToArray();
|
||||
await cache.PullBackingStoreRecordsAsync(metadata =>
|
||||
journalRanges.Any(range => range.Contains(metadata.Key))).ConfigureAwait(false);
|
||||
Trace("hydrate-journals");
|
||||
|
||||
foreach (var pair in zones.Zip(resumes, (zone, resume) => (zone, resume: resume!)))
|
||||
coordinator.RestoreZone(frame.Run.RunId, frame.FrameId, pair.zone.ZoneIndex, pair.resume);
|
||||
Trace("restore-zones");
|
||||
return coordinator;
|
||||
}
|
||||
catch
|
||||
|
|
@ -320,9 +356,43 @@ public sealed class AetheriaYmirPersistenceCoordinator : IDisposable
|
|||
long firstEntryIndex) =>
|
||||
$"private:aetheria.ymir.journal.v1:{RunKey(runId)}:{zoneIndex}:{channel}:{generation}:{firstEntryIndex:D20}";
|
||||
|
||||
private static JournalRange[] JournalRanges(
|
||||
string runId,
|
||||
int zoneIndex,
|
||||
AetheriaYmirResumeDocument resume)
|
||||
{
|
||||
var world = YmirSessionCheckpointCodec.DecodeResumeDescriptor(resume.WorldDescriptorPayload);
|
||||
var ranges = new List<JournalRange>
|
||||
{
|
||||
new(JournalRecordPrefix(runId, zoneIndex, WorldChannel, world.SessionGeneration), world.JournalEntryCount)
|
||||
};
|
||||
if (resume.PayloadDescriptorPayload.Length > 0)
|
||||
{
|
||||
var payload = YmirSessionCheckpointCodec.DecodeResumeDescriptor(resume.PayloadDescriptorPayload);
|
||||
ranges.Add(new JournalRange(
|
||||
JournalRecordPrefix(runId, zoneIndex, PayloadChannel, payload.SessionGeneration),
|
||||
payload.JournalEntryCount));
|
||||
}
|
||||
return ranges.ToArray();
|
||||
}
|
||||
|
||||
private static string JournalRecordPrefix(
|
||||
string runId,
|
||||
int zoneIndex,
|
||||
string channel,
|
||||
string generation) =>
|
||||
$"private:aetheria.ymir.journal.v1:{RunKey(runId)}:{zoneIndex}:{channel}:{generation}:";
|
||||
|
||||
private static string RunKey(string runId) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(runId))).ToLowerInvariant();
|
||||
|
||||
private readonly record struct CursorKey(string RunId, int ZoneIndex, string Channel);
|
||||
private readonly record struct Cursor(string Generation, long EntryCount);
|
||||
private readonly record struct JournalRange(string Prefix, long EntryCount)
|
||||
{
|
||||
public bool Contains(string key) =>
|
||||
key.StartsWith(Prefix, StringComparison.Ordinal) &&
|
||||
long.TryParse(key.AsSpan(Prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var firstEntryIndex) &&
|
||||
firstEntryIndex < EntryCount;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ await using var node = await AetheriaStateNode.OpenAsync(
|
|||
options.StatePath,
|
||||
runtimeId: options.DaemonId,
|
||||
startServer: true,
|
||||
enableDurableShardLogs: false).ConfigureAwait(false);
|
||||
enableDurableShardLogs: false,
|
||||
hydrationProfile: AetheriaStateHydrationProfile.DaemonBoot).ConfigureAwait(false);
|
||||
TraceStartup("open-state-node");
|
||||
using var discoveryHost = new AetheriaVerseDiscoveryHost(node);
|
||||
var latestFrame = await node.MutableDocument<AetheriaRuntimeDaemonFrameDocument>(AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest).ReadAsync().ConfigureAwait(false);
|
||||
|
|
@ -87,7 +88,10 @@ var nativeCatalogChanged = await AetheriaDaemonNativeCatalog.EnsureAsync(node).C
|
|||
TraceStartup("native-catalog");
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
TraceStartup("initial-flush");
|
||||
if (persistedRuntimeCatalog == null || tradePolicyChanged || nativeCatalogChanged)
|
||||
if (persistedRuntimeCatalog == null ||
|
||||
string.IsNullOrWhiteSpace(persistedRuntimeCatalog.NameCorpusRecordKey) ||
|
||||
tradePolicyChanged ||
|
||||
nativeCatalogChanged)
|
||||
await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false);
|
||||
TraceStartup("runtime-catalog");
|
||||
await EnsurePlayableRunDocumentsAsync(node, options, startedAtUtc).ConfigureAwait(false);
|
||||
|
|
@ -2002,7 +2006,9 @@ static async Task PublishDaemonMenuSurfacesAsync(
|
|||
catalog,
|
||||
frame.Run?.RunId ?? frame.SessionId,
|
||||
"cultmesh://aetheria/assets");
|
||||
var loadoutTemplates = AetheriaRuntimeCatalogStore.ReadLoadoutTemplates(node.StatePath);
|
||||
var loadoutTemplates = node.Cache.GetAll<AetheriaLoadoutTemplate>()
|
||||
.Select(ToLoadoutTemplateSnapshot)
|
||||
.ToArray();
|
||||
var playerSettings = await ReadRuntimePlayerSettingsAsync(node).ConfigureAwait(false);
|
||||
var currentEntity = AetheriaRuntimeGameDocuments.CurrentEntity(frame);
|
||||
var stationRefit = AetheriaRuntimeGameDocuments.StationRefit(frame, loadoutTemplates, catalog);
|
||||
|
|
@ -2871,7 +2877,10 @@ static async Task EnsurePlayableRunDocumentsAsync(
|
|||
}
|
||||
|
||||
await AetheriaDaemonRunFactory.WriteAsync(
|
||||
node, node.RuntimeCatalog().Latest(), now, now).ConfigureAwait(false);
|
||||
node,
|
||||
await node.RuntimeCatalogForGenerationAsync().ConfigureAwait(false),
|
||||
now,
|
||||
now).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static async Task EnsureGameSessionAsync(
|
||||
|
|
@ -2933,7 +2942,10 @@ static async Task<bool> ApplyRequestedNewGameSessionAsync(
|
|||
|
||||
var now = DateTimeOffset.UtcNow.ToString("O");
|
||||
var written = await AetheriaDaemonRunFactory.WriteAsync(
|
||||
node, node.RuntimeCatalog().Latest(), now, menu.LastCommandId).ConfigureAwait(false);
|
||||
node,
|
||||
await node.RuntimeCatalogForGenerationAsync().ConfigureAwait(false),
|
||||
now,
|
||||
menu.LastCommandId).ConfigureAwait(false);
|
||||
session.Mode = written.SessionMode;
|
||||
session.SessionId = options.SessionId;
|
||||
session.RunId = written.RunId;
|
||||
|
|
@ -3476,6 +3488,65 @@ static AetheriaRuntimeLoadoutItemCommit ToLoadoutItemCommit(AetheriaLoadoutItem?
|
|||
};
|
||||
}
|
||||
|
||||
static AetheriaRuntimeLoadoutTemplateSnapshot ToLoadoutTemplateSnapshot(AetheriaLoadoutTemplate template)
|
||||
{
|
||||
return new AetheriaRuntimeLoadoutTemplateSnapshot(
|
||||
template.Name ?? "",
|
||||
template.OwnerPlayerKey ?? "",
|
||||
ToEntityLoadoutSnapshot(template.RootEntity),
|
||||
template.CreatedAtUtc ?? "",
|
||||
template.UpdatedAtUtc ?? "");
|
||||
}
|
||||
|
||||
static AetheriaRuntimeEntityLoadoutSnapshot ToEntityLoadoutSnapshot(AetheriaEntityLoadout? entity)
|
||||
{
|
||||
entity ??= new AetheriaEntityLoadout();
|
||||
return new AetheriaRuntimeEntityLoadoutSnapshot(
|
||||
entity.Name ?? "",
|
||||
entity.Kind ?? "",
|
||||
entity.FactionKey ?? "",
|
||||
ToLoadoutItemSnapshot(entity.Hull),
|
||||
ToSlotSnapshots(entity.Equipment),
|
||||
ToSlotSnapshots(entity.CargoBays),
|
||||
ToSlotSnapshots(entity.DockingBays),
|
||||
ToCargoSnapshots(entity.CargoContents),
|
||||
ToCargoSnapshots(entity.DockingBayContents),
|
||||
(entity.DockingBayAssignments ?? []).ToArray(),
|
||||
(entity.WeaponGroups ?? []).Select(group => (IReadOnlyList<int>)group.ToArray()).ToArray(),
|
||||
(entity.Children ?? []).Select(ToEntityLoadoutSnapshot).ToArray());
|
||||
}
|
||||
|
||||
static AetheriaRuntimeLoadoutItemSnapshot ToLoadoutItemSnapshot(AetheriaLoadoutItem? item)
|
||||
{
|
||||
item ??= new AetheriaLoadoutItem();
|
||||
return new AetheriaRuntimeLoadoutItemSnapshot(
|
||||
item.ItemKey ?? "",
|
||||
item.Quality,
|
||||
item.Durability,
|
||||
item.Quantity,
|
||||
item.Enabled,
|
||||
item.OverrideShutdown,
|
||||
item.Temperature);
|
||||
}
|
||||
|
||||
static AetheriaRuntimeLoadoutItemSlotSnapshot[] ToSlotSnapshots(IEnumerable<AetheriaLoadoutItemSlot>? slots)
|
||||
{
|
||||
return (slots ?? [])
|
||||
.Select(slot => new AetheriaRuntimeLoadoutItemSlotSnapshot(
|
||||
slot.Position?.X ?? 0,
|
||||
slot.Position?.Y ?? 0,
|
||||
ToLoadoutItemSnapshot(slot.Item),
|
||||
slot.Rotation ?? "None"))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
static AetheriaRuntimeCargoBayLoadoutSnapshot[] ToCargoSnapshots(IEnumerable<AetheriaCargoBayLoadout>? bays)
|
||||
{
|
||||
return (bays ?? [])
|
||||
.Select(bay => new AetheriaRuntimeCargoBayLoadoutSnapshot(ToSlotSnapshots(bay.Items)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal sealed record AetheriaPreparedPublication(
|
||||
AetheriaRuntimeDaemonTickResult Publication,
|
||||
IReadOnlyList<AetheriaYmirZonePersistenceCapture> Physics,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public static class AetheriaDocumentRegistry
|
|||
typeof(AetheriaLegacyCatalogQuarantine),
|
||||
typeof(AetheriaTradeValuePolicy),
|
||||
typeof(AetheriaRuntimeCatalogSnapshot),
|
||||
typeof(AetheriaRuntimeNameCorpusSnapshot),
|
||||
typeof(AetheriaPlayerSettings),
|
||||
typeof(AetheriaLoadoutTemplate),
|
||||
typeof(AetheriaRunState),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
|
|
@ -9,13 +11,22 @@ using GameCult.Mesh;
|
|||
using GameCult.Networking;
|
||||
using R3;
|
||||
using EveSurfaceDocument = GameCult.Eve.Surface.EveSurfaceDocument;
|
||||
using EveSurfaceCommandRequest = GameCult.Eve.Surface.EveSurfaceCommandRequest;
|
||||
using EveCommandReceiptDocument = GameCult.Eve.Surface.EveCommandReceiptDocument;
|
||||
|
||||
namespace Aetheria.State;
|
||||
|
||||
public enum AetheriaStateHydrationProfile
|
||||
{
|
||||
All,
|
||||
DaemonBoot
|
||||
}
|
||||
|
||||
public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
|
||||
{
|
||||
private readonly CultMeshNode _node;
|
||||
private CultMeshDocumentHandle<AetheriaRuntimeCatalogSnapshot>? _runtimeCatalog;
|
||||
private CultMeshDocumentHandle<AetheriaRuntimeNameCorpusSnapshot>? _runtimeNameCorpus;
|
||||
private CultMeshDocumentHandle<EveSurfaceDocument>? _catalogSurface;
|
||||
|
||||
private AetheriaStateNode(string statePath, string runtimeId, CultMeshNode node)
|
||||
|
|
@ -42,7 +53,8 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
|
|||
string runtimeId = "aetheria-local",
|
||||
bool startServer = false,
|
||||
bool enableDurableShardLogs = true,
|
||||
bool useDirectoryStore = true)
|
||||
bool useDirectoryStore = true,
|
||||
AetheriaStateHydrationProfile hydrationProfile = AetheriaStateHydrationProfile.All)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statePath))
|
||||
{
|
||||
|
|
@ -52,6 +64,7 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
|
|||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(statePath)) ?? ".");
|
||||
|
||||
var cacheRegistry = AetheriaDocumentRegistry.CreateCultCacheRegistry();
|
||||
var hydrationFilter = CreateHydrationFilter(hydrationProfile, cacheRegistry);
|
||||
var node = await CultMesh.CreateNodeAsync(
|
||||
statePath,
|
||||
new CultMeshNodeOptions
|
||||
|
|
@ -63,7 +76,8 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
|
|||
Registry = cacheRegistry,
|
||||
PullOnOpen = true,
|
||||
StoreFlushOnDispose = true,
|
||||
UseDirectoryStore = useDirectoryStore
|
||||
UseDirectoryStore = useDirectoryStore,
|
||||
DirectoryStoreHydrationFilter = hydrationFilter
|
||||
},
|
||||
DatabaseOptions = new CultNetDatabaseOptions
|
||||
{
|
||||
|
|
@ -75,6 +89,51 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
|
|||
return new AetheriaStateNode(statePath, runtimeId, node);
|
||||
}
|
||||
|
||||
private static Func<CultPersistedRecordMetadata, bool>? CreateHydrationFilter(
|
||||
AetheriaStateHydrationProfile profile,
|
||||
CultDocumentRegistry registry)
|
||||
{
|
||||
if (profile == AetheriaStateHydrationProfile.All)
|
||||
return null;
|
||||
if (profile != AetheriaStateHydrationProfile.DaemonBoot)
|
||||
throw new ArgumentOutOfRangeException(nameof(profile), profile, "Unknown Aetheria state hydration profile.");
|
||||
|
||||
var schemaTypes = new[]
|
||||
{
|
||||
typeof(AetheriaItemDefinition),
|
||||
typeof(AetheriaLoadoutTemplate),
|
||||
typeof(AetheriaRunState),
|
||||
typeof(AetheriaZoneState),
|
||||
typeof(AetheriaEntitySnapshot),
|
||||
typeof(AetheriaRuntimeAuthorityLeaseDocument),
|
||||
typeof(AetheriaRuntimeDaemonCommandDocument),
|
||||
typeof(AetheriaRuntimeCommittedCommandFactDocument),
|
||||
typeof(AetheriaRuntimeEveCommandDocument),
|
||||
typeof(EveSurfaceCommandRequest),
|
||||
typeof(EveCommandReceiptDocument)
|
||||
};
|
||||
var schemaIds = schemaTypes
|
||||
.Select(registry.GetRequired)
|
||||
.SelectMany(descriptor => descriptor.ToCatalogEntry().CompatibleSchemaIds.Append(descriptor.SchemaId))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var recordKeys = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
WorldKey.ToString(),
|
||||
RuntimeCatalogKey.ToString(),
|
||||
TradeValuePolicyKey.ToString(),
|
||||
PlayerSettingsKey.ToString(),
|
||||
VerseHostSettingsKey.ToString(),
|
||||
GameSessionStateKey.ToString(),
|
||||
MainMenuStateKey.ToString(),
|
||||
EveCommandAcceptanceStatusKey.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.VerseAuthorityPolicy.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.StarbridgeScenarioLatest.ToString(),
|
||||
AetheriaRuntimeVerseRecordKeys.StarbridgeSessionLatest.ToString()
|
||||
};
|
||||
return record => recordKeys.Contains(record.Key) || schemaIds.Contains(record.SchemaId);
|
||||
}
|
||||
|
||||
public CultMeshDocumentHandle<TDocument> Document<TDocument>(CultRecordKey key)
|
||||
where TDocument : class
|
||||
{
|
||||
|
|
@ -127,6 +186,50 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
|
|||
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "Aetheria typed catalog state"));
|
||||
}
|
||||
|
||||
public CultMeshDocumentHandle<AetheriaRuntimeNameCorpusSnapshot> RuntimeNameCorpus()
|
||||
{
|
||||
return _runtimeNameCorpus ??= CultMesh.Document(
|
||||
"aetheria.catalog.runtime-names",
|
||||
CultMesh.Verse("aetheria.local", RuntimeId),
|
||||
async _ => await Database.GetAsync<AetheriaRuntimeNameCorpusSnapshot>(RuntimeNameCorpusKey)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new InvalidDataException("The compiled Aetheria runtime name corpus is missing."),
|
||||
_ => Database.WatchRecord<AetheriaRuntimeNameCorpusSnapshot>(RuntimeNameCorpusKey)
|
||||
.Where(change => change.Document != null)
|
||||
.Select(change => change.Document!),
|
||||
sources: new[]
|
||||
{
|
||||
CultMesh.ProjectionSource(
|
||||
RuntimeNameCorpusKey.ToString(),
|
||||
AetheriaRuntimeNameCorpusSnapshot.SchemaId,
|
||||
"managed Aetheria runtime name corpus")
|
||||
},
|
||||
routeHint: new CultMeshRouteHint(
|
||||
CultMeshLocalityKind.SharedMemory,
|
||||
"Aetheria typed name generation corpus"));
|
||||
}
|
||||
|
||||
public async Task<AetheriaRuntimeCatalogSnapshot> RuntimeCatalogForGenerationAsync()
|
||||
{
|
||||
var catalog = RuntimeCatalog().Latest()
|
||||
?? throw new InvalidDataException("The compiled Aetheria runtime catalog is missing.");
|
||||
if (Cache.Get<AetheriaRuntimeNameCorpusSnapshot>(RuntimeNameCorpusKey) == null)
|
||||
{
|
||||
await Cache.PullBackingStoreRecordsAsync(metadata =>
|
||||
string.Equals(metadata.Key, RuntimeNameCorpusKey.ToString(), StringComparison.Ordinal)).ConfigureAwait(false);
|
||||
}
|
||||
var corpus = await RuntimeNameCorpus().LatestAsync().ConfigureAwait(false);
|
||||
return new AetheriaRuntimeCatalogSnapshot(
|
||||
catalog.Items.ToArray(),
|
||||
catalog.Corporations.ToArray(),
|
||||
corpus.NameFiles.ToArray(),
|
||||
catalog.TradeValueSettings)
|
||||
{
|
||||
CatalogId = catalog.CatalogId,
|
||||
NameCorpusRecordKey = RuntimeNameCorpusKey.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
public CultMeshDocumentHandle<EveSurfaceDocument> CatalogSurface()
|
||||
{
|
||||
var catalog = RuntimeCatalog();
|
||||
|
|
@ -157,6 +260,21 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
|
|||
public async Task<AetheriaRuntimeCatalogSnapshot> RefreshRuntimeCatalogAsync()
|
||||
{
|
||||
var snapshot = AetheriaRuntimeCatalogStore.OpenReadOnly(StatePath);
|
||||
await Database.PutAsync(
|
||||
RuntimeNameCorpusKey,
|
||||
new AetheriaRuntimeNameCorpusSnapshot
|
||||
{
|
||||
NameFiles = snapshot.NameFiles.ToArray()
|
||||
}).ConfigureAwait(false);
|
||||
snapshot.NameFiles = snapshot.NameFiles
|
||||
.Select(nameFile => new AetheriaRuntimeNameFile(
|
||||
nameFile.NameFileKey,
|
||||
nameFile.Name,
|
||||
nameFile.NameCount,
|
||||
nameFile.SampleNames.ToArray(),
|
||||
Array.Empty<string>()))
|
||||
.ToArray();
|
||||
snapshot.NameCorpusRecordKey = RuntimeNameCorpusKey.ToString();
|
||||
await Database.PutAsync(RuntimeCatalogKey, snapshot).ConfigureAwait(false);
|
||||
return snapshot;
|
||||
}
|
||||
|
|
@ -252,6 +370,9 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
|
|||
public static CultRecordKey RuntimeCatalogKey { get; } =
|
||||
new("global:aetheria.runtime_catalog.v1");
|
||||
|
||||
public static CultRecordKey RuntimeNameCorpusKey { get; } =
|
||||
new("global:aetheria.runtime_name_corpus.v1");
|
||||
|
||||
public static CultRecordKey OperationsSurfaceKey { get; } =
|
||||
new(AetheriaEveSurfaceDocuments.OperationsSurfaceKey);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"com.unity.modules.physics": "1.0.0",
|
||||
"com.unity.modules.uielements": "1.0.0",
|
||||
"com.unity.render-pipelines.universal": "17.4.0",
|
||||
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.39",
|
||||
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.40",
|
||||
"org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3",
|
||||
"org.gamecult.eve.surface": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.2.4",
|
||||
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.99"
|
||||
|
|
|
|||
|
|
@ -127,11 +127,11 @@
|
|||
}
|
||||
},
|
||||
"org.gamecult.cultlib": {
|
||||
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.39",
|
||||
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.40",
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {},
|
||||
"hash": "54e2e69ff7f4f5e0a1316bb047c315e65ed2a185"
|
||||
"hash": "cce542d82467a80519803c04f0c4e43c7e105fe8"
|
||||
},
|
||||
"org.gamecult.eve.plugin-fields": {
|
||||
"version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3",
|
||||
|
|
@ -156,7 +156,7 @@
|
|||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {
|
||||
"org.gamecult.cultlib": "1.0.39",
|
||||
"org.gamecult.cultlib": "1.0.40",
|
||||
"org.gamecult.eve.plugin-fields": "0.2.3",
|
||||
"org.gamecult.eve.surface": "0.2.4",
|
||||
"com.unity.mathematics": "1.3.2",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ namespace GameCult.Aetheria.State.Verse
|
|||
[Key(4)]
|
||||
public AetheriaRuntimeTradeValueSettings TradeValueSettings { get; set; } = AetheriaRuntimeTradeValueSettings.Default;
|
||||
|
||||
[Key(5)]
|
||||
public string NameCorpusRecordKey { get; set; } = string.Empty;
|
||||
|
||||
public AetheriaRuntimeCatalogItem? FindItem(string itemKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(itemKey))
|
||||
|
|
@ -138,6 +141,21 @@ namespace GameCult.Aetheria.State.Verse
|
|||
}
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.aetheria.runtime_name_corpus", "gamecult.aetheria.runtime_name_corpus.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaRuntimeNameCorpusSnapshot
|
||||
{
|
||||
public const string SchemaId = "gamecult.aetheria.runtime_name_corpus.v1";
|
||||
public const string DocumentName = "aetheria.runtime_name_corpus";
|
||||
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string CorpusId { get; set; } = DocumentName;
|
||||
|
||||
[Key(1)]
|
||||
public IReadOnlyList<AetheriaRuntimeNameFile> NameFiles { get; set; } = Array.Empty<AetheriaRuntimeNameFile>();
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.aetheria.loadout_templates", "gamecult.aetheria.loadout_templates.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class AetheriaRuntimeLoadoutTemplatesDocument
|
||||
|
|
|
|||
|
|
@ -232,13 +232,18 @@ namespace GameCult.Aetheria.State.Verse
|
|||
var records = new List<PersistedRecord>();
|
||||
var reader = new MessagePackReader(File.ReadAllBytes(stateFilePath));
|
||||
var snapshotFields = reader.ReadArrayHeader();
|
||||
if (snapshotFields > 0) reader.Skip();
|
||||
var formatVersion = snapshotFields > 0 ? ReadString(ref reader) : "";
|
||||
if (snapshotFields > 1) reader.Skip();
|
||||
if (snapshotFields > 2)
|
||||
{
|
||||
var recordCount = reader.ReadArrayHeader();
|
||||
for (var index = 0; index < recordCount; index++)
|
||||
records.Add(ReadPersistedRecord(ref reader));
|
||||
{
|
||||
if (string.Equals(formatVersion, "cultcache.store.v2.directory-indexed", StringComparison.Ordinal))
|
||||
reader.Skip();
|
||||
else
|
||||
records.Add(ReadPersistedRecord(ref reader));
|
||||
}
|
||||
}
|
||||
|
||||
var recordDirectory = stateFilePath + ".records";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Aetheria Current Codebase Model
|
||||
|
||||
Date: 2026-07-19
|
||||
Date: 2026-07-20
|
||||
|
||||
This is a modeling pass over the live codebase as it exists now. It is not the
|
||||
ideal architecture; it is the current control-flow map, with migration pressure
|
||||
|
|
@ -37,7 +37,10 @@ Current daemon control flow:
|
|||
|
||||
1. Parse daemon options: state path, daemon id, session id, Verse id, CultMesh
|
||||
address, tick interval, fixed delta.
|
||||
2. Open `AetheriaStateNode` with `startServer: true`.
|
||||
2. Open `AetheriaStateNode` with `startServer: true` and the daemon boot
|
||||
hydration profile. The indexed CultCache manifest selects the control-plane
|
||||
schemas and exact singleton records required to start; authored name files,
|
||||
the compiled name corpus, asset bodies, and unrelated history remain cold.
|
||||
3. Start `AetheriaVerseDiscoveryHost`.
|
||||
4. Ensure world, Verse host settings, runtime session, compatibility surfaces,
|
||||
and first daemon tick.
|
||||
|
|
@ -67,6 +70,43 @@ intent, docking, loot, trade, loadout, and action-bar operations, but it is not
|
|||
yet a complete standalone simulation engine for every behavior that still lives
|
||||
under Unity `Assets/Scripts/ServerShared/Behaviors`.
|
||||
|
||||
### Daemon boot working set
|
||||
|
||||
- **Owner:** `AetheriaStateNode` owns the daemon boot record selection;
|
||||
`DirectoryMessagePackBackingStore` owns key-to-page discovery and durable
|
||||
retention. A service registry contains only Aetheria's explicitly declared
|
||||
document types.
|
||||
- **Inputs:** the indexed manifest, exact singleton boot keys, and the schema
|
||||
identities for live commands, receipts, item/loadout definitions, and
|
||||
current run/zone/entity state.
|
||||
- **Outputs:** a compact live CultCache working set. The runtime catalog core
|
||||
contains items, corporations, trade settings, and name-file summaries; the
|
||||
full name corpus is a separate typed cold record loaded explicitly only when
|
||||
generation needs it.
|
||||
- **Derived state:** the catalog Eve surface and ordinary daemon ticks use the
|
||||
compact core. Authored name files remain migration/compiler inputs, not boot
|
||||
dependencies.
|
||||
- **Forbidden writers:** daemon boot must not rescan record pages, reread legacy
|
||||
name files, or let global assembly discovery expand its registry. A filtered
|
||||
flush must not erase cold records.
|
||||
- **Shared paths:** first import writes the indexed representation; daemon boot
|
||||
selects its working set from that index; new-run generation uses CultCache's
|
||||
selective pull primitive to hydrate the name corpus through the same open
|
||||
store.
|
||||
- **Cut line:** the monolithic runtime catalog no longer embeds twelve megabytes
|
||||
of names, and menu bootstrap no longer rescans loadout pages from disk.
|
||||
- **Verification layer:** backing-store tests hold an unrelated cold page under
|
||||
an exclusive lock while selectively hydrating another record. Startup phase
|
||||
traces report state open, catalog, Ymir restore, and client publication costs.
|
||||
|
||||
The remaining restart bottleneck is inside Ymir reconstruction, not CultCache
|
||||
I/O: the current resume format validates state by replaying its entire
|
||||
generation journal. Indexed hydration reduced journal page loading to tens of
|
||||
milliseconds, but replay remains proportional to session lifetime. Ymir needs
|
||||
a bounded baseline checkpoint plus journal tail before daemon restart cost is
|
||||
independent of uptime; increasing timeouts or hiding the phase is not an
|
||||
acceptable substitute.
|
||||
|
||||
## Materialized Import And Publication Ownership
|
||||
|
||||
### Catalog import
|
||||
|
|
|
|||
Loading…
Reference in a new issue