Plan for metadata in Light.Results
Goals & Constraints
- Metadata must be trivially serializable/deserializable across:
- HTTP payloads that conform to RFC 9457 (problem details).
- gRPC error trailers/payloads.
- Asynchronous messaging envelopes.
- The shape of metadata must be statically enforceable so that only JSON-compatible values are representable.
- Keep allocations to a minimum—ideally the metadata types are stack-only structs backed by small arrays/spans with pooling.
- Integration points:
- Replace the current
IReadOnlyDictionary<string, object?>? Meta on Error with the new types without breaking ergonomics for callers that only need primitive metadata @src/Light.Results/Error.cs#5-10.
- Introduce metadata support on
Result<T> (and non-generic Result) for correlation/context payloads that apply regardless of success/failure, while preserving zero-cost success cases @src/Light.Results/Result.cs#11-146.
- Ensure
Result<T> stays allocation-free when success paths avoid metadata.
Value Domain (JSON-Compatible)
- Primitive scalars: string, long (int64), double (IEEE 754), decimal-like precise value (optional), bool, null.
- Structured values:
- Arrays: ordered sequences of other metadata values.
- Objects: string-keyed property bags whose values are other metadata values.
- No delegates, type handles, or arbitrary CLR graphs; everything must collapse to the above recursively.
Core Types
-
MetadataValue
readonly struct acting as a discriminated union over the supported kinds.
- Fields:
MetadataKind Kind enum (Null, Boolean, Int64, Double, Decimal128?, String, Array, Object).
- Backing union (e.g.,
long _int64, double _double, string? _string, MetadataArray? _array, MetadataObject? _object).
- Factory members:
MetadataValue.FromString(...), implicit conversions from primitive CLR types.
- Validation guards the domain (e.g., forbidding NaN/Infinity for doubles unless we define canonical encoding).
-
MetadataArray
readonly struct wrapping an ImmutableArray<MetadataValue> or a pooled MetadataValue[] plus length.
- Provides enumerator without extra allocations.
- Builders:
MetadataArray.Create(ReadOnlySpan<MetadataValue> values)
MetadataArray.Builder that rents from ArrayPool<MetadataValue> for bulk construction.
-
MetadataObject
- Represents a small immutable map of string →
MetadataValue.
- Backing storage:
ImmutableArray<MetadataProperty> where MetadataProperty is (string Key, MetadataValue Value); optionally store sorted keys for deterministic serialization.
- Provide lookup by key using linear scan for small counts (<8) and optional
Dictionary fallback for larger payloads.
- Builder similar to array builder, ensuring key uniqueness.
-
MetadataMap
- Convenience wrapper for typical metadata use inside
Error. Could be either MetadataObject or a specialized type that enforces small payload semantics.
- Consider offering
MetadataMap.Empty, MetadataMap.FromPairs(...), and fluent With(...) helpers to encourage zero-allocation happy paths.
Immutability & Ownership
MetadataValue, MetadataArray, and MetadataObject are immutable once created; they never retain references to pooled buffers.
- Builders (
MetadataObjectBuilder, MetadataArrayBuilder) rent arrays from ArrayPool<T> but copy the contents into new, tightly-sized arrays (or ImmutableArray<T>) before producing a public instance, then immediately return the buffers to the pool.
- Document this hand-off agreement so senior engineers know there is no aliasing or threading risk when metadata escapes a builder scope.
Result-Level Metadata
- Motivation:
- Capture execution-wide metadata (correlation IDs, timing data, server node) even when no errors exist.
- Enable transport layers to propagate metadata without materializing an
Error.
- Design:
- Add optional
MetadataObject? Metadata property on Result<T> and the non-generic Result.
- Store metadata in a dedicated field rather than piggybacking on
_errors to avoid conflating concerns.
- Provide fluent helpers (
WithMetadata, MergeMetadata) that return new Result<T> without copying when the metadata reference matches.
- When upgrading
Result<T> with new metadata on success, delay allocation by using MetadataObjectBuilder that writes into a pooled buffer only if metadata is actually added.
- On failure, propagate both
Result<T>.Metadata (context) and Error.Metadata (per-error) when serializing.
- Serialization: envelope-level metadata maps to RFC 9457’s top-level extension members, while per-error metadata maps to each
error entry’s extensions.
Memory & Performance Considerations
- Use
readonly struct + readonly ref struct builders to keep metadata on stack where possible.
- When builders must allocate:
- Rent buffers from
ArrayPool<T> and seal them into ImmutableArray<T> (copy) or keep pooled arrays with copy-on-write semantics to avoid double allocations.
- Strings remain reference types; plan to accept both existing strings and
ReadOnlySpan<char> for builders to reduce intermediate allocations.
- Investigate source generators for compile-time creation of metadata (e.g.,
Metadata.Object(("key", 42))) to avoid runtime array allocations.
Serialization Strategy
- JSON/RFC 9457:
- Provide
MetadataJsonConverter for System.Text.Json that serializes directly from MetadataValue without boxing.
- Deterministic property order (alphabetical or insertion) to aid caching/comparison.
- gRPC:
- Map
MetadataObject to protobuf Struct/Value, or define our own proto schema mirroring the value union.
- Provide extension methods to emit/read
MetadataValue to/from Google.Protobuf.WellKnownTypes.Value.
- Async messaging:
- Ensure metadata can be flattened into a UTF-8 JSON payload; expose
IBufferWriter<byte> writer to stream without intermediate strings.
| Metadata construct |
RFC 9457 mapping |
gRPC mapping |
Async messaging (JSON envelope) |
| Result-level metadata |
Top-level extension members on the Problem Details. |
google.protobuf.Struct attached to status. |
Envelope-level metadata object. |
| Per-error metadata |
errors[n].extensions object. |
google.rpc.Status.details[n] per error entry. |
errors[n].metadata object. |
| Primitive values |
Native JSON fields. |
google.protobuf.Value scalar kinds. |
JSON primitives. |
| Arrays/objects |
JSON arrays/objects; maintain deterministic order. |
google.protobuf.ListValue / Struct. |
JSON arrays/objects. |
API Surface Sketch
readonly record struct Error(...) becomes:
public readonly record struct Error(
string Message,
string? Code = null,
string? Target = null,
MetadataObject? Metadata = null);
- Helper entry points:
Error.WithMetadata(params (string Key, MetadataValue Value)[] properties)
MetadataValue.From<T>(T value) constrained to supported primitives.
MetadataObject.TryGetString(string key, out string? value) etc. for ergonomic consumption.
- Builders for advanced scenarios:
ref struct MetadataObjectBuilder
ref struct MetadataArrayBuilder
- Result helpers:
Result<T> WithMetadata(MetadataObject metadata) / Result WithMetadata(...).
Result<T> MergeMetadata(MetadataObject other, MetadataMergeStrategy strategy).
MetadataObject? Result<T>.Metadata exposed publicly with defensive copy only when builders were used.
Metadata Merge Semantics
- Default strategy: AddOrReplace, where keys from the incoming metadata overwrite existing keys (recursively for objects, element-wise for arrays).
- Alternative strategies:
- PreserveExisting – keep original values, ignore duplicates.
- FailOnConflict – throw if the same key is present in both sources (useful for safety-critical metadata).
- Merges preserve deterministic key order (e.g., insertion order) so serialization remains stable regardless of strategy.
- Provide a small helper (
MetadataObject.Merge) that takes the strategy enum, ensuring consistent behavior between Error and Result helpers.
Sample Merge Algorithm (AddOrReplace)
public static MetadataObject Merge(
MetadataObject original,
MetadataObject incoming,
MetadataMergeStrategy strategy = MetadataMergeStrategy.AddOrReplace)
{
var builder = MetadataObjectBuilder.From(original);
foreach (var (key, value) in incoming)
{
if (!builder.TryGetValue(key, out var existing))
{
builder.Add(key, value);
continue;
}
switch (strategy)
{
case MetadataMergeStrategy.AddOrReplace:
builder.Replace(key, MergeValues(existing, value, strategy));
break;
case MetadataMergeStrategy.PreserveExisting:
break;
case MetadataMergeStrategy.FailOnConflict:
throw new InvalidOperationException($"Duplicate metadata key '{key}'.");
}
}
return builder.Build();
}
private static MetadataValue MergeValues(
MetadataValue left,
MetadataValue right,
MetadataMergeStrategy strategy)
{
if (left.Kind != MetadataKind.Object || right.Kind != MetadataKind.Object)
{
return right; // scalar or array replacement for AddOrReplace.
}
// Recursive object merge; arrays reuse AddOrReplace semantics element-wise.
return Merge(left.AsObject(), right.AsObject(), strategy);
}
Key points:
- Builders guarantee determinism by keeping insertion order stable.
- Recursive merge only applies to object/object collisions; scalars and arrays follow strategy rules directly.
- Helper
MetadataObjectBuilder.From copies the existing object lazily (e.g., rent buffer, copy once) to avoid repeated allocations.
Validation & Testing
- Property-based tests to ensure round-tripping between metadata and JSON / protobuf retains structure.
- Benchmarks comparing:
- Old
Dictionary<string, object?> approach vs new structs for creation, serialization, and enumeration.
- Tests for boundary cases (large arrays, deeply nested objects, invalid value attempts).
- Use the dedicated benchmark project at
./benchmarks/Benchmarks/ to capture before/after numbers for each struct layout/merge optimization.
Open Questions
- Do we need decimal/high-precision numeric representation beyond double? If so, choose encoding (string vs fixed struct).
- Should metadata objects guarantee property order preservation or enforce sorting?
- How strict should we be around null handling (e.g., distinguish missing vs explicit null keys)?
- Do we expose span-based APIs publicly (requires
ref struct consumers) or keep them internal behind builders?
- What is the optimal memory layout for
MetadataValue? Investigate StructLayout(LayoutKind.Explicit) + field packing vs relying on JIT optimizations.
Struct Layout Considerations
- Default layout (auto) may introduce padding between discriminator and payload fields; measure the actual size via
Unsafe.SizeOf<MetadataValue>().
- Target size for
MetadataValue is ≤24 bytes; exceeding this threshold should trigger layout experiments.
StructLayout(LayoutKind.Explicit) can shrink the struct by overlaying fields, but:
- Requires careful alignment to avoid unaligned access penalties on some architectures.
- Can complicate
readonly semantics; prefer to keep fields readonly but remember explicit layout disallows auto-init.
- Recommended approach:
- Start with
LayoutKind.Sequential and order fields from largest to smallest (e.g., union storage first, then MetadataKind/flags).
- If size exceeds desired threshold (e.g., >24 bytes), experiment with
LayoutKind.Explicit. Provide benchmarks comparing copy-by-value throughput before adopting.
- Consider splitting payload storage into
MetadataPrimitive (fits in 16 bytes) and reference payload (arrays/objects) to avoid always carrying reference fields.
- Any layout decision should remain CLS-safe and avoid
unsafe code in public API, keeping the copy-by-value story friendly for callers.
Plan for metadata in Light.Results
Goals & Constraints
IReadOnlyDictionary<string, object?>? MetaonErrorwith the new types without breaking ergonomics for callers that only need primitive metadata @src/Light.Results/Error.cs#5-10.Result<T>(and non-genericResult) for correlation/context payloads that apply regardless of success/failure, while preserving zero-cost success cases @src/Light.Results/Result.cs#11-146.Result<T>stays allocation-free when success paths avoid metadata.Value Domain (JSON-Compatible)
Core Types
MetadataValuereadonly structacting as a discriminated union over the supported kinds.MetadataKind Kindenum (Null, Boolean, Int64, Double, Decimal128?, String, Array, Object).long _int64,double _double,string? _string,MetadataArray? _array,MetadataObject? _object).MetadataValue.FromString(...), implicit conversions from primitive CLR types.MetadataArrayreadonly structwrapping anImmutableArray<MetadataValue>or a pooledMetadataValue[]plus length.MetadataArray.Create(ReadOnlySpan<MetadataValue> values)MetadataArray.Builderthat rents fromArrayPool<MetadataValue>for bulk construction.MetadataObjectMetadataValue.ImmutableArray<MetadataProperty>whereMetadataPropertyis(string Key, MetadataValue Value); optionally store sorted keys for deterministic serialization.Dictionaryfallback for larger payloads.MetadataMapError. Could be eitherMetadataObjector a specialized type that enforces small payload semantics.MetadataMap.Empty,MetadataMap.FromPairs(...), and fluentWith(...)helpers to encourage zero-allocation happy paths.Immutability & Ownership
MetadataValue,MetadataArray, andMetadataObjectare immutable once created; they never retain references to pooled buffers.MetadataObjectBuilder,MetadataArrayBuilder) rent arrays fromArrayPool<T>but copy the contents into new, tightly-sized arrays (orImmutableArray<T>) before producing a public instance, then immediately return the buffers to the pool.Result-Level Metadata
Error.MetadataObject? Metadataproperty onResult<T>and the non-genericResult._errorsto avoid conflating concerns.WithMetadata,MergeMetadata) that return newResult<T>without copying when the metadata reference matches.Result<T>with new metadata on success, delay allocation by usingMetadataObjectBuilderthat writes into a pooled buffer only if metadata is actually added.Result<T>.Metadata(context) andError.Metadata(per-error) when serializing.errorentry’s extensions.Memory & Performance Considerations
readonly struct+readonly ref structbuilders to keep metadata on stack where possible.ArrayPool<T>and seal them intoImmutableArray<T>(copy) or keep pooled arrays with copy-on-write semantics to avoid double allocations.ReadOnlySpan<char>for builders to reduce intermediate allocations.Metadata.Object(("key", 42))) to avoid runtime array allocations.Serialization Strategy
MetadataJsonConverterfor System.Text.Json that serializes directly fromMetadataValuewithout boxing.MetadataObjectto protobufStruct/Value, or define our own proto schema mirroring the value union.MetadataValueto/fromGoogle.Protobuf.WellKnownTypes.Value.IBufferWriter<byte>writer to stream without intermediate strings.google.protobuf.Structattached to status.metadataobject.errors[n].extensionsobject.google.rpc.Status.details[n]per error entry.errors[n].metadataobject.google.protobuf.Valuescalar kinds.google.protobuf.ListValue/Struct.API Surface Sketch
readonly record struct Error(...)becomes:Error.WithMetadata(params (string Key, MetadataValue Value)[] properties)MetadataValue.From<T>(T value)constrained to supported primitives.MetadataObject.TryGetString(string key, out string? value)etc. for ergonomic consumption.ref struct MetadataObjectBuilderref struct MetadataArrayBuilderResult<T> WithMetadata(MetadataObject metadata)/Result WithMetadata(...).Result<T> MergeMetadata(MetadataObject other, MetadataMergeStrategy strategy).MetadataObject? Result<T>.Metadataexposed publicly with defensive copy only when builders were used.Metadata Merge Semantics
MetadataObject.Merge) that takes the strategy enum, ensuring consistent behavior betweenErrorandResulthelpers.Sample Merge Algorithm (AddOrReplace)
Key points:
MetadataObjectBuilder.Fromcopies the existing object lazily (e.g., rent buffer, copy once) to avoid repeated allocations.Validation & Testing
Dictionary<string, object?>approach vs new structs for creation, serialization, and enumeration../benchmarks/Benchmarks/to capture before/after numbers for each struct layout/merge optimization.Open Questions
ref structconsumers) or keep them internal behind builders?MetadataValue? InvestigateStructLayout(LayoutKind.Explicit)+ field packing vs relying on JIT optimizations.Struct Layout Considerations
Unsafe.SizeOf<MetadataValue>().MetadataValueis ≤24 bytes; exceeding this threshold should trigger layout experiments.StructLayout(LayoutKind.Explicit)can shrink the struct by overlaying fields, but:readonlysemantics; prefer to keep fieldsreadonlybut remember explicit layout disallows auto-init.LayoutKind.Sequentialand order fields from largest to smallest (e.g., union storage first, thenMetadataKind/flags).LayoutKind.Explicit. Provide benchmarks comparing copy-by-value throughput before adopting.MetadataPrimitive(fits in 16 bytes) and reference payload (arrays/objects) to avoid always carrying reference fields.unsafecode in public API, keeping the copy-by-value story friendly for callers.