Skip to content

fix(plc4j/ads): cap sum-command item count, configurably - #2714

Open
LivingLikeKrillin wants to merge 1 commit into
apache:developfrom
LivingLikeKrillin:feature/ads-sum-cap
Open

LivingLikeKrillin wants to merge 1 commit into
apache:developfrom
LivingLikeKrillin:feature/ads-sum-cap

Conversation

@LivingLikeKrillin

@LivingLikeKrillin LivingLikeKrillin commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

What

multiRead and multiWrite in the ADS driver previously packed every tag they sent (those that resolved and serialized) into a single ADS sum command (ADSIGRP_MULTIPLE_READ / ADSIGRP_MULTIPLE_WRITE) with no bound on the number of sub-commands.

Now, items are split into consecutive groups of at most max-sum-command-items. The groups are sent sequentially, and per-group results are merged into one response. Requests within the cap produce the same sum command as before: same items, same order, same payload.

max-sum-command-items is added as an AdsConfiguration parameter (default 500). onConnect rejects values below 1 with a PlcConnectionException; otherwise an unusable value would only surface later as an unchecked exception out of read().

Why 500

Beckhoff's TwinCAT 3 ADS-DLL C++ manual (v1.1.3, section "ADS-sum command: Read or Write a list of variables with one single ADS-command"):

Number of Sub-ADS calls: Highly recommended to max. 500! If the PLC is processing one ADS
request, it will completely work on this single ADS request BEFORE starting next PLC cycle. […]
So this large number of ads-sub-commands will jitter the PLC execution!

Behaviour changes

  • With n tags to send, requests above the cap become ceil(n/cap) sequential round trips; each gets a fresh request timeout and there is no request-wide deadline.
  • A group-level ReturnCode != OK fails only that group's tags; other groups return their real results.
  • An outright group failure (send failure, timeout) fails the whole request and remaining groups are not sent. For writes, groups already sent or timed out may have taken effect on the device; nothing is rolled back.
  • With a single group, a group-level failure still fails all of that group's tags as before, except an unparseable sum read response no longer overwrites tags that already failed to resolve with INTERNAL_ERROR.
  • Groups are chained with thenComposeAsync so the next group is not built and written from the receive-thread callback. Consequently, even a request that takes the sum path with a single group now builds and sends that sum command through the common pool instead of on the calling thread. Requests left with one tag after resolution are unaffected; they still take the separate non-sum path.

Testing

  • AdsDriverIT (9 cases) gains three cases (cap=2, 3 direct BOOL addresses):
    • Split read: two ADSIGRP_MULTIPLE_READ requests on the wire (two sub-commands, then one) and merged values.
    • Split write: two ADSIGRP_MULTIPLE_WRITE requests, per-group payload bytes, and merged response codes.
    • One failing group (read): device answers the second sum command with ADSERR_DEVICE_SYMBOLNOTFOUND; first group tags keep values, failing group tag carries INVALID_ADDRESS.
  • I checked that the write and isolation cases fail when code is broken: discarding per-group write codes fails all three tags; removing the existing ADSERR_DEVICE_SYMBOLNOTFOUND → INVALID_ADDRESS mapping fails the isolation case.
  • Unit tests: AdsSumChunkTest (10 cases for chunk(), including a cap of Integer.MAX_VALUE), AdsSumMergeTest (5 cases over the two merge helpers; each tag keeps its own value or code, checked by keyed lookup), and AdsConfigurationTest (default 500, can be lowered).
  • plc4j/drivers/ads: 217 unit tests and 9 IT cases, 0 failures.
  • plc4go: the multi-tag paths in Reader.go and Writer.go send one sum command for the whole request and have no equivalent parameter, so the three new cases are skipped via WithSkippedTestCases. go test -run TestAdsDriver passes.

Not in this PR

  • The manual's other two requirements: "Version of target ADS Device" is a device capability, not a client-side limit; "Bytes length of requested data" (fitting through the AMS router) is not budgeted; the item cap bounds it only indirectly, so a group of large tags is not covered (separate follow-up).
  • Symbol-handle resolution on the subscription path (resolveMultipleSymbolicAddresses) issues its own uncapped sum (ADSIGRP_MULTIPLE_READ_WRITE over ADSIGRP_SYM_HNDBYNAME sub-items). Reads and writes do not go through it and resolve from locally cached tables (separate change).

@sruehl
sruehl requested a balanced review from Copilot August 26, 2026 10:20
@sruehl

sruehl commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

"I could not pin that to a
single citable page, so treat it as a conservative default "... So basically hallucination?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Caps ADS sum-command (multiRead/multiWrite) sub-command count to avoid oversized frames by chunking requests into groups of up to 500 items, sending groups sequentially, and merging results back into the original tag order.

Changes:

  • Add MAX_SUM_COMMAND_ITEMS = 500 and a chunk() helper to partition sum-command items.
  • Update multiRead / multiWrite to execute chunked sum-commands sequentially and merge per-group results.
  • Add unit tests covering chunk partitioning behavior (AdsSumChunkTest).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/AdsTcpConnection.java Introduces sum-command item cap, chunking helper, and chunked multiRead/multiWrite execution/merge logic.
plc4j/drivers/ads/src/test/java/org/apache/plc4x/java/ads/AdsSumChunkTest.java Adds unit tests validating AdsTcpConnection.chunk() partitioning and order preservation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +624 to +632
CompletableFuture<Map<String, PlcResponseItem<PlcValue>>> merged =
CompletableFuture.completedFuture(new LinkedHashMap<>());
for (List<String> groupNames : chunk(orderedNames, MAX_SUM_COMMAND_ITEMS)) {
merged = merged.thenComposeAsync(accumulated ->
multiReadChunk(groupNames, resolved).thenApply(groupValues -> {
accumulated.putAll(groupValues);
return accumulated;
}));
}

@LivingLikeKrillin LivingLikeKrillin Aug 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no executor in the SPI to pass, and no call site in plc4j passes one to an *Async method today. Modbus, SLMP and both no-message-router paths in EipTcpConnection chain the same way on develop.

The hop itself is deliberate: the previous group's stage completes on the receive thread, and the next group should not be built and written from that callback.

Edited 2026-10-19: Corrected "both MSP paths" to "both no-message-router paths".

Comment on lines +626 to +632
for (List<String> groupNames : chunk(orderedNames, MAX_SUM_COMMAND_ITEMS)) {
merged = merged.thenComposeAsync(accumulated ->
multiReadChunk(groupNames, resolved).thenApply(groupValues -> {
accumulated.putAll(groupValues);
return accumulated;
}));
}
@LivingLikeKrillin

Copy link
Copy Markdown
Contributor Author

It's in Beckhoff's documentation. I didn't find the page before opening this, which is on me.

"Number of Sub-ADS calls: Highly recommended to max. 500!"

"We highly recommend to not request more than 500 Ads-Sub commands."

https://infosys.beckhoff.com/content/1033/tc3_adsdll2/124835083.html - "ADS-sum command: Read or Write a list of variables with one single ADS-command", the page for ADSIGRP_SUMUP_READ 0xF080 / ADSIGRP_SUMUP_WRITE 0xF081.

I drafted the description with LLM help and reviewed it myself, but I didn't catch that it claimed Beckhoff guidance without a source. Sorry about that. I should have checked before opening the PR.

@sruehl

sruehl commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

rebase on develop; the driver testsuite now runs, so the unanswered Copilot test-coverage comment is directly addressable - and since you agreed the config idea is good, ask for MAX_SUM_COMMAND_ITEMS as an AdsConfiguration parameter, then an e2e testcase with cap=2 and 3 tags covers the split, order-preserving merge, and per-group isolation without needing 501 tags in XML. Note the four still-commented-out testcases (direct/multi-element/symbolic reads) are separate revival candidates I didn't touch.

@sruehl

sruehl commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

FYI: 4de8bf1 pushed a update to the testsuite so be sure to base on that

The ADS driver's multiRead/multiWrite packed every tag they sent into a
single ADS sum command (ADSIGRP_MULTIPLE_READ / ADSIGRP_MULTIPLE_WRITE)
with no bound on the number of sub-commands. The item list is now split
into consecutive groups of at most max-sum-command-items; the groups are
sent sequentially and their results merged into one response. A request
within the cap produces the same sum command as before: same items, same
order, same payload. max-sum-command-items is an AdsConfiguration
parameter (default 500); onConnect rejects a value below 1. Beckhoff's
manual [1] says "Number of Sub-ADS calls: Highly recommended to max.
500!".

