Skip to content

Server Historical Access

Kevin Herron edited this page Oct 12, 2026 · 10 revisions

Milo 1.2.0-RC1, Java 17. API baseline 69cb25087804.

Clients read stored values with HistoryRead and change stored history with HistoryUpdate. Milo decodes and routes both services to your address space, but the storage and the query semantics are yours to implement. This page shows how to build a history provider, including the continuation-point releases that the default dispatch does not forward to it.

The thermostat from Your first server keeps no history, so the samples here use a generic raw-history provider. The client side of the same workflow is in Client historical access.

What the SDK supplies

The SDK supplies HistoryRead and HistoryUpdate message types, service dispatch, and address-space hooks. It does not include a historian. The default AddressSpace.historyRead(...) and historyUpdate(...) return Bad_HistoryOperationUnsupported for every operation. Setting Historizing=true or a history access bit does not create storage or enable request modes.

Before you write a provider, start with a registered address space and a storage contract that defines:

  • which nodes record values or events
  • retention
  • ordering, timestamps, and quality
  • supported read modes
  • whether history can change

Advertise only capabilities the provider implements. Sampling the current Value is not durable history collection, so the application decides when and how to store samples.

How history requests work

A HistoryRead request carries one details object, which selects the request mode, and a list of operations, each a HistoryReadValueId that names one node. The provider returns one HistoryReadResult per operation, in order. Each result carries its own operation status and a body whose type depends on the requested function. OPC 10000-11 §6.5.1 defines which body each function returns.

Requested function Result body
Raw, processed, or at-time data HistoryData
Modified data HistoryModifiedData
Events HistoryEvent
Modified events (ReadEventDetails2 with readModified=true) HistoryModifiedEvent
Annotations (ReadAnnotationDataDetails) Annotation

History results can be large, so the provider returns them in pages. When more results remain, it returns an opaque continuation point with the page, and the client sends that token back to get the next page. A continuation point is server state owned by one Session, so a client that stops early releases its points by sending HistoryRead with releaseContinuationPoints=true. That flag needs special handling, described in Handle explicit releases.

HistoryUpdate follows the same per-operation pattern, with decoded update details instead of read details.

Implement a provider

A provider is the address space, usually a managed namespace, that answers history operations from your storage. The sample source splits it in two. RawHistoryNamespace holds the provider's continuation points, each owned by one Session and node. RawHistoryService handles explicit releases at the service boundary, as described in Handle explicit releases. Keep this service and namespace split in a real provider.

The sample's RawHistoryNamespace only records and releases continuation points. A complete provider also overrides historyRead to page its stored values and saves each continuation point it returns. It drops a Session's points when that Session closes, bounds how many points each Session holds, and clears its state during server-owned shutdown.

Read modes

The HistoryRead hook receives HistoryReadContext, decoded HistoryReadDetails, requested timestamps, and a list of HistoryReadValueIds. Before accepting a mode, define its time-bound handling, forward and reverse ordering, bounds, equal timestamps, page size, and IndexRange support.

Keep the stored status and source timestamp. A Good history operation can contain a bad historical sample, so do not turn a recorded Bad_SensorFailure into Good because the database read succeeded.

Ignore HistoryReadValueId.dataEncoding. OPC 10000-4 §5.11.3.2 marks the parameter as no longer used. Clients supply null or empty, and servers ignore it.

Timestamps

Data reads require Source, Server, or Both timestamps. TimestampsToReturn.Neither is invalid for them and requires Bad_TimestampsToReturnInvalid, while event reads ignore this parameter. Milo's default dispatch rejects only a missing timestamp setting and passes Neither to the provider. A per-operation error from the namespace hook cannot supply that required service result, so validate the timestamp setting at the service boundary. See timestamp rules and HistoryRead service results.

Return the timestamps the client selected. Milo passes the provider's encoded history body through unchanged, so the provider applies TimestampsToReturn. If storage cannot supply a requested timestamp, return Bad_TimestampNotSupported for the operation or request instead of inventing one. For example, storage may keep only source timestamps when the client asks for Server or Both. See OPC 10000-11 §4.5 and §6.5.3.2.

Continuation points

For paged reads, return an opaque ByteString continuation point when more results remain. Bind it to the requesting Session and query, and validate each use. Bound the number of points and the memory and storage they hold. Do not expose a database cursor or trusted offset directly to clients.

Keep a point valid until it is consumed, explicitly released, or reclaimed for a new request, or until its Session closes. Do not invalidate it just because it is idle. You may close an idle database cursor only if the token can still resume the query. Return Bad_ContinuationPointInvalid for unknown, reclaimed, mismatched, or already released tokens.

The provider must enforce a consistent per-Session limit that matches the advertised MaxHistoryContinuationPoints (see Limits). When a new request needs capacity, reclaim the oldest continuation point from that Session's prior requests, as HistoryRead requires. Later use of the reclaimed token returns Bad_ContinuationPointInvalid. Never reclaim points created for the current response. Once the request uses the per-Session maximum, return Bad_NoContinuationPoints for remaining operations. A separate global resource bound may refuse a new allocation when the Session has nothing to reclaim.

Continuing a previously halted read must not return Bad_NoContinuationPoints. Reuse the capacity of the point it supplies. See OPC 10000-4 §7.9.

Updates

HistoryUpdate receives decoded update details. Implement only the insert, replace, update, delete, and event operations you support, including authorization and per-operation outcomes. Distinguish unsupported modes from missing nodes, invalid parameters, absent data, and storage failures. Transactional database policy is application work. A Good service header does not mean every update succeeded.

Production concerns

Test every mode and external storage failure your application accepts. A production provider also needs:

  • retention
  • concurrent query and update behavior
  • consistent snapshot semantics
  • crash recovery
  • Session cleanup
  • database-specific resource handling

Handle explicit releases

At this source baseline, DefaultAttributeServiceSet does not forward HistoryReadRequest.releaseContinuationPoints to AddressSpace.historyRead(...), and HistoryReadContext has no release flag. A namespace override therefore cannot tell a continuation read from an explicit release. If you expose continuation points, handle release at the full AttributeServiceSet request boundary, because a release request must not silently consume another page.

The client historical-access example releases unfinished reads. A server that implements only the namespace hook must not claim to support that workflow.

Install an attribute service set

You can install a custom AttributeServiceSet in two ways, and either way it must preserve Session lookup, diagnostics, routing, and applicable service checks.

The first is server.addServiceSet(path, attributeServiceSet), called after constructing the server and before startup. Normalize the path with EndpointUtil.getPath(endpoint.getEndpointUrl()), as the dispatcher does. The call replaces all four attribute services (Read, Write, HistoryRead, and HistoryUpdate) on that path, so inherit their defaults unless you intend to replace them.

The second is to override ServiceSets.createAttributeServiceSet(OpcUaServer) and pass the factory to OpcUaServer(config, transportFactory, serviceSets). Factories run before standard type initialization and before the Session manager, access-control manager, and two server namespaces exist. Do not create namespaces or use server managers there. Keep the service the factory returns, then supply its provider namespace after server construction and before startup. Registering a namespace early can take index 1, which is reserved for the server's ApplicationUri.

The sample's RawHistoryService constructs its namespace, so it uses addServiceSet(...) after server construction. This helper registers the namespace as a lifecycle participant and installs the service on every endpoint path except discovery paths. Call it before starting the server. It uses OpcUaServer from org.eclipse.milo.opcua.sdk.server and EndpointUtil from org.eclipse.milo.opcua.stack.core.util. The complete RawHistoryService and RawHistoryNamespace classes are in the linked sample source.

static RawHistoryService installHistoryProvider(OpcUaServer server) {
  var history = new RawHistoryService(server);
  server.addLifecycleParticipant(history.namespace);
  server.getConfig().getEndpoints().stream()
      .map(endpoint -> EndpointUtil.getPath(endpoint.getEndpointUrl()))
      .filter(path -> !path.endsWith("/discovery"))
      .distinct()
      .forEach(path -> server.addServiceSet(path, history));
  return history;
}

Answer the release in the service

RawHistoryService extends DefaultAttributeServiceSet and holds server and namespace fields. Its onHistoryRead override passes every request without the release flag to super.onHistoryRead, which keeps Session lookup, the MaxNodesPerRead and missing-timestamp checks, namespace routing, diagnostics, and counters. For a release request, it asks the namespace to release each point.

The override requires these imports:

  • OpcUaServer, Session, and DiagnosticsContext from org.eclipse.milo.opcua.sdk.server.
  • DefaultAttributeServiceSet from org.eclipse.milo.opcua.sdk.server.servicesets.impl.
  • ServiceRequestContext from org.eclipse.milo.opcua.stack.transport.server.
  • HistoryReadRequest, HistoryReadResponse, HistoryReadValueId, and HistoryReadResult from org.eclipse.milo.opcua.stack.core.types.structured.
  • UaException and StatusCodes from org.eclipse.milo.opcua.stack.core.
@Override
public HistoryReadResponse onHistoryRead(
    ServiceRequestContext context, HistoryReadRequest request) throws UaException {
  if (!request.getReleaseContinuationPoints()) {
    return super.onHistoryRead(context, request);
  }

  Session session = server.getSessionManager().getSession(context, request.getRequestHeader());
  try {
    HistoryReadValueId[] nodes = request.getNodesToRead();
    if (nodes == null || nodes.length == 0) {
      throw new UaException(StatusCodes.Bad_NothingToDo);
    }
    long maxNodesPerRead = server.getConfig().getLimits().getMaxNodesPerRead().longValue();
    if (nodes.length > maxNodesPerRead) {
      throw new UaException(StatusCodes.Bad_TooManyOperations);
    }
    if (request.getTimestampsToReturn() == null) {
      throw new UaException(StatusCodes.Bad_TimestampsToReturnInvalid);
    }

    HistoryReadResult[] results = new HistoryReadResult[nodes.length];
    for (int i = 0; i < nodes.length; i++) {
      results[i] = namespace.release(session, nodes[i]);
    }

    var diagnostics = new DiagnosticsContext<HistoryReadValueId>();
    return new HistoryReadResponse(
        createResponseHeader(request), results, diagnostics.getDiagnosticInfos(nodes));
  } catch (UaException e) {
    session.getSessionDiagnostics().getHistoryReadCount().incrementErrorCount();
    session.getSessionDiagnostics().getTotalRequestCount().incrementErrorCount();
    throw e;
  } finally {
    session.getSessionDiagnostics().getHistoryReadCount().incrementTotalCount();
    session.getSessionDiagnostics().getTotalRequestCount().incrementTotalCount();
  }
}

The release branch repeats Session lookup and the empty-request, MaxNodesPerRead, and missing-timestamp checks. It validates Session-bound tokens and counts service success or failure. It does not add the history-specific operation limits or reject Neither at the service level. If your provider relies on those checks, add them to this override.

The namespace's synchronized release checks node identity and Session ownership before removing a cursor. Unknown, cross-Session, and already released tokens return Bad_ContinuationPointInvalid without consuming another Session's cursor. The namespace's registry holds only this provider's tokens, so an application with several history providers needs an explicit release dispatcher for all their registries.

Limits

Milo advertises the history limits from OpcUaServerConfigLimits, but its default dispatch checks only the general Read and Write limits. To change a value, override its method in the limits passed to OpcUaServerConfigBuilder.setLimits(...).

Limit Default Who enforces it
getMaxHistoryContinuationPoints(), advertised as ServerCapabilities.MaxHistoryContinuationPoints 250 per Session The provider, as a consistent per-Session limit, plus any global bound it needs for memory.
MaxNodesPerHistoryReadData, MaxNodesPerHistoryReadEvents 250 operations each Advertised only. The default dispatch does not check them.
MaxNodesPerHistoryUpdateData, MaxNodesPerHistoryUpdateEvents 250 operations each Advertised only. The default dispatch does not check them.
MaxNodesPerRead 10,000 The default dispatch checks HistoryRead against it.
MaxNodesPerWrite 10,000 The default dispatch checks HistoryUpdate against it.

A provider relying on the advertised history limits must enforce them at the service boundary to return Bad_TooManyOperations for the request. Alternatively, configure the advertised history limits and the matching Read and Write limits consistently. See OperationLimitsType.

Check access

Milo's default HistoryRead and HistoryUpdate dispatch does not consult the AccessController. It enforces no HistoryRead or HistoryWrite access level, UserAccessLevel, role permission, or AccessRestrictions. Each provider must check them at the provider or service boundary, using the request's current Session and intended history operation. Check even when continuation state already exists, and recheck changes that should revoke a running query. History-read and history-write capability bits describe support, and they do not replace a policy. See Access control for how Sessions, roles, and effective attributes fit together.

When things go wrong

Clients inspect both the service-level failure and each operation result, so make both meaningful. Empty data, unsupported mode, invalid cursor, denied history, and unavailable storage should not all collapse into the same Good, empty response. Keep diagnostics that help operators, but do not expose SQL, credentials, or other users' data.

Status Level When it applies
Bad_HistoryOperationUnsupported Operation The default address-space hooks return it for every operation, which is what clients see from a server without a provider.
Bad_TimestampsToReturnInvalid Service A data read requests Neither, or the request has no timestamp setting. Only the service boundary can return it.
Bad_TimestampNotSupported Operation or request Storage cannot supply a requested timestamp.
Bad_ContinuationPointInvalid Operation The token is unknown, reclaimed, mismatched, or already released.
Bad_NoContinuationPoints Operation The request already uses the per-Session maximum. Never return it for continuing a halted read.
Bad_TooManyOperations Service The request exceeds an operation limit checked at the service boundary.

Cleanup and shutdown

The provider lifecycle owns database pools, statement and cursor lifetimes, backing-resource maintenance, and Session listeners. Idle-resource cleanup must preserve valid continuation tokens.

Register a SessionListener with server.getSessionManager().addSessionListener(...) and release that Session's state in onSessionClosed(Session). Callbacks are asynchronous and can be skipped during shutdown, so also check Session.isClosed() before allocating state.

During shutdown, the provider should:

  • stop accepting work
  • settle or cancel pending operations
  • clear all provider state, including cursors
  • remove listeners, including the Session listener with removeSessionListener(...)
  • close owned storage resources

Do not rely on a disconnected client to release its cursors.

Examples and reference

Next steps

This completes the server guide. To have a server open the TCP connection to a listening client instead of waiting for clients to connect, continue with Reverse Connect, the first of the feature guides, or return to the Server SDK overview.

Related: Client historical access · Capabilities and limitations · Configuration

Clone this wiki locally