Skip to content

Flaky: DatabaseStateExpectedStoreTests.ResetToCurrent_RebaselinesAndClearsOverride blocked a nightly with no Lite change #2374

Description

@erikdarlingdata

Lite.Tests.DatabaseStateExpectedStoreTests.ResetToCurrent_RebaselinesAndClearsOverride failed a nightly build with no Lite code change between a passing and a failing run, so it is intermittent rather than a regression. Filing it rather than re-running quietly, because a flake that blocks the release pipeline gets re-run until it is invisible.

Evidence

run dispatched Lite.Tests outcome
32279079612 16:59 UTC pass nightly published
32283591556 17:47 UTC 1 failed of 2483 nightly failed, no artifact

Between those two runs dev moved by exactly two commits: #2372 (Darling service + Darling tests) and #2373 (CHANGELOG). Neither touches Lite/ or Lite.Tests/.

Lite.Tests.DatabaseStateExpectedStoreTests.ResetToCurrent_RebaselinesAndClearsOverride [FAIL]
  Assert.Single() Failure: The collection was empty
  Lite.Tests\DatabaseStateExpectedStoreTests.cs(223,0)
Lite.Tests  Total: 2483, Errors: 0, Failed: 1, Skipped: 0, Time: 500.600s

Line 223 is the FIRST assertion, before the verb under test ever runs:

await DriveAppToStableStateAsync(service, "OFFLINE");
Assert.Single(await service.GetDatabaseStateDeviationsAsync(ServerId));   // <-- here

So the setup produced no deviation at all: three seeded snapshots (ONLINE at T0, OFFLINE at +1m and +2m) and a baseline read between them, and the deviation query then returned nothing.

Two theories I checked and disproved

Recording these so nobody re-walks them.

Not a wall-clock cliff. T0 is a fixed 2026-08-01 09:00:00 and today is the 19th, which looks exactly like a fixture aging out of a lookback window. It is not: the only now() in LocalDataService.DatabaseStates.cs stamps updated_at, and nothing filters database_states on wall clock. A date cliff also cannot produce a pass and a fail on the same day.

Not cross-class interference over a shared database. The constructor calls fixture.ResetData(), and 42 test classes take SharedDuckDbFixture, which looks like one class wiping another's rows mid-test. It is not: the fixture builds its own database under a GUID temp directory, so each class owns a file, and the type documents that choice deliberately ("Deliberately IClassFixture (one database per class), NOT a collection fixture"). Within a class xUnit is serial.

What I have not established

I do not have a root cause and am not going to assert one. What is worth someone's attention:

  • Lite.Tests takes 500 s with 42 classes each running ~80 DDL statements against their own DuckDB file, so the runner is heavily loaded and this is the kind of test that fails under scheduling pressure rather than logic.
  • [CI] xunit.v3 4.0.0 cannot be taken until the test invocation migrates off VSTest mode (.NET 10 SDK dropped it) #2347 moved the suites from VSTest to MTP in this same release. If MTP schedules more aggressively than VSTest did, a latent timing sensitivity here would start showing now and would look exactly like this. That is a hypothesis, not a finding — worth checking before assuming the test itself is at fault.
  • SeedConnectionAsync holds one connection per test instance and the reads go through AcquireReadLock. If a lock acquisition can time out and degrade to an empty result rather than throwing, that would produce this failure signature precisely. I have not read that path closely enough to say.

Why it matters beyond one red build

The #2266 scale test is the precedent: it failed on PR after PR whose diffs could not reach it, and the fix was to assert something the product actually owns rather than to keep re-running. Same principle here — an empty collection where a deviation was seeded is either a real ordering bug or a test that does not control its own preconditions, and both are worth knowing before 3.5.0.

Re-dispatched the nightly to unblock the soak artifact. If this reproduces, that is a second data point and it should be fixed rather than retried.

Activity

  1. erikdarlingdata commented on Aug 19, 2026

    @erikdarlingdata
    OwnerAuthor

    Following the lock lead from the issue body, one concrete thing that is verifiable regardless of what the flake turns out to be.

    DuckDbInitializer's lock is process-wide:

    private static readonly ReaderWriterLockSlim s_dbLock = new(LockRecursionPolicy.NoRecursion);

    Every AcquireReadLock / AcquireWriteLock goes through that one static, so all 42 test classes serialize against each other on it even though each owns a separate database file. That directly contradicts what SharedDuckDbFixture believes it has bought:

    Deliberately IClassFixture (one database per class), NOT a collection fixture: a single shared collection would serialize these classes against each other and give back most of the win. Each class owns its own database file, keeping xUnit's cross-class parallelism intact.

    The file separation is real, but the parallelism it is protecting is given straight back by the static lock — the classes serialize anyway, just through a lock instead of through a collection, and with no ordering guarantee. That is a plausible contributor to Lite.Tests taking 500 s, and it means cross-class timing is coupled in a way the fixture's reasoning assumes it is not.

    Second thing in the same method, which I flag as a hazard rather than a diagnosis:

    catch (LockRecursionException)
    {
        /* The current thread already owns a read lock — likely leaked by an unhandled
           exception that prevented Dispose(). ... */
        return NoOpDisposable.Instance;
    }

    With LockRecursionPolicy.NoRecursion this fires for any nesting on a thread, not only for a leak — a caller that legitimately holds a read lock and calls something that takes another gets an unprotected no-op, and the comment's "we're already protected by a read lock" only holds for the leak case it names. It is a swallow that converts a lock-discipline problem into silent unsynchronized access, which is the shape of failure that shows up as a read returning nothing.

    I have not tied either of these to the failing assertion and I am not claiming they are the cause. But the static lock in particular is worth fixing on its own terms: the fixture's stated design and the lock's actual scope disagree, and one of the two is wrong.

  2. erikdarlingdata commented on Aug 19, 2026

    @erikdarlingdata
    OwnerAuthor

    Second data point, and it confirms the flake: re-dispatched the identical commit as 32285071843 and Lite.Tests passed 2483/2483. Same tree, same test, opposite result — so this is timing, not logic in the change set.

    That makes three runs of the same Lite code: pass (16:59), fail (17:47), pass (18:02). The nightly published on the third, so the soak is unblocked, but the failure rate is high enough to bite the release pipeline again.

  3. added a commit that references this issue on Aug 19, 2026
    eb28346
  4. erikdarlingdata commented on Aug 19, 2026

    @erikdarlingdata
    OwnerAuthor

    Fixed in #2375.

    The seed rides the best-effort maintenance block, so a bare sweep can return having written no expectation at all — and the test then asserts on a precondition it never established, failing far from the cause because the deviation read still succeeds with nothing to deviate from. BaselineAsync waits for the baseline to actually land, settling on the recorded state rather than row count (the expectations read LEFT JOINs the snapshot, so an unseeded database is a present row with an empty state).

    The topology that manufactures this is split out as #2376 — the process-wide store lock hands back the cross-class parallelism SharedDuckDbFixture believes it has. That one is not a release blocker, and the lock must NOT be narrowed: production runs many DuckDbInitializer instances against one file and relies on it.

    No re-cut or re-install: this is test-only, so the soaking artifact (c78240c5…, built from c4721626) is still exactly what dev produces.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions