Cette page n'est pas encore traduite. La version anglaise est affichée ci-dessous.

FoundationDB .NET client 7.4.3

Released on 2026-08-17.

The changes delivered in the 7.4.3 packages, from 7.4.2, 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

  • A new cluster is ready on first start. With Aspire, the SDK creates FoundationDB's database the first time the cluster runs, so a new local cluster no longer hangs waiting to be set up by hand.
  • Native idempotency: on FoundationDB 7.2 and later, tr.Options.WithAutomaticIdempotency() keeps a commit whose outcome the client never saw from being applied twice when the retry loop retries it.
  • FdbFuture<T> allocates less: about 192 fewer bytes per asynchronous operation, and shutdown drains pending operations deterministically.
  • Native client loading on macOS and Linux is fixed: a libfdb_c in the application directory no longer loads instead of the one the package deployed.
  • FdbTextIndex, a new demonstration Layer for full-text search (experimental, moving to supported soon).

SnowBank

  • CrystalJson: the reflection path and the source generator now agree on the output for the same type, and enums serialize as strings by default (reading still accepts numbers).
  • CrystalXml: source-generated XML output.
  • A new DataContractCompat profile reproduces the DataContractJsonSerializer output when you select it (numeric enums, \/Date(ms)\/ dates, ISO 8601 durations, pair-array dictionaries), so you can replace DCJS without changing the JSON a consumer sees. It is opt-in; the default output is unchanged in shape.
  • BetterHttpClient defaults apply to every HttpClient: AddBetterHttpClientDefaults() routes a plain AddHttpClient client through the BetterHttpClient stack; AddBetterHttpClient(name, ...) stays for a client that needs its own settings.

FoundationDB

A new cluster creates its database on first start

A newly created FoundationDB cluster has no database yet. FoundationDB needs a one-time configure new command to create one, and until that runs every transaction hangs and status reports the database as unavailable. With Aspire, the SDK now runs that command for you: a cluster started from the AppHost creates its database the first time it runs, so a fresh local-dev cluster is ready with no manual fdbcli --exec "configure new single ssd" step.

// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// starts a FoundationDB container; a brand-new cluster is provisioned on first run
var fdb = builder.AddFoundationDb("fdb", apiVersion: 720, root: "myapp");

builder.AddProject<Projects.MyApi>("api").WithReference(fdb);

builder.Build().Run();

Auto-provisioning is on by default. Turn it off to manage the database yourself:

var fdb = builder.AddFoundationDb("fdb", apiVersion: 720, root: "myapp")
    .WithAutoProvisioning(false);

Under the hood it calls Fdb.Provisioning.EnsureDatabaseConfiguredAsync, which the test harness also uses; call that directly only when you provision a cluster yourself.

Native idempotency support

When a client sends a commit and then loses the connection before the cluster answers, the commit's outcome is unknown: it may have applied or not (FoundationDB calls this commit_unknown_result). A blind retry is unsafe, because retrying a transaction that did commit applies it twice: a second withdrawal, a duplicate insert. Before FoundationDB 7.2 the application had to make each transaction check whether its own effect was already present. FoundationDB 7.2 added a built-in fix: the client tags each commit with an idempotency id, the cluster records it, and a retried commit that already applied is recognized and not repeated.

The SDK exposes it on the transaction options: WithAutomaticIdempotency() generates the id for you, WithIdempotencyId(id) sets one you control. When you target api level 720 or greater, set it in the handler and the retry loop does the rest:

long newBalance = await db.ReadWriteAsync(async tr =>
{
    // reset per attempt, so set it inside the handler
    tr.Options.WithAutomaticIdempotency();

    long balance = (await tr.GetAsync(accountKey)).ToInt64();
    long updated = balance - amount;
    tr.Set(accountKey, Slice.FromInt64(updated));
    return updated;
}, ct);

The retry loop retries on commit_unknown_result. Without idempotency, that re-runs the handler, which reads the already-updated balance and subtracts a second time. With automatic idempotency, the loop confirms the first commit landed and returns that attempt's result (newBalance) instead of re-running the handler. Setting the option against a cluster below api level 720 throws NotSupportedException where the option is set (7.4.2 produced a cryptic native error or a silent no-op), so targeting api level 720 is all a caller needs.

A Layer that must also run on api level 710 or lower checks tr.Options.IsAutomaticIdempotencySupported first and falls back when the feature is absent:

if (tr.Options.IsAutomaticIdempotencySupported)
{
    tr.Options.WithAutomaticIdempotency();
}
else
{
    // handle idempotency yourself for the old cluster
}

Optimized FdbFuture<T> to reduce memory allocations

Every asynchronous operation waits on an FDBFuture from the native client. The managed wrapper for one used to allocate a TaskCompletionSource and its Task (144 bytes) and register in a ConcurrentDictionary (a 48-byte node), about 192 bytes per operation. On a read-heavy workload that was most of the per-operation managed allocation. The wrapper now uses a pooled IValueTaskSource and a GCHandle cookie with one UnmanagedCallersOnly completion callback shared by every result type, so a completed future allocates nothing on the managed side, and the register-resolve-unregister round-trip runs about four times faster in a microbenchmark (43.9 ns to 10.7 ns). Fdb.Stop() and database dispose now drain pending futures deterministically, which closes the shutdown races of issue #48. The netstandard2.0 build keeps the marshaled-delegate path. Nothing to call differently.

Loading the native client on macOS and Linux is fixed

On macOS and Linux the client loads libfdb_c by path, but the P/Invoke name fdb_c was resolved separately on the first call, so it could bind a different copy: a stale libfdb_c in the application base directory, a system copy (/usr/local/lib/libfdb_c.dylib), or a dyld leaf-name match. The client now installs a DllImportResolver that resolves every fdb_c import to the library it preloaded, so the loaded client is always the one the package deployed. The native package's build targets also stop leaving a copy of libfdb_c at the folder root. Windows was never affected: its module table binds the name once the library loads.

A new demonstration Layer: FdbTextIndex

FoundationDB.Layers.Experimental gained FdbTextIndex<TId>, a full-text search Layer over JSON documents: term, phrase, proximity, and boolean queries, ranked by a weighted-sum (BM25-style) scorer. It is a demonstration, not a production Layer. It shows how to build something past a key-value map on FoundationDB (an inverted index, per-field weighting, a relevance scorer). Its keyspace layout and API are free to change today, and it will move out of FoundationDB.Layers.Experimental to a supported namespace in a future release. Read it to learn the pattern; do not put a production index on it yet.

An index is declared over a set of JSON paths, each with a weight:

var index = new FdbTextIndex<string>(db.Root["books"],
[
    new FdbTextField("title", weight: 3.0),
    new FdbTextField("synopsis", weight: 1.0),
]);

Like every layer, it resolves per transaction. Indexing takes the document as a JsonObject:

await db.WriteAsync(async tr =>
{
    var state = await index.Resolve(tr);
    await state.IndexAsync(
        tr,
        id: "1984",
        JsonObject.Parse("""
        {
            "title": "1984",
            "synopsis": "A clerk rebels against a total surveillance state."
        }
        """)
    );
}, ct);

Search returns ranked FtsHit<TId> results. A string query is parsed into the query tree; the tree (FtsTerm, FtsAnd, FtsOr, FtsAndNot, FtsPhrase) is also accepted directly:

var hits = await db.ReadAsync(async tr =>
{
    var state = await index.Resolve(tr);
    return await state.SearchAsync(tr, "surveillance state", limit: 10);
}, ct);
// hits ranked by relevance; a title match outranks a synopsis match, given the 3x title weight

RemoveAsync(tr, id) drops a document from the index.

A database that fails to open at startup can recover

A database that failed to open during startup used to stay broken for the process lifetime: FdbDatabaseProvider, the DI-registered IFdbDatabaseProvider, latched the first failure and returned it to every later request. A failed first open is now transient. A later request retries, so a node that starts before its cluster is reachable recovers on its own, with no code change.

FakeDb matches the real client more closely

