Skip to content

EDG-217 Houston: Bulk device tag browsing and import#1444

Open
marregui wants to merge 39 commits intomasterfrom
feature/EDG-217-houston-implementation
Open

EDG-217 Houston: Bulk device tag browsing and import#1444
marregui wants to merge 39 commits intomasterfrom
feature/EDG-217-houston-implementation

Conversation

@marregui
Copy link
Copy Markdown
Contributor

@marregui marregui commented Mar 12, 2026

Summary

Core implementation of the Houston feature (EDG-217) — bulk device tag browsing and import for OPC UA adapters. Enables customers with large OPC UA installations to browse the address space, export discovered nodes as CSV/JSON/YAML, edit in familiar tools (Excel, Python, curl), and import the result to atomically create tags and mappings.

REST endpoints

Two new endpoints under /api/v1/management/protocol-adapters/adapters/{adapterId}/device-tags/:

  • POST /browse — scans the OPC UA address space via a connected adapter, streams discovered variable nodes as a downloadable file (CSV, JSON, or YAML via Accept header). Results pre-sorted by nodePath. Response streamed via StreamingOutput — no byte[] buffered in memory.
  • POST /import — accepts an edited tag file, validates exhaustively, and atomically creates/updates/deletes tags and mappings. Five conflict-resolution modes: CREATE, DELETE, OVERWRITE, MERGE_SAFE (default), MERGE_OVERWRITE.

Architecture (~5,400 lines production code)

Layer Key Classes
REST API DeviceTagBrowsingApi (OAS3 annotations), DeviceTagBrowsingResourceImpl
Serialization DeviceTagCsvSerializer, DeviceTagJsonSerializer, DeviceTagYamlSerializer — each with streaming serialize(Iterable, OutputStream) overloads
Import engine DeviceTagImporter, DeviceTagImporterException
Validation DeviceTagValidator (15 error codes, exhaustive collection)
Domain model DeviceTagRow (21-field), ImportMode, ImportResult, TagAction, FieldMappingInstruction, ValidationError
OPC UA browser OpcUaNodeBrowser (async recursive browse + batch attribute read, pre-sorted output)
OPC UA integration OpcUaProtocolAdapter implements BulkTagBrowser

Key design decisions

  • File-based round-trip (browse → edit → import) for maximum tool compatibility
  • SDK/Core type split: BrowsedNode (SDK, 11 fields) → DeviceTagRow (Core, 21 fields)
  • Atomic imports — entire file validated before any mutation
  • nodeId-based tag identity (EDG-363) — renames detected as updates, not delete+create
  • Multi-mapping support (EDG-362) — multiple rows per nodeId create multiple northbound mappings
  • Wildcard resolution (* → default values) for templated imports
  • Memory-optimized streaming — lazy BrowsedNode→DeviceTagRow mapping via Iterable, element-at-a-time JsonGenerator writing. Peak memory at HTTP response: BrowsedNode[N] + O(1) instead of 3 full lists
  • Pre-sorted browse results in OpcUaNodeBrowser — eliminates re-sort in CSV serializer

Post-review fixes

  • EDG-356: prevent adapter crash on duplicate OPC UA node IDs
  • EDG-360: compare all northbound mapping fields in mappingsMatch
  • EDG-361: remove cross-adapter tag name uniqueness check
  • EDG-362: multi-mapping support (multiple northbound mappings per tag)
  • EDG-363: nodeId-based tag correlation instead of tagName
  • EDG-364: reorder CSV columns — editable fields first, device info last
  • EDG-354: OpenAPI 3.0 annotations

Dependencies added

commons-csv:1.12.0, jackson-dataformat-yaml (existing Jackson version)

Not included

  • Frontend/UI (future work)
  • Protocol adapters other than OPC UA (SDK interface ready for future adapters)

Related PRs

  • hivemq-edge-adapter-sdk: #56 — SDK interface
  • hivemq-edge-test: #321 — integration tests
  • hivemq-edge-composite: #137 — OpenAPI resource scan + License CI fix

Linear ticket

EDG-217 — Houston Implementation

Test plan

@marregui marregui self-assigned this Mar 12, 2026
@cla-bot cla-bot bot added the cla-signed label Mar 12, 2026
@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 12, 2026

Test Results

  514 files    514 suites   7m 23s ⏱️
4 543 tests 4 540 ✅ 3 💤 0 ❌
4 563 runs  4 560 ✅ 3 💤 0 ❌

Results for commit ca2e1a6.

♻️ This comment has been updated with latest results.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 12, 2026

Coverage Report

Overall Project 66.44% -0.91%
Files changed 71.34%

