Skip to content

fix: make the TripleStore and TrustStateRegistry singletons thread-safe - #227

Merged
tkuhn merged 1 commit into
mainfrom
fix/thread-safe-singletons
Sep 24, 2026
Merged

tkuhn merged 1 commit into
mainfrom
fix/thread-safe-singletons

Conversation

@tkuhn

@tkuhn tkuhn commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Fixes a startup race condition, found while investigating why npa:thisRepo in the admin repo carries several npa:hasRepoInitId values: 2 on query.knowledgepixels.com, 5 on query.petapico.org and 3 on query.nanodash.net. Every other repo has exactly one.

Cause

  • TripleStore.get() created its instance lazily without synchronization. Port 9393 starts accepting requests (MainVerticle.java:615) before the loader thread starts (line 628), so several threads make the first call at once, and each could construct its own TripleStore.
  • The constructor creates the empty repo, which first creates admin.
  • RDF4J's create request (PUT /repositories/<id>) checks whether config.ttl exists and writes it under separate locks. Concurrent create requests for a new repo are therefore all answered with 204. Tested against eclipse/rdf4j-workbench:6.1.0-tomcat, with five rounds of 5 concurrent PUTs, the number of 204s was 5, 5, 3, 4 and 5.
  • Each 204 makes that instance run initNewRepo(admin), which writes another init ID.

On an existing store, admin already exists and gets a 409, so this only happens when a store is first built. Consistent with that, the counts didn't change when all three instances restarted on 2026-09-24.

Impact

  • The duplicate init IDs: harmless; nothing reads hasRepoInitId.
  • The same race on a counted repo would duplicate its npa:hasNanopubCount and npa:hasNanopubChecksum, breaking the full-vs-meta checksum comparison. It hasn't happened on the current stores: all other repos have one init ID, and the checksums match fleet-wide.
  • On every start, even on an existing store, each extra instance leaked its HTTP connection pool and idle-connection thread.

Fix

  • TripleStore.get(): a volatile field with double-checked locking on TripleStore.class. Later calls still read the field without locking. Construction goes through a private instanceFactory field, so a test can substitute a slow factory.
  • TrustStateRegistry.get(): had the same pattern (the worst outcome is a lost cached hash, costing one extra trust-state update). It is now synchronized; it is called rarely.
  • Existing duplicate init IDs: left in place, since nothing reads them.

Testing

  • New test: TripleStoreTest.concurrentFirstGetConstructsSingleInstance. 16 threads make the first get() call at once against a factory that takes 50 ms; the test asserts that exactly one instance is constructed and returned to all of them.
    • It fails on the old code (checked by applying only the factory change to the old get(): the threads received different instances).
    • It passed 5 of 5 repeated runs with the fix.
  • Full suite: 518 tests, 0 failures, 0 errors.

🤖 Generated with Claude Code

TripleStore.get() created its instance lazily without synchronization,
and the HTTP server accepts requests before the loader thread starts, so
several threads make the first call at once and each could construct its
own instance. The constructor creates the `empty` repo, which first
creates `admin`. RDF4J checks whether a repository exists and writes its
config under separate locks, so concurrent create requests for a new
repo are all answered with 204 (5 of 5 in most rounds against 6.1.0),
and every extra instance ran initNewRepo on `admin`. On a fresh store
that left several npa:hasRepoInitId values in `admin`: 2, 5 and 3 on
the three fleet instances, one everywhere else. The ID is never read, so
this was harmless in itself, but the same race on a counted repo would
duplicate its npa:hasNanopubCount and checksum, and every extra instance
leaked its HTTP client and idle-connection thread on every start.

get() now uses a volatile field with double-checked locking; later calls
still read without locking. Construction goes through a private factory
field so a test can slow it down and check that 16 concurrent first
calls construct exactly one instance (the test fails on the old code).

TrustStateRegistry.get() had the same pattern (a lost hash, costing one
extra trust-state update) and is now synchronized; it is called rarely.

The existing duplicate init IDs are left in place, since nothing reads
them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@tkuhn
tkuhn merged commit 583b40c into main Sep 24, 2026
8 checks passed
@tkuhn
tkuhn deleted the fix/thread-safe-singletons branch September 24, 2026 10:00
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