AyCode.Core/AyCode.Core/docs/BINARY/BINARY_OPTIONS.md

13 KiB
Raw Blame History

AcBinary Configuration

Configuration options, presets, and option interactions for AcBinarySerializerOptions. Wire format: BINARY_FORMAT.md | Features: BINARY_FEATURES.md | Internal architecture: BINARY_IMPLEMENTATION.md.

WireMode

Value Integers Strings Output size Speed
Compact (default) VarInt/VarUInt (15 bytes) UTF-8 with speculative ASCII fast path Smaller Slightly slower
Fast Fixed-width raw bytes (4/8 bytes) UTF-16 memcpy (charCount * 2 bytes) Larger Fastest encode/decode

Format difference for strings:

  • Compact: [VarUInt byteLength] [UTF-8 bytes] — speculative ASCII (1 pass if all ASCII, rewind+UTF-8 fallback otherwise)
  • Fast: [VarUInt charCount] [raw UTF-16 bytes] — zero-encoding memcpy

Code branch: context.FastWire flag set at context.Reset(). Checked in WriteStringUtf8() and integer write methods. FixStr optimization is skipped in Fast mode (UTF-8 specific).

ReferenceHandling

Value Tracked objects Scan pass Header flags Wire markers
None Nothing Skipped 0x00 Standard object markers only
OnlyId IId objects only (by ID value) Partial 0x02 ObjectRefFirst(70) + ObjectRef(65)
All (default) All reference types Full graph walk 0x06 ObjectRefFirst(70) + ObjectRef(65)

Format impact: Multi-referenced objects written once with ObjectRefFirst(70) + VarUInt(refCacheIndex) on first encounter, then ObjectRef(65) + VarUInt(refCacheIndex) subsequently. Header HasCacheCount flag set; cache count written.

Interaction with ThrowOnCircularReference (default: true):

  • true + ref handling enabled: all objects tracked for cycle detection, throws InvalidOperationException on circular reference
  • false + ref handling enabled: only IId types tracked for deduplication, ⚠️ non-IId circular refs silently truncated at MaxDepth — see BINARY_ISSUES.md#accore-bin-i-j6t9.

UseMetadata

Value Wire markers Property matching Overhead
false (default) FixObj/Object Positional index only — types must match None
true ObjectWithMetadata(69) / ObjectWithMetadataRefFirst(71) FNV-1a property name hashes 4 bytes per property per type

Format impact: When enabled, each type's first occurrence writes [VarUInt hashCount] [FNV-1a hash × N] before properties. Deserializer uses hashes to build source→destination index mapping, enabling cross-type deserialization (different property sets/ordering).

Code branch: context.UseMetadata controls whether ObjectWithMetadata(69) or plain Object(64) markers are used. When false, IsDirectObjectWrite=true allows source-generated writers to bypass WriteObject entirely and inline property writes.

Related: CheckDuplicatePropName (default true) — throws on FNV-1a hash collision between property names of the same type. ⚠️ Disabling trades correctness for speed — see BINARY_ISSUES.md#accore-bin-i-c5r7 before turning off.

UseStringInterning

Value Eligible strings Scan overhead Wire markers
None Nothing None String(91) / FixStr only
Attribute (default) Properties with [AcStringIntern(true)] Scans marked properties StringInternFirst(94) + StringInterned(92)
All All strings within length limits Scans all strings StringInternFirst(94) + StringInterned(92)

Length limits: MinStringInternLength (default 4), MaxStringInternLength (default 64, 0=unlimited). Strings outside this range are always written inline.

Format impact: Interned strings on first occurrence: [StringInternFirst(94)] [VarUInt cacheIndex] [string data]. Subsequent: [StringInterned(92)] [VarUInt cacheIndex] (12 bytes vs full string). Single-occurrence strings are never interned — no overhead for unique strings.

Code branch: context.StringInternEligible flag set per-property before WriteString. Scan pass builds a WriteDuplicateEntry[] plan; write pass consumes it via cursor.

MaxDepth + MaxDepthBehavior

MaxDepth (byte, default 255) is the recursion depth limit. MaxDepthBehavior (enum, default Throw) decides what happens at the limit.

MaxDepth value Behavior
255 (default) Effectively unlimited nesting — safety net only triggers on pathological graphs
N (1..254) Action at depth N decided by MaxDepthBehavior
0 First nested object hits the limit immediately — useful with MaxDepthBehavior.Truncate for the "root + null nested" shallow-copy semantic
MaxDepthBehavior Default Action at depth limit Use case
Throw ✓ default Throws InvalidOperationException with type name + position Cycle detection, bug surfacing (fail-fast)
Truncate Writes Null(76) marker in place of the object Intentional shallow serialization (e.g. client→server delta updates where deep references are intentionally cut)
Disable Skips the depth check entirely Maximum hot-path perf; consumer guarantees a cycle-free graph

Only MaxDepthBehavior.Disable opts out the depth check entirely. Throw stays active even with ReferenceHandling = All, because per-type [AcBinarySerializable(EnableRefHandlingFeature = false)] attribute opt-outs leave non-tracked types in the graph — those can still form cycles that ref-handling alone cannot catch. The safety net covers per-type-opt-out cycle gaps + pathological-depth non-cyclic graphs.

Truncate mode — the key shallow-serialization feature. Depth-exceeded values appear as Null(76) on the wire. This is intentional and developer-controlled: the consumer endpoint contract decides what "null nested collection" means in context — typically "no change at this level" on a partial-update endpoint (POST /orders/{id}/header flat update) vs a separate full-save endpoint (POST /orders/{id}/full).

This pattern is significantly cheaper than the alternatives (DTO-mapping per entity, EF-Core .Select() projections, manual flat-DTOs) — zero setup-overhead, no DTO type-system duplication, and the unused sub-graphs are never serialized to wire (zero byte + CPU savings). The same entity type lives on client + server; MaxDepthBehavior=Truncate is the toggle. Works with any persistence layer (Dapper, ADO.NET raw SQL, Cosmos DB, MongoDB, Redis, etc.) — not EF-Core-specific.

The default Throw surfaces accidental depth-exceeded cases as a debuggable exception (offending type comes from the stack trace) so unintentional truncation can't sneak through — closes BINARY_ISSUES.md#accore-bin-i-p2h8.

Code branch: Depth check fires BEFORE any marker byte is written (in runtime WriteObject + SGen WriteObjectFullMarker* / direct-marker emit paths). On limit hit, TryEnterRecursion(hasTruncatePath) on the context (combined check + inc) dispatches Throw / Truncate / Disable. RecursionDepth is a private byte field on the context; ExitRecursion is the inlined helper that decrements at body exit. The _needsDepthCheck gate is pre-computed at Reset() as MaxDepthBehavior != Disable. Hot path: 1 ctx field-read + 1 byte-cmp + 1 inc per object-recursion entry (Truncate use case can be combined with any ReferenceHandling mode).

Known issue: SGen path + Truncate currently exhibits a wire-misalignment in specific test scenarios (DECIMAL_DRIFT on round-trip) — see BINARY_ISSUES.md#accore-bin-i-sg-truncate-pending (TBD). Runtime path + Truncate and SGen path + Throw/Disable are unaffected.

UseCompression

Value Method Granularity Memory
None (default) No compression
Block LZ4 single block Entire payload Full buffer in memory
BlockArray LZ4 chunked 64KB chunks Streaming-friendly, lower peak memory

Format impact: Compression is applied post-serialization as a transparent wrapper — the inner wire format is unchanged. Both modes are pure managed C# (WASM-compatible, no native dependencies).

Code branch: Applied in AcBinarySerializer.Serialize() after the context produces the raw buffer: if (UseCompression != None) Lz4.Compress(buffer, mode). Decompression auto on deserialize.

PropertyFilter

Optional delegate BinaryPropertyFilter? (default: null). When set, invoked for each property to decide inclusion.

delegate bool BinaryPropertyFilter(in BinaryPropertyFilterContext context);

BinaryPropertyFilterContext fields: DeclaringType, PropertyName, PropertyType, Instance (null during metadata phase), IsMetadataPhase, GetValue() (lazy).

Format impact: Excluded properties are completely absent from the stream — no marker, no placeholder. ⚠️ The deserializer must use UseMetadata=true or identical filter to correctly match property indices, otherwise positional drift causes silent corruption — see BINARY_ISSUES.md#accore-bin-i-w3f4.

Code branch: context.HasPropertyFilter checked in ShouldSerializeProperty(). Called twice: once during metadata registration (Instance=null), once during write phase.

PropertyMapper

Optional delegate PropertyMapperDelegate? (default null) for cross-type deserialization property remapping.

delegate PropertyInfo? PropertyMapperDelegate(PropertyInfo sourceProperty, Type destinationType);

Purpose: Maps properties between different class hierarchies (renamed properties, external DTOs). Result is cached — zero overhead on same-type operations (Deserialize<T>).

WASM Options

Option Default Purpose
IsWasm OperatingSystem.IsBrowser() Auto-detect WASM environment
UseStringCaching follows IsWasm Cache short strings during deserialization to reduce GC pressure
MaxCachedStringLength 64 Max string length to cache

Format impact: None — these are deserialization-only optimizations. When UseStringCaching=true, the deserializer maintains an intern cache for strings ≤ MaxCachedStringLength chars. Disabled automatically when StringInternFirst marker is encountered (interning takes precedence).

Other Options

Option Type Default Purpose
UseGeneratedCode bool true Use source-generated writers/readers when available. Enables SGen root fast path (see BINARY_SGEN.md)
InitialBufferCapacity int 16384 Starting buffer size (bytes) for serialization output
RemoveOrphanedItems bool false During PopulateMerge: remove destination collection items with no matching source ID
UseAsync bool false Async context pool return via ThreadPool. Auto-disabled in WASM and when ReferenceHandling=None
MaxContextPoolSize int 8 Max serialization contexts kept in pool

Presets

Preset WireMode Metadata StringInterning RefHandling MaxDepth MaxDepthBehavior Compression Other
Default Compact false Attribute All 255 Throw None
FastMode Compact false None None 255 Throw None No scan pass
FlatCopy Compact false None None 0 Truncate ⚠️ None Root + Null nested (the Truncate behavior makes this preset's semantic meaningful — under default Throw it would throw on first nested object)
WasmOptimized Compact false Attribute All 255 Throw None +StringCaching
WithoutReferenceHandling Compact false Attribute None 255 Throw None No scan pass
WithoutMetadata Compact false Attribute All 255 Throw None

Performance implication of presets:

  • Default / WasmOptimized — two-phase (scan + serialize) due to ReferenceHandling=All
  • FastMode / FlatCopy — single-phase (no scan pass) since both interning and refs are disabled
  • The scan pass adds ~20-30% overhead; disable it when the object graph is a simple tree

Option Interactions

Key interdependencies affecting code branches:

Combination Effect
ReferenceHandling=None + UseStringInterning=None No scan pass — fastest path, single-phase serialization
ReferenceHandling=All + UseMetadata=true Uses ObjectWithMetadataRefFirst(71) marker — combined ref + metadata
UseMetadata=false + UseGeneratedCode=true IsDirectObjectWrite=true — generated code inlines property writes, bypasses WriteObject
UseMetadata=true + PropertyFilter set Filter invoked twice (metadata phase + write phase); filter results must be stable
WireMode=Fast + UseStringInterning!=None Interned strings still use the fast string path (UTF-16 for first occurrence, VarUInt index for subsequent)
UseCompression!=None + any other option Compression is orthogonal — applied post-serialization, inner format unchanged