Skip to content

Serializable Metadata #1

@feO2x

Description

@feO2x

Plan for metadata in Light.Results

Goals & Constraints

  1. Metadata must be trivially serializable/deserializable across:
    • HTTP payloads that conform to RFC 9457 (problem details).
    • gRPC error trailers/payloads.
    • Asynchronous messaging envelopes.
  2. The shape of metadata must be statically enforceable so that only JSON-compatible values are representable.
  3. Keep allocations to a minimum—ideally the metadata types are stack-only structs backed by small arrays/spans with pooling.
  4. 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

  1. 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).
  2. 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.
  3. 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.
  4. 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:
    1. PreserveExisting – keep original values, ignore duplicates.
    2. 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

  1. Do we need decimal/high-precision numeric representation beyond double? If so, choose encoding (string vs fixed struct).
  2. Should metadata objects guarantee property order preservation or enforce sorting?
  3. How strict should we be around null handling (e.g., distinguish missing vs explicit null keys)?
  4. Do we expose span-based APIs publicly (requires ref struct consumers) or keep them internal behind builders?
  5. 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:
    1. Start with LayoutKind.Sequential and order fields from largest to smallest (e.g., union storage first, then MetadataKind/flags).
    2. If size exceeds desired threshold (e.g., >24 bytes), experiment with LayoutKind.Explicit. Provide benchmarks comparing copy-by-value throughput before adopting.
    3. 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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions