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 1cabec6b5e5 NIFI-15913 Fixed Repository Record creation for S2S and 
Load-Balanced Connections (#11215)
1cabec6b5e5 is described below

commit 1cabec6b5e55ef4ba782ecb66a3d51e55dcdb753
Author: Mark Payne <[email protected]>
AuthorDate: Wed May 6 14:41:58 2026 -0400

    NIFI-15913 Fixed Repository Record creation for S2S and Load-Balanced 
Connections (#11215)
    
    -  Add system test reproducing premature content claim truncation after 
node offload
    
    - Fixed bug in which we create RepositoryRecords with the incorrect 'type' 
when receiving data via site-to-site or load-balanced connections; also 
addressed the same issue in cases where we have a Stateless Process Group with 
an OutputPort that clones to multiple destinations.
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../apache/nifi/connectable/ConnectionUtils.java   |   3 +-
 .../nifi/connectable/TestConnectionUtils.java      | 121 ++++++++++++
 .../server/StandardLoadBalanceProtocol.java        |   5 +-
 .../server/TestStandardLoadBalanceProtocol.java    |   6 +
 .../TestWriteAheadFlowFileRepository.java          |  59 ++++++
 .../OffloadContentClaimTruncationIT.java           | 212 +++++++++++++++++++++
 .../StatelessOutputContentClaimTruncationIT.java   | 191 +++++++++++++++++++
 7 files changed, 595 insertions(+), 2 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/connectable/ConnectionUtils.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/connectable/ConnectionUtils.java
index d6eccf61a34..87792473fdc 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/connectable/ConnectionUtils.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/connectable/ConnectionUtils.java
@@ -101,7 +101,8 @@ public class ConnectionUtils {
     }
 
     private static RepositoryRecord createRepositoryRecord(final 
FlowFileRecord flowFile, final FlowFileQueue destinationQueue) {
-        final StandardRepositoryRecord repoRecord = new 
StandardRepositoryRecord(null, flowFile);
+        // The FlowFile is being introduced to this repository for the first 
time, so it is tracked as a CREATE record.
+        final StandardRepositoryRecord repoRecord = new 
StandardRepositoryRecord(destinationQueue);
         repoRecord.setWorking(flowFile, Collections.emptyMap(), false);
         repoRecord.setDestination(destinationQueue);
         return repoRecord;
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/connectable/TestConnectionUtils.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/connectable/TestConnectionUtils.java
new file mode 100644
index 00000000000..9dcf56843ad
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/connectable/TestConnectionUtils.java
@@ -0,0 +1,121 @@
+/*
+ * 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.connectable;
+
+import org.apache.nifi.connectable.ConnectionUtils.FlowFileCloneResult;
+import org.apache.nifi.controller.queue.FlowFileQueue;
+import org.apache.nifi.controller.repository.ContentRepository;
+import org.apache.nifi.controller.repository.FlowFileRecord;
+import org.apache.nifi.controller.repository.FlowFileRepository;
+import org.apache.nifi.controller.repository.RepositoryRecord;
+import org.apache.nifi.controller.repository.RepositoryRecordType;
+import org.apache.nifi.controller.repository.StandardFlowFileRecord;
+import org.apache.nifi.controller.repository.claim.ContentClaim;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+public class TestConnectionUtils {
+
+    private FlowFileRepository flowFileRepository;
+    private ContentRepository contentRepository;
+
+    @BeforeEach
+    public void setup() {
+        flowFileRepository = Mockito.mock(FlowFileRepository.class);
+        contentRepository = Mockito.mock(ContentRepository.class);
+
+        final AtomicLong nextId = new AtomicLong(1000L);
+        
when(flowFileRepository.getNextFlowFileSequence()).thenAnswer(invocation -> 
nextId.getAndIncrement());
+    }
+
+    @Test
+    public void testCloneSingleDestinationProducesCreateRecord() {
+        final FlowFileRecord flowFile = new StandardFlowFileRecord.Builder()
+                .id(1L)
+                .addAttribute("uuid", "test-uuid-1")
+                .build();
+
+        final FlowFileQueue destinationQueue = 
Mockito.mock(FlowFileQueue.class);
+        final Connection destination = mockConnection(destinationQueue);
+
+        final FlowFileCloneResult result = ConnectionUtils.clone(flowFile, 
List.of(destination), flowFileRepository, contentRepository);
+
+        final List<RepositoryRecord> records = result.getRepositoryRecords();
+        assertEquals(1, records.size());
+        final RepositoryRecord record = records.get(0);
+        assertEquals(RepositoryRecordType.CREATE, record.getType());
+        assertEquals(destinationQueue, record.getDestination());
+        assertNotNull(record.getCurrent());
+        assertEquals(flowFile.getId(), record.getCurrent().getId());
+    }
+
+    @Test
+    public void testCloneMultipleDestinationsAllProduceCreateRecords() {
+        final ContentClaim contentClaim = Mockito.mock(ContentClaim.class);
+        final FlowFileRecord flowFile = new StandardFlowFileRecord.Builder()
+                .id(1L)
+                .addAttribute("uuid", "test-uuid-1")
+                .contentClaim(contentClaim)
+                .size(1024L)
+                .build();
+
+        final FlowFileQueue queueOne = Mockito.mock(FlowFileQueue.class);
+        final FlowFileQueue queueTwo = Mockito.mock(FlowFileQueue.class);
+        final FlowFileQueue queueThree = Mockito.mock(FlowFileQueue.class);
+        final List<Connection> destinations = List.of(
+                mockConnection(queueOne),
+                mockConnection(queueTwo),
+                mockConnection(queueThree));
+
+        final FlowFileCloneResult result = ConnectionUtils.clone(flowFile, 
destinations, flowFileRepository, contentRepository);
+
+        final List<RepositoryRecord> records = result.getRepositoryRecords();
+        assertEquals(destinations.size(), records.size());
+
+        // Each record produced by clone() represents a FlowFile being 
introduced to the repository,
+        // whether it is the original routed FlowFile or a sibling clone.
+        assertTrue(records.stream().allMatch(record -> record.getType() == 
RepositoryRecordType.CREATE));
+
+        // The clones (one per additional destination beyond the first) should 
each have triggered a
+        // claimant count increment on the shared Content Claim.
+        Mockito.verify(contentRepository, Mockito.times(destinations.size() - 
1)).incrementClaimaintCount(contentClaim);
+
+        final List<FlowFileQueue> destinationQueues = new ArrayList<>();
+        for (final RepositoryRecord record : records) {
+            destinationQueues.add(record.getDestination());
+        }
+        assertTrue(destinationQueues.contains(queueOne));
+        assertTrue(destinationQueues.contains(queueTwo));
+        assertTrue(destinationQueues.contains(queueThree));
+    }
+
+    private Connection mockConnection(final FlowFileQueue queue) {
+        final Connection connection = Mockito.mock(Connection.class);
+        when(connection.getFlowFileQueue()).thenReturn(queue);
+        return connection;
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/queue/clustered/server/StandardLoadBalanceProtocol.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/queue/clustered/server/StandardLoadBalanceProtocol.java
index 4d82d5ee86c..b187badce3e 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/queue/clustered/server/StandardLoadBalanceProtocol.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/queue/clustered/server/StandardLoadBalanceProtocol.java
@@ -56,6 +56,7 @@ import java.net.SocketTimeoutException;
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -421,7 +422,9 @@ public class StandardLoadBalanceProtocol implements 
LoadBalanceProtocol {
     private void updateFlowFileRepository(final List<RemoteFlowFileRecord> 
flowFiles, final FlowFileQueue flowFileQueue) throws IOException {
         final List<RepositoryRecord> repoRecords = flowFiles.stream()
                 .map(remoteFlowFile -> {
-                    final StandardRepositoryRecord record = new 
StandardRepositoryRecord(flowFileQueue, remoteFlowFile.getFlowFile());
+                    // A received FlowFile is new to this node's repository, 
so track it as a CREATE record.
+                    final StandardRepositoryRecord record = new 
StandardRepositoryRecord(flowFileQueue);
+                    record.setWorking(remoteFlowFile.getFlowFile(), 
Collections.emptyMap(), false);
                     record.setDestination(flowFileQueue);
                     return record;
                 })
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 1df846d1ac6..04a57646648 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
@@ -27,6 +27,7 @@ import 
org.apache.nifi.controller.repository.ContentRepository;
 import org.apache.nifi.controller.repository.FlowFileRecord;
 import org.apache.nifi.controller.repository.FlowFileRepository;
 import org.apache.nifi.controller.repository.RepositoryRecord;
+import org.apache.nifi.controller.repository.RepositoryRecordType;
 import org.apache.nifi.controller.repository.claim.ContentClaim;
 import org.apache.nifi.controller.repository.claim.ResourceClaim;
 import org.apache.nifi.provenance.ProvenanceEventRecord;
@@ -206,6 +207,10 @@ public class TestStandardLoadBalanceProtocol {
         Mockito.verify(provenanceRepo, times(1)).registerEvents(anyList());
         Mockito.verify(flowFileQueue, times(0)).putAll(anyCollection());
         Mockito.verify(flowFileQueue, 
times(1)).receiveFromPeer(anyCollection());
+
+        // Repository records for received FlowFiles must be CREATE so that 
the FlowFile Repository
+        // increments the Content Claim's truncation reference count for each 
received FlowFile.
+        assertTrue(flowFileRepoUpdateRecords.stream().allMatch(record -> 
record.getType() == RepositoryRecordType.CREATE));
     }
 
     @Test
@@ -270,6 +275,7 @@ public class TestStandardLoadBalanceProtocol {
         assertEquals(4, flowFileQueueReceiveRecords.size());
 
         assertTrue(provRepoUpdateRecords.stream().allMatch(event -> 
event.getEventType() == ProvenanceEventType.RECEIVE));
+        assertTrue(flowFileRepoUpdateRecords.stream().allMatch(record -> 
record.getType() == RepositoryRecordType.CREATE));
     }
 
     @Test
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/repository/TestWriteAheadFlowFileRepository.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/repository/TestWriteAheadFlowFileRepository.java
index 7898820435d..af5d92b3956 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/repository/TestWriteAheadFlowFileRepository.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/repository/TestWriteAheadFlowFileRepository.java
@@ -1155,6 +1155,65 @@ public class TestWriteAheadFlowFileRepository {
         assertTrue(truncated.contains(originalClaim));
     }
 
+    @Test
+    public void 
testCreateRecordsForSharedTruncatableClaimPreventPrematureTruncation() throws 
IOException {
+        // Multiple CREATE records for the same truncation-eligible Content 
Claim should increment
+        // the truncation reference count per FlowFile, so deleting one 
sibling does not queue the
+        // shared claim for truncation while others still reference it.
+        final RuntimeRepoContext context = createRuntimeRepoContext();
+
+        final ResourceClaim resourceClaim = 
context.claimManager().newResourceClaim("container", "section", "1", false, 
false);
+        context.claimManager().incrementClaimantCount(resourceClaim);
+        context.claimManager().incrementClaimantCount(resourceClaim);
+        context.claimManager().incrementClaimantCount(resourceClaim);
+        final StandardContentClaim sharedClaim = createClaim(resourceClaim, 
1024L, TRUNCATION_CANDIDATE_LENGTH, true);
+
+        final FlowFileRecord first = new StandardFlowFileRecord.Builder()
+                .id(1L)
+                .addAttribute("uuid", UUID.randomUUID().toString())
+                .contentClaim(sharedClaim)
+                .build();
+        final FlowFileRecord second = new StandardFlowFileRecord.Builder()
+                .id(2L)
+                .addAttribute("uuid", UUID.randomUUID().toString())
+                .contentClaim(sharedClaim)
+                .build();
+        final FlowFileRecord third = new StandardFlowFileRecord.Builder()
+                .id(3L)
+                .addAttribute("uuid", UUID.randomUUID().toString())
+                .contentClaim(sharedClaim)
+                .build();
+
+        try (final WriteAheadFlowFileRepository repo = new 
WriteAheadFlowFileRepository(niFiProperties)) {
+            repo.initialize(context.claimManager());
+            repo.loadFlowFiles(context.queueProvider());
+
+            final List<RepositoryRecord> createRecords = new ArrayList<>();
+            for (final FlowFileRecord flowFile : List.of(first, second, 
third)) {
+                final StandardRepositoryRecord createRecord = new 
StandardRepositoryRecord(context.queue());
+                createRecord.setWorking(flowFile, false);
+                createRecord.setDestination(context.queue());
+                createRecords.add(createRecord);
+            }
+            repo.updateRepository(createRecords);
+
+            assertEquals(3, repo.getContentClaimReferenceCount(sharedClaim),
+                    "Each CREATE record for a truncation-eligible Content 
Claim must increment the truncation reference count");
+
+            final StandardRepositoryRecord deleteRecord = new 
StandardRepositoryRecord(context.queue(), first);
+            deleteRecord.markForDelete();
+            repo.updateRepository(List.of(deleteRecord));
+            repo.checkpoint();
+        }
+
+        assertEquals(2, 
context.claimManager().getTruncationReferenceCount(sharedClaim));
+
+        final List<ContentClaim> truncated = new ArrayList<>();
+        context.claimManager().drainTruncatableClaims(truncated, 100);
+        assertFalse(truncated.contains(sharedClaim),
+                "Shared Content Claim must not be queued for truncation while 
live siblings still reference it");
+    }
+
     // 
=========================================================================
     // Truncation Feature: Recovery Tests
     // 
=========================================================================
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/repositories/OffloadContentClaimTruncationIT.java
 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/repositories/OffloadContentClaimTruncationIT.java
new file mode 100644
index 00000000000..a4b084ee1fb
--- /dev/null
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/repositories/OffloadContentClaimTruncationIT.java
@@ -0,0 +1,212 @@
+/*
+ * 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.tests.system.repositories;
+
+import org.apache.nifi.cluster.coordination.node.ClusterRoles;
+import org.apache.nifi.scheduling.ExecutionNode;
+import org.apache.nifi.tests.system.InstanceConfiguration;
+import org.apache.nifi.tests.system.NiFiInstanceFactory;
+import org.apache.nifi.tests.system.NiFiSystemIT;
+import org.apache.nifi.tests.system.SpawnedClusterNiFiInstanceFactory;
+import org.apache.nifi.web.api.dto.NodeDTO;
+import org.apache.nifi.web.api.entity.ConnectionEntity;
+import org.apache.nifi.web.api.entity.ProcessorEntity;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * System test that exercises the interaction between Node Offload and Content 
Claim Truncation.
+ *
+ * <p>When a node is offloaded, every FlowFile in its local partitions is 
shipped to peer nodes via
+ * the load-balance protocol. On the peer, {@code StandardLoadBalanceProtocol} 
packs every received
+ * FlowFile in a transaction into a single {@code ContentClaim} produced by
+ * {@code ContentRepository.create()}. If the peer's {@code 
writableClaimQueue} contains a
+ * partially-filled Resource Claim at the time of receive, that {@code 
ContentClaim} ends up with
+ * {@code offset > 0} and, given a sufficiently large transaction, with {@code 
length} above the
+ * truncation threshold &mdash; making the claim a truncation candidate.</p>
+ *
+ * <p>The receive path persists each received FlowFile as an {@code UPDATE} 
repository record without
+ * marking the record as content-modified, so the truncation reference count 
for the shared Content
+ * Claim is never incremented to reflect the live FlowFiles that point inside 
it. Once a single
+ * sibling FlowFile is removed downstream, the shared Content Claim's 
reference count is observed as
+ * zero by {@code isTruncationAllowed} and the claim is queued for truncation 
while the surviving
+ * FlowFiles still reference offsets inside it.</p>
+ *
+ * <p>This test reproduces that scenario end-to-end. It generates content on 
the primary node, offloads
+ * the primary node so the data is shipped to the peer, removes a single 
FlowFile to arm truncation,
+ * waits for the periodic truncate task to run, and then asserts that every 
remaining FlowFile in the
+ * connection on the peer still returns its original content.</p>
+ */
+@Timeout(value = 5, unit = TimeUnit.MINUTES)
+public class OffloadContentClaimTruncationIT extends NiFiSystemIT {
+
+    private static final int CONTENT_BYTES_PER_FLOW_FILE = 8 * 1024;
+    private static final int FLOW_FILE_COUNT = 30;
+    private static final String CONTENT_TEXT = 
"x".repeat(CONTENT_BYTES_PER_FLOW_FILE);
+
+    private static final int CLUSTER_NODE_COUNT = 2;
+
+    @Override
+    public NiFiInstanceFactory getInstanceFactory() {
+        final Map<String, String> propertyOverrides = Map.of(
+                "nifi.flowfile.repository.checkpoint.interval", "1 sec",
+                "nifi.content.repository.archive.cleanup.frequency", "1 sec",
+                "nifi.content.claim.max.appendable.size", "50 KB",
+                "nifi.content.claim.truncation.enabled", "true",
+                "nifi.content.repository.archive.max.usage.percentage", "1%");
+
+        return new SpawnedClusterNiFiInstanceFactory(
+                new InstanceConfiguration.Builder()
+                        
.bootstrapConfig("src/test/resources/conf/clustered/node1/bootstrap.conf")
+                        .instanceDirectory("target/node1")
+                        .overrideNifiProperties(propertyOverrides)
+                        .build(),
+                new InstanceConfiguration.Builder()
+                        
.bootstrapConfig("src/test/resources/conf/clustered/node2/bootstrap.conf")
+                        .instanceDirectory("target/node2")
+                        .overrideNifiProperties(propertyOverrides)
+                        .build());
+    }
+
+    @Override
+    protected boolean isAllowFactoryReuse() {
+        return false;
+    }
+
+    @Override
+    protected boolean isDestroyEnvironmentAfterEachTest() {
+        return true;
+    }
+
+    @Test
+    public void testOffloadedFlowFileContentNotPrematurelyTruncated() throws 
Exception {
+        // Pre-warm both nodes' writableClaimQueue with a partially-filled 
Resource Claim. Each node
+        // generates a single small FlowFile, leaving it queued upstream of an 
unscheduled
+        // TerminateFlowFile. The Resource Claim file remains writable and a 
few bytes long, so the
+        // next ContentRepository.create() call on each node will reuse this 
Resource Claim and the
+        // resulting ContentClaim will have offset > 0.
+        final ProcessorEntity prewarmGenerator = 
getClientUtil().createProcessor("GenerateFlowFile");
+        getClientUtil().updateProcessorProperties(prewarmGenerator, Map.of(
+                "Text", "warmup",
+                "Batch Size", "1",
+                "Max FlowFiles", "1"));
+        getClientUtil().updateProcessorSchedulingPeriod(prewarmGenerator, "0 
sec");
+
+        final ProcessorEntity prewarmTerminate = 
getClientUtil().createProcessor("TerminateFlowFile");
+        final ConnectionEntity prewarmConnection = 
getClientUtil().createConnection(prewarmGenerator, prewarmTerminate, "success");
+
+        getClientUtil().startProcessor(prewarmGenerator);
+        waitForQueueCount(prewarmConnection.getId(), CLUSTER_NODE_COUNT);
+        getClientUtil().stopProcessor(prewarmGenerator);
+
+        // Source generator runs only on the primary node and produces 
FLOW_FILE_COUNT FlowFiles,
+        // each carrying the same CONTENT_TEXT. The non-primary node generates 
nothing in this phase, so
+        // its writableClaimQueue retains the partially-filled Resource Claim 
from pre-warm.
+        final ProcessorEntity sourceGenerator = 
getClientUtil().createProcessor("GenerateFlowFile");
+        getClientUtil().updateProcessorProperties(sourceGenerator, Map.of(
+                "Text", CONTENT_TEXT,
+                "Batch Size", String.valueOf(FLOW_FILE_COUNT),
+                "Max FlowFiles", String.valueOf(FLOW_FILE_COUNT)));
+        getClientUtil().updateProcessorExecutionNode(sourceGenerator, 
ExecutionNode.PRIMARY);
+        getClientUtil().updateProcessorSchedulingPeriod(sourceGenerator, "0 
sec");
+
+        ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile");
+        final ConnectionEntity dataConnection = 
getClientUtil().createConnection(sourceGenerator, terminate, "success");
+
+        getClientUtil().startProcessor(sourceGenerator);
+        waitForQueueCount(dataConnection.getId(), FLOW_FILE_COUNT);
+        getClientUtil().stopProcessor(sourceGenerator);
+
+        // Identify the primary and non-primary nodes. The primary node is the 
one holding the
+        // FlowFiles produced above and is the node that will be offloaded. 
The non-primary node is
+        // the receiver and must remain reachable for queue listings and 
content fetches after offload.
+        final NodeDTO primaryNode = findNodeByRole(ClusterRoles.PRIMARY_NODE);
+        assertNotNull(primaryNode, "Cluster does not have a Primary Node");
+        final NodeDTO nonPrimaryNode = findOtherNode(primaryNode);
+        assertNotNull(nonPrimaryNode, "Cluster does not have a non-primary 
node");
+
+        final int nonPrimaryNodeIndex = nonPrimaryNode.getApiPort() - 
CLUSTERED_CLIENT_API_BASE_PORT + 1;
+        switchClientToNode(nonPrimaryNodeIndex);
+
+        // Disconnect and offload the primary node. All FlowFiles in the 
primary's local partition are
+        // shipped to the non-primary via the load-balance receive path. On 
the receiver, every FlowFile
+        // in the receive transaction is packed into a single ContentClaim 
that sits at offset > 0
+        // inside the recycled Resource Claim. With FLOW_FILE_COUNT * 
CONTENT_BYTES_PER_FLOW_FILE bytes
+        // exceeding the 50 KB truncation threshold, the shared ContentClaim 
is flagged as a truncation
+        // candidate.
+        getClientUtil().disconnectNode(primaryNode.getNodeId());
+        waitForNodeStatus(primaryNode, "DISCONNECTED");
+        getClientUtil().offloadNode(primaryNode.getNodeId());
+        waitForNodeStatus(primaryNode, "OFFLOADED");
+
+        waitForQueueCount(dataConnection.getId(), FLOW_FILE_COUNT);
+
+        // Trigger a single DELETE record that references the shared, 
truncatable ContentClaim by
+        // running TerminateFlowFile once on the receiver. Without correct 
truncation reference count
+        // tracking on the receive path the ContentClaim's reference count is 
zero, so this single
+        // DELETE is sufficient for updateContentClaims() to queue the shared 
claim for truncation
+        // even though FLOW_FILE_COUNT - 1 sibling FlowFiles still point 
inside it.
+        terminate = 
getNifiClient().getProcessorClient().getProcessor(terminate.getId());
+        terminate = 
getNifiClient().getProcessorClient().runProcessorOnce(terminate);
+        getClientUtil().waitForStoppedProcessor(terminate.getId());
+        waitForQueueCount(dataConnection.getId(), FLOW_FILE_COUNT - 1);
+
+        // Allow time for the periodic TruncateClaims task on the receiver to 
drain the truncatable
+        // claim queue and call FileChannel.truncate(claim.getOffset()), which 
would otherwise destroy
+        // the on-disk bytes for every remaining live FlowFile that references 
this claim.
+        Thread.sleep(5_000L);
+
+        // Read every remaining FlowFile and verify its content is intact. 
With the bug present, the
+        // underlying Resource Claim file has been truncated back to 
claim.getOffset() and these reads
+        // return zero bytes (or fewer than expected) for each offload 
FlowFile.
+        final byte[] expectedContent = 
CONTENT_TEXT.getBytes(StandardCharsets.UTF_8);
+        final int remaining = FLOW_FILE_COUNT - 1;
+        for (int index = 0; index < remaining; index++) {
+            final byte[] actualContent = 
getClientUtil().getFlowFileContentAsByteArray(dataConnection.getId(), index);
+            assertArrayEquals(expectedContent, actualContent,
+                    "FlowFile at index " + index + " has unexpected content; 
expected "
+                            + expectedContent.length + " bytes, got " + 
actualContent.length);
+        }
+    }
+
+    private NodeDTO findNodeByRole(final String role) throws Exception {
+        for (final NodeDTO nodeDto : 
getNifiClient().getControllerClient().getNodes().getCluster().getNodes()) {
+            final Set<String> roles = nodeDto.getRoles();
+            if (roles != null && roles.contains(role)) {
+                return nodeDto;
+            }
+        }
+        return null;
+    }
+
+    private NodeDTO findOtherNode(final NodeDTO excludedNode) throws Exception 
{
+        for (final NodeDTO nodeDto : 
getNifiClient().getControllerClient().getNodes().getCluster().getNodes()) {
+            if (!nodeDto.getNodeId().equals(excludedNode.getNodeId())) {
+                return nodeDto;
+            }
+        }
+        return null;
+    }
+}
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/repositories/StatelessOutputContentClaimTruncationIT.java
 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/repositories/StatelessOutputContentClaimTruncationIT.java
new file mode 100644
index 00000000000..79d24be824e
--- /dev/null
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/repositories/StatelessOutputContentClaimTruncationIT.java
@@ -0,0 +1,191 @@
+/*
+ * 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.tests.system.repositories;
+
+import org.apache.nifi.tests.system.NiFiSystemIT;
+import org.apache.nifi.web.api.entity.ConnectionEntity;
+import org.apache.nifi.web.api.entity.PortEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupEntity;
+import org.apache.nifi.web.api.entity.ProcessorEntity;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+/**
+ * System test that exercises the interaction between Content Claim Truncation 
and the Stateless
+ * to framework output bridge.
+ *
+ * <p>When a Stateless Process Group hands its output FlowFiles back to the 
framework,
+ * {@code StatelessFlowTask.createOutputRecords} delegates to
+ * {@code ConnectionUtils.createRepositoryRecord}, which constructs each output
+ * {@code StandardRepositoryRecord} as an {@code UPDATE} record without 
marking the record as
+ * content-modified. As a result, when {@code 
WriteAheadFlowFileRepository.updateRepository}
+ * processes the batch, the truncation reference count for the output 
FlowFiles' Content Claim is
+ * never incremented. The framework's view of that Content Claim therefore 
retains only the count
+ * that was established for the input FlowFile when it was originally created 
upstream of the
+ * Stateless group.</p>
+ *
+ * <p>If the input FlowFile inside the Stateless group is split via {@code 
session.clone(offset, size)}
+ * into many children that all share the same Content Claim, and then the 
input FlowFile is removed
+ * inside the Stateless group, the framework records the input FlowFile as a 
{@code DELETE} alongside
+ * the children's {@code UPDATE}-without-content-modified records. The {@code 
DELETE} decrements the
+ * truncation reference count for the shared Content Claim to zero while the 
children still reference
+ * offsets inside it. The Content Claim is queued for truncation and its 
on-disk Resource Claim file
+ * is shrunk back to {@code claim.getOffset()} by the periodic truncate task, 
destroying every byte
+ * the surviving children depend on.</p>
+ *
+ * <p>This test reproduces that scenario end-to-end. A pre-warm phase ensures 
the Resource Claim
+ * reused for the input FlowFile begins at {@code offset > 0}. A multi-line 
input FlowFile larger than
+ * the truncation threshold flows into a Stateless group that splits it via 
{@code session.clone}. The
+ * children land in a downstream queue and the test then asserts that every 
child still returns its
+ * original line bytes after the truncate task has had time to run.</p>
+ */
+@Timeout(value = 5, unit = TimeUnit.MINUTES)
+public class StatelessOutputContentClaimTruncationIT extends NiFiSystemIT {
+
+    private static final int LINE_BYTES = 2 * 1024;
+    private static final int LINE_COUNT = 30;
+    private static final String LINE_TEXT = "x".repeat(LINE_BYTES);
+
+    @Override
+    protected Map<String, String> getNifiPropertiesOverrides() {
+        return Map.of(
+                "nifi.flowfile.repository.checkpoint.interval", "1 sec",
+                "nifi.content.repository.archive.cleanup.frequency", "1 sec",
+                "nifi.content.claim.max.appendable.size", "50 KB",
+                "nifi.content.claim.truncation.enabled", "true",
+                "nifi.content.repository.archive.max.usage.percentage", "1%");
+    }
+
+    @Override
+    protected boolean isAllowFactoryReuse() {
+        return false;
+    }
+
+    @Override
+    protected boolean isDestroyEnvironmentAfterEachTest() {
+        return true;
+    }
+
+    @Test
+    public void testStatelessOutputContentNotPrematurelyTruncated() throws 
Exception {
+        // Pre-warm the writableClaimQueue with a partially-filled Resource 
Claim. The pre-warm
+        // FlowFile is queued upstream of an unstarted TerminateFlowFile so 
the Resource Claim file
+        // remains writable and only a few bytes long. The next 
ContentRepository.create() call will
+        // reuse this Resource Claim and the resulting ContentClaim will have 
offset > 0.
+        final ProcessorEntity prewarmGenerator = 
getClientUtil().createProcessor("GenerateFlowFile");
+        getClientUtil().updateProcessorProperties(prewarmGenerator, Map.of(
+                "Text", "warmup",
+                "Batch Size", "1",
+                "Max FlowFiles", "1"));
+        getClientUtil().updateProcessorSchedulingPeriod(prewarmGenerator, "0 
sec");
+
+        final ProcessorEntity prewarmTerminate = 
getClientUtil().createProcessor("TerminateFlowFile");
+        final ConnectionEntity prewarmConnection = 
getClientUtil().createConnection(prewarmGenerator, prewarmTerminate, "success");
+
+        getClientUtil().startProcessor(prewarmGenerator);
+        waitForQueueCount(prewarmConnection.getId(), 1);
+        getClientUtil().stopProcessor(prewarmGenerator);
+
+        // Build the multi-line content. Each line is LINE_BYTES of 'x' 
followed by a unique numeric
+        // suffix and a newline. The unique suffix lets the assertion identify 
which line each child
+        // FlowFile carries. The total content size of LINE_COUNT * 
(LINE_BYTES + suffix + '\n') is
+        // well above the 50 KB truncation threshold, so the input FlowFile's 
ContentClaim becomes a
+        // truncation candidate once the writableClaimQueue is pre-warmed.
+        final StringBuilder contentBuilder = new StringBuilder();
+        final String[] expectedLines = new String[LINE_COUNT];
+        for (int lineIndex = 0; lineIndex < LINE_COUNT; lineIndex++) {
+            final String line = LINE_TEXT + "-" + lineIndex;
+            expectedLines[lineIndex] = line;
+            contentBuilder.append(line).append('\n');
+        }
+        final String inputContent = contentBuilder.toString();
+
+        // Source generator produces a single multi-line FlowFile. Connecting 
it to the Stateless
+        // group's input port enrolls the FlowFile in the framework's FlowFile 
repository with a
+        // CREATE record that increments the truncation reference count for 
the new ContentClaim.
+        final ProcessorEntity sourceGenerator = 
getClientUtil().createProcessor("GenerateFlowFile");
+        getClientUtil().updateProcessorProperties(sourceGenerator, Map.of(
+                "Text", inputContent,
+                "Batch Size", "1",
+                "Max FlowFiles", "1"));
+        getClientUtil().updateProcessorSchedulingPeriod(sourceGenerator, "0 
sec");
+
+        final ProcessGroupEntity statelessGroup = 
getClientUtil().createProcessGroup("Stateless", "root");
+        getClientUtil().markStateless(statelessGroup, "1 min");
+
+        final PortEntity inputPort = getClientUtil().createInputPort("In", 
statelessGroup.getId());
+        final PortEntity outputPort = getClientUtil().createOutputPort("Out", 
statelessGroup.getId());
+
+        // Inside the Stateless group, SplitByLine with Use Clone = true uses 
session.clone(offset,
+        // size) so every child FlowFile shares the input FlowFile's 
ContentClaim, with each child's
+        // FlowFile-level offset pointing into a different region of that 
shared claim.
+        final ProcessorEntity splitByLine = 
getClientUtil().createProcessor("SplitByLine", statelessGroup.getId());
+        getClientUtil().updateProcessorProperties(splitByLine, Map.of("Use 
Clone", "true"));
+
+        final ConnectionEntity sourceToInput = 
getClientUtil().createConnection(sourceGenerator, inputPort, "success");
+        getClientUtil().createConnection(inputPort, splitByLine, 
statelessGroup.getId());
+        getClientUtil().createConnection(splitByLine, outputPort, "success", 
statelessGroup.getId());
+
+        // The output queue is connected to an unstarted TerminateFlowFile so 
children stay queued
+        // and remain readable for the assertion phase. The act of bridging 
the children back to the
+        // framework from the Stateless group is itself sufficient to expose 
the bug; no manual
+        // delete on the output side is required.
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile");
+        final ConnectionEntity outputToTerminate = 
getClientUtil().createConnection(outputPort, terminate);
+
+        getClientUtil().waitForValidProcessor(sourceGenerator.getId());
+        getClientUtil().waitForValidProcessor(splitByLine.getId());
+
+        getClientUtil().startProcessor(sourceGenerator);
+        waitForQueueCount(sourceToInput.getId(), 1);
+        getClientUtil().stopProcessor(sourceGenerator);
+
+        getClientUtil().startProcessGroupComponents(statelessGroup.getId());
+
+        // Wait for the Stateless invocation to land all of its children in 
the framework queue.
+        // Internally this is the moment StatelessFlowTask calls 
flowFileRepository.updateRepository
+        // with one DELETE record for the input FlowFile and LINE_COUNT 
UPDATE-without-modified
+        // records for the children. With the bug present, the shared 
ContentClaim's truncation
+        // reference count drops to zero on this single repository call and 
the claim is enqueued
+        // for truncation by the periodic TruncateClaims task.
+        waitForQueueCount(outputToTerminate.getId(), LINE_COUNT);
+        getClientUtil().stopProcessGroupComponents(statelessGroup.getId());
+
+        // Allow the periodic TruncateClaims task to run. With the bug 
present, this is when the
+        // shared Resource Claim file is truncated back to the input 
FlowFile's ContentClaim offset
+        // and the children's bytes are destroyed.
+        Thread.sleep(5_000L);
+
+        // Read every child and verify its content matches the corresponding 
line. With the bug
+        // present, the underlying Resource Claim file has been shrunk to 
ContentClaim.getOffset()
+        // and these reads return zero bytes (or fewer than expected) for 
every child FlowFile that
+        // lived inside the truncated region of the file.
+        for (int childIndex = 0; childIndex < LINE_COUNT; childIndex++) {
+            final byte[] expectedContent = 
expectedLines[childIndex].getBytes(StandardCharsets.UTF_8);
+            final byte[] actualContent = 
getClientUtil().getFlowFileContentAsByteArray(outputToTerminate.getId(), 
childIndex);
+            assertArrayEquals(expectedContent, actualContent,
+                    "Child FlowFile at queue index " + childIndex + " has 
unexpected content; expected "
+                            + expectedContent.length + " bytes, got " + 
actualContent.length);
+        }
+    }
+}


Reply via email to