The groups are chained with thenComposeAsync, so the next group is not
built from the receive-thread callback that completed the previous one.
As a consequence even a request that takes the sum path with a single
group now builds and sends that sum command through the common pool
instead of on the calling thread; requests left with one tag after
resolution keep their separate non-sum path. A group-level
ReturnCode != OK fails only that group's tags. A group that fails
outright (send failure or timeout) fails the whole request and the
remaining groups are not sent; in a multi-group write the earlier
groups' writes may already have taken effect and are not rolled back.

The same manual section also requires bounding the byte length of the
requested data. This change does not do that. Also not covered: the
separate, uncapped sum command used for symbolic handle resolution
(ADSIGRP_SYM_HNDBYNAME sub-items). Both are left for a follow-up.

The shared ADS driver testsuite gains three cases with the cap set to
two and three tags: a split read, a split write, and one where the
second group fails and only that group's tag reports the error. plc4go's
multi-tag paths send one sum command per request and have no such
parameter, so its runner skips these three cases. Unit tests cover the
grouping helper chunk(), the two merge helpers, and the parameter
default.

[1] https://download.beckhoff.com/download/document/automation/twincat3/TwinCAT_ADS-DLL_C_EN.pdf
    TwinCAT 3 ADS-DLL C++ manual v1.1.3, section "ADS-sum command: Read
    or Write a list of variables with one single ADS-command"

Signed-off-by: Jooyoung Jung <livinglikekrillin@gmail.com>
@LivingLikeKrillin

Copy link
Copy Markdown
Contributor Author

@sruehl Rebased onto current develop (1980c1b6b2), so 4de8bf1a45 and the revived testcases are in.

MAX_SUM_COMMAND_ITEMS is now max-sum-command-items, default 500, rejected below 1 at connect.

Three testsuite cases at cap 2 with three tags: a split read, a split write, and one where the device answers the second sum command with ADSERR_DEVICE_SYMBOLNOTFOUND, so the first group keeps its values and only the failing group's tag carries the mapped code.

The split read pins the group order on the wire (offsets 1 and 2 in the first sum command, 3 in the second) and checks the merged values per tag. With BOOLs it cannot catch hurz1 and hurz3 swapping values; AdsSumMergeTest checks the merge helpers' tag-to-value pairing with distinct values. This is my answer to the Copilot test-coverage comment.

The multi-tag paths in Reader.go and Writer.go each send one sum command for the whole request and have no equivalent parameter, so all three cases are in WithSkippedTestCases on the Go side.

Java: 217 unit, 9 IT. Go: go test -run TestAdsDriver green.

@LivingLikeKrillin LivingLikeKrillin changed the title fix(plc4j/ads): cap sum-command item count at 500 fix(plc4j/ads): cap sum-command item count, configurably Oct 17, 2026
@sruehl
sruehl requested a balanced review from Copilot October 19, 2026 08:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (maxPerChunk <= 0) {
throw new IllegalArgumentException("maxPerChunk must be positive, got " + maxPerChunk);
}
List<List<T>> chunks = new ArrayList<>(Math.ceilDiv(items.size(), maxPerChunk));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The build compiles with <release>${java.version}</release> and java.version is 21; Math.ceilDiv(int, int) has been in the JDK since Java 18. The previous revision of this PR used the suggested (items.size() + maxPerChunk - 1) / maxPerChunk, but it was replaced: maxPerChunk is the max-sum-command-items setting, the connection only rejects values below 1, and with Integer.MAX_VALUE and two items the sum overflows, the old expression gives -1, and new ArrayList<>(-1) throws IllegalArgumentException. AdsSumChunkTest.maximumCapYieldsOneChunk covers that case.

Comment on lines +616 to +624
CompletableFuture<Map<String, PlcResponseItem<PlcValue>>> merged =
CompletableFuture.completedFuture(new LinkedHashMap<>());
for (List<String> groupNames : chunk(orderedNames, getConfiguration().getMaxSumCommandItems())) {
merged = merged.thenComposeAsync(accumulated ->
multiReadChunk(groupNames, resolved).thenApply(groupValues -> {
accumulated.putAll(groupValues);
return accumulated;
}));
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted in the earlier thread (#2714 (comment)), the SPI has no executor to pass and the hop off the receive thread is deliberate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants