FoundationDB .NET client 7.4.5

Released on 2026-08-29.

In this release: generated JSON converters stop ignoring a type's hand-written serialization, tests take control of time inside the database client, and Native AoT applications can drop the reflection code entirely.

Highlights

  • Generated JSON converters now call the IJsonSerializable, IJsonPackable, or IJsonDeserializable<T> methods of a type that implements them. This can change the JSON output of affected types; an opt-out restores the old behavior.
  • IFdbDatabase now has a Time property (a TimeProvider); watch timeouts, provisioning waits, and bulk operation timers read it, so a test on a fake clock runs its timeout scenarios in milliseconds instead of minutes.
  • Two feature switches, SnowBank.Data.Json.CrystalJson.IsReflectionSupported and SnowBank.Data.Tuples.TuPack.IsReflectionSupported, remove the reflection code from a Native AoT application.

FoundationDB

IFdbDatabase now has its own TimeProvider

If a test that exercises a watch timeout, a retry, or a provisioning wait spends real wall-clock time in those waits, this section is your fix. Nothing here changes production behavior: the previous arrangement, where every wait read the system clock, has run in production for more than ten years and still does by default. The pain was in the tests. A corner case around a timeout could only be reached by actually waiting for it, a suite full of such tests takes minutes to do nothing, and parallel test runs contend for real time.

The change lets a test inject a custom TimeProvider at the root: IFdbDatabase gains a Time property (a TimeProvider, system clock by default), and every timeout, delay, and elapsed-time measurement implemented in managed code reads it. Hand the database a FakeTimeProvider and the test framework decides how fast time runs inside it, Inception style: one call to Advance() and a 45-second timeout has elapsed before the wall clock ticks:

var clock = new FakeTimeProvider();
var store = new FakeDbStore(time: clock);
using var db = store.OpenDatabase(null, readOnly: false);

FdbWatch watch;
using (var tr = db.BeginTransaction(ct))
{
	watch = tr.Watch(Slice.FromStringAscii("sample-key"), ct);
	await tr.CommitAsync();
}

var waitTask = watch.WaitAsync(TimeSpan.FromSeconds(45), ct);
clock.Advance(TimeSpan.FromSeconds(46));
// the 45 s timeout has elapsed on the database clock; the wall clock barely moved
Console.WriteLine(await waitTask); // false: the watch did not fire before the timeout

What reads the database Time:

  • FdbWatch.WaitAsync(timeout, ct) measures its timeout with the Time of the database that created the watch. A directly constructed watch falls back to the system clock. The explicit WaitAsync(timeout, provider, ct) overload from 7.4.4 is unchanged.
  • Fdb.Provisioning.EnsureDatabaseConfiguredAsync takes an optional TimeProvider and measures its availability deadline and poll delay on it. The fdbcli probes still run in real time.
  • Fdb.Bulk runs its operation timers on the database Time.
  • Transaction-log timestamps are read from the database Time.

FdbDatabase.Create gains an optional TimeProvider parameter. The native open path still defaults to the system clock, and timeouts enforced by the native client (fdb_c) are not affected by any of this.

If you have never faked a clock in a test: TimeProvider is the standard .NET abstraction for exactly this, and any code that takes one instead of reading DateTime.UtcNow or Task.Delay directly gets the same superpower.

Injecting a TimeProvider into FakeDb

FakeDbProviderOptions gains a Time property, and AddFakeDb gains an optional time parameter. The provider selects its TimeProvider with a fixed precedence: the explicit option, else the instance registered in the service collection, else the system clock, and attaches it to every database it opens. Register one fake clock and every FakeDb-backed database, and every watch created from those databases, reads it:

var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-08-28T10:00:00Z"));
services.AddSingleton<TimeProvider>(clock);
services.AddFakeDb(730, FdbPath.Root);
// every FakeDb database now reads clock; db.Time == clock

Fixed: the Fdb.Bulk four-second early commit worked only once

If a large Fdb.Bulk insert ran fine at first and then failed with transaction_too_old on a later batch, this was why. Fdb.Bulk commits a batch early when it has been open longer than four seconds, to stay clear of the five-second transaction limit. The stopwatch measuring the four seconds was stopped after the first early commit instead of restarted, so the early commit never fired again. It now restarts after each commit and works for every batch. No caller change is needed.

SnowBank

Generated converters now call a type's own IJson* implementations

This is a behavior change and can change the JSON output of affected types. Read the population paragraph below to see whether it concerns you.

The symptom: you implement IJsonPackable on a type to control its JSON format, and it works, until the same type is serialized through a generated converter and comes out member-by-member, as if your implementation did not exist. Same type, two different outputs, depending on the code path. The known workaround was to keep such types out of the generated containers and wire their serialization by hand.

The cause: a type can hand-write its own JSON format by implementing IJsonSerializable (writes text), IJsonPackable (builds a DOM value), or IJsonDeserializable<T> (reads a DOM value). Through 7.4.4 the runtime reflection path called those implementations, but a source-generated converter did not: it was built from the type's members and ignored the interfaces.

In 7.4.5 the generated converter calls the type's own method for each interface it implements; the three interfaces are checked independently, and the methods the type does not implement are still generated from its members. The type's format takes precedence over the container's profile settings, because a hand-written Pack returns property names already chosen. The workaround above is no longer needed: such types can be registered like any other.

public sealed record GpsPoint : IJsonPackable
{
	public int Lat { get; init; }
	public int Lon { get; init; }

	JsonValue IJsonPackable.JsonPack(CrystalJsonSettings settings, ICrystalJsonTypeResolver resolver)
		=> JsonString.Return($"{this.Lat}:{this.Lon}");
}

[CrystalConverter]
[CrystalJsonOutput]
[CrystalSerializable(typeof(GpsPoint))]
public static partial class AppSerializers { }

var pt = new GpsPoint { Lat = 48, Lon = 2 };
AppSerializers.GpsPoint.Pack(pt);
// 7.4.4: { "Lat": 48, "Lon": 2 }   built from the members, JsonPack ignored
// 7.4.5: "48:2"                    the type's own JsonPack

Who is affected: types that are both registered with a [CrystalConverter] container and implement one of the three interfaces. For those types the generated output changes from member-based to whatever the type's own method produces. A type using only one of the two mechanisms sees no change.

To keep the old member-based output for one type, opt it out where it is registered:

[CrystalSerializable(typeof(LegacyRecord), IgnoreCustomSerialization = true)]

The opt-out has two limits. It cannot help a type the generator cannot construct from members (for example, a type with required members and no parameterless constructor). And it cannot be applied to a type discovered through another type: a type reached through another type's member is registered automatically, and no attribute can be applied to it.

For a type that implements its own serialization, no ReadOnly or Writable proxy is generated, and the generator emits a warning (CJSON0025): it does not know the shape the type produces, so it cannot describe it. The converter itself is unaffected.

Overriding a generated converter with a hand-written method

If you ever needed one container to serialize a type with a custom format, and could not touch the type itself (a third-party type, or a type whose format must differ per consumer), this is the new way in.

The generator emits a nested class for each registered type, and that class is now partial: an author can declare a Serialize, Pack, or Unpack method in their own part, and the generated converter calls it instead of the generated body. The generated code still implements IJsonConverter<T>, still performs the null check and the circular-reference check, and still converts the result to read-only where the generated body did. The null check runs before your method: a null instance serializes as JSON null and the hand-written method is never called with one, so it takes a non-nullable parameter and needs no null branch. This override is per container, and takes precedence over the type's own interface methods, so two containers can serialize one type differently.

The nested class has the same name as the type it serves, so inside the container that name refers to the nested class; qualify the serialized type in the registration and in the method signatures:

[CrystalConverter]
[CrystalJsonOutput]
[CrystalSerializable(typeof(Telemetry.SensorReading))]
public static partial class TelemetrySerializers
{
	public static partial class SensorReading
	{
		// replaces the generated Pack body for this container only; the generated code
		// handles a null instance before this method runs, so there is no null case here
		public static JsonValue Pack(Telemetry.SensorReading instance, CrystalJsonSettings? settings = default, ICrystalJsonTypeResolver? resolver = default)
			=> JsonArray.Create(JsonString.Return(instance.Sensor), JsonNumber.Return(instance.Value));
	}
}

The names Serialize, Pack, and Unpack are reserved inside the generated nested class. A method with one of those names and an incompatible signature is a compile error (CJSON0024) naming the expected signature; there is no silent fallback to a generated body. For a polymorphic type serialized by a hand-written method, the generator does not write the type discriminator: the hand-written method must write it itself.

One consequence of the nested class sharing the type's name: an unqualified typeof(SensorReading) inside the container binds to the nested class instead of the data type. Registering a static class fails with a compile error (CJSON0026) whose message names the qualification fix.

New feature switches to remove the reflection code (Native AoT)

If publishing your application with Native AoT produced IL2026 or IL3050 warnings from CrystalJson or the tuple key encoder, even though you only use source-generated converters and ordinary key types, these switches are the fix. The warnings pointed at reflection code your application can never reach, but the trimmer could not prove that on its own.

Two independent feature switches, both defaulting to true:

Switch Removes With reflection off
SnowBank.Data.Json.CrystalJson.IsReflectionSupported the CrystalJson runtime reflection (de)serialization code a reflected type throws JsonReflectionDisabledException
SnowBank.Data.Tuples.TuPack.IsReflectionSupported the reflection-based encoder/decoder builder for uncommon tuple element types an uncommon element type throws NotSupportedException

A Native AoT application sets them in the project file:

<ItemGroup>
	<RuntimeHostConfigurationOption Include="SnowBank.Data.Json.CrystalJson.IsReflectionSupported" Value="false" Trim="true" />
	<RuntimeHostConfigurationOption Include="SnowBank.Data.Tuples.TuPack.IsReflectionSupported" Value="false" Trim="true" />
</ItemGroup>

The trimmer then substitutes each property with false and removes the code it guards, so an application that uses source-generated converters (JSON) or common key element types (tuples) builds with no trim warnings. A trimmed (non-AoT) application can set them too, but does not have to: with reflection kept, trimming still works and the reflection code simply stays in. Common tuple element types (int, long, string, Guid, bool, and the other types with a compile-time code path) never use the reflection-based builder and produce the same bytes with the switch on or off. The reflection entry points of CrystalJson and TuPack are also annotated with [RequiresUnreferencedCode] / [RequiresDynamicCode], so a trimmed build warns at the exact call site instead of deep inside the library.

New IJsonSerializer<T> / IJsonDeserializer<T> overloads on the HTTP JSON helpers

If the HTTP JSON helpers were the last source of trim warnings in an otherwise clean publish, these overloads close the gap. Each helper gains an overload that takes a source-generated serializer, so a trimmed application sends and receives typed bodies with no reflection involved:

  • Send: CrystalJsonContent.Create<T>(value, IJsonSerializer<T>), and RestHttpProtocol.PostJsonAsync / PutJsonAsync / PatchJsonAsync overloads taking an IJsonSerializer<TRequest>.
  • Receive: BetterHttpClientContext.ReadAsJsonAsync<T> and HttpContent.ReadFromCrystalJsonAsync<T>, each with an IJsonDeserializer<T> overload (exact type known) and an ICrystalJsonTypeResolver overload.

With Order registered in a [CrystalConverter] container, the generated Default instance is both the serializer and the deserializer:

// send: the request body is written by the generated serializer
var content = CrystalJsonContent.Create(order, AppSerializers.Order.Default);

// receive: read the typed body back with the generated deserializer
var received = await response.Content.ReadFromCrystalJsonAsync<Order>(
	AppSerializers.Order.Default,
	ct);

The existing reflection-based overloads are unchanged; they are now annotated for trimming.

Fixed: JsonWritableProxyArray<T>.Count always returned 0

If Count on a writable array proxy read 0 while the array clearly had elements, that was a bug, not your code. Count was an unassigned auto-property on the struct, so it returned 0 for any array; it now reads the wrapped array's length. Code that iterated the proxy was unaffected; only reads of Count were wrong.

Breaking changes

Read the section on generated converters before upgrading. In short:

  • A type both registered with a container and implementing IJsonSerializable, IJsonPackable, or IJsonDeserializable<T> changes its generated JSON output from member-based to the type's own format. This aligns the generated path with what the runtime path already produced. To keep the old output for one type: [CrystalSerializable(typeof(T), IgnoreCustomSerialization = true)].
  • For such a type, no ReadOnly/Writable proxy is generated (CJSON0025, warning).
  • Registering a static class is now a compile error (CJSON0026); it previously produced broken generated code.
  • Everything else in this release is additive.