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, orIJsonDeserializable<T>methods of a type that implements them. This can change the JSON output of affected types; an opt-out restores the old behavior. IFdbDatabasenow has aTimeproperty (aTimeProvider); 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.IsReflectionSupportedandSnowBank.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 theTimeof the database that created the watch. A directly constructed watch falls back to the system clock. The explicitWaitAsync(timeout, provider, ct)overload from 7.4.4 is unchanged.Fdb.Provisioning.EnsureDatabaseConfiguredAsynctakes an optionalTimeProviderand measures its availability deadline and poll delay on it. Thefdbcliprobes still run in real time.Fdb.Bulkruns its operation timers on the databaseTime.- 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>), andRestHttpProtocol.PostJsonAsync/PutJsonAsync/PatchJsonAsyncoverloads taking anIJsonSerializer<TRequest>. - Receive:
BetterHttpClientContext.ReadAsJsonAsync<T>andHttpContent.ReadFromCrystalJsonAsync<T>, each with anIJsonDeserializer<T>overload (exact type known) and anICrystalJsonTypeResolveroverload.
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, orIJsonDeserializable<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/Writableproxy 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.