Repository navigation
Server Sampling
Milo 1.2.0-SNAPSHOT, Java 17. API baseline 7faaf4f0516c.
When a client subscribes to Setpoint, something on the server has to read Setpoint on a schedule and hand each change to the subscription. That work is sampling. This page explains how Milo's sampling framework does it, how to set its rates, and how to read a device in batches when values live on hardware instead of in nodes.
Types and instantiation built the server's structure, and Data access covers how values get into its nodes. This page assumes a managed namespace such as the tutorial's from Your first server.
Two clocks shape what a subscribed client sees. A MonitoredItem's sampling interval controls when the server obtains its value, and the subscription's publishing interval controls when queued notifications are sent. Queue size, monitoring mode, deadband, and data-change filters also affect the result. A short requested interval is therefore not a promise about device latency or end-to-end delivery, and clients must use the revised intervals and operation results the server returns.
Every ManagedAddressSpace owns a SamplingManager, which groups items by interval and collects their values. ManagedNamespaceWithLifecycle, ManagedAddressSpaceWithLifecycle, and ManagedAddressSpaceFragmentWithLifecycle start the manager after their own startup tasks and stop it first at shutdown. A subclass of plain ManagedNamespace or ManagedAddressSpace must call getSamplingManager().startup() and shutdown() itself. Otherwise its MonitoredItems can be created with Good status and never sampled.
For ordinary Variables, leave the inherited data-item callbacks in place. The default AddressSpaceSamplingGroup then reads through the address space and filters, groups work by Session, splits reads at MaxNodesPerRead, and honors requested timestamps.
The tutorial server needs nothing more. Its namespace extends ManagedNamespaceWithLifecycle, so the manager starts and stops with the server, and Temperature and Setpoint keep their values in the nodes. When a client writes Setpoint, the next sample reads the new stored value, which is why a subscription on Setpoint reports each write without any sampling code.
Each namespace configures its own inherited manager by overriding samplingManagerConfig(). Import SamplingManagerConfig and ReadAccessPolicy from org.eclipse.milo.opcua.sdk.server.sampling. This example sets a 100 ms floor and cached authorization, which requires application invalidation as described in Read access while sampling.
@Override
protected SamplingManagerConfig samplingManagerConfig() {
return SamplingManagerConfig.defaults()
.withMinimumIntervalMillis(100)
.withReadAccessPolicy(ReadAccessPolicy.cached());
}The manager rounds a requested interval up to a whole millisecond, raises it to the minimum, and then rounds it up to the next bucket. The default bucket size is 25 ms, and a bucket size of zero disables bucketing. With this configuration, a 50 ms request is revised to 100 ms. With unmodified defaults the minimum is 1 ms, but bucketing normally makes the fastest periodic group 25 ms.
The client must be told the revised interval, so the managed namespace returns the manager's revision in each item's create and modify results. Configure the inherited manager rather than creating a second one beside it, which would sample at intervals other than the ones the client is told. A Variable's MinimumSamplingInterval and the server limits apply before the namespace revision hook. With the Variable default of -1, a zero request can be revised to the publishing interval first, so set a Variable's minimum deliberately when you rely on fastest-rate behavior.
New items get a debounced initial sample. The default delay is 100 ms, and the maximum collection window is 500 ms. These values bound batching, not total first-value latency, because slow I/O or a busy worker can delay delivery.
The tutorial's values live in its nodes, but a real thermostat's temperature lives on the device. There are two ways to bring it to subscribed clients.
The first is to have the application poll the device or receive its readings and store each one in the node with setValue(...), as the alarm feed in Alarms and Conditions does. The default group then samples the stored value, and the node stays current even when no client is subscribed, which an alarm needs.
The second is a custom SamplingGroup that reads the device only for the items clients monitor. Use it when one device request can supply many items, so a single request per sampling turn serves the whole group and device traffic follows client demand. A group exists only while it has items, and the manager shuts down a group left empty, so this approach cannot by itself feed an alarm that must evaluate when nobody is subscribed. Keep a separate feed for that.
Override samplingGroupFactory() to return a factory that creates your SamplingGroup subclass. onItemsChanged(List<DataItem>) receives the full membership and is where to rebuild a register plan. sample(List<DataItem>) receives the permitted items for this turn, which may be only newly added items. Permission changes do not call onItemsChanged().
The group serializes sample() and onItemsChanged(), but the item-added and item-removed hooks can overlap a sample. Several groups can access the same device at once, so the driver owns device-level serialization.
This group calls a synchronous readBatch adapter once per turn with the distinct NodeIds. The adapter supplies scalar Values and must complete within a bounded time. It must return a non-null map with no null values and preserve source quality and timestamps.
static final class BatchGroup extends SamplingGroup {
private final Function<List<NodeId>, Map<NodeId, DataValue>> readBatch;
BatchGroup(
OpcUaServer server,
long intervalMillis,
Function<List<NodeId>, Map<NodeId, DataValue>> readBatch) {
super(server, intervalMillis);
this.readBatch = readBatch;
setRequestCount(1);
}
@Override
protected @Nullable CompletionStage<@Nullable Void> sample(List<DataItem> items) {
List<NodeId> nodeIds =
items.stream().map(item -> item.getReadValueId().getNodeId()).distinct().toList();
Map<NodeId, DataValue> values;
try {
values = readBatch.apply(nodeIds);
} catch (RuntimeException e) {
for (DataItem item : items) {
deliver(item, new DataValue(StatusCodes.Bad_CommunicationError));
}
return null;
}
for (DataItem item : items) {
NodeId nodeId = item.getReadValueId().getNodeId();
DataValue value = values.getOrDefault(nodeId, new DataValue(StatusCodes.Bad_NoData));
deliver(item, value);
}
return null;
}
}A missing entry becomes Bad_NoData, and an adapter failure produces Bad_CommunicationError for each item. In a production driver, choose a status that describes the device condition. The larger DeviceSamplingGroup example uses Bad_CommunicationError for a missing register value. deliver(item, value) applies timestamp selection and delivers to the item.
In the owning namespace, install the group through the factory. readBatch is the application-owned adapter used above.
@Override
protected SamplingGroupFactory samplingGroupFactory() {
return (server, intervalMillis) -> new BatchGroup(server, intervalMillis, readBatch);
}The group and factory need these imports:
-
SamplingGroupandSamplingGroupFactoryfromorg.eclipse.milo.opcua.sdk.server.sampling -
OpcUaServerfromorg.eclipse.milo.opcua.sdk.server -
DataItemfromorg.eclipse.milo.opcua.sdk.server.items -
StatusCodesfromorg.eclipse.milo.opcua.stack.core -
NodeIdandDataValuefromorg.eclipse.milo.opcua.stack.core.types.builtin -
java.util.List,java.util.Map,java.util.function.Function, andjava.util.concurrent.CompletionStage org.jspecify.annotations.Nullable
When three monitored Values share one group, one adapter call reads all three NodeIds. Deleting their subscription removes the group.
The DeviceSamplingGroup source example is a larger implementation backed by an in-memory example device. A real driver must also map NodeIds and attributes to device addresses, bound its requests, and return a CompletionStage that completes only after delivery or failure for the turn.
The inherited manager sends the group data items for every monitored attribute, not only Value. The fragment does not check the attribute, so it wrongly answers a non-Value item, such as DisplayName, with the device value for that NodeId. Where clients can monitor other attributes, partition the items inside sample() as DeviceSamplingGroup does. Deliver device results only to Value items, and read the other attributes through the address space for each Session. The source example groups those reads by Session and calls AddressSpace.read(...) with that Session's ReadContext.
The fragment and DeviceSamplingGroup both ignore each Value item's IndexRange, while the default group applies the range through the full ReadValueId. A driver serving these requests must apply it with NumericRange.parse(...) and NumericRange.readFromValueAtRange(...) from org.eclipse.milo.opcua.sdk.core, and deliver the resulting status on failure. For a Matrix, apply the range to matrix.nestedArrayValue() and wrap a multidimensional result in a new Matrix, as AttributeReader does. Without this, an IndexRange such as "0" on a Double can wrongly return the whole value as Good instead of Bad_IndexRangeNoData.
When IndexRange is absent, Milo's default read path still applies a requested DataEncoding to structure values, although OPC 10000-4 §7.28 says servers ignore this unused parameter. The fragment and DeviceSamplingGroup do not reproduce that behavior, so use the address-space read path when clients rely on it.
Direct device sampling also bypasses a Variable's Value filter. Apply equivalent per-Session value transformations, or use the default group where those transformations matter.
A stage that never completes stalls its group. Exceptions are logged and later cycles can continue, but an exception alone does not publish bad quality, so deliver a bad DataValue explicitly for a communication failure. The driver owns timeouts and cancellation, including at shutdown.
Sampling delivers values according to each Session's current read permission, so the manager tracks that permission. The default ReadAccessPolicy.perCycle() refreshes each Session's access before collection. The cached policy stores grants and denials with no expiry and needs invalidateReadAccess(...) whenever application permissions change.
A direct Read reflects a permission change at once, but a MonitoredItem keeps its cached grant or denial until invalidation and the next refresh. Access control explains what invalidation does not do, such as waking the sampler or purging queued values, and which failures keep the prior decision.
A push-based source can own items outside the framework. Override the inherited callbacks so each item has exactly one producer. Override reviseSamplingInterval(ReadValueId, double) when the source's supported intervals differ from the manager's. A zero interval can describe report-by-exception delivery, but the periodic SamplingManager does not implement that mode, so route those items to your own push implementation. Configure Variable and server minimum rates consistently.
When an item moves from push to periodic delivery, add it with onDataItemsCreated(), because modifying an item the manager never owned does not add it. When an item moves the other way, remove it with onDataItemsDeleted().
An external producer must also refresh stored permissions, even when no device value changes. Track item creation, deletion, monitoring-mode changes, transfers, and application invalidations. SDK data-item and access-invalidation listeners are synchronous, so keep them short. Session listeners are asynchronous.
To refresh an item, snapshot its current Session and getAccessEpoch(), get a decision through the controller or the managed cache, then call setReadAccessResult(result, session, accessEpoch). Serialize or version application refresh tasks so an old task cannot overwrite a newer application decision. Refresh before re-enabled items resume delivery. Do not run a competing refresher beside framework sampling for the same items.
If samples arrive twice, look for an inherited producer running alongside a legacy or custom one. If samples stop, check these causes before changing thread counts:
- a manager that was never started
- stages that never complete
- monitoring mode set to Disabled
- denied access
- blocked filters
An overrun warning in the log is a diagnostic. It does not cancel the turn or guarantee a deadline.
SubscriptionModel is a deprecated adapter over SamplingManager with an unbucketed legacy configuration. In an ordinary managed namespace, remove its field, lifecycle registration, and four forwarding callbacks, and the inherited manager takes over. Keep unrelated callback work and call super where needed. After migration, requests may be revised to bucket boundaries.
A direct AddressSpace has no inherited manager. It must own one explicitly, forward callbacks to it, start and stop it, and report the same interval revision the manager uses.
A sampling manager's lifecycle is one-shot. Before releasing a device, stop new work, stop the manager through its owner, and cancel or settle driver requests. The framework cannot cancel an arbitrary external transport, so the driver must.
The thermostat so far runs without security. To give the server an application certificate, trust clients, and offer secured endpoints, continue with Server security.
Related: Server SDK · Data access · Access control
User guide for Milo 1.2.0-SNAPSHOT at source 7faaf4f05, 2026-10-04. Snapshot builds can change after this baseline. Home · Getting started · Examples · Troubleshooting · Release notes
- Home
- Start here
- Client guide
- Server guide
- Feature guides
- Reference
- Operations
- Examples
- Release notes
- Contributing