861a1d1df0
Walks schemas -> tables/views -> columns via ISqlDialect's catalog SQL, with @schema/@table bound as parameters at every level. Column leaves carry the dialect-mapped DriverDataType in the attribute side-panel (ViewOnly, read-only v1). The NodeId encoding deliberately departs from the design sketch's literal `schema.table|column`: SQL Server permits `.` and `|` inside a quoted identifier, so that form mis-parses (main.a.b|c reads equally as schema `main`+table `a.b` and schema `main.a`+table `b`) and silently binds an operator's tag to the wrong column. SqlBrowseNodeId encodes `<kind>:<part>[|<part>...]` with `\`/`|` escaped and the kind prefix carrying the arity; it is public because the picker body decodes it back. The session owns the connection it is handed and closes it on dispose -- the registry-held session is the only lifetime hook, so a non-owning session would leak one pooled connection per reaped picker. Per-call work stays bounded by the AdminUI's existing 20s linked CTS (BrowserSessionService.PerCallTimeout); no second deadline is invented here. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
205 lines
6.5 KiB
C#
205 lines
6.5 KiB
C#
using System.Data;
|
|
using System.Data.Common;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
|
|
|
|
/// <summary>
|
|
/// Records how many command executions are ever in flight at once, and how long each one is artificially
|
|
/// held open. Shared by <see cref="ConcurrencyProbingDbConnection"/> and the commands it creates.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This exists because "the session serializes on a gate" is otherwise unfalsifiable from the outside:
|
|
/// against a real SQLite connection, overlapping calls mostly still *work*, so a test that only asserted
|
|
/// correct results would pass with the gate deleted. Measuring the overlap directly is what makes the
|
|
/// gate's removal visible.
|
|
/// </remarks>
|
|
public sealed class ConcurrencyProbe
|
|
{
|
|
private int _inFlight;
|
|
private int _peak;
|
|
|
|
/// <summary>How long each command execution is held before it reaches the real provider.</summary>
|
|
public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(120);
|
|
|
|
/// <summary>The greatest number of executions observed in flight simultaneously.</summary>
|
|
public int Peak => Volatile.Read(ref _peak);
|
|
|
|
/// <summary>Marks the start of one command execution.</summary>
|
|
public void Enter()
|
|
{
|
|
var current = Interlocked.Increment(ref _inFlight);
|
|
int observed;
|
|
while (current > (observed = Volatile.Read(ref _peak)))
|
|
{
|
|
if (Interlocked.CompareExchange(ref _peak, current, observed) == observed) break;
|
|
}
|
|
}
|
|
|
|
/// <summary>Marks the end of one command execution.</summary>
|
|
public void Exit() => Interlocked.Decrement(ref _inFlight);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A pass-through <see cref="DbConnection"/> that hands out commands instrumented with a
|
|
/// <see cref="ConcurrencyProbe"/>. Everything else delegates to the wrapped connection, so the code under
|
|
/// test runs against a real SQLite catalog.
|
|
/// </summary>
|
|
/// <param name="inner">The real connection to delegate to. Not owned — the caller disposes it.</param>
|
|
/// <param name="probe">The probe that observes command overlap.</param>
|
|
public sealed class ConcurrencyProbingDbConnection(DbConnection inner, ConcurrencyProbe probe) : DbConnection
|
|
{
|
|
/// <inheritdoc />
|
|
[System.Diagnostics.CodeAnalysis.AllowNull]
|
|
public override string ConnectionString
|
|
{
|
|
get => inner.ConnectionString;
|
|
set => inner.ConnectionString = value!;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override string Database => inner.Database;
|
|
|
|
/// <inheritdoc />
|
|
public override string DataSource => inner.DataSource;
|
|
|
|
/// <inheritdoc />
|
|
public override string ServerVersion => inner.ServerVersion;
|
|
|
|
/// <inheritdoc />
|
|
public override ConnectionState State => inner.State;
|
|
|
|
/// <inheritdoc />
|
|
public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName);
|
|
|
|
/// <inheritdoc />
|
|
public override void Close() => inner.Close();
|
|
|
|
/// <inheritdoc />
|
|
public override void Open() => inner.Open();
|
|
|
|
/// <inheritdoc />
|
|
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) =>
|
|
inner.BeginTransaction(isolationLevel);
|
|
|
|
/// <inheritdoc />
|
|
protected override DbCommand CreateDbCommand() =>
|
|
new ConcurrencyProbingDbCommand(inner.CreateCommand(), this, probe);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A pass-through <see cref="DbCommand"/> that reports every execution to a <see cref="ConcurrencyProbe"/>
|
|
/// and holds it open for the probe's delay, so overlapping executions are observable.
|
|
/// </summary>
|
|
/// <param name="inner">The real command to delegate to.</param>
|
|
/// <param name="owner">The connection that created this command.</param>
|
|
/// <param name="probe">The probe that observes command overlap.</param>
|
|
public sealed class ConcurrencyProbingDbCommand(DbCommand inner, DbConnection owner, ConcurrencyProbe probe)
|
|
: DbCommand
|
|
{
|
|
/// <inheritdoc />
|
|
[System.Diagnostics.CodeAnalysis.AllowNull]
|
|
public override string CommandText
|
|
{
|
|
get => inner.CommandText;
|
|
set => inner.CommandText = value!;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override int CommandTimeout
|
|
{
|
|
get => inner.CommandTimeout;
|
|
set => inner.CommandTimeout = value;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override CommandType CommandType
|
|
{
|
|
get => inner.CommandType;
|
|
set => inner.CommandType = value;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override bool DesignTimeVisible
|
|
{
|
|
get => inner.DesignTimeVisible;
|
|
set => inner.DesignTimeVisible = value;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override UpdateRowSource UpdatedRowSource
|
|
{
|
|
get => inner.UpdatedRowSource;
|
|
set => inner.UpdatedRowSource = value;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override DbConnection? DbConnection
|
|
{
|
|
get => owner;
|
|
set { /* The session never reassigns the connection; the wrapped command keeps its own. */ }
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override DbParameterCollection DbParameterCollection => inner.Parameters;
|
|
|
|
/// <inheritdoc />
|
|
protected override DbTransaction? DbTransaction
|
|
{
|
|
get => inner.Transaction;
|
|
set => inner.Transaction = value;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override void Cancel() => inner.Cancel();
|
|
|
|
/// <inheritdoc />
|
|
public override int ExecuteNonQuery() => inner.ExecuteNonQuery();
|
|
|
|
/// <inheritdoc />
|
|
public override object? ExecuteScalar() => inner.ExecuteScalar();
|
|
|
|
/// <inheritdoc />
|
|
public override void Prepare() => inner.Prepare();
|
|
|
|
/// <inheritdoc />
|
|
protected override DbParameter CreateDbParameter() => inner.CreateParameter();
|
|
|
|
/// <inheritdoc />
|
|
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
|
|
{
|
|
probe.Enter();
|
|
try
|
|
{
|
|
Thread.Sleep(probe.Delay);
|
|
return inner.ExecuteReader(behavior);
|
|
}
|
|
finally
|
|
{
|
|
probe.Exit();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override async Task<DbDataReader> ExecuteDbDataReaderAsync(
|
|
CommandBehavior behavior, CancellationToken cancellationToken)
|
|
{
|
|
probe.Enter();
|
|
try
|
|
{
|
|
await Task.Delay(probe.Delay, cancellationToken).ConfigureAwait(false);
|
|
return await inner.ExecuteReaderAsync(behavior, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
probe.Exit();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing) inner.Dispose();
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|