FoundationDB .NET client 7.4.4

Released on 2026-08-23.

The changes delivered in the 7.4.4 packages, from 7.4.3, ordered by what an application built on the SDK uses. Each section that changes behavior says what to do about it; the Breaking changes section lists the changes that stop the build.

Highlights

FoundationDB

  • VisitRangeAsync stops at the limit across pages. A call with a Limit used to visit the whole range; it now visits at most that many key/value pairs.
  • FdbWatch.WaitAsync takes a TimeProvider, so a test can control a watch timeout with an injected fake clock.

SnowBank

  • BetterHttpClient moves to IHttpClientFactory. Each named client has its own options, including per-client cookie and proxy switches.
  • The CrystalXml DataContractCompat preset writes the standard DataContractSerializer (DCS) format, namespaces included, and CrystalXmlSettings adds runtime writer options (indentation, line ending, XML declaration, encoding).
  • CrystalJson reads more legacy serializer output: a DateTimeOffset written by DataContractJsonSerializer, and a DataContract dictionary with lowercase entry names. The source generator recognizes [JsonProperty(PropertyName = ...)] and finds a type's members the way the reference serializers do.
  • Virtual-time test waits get a 15-second minimum timeout and a 15 ms step. A wait that expects a result now has a 15-second minimum timeout, so a late-but-correct result under CPU load no longer fails the test. Each virtual-time step now grants 15 ms of real time, at or above the timer resolution of every platform, so Windows, Linux, and macOS behave the same.

FoundationDB

VisitRangeAsync stops at the limit across pages

VisitRangeAsync visits every key/value pair in a range and calls a handler for each pair. A call with a Limit used to visit the whole range anyway: the page loop fetched each page with the original limit for as long as a page reported more data, so the limit only bounded the first page. The loop now subtracts each page's count from the remaining limit and stops when that limit reaches zero. Callers change nothing; a VisitRangeAsync call with a Limit now visits at most that many pairs, across pages.

// visits at most 1000 pairs, even when the range holds more and spans several pages
await tr.VisitRangeAsync(begin, end, ..., new FdbRangeOptions { Limit = 1000 });

FdbWatch.WaitAsync takes a TimeProvider

FdbWatch.WaitAsync waits for a watched key to change, up to a timeout. The timeout ran on the wall clock, so a test that virtualizes time could not control it. A new overload measures the timeout on a TimeProvider you pass, so a test can supply a fake clock and advance the watch's timeout deterministically.

// TimeProvider.System, or a fake time provider injected by a test framework
private TimeProvider Clock { get; }

FdbWatch watch = ...; // from tr.Watch(...) inside a read-write transaction

// the new overload can use an injected fake TimeProvider:
await watch.WaitAsync(TimeSpan.FromSeconds(5), this.Clock, ct);

SnowBank

Networking: BetterHttpClient moves to IHttpClientFactory

This release rebuilds BetterHttpClient on IHttpClientFactory. In 7.4.3 a client was its own type that wrapped the handler chain. A client is now a plain HttpClient that the factory produces, configured by the options registered under its name. AddBetterHttpClient(name, ...) registers a named client and returns an IBetterHttpClientBuilder, and the request stages now run on the pooled handler chain for every factory client, no longer only the default one.

  • "Bundle" becomes "client" across the API. ResolveBundleOptions becomes ResolveClientOptions, and BetterHttpShellOptions is retired. The old names stay as [Obsolete] forwarders; 8.0 removes them.

  • Per-client cookie and proxy switches. BetterHttpClientOptions gains nullable UseCookies and UseProxy. null keeps the historical behavior: the feature turns on only when the caller supplies a Cookies container or a Proxy. false forces the feature off and refuses a contradictory container or proxy. true turns the feature on. UseCookies = true with no container creates one CookieContainer per client name; the cookie state survives handler-chain rebuilds and never crosses to another client name on the pooled transport. UseProxy = true with no Proxy uses the system proxy. For a cookies-off baseline with per-client opt-in:

    // Cookies are disabled by default for all clients
    services.AddBetterHttpClientDefaults(options =>
    {
        options.UseCookies = false;
    });
    
    // Cookies are enabled for this specific named client
    services.AddBetterHttpClient("payments", options =>
    {
        options.UseCookies = true;
    });
  • The old entry points remain as [Obsolete] forwarders, so existing code compiles. Move to the factory client and the per-client options before 8.0.

