merge: SQL dialect+chunking review findings resolved (Driver.Sql-002, Driver.Sql.Dialect-001/-002/-003)
This commit is contained in:
@@ -7,6 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// <summary>
|
||||
/// Folds a set of <see cref="SqlTagDefinition"/>s into the minimum number of parameterized queries
|
||||
/// (design §3.6) — the driver's batching core. Pure and side-effect free: no connection, no I/O, no clock.
|
||||
/// <para><b>"Minimum" is bounded by the provider, not only by the group key.</b> A key-value group whose
|
||||
/// distinct keys exceed <see cref="ISqlDialect.MaxBindParameters"/> is emitted as several plans over the
|
||||
/// same group key rather than as one statement the backend refuses — see <see cref="AddKeyValuePlans"/>.</para>
|
||||
/// <para><b>The GroupKey is the correctness contract.</b> Two tags share a query <em>only</em> when every
|
||||
/// field that shapes the result set is identical. Too coarse and a tag silently reads a neighbour's cell;
|
||||
/// too fine and batching degenerates to one round-trip per tag. Both directions are pinned by
|
||||
@@ -31,14 +34,21 @@ public static class SqlGroupPlanner
|
||||
private const string RowSelectorBaseName = "w";
|
||||
|
||||
/// <summary>
|
||||
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
|
||||
/// <para>Deterministic: groups appear in the input order of their first member, members keep their input
|
||||
/// order within a group, and parameters keep the first-appearance order of their distinct values — so a
|
||||
/// given tag list always yields byte-identical SQL.</para>
|
||||
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group — or
|
||||
/// <b>several</b>, when a key-value group holds more distinct keys than the dialect's
|
||||
/// <see cref="ISqlDialect.MaxBindParameters"/> allows in one command (see
|
||||
/// <see cref="AddKeyValuePlans"/>). Plans from one group share that group's key.
|
||||
/// <para>Deterministic: groups appear in the input order of their first member, a split group's chunks
|
||||
/// stay adjacent and in key order, members keep their input order within a chunk, and parameters keep
|
||||
/// the first-appearance order of their distinct values — so a given tag list always yields
|
||||
/// byte-identical SQL.</para>
|
||||
/// </summary>
|
||||
/// <param name="tags">The tags to plan. An empty sequence yields no plans.</param>
|
||||
/// <param name="dialect">Supplies identifier quoting and the provider's row-limit syntax.</param>
|
||||
/// <returns>One plan per distinct group key.</returns>
|
||||
/// <param name="dialect">
|
||||
/// Supplies identifier quoting, the provider's row-limit syntax, and its per-command bind-parameter
|
||||
/// ceiling.
|
||||
/// </param>
|
||||
/// <returns>At least one plan per distinct group key; more when a key-value group had to be chunked.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="tags"/> or <paramref name="dialect"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// A definition is missing a field its model requires (e.g. a wide-row tag with no row selector), or an
|
||||
@@ -78,12 +88,18 @@ public static class SqlGroupPlanner
|
||||
foreach (var key in order)
|
||||
{
|
||||
var members = groups[key];
|
||||
plans.Add(members[0].Model switch
|
||||
switch (members[0].Model)
|
||||
{
|
||||
SqlTagModel.KeyValue => BuildKeyValuePlan(key, members, dialect),
|
||||
SqlTagModel.WideRow => BuildWideRowPlan(key, members, dialect),
|
||||
_ => throw UnsupportedModel(members[0]),
|
||||
});
|
||||
case SqlTagModel.KeyValue:
|
||||
// One group can yield SEVERAL plans: the IN list is chunked at the dialect's ceiling.
|
||||
AddKeyValuePlans(plans, key, members, dialect);
|
||||
break;
|
||||
case SqlTagModel.WideRow:
|
||||
plans.Add(BuildWideRowPlan(key, members, dialect));
|
||||
break;
|
||||
default:
|
||||
throw UnsupportedModel(members[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return plans;
|
||||
@@ -119,10 +135,71 @@ public static class SqlGroupPlanner
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Emits <c>SELECT <key>, <value>[, <ts>] FROM <table> WHERE <key> IN (@k0..@kN)</c>.
|
||||
/// The key column is selected because the reader indexes rows by it to slice values back per member.
|
||||
/// Emits one key-value plan per <b>chunk</b> of the group's distinct keys, sized by
|
||||
/// <see cref="ISqlDialect.MaxBindParameters"/>. A group at or under the ceiling — every ordinary group
|
||||
/// — still yields exactly one plan.
|
||||
/// <para><b>Why chunk rather than let the provider refuse.</b> Every backend caps parameters per
|
||||
/// command (2100 on SQL Server, 1000 <c>IN</c>-list entries on Oracle), and a key-value (EAV) staging
|
||||
/// table is precisely the shape that attracts thousands of tags on <em>one</em> table — which the
|
||||
/// group-key rule folds into one group. Over the cap the provider rejects the statement <em>after</em>
|
||||
/// the connection opened, so every tag in the group publishes <c>BadCommunicationError</c> on every
|
||||
/// poll, permanently, and the driver reports a communication fault against a database that is
|
||||
/// answering (Driver.Sql-002).</para>
|
||||
/// <para><b>Chunks share the group key, and no consumer needs to change.</b> The reader executes one
|
||||
/// command per plan and slices each plan's own <see cref="SqlQueryPlan.Members"/> back, so several
|
||||
/// plans over one group key cost extra round-trips and nothing else. The alternative — a deploy-time
|
||||
/// tag-count gate — would refuse a configuration the driver can serve perfectly well.</para>
|
||||
/// <para><b>Members follow their key, never their position.</b> Two tags authored on the same
|
||||
/// <c>keyValue</c> land in the same chunk (one bound parameter, both fed); a member placed in a chunk
|
||||
/// that does not bind its key would read a result set that cannot contain its row and publish
|
||||
/// <c>BadNoData</c> forever.</para>
|
||||
/// </summary>
|
||||
private static void AddKeyValuePlans(
|
||||
List<SqlQueryPlan> plans, object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
|
||||
{
|
||||
var ceiling = dialect.MaxBindParameters;
|
||||
if (ceiling < 1)
|
||||
{
|
||||
// Not a silent clamp: chunking to zero would loop forever, and a one-per-chunk fallback would
|
||||
// hide a broken dialect behind a poll that suddenly costs one round-trip per tag.
|
||||
throw new ArgumentException(
|
||||
$"The {dialect.Provider} dialect reports a per-command bind-parameter ceiling of {ceiling}; " +
|
||||
"it must be at least 1.", nameof(dialect));
|
||||
}
|
||||
|
||||
// Distinct keys in first-appearance order decide the chunk boundaries; every member then follows its
|
||||
// own key. Both halves keep the planner's determinism: a given tag list yields byte-identical SQL.
|
||||
var chunkOfKey = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var member in members)
|
||||
{
|
||||
var keyValue = member.KeyValue
|
||||
?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
|
||||
if (!chunkOfKey.ContainsKey(keyValue)) chunkOfKey[keyValue] = chunkOfKey.Count / ceiling;
|
||||
}
|
||||
|
||||
var chunkCount = ((chunkOfKey.Count - 1) / ceiling) + 1;
|
||||
if (chunkCount == 1)
|
||||
{
|
||||
plans.Add(BuildKeyValuePlan(groupKey, members, dialect));
|
||||
return;
|
||||
}
|
||||
|
||||
var chunks = new List<SqlTagDefinition>[chunkCount];
|
||||
for (var c = 0; c < chunkCount; c++) chunks[c] = [];
|
||||
foreach (var member in members) chunks[chunkOfKey[member.KeyValue!]].Add(member);
|
||||
|
||||
foreach (var chunk in chunks) plans.Add(BuildKeyValuePlan(groupKey, chunk, dialect));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits <c>SELECT <key>, <value>[, <ts>] FROM <table> WHERE <key> IN (@k0..@kN)</c>
|
||||
/// for one chunk of a group. The key column is selected because the reader indexes rows by it to slice
|
||||
/// values back per member.
|
||||
/// <para>Keys are de-duplicated (ordinal) before binding: two tags authored against the same
|
||||
/// <c>keyValue</c> bind one parameter but stay two members, so both nodes are fed from the one row.</para>
|
||||
/// <para>Markers are numbered from <c>k0</c> within the chunk — each chunk is its own command, so
|
||||
/// carrying a group-wide index forward would leave a three-parameter command spelling
|
||||
/// <c>IN (@k3, @k4, @k5)</c>.</para>
|
||||
/// </summary>
|
||||
private static SqlQueryPlan BuildKeyValuePlan(
|
||||
object groupKey, List<SqlTagDefinition> members, ISqlDialect dialect)
|
||||
|
||||
@@ -27,6 +27,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// <param name="GroupKey">
|
||||
/// The opaque equality key that decided this grouping — a value tuple, so two plans compare equal exactly
|
||||
/// when they would have folded together. Exposed for diagnostics and plan caching, never for parsing.
|
||||
/// <para><b>Not unique across plans.</b> A key-value group holding more distinct keys than the dialect's
|
||||
/// <see cref="ISqlDialect.MaxBindParameters"/> is emitted as several plans carrying <em>this same key</em>
|
||||
/// — the chunking is an execution detail of one group, not a regrouping.</para>
|
||||
/// </param>
|
||||
/// <param name="SqlText">The single parameterized statement to execute for this group.</param>
|
||||
/// <param name="ParameterNames">
|
||||
|
||||
Reference in New Issue
Block a user