Merge #602 stability: FOCAS/AbLegacy loop joins on shutdown, Postgres untyped string binds, MTConnect pump generation, MQTT birth-typed writes + QoS1 plain writes + claim backoff

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:33:03 -04:00
21 changed files with 691 additions and 31 deletions
@@ -229,7 +229,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
@@ -245,10 +245,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// handles alive. Mirrors the body of ShutdownAsync without awaiting the poll engine
// (nothing has been subscribed yet at init time).
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
JoinProbeLoopsSync();
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeRuntimes();
}
_devices.Clear();
@@ -266,15 +269,45 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <summary>Upper bound on how long shutdown waits for the probe loops to observe cancellation.</summary>
internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Synchronous bounded join of the (already-cancelled) probe loops for the sync teardown paths.
/// The loop body uses <c>ConfigureAwait(false)</c> throughout, so blocking here cannot deadlock.
/// </summary>
private void JoinProbeLoopsSync()
{
var loops = _devices.Values.Select(d => d.ProbeTask).OfType<Task>().ToArray();
if (loops.Length == 0) return;
try { Task.WaitAll(loops, LoopJoinTimeout); }
catch (AggregateException) { /* loops observe cancellation; a fault is not actionable at teardown */ }
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _poll.DisposeAsync().ConfigureAwait(false);
// STAB-6: cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes.
// The loop used to be fire-and-forget, so one parked in a probe-runtime create/read could
// outlive shutdown and keep a libplctag handle alive past DisposeRuntimes.
foreach (var state in _devices.Values)
try { state.ProbeCts?.Cancel(); } catch { }
var loops = _devices.Values.Select(d => d.ProbeTask).OfType<Task>().ToList();
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning(
"AbLegacy shutdown: {Count} probe loop(s) did not stop within {Timeout}; proceeding with teardown. Driver={DriverInstanceId}",
loops.Count, LoopJoinTimeout, _driverInstanceId);
}
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeRuntimes();
}
_devices.Clear();
@@ -908,10 +941,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// can't poison the rest of the disposal chain.
try { _poll.DisposeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } catch { }
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
JoinProbeLoopsSync();
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeRuntimes();
}
_devices.Clear();
@@ -1037,6 +1073,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// </summary>
public CancellationTokenSource? ProbeCts { get; set; }
/// <summary>
/// Gets or sets the probe loop task, retained so shutdown can join it (STAB-6).
/// </summary>
public Task? ProbeTask { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the probe has been initialized.
/// </summary>
@@ -277,7 +277,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
@@ -287,7 +287,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
state.RecycleCts = new CancellationTokenSource();
var ct = state.RecycleCts.Token;
_ = Task.Run(() => RecycleLoopAsync(state, ct), ct);
state.RecycleTask = Task.Run(() => RecycleLoopAsync(state, ct), ct);
}
}
@@ -305,7 +305,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
state.FixedTreeCts = new CancellationTokenSource();
var ct = state.FixedTreeCts.Token;
_ = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
state.FixedTreeTask = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
}
}
@@ -326,6 +326,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <summary>Upper bound on how long shutdown waits for the background loops to observe cancellation.</summary>
internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5);
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
@@ -335,22 +338,43 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await proj.DisposeAsync().ConfigureAwait(false);
_alarmProjection = null;
}
// STAB-23: cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
// client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
// finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
var loops = new List<Task>();
foreach (var state in _devices.Values)
{
// Cancel-then-dispose can race in tight shutdown loops; swallowing is intentional
// but we now log the cause so a noisy shutdown leaves a Debug trace.
try { state.ProbeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
try { state.RecycleCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); }
state.RecycleCts?.Dispose();
state.RecycleCts = null;
try { state.FixedTreeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); }
if (state.ProbeTask is { } pt) loops.Add(pt);
if (state.RecycleTask is { } rt) loops.Add(rt);
if (state.FixedTreeTask is { } ft) loops.Add(ft);
}
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning("FOCAS shutdown: {Count} background loop(s) did not stop within {Timeout}; proceeding with teardown",
loops.Count, LoopJoinTimeout);
}
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.RecycleCts?.Dispose();
state.RecycleCts = null;
state.RecycleTask = null;
state.FixedTreeCts?.Dispose();
state.FixedTreeCts = null;
state.FixedTreeTask = null;
state.DisposeClient();
}
_devices.Clear();
@@ -1575,6 +1599,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public CancellationTokenSource? RecycleCts { get; set; }
/// <summary>Gets or sets the cancellation token source for the fixed-tree loop.</summary>
public CancellationTokenSource? FixedTreeCts { get; set; }
/// <summary>The probe loop task, retained so shutdown can join it (STAB-23).</summary>
public Task? ProbeTask { get; set; }
/// <summary>The recycle loop task, retained so shutdown can join it (STAB-23).</summary>
public Task? RecycleTask { get; set; }
/// <summary>The fixed-tree loop task, retained so shutdown can join it (STAB-23).</summary>
public Task? FixedTreeTask { get; set; }
/// <summary>Gets or sets the fixed-tree cache for this device.</summary>
public FocasFixedTreeCache? FixedTreeCache { get; set; }
@@ -327,6 +327,20 @@ public sealed class MTConnectDriver
/// <summary>The shared <c>/sample</c> pump task. Guarded by <see cref="_subscriptionLock"/>.</summary>
private Task? _pumpTask;
/// <summary>
/// The <see cref="_sessionGeneration"/> the running pump was started against (STAB-20), guarded by
/// <see cref="_subscriptionLock"/> like the rest of the control block. A pump whose generation is
/// not the current one is bound to a client a lifecycle restart has retired.
/// </summary>
private long _pumpGeneration;
/// <summary>
/// Bumped each time <see cref="StartCoreAsync"/> installs a NEW <see cref="AgentSession"/> (not on a
/// <c>with</c>-copy that only advances the cursor or the probe cache). Written under
/// <see cref="_subscriptionLock"/>.
/// </summary>
private long _sessionGeneration;
/// <summary>
/// The Agent answered <c>/sample</c> with something that cannot be streamed at all, so the
/// pump gave up. Latched — reconnecting reproduces the identical answer forever, and a
@@ -1808,7 +1822,23 @@ public sealed class MTConnectDriver
// revived by a resubscribe. The genuinely-unrepeatable case is the latch below, not this.
if (_pumpTask is { IsCompleted: false })
{
return;
if (_pumpGeneration == _sessionGeneration)
{
return;
}
// STAB-20: a Subscribe can land between StopSampleStreamAsync clearing the registry slots and
// the teardown that retires the client — StopSampleStreamAsync is still awaiting the OLD pump
// then — and starts a pump on the session about to be disposed. Treating that pump as "the
// running stream" would leave the freshly-installed session with no live stream at all
// (the stale pump just reads its disposed client as a dropped connection). Retire it: it is
// cancelled and can only fail against its disposed client.
try { _pumpCts?.Cancel(); }
catch (ObjectDisposedException) { /* already going away */ }
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} replaced a /sample pump bound to a retired Agent session.",
_driverInstanceId);
}
if (_streamUnsupported)
@@ -1856,6 +1886,7 @@ public sealed class MTConnectDriver
var options = Volatile.Read(ref _options);
var cts = new CancellationTokenSource();
_pumpCts = cts;
_pumpGeneration = _sessionGeneration;
_pumpTask = Task.Run(
() => RunSampleStreamAsync(
session.Client, options, session.NextSequence, session.InstanceId, republishOnStart, cts.Token),
@@ -2695,6 +2726,11 @@ public sealed class MTConnectDriver
// One publish, so a concurrent lock-free reader sees the new client, the new probe
// cache, and the pump's opening cursor together or none of them.
lock (_subscriptionLock)
{
_sessionGeneration++;
}
Volatile.Write(
ref _session,
new AgentSession(client, probe, current.InstanceId, current.NextSequence));
@@ -74,9 +74,20 @@ public enum MqttWriteEncodeError
/// </remarks>
public static class MqttWriteEncoder
{
/// <summary>QoS for an outbound write. See the remarks on <see cref="TryEncode"/>.</summary>
/// <summary>
/// QoS for a Sparkplug NCMD/DCMD command. See the remarks on <see cref="TryEncode"/>. Plain-mode
/// writes use <see cref="PlainWriteQos"/> instead.
/// </summary>
public const int WriteQos = 0;
/// <summary>
/// QoS for a Plain-mode <c>writeTopic</c> publish (STAB-26). At QoS 1 the publish completes only
/// once the broker has PUBACKed (or refused) it, so a Good write status means "the broker accepted
/// the message" rather than "the bytes left this socket"; at QoS 0 a broker-side refusal or a
/// connection that dropped under the send was indistinguishable from success.
/// </summary>
public const int PlainWriteQos = 1;
/// <summary>
/// Encodes one write for the tag <paramref name="def"/> under the driver's <paramref name="mode"/>.
/// </summary>
@@ -84,9 +95,16 @@ public static class MqttWriteEncoder
/// <param name="def">The resolved tag definition.</param>
/// <param name="value">The value to write.</param>
/// <param name="encoded">The topic + payload to publish, when this returns <see cref="MqttWriteEncodeError.None"/>.</param>
/// <param name="effectiveDataType">
/// Sparkplug only (STAB-25): the type to encode the metric as when the tag did not author one — the
/// type the metric's live birth certificate declared, the same <c>authored ? authored : birth</c>
/// precedence the ingest path coerces reads with. <see langword="null"/> encodes as
/// <see cref="MqttTagDefinition.DataType"/>, which for an unauthored tag is only the record's
/// default and would command the metric with the wrong wire type. Ignored in Plain mode.
/// </param>
/// <returns>The encode outcome.</returns>
/// <remarks>
/// <b>QoS 0, retain <see langword="false"/>.</b> Retain is the one that matters: a retained
/// <b>Retain <see langword="false"/> always; QoS 1 for Plain, 0 for Sparkplug commands.</b> Retain is the one that matters: a retained
/// command would be replayed by the broker to the device on every future subscribe — including
/// the device's own reconnect — so a single stale write would be re-applied forever. This is the
/// same hazard <see cref="RebirthRequester"/> avoids for NCMD, and the reason neither is
@@ -96,13 +114,14 @@ public static class MqttWriteEncoder
MqttMode mode,
MqttTagDefinition def,
object? value,
out MqttEncodedWrite encoded)
out MqttEncodedWrite encoded,
DriverDataType? effectiveDataType = null)
{
ArgumentNullException.ThrowIfNull(def);
encoded = default;
return mode == MqttMode.SparkplugB
? TryEncodeSparkplug(def, value, out encoded)
? TryEncodeSparkplug(def, value, effectiveDataType, out encoded)
: TryEncodePlain(def, value, out encoded);
}
@@ -163,7 +182,7 @@ public static class MqttWriteEncoder
: value.ToString() ?? "";
}
encoded = new MqttEncodedWrite(def.WriteTopic!.Trim(), Encoding.UTF8.GetBytes(text));
encoded = new MqttEncodedWrite(def.WriteTopic!.Trim(), Encoding.UTF8.GetBytes(text), PlainWriteQos);
return MqttWriteEncodeError.None;
}
@@ -175,6 +194,7 @@ public static class MqttWriteEncoder
private static MqttWriteEncodeError TryEncodeSparkplug(
MqttTagDefinition def,
object? value,
DriverDataType? effectiveDataType,
out MqttEncodedWrite encoded)
{
encoded = default;
@@ -206,7 +226,7 @@ public static class MqttWriteEncoder
}
var metric = new Payload.Types.Metric { Name = metricName };
var error = ApplyValue(metric, def.DataType, value);
var error = ApplyValue(metric, def.DataTypeAuthored ? def.DataType : effectiveDataType ?? def.DataType, value);
if (error != MqttWriteEncodeError.None)
{
return error;
@@ -227,7 +247,7 @@ public static class MqttWriteEncoder
edgeNodeId,
isDevice ? def.DeviceId : null);
encoded = new MqttEncodedWrite(topic, payload.ToByteArray());
encoded = new MqttEncodedWrite(topic, payload.ToByteArray(), WriteQos);
return MqttWriteEncodeError.None;
}
@@ -344,4 +364,5 @@ public static class MqttWriteEncoder
/// <summary>One encoded outbound write: where it goes and what it carries.</summary>
/// <param name="Topic">The concrete topic to publish on.</param>
/// <param name="Payload">The encoded message body.</param>
public readonly record struct MqttEncodedWrite(string Topic, byte[] Payload);
/// <param name="Qos">The MQTT QoS to publish at — see <see cref="MqttWriteEncoder.PlainWriteQos"/> / <see cref="MqttWriteEncoder.WriteQos"/>.</param>
public readonly record struct MqttEncodedWrite(string Topic, byte[] Payload, int Qos = MqttWriteEncoder.WriteQos);
@@ -544,6 +544,11 @@ public sealed class MqttDriver
/// </summary>
/// <remarks>
/// <para>
/// <b>Also the write path's type (STAB-25).</b> <see cref="WriteAsync"/> encodes a Sparkplug
/// command with this same answer, so a birth-typed Int32 metric is commanded as Int32 rather than
/// as the record's default.
/// </para>
/// <para>
/// <b>The precedence is the ingest path's, restated.</b> <c>SparkplugIngestor.PublishMetric</c>
/// coerces every value with <c>DataTypeAuthored ? authored : birth</c>; a discovery surface
/// that disagreed with it would advertise a type no value ever arrives as. The two must be
@@ -674,7 +679,10 @@ public sealed class MqttDriver
/// reference reject every other write in the same OPC UA request.
/// </para>
/// <para>
/// <b>Good means "published", not "applied".</b> MQTT has no application-level write
/// <b>Good means "published", not "applied".</b> A Plain-mode write is published at QoS 1
/// (STAB-26), so "published" means the broker PUBACKed it — a broker-side refusal or a drop under
/// the send is Bad, not Good. Sparkplug NCMD/DCMD stay at QoS 0 (a redelivered command would be
/// re-applied). MQTT has no application-level write
/// acknowledgement, so a successful PUBLISH is the strongest truth available at this seam —
/// the same optimistic-Good posture Galaxy's fire-and-forget write takes. Device-level truth
/// arrives afterwards, as the echoed value on the tag's read topic, and corrects the node
@@ -709,7 +717,8 @@ public sealed class MqttDriver
continue;
}
var error = MqttWriteEncoder.TryEncode(_options.Mode, def, w.Value, out var encoded);
var error = MqttWriteEncoder.TryEncode(
_options.Mode, def, w.Value, out var encoded, ResolveDiscoveredDataType(def));
if (error != MqttWriteEncodeError.None)
{
results[i] = new WriteResult(MapEncodeError(error));
@@ -728,7 +737,7 @@ public sealed class MqttDriver
try
{
await connection
.PublishAsync(encoded.Topic, encoded.Payload, MqttWriteEncoder.WriteQos, retain: false, cancellationToken)
.PublishAsync(encoded.Topic, encoded.Payload, encoded.Qos, retain: false, cancellationToken)
.ConfigureAwait(false);
results[i] = new WriteResult(0u);
}
@@ -66,6 +66,14 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
/// <summary>How often the role gate is sampled. Bounds the failover republish latency.</summary>
private static readonly TimeSpan RoleSampleInterval = TimeSpan.FromSeconds(1);
/// <summary>Ceiling on the delay between failed claim attempts (STAB-27).</summary>
internal static readonly TimeSpan MaxClaimBackoff = TimeSpan.FromSeconds(30);
/// <summary>Consecutive failed claim attempts; reset by a successful claim or a stand-down. Reconcile-loop-owned.</summary>
private int _claimFailures;
private DateTimeOffset _nextClaimAttemptUtc;
private readonly MqttDriverOptions _options;
private readonly string _hostId;
private readonly string _driverId;
@@ -321,10 +329,21 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
{
if (_roleSource.ShouldActAsPrimary)
{
// STAB-27: a claim that keeps failing backs off instead of redialling (and logging a
// Warning) every RoleSampleInterval for as long as the broker refuses it. The role is still
// sampled each tick — only the connect attempt waits.
if (_claimFailures > 0 && _timeProvider.GetUtcNow() < _nextClaimAttemptUtc)
{
return;
}
await ClaimAsync(cancellationToken).ConfigureAwait(false);
_claimFailures = 0;
}
else
{
// Standing down is not a failure, and a later re-promotion should claim at once.
_claimFailures = 0;
await ReleaseAsync("this node is not the redundancy Primary", cancellationToken).ConfigureAwait(false);
}
}
@@ -334,14 +353,42 @@ public sealed class SparkplugPrimaryHost : IAsyncDisposable
}
catch (Exception ex)
{
_logger?.LogWarning(
ex,
"MQTT driver '{DriverId}': could not reconcile the Sparkplug primary-host claim for '{HostId}'; will retry.",
_driverId,
_hostId);
if (_roleSource.ShouldActAsPrimary)
{
_claimFailures++;
var delay = ClaimBackoffFor(_claimFailures);
_nextClaimAttemptUtc = _timeProvider.GetUtcNow() + delay;
// One Warning per ATTEMPT, and attempts are now spaced by the backoff, so this is the
// rate limit: 2 s, 4 s, 8 s … capped at MaxClaimBackoff rather than one line a second.
_logger?.LogWarning(
ex,
"MQTT driver '{DriverId}': could not claim Sparkplug primary host '{HostId}' (attempt {Attempt}); retrying in {RetryIn}.",
_driverId,
_hostId,
_claimFailures,
delay);
}
else
{
_logger?.LogWarning(
ex,
"MQTT driver '{DriverId}': could not reconcile the Sparkplug primary-host claim for '{HostId}'; will retry.",
_driverId,
_hostId);
}
}
}
/// <summary>Capped exponential delay before claim attempt N+1: <c>2^N</c> seconds, at most <see cref="MaxClaimBackoff"/>.</summary>
/// <param name="failures">Consecutive failed claim attempts so far (at least 1).</param>
/// <returns>How long to wait before the next attempt.</returns>
internal static TimeSpan ClaimBackoffFor(int failures)
{
var seconds = Math.Pow(2, Math.Min(failures, 16));
return TimeSpan.FromSeconds(Math.Min(seconds, MaxClaimBackoff.TotalSeconds));
}
private async Task ClaimAsync(CancellationToken cancellationToken)
{
if (Volatile.Read(ref _connection) is not null)
@@ -107,6 +107,21 @@ public interface ISqlDialect
/// <param name="command">The command to configure.</param>
void ConfigureCommand(DbCommand command);
/// <summary>
/// Gives the dialect a chance to adjust a poll-path parameter after its name and value are set and
/// before it is added to the command (STAB-24). Authored key / row-selector values are CLR strings,
/// because a tag's <c>keyValue</c> is configuration text — which a strictly-typed provider will not
/// compare against a numeric key column (Npgsql sends <c>text</c>, and
/// <c>integer = text</c> has no operator). A dialect that needs the server to infer the type from
/// context overrides this. The default does nothing, which is correct for providers that coerce
/// implicitly (SQL Server, ODP.NET).
/// <para>Implementations must tolerate any <see cref="DbParameter"/> they are handed, for the same
/// substituted-factory reason as <see cref="ConfigureCommand"/>.</para>
/// </summary>
/// <param name="parameter">The parameter, with <see cref="DbParameter.ParameterName"/> and value already set.</param>
/// <param name="value">The authored value being bound (never <see cref="DBNull"/>; may be <c>null</c>).</param>
void ConfigureParameter(DbParameter parameter, object? value) { }
/// <summary>
/// The most bound parameters this backend accepts in <b>one</b> command — 2098 usable of SQL Server's
/// 2100, 1000 entries in an Oracle <c>IN</c> expression list, 65535 on the Postgres wire protocol.
@@ -64,6 +64,21 @@ public sealed class PostgresDialect : ISqlDialect
// Intentionally empty: see the summary.
}
/// <summary>
/// STAB-24: a string-valued bind is sent with <see cref="NpgsqlTypes.NpgsqlDbType.Unknown"/> so the
/// server infers its type from the compared column. Bound as the default <c>text</c>, a tag whose
/// <c>keyValue</c> is <c>"3"</c> against an <c>integer</c> key column fails with
/// <c>operator does not exist: integer = text</c>. Unknown still carries the value as data — it is
/// never interpolated — so the injection boundary is unchanged.
/// </summary>
/// <param name="parameter">The parameter to adjust.</param>
/// <param name="value">The authored value.</param>
public void ConfigureParameter(DbParameter parameter, object? value)
{
if (parameter is NpgsqlParameter npgsql && value is string)
npgsql.NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown;
}
/// <summary>
/// 65535 — the extended-query protocol carries the parameter count as a signed/unsigned 16-bit field
/// in <c>Bind</c>, so that is a protocol ceiling rather than a server setting, and Npgsql raises it
@@ -393,6 +393,7 @@ public sealed class SqlPollReader
// and never from ParameterNames: the marker and the bind name differ on ODP.NET.
parameter.ParameterName = plan.ParameterBindNames[i];
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
_dialect.ConfigureParameter(parameter, plan.Parameters[i]);
command.Parameters.Add(parameter);
}