This is an automated email from the ASF dual-hosted git repository.
exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new c45c77cacb5 NIFI-16084 Fixed Load-balanced connection desynchronizing
Socket after in-flight transaction unregistered (#11402)
c45c77cacb5 is described below
commit c45c77cacb57041978938170ea4357392b939295
Author: Pierre Villard <[email protected]>
AuthorDate: Mon Jul 13 22:07:25 2026 +0200
NIFI-16084 Fixed Load-balanced connection desynchronizing Socket after
in-flight transaction unregistered (#11402)
Signed-off-by: David Handermann <[email protected]>
---
.../async/nio/NioAsyncLoadBalanceClient.java | 6 +-
.../client/async/nio/TestLoadBalanceSession.java | 43 ++++++++
.../async/nio/TestNioAsyncLoadBalanceClient.java | 110 +++++++++++++++++++++
.../server/TestStandardLoadBalanceProtocol.java | 42 ++++++++
4 files changed, 200 insertions(+), 1 deletion(-)
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/queue/clustered/client/async/nio/NioAsyncLoadBalanceClient.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/queue/clustered/client/async/nio/NioAsyncLoadBalanceClient.java
index 9248cc2d4c7..376dcac1e34 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/queue/clustered/client/async/nio/NioAsyncLoadBalanceClient.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/queue/clustered/client/async/nio/NioAsyncLoadBalanceClient.java
@@ -138,6 +138,10 @@ public class NioAsyncLoadBalanceClient implements
AsyncLoadBalanceClient {
logger.debug("{} Triggering failure callback for {} FlowFiles
for Registered Partition {} because partition was unregistered", this,
flowFilesSent.size(), removedPartition);
removedPartition.getFailureCallback().onTransactionFailed(flowFilesSent,
TransactionFailureCallback.TransactionPhase.SENDING);
+
+ // The transaction was abandoned mid-stream. Close the
connection so the socket is not reused for the next
+ // transaction; a reused socket would leave the peer reading a
stray protocol byte and aborting the transfer.
+ close();
}
}
}
@@ -427,7 +431,7 @@ public class NioAsyncLoadBalanceClient implements
AsyncLoadBalanceClient {
return new SimpleLimitThreshold(1000, 10_000_000L);
}
- private synchronized boolean isConnectionEstablished() {
+ synchronized boolean isConnectionEstablished() {
return selector != null && channel != null && channel.isConnected();
}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/queue/clustered/client/async/nio/TestLoadBalanceSession.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/queue/clustered/client/async/nio/TestLoadBalanceSession.java
index de5eea23023..267e0595cd3 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/queue/clustered/client/async/nio/TestLoadBalanceSession.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/queue/clustered/client/async/nio/TestLoadBalanceSession.java
@@ -189,6 +189,49 @@ public class TestLoadBalanceSession {
assertEquals(Arrays.asList(flowFile1, flowFile2),
transaction.getAndPurgeFlowFilesSent());
}
+ @Test
+ @Timeout(30)
+ public void
testSessionCancelLeavesChannelOpenSoReusedStreamRepeatsVersionByte() throws
InterruptedException, IOException {
+ final SocketChannel socketChannel = SocketChannel.open(new
InetSocketAddress("localhost", port));
+ socketChannel.configureBlocking(false);
+ final PeerChannel peerChannel = new PeerChannel(socketChannel, null,
"unit-test");
+
+ final RegisteredPartition partition1 = new
RegisteredPartition("unit-test-connection", () -> false,
+ () -> new MockFlowFileRecord(5), NOP_FAILURE_CALLBACK, (ff,
nodeId) -> { }, () -> LoadBalanceCompression.DO_NOT_COMPRESS, () -> true);
+ final FlowFileContentAccess contentAccess = ff -> new
ByteArrayInputStream("hello".getBytes());
+
+ final LoadBalanceSession session1 = new LoadBalanceSession(partition1,
contentAccess, new StandardLoadBalanceFlowFileCodec(), peerChannel, 30000,
+ new SimpleLimitThreshold(100, 10_000_000));
+
+ // Advance session1 until it has written its opening protocol-version
byte.
+ while (received.size() < 1) {
+ session1.communicate();
+ Thread.sleep(10L);
+ }
+
+ // A LoadBalanceSession cancel neither closes the channel nor sends
ABORT_TRANSACTION; closing a channel whose
+ // transaction was abandoned is the caller's responsibility
(NioAsyncLoadBalanceClient#unregister).
+ assertTrue(session1.cancel());
+ assertTrue(socketChannel.isOpen());
+
+ // If the same channel is reused for the next transaction, it again
writes protocol-version byte 1.
+ final LoadBalanceSession session2 = new LoadBalanceSession(partition1,
contentAccess, new StandardLoadBalanceFlowFileCodec(), peerChannel, 30000,
+ new SimpleLimitThreshold(100, 10_000_000));
+ while (session2.communicate()) {
+ }
+
+ // Two protocol-version bytes then sit back-to-back on the stream: the
abandoned transaction's, then the reused
+ // channel's. The second lands where the peer expects a continuation
of the first transaction (the desync).
+ while (received.size() < 2) {
+ Thread.sleep(10L);
+ }
+ final byte[] sent = received.toByteArray();
+ assertEquals(1, sent[0]);
+ assertEquals(1, sent[1]);
+
+ socketChannel.close();
+ }
+
@Test
@Timeout(10)
public void testLargeContent() throws InterruptedException, IOException {
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/queue/clustered/client/async/nio/TestNioAsyncLoadBalanceClient.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/queue/clustered/client/async/nio/TestNioAsyncLoadBalanceClient.java
new file mode 100644
index 00000000000..e676c448df8
--- /dev/null
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/queue/clustered/client/async/nio/TestNioAsyncLoadBalanceClient.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.nifi.controller.queue.clustered.client.async.nio;
+
+import org.apache.nifi.cluster.coordination.ClusterCoordinator;
+import org.apache.nifi.cluster.coordination.node.NodeConnectionState;
+import org.apache.nifi.cluster.coordination.node.NodeConnectionStatus;
+import org.apache.nifi.cluster.protocol.NodeIdentifier;
+import org.apache.nifi.controller.MockFlowFileRecord;
+import org.apache.nifi.controller.queue.LoadBalanceCompression;
+import org.apache.nifi.controller.queue.clustered.FlowFileContentAccess;
+import
org.apache.nifi.controller.queue.clustered.client.StandardLoadBalanceFlowFileCodec;
+import
org.apache.nifi.controller.queue.clustered.client.async.TransactionFailureCallback;
+import org.apache.nifi.controller.repository.FlowFileRecord;
+import org.apache.nifi.events.EventReporter;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class TestNioAsyncLoadBalanceClient {
+
+ private static final TransactionFailureCallback NOP_FAILURE_CALLBACK = new
TransactionFailureCallback() {
+ @Override
+ public void onTransactionFailed(final List<FlowFileRecord> flowFiles,
final Exception cause, final TransactionPhase transactionPhase) {
+ }
+
+ @Override
+ public boolean isRebalanceOnFailure() {
+ return false;
+ }
+ };
+
+ @Test
+ @Timeout(30)
+ void testUnregisterOfInFlightTransactionClosesChannel() throws Exception {
+ final ServerSocket serverSocket = new ServerSocket(0);
+ final int port = serverSocket.getLocalPort();
+
+ final Thread server = new Thread(() -> {
+ try (final Socket socket = serverSocket.accept()) {
+ final InputStream in = socket.getInputStream();
+ while (in.read() != -1) {
+ }
+ } catch (final Exception ignored) {
+ }
+ });
+ server.setDaemon(true);
+ server.start();
+
+ final String localhost = "localhost";
+ final NodeIdentifier nodeId = new NodeIdentifier("node-1", localhost,
port, localhost, port,
+ localhost, port, localhost, port, port, false);
+
+ final ClusterCoordinator clusterCoordinator =
mock(ClusterCoordinator.class);
+ when(clusterCoordinator.getConnectionStatus(nodeId)).thenReturn(new
NodeConnectionStatus(nodeId, NodeConnectionState.CONNECTED));
+
+ final FlowFileContentAccess contentAccess = ff -> new
ByteArrayInputStream(new byte[0]);
+
+ final NioAsyncLoadBalanceClient client = new
NioAsyncLoadBalanceClient(nodeId, null, 30000, contentAccess,
+ new StandardLoadBalanceFlowFileCodec(), EventReporter.NO_OP,
clusterCoordinator);
+
+ final String connectionId = "unit-test-connection";
+ try {
+ client.start();
+ client.register(connectionId, () -> false, () -> new
MockFlowFileRecord(5), NOP_FAILURE_CALLBACK,
+ (flowFiles, node) -> { }, () ->
LoadBalanceCompression.DO_NOT_COMPRESS, () -> true);
+
+ final long deadline = System.currentTimeMillis() + 20_000L;
+ while (!client.isConnectionEstablished() &&
System.currentTimeMillis() < deadline) {
+ client.communicate();
+ Thread.sleep(10L);
+ }
+ assertTrue(client.isConnectionEstablished(), "Expected the client
to establish a connection and begin a transaction");
+
+ // Unregistering while a transaction is in flight must tear down
the (now desynchronized) socket so the
+ // next transaction reconnects on a clean stream rather than
reusing it.
+ client.unregister(connectionId);
+
+ assertFalse(client.isConnectionEstablished(), "Channel must be
closed after an in-flight transaction is unregistered");
+ } finally {
+ client.stop();
+ serverSocket.close();
+ }
+ }
+}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/queue/clustered/server/TestStandardLoadBalanceProtocol.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/queue/clustered/server/TestStandardLoadBalanceProtocol.java
index 04a57646648..e2df99f173f 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/queue/clustered/server/TestStandardLoadBalanceProtocol.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/queue/clustered/server/TestStandardLoadBalanceProtocol.java
@@ -213,6 +213,48 @@ public class TestStandardLoadBalanceProtocol {
assertTrue(flowFileRepoUpdateRecords.stream().allMatch(record ->
record.getType() == RepositoryRecordType.CREATE));
}
+ @Test
+ public void
testStrayVersionByteFromReusedSocketReadAsCompletionIndicator() throws
IOException, IllegalClusterStateException {
+ final StandardLoadBalanceProtocol protocol = new
StandardLoadBalanceProtocol(flowFileRepo, contentRepo, provenanceRepo,
flowController, ALWAYS_AUTHORIZED);
+
+ final PipedInputStream serverInput = new PipedInputStream();
+ final PipedOutputStream serverContentSource = new PipedOutputStream();
+ serverInput.connect(serverContentSource);
+
+ final ByteArrayOutputStream serverOutput = new ByteArrayOutputStream();
+
+ final Checksum checksum = new CRC32();
+ final OutputStream checkedOutput = new
CheckedOutputStream(serverContentSource, checksum);
+ final DataOutputStream dos = new DataOutputStream(checkedOutput);
+ dos.writeUTF("unit-test-connection-id");
+
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("uuid", "unit-test-id");
+
+ dos.write(CHECK_SPACE);
+ dos.write(MORE_FLOWFILES);
+ writeAttributes(attributes, dos);
+ writeContent("hello".getBytes(), dos);
+ dos.write(NO_MORE_FLOWFILES);
+ dos.writeLong(checksum.getValue());
+
+ // A transaction abandoned on a reused socket leaves the next
transaction's leading protocol-version
+ // byte (1) where the server expects COMPLETE_TRANSACTION (0x23) or
ABORT_TRANSACTION (0x24).
+ dos.write(1);
+ dos.flush();
+
+ final IOException thrown = assertThrows(IOException.class,
+ () -> protocol.receiveFlowFiles(serverInput, serverOutput,
"Unit Test", 1));
+ assertTrue(thrown.getMessage().contains("received a value of 1"),
"Unexpected message: " + thrown.getMessage());
+
+ final byte[] serverResponse = serverOutput.toByteArray();
+ assertEquals(SPACE_AVAILABLE, serverResponse[0]);
+ assertEquals(CONFIRM_CHECKSUM, serverResponse[1]);
+ assertEquals(ABORT_TRANSACTION, serverResponse[serverResponse.length -
1]);
+
+ Mockito.verify(flowFileQueue,
times(0)).receiveFromPeer(anyCollection());
+ }
+
@Test
public void testMultipleFlowFiles() throws IOException {
final StandardLoadBalanceProtocol protocol = new
StandardLoadBalanceProtocol(flowFileRepo, contentRepo, provenanceRepo,
flowController, ALWAYS_AUTHORIZED);