Skip to content

fix(postgresql): make the Npgsql type-mapping registry safe for concurrent registration (weasel#406) - #412

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/406-thread-safe-mapping-registry
Jul 30, 2026
Merged

fix(postgresql): make the Npgsql type-mapping registry safe for concurrent registration (weasel#406)#412
jeremydmiller merged 1 commit into
masterfrom
fix/406-thread-safe-mapping-registry

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #406.

NpgsqlTypeMapper.Mappings is the documented extension point for consuming code to register custom Npgsql mappings:

This is lazily calculated instead of precached because it allows consuming code to register custom npgsql mappings prior to execution.

It was a JasperFx.Core.Cache.

Reads were already fine — writes were not

Reads: Cache is backed by an immutable ImHashMap, so an enumerating reader sees a snapshot and cannot tear or throw Collection was modified. A 4.3M-read stress run against concurrent writers produced zero failures.

This corrects the reader-side race I speculated about in #402. That hypothesis was wrong, and I'd rather say so than leave it standing.

Writes: the indexer setter is a non-atomic read-modify-write over that map, so concurrent registrations clobber each other:

8 threads x 5,000 distinct keys
expected 40,000 entries, observed 7,660  ->  LOST 32,340  (81%)

Silently. A lost registration surfaces much later as a missing or wrong type mapping, with nothing pointing back at the registration that vanished.

Change

Replace the Cache with NpgsqlTypeMappingRegistry — a small ConcurrentDictionary-backed type that keeps the same surface: an indexer plus IEnumerable<NpgsqlTypeMapping>. Existing registration code and anything enumerating Mappings compiles and behaves unchanged. ConcurrentDictionary fixes the writes, and its Values snapshot keeps reads safe.

Ordering is not a concern

GetTypeMapping breaks ties with LastOrDefault, so it's worth being explicit that this doesn't disturb resolution:

With no CLR type claimed twice, which element LastOrDefault lands on is immaterial.

Tests

Four new tests, exercising their own registry instances rather than the static Mappings — registering tens of thousands of entries globally would persist for the rest of the run, and GetTypeMapping scans linearly, so every other test resolving an unmapped type would slow to a crawl. (Measured: a registry grown to ~16M entries dropped a reader to 506 reads in 8 seconds.)

  • concurrent_registrations_are_not_lost — 8 × 2,000; would have lost ~80% before
  • enumerating_while_registrations_land_neither_throws_nor_tears
  • seeded_mappings_are_readable_by_key_and_by_enumeration
  • registering_over_an_existing_key_replaces_it

Verification

Postgres 802 passed on all four matrix legs (net9.0/net10.0 × case-sensitive true/false), Core 21, SQLite 361.

🤖 Generated with Claude Code

…rrent registration (weasel#406)

NpgsqlTypeMapper.Mappings is the documented extension point for consuming
code to register custom Npgsql mappings. It was a JasperFx.Core.Cache.

Reads were already safe there: the Cache is backed by an immutable
ImHashMap, so an enumerating reader sees a snapshot and cannot tear or throw
"Collection was modified". A 4.3M-read stress run against concurrent writers
produced zero failures. (This corrects the reader-side race speculated about
in weasel#402.)

Writes were not safe. The indexer setter is a non-atomic read-modify-write
over that map, so concurrent registrations clobber each other:

    8 threads x 5,000 distinct keys
    expected 40,000 entries, observed 7,660  ->  LOST 32,340  (81%)

Silently, too -- a lost registration surfaces much later as a missing or
wrong type mapping with nothing pointing back at the registration.

Replace the Cache with NpgsqlTypeMappingRegistry, a small
ConcurrentDictionary-backed type that keeps the same surface: an indexer
plus IEnumerable<NpgsqlTypeMapping>. Existing registration code and anything
enumerating Mappings compiles and behaves unchanged; ConcurrentDictionary
fixes the writes and its Values snapshot keeps reads safe.

Ordering is not a concern here. GetTypeMapping breaks ties with
LastOrDefault, but the Cache enumerated in ImHashMap hash order rather than
insertion order anyway, so nothing depended on a defined order -- and
weasel#405 removed the only doubly-claimed CLR type and added a guard
against another appearing.

Tests use their own registry instances rather than the static Mappings:
registering tens of thousands of entries globally would persist for the rest
of the run, and GetTypeMapping scans linearly, so every other test resolving
an unmapped type would slow down.

Postgres 802 passed on all four CI matrix legs, Core 21, SQLite 361.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 47932b3 into master Jul 30, 2026
16 checks passed
@jeremydmiller
jeremydmiller deleted the fix/406-thread-safe-mapping-registry branch July 30, 2026 16:33
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.

Concurrent registrations into NpgsqlTypeMapper.Mappings are silently lost

1 participant