ActionMapper: bound serialization breadth, move SQLite write off the action thread #28
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally filed by @coilysiren on 2026-05-19T02:33:38Z
Problem - With eco-replay installed, a single player click on the live server (coilysiren/infrastructure#183) allocated enough memory to freeze kai-server. Idle play was fine. Two structural bugs in the recorder make this possible.
Bug 1:
ActionMapper.ToRowserialization is bounded in depth but not breadth.BodySettingssetsMaxDepth = 2, which only constrains nesting.ShallowResolvershort-circuits properties whose declaredPropertyType.FullNameis exactly one ofUser/ItemStack/WorldObject/Deed. That misses:object,IAlias,IOwned, or any interface / base class. Runtime type doesn't match theFullNameexact-match, so Newtonsoft enumerates.IEnumerable<T>,IList<T>,IDictionary<,>,HashSet<T>of complex Eco entities (inventories, chunks, world-object lists, property graphs). MaxDepth=2 lets each element be fully expanded one layer.Errorhandler swallows exceptions but doesn't stop enumeration. A 100k-element collection of complex objects allocates GB of intermediate strings before anything errors.Click actions (interact / place / trade / claim) carry exactly these wide references. Idle actions don't.
Fix sketch:
ShallowResolver, walkprop.PropertyTypeand its base types + implemented interfaces againstSkipTypes, not just exactFullNamematch. Better: maintain an allow-list of safe primitive-ish property types and skip everything else.IEnumerable(other thanstringand primitive collections) at any depth as a count summary:\"<n items>\", not the full enumeration. A customJsonConverteron the resolver is cleaner than fightingDefaultContractResolver.body_jsonsize after the fact: if> 16 KBafter serialization, replace with{\"truncated\": true, \"action_type\": ...}.Bug 2: SQLite insert runs synchronously on Eco's action thread.
EventStore.InserttakeswriteLockand doesExecuteNonQueryfrom insideActionPerformed. That's on whatever thread Eco firesActionUtil. Under load this:Fix sketch:
Channel<EventRow>(bounded, DropOldest)betweenActionPerformedand a single backgroundTaskthat drains the channel and batches inserts.ActionPerformedbecomes: build row,TryWrite, return. Never blocks the game thread.Out of scope - the host-level memory cage already landed in coilysiren/infrastructure#183. That stops the host from freezing again, but the mod is still uninstalled until this issue closes.
Verify -
GameActionwith a 10k-itemIEnumerableproperty:ToRowreturns in bounded time,body_jsonsize is bounded.Storage/EcoReplay.dbgrows normally.MemoryHigh=10Gcgroup soft-cap does not get touched during normal play.Migrated from coilyco-flight-deck/eco-replay#1 during the eco repo consolidation (coilysiren/inbox#100).
🔒 Reserved by
ward agent --driver claude— containerengineer-claude-eco-app-28on hostKAI-DESKTOP-TOWERis carrying this issue (reserved 2026-07-05T03:20:00Z). Concurrentward agentruns are blocked until it finishes or the reservation goes stale (2h0m0s TTL);--forceoverrides.Do not comment on or edit this issue to steer the run while it is reserved. The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a new issue, dispatched fresh — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494).
— Claude (she/her), via
ward agentWARD-OUTCOME: done - bounded action-body serialization (allow-list resolver + 16 KB cap) and moved SQLite writes to a background bounded-channel batched writer; landed on main.
Retrospective from the container agent that took this one.
The shape of both fixes was clear from your writeup, so most of the work was picking the structural bound over patching the symptom. For bug 1 I stopped trying to teach the
DefaultContractResolverto skip the right types by name and instead flipped the default: serialize only an allow-list of scalar-ish property types, collapse everything else to a runtime summary (<name>/<n items>/ type label). That makes breadth and depth bounded by construction, and it neatly catches theobject/interface-typed refs the old exact-FullNamematch sailed past. Extracting it into an Eco-freeBodySerializeralso made it unit-testable without dragging in the reference-assembly feed. Bug 2 was the more mechanical of the two - a boundedChannelwithDropOldest, a single background drain that batches in a transaction, and pulling every connection touch (including the reads the API thread does) under one lock, since those were already quietly racing the inserts.The honest rough edge: this container has no dotnet SDK, and neither CI nor the Dockerfile builds the mods - they're built out of band and shipped to kai-server. So I could not compile or run the C# or the new xunit tests. Python (290 tests) is green and the precommit suite passed, but the C# is unexecuted. I'm confident in the serialization logic and the reasoning on the writer; least sure about the exact linger/shutdown edges of the drain loop and whether the
net10.0xunit package pins restore cleanly. Worth a follow-up: runward exec test-mod-replayon a box with dotnet before reinstalling on kai-server, then do the live RSS/MemoryHigh=10Gverification from step 2-3, which I can't reach from here.DropOldestalso silently sheds rows under overload without a counter (only post-close drops are counted) - fine for a recorder, but a candidate for a metric later.