Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/Weasel.Postgresql.Tests/NpgsqlTypeMappingRegistryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System.Data;
using NpgsqlTypes;
using Shouldly;
using Xunit;

namespace Weasel.Postgresql.Tests;

/// <remarks>
/// Exercises its own registry instances rather than the static NpgsqlTypeMapper.Mappings.
/// Registering tens of thousands of mappings into the global one would linger for the rest of
/// the run, and PostgresqlProvider.GetTypeMapping scans it linearly, so every other test that
/// resolves an unmapped type would slow to a crawl. weasel#406.
/// </remarks>
public class NpgsqlTypeMappingRegistryTests
{
private static NpgsqlTypeMappingRegistry EmptyRegistry()
{
return new NpgsqlTypeMappingRegistry(new Dictionary<NpgsqlDbType, NpgsqlTypeMapping>());
}

private static NpgsqlTypeMapping AnyMapping()
{
return new NpgsqlTypeMapping(NpgsqlDbType.Varchar, DbType.String, "varchar", typeof(string));
}

[Fact]
public void concurrent_registrations_are_not_lost()
{
// The regression: backed by a JasperFx Cache, the indexer setter was a non-atomic
// read-modify-write over an ImHashMap, and this landed 7,660 of 40,000 -- an 81% loss,
// silently. weasel#406.
const int threads = 8;
const int perThread = 2000;
var registry = EmptyRegistry();

Parallel.For(0, threads, t =>
{
for (var i = 0; i < perThread; i++)
{
registry[(NpgsqlDbType)(t * perThread + i)] = AnyMapping();
}
});

registry.Count.ShouldBe(threads * perThread);
}

[Fact]
public void enumerating_while_registrations_land_neither_throws_nor_tears()
{
const int seeded = 500;
const int added = 5000;

var registry = EmptyRegistry();
for (var i = 0; i < seeded; i++)
{
registry[(NpgsqlDbType)i] = AnyMapping();
}

// Bounded on both sides: the writer stops after a fixed number of registrations and the
// reader stops with it, so the dictionary cannot grow without limit underneath repeated
// full enumerations.
var writer = Task.Run(() =>
{
for (var i = 0; i < added; i++)
{
registry[(NpgsqlDbType)(100_000 + i)] = AnyMapping();
}
});

var passes = 0;
while (!writer.IsCompleted)
{
// Each read must see a coherent snapshot: never fewer than what was there before the
// writer started, and never a null hole.
var seen = registry.ToList();
seen.Count.ShouldBeGreaterThanOrEqualTo(seeded);
seen.ShouldAllBe(mapping => mapping != null);
passes++;
}

writer.GetAwaiter().GetResult();
registry.Count.ShouldBe(seeded + added);
passes.ShouldBeGreaterThan(0);
}

[Fact]
public void seeded_mappings_are_readable_by_key_and_by_enumeration()
{
var mapping = AnyMapping();
var registry = new NpgsqlTypeMappingRegistry(new Dictionary<NpgsqlDbType, NpgsqlTypeMapping>
{
{ NpgsqlDbType.Varchar, mapping }
});

registry[NpgsqlDbType.Varchar].ShouldBeSameAs(mapping);
registry.TryGetValue(NpgsqlDbType.Varchar, out var found).ShouldBeTrue();
found.ShouldBeSameAs(mapping);
registry.ShouldContain(mapping);

registry.TryGetValue(NpgsqlDbType.Bigint, out _).ShouldBeFalse();
}

[Fact]
public void registering_over_an_existing_key_replaces_it()
{
var registry = EmptyRegistry();
var first = AnyMapping();
var second = AnyMapping();

registry[NpgsqlDbType.Varchar] = first;
registry[NpgsqlDbType.Varchar] = second;

registry[NpgsqlDbType.Varchar].ShouldBeSameAs(second);
registry.Count.ShouldBe(1);
}
}
61 changes: 59 additions & 2 deletions src/Weasel.Postgresql/NpgsqlTypeMapping.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Collections.Specialized;
using System.Data;
using System.Net;
using System.Net.NetworkInformation;
using System.Numerics;
using System.Text.Json;
using JasperFx.Core;
using NetTopologySuite.Geometries;
using NpgsqlTypes;

Expand All @@ -30,6 +30,63 @@ public NpgsqlTypeMapping(NpgsqlDbType? npgsqlDbType, DbType dbType, string? data
public Type[] ClrTypes { get; }
}

/// <summary>
/// Registry of <see cref="NpgsqlDbType" /> to <see cref="NpgsqlTypeMapping" />, keyed for
/// lookup and enumerable by mapping. Consuming code registers custom mappings through the
/// indexer, from any thread.
/// </summary>
/// <remarks>
/// <para>
/// This used to be a <c>JasperFx.Core.Cache</c>. Reads were already safe there — it is
/// backed by an immutable <c>ImHashMap</c>, so an enumerating reader sees a snapshot and
/// cannot tear. Writes were not: the indexer setter is a non-atomic read-modify-write
/// over that map, so concurrent registrations silently clobbered one another. Eight
/// threads registering 5,000 distinct keys each landed 7,660 of 40,000 — an 81% loss,
/// with nothing to indicate a mapping had gone missing. weasel#406.
/// </para>
/// <para>
/// <see cref="ConcurrentDictionary{TKey,TValue}" /> fixes the writes and keeps reads
/// safe: <c>Values</c> hands back a snapshot, so enumeration never throws
/// mid-registration. The surface is deliberately the same as the <c>Cache</c> it
/// replaces — an indexer plus <see cref="IEnumerable{T}" /> over the mappings — so
/// existing registration code is unaffected.
/// </para>
/// </remarks>
public class NpgsqlTypeMappingRegistry: IEnumerable<NpgsqlTypeMapping>
{
private readonly ConcurrentDictionary<NpgsqlDbType, NpgsqlTypeMapping> _values;

public NpgsqlTypeMappingRegistry(IDictionary<NpgsqlDbType, NpgsqlTypeMapping> seed)
{
_values = new ConcurrentDictionary<NpgsqlDbType, NpgsqlTypeMapping>(seed);
}

public NpgsqlTypeMapping this[NpgsqlDbType key]
{
get => _values[key];
set => _values[key] = value;
}

public int Count => _values.Count;

public bool TryGetValue(NpgsqlDbType key, out NpgsqlTypeMapping? value)
{
return _values.TryGetValue(key, out value);
}

public IEnumerator<NpgsqlTypeMapping> GetEnumerator()
{
// ConcurrentDictionary.Values is a point-in-time snapshot, so a registration landing
// mid-enumeration cannot disturb this.
return _values.Values.GetEnumerator();
}

IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}

/// <summary>
/// Class defining custom NpgsqlType <=> DbType <=> CLR types
/// </summary>
Expand All @@ -39,7 +96,7 @@ public NpgsqlTypeMapping(NpgsqlDbType? npgsqlDbType, DbType dbType, string? data
/// </remarks>
public class NpgsqlTypeMapper
{
public static readonly Cache<NpgsqlDbType, NpgsqlTypeMapping> Mappings = new(new Dictionary<NpgsqlDbType, NpgsqlTypeMapping>
public static readonly NpgsqlTypeMappingRegistry Mappings = new(new Dictionary<NpgsqlDbType, NpgsqlTypeMapping>
{
// Numeric types
{NpgsqlDbType.Smallint,new NpgsqlTypeMapping(NpgsqlDbType.Smallint, DbType.Int16, "smallint", typeof(short), typeof(byte),
Expand Down
Loading