File Coverage
DeviceTagImporterException.java 100%
ValidationError.java 100%
BrowsedNode.java 100%
DeviceTagJsonSerializer.java 100%
DeviceTagYamlSerializer.java 100%
TagAction.java 100%
FieldMappingInstruction.java 100%
ImportResult.java 100%
ImportMode.java 100%
DeviceTagRow.java 99.35% -0.65%
PulseAgentAssetUtils.java 98.44%
DeviceTagCsvSerializer.java 98.4% -1.6%
DeviceTagValidator.java 89.7% -10.3%
DeviceTagImporter.java 89.23% -10.77%
ApiResourceRegistry.java 88.83%
ApiModule.java 88.75%
OpcUaSubscriptionLifecycleHandler.java 73.6% -12.16%
PulseAssetEntity.java 59.31%
OpcUaProtocolAdapter.java 55.01% -7.49%
OpcUaToJsonConverter.java 18.69% -1.66%
OpcUaNodeBrowser.java 9.67% -90.33%
DeviceTagBrowsingResourceImpl.java 4.13% -95.87%
BrowseException.java 0%
BulkTagBrowser.java 0%

@marregui marregui force-pushed the feature/EDG-217-houston-implementation branch from 76a9caa to 04f7db8 Compare March 13, 2026 08:12
@codepitbull codepitbull force-pushed the feature/EDG-217-houston-implementation branch from 04f7db8 to 71271e6 Compare March 20, 2026 16:46
@sonarqubecloud
Copy link
Copy Markdown

@marregui marregui force-pushed the feature/EDG-217-houston-implementation branch from 2b0a210 to 12be4a7 Compare March 24, 2026 14:28
@mschoenert-hivemq mschoenert-hivemq changed the title Houston: Bulk device tag browsing and import EDG-217 Houston: Bulk device tag browsing and import Mar 25, 2026
@marregui marregui force-pushed the feature/EDG-217-houston-implementation branch 4 times, most recently from ff39191 to 2a5314a Compare March 27, 2026 15:57
marregui added 15 commits March 30, 2026 11:11
Fixes SNYK-JAVA-COMFASTERXMLJACKSONCORE-15365924 (Allocation of
Resources Without Limits or Throttling, CVSSv3 8.7) in jackson-core.
Two assertions in DeviceTagImporterTest compared ValidationError.Code
enum values against String literals, causing AssertJ failures.
All assertions were comparing ValidationError.Code enum values against
String literals, causing type mismatch failures in AssertJ.
- Thread safety: CopyOnWriteArrayList for concurrent variable collection
- Back-pressure: Semaphore(32) gates concurrent browse requests
- DataTypeTree for type resolution instead of hardcoded switch
- AccessLevelType for access level decoding instead of bit masks
- AttributeId enum instead of magic integer constants
- Fix continuation point handling (single point, not list)
- Eliminate redundant double toNodeId resolution
- Batch attribute reads (DataType, AccessLevel, Description)
mschoenert-hivemq and others added 24 commits March 30, 2026 11:11
Switch import classification (edgeOnly/fileOnly/inBoth) from tagName to
nodeId in DeviceTagImporter and DeviceTagValidator. Renames (same nodeId,
different tagName) are now recognised as updates. Add tagName collision
check for MERGE_SAFE/MERGE_OVERWRITE modes.
Add merge functions to Collectors.toMap() calls in
OpcUaSubscriptionLifecycleHandler that previously threw
IllegalStateException on duplicate node IDs. The first tag wins and a
warning is logged. The import-time validation gap was already closed by
the EDG-363 nodeId-based classification change.
Extend mappingsMatch() to also check includeTagNames, includeTimestamp,
messageExpiryInterval, and mqttUserProperties. Previously only topic and
maxQos were compared, so changes to the other fields were silently
discarded even in OVERWRITE mode.
Move user-editable columns (tag_name, topics, QoS, etc.) before the
protocol-specific informational columns (node_path, namespace_uri, etc.)
so they appear first when opened in Excel. Import is unaffected since
deserialization uses named column lookup.
Tag names are scoped to their adapter. Remove the EDGE_TAG_CONFLICT
validation that rejected imported tags whose name existed on a different
adapter. Replace the test with one asserting the same name is allowed.
Relax duplicate tag name and node ID checks in DeviceTagValidator to
allow rows that share the same tag definition (nodeId + tagName +
description) but differ in northbound mapping fields. Each row produces
one northbound mapping; the tag is created once.

Change DeviceTagImporter to group file rows by nodeId and build one
NorthboundMappingEntity per row, one SouthboundMappingEntity per tag.
Edge-side NB/SB maps are now list-valued (groupingBy). The identical
check compares NB mapping sets rather than single entities.
Migrate DeviceTagBrowsingApi from Swagger v1 to OAS3 annotations
(@tag, @operation, @parameter, @apiresponse) with operationIds for
both browse and import endpoints.

Add Redocly split YAML: path specs for browse and import, plus
ImportResult and TagAction response schemas.

Add annotation unit tests verifying OAS3 metadata is present.
Pre-sort BrowsedNode results in OpcUaNodeBrowser so the CSV
serializer no longer re-sorts. Change streaming serialize overloads
to accept Iterable<DeviceTagRow> and use JsonGenerator for
element-at-a-time writing, avoiding intermediate List<RowDto>.