FakeDb is the in-memory FoundationDB that tests use instead of a live cluster. This release ran a conformance campaign that compares FakeDb against a real cluster and corrects every place they diverged, so more application logic can be validated against FakeDb before it touches a real database. The corrected cases cover key-selector resolution near pending atomic writes and CompareAndClear, boundary-key anchoring for GetKey and GetRange, read-conflict tracking across atomic chains and clear-ranges, versionstamp handling, and the watch lifecycle. A test that asserted FakeDb's old, wrong answer sees the corrected one.

The committed data also moved behind an internal storage seam (IFdbCommittedStore), groundwork for a future storage backend.

FdbTop and FdbShell

FdbTop reads command-line options instead of only its defaults: a cluster file, an api version, a connection string, a timeout, and the Aspire and Docker modes.

fdbtop --connfile fdb.cluster --api 720
fdbtop --aspire

FdbShell no longer emits an empty command token when ENTER completes a command that takes no arguments.

SnowBank

CrystalJson

CrystalJson now reproduces the DataContractJsonSerializer output, and the reflection path and the source generator agree on the output for the same type, so a [DataContract] estate can port to CrystalJson one service at a time. The individual changes follow.

Enums serialize as strings by default

The default form flips from the numeric value to the string literal, on the text writer, the DOM route and generated converters alike. Reading stays tolerant: names bind case-insensitively, and numbers and numeric strings are still accepted, so pre-existing payloads deserialize unchanged.

enum Status { Active = 2 }
// 7.4.2: {"status":2}
// 7.4.3: {"status":"Active"}

To keep the numeric form: WithEnumAsNumbers() on the settings, or per member:

[JsonProperty(EnumFormat = JsonEnumFormat.Number)]
public Status Status { get; init; }

The DataContractCompat preset: output compatible with DataContractJsonSerializer

When you replace DataContractJsonSerializer (DCJS) but a consumer still expects its exact JSON, CrystalJsonSettings.DataContractCompat reproduces that output: numeric enums, Microsoft \/Date(ms)\/ dates, ISO 8601 duration strings for TimeSpan, dictionaries as [{"Key":..,"Value":..}] pair arrays, and explicit null members. The modern default writes string enums, ISO 8601 dates, object-map dictionaries, and omits nulls.

public sealed record Snapshot
{
    public DayOfWeek Kind { get; init; }
    public DateTime When { get; init; }
    public TimeSpan Elapsed { get; init; }
    public Dictionary<string, int> Counts { get; init; }
    public string? MaybeNull { get; init; }
}

var dto = new Snapshot
{
    Kind = DayOfWeek.Friday,
    When = new DateTime(2009, 2, 13, 23, 31, 30, DateTimeKind.Utc),
    Elapsed = new TimeSpan(1, 2, 3, 4, 5),
    Counts = new() { ["a"] = 1 },
    MaybeNull = null,
};

CrystalJson.Serialize(dto), the modern default:

{
    "Kind": "Friday",
    "When": "2009-02-13T23:31:30Z",
    "Elapsed": 93784.005,
    "Counts": { "a": 1 }
}

CrystalJson.Serialize(dto, CrystalJsonSettings.DataContractCompat), compatible with DataContractJsonSerializer:

{
    "Kind": 5,
    "When": "\/Date(1234567890000)\/",
    "Elapsed": "P1DT2H3M4.005S",
    "Counts": [ { "Key": "a", "Value": 1 } ],
    "MaybeNull": null
}

Enums become numbers, dates take the Microsoft \/Date(ms)\/ form, TimeSpan takes the ISO 8601 duration form, dictionaries become Key/Value pair arrays, and a null member is emitted rather than omitted.

Reading accepts both forms regardless of settings, so producers and consumers migrate independently. Each legacy form also has its own toggle: WithIso8601Durations() / WithNumericDurations() for TimeSpan, WithDictionariesAsPairArrays() / WithDictionariesAsMaps() for dictionaries. A container bakes the preset with [CrystalJsonConverter(CrystalJsonSerializerDefaults.DataContractCompat)]. Combining the preset with a naming policy (camelCase) is refused at build time (CJSON0013).

[JsonIgnore(Condition = ...)] follows the System.Text.Json semantics

Both paths now read the Condition property instead of treating any [JsonIgnore] as unconditional.

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]  // omitted only when null
public string? Note { get; init; }
Condition 7.4.2 7.4.3
Always (default) excluded excluded (unchanged)
Never excluded always emitted
WhenWritingNull excluded omitted only when null
WhenWritingDefault excluded omitted only when equal to the member default

JsonIgnoreCondition.Never means "never ignore this member". WhenWritingDefault compares against the member's declared default, and WhenWritingNull on a non-nullable value type is inert instead of throwing.

Non-public members

On a [DataContract] type, [DataMember] on a non-public member is honored automatically (private, internal, protected fields and properties, and the non-public accessors of a public [DataMember] property), matching DCJS. This is an output change: members appear that did not before.

[DataContract]
public sealed class Order
{
    [DataMember(Name = "total")]
    private decimal Total { get; set; }   // now serialized
}

On a type without [DataContract], non-public members stay invisible unless annotated with [JsonInclude]. The generator reaches non-public [JsonInclude] members through accessor thunks ([UnsafeAccessor] on net8+, reflection otherwise). An internal member with no include/exclude signal keeps its historical generated-only inclusion and gets the suppressible warning CJSON0012.

[IgnoreDataMember] now excludes the member

7.4.2 never read the attribute; the member serialized anyway. Both paths honor it now on a type without [DataContract]. This is an output change for any POCO that already carried it. On a [DataContract] type the attribute stays inert, because the [DataMember] opt-in gate runs first (DCJS fidelity).

The foreign [JsonConverter(typeof(...))] spellings are honored

A member or type carrying System.Text.Json's or JSON.NET's [JsonConverter(typeof(X))] now serializes through X, when X implements the CrystalJson converter contract for the value's type. 7.4.2 ignored the attribute. A converter type this library cannot run keeps being ignored, so a half-migrated DTO stays serializable with default handling.

[JsonConvertWith], the native converter attribute

[JsonConvertWith(typeof(X))] attaches a CrystalJson converter to a member or type, where X implements IJsonPacker<T> and/or IJsonDeserializer<T>.

[JsonConvertWith(typeof(TrimmedStringConverter))]
public string Code { get; init; }

Recognition is per facet: a type that implements one half serves that direction, and default handling covers the other. The native attribute wins over [JsonBooleanLiterals] and both foreign [JsonConverter] spellings. A [JsonConvertWith] naming a type without the Pack/Unpack pair fails loudly (CJSON0010).

Member converters for the nullable form

A member converter can be declared for the nullable form itself: IJsonMemberConverter<DateTime?> on a DateTime? member. The bridge probes the member's exact type first, then falls back to the existing lift. The T? form owns every present value and can answer null, distinct from default(T) and from JSON null or a missing member. The declaration transfers the read side only. A T? converter on a non-nullable member is refused.

Required members are enforced on read

Two changes, both throwing JsonBindingException where a bind used to succeed silently. C# required members: a null-or-missing member throws on the reflection path (the source-generated path always did). [DataMember(IsRequired = true)] on a [DataContract] type: a member absent from the document throws, while an explicit null satisfies it. If a stored population legitimately misses such members, the fix is on the type, not the reader.

Dictionaries bind from the legacy DataContract array shape

The dictionary binders accept the DCJS array shape on read, in the branch that previously threw.

[ { "Key": "a", "Value": 1 }, { "Key": "b", "Value": 2 } ]   // 7.4.2: JsonBindingException. 7.4.3: binds.

The object-map fast path is untouched. The reader is strict: every element must be an object with exactly Key and Value (exact casing). Types without a custom binder (for example ImmutableDictionary) still reject the legacy shape.

Legacy dictionary emission

WithDictionariesAsPairArrays() writes every dictionary as [ {"Key":..,"Value":..}, ... ] for legacy clients that cannot read maps; WithDictionariesAsMaps() restores the default. Off by default, and the shape is always accepted on read. Keys keep their natural JSON type.

string json = CrystalJson.Serialize(map, CrystalJsonSettings.Json.WithDictionariesAsPairArrays());

The legacy and BCL collection types bind instead of throwing, or binding wrong

Every collection type either round-trips correctly or fails loudly.

Declared type 7.4.2 7.4.3
Queue<T>, ConcurrentQueue<T> JsonBindingException binds; order is dequeue order
Stack<T>, ConcurrentStack<T> no binder binds; the round-trip preserves order
ConcurrentBag<T> no binder binds; content preserved, order unspecified
ImmutableSortedSet<T> wrong type, silent null dedicated binder, correct type
IReadOnlySet<T> a type that does not implement the interface binds to HashSet<T>
IDictionary<K,V>, IReadOnlyDictionary<K,V> no binder bind to Dictionary<K,V>

Subclasses of Collection<T> bind to the declared type, and the generator no longer produces invalid converters for them. Nothing that bound correctly in 7.4.2 changes meaning.

[JsonBooleanLiterals] takes one constructor, and a null false literal means "do not emit"

The typed constructors are replaced by JsonBooleanLiteralsAttribute(object? whenFalse, object whenTrue). Source-compatible: [JsonBooleanLiterals("0", "1")] and [JsonBooleanLiterals(0, 1)] still compile.

[JsonBooleanLiterals("0", "1")]
public bool Active { get; init; }   // true -> "1",  false -> "0"

A null first argument omits the member when false: [JsonBooleanLiterals(null, "1")] writes "1" for true and nothing for false; [JsonBooleanLiterals(null, true)] writes true for true and nothing for false.

Arguments are checked at contract build (reflection) or reported as CJSON0017 (generator). StrictLiterals = true with a null false literal is contradictory and warns (CJSON0018).

Lifecycle callbacks now run

The four [OnSerializing] / [OnSerialized] / [OnDeserializing] / [OnDeserialized] callbacks were never invoked before this release. They run now, on both write routes and on read, identically on the reflection path and in generated converters. Proxies have no lifecycle: the callbacks run when a value is materialized (ToValue(), Deserialize, Unpack).

[OnDeserialized]
void AfterRead() => this.FullName = $"{this.First} {this.Last}";

Only the modern signatures are accepted. void M(StreamingContext) is refused (CJSON0015), and an [OnDeserializing] callback cannot share a type with a required or init-only member (CJSON0016).

To port a DataContractJsonSerializer estate: for each callback, remove the StreamingContext parameter (void M()), or on [OnDeserializing] / [OnDeserialized] replace it with JsonObject, JsonArray or JsonValue to read the incoming document. Converting a type costs it its DCJS compatibility, so convert a shared DTO together with every service that still serializes it through DCJS. When [OnDeserializing] meets a required or init-only member, drop [OnDeserializing] (usually the right move, since [OnDeserialized] runs on the fully populated instance with no such limit) or relax the member.

One nesting-depth cap for every format

The two JSON writers each carried a private cap of 16 levels and the generated paths had none. All formats and the parser now share SnowBank.Data.Json.CrystalJsonWriter.MaxDepth = 64 (CrystalXml.MaxDepth is an alias). For the JSON writers this is a loosening (64 up from 16); the generated paths refuse past 64 with a typed exception instead of overflowing the stack.

Microsoft-format dates, and three date and number read fixes

"/Date(ms+HHMM)/" carries the UTC epoch in the milliseconds and the producer's offset in the suffix. The reader used to shift the instant by the machine-vs-suffix offset difference. JsonString.ToDateTime now surfaces the instant in the reader's local time (DCJS-faithful) and ToDateTimeOffset keeps the instant and the producer's offset. Also fixed: a platform-localized long date parses everywhere (the U+202F narrow no-break space before AM/PM on macOS and Linux); JsonDateTime extremes map to DateTimeOffset.MinValue/ MaxValue regardless of the machine offset; and JsonNumber.ToDecimal recovers scale from the literal, so 1.100 binds to 1.100m (scale 3) rather than 1.1m.

[JsonProperty(NumberFormat = String)]

A numeric member can serialize as a JSON string, which protects 64-bit values from the precision loss of JavaScript consumers. Reading always accepts both forms.

[JsonProperty(NumberFormat = JsonNumberFormat.String)]
public long AccountId { get; init; }
// => {"AccountId": "12345678901234567"}

Canonical(), deterministic output for hashing

CrystalJsonSettings.Canonical() writes a deterministic form, so two serializations of equal values produce byte-identical output whether the value was built in code or parsed from text. It sorts object members (ordinal, case-sensitive, RFC 8785) and normalizes numbers to a single form. For golden-file comparisons, hashing, and content-addressed storage. Off by default.

var doc = CrystalJson.Parse("""{ "bar": 2, "Baz": 1, "alpha": 3 }""");

// {"bar":2,"Baz":1,"alpha":3}   member order kept
doc.ToJsonText(CrystalJsonSettings.JsonCompact);
// {"Baz":1,"alpha":3,"bar":2}   sorted
doc.ToJsonText(CrystalJsonSettings.JsonCompact.Canonical());

Numbers collapse to one form: a whole-valued double keeps a float marker (1.0), a decimal drops trailing-zero scale (9.90 becomes 9.9), and the exponent form is fixed (1e-7, 1e+21).

Self-serializable types

[CrystalJsonSelfSerializable] is a meta-attribute applied to one of your own attribute classes; every type carrying that attribute is opted into JSON source generation, so a layer can opt its own types in without coupling the application to the generator.

[CrystalJsonSelfSerializable]
public sealed class MyEntityAttribute : Attribute { }

[MyEntity]
public sealed partial record Widget { public required string Name { get; init; } }

// generated, one reserved member name:
var converter = Widget.Json.Default;
var proxy = Widget.Json.ReadOnly;

Diagnostics: CJSON0004/CJSON0005 (not partial, generic, or nested), CJSON0006 (a member already named Json), CJSON0007 (a referenced type named like a scope member).

The source generator's generated code

Generated JSON code no longer relies on the project's global usings: it fully qualifies every BCL name and is warning-free under its own #nullable enable, so a warnings-as-errors project no longer trips on CS8600/CS8603/CS8625 in generated files. A value-type T? or a read-only member no longer breaks generation; a read-only member is serialization-only, written to the output and skipped on read. The generator's language floor is C# 9, checked explicitly (SYSLIB1221 below it).

CrystalXml

CrystalJson's source generator can now emit XML as well as JSON. A container that requests XML output gets an XML serializer for each enrolled type, with a DataContractSerializer-compatible profile and a general profile. The truth table, the two profiles, and the CXML0001-CXML0013 diagnostics are in crystalxml.md.

[CrystalConverter]
[CrystalXmlOutput]
[CrystalSerializable(typeof(Order))]
public static partial class XmlSerializers { }

Declaring a container for JSON, XML, or both

Because a container can now produce JSON, XML, or both, declaring one is split into three parts: which class hosts the generated code ([CrystalConverter]), which formats it produces ([CrystalJsonOutput] and [CrystalXmlOutput]), and which types it enrolls ([CrystalSerializable], the same enrollment for every format).

[CrystalConverter]
[CrystalJsonOutput(CrystalJsonSerializerDefaults.Web)]
[CrystalXmlOutput]
[CrystalSerializable(typeof(User))]
[CrystalSerializable(typeof(Product))]
public static partial class ApplicationSerializers { }

[CrystalJsonConverter] and [CrystalXmlConverter] remain as single-format shortcuts. A container that names no output format (CRYS0001), combines a shortcut with an output attribute (CRYS0002), or carries several markers (CRYS0003) is refused at build time.

BetterHttpClient: the defaults apply to every HttpClient

AddBetterHttpClientDefaults(configure) installs a ConfigureHttpClientDefaults hook, so every client the IHttpClientFactory builds routes through the BetterHttpClient stack (the INetworkMap transport, packet capture, and the global options), whether you register it with a plain AddHttpClient, a typed AddHttpClient<T>, or a named AddHttpClient("x"). A stock client needs no enrollment; reach for AddBetterHttpClient("name", ...) only when one client needs its own certificates, credentials, or filters.

// baseline for every factory client
services.AddBetterHttpClientDefaults(options =>
{
    options.DefaultRequestHeaders.UserAgent = [ new ProductInfoHeaderValue("AcmeApp", "5.2") ];
});

// a plain HttpClient now carries those defaults
services.AddHttpClient("catalog");

// a named bundle for a client that needs more control
services.AddBetterHttpClient("payments", options =>
{
    options.AcceptSelfSignedServerCertificates();
});

Calling AddBetterHttpClientDefaults more than once is safe: each configure composes and the hook installs once. The old no-name AddBetterHttpClient(configure) overload is retired (see the Breaking changes).

SnowBank.Core: slices and collections

Slice fixed-width encoders: two output fixes

Slice.FromFixedU16BE returned a slice larger than the two bytes it wrote, and FromFixed128BE(upper, lower) split overloads emitted little-endian bytes. Both now produce what their names promise. Keys stored through the broken overloads do not match the corrected output.

BitMap64.FindSpan

// index of the first run of N contiguous set bits
int at = BitMap64.FindSpan(bits, start);

SnowBank.Testing

SnowBank.Testing.Framework.Playwright: builder hooks

Five configuration hooks on the Playwright component, plus a readiness-predicate overload of WaitForPageReadyAsync on IPage.

component.WithContextOptions(o => o.ViewportSize = new ViewportSize { Width = 1920, Height = 1080 });
component.WithInitScript("window.__testMode = true;");
// return null drops the line
component.WithConsoleFormatter(msg => msg.Type == "debug" ? null : msg.Text);
component.WithSnapshots();

await page.WaitForPageReadyAsync(ct, readyPredicate: (p, c) => p.EvaluateAsync<bool>("window.appReady"));
Hook What it does
WithBrowserOptions / WithContextOptions tweak the Chromium launch and browser-context options on top of the defaults
WithInitScript inject a context-level init script, before the first page
WithConsoleFormatter reformat or drop JS-console lines routed to the test journal
WithSnapshots full-page screenshots into the per-test directory, plus an index.html contact sheet

SimpleTest.ShouldNotHappenWithin

Asserts that a condition stays false for the whole duration, the negative counterpart of the existing settle helpers, in sync and async forms.

await ShouldNotHappenWithin(() => queue.Count > 0, TimeSpan.FromSeconds(1), "queue must stay empty");

Breaking changes

Your code no longer compiles, or a required signature moved. Each entry names its fix. The output changes above (enums as strings, honored [JsonIgnore], non-public [DataMember] members, the DataContract output) change bytes without breaking the build; review them if you compare output.

[CrystalJsonSerializable] is obsolete, use [CrystalSerializable]

Enrolling a type in a container is format-neutral now. [CrystalSerializable(typeof(...))] is the new spelling; the old one compiles with a CS0618 warning. The rename is mechanical.

// before (CS0618 warning)
[CrystalJsonSerializable(typeof(Widget))]
// after
[CrystalSerializable(typeof(Widget))]

IJsonPacker<T>.Pack takes a ref CrystalJsonPackContext

The member changed to Pack(ref CrystalJsonPackContext context, T instance). The context carries the settings, resolver, nesting depth, and visited-object stack by ref, so a reference cycle through a collection member raises a catchable recursion error instead of a StackOverflowException.

// hand-written converter, new signature
public void Pack(ref CrystalJsonPackContext context, Money instance)
{
    context.Writer.WriteValue(instance.Cents);
}

The Pack(instance, settings, resolver) shape survives as an extension method; source-generated containers pick up the change by recompiling; precompiled assemblies must be recompiled.

CrystalJsonSettings.OptionFlags.EnumsAsString is a compile error

The flag bit is renamed EnumsAsNumbers, with the opposite meaning, because the default enum form flipped. The old member is [Obsolete(error: true)] and names the replacement. The EnumsAsString boolean property and the WithEnumAsStrings() / WithEnumAsNumbers() methods keep their names. Only code that manipulates OptionFlags directly is affected.

IFdbDatabase.BeginTransactionAsync is a compile error

The three-argument BeginTransactionAsync(FdbTransactionMode, CancellationToken, FdbOperationContext?) now carries [Obsolete(error: true)]. Call BeginTransaction(...).

The no-name AddBetterHttpClient(configure) overload is a compile error

AddBetterHttpClient(Action<BetterHttpClientOptions>), with no name, configured only the default bundle, so a stock AddHttpClient client escaped the network map. It now carries [Obsolete(error: true)]. Call AddBetterHttpClientDefaults(configure), which routes every factory client through the map.

services.AddBetterHttpClient(options => { /* ... */ });      // before: compile error
services.AddBetterHttpClientDefaults(options => { /* ... */ }); // after

TokenMap and TokenDictionary are compile errors on net9+

TokenMap<T>, ByteStringTokenMap, CharStringTokenMap and the TokenDictionary backing type are now [Obsolete(error: true)] on net9+. Use the BCL alternate lookup:

var lookup = dictionary.GetAlternateLookup<ReadOnlySpan<char>>();

The types remain usable on net8.0, netstandard2.0 and net472, which have no AlternateLookup equivalent.

An ambiguous DTO is refused

Two combinations that used to resolve silently, and how they resolved changed between versions, are now refused on both paths:

// CJSON0008: unconditional ignore next to an include signal
[DataMember, JsonIgnore]
public string Secret { get; set; }

// CJSON0011: two different output names
[DataMember(Name = "id"), JsonProperty("identifier")]
public string? Id { get; set; }

The reflection path throws at contract build; the generator reports the diagnostic. The remedy is the split, one DTO per serializer, each with a single coherent set of attributes.

A [DataContract] type in a generated container gets the DataContract output

The generator used to ignore a type's DataContract attributes (all public members, C# names), producing a different output than the reflection path. Generated converters now implement the DataContract membership model, so both paths agree.

[DataContract]
public sealed class Order
{
    [DataMember(Name = "id")]
    public string? Id { get; set; }

    public string? Scratch { get; set; }   // no [DataMember]: excluded
}
// 7.4.2 generated: {"Id":null,"Scratch":null}
// 7.4.3 generated: {"id":null}

Member order is declaration order and does not match DCJS. This is an output change for any type already enrolled.

Two IL-level shape changes behind the new attribute hierarchy

CrystalJsonConverterAttribute now derives from CrystalConverterAttribute (not System.Attribute directly), and CrystalJsonSerializableAttribute inherits its Types property from CrystalSerializableAttribute. Both are source-compatible; only code doing its own reflection over a precompiled assembly built against the old shape can observe a difference. Recompile against the new package rather than mixing binaries.

Other fixes

Internal corrections and minor fixes, listed for completeness:

  • FdbException(FdbError) resolves its message from a 337-entry managed table (FdbErrorMessages), so the code-only constructor works on a process with no fdb_c loaded. Used by the binding and FakeDb, not application code.
  • FdbErrorDebugger exposes the native client's error translation (GetErrorMessage, MapToException, TestErrorPredicate) for test oracles that check against those answers, without an InternalsVisibleTo grant. Test and tooling use, not application code.
  • The Aspire default container tags moved forward (LatestVersion73 to 7.3.78, LatestVersion71 to 7.1.66), and LatestVersion72 was corrected from the AVX-only 7.2.9 to the non-AVX 7.2.8, which also runs on ARM64 and Apple Silicon. Pin an explicit version to override.
  • The transaction debugger's second dump site read the read-conflict set under its "Write Conflicts" heading, printing the same set twice and reporting zero write conflicts; the dump text changed, the conflict-marking logic was always correct.
  • ColaRangeSet<TKey>.Mark(begin, end) treats an empty range (begin == end) as a no-op instead of throwing; a backwards range still throws.
  • ColaOrderedDictionary.RemoveRange and IterateAndRemoveRange no longer throw IndexOutOfRangeException when the range search runs out of entries.
  • The packet-capture decoder (SnowBank.Networking.PacketCapture) decodes leniently and no longer throws on a malformed dump.

Nothing to do

Recorded so you can tell "no entry" from "not looked at":

  • The public FoundationDB.Client transaction, key, subspace and directory APIs are unchanged in 7.4.3.
  • CrystalJsonSettings.AllowTrailingData() and WithoutComments() shipped in 7.4.2, not here.
  • FdbMutationType.ByteMin / ByteMax predate 7.4.2 as well.
  • Existing JSON-only containers generate exactly what they generated in 7.4.2; nothing about the JSON format itself moved.