Who is affected, and what to do. Applications that registered a BetterHttpClient through the old entry points still compile, through the obsolete forwarders. Move the registration to AddBetterHttpClient(name) and read the options with ResolveClientOptions. Applications that never called the old entry points are unaffected.

CrystalJson

CrystalJson now decodes a DateTimeOffset serialized by DataContractJsonSerializer

DataContractJsonSerializer writes a DateTimeOffset as a nested object:

{
    "DateTime": "\/Date(1704067200000)\/",
    "OffsetMinutes": 120
}

CrystalJson now decodes that object back into a DateTimeOffset, so a document written by the legacy serializer round-trips without a custom converter.

var when = CrystalJson.Deserialize<DateTimeOffset>(json);
// when == 2024-01-01T02:00:00.0000000+02:00

The source generator now recognizes [JsonProperty(PropertyName = ...)]

[JsonProperty] sets the JSON name of a member. The positional form, [JsonProperty("displayName")], was already recognized. The overload that names the argument, [JsonProperty(PropertyName = "...")], was not: the generator ignored it and kept the .NET name. The generator now honors both forms, matching the reflection path.

public sealed class Profile
{
    [JsonProperty("displayName")]        // positional form: already recognized
    public string DisplayName { get; init; } = "";

    [JsonProperty(PropertyName = "id")]  // named-argument overload: now recognized too
    public string Identifier { get; init; } = "";
}

[CrystalJsonConverter]
[CrystalSerializable(typeof(Profile))]
public static partial class ProfileSerializers { }

var json = ProfileSerializers.Profile.ToJsonText(
    new Profile { DisplayName = "Ada", Identifier = "u-1" }
);
// json == { "displayName": "Ada", "id": "u-1" }

CrystalJson now reads a DataContract dictionary with lowercase entry names

DataContractSerializer writes a dictionary as an array of {"Key":..,"Value":..} pairs. CrystalJson already read that form; it now also reads the lowercase {"key":..,"value":..} form that some writers produce.

var upper = CrystalJson.Deserialize<Dictionary<string, int>>("""[{"Key":"a","Value":1}]""");
var lower = CrystalJson.Deserialize<Dictionary<string, int>>("""[{"key":"a","value":1}]""");
// upper["a"] == lower["a"] == 1

The generator discovers members like the reference serializers

The generator now discovers a type's serialized members the way the reference serializers do:

  • An indexer is now ignored. Enrolling a type with an indexer used to emit code that does not compile. The generator now skips indexers, as the reference serializers do.

    public sealed class Palette
    {
        public string Name { get; init; } = "";
        public string this[int index] => Name;
    }
    
    [CrystalJsonConverter]
    [CrystalSerializable(typeof(Palette))]
    public static partial class PaletteSerializers { }
    
    var json = PaletteSerializers.Palette.ToJsonText(new Palette { Name = "sunset" });
    // json == { "Name": "sunset" }   (the indexer is skipped)
  • A member redeclared in a derived type with new is counted once. An override keeps the inheritance level of the member it overrides. A member redeclared with new takes its own level and is written once, from the most derived accessor. The reference serializer writes both copies of a new member; generated code has one accessor per name, so it writes one.

    public class BaseBox
    {
        public int Size { get; init; }
    }
    
    public sealed class DerivedBox : BaseBox
    {
        public new int Size { get; init; }
    }
    
    [CrystalJsonConverter]
    [CrystalSerializable(typeof(DerivedBox))]
    public static partial class BoxSerializers { }
    
    var json = BoxSerializers.DerivedBox.ToJsonText(new DerivedBox { Size = 9 });
    // json == { "Size": 9 }   (one Size key, from the most derived accessor)
  • An interface member implemented explicitly is ignored on a plain DTO, which matches the reference serializer. On a [DataContract] type, the generator refuses an explicit implementation that carries [DataMember] (CJSON0022); it used to emit code that does not compile.

    [DataContract]
    public sealed class Coupon : IHasCode
    {
        [DataMember]
        string IHasCode.Code => "SAVE10";
    }
    // error CJSON0022: The member 'Coupon.IHasCode.Code' is an explicit interface implementation
    // carrying [DataMember], so it belongs to the data contract, but generated code cannot declare an
    // accessor for a qualified member name. Promote it to a normal member (the explicit implementation
    // can then delegate to it), or move the contract onto a DTO of its own.
  • The generator warns (CJSON0023) when a serialized member declares an abstract type or an interface without [JsonPolymorphic]. CrystalJson cannot read such a document back into the declared type, so the generator reports the problem at build time instead of letting the read fail at run time.

    public abstract class Shape { public int Sides { get; init; } }
    
    public sealed class Drawing
    {
        public Shape? Outline { get; init; }
    }
    // warning CJSON0023: The member 'Drawing.Outline' declares the abstract class 'Shape', which carries
    // no [JsonPolymorphic] attribute. The writer emits the members of the runtime value with no
    // discriminator, so a reader cannot bind the document back to 'Shape'. Add [JsonPolymorphic] to
    // 'Shape', plus one [JsonDerivedType] per derived type.
  • The reflection path's unregistered-derived-type error now names the two types involved. The message used to show two placeholders that nothing filled.