In the REST endpoint, replace the materialized List<DeviceTagRow>
with a lazy Iterable that maps BrowsedNode→DeviceTagRow on-the-fly
during serialization. Peak memory at the HTTP response phase drops
from ~3 full-size lists to ~1.
The method was named equals(Asset) which shadowed Object.equals and
required a @SuppressWarnings. Callers and tests already expected the
name matchesRemoteAsset. Also includes updated third-party licenses.
Align with SDK rename: rootNodeId → rootId in query parameter,
OpenAPI spec, REST implementation, and OPC UA adapter/browser.
Replace the hand-crafted DeviceTagBrowsingApi with the interface
generated by genJaxRs from the Redocly-bundled OpenAPI spec, aligning
Houston's device-tag browsing with the standard API pattern used by
all other REST endpoints in the codebase.

- Delete hand-crafted DeviceTagBrowsingApi.java (128 lines)
- Give device-tag endpoints their own "Device Tag Browsing" OpenAPI tag
  so genJaxRs generates a dedicated DeviceTagBrowsingApi interface
  instead of adding methods to ProtocolAdaptersApi
- Adapt DeviceTagBrowsingResourceImpl to the generated method signatures:
  browse() -> browseDeviceTags(), Accept header via AbstractApi.headers
  importTags() -> importDeviceTags(), body as File, Content-Type via headers
- Use PLACEHOLDER_HIVEMQ_VERSION in Redocly source for Gradle version stamping
- Regenerate ext/hivemq-edge-openapi.yaml via Redocly bundle (71 paths)
Addresses Martin's review feedback on EDG-217: remove browse types
from the adapter SDK and keep them internal until the right
abstractions emerge from a second protocol implementation.

- BulkTagBrowser, BrowsedNode, BrowseException moved to
  com.hivemq.edge.adapters.browse inside the OPC UA module
  (neutral package, visible to core via existing compileOnly)
- BulkTagBrowser.browse() now returns Stream<BrowsedNode>
- OpcUaNodeBrowser sorts DiscoveredVariable by path early, then
  lazily batch-reads attributes via a Spliterator — only one batch
  of BrowsedNode (100 nodes) is alive at any time
- DeviceTagBrowsingResourceImpl consumes the stream directly,
  with UncheckedBrowseException handling for mid-stream failures
BulkTagBrowser, BrowsedNode, and BrowseException must live in core
because DeviceTagBrowsingResourceImpl references them at class-load
time, and modules are loaded after core initializes.  Placing them
in the OPC UA module caused ClassNotFoundException at runtime.

- Move 3 files to hivemq-edge/src/main/java/.../browse/
- Remove compileOnly("com.hivemq:hivemq-edge-module-opcua") from core
- Add compileOnly("com.hivemq:hivemq-edge") to OPC UA module
- Package stays com.hivemq.edge.adapters.browse (no import changes)
Stress testing the device-tag browse/import APIs with concurrent
workers uncovered several issues that are addressed here:

- Browse dedup: track visited NodeIds during OPC UA address space
  traversal to eliminate duplicates from multi-path graph references
- Import atomicity: synchronize the read-compute-write cycle in
  DeviceTagImporter on the ProtocolAdapterExtractor lock to prevent
  TOCTOU races between concurrent OVERWRITE imports
- Debounced restart: coalesce rapid config notifications (500ms) so
  multiple sequential imports trigger a single adapter restart
- Browse fallback: maintain a browseClient snapshot in OpcUaProtocol-
  Adapter so browse operations survive adapter restarts
- Partial subscription tolerance: adapter stays CONNECTED when some
  monitored items fail (e.g. non-existent nodes), only goes to ERROR
  on total failure
- DynamicEnumType converter: explicit JSON serialization for OPC UA
  dynamic enum types, eliminating fallback warnings
- Skip empty payloads: guard against null/empty OPC UA values reaching
  the Jackson deserializer in NorthboundTagConsumer
- Browse log level: downgrade transient browse failures during adapter
  restart from ERROR to WARN
The debounce on notifyConsumer() was applied to all config change
paths (addAdapter, deleteAdapter, updateConfig, registerConsumer),
causing adapter creation/deletion to be delayed by 500ms. Tests that
create adapters and immediately list them saw 0 adapters.

Split into notifyConsumer() (immediate, original behavior) and
notifyConsumerDebounced() (500ms delay). Only updateAdapter() uses
the debounced path — this is the import codepath where rapid
sequential calls cause adapter restart storms.
The debounce on notifyConsumer/updateAdapter caused CI failures:
- AdapterApiIT: adapter creation immediate but refresh delayed,
  listing saw 0 adapters
- DeviceTagEndToEndIT: browse on adapter-b returned 409/500 because
  adapter wasn't restarted yet after import
- AdapterApiIT test_list_adapter_update: getAdapter() reads from
  the running wrapper (not allConfigs), returned stale config

The import atomicity (synchronized on adapterExtractor in doImport)
already prevents the TOCTOU race. The restart storm from concurrent
imports is an optimization to revisit separately — it requires the
debounce to live in DeviceTagImporter with a quiet updateAdapter
variant, plus test updates.
@marregui marregui force-pushed the feature/EDG-217-houston-implementation branch from 66ccfaa to ca2e1a6 Compare March 30, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants