Skip to content

Persist effect result types as ids through a dedicated type store - #254

Merged
stidsborg merged 10 commits into
mainfrom
dotnet-type-store
Aug 9, 2026
Merged

stidsborg merged 10 commits into
mainfrom
dotnet-type-store

Conversation

@stidsborg

@stidsborg stidsborg commented Aug 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

Types persisted inside effect results and messages are no longer stored as inline encoded type strings - they are stored as content-derived ids mapped through a dedicated type store.

  • TypeId: a readonly record struct wrapping a long - the first 8 bytes of the SHA-256 hash of the type's encoded form (UTF-8 of its simple qualified name). Since the id is content-derived it is computed purely, with no store round-trip and no cross-replica coordination.
  • ITypeStore persists the id → encoded-type mapping ({prefix}_types table in the in-memory, PostgreSQL, SqlServer and MariaDB stores). Inserts are idempotent: Postgres uses unnest array parameters with ON CONFLICT DO NOTHING, MariaDB INSERT IGNORE, SqlServer a multi-row VALUES filtered by NOT EXISTS.
  • TypeMapper (one per registry) mints ids (GetTypeId, cached per Type), resolves them back (ResolveType), and enforces the crash invariant via the parameterless EnsurePersisted(): every id minted by this process that is not yet known-durable is inserted before any durable write referencing it - effect flush, message append, flow creation and control-panel writes all await it first. Persisting all minted ids is what also covers ids buried inside already-encoded payloads (staged-message children written flushlessly), without threading ids through the pipeline. _serializedTypes only ever contains persisted mappings - entries are added after the insert completes - so concurrent writers may insert the same idempotent mapping twice rather than queue behind a lock.
  • Resolution is async (ResolveType returns Task<Type>, completing synchronously on a cache hit). Only the first resolution of an id minted by another process reaches the store, and those refreshes are admitted one at a time: a refresh fetches the whole mapping table, so waiters re-check before doing I/O of their own and the first refresh resolves the whole batch a restart misses on. This also keeps the store round-trip out of EffectResults' _sync lock - the deserializing paths snapshot the (immutable) stored effect under the lock and resolve outside it.
  • Effects: StoredEffect.ResultType is a TypeId? instead of the serializer-encoded type bytes.
  • Messages: SerializedMessage.Type, StoredMessage.MessageType (both TypeId? - null marks an empty restart-poke, replacing the empty-content-and-type convention), StoredDlqMessage.MessageType and the PendingMessages effect-carrier encoding all carry the id. Producers mint through the registry's TypeMapper (MessageSender, QueueManager staging, control-panel appends, initial messages); consumers resolve through it (MessageDeserializer, QueueClient, ExistingMessages). Store row/blob formats keep their shape - the type piece is just 8 bytes (or null) now - so no message-table schema changes.
  • ISerializer no longer serializes types: the SerializeType/ResolveType default methods are deleted; the framework-owned encoding lives in TypeHelper extensions used by the TypeMapper.
  • The flow-type store moves to IFunctionStore.FlowTypeStore, freeing the TypeStore property for the new store.

Note: pre-existing databases need re-initialization - store Initialize() is skipped when the main tables already exist, so the new type tables (_types for .NET types, _flowtypes for flow types) are only created on a fresh schema.

Public members that became task-returning with the async resolution: TypeMapper.ResolveType, the StoredEffect.ResolveResultType extension, and the two DefaultDeserialize methods (both already marked //todo remove). Effect's TryGet/Get also changed shape - TryGet returns (bool Success, T? Value) since out params cannot survive async - but they are internal.

Test plan

  • New TypeStoreSunshineScenarioTest covering insert/get-all/idempotent re-insert in all four stores; the flow-type variant renamed to FlowTypeStoreSunshineScenarioTest.
  • EffectResultTypeTests resolve persisted types through a fresh TypeMapper over the store, exercising the refresh-on-miss path.
  • Test-side TypeIdTestHelper mints-and-persists ids for directly-constructed messages; DefaultDeserialize resolves through the mapper.
  • Full suites green: in-memory (557), PostgreSQL (393), SqlServer (393), MariaDB (393).

🤖 Generated with Claude Code

StoredEffect.ResultType is now a long - the first 8 bytes of the SHA-256
hash of the type's encoded form (UTF-8 of its simple qualified name) -
instead of the inline encoded type. The new ITypeStore persists the
id -> encoded-type mapping in a {prefix}_dotnet_types table in every
store; ids are content-derived, so inserts are idempotent.

The registry-wide TypeMapper computes ids without touching the store and
tracks which mappings are known-persisted. Every effect write path
awaits TypeMapper.EnsurePersisted before handing effects to the store
(EffectResults.Flush, control-panel effect writes, staged-message
children and CreateFunction's initial effects), so a mapping row is
always durable before the first effect referencing it - a crash between
the two writes can never leave effects whose results cannot be
deserialized. Resolution of an unknown id refreshes the whole (small)
mapping table once per process.

The flow-type store moves to IFunctionStore.FlowTypeStore, freeing the
TypeStore property for the new store. ISerializer no longer serializes
types at all: the SerializeType/ResolveType default methods are deleted
and the encoding lives in TypeHelper extensions, shared by the TypeMapper
and the message paths (where message types remain inline bytes).
_serializedTypes now only ever contains mappings that are durable in the
type store: an entry is added after its insert has completed (or from a
store refresh), never before - making the dictionary itself the persisted
marker and removing the separate _persisted set. Concurrent
EnsurePersisted calls may insert the same mapping more than once; inserts
are idempotent, so duplicates are accepted rather than queued behind a
lock.

Bytes for a minted-but-not-yet-persisted id are recovered by a reverse
lookup over the ids GetTypeId has handed out - both when persisting the
mapping and when an effect created in this process is read back before
its first flush.
…e model

TypeId - a readonly struct wrapping the content-derived long - replaces
raw longs and inline encoded types everywhere a type travels: an effect's
ResultType, SerializedMessage.Type, StoredMessage/StoredDlqMessage's
MessageType and the PendingMessages effect-carrier encoding all carry a
TypeId now (8 bytes in the packed forms). A null message type marks an
empty restart-poke - the poke's empty-content-and-type encoding is gone.

Message producers mint ids through the registry's TypeMapper and
consumers resolve through it; the ISerializer-independent inline type
bytes are gone from the message pipeline. EnsurePersisted becomes
parameterless - it persists every id minted by this process that is not
yet known durable - so ids buried inside already-encoded payloads
(staged-message children, delivered-message captures) are covered by the
effect flush without threading them around, and MessageSender persists
before appending rows for the same reason.

Test-side, DefaultDeserialize takes the mapper it resolves through, and
directly-constructed messages mint-and-persist their type id via the new
TypeIdTestHelper.
The .NET-type mapping is the primary type table, so it takes the _types
name; the flow-type table moves to _flowtypes (matching its
IFlowTypeStore rename on main).
…ed ids

GetTypeId records a newly minted mapping in an unpersisted dictionary,
making EnsurePersisted's fast path an emptiness check instead of an
iteration over every minted type, and making unpersisted bytes
addressable by id - which replaces the reverse lookup over minted ids in
both the persist and the read-back-before-first-flush resolution paths.
Entries move to the persisted dictionary before they are drained, so a
concurrent reader always finds a mapping in at least one of the two.
GetTypeId seeds the cache with the Type it already has in hand, and the
first resolution of a foreign id fills it lazily - so repeated
ResolveType calls are a single dictionary lookup instead of a
Type.GetType round-trip over the encoded name.
TypeMapper.ResolveType returns a Task<Type>: the refresh a foreign id
falls back on was blocked on with GetAwaiter().GetResult(), and three of
its four call chains reached it from inside EffectResults' _sync lock -
so one first-resolution parked a thread pool thread on a store
round-trip and held every other effect operation on that flow behind it.
Nothing required the synchronous signature; every call site bottoms out
in an already-async method.

EffectResults' three deserializing paths (TryGet, CreateOrGet and the
generic InnerCapture) now snapshot the StoredEffect under the lock and
resolve outside it - safe because the pending change and the stored
effect are immutable records - so no store round-trip happens while the
effect state is locked. The new await sits only on the already-completed
return path, leaving the write paths' interleaving unchanged.

Out params cannot survive async, so EffectResults/Effect.TryGet return
(bool Success, T? Value) and Effect.Get returns a Task; both are
internal. IdempotencyKeys.Initialize follows its caller into async. The
public surface changes are ResolveType, the ResolveResultType extension
and the two test-only DefaultDeserialize methods, which now return tasks
- the test helper blocks on them rather than rippling awaits through
assertion sites, some of which sit inside LINQ predicates.
Resolving an unknown id refreshes the whole mapping table, so concurrent
misses each fetched it in full. A restart reading back a batch of
foreign payloads misses on many distinct ids at once, which made the cost
scale with the number of unknown types rather than with the one fetch
that already covers all of them.

A semaphore admits one refresh, and waiters re-check the persisted
mappings before reaching the store: the refresh they queued behind
fetched every type, so their id is present and they return without
further I/O. Only an id that genuinely is not in the store - the path to
the TypeLoadException below - refreshes again, so a mapping written
between the two attempts is still picked up rather than cached away as a
permanent miss.
@stidsborg
stidsborg merged commit 85bb569 into main Aug 9, 2026
8 checks passed
@stidsborg
stidsborg deleted the dotnet-type-store branch August 9, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant