Auto: abcip-4.1 — per-tag scan rate / scan group bucketing

Closes #238
This commit is contained in:
Joseph Doherty
2026-04-26 02:15:50 -04:00
parent e5c38a5a0e
commit b45713622f
8 changed files with 761 additions and 7 deletions

View File

@@ -214,7 +214,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
DataType: member.DataType,
Writable: member.Writable,
WriteIdempotent: member.WriteIdempotent,
StringLength: member.StringLength);
StringLength: member.StringLength,
// PR abcip-4.1 — inherit per-tag scan rate from parent so a UDT
// declared at 100 ms publishes every member at 100 ms without the
// operator having to repeat ScanRateMs on every member.
ScanRateMs: tag.ScanRateMs);
_tagsByName[memberTag.Name] = memberTag;
}
}
@@ -416,16 +420,120 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// ---- ISubscribable (polling overlay via shared engine) ----
/// <summary>Per-bucket subscription handles owned by one composite <see cref="AbCipCompositeSubscriptionHandle"/>.</summary>
private readonly Dictionary<long, IReadOnlyList<ISubscriptionHandle>> _compositeSubscriptions = new();
private long _nextCompositeId;
/// <summary>
/// PR abcip-4.1 — partitions <paramref name="fullReferences"/> by the resolved publishing
/// interval (per-tag <see cref="AbCipTagDefinition.ScanRateMs"/> override falling back
/// to <paramref name="publishingInterval"/>) and registers one
/// <see cref="PollGroupEngine"/> subscription per distinct interval. The returned handle
/// wraps every per-bucket subscription so <see cref="UnsubscribeAsync"/> tears them all
/// down together — callers see one logical subscription, the engine sees N independent
/// poll loops at their own cadence.
/// </summary>
/// <remarks>
/// Approach B from the PR plan — keeps <see cref="PollGroupEngine"/> unchanged and
/// handles the multi-rate split entirely at the driver level. The engine already floors
/// each call's interval at 100 ms, so a misconfigured <c>ScanRateMs &lt; 100</c> is
/// clamped per-bucket without driver-side validation. Tags whose <c>ScanRateMs</c>
/// equals the subscription default (or that have no override) collapse into the
/// default-rate bucket — the legacy single-rate path is preserved for callers that
/// don't set <c>ScanRateMs</c> on any tag.
/// </remarks>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
// Bucket tags by resolved interval. Unknown tags (not in _tagsByName, e.g. typo)
// and tags with no ScanRateMs fall back to the subscription default — matches the
// S7 driver's "config typo degrades, doesn't break" stance.
var buckets = new Dictionary<TimeSpan, List<string>>();
foreach (var tagRef in fullReferences)
{
var interval = ResolveTagInterval(tagRef, publishingInterval);
if (!buckets.TryGetValue(interval, out var list))
{
list = [];
buckets[interval] = list;
}
list.Add(tagRef);
}
var innerHandles = new List<ISubscriptionHandle>(buckets.Count);
foreach (var (interval, refs) in buckets)
{
innerHandles.Add(_poll.Subscribe(refs, interval));
}
var compositeId = Interlocked.Increment(ref _nextCompositeId);
lock (_compositeSubscriptions)
_compositeSubscriptions[compositeId] = innerHandles;
return Task.FromResult<ISubscriptionHandle>(new AbCipCompositeSubscriptionHandle(compositeId, innerHandles.Count));
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
if (handle is AbCipCompositeSubscriptionHandle composite)
{
IReadOnlyList<ISubscriptionHandle>? inner;
lock (_compositeSubscriptions)
{
_compositeSubscriptions.TryGetValue(composite.Id, out inner);
_compositeSubscriptions.Remove(composite.Id);
}
if (inner is not null)
{
foreach (var h in inner)
_poll.Unsubscribe(h);
}
}
else
{
// Defensive — older callers (or tests stubbing in a raw PollGroupEngine handle)
// can still unsubscribe directly through the engine.
_poll.Unsubscribe(handle);
}
return Task.CompletedTask;
}
/// <summary>
/// Resolve the publishing interval for one tag — per-tag <see cref="AbCipTagDefinition.ScanRateMs"/>
/// wins, otherwise fall back to the subscription default. The engine's 100 ms floor still
/// applies at <see cref="PollGroupEngine.Subscribe"/> time so this method does NOT clamp.
/// A negative or zero <c>ScanRateMs</c> is treated as null (use default) — mis-typed
/// overrides degrade rather than fault.
/// </summary>
internal TimeSpan ResolveTagInterval(string tagRef, TimeSpan defaultInterval)
{
if (_tagsByName.TryGetValue(tagRef, out var def) &&
def.ScanRateMs is { } ms && ms > 0)
{
return TimeSpan.FromMilliseconds(ms);
}
return defaultInterval;
}
/// <summary>
/// Test-only: count of distinct poll-engine subscriptions a composite handle owns.
/// Used by <c>AbCipPerTagScanRateTests</c> to assert that 2 tags at 2 rates produce
/// 2 buckets (and 2 tags at 1 rate produce 1 bucket).
/// </summary>
internal int GetSubscriptionBucketCount(ISubscriptionHandle handle) =>
handle is AbCipCompositeSubscriptionHandle composite ? composite.BucketCount : 0;
/// <summary>
/// Composite handle returned by <see cref="SubscribeAsync"/>. Wraps one or more
/// <see cref="PollGroupEngine"/> handles so the driver can fan out multi-rate
/// subscriptions while presenting a single token to OPC UA-side callers.
/// </summary>
internal sealed record AbCipCompositeSubscriptionHandle(long Id, int BucketCount) : ISubscriptionHandle
{
public string DiagnosticId => $"abcip-sub-{Id}({BucketCount}b)";
}
// ---- IAlarmSource (ALMD projection, #177) ----
/// <summary>