The generator names the type when it refuses a StreamingContext callback

Both paths now name the declaring type when they refuse a lifecycle callback that keeps the legacy StreamingContext parameter (CJSON0015). The generated path used to report the refusal without the type name.

public sealed class Invoice
{
    public int Total { get; init; }

    [OnDeserialized]
    private void AfterLoad(StreamingContext context) { }
}
// error CJSON0015: Remove the StreamingContext parameter from serialization callback
// 'Invoice.AfterLoad', or replace it with JsonValue, JsonObject or JsonArray. The legacy
// DataContractJsonSerializer callback signature is not supported.

CrystalXml

Use the DataContractCompat preset to write the standard DCS format

The DataContractCompat preset now writes the same format as DataContractSerializer (DCS), namespaces included. In 7.4.3 it wrote a namespace-free document, which matched one family of stored documents but not what DataContractSerializer writes. Two things change in the bytes:

  • the root element declares the contract namespace as its default namespace (from [DataContract(Namespace = ...)], else http://schemas.datacontract.org/2004/07/ plus the CLR namespace);
  • the writer marks a null member with the XML attribute i:nil="true", where i is the prefix of the XMLSchema-instance namespace declared on the root. 7.4.3 wrote a bare nil attribute in no namespace.
public sealed class Customer
{
    public string DisplayName { get; init; } = "";
    public string? Note { get; init; }
}

[CrystalConverter]
[CrystalXmlOutput(CrystalXmlSerializerDefaults.DataContractCompat)]
[CrystalSerializable(typeof(Customer))]
public static partial class CustomerSerializers { }

var customer = new Customer { DisplayName = "Acme", Note = null };
string xml = CrystalXml.ToText(CustomerSerializers.Customer.Default, customer);
<!-- 7.4.4: full DCS output, namespaces included (one line) -->
<Customer xmlns="http://schemas.datacontract.org/2004/07/Demo" xmlns:i="http://www.w3.org/2001/XMLSchema-instance"><DisplayName>Acme</DisplayName><Note i:nil="true" /></Customer>

Who is affected, and what to do. This changes the output bytes of every container that uses DataContractCompat. A consumer that resolves XML names through the namespace declarations reads the 7.4.3 output and the new output alike. A consumer that stores or compares the namespace-free bytes keeps them by adding OmitNamespaces:

[CrystalConverter]
[CrystalXmlOutput(CrystalXmlSerializerDefaults.DataContractCompat, OmitNamespaces = true)]
[CrystalSerializable(typeof(Customer))]
public static partial class LegacyCustomerSerializers { }

string xml = CrystalXml.ToText(LegacyCustomerSerializers.Customer.Default, customer);
<!-- with OmitNamespaces: the namespace-free 7.4.3 output, bare nil attribute -->
<Customer><DisplayName>Acme</DisplayName><Note nil="true" /></Customer>

OmitNamespaces applies to DataContractCompat only; on the General format it does nothing and the generator reports CXML0012. The General format is unchanged in this release. crystalxml.md states the full namespace rules.

Write a collection or a scalar as the document root

New entry points on CrystalXml write a document whose root element is a collection or a scalar. Enrolling a bare collection or scalar type stays refused (CJSON0019: enroll the element type, not the collection); these entry points write the root instead:

// CatalogSerializers is a DataContractCompat container that enrolls Shelf
string xml = CrystalXml.ToText(CatalogSerializers.Shelf.Default, shelves);
// <ArrayOfShelf xmlns="http://schemas.datacontract.org/2004/07/Demo"><Shelf>...</Shelf><Shelf>...</Shelf></ArrayOfShelf>

string scalar = CrystalXml.Scalar.ToText("hello");
// <string xmlns="http://schemas.microsoft.com/2003/10/Serialization/">hello</string>

Name the root element with the rootName argument. Without it, DataContractCompat falls back to the ArrayOfX convention (ArrayOfShelf above, for a collection of Shelf) in the item contract's namespace. The General format has no such convention, so a collection root with no rootName raises CrystalXmlRootNameException. The itemName argument renames the item elements. The scalar entry points cover the xsd lexical types (the types XML Schema defines a text form for) and write the reference format, with a nil element for a null value; any other type raises CrystalXmlUnknownTypeException.

The XML element name now follows the data contract

On DataContractCompat, the XML element name comes from the data contract, not the JSON name. In 7.4.3 the XML element took the resolved JSON name. A plain DTO that renames a member in JSON with [JsonProperty] and carries no [DataMember] rename now keeps the JSON name in JSON and the member's own name in XML, which is what DataContractSerializer writes.

public sealed class Account
{
    [JsonProperty("identifier")]
    public string Identifier { get; init; } = "";

    public int Balance { get; init; }
}

[CrystalConverter]
[CrystalJsonOutput]
[CrystalXmlOutput(CrystalXmlSerializerDefaults.DataContractCompat)]
[CrystalSerializable(typeof(Account))]
public static partial class AccountSerializers { }

var account = new Account { Identifier = "acct-42", Balance = 500 };

string json = AccountSerializers.Account.ToJsonText(account);
// json == { "identifier": "acct-42", "Balance": 500 }

string xml = CrystalXml.ToText(AccountSerializers.Account.Default, account);
// xml (one line) == <Account xmlns="http://schemas.datacontract.org/2004/07/Acme.Billing"><Balance>500</Balance><Identifier>acct-42</Identifier></Account>
// the XML element keeps the member's own name (Identifier); the JSON key is the renamed one (identifier)

As a consequence, a bare [DataMember] next to a renaming JSON attribute now yields two different names (the member's own name in XML, the renamed one in JSON), so CJSON0011 (two different names on one member) fires there too. The fix is to split the type: one type per serializer, each with one coherent set of attributes.

Runtime output options: CrystalXmlSettings

CrystalXmlSettings, a new readonly struct that wraps a flags field, configures the writer. Every CrystalXml entry point now takes it; the entry points used to take a CrystalJsonSettings. The [CrystalXmlOutput(...)] attribute takes a CrystalXmlSerializerDefaults preset that the generator stores as the container's default: Inherit (the parameterless default, which derives the format from the container's JSON profile), General (the standard XML format), or DataContractCompat (the DCS format).

The default output is unchanged: a caller that passes no settings gets the same compact document as before. The options are opt-in.

string xml = CrystalXml.ToText(
    OrderSerializers.Order.Default,
    order,
    CrystalXmlSettings.General.WithIndented().WithNewLine(CrystalXmlNewLine.Lf)
);
  • Indented turns on indentation; NewLine (Crlf or Lf) selects the line ending. Compact is the default, and the writer never uses the host's Environment.NewLine.
  • EmptyElementStyle selects <foo/> (default) or <foo></foo> for an empty element.
  • WriteXmlDeclaration adds the <?xml ...?> line.
  • ShowNullMembers writes a null member as an i:nil element instead of skipping it. It is on for DataContractCompat and off for General; WithNullMembers() / WithoutNullMembers() toggle it.
  • Encoding is a parameter on the byte entry points (ToBytes, ToSlice, WriteTo(Stream)); the string entry points return UTF-16.

Two limits: WriteTo(Stream) with a non-default Encoding buffers the whole document before it writes (the default path still streams), and UTF-16 output does not start with a byte order mark (BOM).

SnowBank.Testing

Virtual-time test waits get a 15-second minimum timeout and a 15 ms step

Two fixes to the virtual-time test helpers:

  • A wait that expects a result (Await, WaitFor, WaitUntil) now waits at least 15 seconds, whatever shorter timeout the caller passed. Under CPU load a correct result can arrive after a short timeout and fail the test even though the code is right. Such a wait returns the moment the result arrives, so a longer minimum costs nothing when the test passes; it only gives a loaded machine more time before the wait fails. A result that never arrives still fails, 15 seconds later, and the failure message notes that such a failure under load usually comes from CPU contention on the test machine. A wait that expects a result not to arrive (ShouldNotHappenWithin) keeps its short timeout.
  • Advancing virtual time now grants 15 ms of real time per step, not 1 ms. Task.Delay rounds a request up to the operating-system timer resolution: about 15 ms on Windows, about 1 ms on Linux and macOS, so the old 1 ms request gave the test's asynchronous callbacks less real time on Linux and macOS than on Windows. A 15 ms step is at or above every platform's resolution, so a virtual-time test behaves the same on all three.

Test authors change nothing.

Breaking changes

BetterHttpClient moves to the factory model

The old registration entry points and the "bundle" member names are obsolete. The no-name AddBetterHttpClient(configure) is obsolete (error: true); register defaults with AddBetterHttpClientDefaults. AddBetterHttpClient(name, ...) returns IBetterHttpClientBuilder. ResolveBundleOptions becomes ResolveClientOptions, and BetterHttpShellOptions is retired. The old members forward with [Obsolete]; 8.0 removes them.

// before (7.4.3): the no-name overload wired only the default client, and options were read with
// ResolveBundleOptions
//   services.AddBetterHttpClient(options => { /* ... */ });
//   var options = BetterHttpClientExtensions.ResolveBundleOptions(provider, name);

// after: register process-wide defaults, then each named client
services.AddBetterHttpClientDefaults(options => { /* process-wide defaults */ });
IBetterHttpClientBuilder builder = services.AddBetterHttpClient("payments");

var options = BetterHttpClientExtensions.ResolveClientOptions(provider, "payments");
// builder.Name == "payments"

A custom ICrystalXmlEmitter must implement the new namespace members

ICrystalXmlEmitter gains six members for namespaces. A hand-written emitter must implement them; the three emitters the library ships (CrystalXmlWriter, CrystalXDocumentEmitter, CrystalXmlWriterEmitter) already do. The new members are an element form and an attribute form that each take a namespace, a qualified-name attribute value in two forms (one with an explicit namespace, one without), and two writers for namespace declarations (one for a prefixed namespace, one for the default namespace). The interface passes namespaces, never prefixes: each emitter assigns its own prefixes from the declarations in scope.

A bare [DataMember] beside a renaming [JsonProperty] now fails (CJSON0011)

Both paths now refuse a member that carries [DataMember] with no rename next to a renaming JSON attribute. 7.4.3 refused only two explicit names on one member. The fix is to split the type: one type per serializer.

[DataContract]
public sealed class Product
{
    [DataMember]
    [JsonProperty(PropertyName = "sku")]
    public string Code { get; init; } = "";
}
// error CJSON0011: The member 'Product.Code' declares two different format names: the data contract
// names it 'Code' ([DataMember], which names it after the member), and [JsonProperty("sku")] names it
// 'sku'. One type cannot serve two format contracts at once: split it into one DTO per serializer,
// each carrying a single naming attribute.

An explicit interface implementation cannot carry [DataMember] (CJSON0022)

The generator used to emit an accessor whose identifier was the qualified member name, which does not compile. It now refuses the shape on a [DataContract] type (CJSON0022) and skips the member on a plain DTO. The member-discovery section above shows the refused shape; the fix is to promote the member to a normal [DataMember] and let the explicit implementation delegate to it:

[DataContract]
public sealed class Coupon : IHasCode
{
    [DataMember]
    public string Code { get; init; } = "";

    string IHasCode.Code => this.Code;
}

var json = CouponSerializers.Coupon.ToJsonText(new Coupon { Code = "SAVE10" });
// json == { "Code": "SAVE10" }