diff --git a/Applications/ConsoleReferenceClient/ClientSamples.cs b/Applications/ConsoleReferenceClient/ClientSamples.cs index b513e409e7..67a9e3413e 100644 --- a/Applications/ConsoleReferenceClient/ClientSamples.cs +++ b/Applications/ConsoleReferenceClient/ClientSamples.cs @@ -224,6 +224,7 @@ namespace Quickstarts browser.BrowseDirection = BrowseDirection.Forward; browser.NodeClassMask = (int)NodeClass.Object | (int)NodeClass.Variable; browser.ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences; + browser.IncludeSubtypes = true; NodeId nodeToBrowse = ObjectIds.Server; @@ -380,7 +381,8 @@ namespace Quickstarts NodeId startingNode, bool fetchTree = false, bool addRootNode = false, - bool filterUATypes = true) + bool filterUATypes = true, + bool clearNodeCache = true) { var stopwatch = new Stopwatch(); var nodeDictionary = new Dictionary(); @@ -389,19 +391,15 @@ namespace Quickstarts startingNode }; - // clear NodeCache to fetch all nodes from server - uaClient.Session.NodeCache.Clear(); - // start stopwatch.Start(); - // fetch the reference types first, otherwise browse for e.g. hierarchical references with subtypes won't work - var bindingFlags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public; - var namespaceUris = uaClient.Session.NamespaceUris; - var referenceTypes = typeof(ReferenceTypeIds) - .GetFields(bindingFlags) - .Select(field => NodeId.ToExpandedNodeId((NodeId)field.GetValue(null), namespaceUris)); - uaClient.Session.FetchTypeTree(new ExpandedNodeIdCollection(referenceTypes)); + if (clearNodeCache) + { + // clear NodeCache to fetch all nodes from server + uaClient.Session.NodeCache.Clear(); + FetchReferenceIdTypes(uaClient.Session); + } // add root node if (addRootNode) @@ -421,7 +419,7 @@ namespace Quickstarts searchDepth++; Utils.LogInfo("{0}: Find {1} references after {2}ms", searchDepth, nodesToBrowse.Count, stopwatch.ElapsedMilliseconds); - var response = uaClient.Session.NodeCache.FindReferences( + IList response = uaClient.Session.NodeCache.FindReferences( nodesToBrowse, references, false, @@ -429,13 +427,33 @@ namespace Quickstarts var nextNodesToBrowse = new ExpandedNodeIdCollection(); int duplicates = 0; - foreach (var node in response) + int leafNodes = 0; + foreach (INode node in response) { if (!nodeDictionary.ContainsKey(node.NodeId)) { if (fetchTree) { - nextNodesToBrowse.Add(node.NodeId); + bool leafNode = false; + + // no need to browse property types + if (node is VariableNode variableNode) + { + var hasTypeDefinition = variableNode.ReferenceTable.FirstOrDefault(r => r.ReferenceTypeId.Equals(ReferenceTypeIds.HasTypeDefinition)); + if (hasTypeDefinition != null) + { + leafNode = (hasTypeDefinition.TargetId == VariableTypeIds.PropertyType); + } + } + + if (!leafNode) + { + nextNodesToBrowse.Add(node.NodeId); + } + else + { + leafNodes++; + } } if (filterUATypes) @@ -460,6 +478,10 @@ namespace Quickstarts { Utils.LogInfo("Find References {0} duplicate nodes were ignored", duplicates); } + if (leafNodes > 0) + { + Utils.LogInfo("Find References {0} leaf nodes were ignored", leafNodes); + } nodesToBrowse = nextNodesToBrowse; } @@ -515,7 +537,7 @@ namespace Quickstarts var referenceDescriptions = new Dictionary(); int searchDepth = 0; - uint maxNodesPerBrowse = 0; + uint maxNodesPerBrowse = uaClient.Session.OperationLimits.MaxNodesPerBrowse; while (browseDescriptionCollection.Any() && searchDepth < kMaxSearchDepth) { searchDepth++; @@ -625,7 +647,10 @@ namespace Quickstarts if (!referenceDescriptions.ContainsKey(reference.NodeId)) { referenceDescriptions[reference.NodeId] = reference; - browseTable.Add(ExpandedNodeId.ToNodeId(reference.NodeId, uaClient.Session.NamespaceUris)); + if (reference.ReferenceTypeId != ReferenceTypeIds.HasProperty) + { + browseTable.Add(ExpandedNodeId.ToNodeId(reference.NodeId, uaClient.Session.NamespaceUris)); + } } else { @@ -707,6 +732,28 @@ namespace Quickstarts } #endregion + #region Fetch ReferenceId Types + /// + /// Read all ReferenceTypeIds from the server that are not known by the client. + /// To reduce the number of calls due to traversal call pyramid, start with all + /// known reference types to reduce the number of FetchReferences/FetchNodes calls. + /// + /// + /// The NodeCache needs this information to function properly with subtypes of hierarchical calls. + /// + /// The session to use + void FetchReferenceIdTypes(ISession session) + { + // fetch the reference types first, otherwise browse for e.g. hierarchical references with subtypes won't work + var bindingFlags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public; + var namespaceUris = session.NamespaceUris; + var referenceTypes = typeof(ReferenceTypeIds) + .GetFields(bindingFlags) + .Select(field => NodeId.ToExpandedNodeId((NodeId)field.GetValue(null), namespaceUris)); + session.FetchTypeTree(new ExpandedNodeIdCollection(referenceTypes)); + } + #endregion + #region Read Values and output as JSON sample /// /// Output all values as JSON. @@ -790,6 +837,84 @@ namespace Quickstarts } #endregion + #region Subscribe Values + /// + /// Output all values as JSON. + /// + /// The UAClient with a session to use. + /// The variables to subscribe. + public async Task SubscribeAllValuesAsync( + IUAClient uaClient, + NodeCollection variableIds, + int samplingInterval, + int publishingInterval, + uint queueSize, + uint lifetimeCount, + uint keepAliveCount) + { + if (uaClient.Session == null || !uaClient.Session.Connected) + { + m_output.WriteLine("Session not connected!"); + return; + } + + try + { + // Create a subscription for receiving data change notifications + var session = uaClient.Session; + + // test for deferred ack of sequence numbers + session.PublishSequenceNumbersToAcknowledge += DeferSubscriptionAcknowledge; + + // set a minimum amount of three publish requests per session + session.MinPublishRequestCount = 3; + + // Define Subscription parameters + Subscription subscription = new Subscription(session.DefaultSubscription) { + DisplayName = "Console ReferenceClient Subscription", + PublishingEnabled = true, + PublishingInterval = publishingInterval, + LifetimeCount = lifetimeCount, + KeepAliveCount = keepAliveCount, + SequentialPublishing = true, + RepublishAfterTransfer = true, + MaxNotificationsPerPublish = 1000, + MinLifetimeInterval = (uint)session.SessionTimeout, + FastDataChangeCallback = FastDataChangeNotification, + }; + session.AddSubscription(subscription); + + // Create the subscription on Server side + await subscription.CreateAsync().ConfigureAwait(false); + m_output.WriteLine("New Subscription created with SubscriptionId = {0}.", subscription.Id); + + // Create MonitoredItems for data changes + foreach (Node item in variableIds) + { + MonitoredItem monitoredItem = new MonitoredItem(subscription.DefaultItem) { + StartNodeId = item.NodeId, + AttributeId = Attributes.Value, + SamplingInterval = samplingInterval, + DisplayName = item.DisplayName.Text ?? item.BrowseName.Name, + QueueSize = queueSize, + DiscardOldest = true, + MonitoringMode = MonitoringMode.Reporting, + }; + subscription.AddItem(monitoredItem); + if (subscription.CurrentKeepAliveCount > 1000) break; + } + + // Create the monitored items on Server side + subscription.ApplyChanges(); + m_output.WriteLine("MonitoredItems {0} created for SubscriptionId = {1}.", subscription.MonitoredItemCount, subscription.Id); + } + catch (Exception ex) + { + m_output.WriteLine("Subscribe error: {0}", ex.Message); + } + } + #endregion + #region Helper Methods /// /// Create a prettified JSON string of a DataValue. @@ -834,6 +959,21 @@ namespace Quickstarts #endregion #region Private Methods + /// + /// The fast data change notification callback. + /// + private void FastDataChangeNotification(Subscription subscription, DataChangeNotification notification, IList stringTable) + { + try + { + m_output.WriteLine("Notification: Id={0} Items={1}.", subscription.Id, notification.MonitoredItems.Count); + } + catch (Exception ex) + { + m_output.WriteLine("FastDataChangeNotification error: {0}", ex.Message); + } + } + /// /// Handle DataChange notifications from Server /// @@ -843,7 +983,7 @@ namespace Quickstarts { // Log MonitoredItem Notification event MonitoredItemNotification notification = e.NotificationValue as MonitoredItemNotification; - m_output.WriteLine("Notification: {0} \"{1}\" and Value = {2}.", notification.Message.SequenceNumber, monitoredItem.DisplayName, notification.Value); + m_output.WriteLine("Notification: {0} \"{1}\" and Value = {2}.", notification.Message.SequenceNumber, monitoredItem.ResolvedNodeId, notification.Value); } catch (Exception ex) { @@ -851,6 +991,25 @@ namespace Quickstarts } } + /// + /// Event handler to defer publish response sequence number acknowledge. + /// + private void DeferSubscriptionAcknowledge(ISession session, PublishSequenceNumbersToAcknowledgeEventArgs e) + { + // for testing keep the latest sequence numbers for a while + const int AckDelay = 5; + if (e.AcknowledgementsToSend.Count > 0) + { + // defer latest sequence numbers + var deferredItems = e.AcknowledgementsToSend.OrderByDescending(s => s.SequenceNumber).Take(AckDelay).ToList(); + e.DeferredAcknowledgementsToSend.AddRange(deferredItems); + foreach (var deferredItem in deferredItems) + { + e.AcknowledgementsToSend.Remove(deferredItem); + } + } + } + /// /// Create a browse description from a node id collection. /// diff --git a/Applications/ConsoleReferenceClient/Program.cs b/Applications/ConsoleReferenceClient/Program.cs index aa606955c7..70235dd861 100644 --- a/Applications/ConsoleReferenceClient/Program.cs +++ b/Applications/ConsoleReferenceClient/Program.cs @@ -76,6 +76,7 @@ namespace Quickstarts.ConsoleReferenceClient bool fetchall = false; bool jsonvalues = false; bool verbose = false; + bool subscribe = false; string password = null; int timeout = Timeout.Infinite; string logFile = null; @@ -97,6 +98,7 @@ namespace Quickstarts.ConsoleReferenceClient { "f|fetchall", "Fetch all nodes", f => { if (f != null) fetchall = true; } }, { "j|json", "Output all Values as JSON", j => { if (j != null) jsonvalues = true; } }, { "v|verbose", "Verbose output", v => { if (v != null) verbose = true; } }, + { "s|subscribe", "Subscribe", s => { if (s != null) subscribe = true; } }, }; try @@ -128,7 +130,7 @@ namespace Quickstarts.ConsoleReferenceClient }; // load the application configuration. - var config = await application.LoadApplicationConfiguration(silent: false); + var config = await application.LoadApplicationConfiguration(silent: false).ConfigureAwait(false); // override logfile if (logFile != null) @@ -177,7 +179,8 @@ namespace Quickstarts.ConsoleReferenceClient // create the UA Client object and connect to configured server. using (UAClient uaClient = new UAClient( application.ApplicationConfiguration, output, ClientBase.ValidateResponse) { - AutoAccept = autoAccept + AutoAccept = autoAccept, + SessionLifeTime = 60000, }) { // set user identity @@ -186,12 +189,13 @@ namespace Quickstarts.ConsoleReferenceClient uaClient.UserIdentity = new UserIdentity(username, userpassword ?? string.Empty); } - bool connected = await uaClient.ConnectAsync(serverUrl.ToString(), false); + bool connected = await uaClient.ConnectAsync(serverUrl.ToString(), false).ConfigureAwait(false); if (connected) { output.WriteLine("Connected! Ctrl-C to quit."); // enable subscription transfer + uaClient.ReconnectPeriod = 1000; uaClient.Session.TransferSubscriptionsOnReconnect = true; var samples = new ClientSamples(output, ClientBase.ValidateResponse, quitEvent, verbose); @@ -219,16 +223,55 @@ namespace Quickstarts.ConsoleReferenceClient allNodes = samples.FetchAllNodesNodeCache( uaClient, Objects.RootFolder, true, true, false); variableIds = new NodeIdCollection(allNodes - .Where(r => r.NodeClass == NodeClass.Variable && ((VariableNode)r).DataType.NamespaceIndex != 0) + .Where(r => r.NodeClass == NodeClass.Variable && r is VariableNode && ((VariableNode)r).DataType.NamespaceIndex != 0) .Select(r => ExpandedNodeId.ToNodeId(r.NodeId, uaClient.Session.NamespaceUris))); } if (jsonvalues && variableIds != null) { - await samples.ReadAllValuesAsync(uaClient, variableIds); + await samples.ReadAllValuesAsync(uaClient, variableIds).ConfigureAwait(false); + } + + if (subscribe) + { + const int MaxVariables = 100; + NodeCollection variables = new NodeCollection(); + Random random = new Random(62541); + if (fetchall) + { + variables.AddRange(allNodes + .Where(r => r.NodeClass == NodeClass.Variable && r.NodeId.NamespaceIndex > 1) + .Select(r => ((VariableNode)r)) + .OrderBy(o => random.Next()) + .Take(MaxVariables)); + } + else if (browseall) + { + var variableReferences = referenceDescriptions + .Where(r => r.NodeClass == NodeClass.Variable && r.NodeId.NamespaceIndex > 1) + .Select(r => r.NodeId) + .OrderBy(o => random.Next()) + .Take(MaxVariables) + .ToList(); + variables.AddRange(uaClient.Session.NodeCache.Find(variableReferences).Cast()); + } + + await samples.SubscribeAllValuesAsync(uaClient, + variableIds: new NodeCollection(variables.Take(100)), + samplingInterval: 1000, + publishingInterval: 5000, + queueSize: 10, + lifetimeCount: 12, + keepAliveCount: 2).ConfigureAwait(false); + + // Wait for DataChange notifications from MonitoredItems + quit = quitEvent.WaitOne(timeout > 0 ? waitTime : Timeout.Infinite); + } + else + { + quit = true; } - quit = true; } else { diff --git a/Applications/ConsoleReferenceClient/UAClient.cs b/Applications/ConsoleReferenceClient/UAClient.cs index 8332a0857f..79f5ed1903 100644 --- a/Applications/ConsoleReferenceClient/UAClient.cs +++ b/Applications/ConsoleReferenceClient/UAClient.cs @@ -62,6 +62,7 @@ namespace Quickstarts { Utils.SilentDispose(m_session); m_configuration.CertificateValidator.CertificateValidation -= CertificateValidation; + GC.SuppressFinalize(this); } #endregion @@ -89,7 +90,7 @@ namespace Quickstarts /// /// The session lifetime. /// - public uint SessionLifeTime { get; set; } = 30 * 1000; + public uint SessionLifeTime { get; set; } = 60 * 1000; /// /// The user identity to use to connect to the server. @@ -135,7 +136,7 @@ namespace Quickstarts var session = await Opc.Ua.Client.Session.Create( m_configuration, endpoint, - false, + true, false, m_configuration.ApplicationName, SessionLifeTime, @@ -151,8 +152,15 @@ namespace Quickstarts // override keep alive interval m_session.KeepAliveInterval = KeepAliveInterval; + // support transfer + m_session.DeleteSubscriptionsOnClose = false; + m_session.TransferSubscriptionsOnReconnect = true; + // set up keep alive callback. m_session.KeepAlive += Session_KeepAlive; + + // prepare a reconnect handler + m_reconnectHandler = new SessionReconnectHandler(true, 30_000); } // Session created successfully. @@ -227,19 +235,14 @@ namespace Quickstarts return; } - lock (m_lock) + var state = m_reconnectHandler.BeginReconnect(m_session, ReconnectPeriod, Client_ReconnectComplete); + if (state == SessionReconnectHandler.ReconnectState.Triggered) { - if (m_reconnectHandler == null) - { - Utils.LogInfo("KeepAlive status {0}, reconnecting in {1}ms.", e.Status, ReconnectPeriod); - m_output.WriteLine("--- RECONNECTING {0} ---", e.Status); - m_reconnectHandler = new SessionReconnectHandler(true); - m_reconnectHandler.BeginReconnect(m_session, ReconnectPeriod, Client_ReconnectComplete); - } - else - { - Utils.LogInfo("KeepAlive status {0}, reconnect in progress.", e.Status); - } + Utils.LogInfo("KeepAlive status {0}, reconnect status {1}, reconnect period {2}ms.", e.Status, state, ReconnectPeriod); + } + else + { + Utils.LogInfo("KeepAlive status {0}, reconnect status {1}.", e.Status, state); } return; @@ -267,14 +270,27 @@ namespace Quickstarts // if session recovered, Session property is null if (m_reconnectHandler.Session != null) { - m_session = m_reconnectHandler.Session as Session; + // ensure only a new instance is disposed + // after reactivate, the same session instance may be returned + if (!Object.ReferenceEquals(m_session, m_reconnectHandler.Session)) + { + m_output.WriteLine("--- RECONNECTED TO NEW SESSION --- {0}", m_reconnectHandler.Session.SessionId); + var session = m_session; + session.KeepAlive -= Session_KeepAlive; + m_session = m_reconnectHandler.Session as Session; + m_session.KeepAlive += Session_KeepAlive; + Utils.SilentDispose(session); + } + else + { + m_output.WriteLine("--- REACTIVATED SESSION --- {0}", m_reconnectHandler.Session.SessionId); + } + } + else + { + m_output.WriteLine("--- RECONNECT KeepAlive recovered ---"); } - - m_reconnectHandler.Dispose(); - m_reconnectHandler = null; } - - m_output.WriteLine("--- RECONNECTED ---"); } #endregion diff --git a/Libraries/Opc.Ua.Client/ISession.cs b/Libraries/Opc.Ua.Client/ISession.cs index bf26be5a83..6c620fe7c2 100644 --- a/Libraries/Opc.Ua.Client/ISession.cs +++ b/Libraries/Opc.Ua.Client/ISession.cs @@ -55,6 +55,11 @@ namespace Opc.Ua.Client /// public delegate void PublishErrorEventHandler(ISession session, PublishErrorEventArgs e); + /// + /// The delegate used to modify publish response sequence numbers to acknowledge. + /// + public delegate void PublishSequenceNumbersToAcknowledgeEventHandler(ISession session, PublishSequenceNumbersToAcknowledgeEventArgs e); + /// /// Manages a session with a server. /// @@ -93,6 +98,15 @@ namespace Opc.Ua.Client /// event PublishErrorEventHandler PublishError; + /// + /// Raised when a publish request is about to acknolegde sequence numbers. + /// + /// + /// If the client chose to defer acknowledge of sequenece numbers, it is responsible + /// to transfer these to the deferred list. + /// + event PublishSequenceNumbersToAcknowledgeEventHandler PublishSequenceNumbersToAcknowledge; + /// /// Raised when a subscription is added or removed /// @@ -243,6 +257,11 @@ namespace Opc.Ua.Client /// int GoodPublishRequestCount { get; } + /// + /// Gets and sets the minimum number of publish requests to be used in the session. + /// + int MinPublishRequestCount { get; set; } + /// /// Stores the operation limits of a OPC UA Server. /// diff --git a/Libraries/Opc.Ua.Client/ReverseConnectManager.cs b/Libraries/Opc.Ua.Client/ReverseConnectManager.cs index 2b27297d23..3d1246720b 100644 --- a/Libraries/Opc.Ua.Client/ReverseConnectManager.cs +++ b/Libraries/Opc.Ua.Client/ReverseConnectManager.cs @@ -216,6 +216,11 @@ namespace Opc.Ua.Client Utils.SilentDispose(m_configurationWatcher); m_configurationWatcher = null; } + if (m_cts != null) + { + Utils.SilentDispose(m_cts); + m_cts = null; + } DisposeHosts(); } #endregion @@ -725,6 +730,7 @@ namespace Opc.Ua.Client CancellationTokenSource cts = m_cts; m_cts = new CancellationTokenSource(); cts.Cancel(); + cts.Dispose(); } #endregion diff --git a/Libraries/Opc.Ua.Client/Session.cs b/Libraries/Opc.Ua.Client/Session.cs index ee368e5ccf..33067289ff 100644 --- a/Libraries/Opc.Ua.Client/Session.cs +++ b/Libraries/Opc.Ua.Client/Session.cs @@ -114,6 +114,7 @@ namespace Opc.Ua.Client m_transferSubscriptionsOnReconnect = template.m_transferSubscriptionsOnReconnect; m_sessionTimeout = template.m_sessionTimeout; m_maxRequestMessageSize = template.m_maxRequestMessageSize; + m_minPublishRequestCount = template.m_minPublishRequestCount; m_preferredLocales = template.PreferredLocales; m_sessionName = template.SessionName; m_handle = template.Handle; @@ -130,6 +131,7 @@ namespace Opc.Ua.Client m_KeepAlive = template.m_KeepAlive; m_Publish = template.m_Publish; m_PublishError = template.m_PublishError; + m_PublishSequenceNumbersToAcknowledge = template.m_PublishSequenceNumbersToAcknowledge; m_SubscriptionsChanged = template.m_SubscriptionsChanged; m_SessionClosing = template.m_SessionClosing; } @@ -267,6 +269,7 @@ namespace Opc.Ua.Client m_outstandingRequests = new LinkedList(); m_keepAliveInterval = 5000; m_tooManyPublishRequests = 0; + m_minPublishRequestCount = kDefaultPublishRequestCount; m_sessionName = ""; m_deleteSubscriptionsOnClose = true; m_transferSubscriptionsOnReconnect = false; @@ -474,6 +477,27 @@ namespace Opc.Ua.Client } } + + /// + public event PublishSequenceNumbersToAcknowledgeEventHandler PublishSequenceNumbersToAcknowledge + { + add + { + lock (m_eventLock) + { + m_PublishSequenceNumbersToAcknowledge += value; + } + } + + remove + { + lock (m_eventLock) + { + m_PublishSequenceNumbersToAcknowledge -= value; + } + } + } + /// /// Raised when a subscription is added or removed /// @@ -767,6 +791,29 @@ namespace Opc.Ua.Client } } } + + /// + /// Gets and sets the minimum number of publish requests to be used in the session. + /// + public int MinPublishRequestCount + { + get => m_minPublishRequestCount; + set + { + lock (SyncRoot) + { + if (value >= kDefaultPublishRequestCount && value <= kMinPublishRequestCountMax) + { + m_minPublishRequestCount = value; + } + else + { + throw new ArgumentOutOfRangeException(nameof(MinPublishRequestCount), + $"Minimum publish request count must be between {kDefaultPublishRequestCount} and {kMinPublishRequestCountMax}."); + } + } + } + } #endregion #region Public Static Methods @@ -1356,7 +1403,7 @@ namespace Opc.Ua.Client m_previousServerNonce = m_serverNonce; m_serverNonce = serverNonce; m_reconnecting = false; - publishCount = m_subscriptions.Count; + publishCount = GetMinPublishRequestCount(); } // refill pipeline. @@ -1369,7 +1416,10 @@ namespace Opc.Ua.Client } finally { - m_reconnecting = false; + lock (SyncRoot) + { + m_reconnecting = false; + } } } @@ -4223,11 +4273,7 @@ namespace Opc.Ua.Client } } - lock (SyncRoot) - { - count = m_subscriptions.Count; - } - + count = GetMinPublishRequestCount(); while (count-- > 0) { BeginPublish(OperationTimeout); @@ -5044,13 +5090,39 @@ namespace Opc.Ua.Client return null; } - SubscriptionAcknowledgementCollection acknowledgementsToSend = null; + // get event handler to modify ack list + PublishSequenceNumbersToAcknowledgeEventHandler callback = null; + lock (m_eventLock) + { + callback = m_PublishSequenceNumbersToAcknowledge; + } // collect the current set if acknowledgements. + SubscriptionAcknowledgementCollection acknowledgementsToSend = null; lock (SyncRoot) { - acknowledgementsToSend = m_acknowledgementsToSend; - m_acknowledgementsToSend = new SubscriptionAcknowledgementCollection(); + if (callback != null) + { + try + { + var deferredAcknowledgementsToSend = new SubscriptionAcknowledgementCollection(); + callback(this, new PublishSequenceNumbersToAcknowledgeEventArgs(m_acknowledgementsToSend, deferredAcknowledgementsToSend)); + acknowledgementsToSend = m_acknowledgementsToSend; + m_acknowledgementsToSend = deferredAcknowledgementsToSend; + } + catch (Exception e2) + { + Utils.LogError(e2, "Session: Unexpected error invoking PublishSequenceNumbersToAcknowledgeEventArgs."); + } + } + + if (acknowledgementsToSend == null) + { + // send all ack values, clear list + acknowledgementsToSend = m_acknowledgementsToSend; + m_acknowledgementsToSend = new SubscriptionAcknowledgementCollection(); + } + foreach (var toSend in acknowledgementsToSend) { m_latestAcknowledgementsSent[toSend.SubscriptionId] = toSend.SequenceNumber; @@ -5245,14 +5317,14 @@ namespace Opc.Ua.Client } int requestCount = GoodPublishRequestCount; - var subscriptionsCount = m_subscriptions.Count; - if (requestCount < subscriptionsCount) + var minPublishRequestCount = GetMinPublishRequestCount(); + if (requestCount < minPublishRequestCount) { BeginPublish(OperationTimeout); } else { - Utils.LogInfo("PUBLISH - Did not send another publish request. GoodPublishRequestCount={0}, Subscriptions={1}", requestCount, subscriptionsCount); + Utils.LogInfo("PUBLISH - Did not send another publish request. GoodPublishRequestCount={0}, MinPublishRequestCount={1}", requestCount, minPublishRequestCount); } } @@ -5304,7 +5376,7 @@ namespace Opc.Ua.Client Utils.LogWarning("Message {0}-{1} no longer available.", subscriptionId, sequenceNumber); break; // if encoding limits are exceeded, the issue is logged and - // the published data is acknoledged to prevent the endless republish loop. + // the published data is acknowledged to prevent the endless republish loop. case StatusCodes.BadEncodingLimitsExceeded: Utils.LogError(e, "Message {0}-{1} exceeded size limits, ignored.", subscriptionId, sequenceNumber); var ack = new SubscriptionAcknowledgement { @@ -5537,7 +5609,15 @@ namespace Opc.Ua.Client } catch (ServiceResultException sre) { - Utils.LogError(sre, "Transfer subscriptions failed."); + if (sre.StatusCode == StatusCodes.BadServiceUnsupported) + { + TransferSubscriptionsOnReconnect = false; + Utils.LogWarning("Transfer subscription unsupported, TransferSubscriptionsOnReconnect set to false."); + } + else + { + Utils.LogError(sre, "Transfer subscriptions failed."); + } } catch (Exception ex) { @@ -5682,6 +5762,24 @@ namespace Opc.Ua.Client return (m_tooManyPublishRequests == 0) || (requestCount < m_tooManyPublishRequests); } + + /// + /// Returns the minimum number of active publish request that should be used. + /// + /// + /// Returns 0 if there are no subscriptions. + /// + private int GetMinPublishRequestCount() + { + lock (SyncRoot) + { + if (m_subscriptions.Count == 0) + { + return 0; + } + return Math.Max(m_subscriptions.Count, m_minPublishRequestCount); + } + } #endregion #region Protected Fields @@ -5760,6 +5858,9 @@ namespace Opc.Ua.Client private long m_keepAliveCounter; private bool m_reconnecting; private const int kReconnectTimeout = 15000; + private const int kMinPublishRequestCountMax = 100; + private const int kDefaultPublishRequestCount = 1; + private int m_minPublishRequestCount; private LinkedList m_outstandingRequests; private readonly EndpointDescriptionCollection m_discoveryServerEndpoints; private readonly StringCollection m_discoveryProfileUris; @@ -5777,6 +5878,7 @@ namespace Opc.Ua.Client private event KeepAliveEventHandler m_KeepAlive; private event NotificationEventHandler m_Publish; private event PublishErrorEventHandler m_PublishError; + private event PublishSequenceNumbersToAcknowledgeEventHandler m_PublishSequenceNumbersToAcknowledge; private event EventHandler m_SubscriptionsChanged; private event EventHandler m_SessionClosing; #endregion @@ -5934,4 +6036,57 @@ namespace Opc.Ua.Client #endregion } #endregion + + #region PublishSequenceNumbersToAcknowledgeEventArgs Class + /// + /// Represents the event arguments provided when publish response + /// sequence numbers are about to be achknoledged with a publish request. + /// + /// + /// A callee can defer an acknowledge to the next publish request by + /// moving the to the deferred list. + /// The callee can modify the list of acknowledgements to send, it is the + /// responsibility of the caller to protect the lists for modifications. + /// + public class PublishSequenceNumbersToAcknowledgeEventArgs : EventArgs + { + #region Constructors + /// + /// Creates a new instance. + /// + internal PublishSequenceNumbersToAcknowledgeEventArgs( + SubscriptionAcknowledgementCollection acknowledgementsToSend, + SubscriptionAcknowledgementCollection deferredAcknowledgementsToSend) + { + m_acknowledgementsToSend = acknowledgementsToSend; + m_deferredAcknowledgementsToSend = deferredAcknowledgementsToSend; + } + #endregion + + #region Public Properties + /// + /// The acknowledgements which are sent with the next publish request. + /// + /// + /// A client may also chose to remove an acknowledgement from this list to add it back + /// to the list in a subsequent callback when the request is fully processed. + /// + public SubscriptionAcknowledgementCollection AcknowledgementsToSend => m_acknowledgementsToSend; + + /// + /// The deferred list of acknowledgements. + /// + /// + /// The callee can transfer an outstanding + /// to this list to defer the acknowledge of a sequence number to the next publish request. + /// + public SubscriptionAcknowledgementCollection DeferredAcknowledgementsToSend => m_deferredAcknowledgementsToSend; + #endregion + + #region Private Fields + private readonly SubscriptionAcknowledgementCollection m_acknowledgementsToSend; + private readonly SubscriptionAcknowledgementCollection m_deferredAcknowledgementsToSend; + #endregion + } + #endregion } diff --git a/Libraries/Opc.Ua.Client/SessionAsync.cs b/Libraries/Opc.Ua.Client/SessionAsync.cs index c5efc420e4..89791b0238 100644 --- a/Libraries/Opc.Ua.Client/SessionAsync.cs +++ b/Libraries/Opc.Ua.Client/SessionAsync.cs @@ -493,6 +493,10 @@ namespace Opc.Ua.Client Utils.LogError("Session close error: " + result); } + finally + { + this.OperationTimeout = existingTimeout; + } } // clean up. diff --git a/Libraries/Opc.Ua.Client/SessionReconnectHandler.cs b/Libraries/Opc.Ua.Client/SessionReconnectHandler.cs index 7817ef762d..6edabe27a2 100644 --- a/Libraries/Opc.Ua.Client/SessionReconnectHandler.cs +++ b/Libraries/Opc.Ua.Client/SessionReconnectHandler.cs @@ -1,5 +1,5 @@ /* ======================================================================== - * Copyright (c) 2005-2020 The OPC Foundation, Inc. All rights reserved. + * Copyright (c) 2005-2023 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * @@ -38,13 +38,66 @@ namespace Opc.Ua.Client /// public class SessionReconnectHandler : IDisposable { + /// + /// The minimum reconnect period in ms. + /// + public const int MinReconnectPeriod = 500; + + /// + /// The maximum reconnect period in ms. + /// + public const int MaxReconnectPeriod = 30000; + + /// + /// The default reconnect period in ms. + /// + public const int DefaultReconnectPeriod = 1000; + + /// + /// The internal state of the reconnect handler. + /// + public enum ReconnectState + { + /// + /// The reconnect handler is ready to start the reconnect timer. + /// + Ready = 0, + + /// + /// The reconnect timer is triggered and waiting to reconnect. + /// + Triggered = 1, + + /// + /// The reconnection is in progress. + /// + Reconnecting = 2, + + /// + /// The reconnect handler is disposed and can not be used for further reconnect attempts. + /// + Disposed = 4 + }; + /// /// Create a reconnect handler. /// /// Set to true to allow reconnect abort if keep alive recovered. - public SessionReconnectHandler(bool reconnectAbort = false) + /// + /// The upper limit for the reconnect period after exponential backoff. + /// -1 (default) indicates that no exponential backoff should be used. + /// + public SessionReconnectHandler(bool reconnectAbort = false, int maxReconnectPeriod = -1) { m_reconnectAbort = reconnectAbort; + m_reconnectTimer = new Timer(OnReconnect, this, Timeout.Infinite, Timeout.Infinite); + m_state = ReconnectState.Ready; + m_cancelReconnect = false; + m_updateFromServer = false; + m_baseReconnectPeriod = DefaultReconnectPeriod; + m_maxReconnectPeriod = maxReconnectPeriod < 0 ? -1 : + Math.Max(MinReconnectPeriod, Math.Min(maxReconnectPeriod, MaxReconnectPeriod)); + m_random = new Random(); } #region IDisposable Members @@ -71,6 +124,7 @@ namespace Opc.Ua.Client m_reconnectTimer.Dispose(); m_reconnectTimer = null; } + m_state = ReconnectState.Disposed; } } } @@ -83,32 +137,139 @@ namespace Opc.Ua.Client /// The session. public ISession Session => m_session; + /// + /// The internal state of the reconnect handler. + /// + public ReconnectState State + { + get + { + lock (m_lock) + { + if (m_reconnectTimer == null) + { + return ReconnectState.Disposed; + } + return m_state; + } + } + } + + /// + /// Cancel a reconnect in progress. + /// + public void CancelReconnect() + { + lock (m_lock) + { + if (m_reconnectTimer == null) + { + return; + } + + if (m_state == ReconnectState.Triggered) + { + m_session = null; + EnterReadyState(); + return; + } + + m_cancelReconnect = true; + } + } + /// /// Begins the reconnect process. /// - public void BeginReconnect(ISession session, int reconnectPeriod, EventHandler callback) + public ReconnectState BeginReconnect(ISession session, int reconnectPeriod, EventHandler callback) { - BeginReconnect(session, null, reconnectPeriod, callback); + return BeginReconnect(session, null, reconnectPeriod, callback); } /// /// Begins the reconnect process using a reverse connection. /// - public void BeginReconnect(ISession session, ReverseConnectManager reverseConnectManager, int reconnectPeriod, EventHandler callback) + public ReconnectState BeginReconnect(ISession session, ReverseConnectManager reverseConnectManager, int reconnectPeriod, EventHandler callback) { lock (m_lock) { - if (m_reconnectTimer != null) + if (m_reconnectTimer == null) { throw new ServiceResultException(StatusCodes.BadInvalidState); } - m_session = session; - m_reconnectFailed = false; - m_reconnectPeriod = reconnectPeriod; - m_callback = callback; - m_reverseConnectManager = reverseConnectManager; - m_reconnectTimer = new System.Threading.Timer(OnReconnect, null, reconnectPeriod, Timeout.Infinite); + // cancel reconnect requested, if possible + if (session == null) + { + if (m_state == ReconnectState.Triggered) + { + m_session = null; + EnterReadyState(); + return m_state; + } + // reconnect already in progress, schedule cancel + m_cancelReconnect = true; + return m_state; + } + + // set reconnect period within boundaries + m_baseReconnectPeriod = reconnectPeriod = CheckedReconnectPeriod(reconnectPeriod); + + // ignore subsequent trigger requests + if (m_state == ReconnectState.Ready) + { + m_session = session; + m_reconnectFailed = false; + m_cancelReconnect = false; + m_callback = callback; + m_reverseConnectManager = reverseConnectManager; + m_reconnectTimer.Change(JitteredReconnectPeriod(reconnectPeriod), Timeout.Infinite); + m_reconnectPeriod = CheckedReconnectPeriod(reconnectPeriod, true); + m_state = ReconnectState.Triggered; + return m_state; + } + + // if triggered, reset timer if requested reconnect period is smaller + if (m_state == ReconnectState.Triggered && reconnectPeriod < m_reconnectPeriod) + { + m_reconnectTimer.Change(JitteredReconnectPeriod(reconnectPeriod), Timeout.Infinite); + m_reconnectPeriod = CheckedReconnectPeriod(reconnectPeriod, true); + } + + return m_state; + } + } + + /// + /// Returns the reconnect period with a random jitter. + /// + public virtual int JitteredReconnectPeriod(int reconnectPeriod) + { + // The factors result in a jitter of 10%. + const int JitterResolution = 1000; + const int JitterFactor = 10; + int jitter = (reconnectPeriod * m_random.Next(-JitterResolution, JitterResolution)) / + (JitterResolution * JitterFactor); + return reconnectPeriod + jitter; + } + + /// + /// Returns the reconnect period within the min and max boundaries. + /// + public virtual int CheckedReconnectPeriod(int reconnectPeriod, bool exponentialBackoff = false) + { + // exponential backoff is controlled by m_maxReconnectPeriod + if (m_maxReconnectPeriod > MinReconnectPeriod) + { + if (exponentialBackoff) + { + reconnectPeriod *= 2; + } + return Math.Min(Math.Max(reconnectPeriod, MinReconnectPeriod), m_maxReconnectPeriod); + } + else + { + return Math.Max(reconnectPeriod, MinReconnectPeriod); } } #endregion @@ -123,9 +284,18 @@ namespace Opc.Ua.Client try { // check for exit. - if (m_reconnectTimer == null) + lock (m_lock) { - return; + if (m_reconnectTimer == null || m_session == null) + { + return; + } + if (m_state != ReconnectState.Triggered) + { + return; + } + // enter reconnecting state + m_state = ReconnectState.Reconnecting; } bool keepaliveRecovered = false; @@ -147,11 +317,7 @@ namespace Opc.Ua.Client { lock (m_lock) { - if (m_reconnectTimer != null) - { - m_reconnectTimer.Dispose(); - m_reconnectTimer = null; - } + EnterReadyState(); } // notify the caller. @@ -168,12 +334,21 @@ namespace Opc.Ua.Client // schedule the next reconnect. lock (m_lock) { - int adjustedReconnectPeriod = m_reconnectPeriod - (int)DateTime.UtcNow.Subtract(reconnectStart).TotalMilliseconds; - if (adjustedReconnectPeriod <= 0) + if (m_state != ReconnectState.Disposed) { - adjustedReconnectPeriod = 100; + if (m_cancelReconnect) + { + EnterReadyState(); + } + else + { + int adjustedReconnectPeriod = JitteredReconnectPeriod(m_reconnectPeriod) - + (int)DateTime.UtcNow.Subtract(reconnectStart).TotalMilliseconds; + m_reconnectTimer.Change(CheckedReconnectPeriod(adjustedReconnectPeriod), Timeout.Infinite); + m_reconnectPeriod = CheckedReconnectPeriod(m_reconnectPeriod, true); + m_state = ReconnectState.Triggered; + } } - m_reconnectTimer = new Timer(OnReconnect, null, adjustedReconnectPeriod, Timeout.Infinite); } } @@ -182,15 +357,17 @@ namespace Opc.Ua.Client /// private async Task DoReconnect() { - // override operation timeout - var operationTimeout = m_session.OperationTimeout; + // helper to override operation timeout + int operationTimeout = m_session.OperationTimeout; + int reconnectOperationTimeout = m_reconnectPeriod >= DefaultReconnectPeriod ? + m_reconnectPeriod : DefaultReconnectPeriod; // try a reconnect. if (!m_reconnectFailed) { try { - m_session.OperationTimeout = m_reconnectPeriod; + m_session.OperationTimeout = reconnectOperationTimeout; if (m_reverseConnectManager != null) { var connection = await m_reverseConnectManager.WaitForConnection( @@ -229,6 +406,13 @@ namespace Opc.Ua.Client return false; } } + + // check if the security configuration may have changed + if (sre.StatusCode == StatusCodes.BadSecurityChecksFailed) + { + m_updateFromServer = true; + Utils.LogInfo("Reconnect failed due to security check. Request endpoint update from server. {0}", sre.Message); + } } else { @@ -247,24 +431,68 @@ namespace Opc.Ua.Client try { ISession session; - m_session.OperationTimeout = m_reconnectPeriod; + m_session.OperationTimeout = reconnectOperationTimeout; if (m_reverseConnectManager != null) { - var connection = await m_reverseConnectManager.WaitForConnection( - new Uri(m_session.Endpoint.EndpointUrl), - m_session.Endpoint.Server.ApplicationUri - ).ConfigureAwait(false); + ITransportWaitingConnection connection; + do + { + connection = await m_reverseConnectManager.WaitForConnection( + new Uri(m_session.Endpoint.EndpointUrl), + m_session.Endpoint.Server.ApplicationUri + ).ConfigureAwait(false); + + if (m_updateFromServer) + { + var endpoint = m_session.ConfiguredEndpoint; + await endpoint.UpdateFromServerAsync( + endpoint.EndpointUrl, connection, + endpoint.Description.SecurityMode, + endpoint.Description.SecurityPolicyUri).ConfigureAwait(false); + m_updateFromServer = false; + connection = null; + } + } while (connection == null); session = await m_session.SessionFactory.RecreateAsync(m_session, connection).ConfigureAwait(false); } else { + if (m_updateFromServer) + { + var endpoint = m_session.ConfiguredEndpoint; + await endpoint.UpdateFromServerAsync( + endpoint.EndpointUrl, + endpoint.Description.SecurityMode, + endpoint.Description.SecurityPolicyUri).ConfigureAwait(false); + m_updateFromServer = false; + } + session = await m_session.SessionFactory.RecreateAsync(m_session).ConfigureAwait(false); } m_session.Close(); m_session = session; return true; } + catch (ServiceResultException sre) + { + if (sre.InnerResult?.StatusCode == StatusCodes.BadSecurityChecksFailed) + { + // schedule endpoint update and retry + m_updateFromServer = true; + if (m_maxReconnectPeriod > MinReconnectPeriod && + m_reconnectPeriod >= m_maxReconnectPeriod) + { + m_reconnectPeriod = m_baseReconnectPeriod; + } + Utils.LogError("Could not reconnect due to failed security check. Request endpoint update from server. {0}", sre.Message); + } + else + { + Utils.LogError("Could not reconnect the Session. {0}", sre.Message); + } + return false; + } catch (Exception exception) { Utils.LogError("Could not reconnect the Session. {0}", exception.Message); @@ -275,14 +503,31 @@ namespace Opc.Ua.Client m_session.OperationTimeout = operationTimeout; } } + + /// + /// Reset the timer and enter ready state. + /// + private void EnterReadyState() + { + m_reconnectTimer.Change(Timeout.Infinite, Timeout.Infinite); + m_state = ReconnectState.Ready; + m_cancelReconnect = false; + m_updateFromServer = false; + } #endregion #region Private Fields private object m_lock = new object(); private ISession m_session; + private ReconnectState m_state; + private Random m_random; private bool m_reconnectFailed; private bool m_reconnectAbort; + private bool m_cancelReconnect; + private bool m_updateFromServer; private int m_reconnectPeriod; + private int m_baseReconnectPeriod; + private int m_maxReconnectPeriod; private Timer m_reconnectTimer; private EventHandler m_callback; private ReverseConnectManager m_reverseConnectManager; diff --git a/Libraries/Opc.Ua.Client/Subscription.cs b/Libraries/Opc.Ua.Client/Subscription.cs index 2be34b855d..9534e335d2 100644 --- a/Libraries/Opc.Ua.Client/Subscription.cs +++ b/Libraries/Opc.Ua.Client/Subscription.cs @@ -404,11 +404,11 @@ namespace Opc.Ua.Client /// /// If the available sequence numbers of a subscription - /// are republished or acknoledged after a transfer. + /// are republished or acknowledged after a transfer. /// /// /// Default false, set to true if no data loss is important - /// and available publish requests (sequence numbers) that were never acknoledged should be + /// and available publish requests (sequence numbers) that were never acknowledged should be /// recovered with a republish. The setting is used after a subscription transfer. /// [DataMember(Name = "RepublishAfterTransfer", Order = 15)] @@ -1674,6 +1674,10 @@ namespace Opc.Ua.Client Utils.LogError(e, "Error while raising PublishStateChanged event."); } } + + // try to send a publish to recover stopped publishing. + int keepAliveInterval = (int)(Math.Min(m_currentPublishingInterval * m_currentKeepAliveCount, Int32.MaxValue)); + m_session?.BeginPublish(Math.Min(keepAliveInterval, Int32.MaxValue / 3) * 3); } /// @@ -1817,7 +1821,7 @@ namespace Opc.Ua.Client { if (m_minLifetimeInterval > 0 && m_minLifetimeInterval < m_session.SessionTimeout) { - Utils.LogWarning("A smaller lifeTime {0}ms than session timeout {1}ms configured for subscription {2}.", + Utils.LogWarning("A smaller minLifetimeInterval {0}ms than session timeout {1}ms configured for subscription {2}.", m_minLifetimeInterval, m_session.SessionTimeout, Id); } diff --git a/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs b/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs index 982fb50e0b..48e8f2e96e 100644 --- a/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs +++ b/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs @@ -213,7 +213,7 @@ namespace Opc.Ua if (ii.UriScheme == url.Scheme) { listener = ii; - break; + listener.CreateReverseConnection(url, timeout); } } } @@ -222,8 +222,6 @@ namespace Opc.Ua { throw new ArgumentException("No suitable listener found.", nameof(url)); } - - listener.CreateReverseConnection(url, timeout); } /// diff --git a/Stack/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs b/Stack/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs index 69f83f06c1..6139cc6fb1 100644 --- a/Stack/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs +++ b/Stack/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs @@ -155,13 +155,15 @@ namespace Opc.Ua.Bindings else { Socket = m_socketFactory.Create(this, BufferManager, Quotas.MaxBufferSize); - task = Task.Run(async () => - await (Socket?.BeginConnect( - m_via, m_ConnectCallback, operation, - new CancellationTokenSource(timeout).Token) ?? Task.FromResult(false)).ConfigureAwait(false)); + task = Task.Run(async () => { + using (var cts = new CancellationTokenSource(timeout)) + { + await (Socket?.BeginConnect(m_via, m_ConnectCallback, operation, cts.Token) ?? Task.FromResult(false)).ConfigureAwait(false); + + } + }); } } - return m_handshakeOperation; } @@ -1272,7 +1274,7 @@ namespace Opc.Ua.Bindings m_queuedOperations = null; } } - #endregion + #endregion #region Message Processing /// diff --git a/Stack/Opc.Ua.Core/Stack/Transport/AsyncResultBase.cs b/Stack/Opc.Ua.Core/Stack/Transport/AsyncResultBase.cs index 1e7fd907d2..86aff8d7a2 100644 --- a/Stack/Opc.Ua.Core/Stack/Transport/AsyncResultBase.cs +++ b/Stack/Opc.Ua.Core/Stack/Transport/AsyncResultBase.cs @@ -76,6 +76,13 @@ namespace Opc.Ua // signal any waiting threads. DisposeWaitHandle(true); + + // dispose the cancellation token. + if (m_cts != null) + { + Utils.SilentDispose(m_cts); + m_cts = null; + } } } #endregion diff --git a/Stack/Opc.Ua.Core/Types/Encoders/JsonDecoder.cs b/Stack/Opc.Ua.Core/Types/Encoders/JsonDecoder.cs index 17bad5a3d2..ed2bd0ad0a 100644 --- a/Stack/Opc.Ua.Core/Types/Encoders/JsonDecoder.cs +++ b/Stack/Opc.Ua.Core/Types/Encoders/JsonDecoder.cs @@ -1445,11 +1445,14 @@ namespace Opc.Ua return new ExtensionObject(typeId, encodeable); } - var ostrm = new MemoryStream(); - using (var stream = new StreamWriter(ostrm)) - using (JsonTextWriter writer = new JsonTextWriter(stream)) + using (var ostrm = new MemoryStream()) { - EncodeAsJson(writer, token); + using (var stream = new StreamWriter(ostrm)) + using (JsonTextWriter writer = new JsonTextWriter(stream)) + { + EncodeAsJson(writer, token); + } + // Close the writer before retrieving the data return new ExtensionObject(typeId, ostrm.ToArray()); } } @@ -1463,7 +1466,7 @@ namespace Opc.Ua /// Reads an encodeable object from the stream. /// /// The encodeable object field name - /// The system type of the encopdeable object to be read + /// The system type of the encodeable object to be read /// The TypeId for the instance that will be read. /// An object that was read from the stream. public IEncodeable ReadEncodeable(string fieldName, System.Type systemType, ExpandedNodeId encodeableTypeId = null)