From c20752cb85b3a1775e59d5d88e961a546497e70b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 20 Jul 2026 03:00:28 +0100 Subject: [PATCH] Keep bootstrap run records cold on restart --- Aetheria.State.Daemon/Program.cs | 50 ++++++++++++++++++------- Aetheria.State.Smoke/Program.cs | 25 +++++++++++++ Aetheria.State/AetheriaStateNode.cs | 3 -- docs/aetheria-current-codebase-model.md | 24 +++++++++--- 4 files changed, 79 insertions(+), 23 deletions(-) diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 76a204ac5..06c8c619a 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -94,9 +94,9 @@ if (persistedRuntimeCatalog == null || nativeCatalogChanged) await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false); TraceStartup("runtime-catalog"); -await EnsurePlayableRunDocumentsAsync(node, options, startedAtUtc).ConfigureAwait(false); +await EnsurePlayableRunDocumentsAsync(node, options, startedAtUtc, latestFrame).ConfigureAwait(false); TraceStartup("playable-run"); -await EnsureGameSessionAsync(node, options, startedAtUtc).ConfigureAwait(false); +await EnsureGameSessionAsync(node, options, startedAtUtc, latestFrame).ConfigureAwait(false); TraceStartup("game-session"); var verseHost = await EnsureVerseHostSettingsAsync(node, options, startedAtUtc).ConfigureAwait(false); TraceStartup("verse-host"); @@ -2964,7 +2964,8 @@ static async Task EnsureTradeValuePolicyAsync(AetheriaStateNode node, stri static async Task EnsurePlayableRunDocumentsAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options, - string now) + string now, + AetheriaRuntimeDaemonFrameDocument? latestFrame) { if (options.UseTerminusFixture) { @@ -2973,6 +2974,9 @@ static async Task EnsurePlayableRunDocumentsAsync( return; } + if (HasPlayableRun(latestFrame?.Run)) + return; + var settings = await node.MutableDocument(AetheriaStateNode.PlayerSettingsKey).ReadAsync().ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(settings?.ActiveRunKey)) { @@ -2991,15 +2995,13 @@ static async Task EnsurePlayableRunDocumentsAsync( static async Task EnsureGameSessionAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options, - string now) + string now, + AetheriaRuntimeDaemonFrameDocument? latestFrame) { - var player = await node.MutableDocument(AetheriaStateNode.PlayerSettingsKey) - .ReadAsync().ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(player?.ActiveRunKey)) - throw new InvalidDataException("Aetheria game session requires an active daemon run."); - var run = await node.MutableDocument(new CultRecordKey(player.ActiveRunKey)) - .ReadAsync().ConfigureAwait(false) - ?? throw new InvalidDataException($"Active Aetheria run is missing: {player.ActiveRunKey}"); + var run = !options.UseTerminusFixture && HasPlayableRun(latestFrame?.Run) + ? latestFrame!.Run + : await ReadRuntimeRunCheckpointAsync(node, options.RenderSettings).ConfigureAwait(false) + ?? throw new InvalidDataException("Aetheria game session requires an authoritative frame or active bootstrap run."); var expectedMode = string.IsNullOrWhiteSpace(run.GameMode) ? run.IsTutorial ? AetheriaGameSessionState.AetheriaMode : AetheriaGameSessionState.TerminusMode : run.GameMode; @@ -3088,7 +3090,9 @@ static async Task ReadRuntimeRunCheckpointA if (string.IsNullOrWhiteSpace(settings?.ActiveRunKey)) return null; - var run = await node.MutableDocument(new CultRecordKey(settings.ActiveRunKey)).ReadAsync().ConfigureAwait(false); + var run = await ReadDurableBootstrapDocumentAsync( + node, + new CultRecordKey(settings.ActiveRunKey)).ConfigureAwait(false); if (run == null) return null; @@ -3097,7 +3101,9 @@ static async Task ReadRuntimeRunCheckpointA var zoneKeys = run.ZoneKeys ?? Array.Empty(); for (var zoneIndex = 0; zoneIndex < zoneKeys.Length; zoneIndex++) { - var zone = await node.MutableDocument(new CultRecordKey(zoneKeys[zoneIndex])).ReadAsync().ConfigureAwait(false); + var zone = await ReadDurableBootstrapDocumentAsync( + node, + new CultRecordKey(zoneKeys[zoneIndex])).ConfigureAwait(false); if (zone == null) continue; @@ -3179,7 +3185,9 @@ static async Task ToRuntimeZoneAsync( var entities = new List(); for (var entityIndex = 0; entityIndex < entityKeys.Length; entityIndex++) { - var entity = await node.MutableDocument(new CultRecordKey(entityKeys[entityIndex])).ReadAsync().ConfigureAwait(false); + var entity = await ReadDurableBootstrapDocumentAsync( + node, + new CultRecordKey(entityKeys[entityIndex])).ConfigureAwait(false); if (entity != null) entities.Add(ToRuntimeEntity(entityKeys[entityIndex], entity, entityIndex, entityIndices, catalog)); } @@ -3212,6 +3220,20 @@ static async Task ToRuntimeZoneAsync( }; } +static async Task ReadDurableBootstrapDocumentAsync( + AetheriaStateNode node, + CultRecordKey key) + where TDocument : class +{ + if (node.Cache.Get(key) == null) + { + await node.Cache.PullBackingStoreRecordsAsync(metadata => + string.Equals(metadata.Key, key.ToString(), StringComparison.Ordinal)).ConfigureAwait(false); + } + + return await node.MutableDocument(key).ReadAsync().ConfigureAwait(false); +} + static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity( string entityKey, AetheriaEntitySnapshot entity, diff --git a/Aetheria.State.Smoke/Program.cs b/Aetheria.State.Smoke/Program.cs index 1cf04e0e7..7db9ff584 100644 --- a/Aetheria.State.Smoke/Program.cs +++ b/Aetheria.State.Smoke/Program.cs @@ -717,6 +717,31 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s await node.FlushAsync(); } +await using (var bootNode = await AetheriaStateNode.OpenAsync( + statePath, + "aetheria-state-smoke-daemon-boot", + hydrationProfile: AetheriaStateHydrationProfile.DaemonBoot)) +{ + if (bootNode.Cache.Get(AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest) == null) + throw new InvalidOperationException("Daemon boot did not hydrate the authoritative restart frame."); + if (bootNode.Cache.Get(runKey) != null || + bootNode.Cache.Get(zoneKey) != null || + bootNode.Cache.Get(entityKey) != null) + { + throw new InvalidOperationException("Daemon boot eagerly hydrated bootstrap run, zone, or entity records."); + } + + await bootNode.Cache.PullBackingStoreRecordsAsync(metadata => + string.Equals(metadata.Key, runKey.ToString(), StringComparison.Ordinal) || + string.Equals(metadata.Key, zoneKey.ToString(), StringComparison.Ordinal) || + string.Equals(metadata.Key, entityKey.ToString(), StringComparison.Ordinal)); + if (bootNode.Cache.Get(runKey)?.RunId != "smoke" || + bootNode.Cache.Get(zoneKey) == null || + bootNode.Cache.Get(entityKey) == null) + { + throw new InvalidOperationException("Cold bootstrap records could not be pulled explicitly for frame recovery."); + } +} await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aetheria-state-smoke-reopen")) { var world = await reopened.MutableDocument(AetheriaStateNode.WorldKey).ReadAsync(); diff --git a/Aetheria.State/AetheriaStateNode.cs b/Aetheria.State/AetheriaStateNode.cs index f8d9bc8c5..51ff5687d 100644 --- a/Aetheria.State/AetheriaStateNode.cs +++ b/Aetheria.State/AetheriaStateNode.cs @@ -126,9 +126,6 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable var schemaTypes = new[] { typeof(AetheriaLoadoutTemplate), - typeof(AetheriaRunState), - typeof(AetheriaZoneState), - typeof(AetheriaEntitySnapshot), typeof(AetheriaRuntimeAuthorityLeaseDocument), typeof(AetheriaRuntimeDaemonCommandDocument), typeof(AetheriaRuntimeCommittedCommandFactDocument), diff --git a/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index 53b6fd49e..0cee63232 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -77,22 +77,24 @@ under Unity `Assets/Scripts/ServerShared/Behaviors`. 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. + identities for live commands, receipts, and loadout definitions. The latest + committed daemon frame supplies restored gameplay truth; source run, zone, + and entity records remain cold. - **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 + compact core. Authored name files and source run/zone/entity documents remain + migration, generation, and missing-frame recovery inputs, not ordinary 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. + selects its working set from that index; new-run generation and missing-frame + recovery use CultCache's selective pull primitive to hydrate exact bootstrap + records 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 @@ -116,6 +118,16 @@ canonical MessagePack fallback. This reduced the profiled 52-page hydration from about 295 ms to 223 ms and cache open from about 353 ms to 296 ms. The 1.12 MB daemon frame and 574 KB runtime catalog remain the largest hot records. +The daemon frame is also the sole restored-run owner. `AetheriaRunState`, +`AetheriaZoneState`, and `AetheriaEntitySnapshot` are bootstrap/import records; +they are not tick-time mirrors and cannot override a playable committed frame. +Removing their schema-wide boot hydration reduced the profiled working set from +52 to 38 pages and one measured state-node open from 485 ms to 385 ms. If no +playable frame exists, checkpoint reconstruction explicitly pulls the active +run, then its referenced zones and entities by exact key. The state smoke proves +both that these records remain cold on an ordinary daemon boot and that the +durable fallback graph can still be recovered. + Ymir reconstruction remains full replay within a session generation, but it is now bounded by an explicit quiescent generation rollover. When a private world or payload journal reaches 64 commands, `AetheriaYmirWorldPhysics` asks Ymir to