Skip to content

fix: make OsduClient service-client initialisation thread-safe - #87

Open
shjellvik wants to merge 2 commits into
mainfrom
fix/thread-safe-client-init
Open

fix: make OsduClient service-client initialisation thread-safe#87
shjellvik wants to merge 2 commits into
mainfrom
fix/thread-safe-client-init

Conversation

@shjellvik

@shjellvik shjellvik commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What

Makes lazy initialisation of OsduClient's service clients and request adapters thread-safe, and adds a regression suite that fails reliably without the fix.

Why

Service clients are built on first property access. A singleton OsduClient — the normal DI registration — is hit by many requests at once during cold start, so that first access races.

Measured on main, 300 trials × 16 threads racing on first .Search access:

trials handing out >1 SearchClient       = 269 / 300
  -> orphaned HttpClients never disposed = 576
threads that threw during lazy init      = 0–1 per run
trials where Dispose() threw             = 0–1 per run  (NullReferenceException)

So ~90% of concurrent cold starts silently built duplicate clients over duplicate HttpClients, and the unsynchronised List<HttpClient> could be corrupted badly enough that Dispose() threw an NRE. Same numbers with the fix applied: 0 / 300 on every counter.

This is why petrodb-api carries an OsduFacadeWarmup hosted service that touches every service property single-threaded at startup (equinor/petrodb-api#148). Once this lands, that workaround can go.

Changes

  • Added a Lock guarding all lazy-init state. Contention is irrelevant — each service is built at most once per client instance.
  • Replaced the 20 nullable backing fields and Build(ref field, attr) with a single Dictionary<Type, object> client cache behind Client<T>(serviceAttr). Keyed by client type, not service name, because WellboreDdms and WellboreDdmsBulk share the wellbore_ddms adapter.
  • GetOrCreateAdapter now takes the same lock, so _adapters and _httpClients are no longer mutated concurrently.
  • Dispose() takes the lock and clears the caches.

Behaviour change worth flagging

Touching a service property or GetRequestAdapter after Dispose() now throws ObjectDisposedException. Previously it returned a client sitting on a disposed HttpClient, which failed later and more confusingly inside Kiota. I judged this part of a coherent thread-safe lifecycle rather than separate scope — happy to drop it if you'd rather keep this PR strictly to the race.

Verification

  • dotnet test tests/OsduCsharpClient.Tests — 44/44 pass.

  • The four new lifecycle/race tests fail on main in 3/3 runs and pass with the fix. Confirmed by reverting OsduClient.cs to origin/main and rerunning:

    FAIL ServiceProperty_ReturnsSameInstance_WhenFirstAccessIsRaced
    FAIL GetRequestAdapter_ReturnsSameAdapter_WhenFirstAccessIsRaced
    FAIL DistinctServiceProperties_InitialiseSafely_WhenRaced
    FAIL ServiceProperty_Throws_AfterDispose
    

Note that none of this runs in CI yet — a separate PR adds the test workflow.

@shjellvik
shjellvik requested a review from a team as a code owner August 19, 2026 09:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant