Files
Marc Schier 1cca744b5c Retire the last exposed locks: node, browser, server diagnostics and application properties (#4222)
Closes #4201. Closes #4202. Closes #4203. Closes #4215. Closes #4216.

(#4215 and #4216 are @marcschier's originals from the #4188 review;
#4202 and #4201 are my later duplicates of the same two findings. All
four describe the two changes in section 1 and are closed together.)

**This PR now carries what was #4221 as well.** The two were a stack;
they have been unstacked and combined here, and #4221 is closed.
Everything below the "Read the server diagnostics" section is the work
that was previously reviewed as #4221.

---

## 1. The browse path guards itself, and the last node locks are gone

`AsyncCustomNodeManager` kept two `lock (source)` sites around
`CreateBrowser`, carrying a NOTE that browser creation was not
internally synchronized. `NodeBrowser` still handed its lock to derived
types through `protected Lock DataLock`.

`NodeState.CreateBrowser` now holds a browse lock across
`PopulateBrowser` and `OnPopulateBrowser`, so browser construction on a
node is serialized — which is what the external `lock (source)` was
actually providing, and what a handler that mutates the node during
population (the historian's lazy config installer) depends on. Both
`lock (source)` statements and the NOTE are gone.

**What that lock does not do**, stated plainly in the XML docs and in
`docs/NodeManagers.md` rather than over-promised: it is not an atomic
snapshot across the node's children, notifiers and references. Writers
take those collections' own locks, not the browse lock. The browser is a
point-in-time copy and each collection is read consistently, but the
combination is not a transaction.

No `lock` takes a `NodeState` as its sync root any more. That was the
last thing keeping `NodeState` from being documentably self-guarding, so
the other eleven went with it: `lock (activeNode)`, `lock (target)`,
`lock (source.Node)`, `lock (target.Node)`, `lock (notifier)`, `lock
(handle.Node)` in `AsyncCustomNodeManager`, and `lock (node)` in
`CustomNodeManager2`. Most were a check-then-act pair now served by
`NodeState.AddReferenceIfMissing`, which does the check and the insert
under the node's own lock.

`NodeBrowser.DataLock` is removed. A browser is **single-consumer**: it
belongs to whoever created it, and both server browse paths already
serialize on the owning side. The exposed lock only added an
inheritance-level locking contract that a derived browser could not
reason about.

## 2. Read the server diagnostics under the lock every writer takes

`OnUpdateDiagnostics` locked the diagnostics **payload** rather than the
lock every writer takes through `UpdateServerDiagnostics`, so the
snapshot a client reads from the `ServerDiagnostics` node was never
excluded from a concurrent writer.

Both halves matter, and the second is the one that is easy to miss:

```csharp
lock (m_diagnosticsLock)                                        // was: lock (ServerDiagnostics)
{
    value = Variant.FromStructure(ServerDiagnostics, copy: true);  // was: copy defaulted to false
}
```

`copy: true` is what makes it a snapshot. `FromStructure(value, copy:
false)` does `new ExtensionObject(value, copy)` — it wraps the **live**
structure without copying it, so the caller would read the fields long
after the lock was released and see them change underneath. Swapping the
lock alone would have looked like a fix while still excluding nothing.

## 3. Analyzer numbering

The browser rule is **UA0027**, not UA0025. This branch had
independently taken UA0025 for `NodeBrowser.DataLock` while #4188
published UA0025 for `ILocalNode.DataLock` and UA0026 for
`BaseVariableValue.Lock`. Master's are shipped, so they keep the
numbers:

| | UA0025 | UA0026 | UA0027 |
|---|---|---|---|
| master (#4188) | `ILocalNode.DataLock` | `BaseVariableValue.Lock` | —
|
| **this PR** | `ILocalNode.DataLock` | `BaseVariableValue.Lock` |
**`NodeBrowser.DataLock`** |

Both sides had written a different `UA0025Tests.cs`, which arrived as an
add/add conflict. They are now split: `UA0025Tests.cs` holds master's
`ILocalNode` tests, `UA0027Tests.cs` holds the `NodeBrowser` ones.

UA0024's help text also drops its reference to `ReadServerDiagnostics`,
which #4193 removed as unused, and names `ISession.ReadDiagnostics` /
`ISubscription.ReadDiagnostics` instead — those do still exist.


## 4. Two more locks reachable from outside their owner

**`ApplicationConfiguration.PropertiesLock` is removed.** It returned
the properties dictionary itself, so `lock
(configuration.PropertiesLock)` was `lock (configuration.Properties)` —
the lock *was* the data it guarded, the same shape as #4203.

It had exactly one production consumer
(`OAuth2CredentialCollection.Load`, a get-or-add with `ParseExtension`
called inside the lock), so it could not simply be deleted. `Properties`
is now a `ConcurrentDictionary` — same public type, every operation
atomic on its own — and the one multi-operation need gets a member of
its own:

```csharp
public T GetOrAddProperty<T>(string key, Func<T> valueFactory);
```

It deliberately does **not** invoke the factory under a lock, so
`ParseExtension` now runs outside any critical section. Holding a lock
across a caller-supplied callback is exactly the shape that produced the
attribute/diagnostics deadlock earlier in this work.

The two existing tests asserted only `PropertiesLock is not null`, so
they tested nothing but its presence. They are replaced by tests of the
contract: factory runs once for a missing key, not at all for a present
one, 32 concurrent callers all observe the same instance, null arguments
throw.

`PropertiesLock` ships in 1.5.378, so this is a breaking removal —
migration guide section plus analyzer **UA0028**.

**`SharedDefinitionCache` no longer hands out its lock.** Three sites in
`SharedKeyValueSubscriptionStore` took the nested cache's `Lock` to
operate on the cache's own dictionary. Both are private now, behind
`ReplaceAll` and `CloneWhere`.

`SnapshotCommitLock` stays exposed on purpose: it is a `SemaphoreSlim`
sequencing an async commit across `await` points, which
`System.Threading.Lock` cannot span, so folding it in would change
behaviour rather than hide a lock.
## Verification

Merged `master` (`0edfe237a`, including #4234) with no conflicts.

- `dotnet build UA.slnx` — 0 warnings, 0 errors
- `Opc.Ua.Server.Tests` — 4074 passed
- `Opc.Ua.Types.Tests` — 8501 passed
- `Opc.Ua.MigrationAnalyzer.Tests` — 151 passed on all five TFMs (138 →
151, the new ones being UA0027 and UA0028)
- `Opc.Ua.Core.Tests` — 4249 passed
- `Opc.Ua.Redundancy.Server.Tests` — 573 passed

## Note for the reviewer

@romanett — your approval was given when this PR's diff was the 7
diagnostics files, because it was measured against
`marcschier/node-browse-locks`. Now that the two are combined and the
base is `master`, the diff is 23 files, and the extra 16 are the
node/browser lock work that was #4221. GitHub kept the approval across
the retarget, but it did not cover that code, so please re-review rather
than let it ride.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e9c13dd0-a6e3-4ad2-8ad6-1c3fe6323041
Copilot-Session: b3c0e146-5b9e-4d08-a0f6-db3f940484b1
Copilot-Session: d18f87e9-2484-44ce-98ce-463ba6cf5f63
Copilot-Session: 0ee22c1d-c319-4257-bf78-a5a077a1824a
2026-08-11 22:14:10 +02:00

723 lines
24 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Threading;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
using Quickstarts.Servers;
namespace MemoryBuffer
{
#pragma warning disable CA1001 // Using timers that are disposed in OnAfterDelete
public partial class MemoryBufferState
{
#pragma warning restore CA1001 // Using timers that are disposed in OnAfterDelete
/// <summary>
/// Initializes the buffer from the configuration.
/// </summary>
public MemoryBufferState(
ISystemContext context,
MemoryBufferInstance? configuration)
: base(null)
{
m_logger = context.Telemetry.CreateLogger<MemoryBufferState>();
Initialize(context);
string dataType = "UInt32";
string name = dataType;
int count = 10;
if (configuration != null)
{
count = configuration.TagCount;
if (!string.IsNullOrEmpty(configuration.DataType))
{
dataType = configuration.DataType;
}
if (!string.IsNullOrEmpty(configuration.Name))
{
name = dataType;
}
}
SymbolicName = name;
BuiltInType elementType = dataType != "Double" ? BuiltInType.UInt32 : BuiltInType.Double;
CreateBuffer(elementType, count);
}
/// <summary>
/// The server that the buffer belongs to.
/// </summary>
public IServerInternal Server { get; private set; } = null!;
/// <summary>
/// The node manager that the buffer belongs to.
/// </summary>
public INodeManager NodeManager { get; private set; } = null!;
/// <summary>
/// The built-in type for the values stored in the buffer.
/// </summary>
public BuiltInType ElementType { get; private set; }
/// <summary>
/// The size of each element in the buffer.
/// </summary>
public uint ElementSize => (uint)m_elementSize;
/// <summary>
/// The rate at which the buffer is scanned.
/// </summary>
public int MaximumScanRate { get; private set; }
/// <inheritdoc/>
protected override void OnAfterDelete(ISystemContext context)
{
base.OnAfterDelete(context);
m_scanTimer?.Dispose();
m_scanTimer = null;
}
/// <summary>
/// Initializes the buffer with enough space to hold the specified number of elements.
/// </summary>
/// <param name="elementName">The type of element.</param>
/// <param name="noOfElements">The number of elements.</param>
public void CreateBuffer(string elementName, int noOfElements)
{
if (string.IsNullOrEmpty(elementName))
{
elementName = "UInt32";
}
BuiltInType elementType = elementName != "Double" ? BuiltInType.UInt32 : BuiltInType.Double;
CreateBuffer(elementType, noOfElements);
}
/// <summary>
/// Initializes the buffer with enough space to hold the specified number of elements.
/// </summary>
/// <param name="elementType">The type of element.</param>
/// <param name="noOfElements">The number of elements.</param>
/// <exception cref="ServiceResultException"></exception>
public void CreateBuffer(BuiltInType elementType, int noOfElements)
{
lock (m_dataLock)
{
ElementType = elementType;
switch (ElementType)
{
case BuiltInType.UInt32:
m_elementSize = 4;
break;
case BuiltInType.Double:
m_elementSize = 8;
break;
case >= BuiltInType.Null and <= BuiltInType.Enumeration:
m_elementSize = 1;
break;
default:
throw ServiceResultException.Unexpected($"Unexpected BuiltInType {ElementType}");
}
m_lastScanTime = DateTimeUtc.Now;
MaximumScanRate = 1000;
m_buffer = new byte[m_elementSize * noOfElements];
SizeInBytes!.Value = (uint)m_buffer.Length;
}
}
/// <summary>
/// Creates an object which can browse the tags in the buffer.
/// </summary>
public override INodeBrowser CreateBrowser(
ISystemContext context,
ViewDescription? view,
NodeId referenceType,
bool includeSubtypes,
BrowseDirection browseDirection,
QualifiedName browseName,
IEnumerable<IReference>? additionalReferences,
bool internalOnly)
{
NodeBrowser browser = new MemoryBufferBrowser(
context,
view!,
referenceType,
includeSubtypes,
browseDirection,
browseName,
additionalReferences!,
internalOnly,
this);
PopulateBrowserSynchronized(context, browser);
return browser;
}
/// <summary>
/// Handles the read operation for an invidual tag.
/// </summary>
public ServiceResult ReadTagValue(
ISystemContext context,
NodeState node,
NumericRange indexRange,
QualifiedName dataEncoding,
ref Variant value,
ref StatusCode statusCode,
ref DateTimeUtc timestamp)
{
if (node is not MemoryTagState tag)
{
return StatusCodes.BadNodeIdUnknown;
}
if (!indexRange.IsNull)
{
return StatusCodes.BadIndexRangeInvalid;
}
if (!dataEncoding.IsNull)
{
return StatusCodes.BadDataEncodingUnsupported;
}
int offset = (int)tag.Offset;
lock (m_dataLock)
{
if (offset < 0 || offset >= m_buffer.Length)
{
return StatusCodes.BadNodeIdUnknown;
}
if (m_buffer == null)
{
return StatusCodes.BadOutOfService;
}
value = GetValueAtOffset(offset);
}
statusCode = StatusCodes.Good;
timestamp = m_lastScanTime;
return ServiceResult.Good;
}
/// <summary>
/// Handles a write operation for an individual tag.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public ServiceResult WriteTagValue(
ISystemContext context,
NodeState node,
NumericRange indexRange,
QualifiedName dataEncoding,
ref Variant value,
ref StatusCode statusCode,
ref DateTimeUtc timestamp)
{
if (node is not MemoryTagState tag)
{
return StatusCodes.BadNodeIdUnknown;
}
if (!indexRange.IsNull)
{
return StatusCodes.BadIndexRangeInvalid;
}
if (!dataEncoding.IsNull)
{
return StatusCodes.BadDataEncodingUnsupported;
}
if (statusCode != StatusCodes.Good)
{
return StatusCodes.BadWriteNotSupported;
}
if (timestamp != DateTimeUtc.MinValue)
{
return StatusCodes.BadWriteNotSupported;
}
bool changed = false;
int offset = (int)tag.Offset;
lock (m_dataLock)
{
if (offset < 0 || offset >= m_buffer.Length)
{
return StatusCodes.BadNodeIdUnknown;
}
if (m_buffer == null)
{
return StatusCodes.BadOutOfService;
}
byte[] bytes;
switch (ElementType)
{
case BuiltInType.UInt32:
{
if (!value.TryGetValue(out uint valueToWrite))
{
return StatusCodes.BadTypeMismatch;
}
bytes = BitConverter.GetBytes(valueToWrite);
break;
}
case BuiltInType.Double:
{
if (!value.TryGetValue(out double valueToWrite))
{
return StatusCodes.BadTypeMismatch;
}
bytes = BitConverter.GetBytes(valueToWrite);
break;
}
case >= BuiltInType.Null and <= BuiltInType.Enumeration:
return StatusCodes.BadNodeIdUnknown;
default:
throw ServiceResultException.Unexpected($"Unexpected BuiltInType {ElementType}");
}
for (int ii = 0; ii < bytes.Length; ii++)
{
if (!changed && m_buffer[offset + ii] != bytes[ii])
{
changed = true;
}
m_buffer[offset + ii] = bytes[ii];
}
}
if (changed)
{
OnBufferChanged(offset);
}
return ServiceResult.Good;
}
/// <summary>
/// Returns the value at the specified offset.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public Variant GetValueAtOffset(int offset)
{
lock (m_dataLock)
{
if (offset < 0 || offset >= m_buffer.Length)
{
return Variant.Null;
}
if (m_buffer == null)
{
return Variant.Null;
}
switch (ElementType)
{
case BuiltInType.UInt32:
return new Variant(BitConverter.ToUInt32(m_buffer, offset));
case BuiltInType.Double:
return new Variant(BitConverter.ToDouble(m_buffer, offset));
case >= BuiltInType.Null and <= BuiltInType.Enumeration:
return Variant.Null;
default:
throw ServiceResultException.Unexpected($"Bad element type {ElementType}");
}
}
}
/// <summary>
/// Initializes the instance with the context for the node being monitored.
/// </summary>
public void InitializeMonitoring(IServerInternal server, INodeManager nodeManager)
{
lock (m_dataLock)
{
Server = server;
NodeManager = nodeManager;
m_nonValueMonitoredItems = [];
}
}
/// <summary>
/// Creates a new data change monitored item.
/// </summary>
public MemoryBufferMonitoredItem CreateDataChangeItem(
ServerSystemContext context,
MemoryTagState tag,
uint subscriptionId,
uint monitoredItemId,
ReadValueId itemToMonitor,
DiagnosticsMasks diagnosticsMasks,
TimestampsToReturn timestampsToReturn,
MonitoringMode monitoringMode,
uint clientHandle,
double samplingInterval,
bool createDurable)
/*
ISystemContext context,
MemoryTagState tag,
uint monitoredItemId,
uint attributeId,
DiagnosticsMasks diagnosticsMasks,
TimestampsToReturn timestampsToReturn,
MonitoringMode monitoringMode,
uint clientHandle,
double samplingInterval)*/
{
lock (m_dataLock)
{
var monitoredItem = new MemoryBufferMonitoredItem(
Server,
NodeManager.ToAsyncNodeManager(),
this,
tag.Offset,
0,
monitoredItemId,
itemToMonitor,
diagnosticsMasks,
timestampsToReturn,
monitoringMode,
clientHandle,
null,
null,
null,
samplingInterval,
0,
false,
0,
createDurable);
/*
MemoryBufferMonitoredItem monitoredItem = new MemoryBufferMonitoredItem(
this,
monitoredItemId,
tag.Offset,
attributeId,
diagnosticsMasks,
timestampsToReturn,
monitoringMode,
clientHandle,
samplingInterval);
*/
AddMonitoredItemInternal(monitoredItem, tag);
return monitoredItem;
}
}
/// <summary>
/// Restores data change monitored item after a server restart.
/// </summary>
public MemoryBufferMonitoredItem RestoreDataChangeItem(
ServerSystemContext context,
MemoryTagState tag,
IStoredMonitoredItem storedMonitoredItem)
{
lock (m_dataLock)
{
var monitoredItem = new MemoryBufferMonitoredItem(
Server,
NodeManager.ToAsyncNodeManager(),
this,
tag.Offset,
storedMonitoredItem);
AddMonitoredItemInternal(monitoredItem, tag);
return monitoredItem;
}
}
private void AddMonitoredItemInternal(
MemoryBufferMonitoredItem monitoredItem,
MemoryTagState tag)
{
if (monitoredItem.AttributeId != Attributes.Value)
{
m_nonValueMonitoredItems!.Add(monitoredItem.Id, monitoredItem);
return;
}
int elementCount = (int)(SizeInBytes!.Value / ElementSize);
if (m_monitoringTable == null)
{
m_monitoringTable = new MemoryBufferMonitoredItem[elementCount][];
TimeProvider timeProvider = (Server as ITimeProviderProvider)?.TimeProvider
?? TimeProvider.System;
m_scanTimer?.Dispose();
m_scanTimer = timeProvider.CreateTimer(
DoScan,
null,
TimeSpan.FromMilliseconds(100),
TimeSpan.FromMilliseconds(100));
}
int elementOffet = (int)(tag.Offset / ElementSize);
MemoryBufferMonitoredItem[]? monitoredItems = m_monitoringTable![elementOffet];
if (monitoredItems == null)
{
monitoredItems = new MemoryBufferMonitoredItem[1];
}
else
{
monitoredItems = new MemoryBufferMonitoredItem[monitoredItems.Length + 1];
m_monitoringTable[elementOffet]!.CopyTo(monitoredItems, 0);
}
monitoredItems[^1] = monitoredItem;
m_monitoringTable[elementOffet] = monitoredItems;
m_itemCount++;
}
/// <summary>
/// Scans the buffer and updates every other element.
/// </summary>
private void DoScan(object? state)
{
DateTimeUtc start1 = DateTimeUtc.Now;
lock (m_dataLock)
{
for (int ii = 0; ii < m_buffer.Length; ii += m_elementSize)
{
m_buffer[ii]++;
// notify any monitored items that the value has changed.
OnBufferChanged(ii);
}
m_lastScanTime = DateTimeUtc.Now;
}
DateTimeUtc end1 = DateTimeUtc.Now;
double delta1 = (end1 - start1).TotalMilliseconds;
if (delta1 > 100)
{
m_logger.SamplingDelay(nameof(MemoryBufferState), delta1);
}
}
/// <summary>
/// Deletes the monitored item.
/// </summary>
public void DeleteItem(MemoryBufferMonitoredItem monitoredItem)
{
lock (m_dataLock)
{
if (monitoredItem.AttributeId != Attributes.Value)
{
m_nonValueMonitoredItems!.Remove(monitoredItem.Id);
return;
}
if (m_monitoringTable != null)
{
int elementOffet = (int)(monitoredItem.Offset / ElementSize);
MemoryBufferMonitoredItem[]? monitoredItems = m_monitoringTable[elementOffet];
if (monitoredItems != null)
{
int index = -1;
for (int ii = 0; ii < monitoredItems.Length; ii++)
{
if (ReferenceEquals(monitoredItems[ii], monitoredItem))
{
index = ii;
break;
}
}
if (index >= 0)
{
m_itemCount--;
if (monitoredItems.Length == 1)
{
monitoredItems = null;
}
else
{
monitoredItems = new MemoryBufferMonitoredItem[monitoredItems
.Length -
1];
Array.Copy(
m_monitoringTable[elementOffet]!,
0,
monitoredItems,
0,
index);
Array.Copy(
m_monitoringTable[elementOffet]!,
index + 1,
monitoredItems,
index,
monitoredItems.Length - index);
}
m_monitoringTable[elementOffet] = monitoredItems;
}
}
}
}
}
/// <summary>
/// Handles change events raised by the node.
/// </summary>
public void OnBufferChanged(int offset)
{
lock (m_dataLock)
{
if (m_monitoringTable != null)
{
int elementOffet = (int)(offset / ElementSize);
MemoryBufferMonitoredItem[]? monitoredItems = m_monitoringTable[elementOffet];
if (monitoredItems != null)
{
var value = new DataValue(
GetValueAtOffset(offset),
StatusCodes.Good,
m_lastScanTime,
DateTimeUtc.Now);
for (int ii = 0; ii < monitoredItems.Length; ii++)
{
monitoredItems[ii].QueueValue(value, null);
m_updateCount++;
}
}
}
}
}
private void ScanTimer_Tick(object sender, EventArgs e)
{
DoScan(null);
}
private void PublishTimer_Tick(object sender, EventArgs e)
{
DateTimeUtc start1 = DateTimeUtc.Now;
lock (m_dataLock)
{
if (m_itemCount > 0 && m_updateCount < m_itemCount &&
m_logger.IsEnabled(LogLevel.Information))
{
m_logger.MemoryBufferReported(
DateTimeUtc.Now.ToLocalTime(),
m_updateCount,
m_itemCount);
}
m_updateCount = 0;
}
DateTimeUtc end1 = DateTimeUtc.Now;
double delta1 = (end1 - start1).TotalMilliseconds;
if (delta1 > 100)
{
m_logger.PublishDelay(nameof(MemoryBufferState), delta1);
}
}
private readonly ILogger m_logger;
private readonly Lock m_dataLock = new();
private MemoryBufferMonitoredItem[]?[]? m_monitoringTable;
private Dictionary<uint, MemoryBufferMonitoredItem>? m_nonValueMonitoredItems;
private int m_elementSize;
private DateTimeUtc m_lastScanTime;
private byte[] m_buffer = null!;
private ITimer? m_scanTimer;
private int m_updateCount;
private int m_itemCount;
}
internal static partial class MemoryBufferStateLog
{
[LoggerMessage(
EventId = QuickstartsServersEventIds.MemoryBufferState + 0, Level = LogLevel.Warning,
Message = "{StateName} SAMPLING DELAY ({Delta}ms)")]
public static partial void SamplingDelay(this ILogger logger, string stateName, double delta);
[LoggerMessage(
EventId = QuickstartsServersEventIds.MemoryBufferState + 1, Level = LogLevel.Information,
Message = "{Now:HH:mm:ss.fff} MEMORYBUFFER Reported {UpdateCount}/{ItemCount} items ***.")]
public static partial void MemoryBufferReported(
this ILogger logger,
DateTime now,
int updateCount,
int itemCount);
[LoggerMessage(
EventId = QuickstartsServersEventIds.MemoryBufferState + 2, Level = LogLevel.Information,
Message = "{StateName} ****** PUBLISH DELAY ({Delta}ms) ******")]
public static partial void PublishDelay(this ILogger logger, string stateName, double delta);
}
}