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
VisitRangeAsyncstops at the limit across pages. A call with aLimitused to visit the whole range; it now visits at most that many key/value pairs.FdbWatch.WaitAsynctakes aTimeProvider, so a test can control a watch timeout with an injected fake clock.
SnowBank
BetterHttpClientmoves toIHttpClientFactory. Each named client has its own options, including per-client cookie and proxy switches.- The CrystalXml
DataContractCompatpreset writes the standardDataContractSerializer(DCS) format, namespaces included, andCrystalXmlSettingsadds runtime writer options (indentation, line ending, XML declaration, encoding). - CrystalJson reads more legacy serializer output: a
DateTimeOffsetwritten byDataContractJsonSerializer, 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.
ResolveBundleOptionsbecomesResolveClientOptions, andBetterHttpShellOptionsis retired. The old names stay as[Obsolete]forwarders; 8.0 removes them.Per-client cookie and proxy switches.
BetterHttpClientOptionsgains nullableUseCookiesandUseProxy.nullkeeps the historical behavior: the feature turns on only when the caller supplies aCookiescontainer or aProxy.falseforces the feature off and refuses a contradictory container or proxy.trueturns the feature on.UseCookies = truewith no container creates oneCookieContainerper client name; the cookie state survives handler-chain rebuilds and never crosses to another client name on the pooled transport.UseProxy = truewith noProxyuses 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
newis counted once. An override keeps the inheritance level of the member it overrides. A member redeclared withnewtakes its own level and is written once, from the most derived accessor. The reference serializer writes both copies of anewmember; 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 = ...)], elsehttp://schemas.datacontract.org/2004/07/plus the CLR namespace); - the writer marks a null member with the XML attribute
i:nil="true", whereiis the prefix of the XMLSchema-instance namespace declared on the root. 7.4.3 wrote a barenilattribute 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)
);
Indentedturns on indentation;NewLine(CrlforLf) selects the line ending. Compact is the default, and the writer never uses the host'sEnvironment.NewLine.EmptyElementStyleselects<foo/>(default) or<foo></foo>for an empty element.WriteXmlDeclarationadds the<?xml ...?>line.ShowNullMemberswrites a null member as ani:nilelement instead of skipping it. It is on forDataContractCompatand off forGeneral;WithNullMembers()/WithoutNullMembers()toggle it.Encodingis 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.Delayrounds 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" }