Skip to content

v3.2.0

Latest

Choose a tag to compare

@github-actions github-actions released this 26 Aug 22:10

[3.2.0] — 2026-08-26 — Ledger Wire Contract (repairs 3.0.0 / 3.1.0)

Upgrade if you use OpenSearch or MongoDB. Two defects shipped in 3.0.0 and persisted
through 3.1.0, both from the same root cause: the ledger's identity on the wire — which
index a document lives in, what its fields are named — was allowed to depend on
configuration the library does not own.

  • OpenSearch was broken outright. Every migration run that discovered at least one
    migration failed before contacting the cluster, including through the shipped runner and
    CLI. Not AWS-specific.
  • MongoDB failed silently. Squash reconciliation matched nothing, so squashed
    migrations re-ran on every invocation with no error to notice.

Both are fixed with no consumer action and no data migration. Couchbase carried a latent
form of the same coupling and is now pinned. The release also adds a ConnectionSettings
escape hatch on the OpenSearch client factories, which is purely additive — source- and
binary-compatible with 3.1.x.

Added

  • OpenSearch: configureSettings escape hatch on both client factories.
    AddOpenSearchClient and AddOpenSearchAwsClient (and both IConfiguration overloads)
    gain an Action<ConnectionSettings> parameter, applied last — after the endpoint
    and authentication wiring — so a consumer can override anything the library set.

    Purely additive: source- and binary-compatible with 3.1.x. This ships as four new
    overloads rather than as an appended optional parameter. Appending one would have been
    source-compatible but not binary-compatible: it changes the existing method's signature,
    so the 3.1.x entry point stops existing and any assembly compiled against it throws
    MissingMethodException until recompiled. A minor release must not do that. The four
    3.1.x signatures are preserved exactly and forward to the new ones, and a reflection
    test pins all eight so the guarantee cannot regress silently.

    OpenSearch is the only provider whose client the library constructs; the other four
    resolve a consumer-registered client and already have full control. Until now the only
    way to reach ConnectionSettings — for RequestTimeout, MaximumRetries,
    EnableHttpCompression, a proxy, ServerCertificateValidationCallback on a
    self-signed development cluster, DisableDirectStreaming while debugging, or a
    DefaultMappingFor over your own document types — was to stop calling the factory
    and hand-roll the registration, forking the auth-mode switch, the AWS-endpoint
    loud-fail, and the mutual-exclusion guard along with it.

    Because the hook makes it reachable, registration now validates one ledger property
    through the configured inferrer and fails with a pointed message if field-name
    inference is no longer camelCase — the ledger index carries a strict camelCase
    mapping that such a change would otherwise break at first write with an opaque
    strict_dynamic_mapping_exception. Validation runs only when a hook is supplied.

    This is not the remedy for the OpenSearch _mget defect below; see ADR-0029.

Fixed

  • OpenSearch: every migration run failed on a stock client (regression, v3.0.0–v3.1.0).
    IntersectWithAppliedAsync issues one _mget, which carries an index in the URL and
    in each body entry. The URL index was set explicitly from
    OpenSearchMigrationOptions.LedgerIndex, but the body entries were left to resolve via
    IndexName.From<OpenSearchMigrationRecord>() — CLR-type inference that reads
    ConnectionSettings.DefaultMappingFor<T>() / DefaultIndex(). Neither
    AddOpenSearchClient nor AddOpenSearchAwsClient configures either, so request
    serialization threw Index name is null for the given type and no default index is set
    before any byte reached the wire. MigrationRunner.RunAsync calls
    IntersectWithAppliedAsync unconditionally whenever at least one migration is
    discovered, so this broke every OpenSearch run — including through the library's own
    Hyperbee.MigrationRunner.OpenSearch and the CLI. The _mget now sets the ledger index
    per operation. No API change; no consumer action required. Consumers who worked around
    this by declaring their own DefaultMappingFor<OpenSearchMigrationRecord> or by forking
    a client factory can remove both.

    Affects Hyperbee.Migrations.Providers.OpenSearch and
    Hyperbee.Migrations.Providers.OpenSearch.Aws 3.0.0 and 3.1.0. Not AWS-specific.

  • MongoDB: squash reconciliation silently covered nothing (regression, v3.0.0–v3.1.0).
    IntersectWithSquashedAsync built its filter half typed and half literal — a typed
    expression for Kind (which renders through the BSON class map, producing Kind) and
    a raw string for "replaces" (which renders verbatim). The driver's default element
    name is the member name, so the writer stores Replaces; the rendered filter
    { "Kind": 1, "replaces": { "$in": [...] } } could never match. The method returned an
    empty set for every input, so a squash was never recognized as covering its replaced
    versions and those migrations re-ran. The failure was silent because an empty set
    is also the correct answer whenever nothing is squashed. Both terms are now typed.

    No wire change: the fix corrects the query to match what the writer already produces.
    Deliberately not fixed by pinning element names with [BsonElement] or a registered
    class map — pinning would orphan any deployment whose consumer registered a global
    naming convention.

  • Couchbase: ledger documents now serialize through a pinned serializer.
    IntersectWithSquashedAsync must name ledger fields as text (m.kind, m.replaces)
    because N1QL has no typed field reference, but ledger documents serialized through
    ClusterOptions.Serializer — consumer-owned configuration. A consumer registering a
    System.Text.Json serializer, or a Newtonsoft one without the camelCase resolver, wrote
    Kind/Replaces and the squash query silently matched nothing, with the same
    re-run consequence as the MongoDB defect above. Ledger KV reads and writes now use a
    library-owned DefaultSerializer in its default configuration.

    Behavior note: this is byte-for-byte identical for consumers on the stock Couchbase
    serializer, which is the default. Consumers who set a custom ClusterOptions.Serializer
    will see new ledger rows written in the canonical camelCase shape. Existing rows stay
    readable by key (ExistsAsync, IntersectWithAppliedAsync are key-based and unaffected);
    ReadAsync on a row written under a custom shape may return null RunOn/Checksum,
    which can cause one extra cron evaluation. Squash reconciliation, which was broken for
    this configuration, starts working.

CI

  • Integration tests gate PRs again. Since 7ff3808 (twelve days before 3.1.0) the
    repository has had zero integration coverage in CI, and no working way to restore
    it. Tagging every container-spinning class [TestCategory("LocalOnly")] left the CI
    job's negative filter (TestCategory!=LocalOnly) matching nothing; dotnet test fails
    on a no-match, so the whole job was gated behind repo variable
    RUN_HEAVY_INTEGRATION — which was never created, and whose documented "re-enable with
    no code change" path provably does not work (the filter still excludes LocalOnly, so
    flipping the variable un-skips a matrix that matches zero tests and fails). Both defects
    fixed in this release shipped through that gap.

    Every integration class was then measured against explicit criteria rather than
    assumed heavy. Every integration test in the repository now runs automatically:

    • 115 tests on every PR (~2 min) — a Gating tier selected positively on
      TestCategory=Gating, across OpenSearch (88), MongoDB (10), Postgres (9),
      Aerospike (6) and multi-provider (2). No Docker image builds in the PR path, so
      the MCR/Azure-Front-Door pull-throttle failure class is removed there rather than
      mitigated. A guard step fails the build by name if a cell matches zero tests, so
      the trap cannot silently recur.

    • 12 tests on manual dispatch — the LocalOnly remainder: the provider runner
      suites plus CliBinaryEndToEndTests (Docker image builds) and 3 Couchbase squash
      classes. A further 6 multi-node tests stay on their existing manual workflow.

    • 3 tests quarantined as [TestCategory("Flaky")]. The
      Should_Fail_WhenMigrationHasLock test in the Aerospike, MongoDB and Couchbase
      runner suites asserts on a race — it starts concurrent runner containers and
      requires one to observe lock contention, which does not happen on a fast host.
      Aerospike and MongoDB were measured failing with no defect present; the Postgres
      equivalent had already been commented out by an earlier author for the same reason.
      Excluded from automation so the post-merge signal stays trustworthy, and recorded
      as debt rather than quietly disabled.

      All 136 integration tests are accounted for; none are orphaned. Couchbase is the only exclusion on speed: its six
      tests pass but take 5–6.5 minutes, five to six times every other cell combined,
      because IsolatedCouchbaseContainer waits out the GSI indexer's initial rebalance.

    RUN_HEAVY_INTEGRATION is removed, replaced by a Heavy Integration Tests workflow
    that runs on demand for any provider and framework — a trigger that, unlike the repo
    variable, actually works. A push-to-main trigger was built and then removed before
    shipping: measuring the suite first showed it is not dependably green (an intermittent
    Aerospike runner test, plus the quarantined race above), and automating an unreliable
    suite produces a signal people learn to ignore. Enabling it is three commented lines
    once those are fixed; tracked in ADR-0031.

Decisions

  • ADR-0029 — the ledger's
    wire contract is library-owned, never inherited from consumer-configured client
    inference. Rule 1: ledger requests carry their target explicitly. Rule 2: every
    reference to a ledger field routes through the same serialization path as the writer.
    Rule 3: a wire-test tier between mock-tier and container-tier.
  • ADR-0030 — ConnectionSettings
    escape hatch on the OpenSearch client factories
  • ADR-0031 — three test tiers
    (unit / wire / integration), positive CI selection on TestCategory=Gating, and the
    LocalOnly split; amends ADR-0010

Tests

  • New wire-shape test tier for the OpenSearch record store
    (OpenSearchLedgerWireTests): the real client and real serializer over an
    InMemoryConnection, so request construction and serialization actually execute with
    only the socket faked. This is the tier that was missing — the existing provider unit
    tests substitute IOpenSearchClient (a substitute never serializes) and the
    container-backed tests are compile-gated and excluded from CI. Includes a generalized
    probe asserting that no ledger operation depends on type→index inference, which
    guards the next regression rather than only this one.
  • Two OpenSearch record-store integration tests for IntersectWithAppliedAsync, one of
    which drives the client produced by services.AddOpenSearchClient(...) — previously
    nothing exercised the shipped registration path end-to-end.
  • MongoDBLedgerWireTests and CouchbaseLedgerWireTests — render the real queries the
    record stores issue and compare them against the real serializer output, with no mock
    and no container. The MongoDB assertions are convention-independent on purpose: they
    assert that the field a query asks for is the field the writer wrote, not that a field
    has a particular casing, so they still pass for a consumer who registered a camelCase
    convention. Pinning a literal casing would have made the tests agree with the bug.
  • MongoDBRecordStoreIntegrationTests — squash and applied reconciliation against a real
    MongoDB. IntersectWithSquashedAsync previously had no coverage at any tier.
  • OpenSearchConnectionSettingsHookTests — the hook reaches the resolved client on both
    registration paths, runs after auth wiring (a consumer override wins), stays optional,
    and loud-fails on a ledger-breaking field-name inferrer. Includes an ADR-0029
    cross-check that a consumer DefaultIndex does not become the ledger's index.