This is an automated email from the ASF dual-hosted git repository.

pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi-api.git


The following commit(s) were added to refs/heads/main by this push:
     new ef1d975  NIFI-16045: Initial API for Processor / Connector Backlog 
(#98)
ef1d975 is described below

commit ef1d975eb853e55a35d938b6b3d7d6abd1b8b605
Author: Mark Payne <[email protected]>
AuthorDate: Thu Jun 25 11:37:50 2026 -0400

    NIFI-16045: Initial API for Processor / Connector Backlog (#98)
---
 .../java/org/apache/nifi/components/Backlog.java   | 437 +++++++++++++++++++++
 .../nifi/components/BacklogReportingException.java |  53 +++
 .../connector/BacklogReportingConnector.java       | 102 +++++
 .../nifi/components/connector/Connector.java       |  13 +-
 .../connector/components/ConnectionFacade.java     |  14 +
 .../connector/components/ProcessorFacade.java      |  38 ++
 .../connector/components/QueueSnapshot.java        |  68 ++++
 .../nifi/processor/BacklogReportingProcessor.java  |  97 +++++
 .../org/apache/nifi/components/TestBacklog.java    | 144 +++++++
 9 files changed, 961 insertions(+), 5 deletions(-)

diff --git a/src/main/java/org/apache/nifi/components/Backlog.java 
b/src/main/java/org/apache/nifi/components/Backlog.java
new file mode 100644
index 0000000..29402b7
--- /dev/null
+++ b/src/main/java/org/apache/nifi/components/Backlog.java
@@ -0,0 +1,437 @@
+/*
+ * 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.components;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalLong;
+
+/**
+ * <p>
+ *     An immutable description of how much work remains for a Connector or 
Processor
+ *     to consume from its source system. A {@code Backlog} may report any 
combination of
+ *     FlowFile count, byte count, and record count, and may also indicate the 
most recent
+ *     time at which the component observed itself as fully caught up with the 
source.
+ * </p>
+ *
+ * <p>
+ *     All four dimensions are optional and independent. A component reports 
only those
+ *     dimensions that it can determine. For example, a Connector pulling from 
an object
+ *     store may know both the number of objects remaining and the total bytes 
those
+ *     objects represent, while a Connector pulling from a streaming system 
may know only
+ *     the number of records remaining.
+ * </p>
+ *
+ * <p>
+ *     The numeric dimensions are interpreted in light of the {@link 
Precision} attribute,
+ *     which applies to the entire {@code Backlog}. {@link Precision#EXACT} 
(the default)
+ *     means the values are exact counts; {@link Precision#AT_LEAST} means the 
values are
+ *     lower bounds and the real counts may be higher.
+ * </p>
+ *
+ * <p>
+ *     The {@link #getLastCaughtUp() lastCaughtUp} timestamp is unaffected by 
precision and
+ *     is always treated as exact. It represents the most recent moment the 
component
+ *     determined that there was zero data remaining to pull from the source. 
It is computed
+ *     by the component using whatever knowledge it has — typically during its 
normal polling
+ *     of the source — and is not merely the time a backlog query last 
returned zero.
+ * </p>
+ *
+ * <p>
+ *     Backlogs are composed via {@link #plus(Backlog)}; see that method for 
the per-field
+ *     combination rules.
+ * </p>
+ *
+ * <p>
+ *     This API is experimental and may change without notice between releases.
+ * </p>
+ */
+public final class Backlog {
+
+    private final OptionalLong flowFileCount;
+    private final OptionalLong byteCount;
+    private final OptionalLong recordCount;
+    private final Optional<Instant> lastCaughtUp;
+    private final Precision precision;
+
+    private Backlog(final Builder builder) {
+        this.flowFileCount = builder.flowFileCount;
+        this.byteCount = builder.byteCount;
+        this.recordCount = builder.recordCount;
+        this.lastCaughtUp = builder.lastCaughtUp;
+        this.precision = builder.precision;
+    }
+
+    /**
+     * @return the number of FlowFiles remaining on the source, if known
+     */
+    public OptionalLong getFlowFileCount() {
+        return flowFileCount;
+    }
+
+    /**
+     * @return the total number of bytes remaining on the source, if known
+     */
+    public OptionalLong getByteCount() {
+        return byteCount;
+    }
+
+    /**
+     * @return the number of records remaining on the source, if known
+     */
+    public OptionalLong getRecordCount() {
+        return recordCount;
+    }
+
+    /**
+     * @return the most recent moment the component observed itself as fully 
caught up with the source, if known
+     */
+    public Optional<Instant> getLastCaughtUp() {
+        return lastCaughtUp;
+    }
+
+    /**
+     * @return the {@link Precision} of the numeric dimensions of this 
Backlog. Never null.
+     */
+    public Precision getPrecision() {
+        return precision;
+    }
+
+    /**
+     * <p>
+     *     Combines this Backlog with another to produce a new {@code Backlog} 
describing
+     *     the union of the two. Useful for composing a flow-wide backlog from 
multiple sources
+     *     such as multiple Processor reports and queue snapshots.
+     * </p>
+     *
+     * <p>
+     *     Combination rules:
+     * </p>
+     * <ul>
+     *     <li>
+     *         Numeric dimensions ({@code flowFileCount}, {@code byteCount}, 
{@code recordCount}):
+     *         if both sides have a value, the result is their sum. If only 
one side has a value,
+     *         it is carried through but the combined {@link Precision} is 
downgraded to
+     *         {@link Precision#AT_LEAST} (see the precision rule below) when 
the other side reports
+     *         at least one numeric dimension, because the side that did not 
report the dimension is
+     *         unknown rather than zero. If the other side reports no numeric 
dimensions at all, the
+     *         known value is carried through without adding numeric 
uncertainty. If neither side has
+     *         a value, the field stays empty.
+     *     </li>
+     *     <li>
+     *         {@link #getLastCaughtUp() lastCaughtUp}: <b>not summable.</b> 
If both sides have a value,
+     *         the result is the earlier of the two timestamps — the more 
conservative claim about how
+     *         recently the system was fully caught up. If only one side has 
it, that one is carried
+     *         through.
+     *     </li>
+     *     <li>
+     *         {@link #getPrecision() precision}: the result is {@link 
Precision#EXACT} only when both
+     *         sides are {@code EXACT} <i>and</i> either both sides report the 
same set of populated
+     *         numeric dimensions or one side reports no numeric dimensions at 
all. Otherwise the
+     *         result is {@link Precision#AT_LEAST}. Any uncertainty in either 
operand — including a
+     *         missing dimension on one side that the other side reported — 
taints the result,
+     *         because "unknown" must not be treated as zero.
+     *     </li>
+     * </ul>
+     *
+     * @param other the other Backlog to combine with this one
+     * @return a new Backlog representing the combination
+     * @throws ArithmeticException if a numeric sum would overflow {@code long}
+     */
+    public Backlog plus(final Backlog other) {
+        Objects.requireNonNull(other, "other Backlog must not be null");
+
+        final OptionalLong sumFlowFiles = sumOptional(flowFileCount, 
other.flowFileCount);
+        final OptionalLong sumBytes = sumOptional(byteCount, other.byteCount);
+        final OptionalLong sumRecords = sumOptional(recordCount, 
other.recordCount);
+        final Optional<Instant> earliestCaughtUp = earlierOf(lastCaughtUp, 
other.lastCaughtUp);
+
+        final boolean reportsNumericDimensions = flowFileCount.isPresent() || 
byteCount.isPresent() || recordCount.isPresent();
+        final boolean otherReportsNumericDimensions = 
other.flowFileCount.isPresent() || other.byteCount.isPresent() || 
other.recordCount.isPresent();
+
+        // A dimension that one side reports and the other does not is 
"unknown" on the omitting side,
+        // not zero. Carrying the known value forward and still reporting 
EXACT would let the result
+        // claim completeness it cannot back up, so any such asymmetry forces 
AT_LEAST. However, a
+        // Backlog with no numeric dimensions contributes only non-numeric 
information, such as a
+        // lastCaughtUp timestamp, and therefore does not make numeric counts 
less exact.
+        final boolean dimensionsAsymmetric = reportsNumericDimensions && 
otherReportsNumericDimensions
+                && (flowFileCount.isPresent() != 
other.flowFileCount.isPresent()
+                || byteCount.isPresent() != other.byteCount.isPresent()
+                || recordCount.isPresent() != other.recordCount.isPresent());
+        final boolean precisionExact = precision == Precision.EXACT || 
!reportsNumericDimensions;
+        final boolean otherPrecisionExact = other.precision == Precision.EXACT 
|| !otherReportsNumericDimensions;
+        final Precision combinedPrecision = (precisionExact && 
otherPrecisionExact && !dimensionsAsymmetric) ? Precision.EXACT : 
Precision.AT_LEAST;
+
+        final Builder builder = new Builder().precision(combinedPrecision);
+        if (sumFlowFiles.isPresent()) {
+            builder.flowFiles(sumFlowFiles.getAsLong());
+        }
+
+        if (sumBytes.isPresent()) {
+            builder.bytes(sumBytes.getAsLong());
+        }
+
+        if (sumRecords.isPresent()) {
+            builder.records(sumRecords.getAsLong());
+        }
+
+        earliestCaughtUp.ifPresent(builder::lastCaughtUp);
+        return builder.build();
+    }
+
+    private static OptionalLong sumOptional(final OptionalLong left, final 
OptionalLong right) {
+        if (left.isPresent() && right.isPresent()) {
+            return OptionalLong.of(Math.addExact(left.getAsLong(), 
right.getAsLong()));
+        }
+
+        if (left.isPresent()) {
+            return left;
+        }
+
+        if (right.isPresent()) {
+            return right;
+        }
+
+        return OptionalLong.empty();
+    }
+
+    private static Optional<Instant> earlierOf(final Optional<Instant> left, 
final Optional<Instant> right) {
+        if (left.isPresent() && right.isPresent()) {
+            return left.get().isBefore(right.get()) ? left : right;
+        }
+
+        if (left.isPresent()) {
+            return left;
+        }
+
+        return right;
+    }
+
+    /**
+     * Creates a Backlog whose only populated dimension is the FlowFile count, 
with {@link Precision#EXACT}.
+     *
+     * @param count the number of FlowFiles remaining on the source; must be 
non-negative
+     * @return a new Backlog
+     */
+    public static Backlog flowFiles(final long count) {
+        return new Builder().flowFiles(count).build();
+    }
+
+    /**
+     * Creates a Backlog whose only populated dimension is the byte count, 
with {@link Precision#EXACT}.
+     *
+     * @param bytes the number of bytes remaining on the source; must be 
non-negative
+     * @return a new Backlog
+     */
+    public static Backlog bytes(final long bytes) {
+        return new Builder().bytes(bytes).build();
+    }
+
+    /**
+     * Creates a Backlog whose only populated dimension is the record count, 
with {@link Precision#EXACT}.
+     *
+     * @param count the number of records remaining on the source; must be 
non-negative
+     * @return a new Backlog
+     */
+    public static Backlog records(final long count) {
+        return new Builder().records(count).build();
+    }
+
+    /**
+     * Creates a Backlog populated with the provided {@code lastCaughtUp} 
timestamp. Useful for
+     * combining with a count-only Backlog via {@link #plus(Backlog)}.
+     *
+     * @param instant the moment at which the component was last observed as 
fully caught up; may be
+     *        null, in which case the returned Backlog has no populated 
dimensions
+     * @return a new Backlog
+     */
+    public static Backlog lastCaughtUp(final Instant instant) {
+        return new Builder().lastCaughtUp(instant).build();
+    }
+
+    /**
+     * <p>
+     *     Returns the canonical "fully caught up" Backlog: zero FlowFiles, 
zero bytes, zero records,
+     *     and a {@code lastCaughtUp} timestamp of {@link Instant#now()}.
+     * </p>
+     *
+     * <p>
+     *     This state is the unambiguous "I am fully synchronized with the 
source as of now" signal.
+     * </p>
+     *
+     * @return a new "caught up" Backlog
+     */
+    public static Backlog caughtUp() {
+        return new Builder()
+                .flowFiles(0L)
+                .bytes(0L)
+                .records(0L)
+                .lastCaughtUp(Instant.now())
+                .build();
+    }
+
+    /**
+     * @return a new {@link Builder} for constructing a Backlog with multiple 
dimensions and/or {@link Precision#AT_LEAST}
+     */
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    @Override
+    public boolean equals(final Object object) {
+        if (this == object) {
+            return true;
+        }
+
+        if (!(object instanceof Backlog)) {
+            return false;
+        }
+
+        final Backlog other = (Backlog) object;
+        return Objects.equals(flowFileCount, other.flowFileCount)
+                && Objects.equals(byteCount, other.byteCount)
+                && Objects.equals(recordCount, other.recordCount)
+                && Objects.equals(lastCaughtUp, other.lastCaughtUp)
+                && precision == other.precision;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(flowFileCount, byteCount, recordCount, 
lastCaughtUp, precision);
+    }
+
+    @Override
+    public String toString() {
+        return "Backlog["
+                + "flowFileCount=" + flowFileCount
+                + ", byteCount=" + byteCount
+                + ", recordCount=" + recordCount
+                + ", lastCaughtUp=" + lastCaughtUp
+                + ", precision=" + precision
+                + "]";
+    }
+
+    /**
+     * Builder for constructing a {@link Backlog} that populates more than one 
dimension or uses a
+     * non-default {@link Precision}. For single-dimension Backlogs, prefer 
the static factory methods
+     * such as {@link Backlog#flowFiles(long)}.
+     */
+    public static final class Builder {
+
+        private OptionalLong flowFileCount = OptionalLong.empty();
+        private OptionalLong byteCount = OptionalLong.empty();
+        private OptionalLong recordCount = OptionalLong.empty();
+        private Optional<Instant> lastCaughtUp = Optional.empty();
+        private Precision precision = Precision.EXACT;
+
+        /**
+         * Sets the FlowFile count. Overwrites any previously set value.
+         *
+         * @param count the number of FlowFiles remaining on the source; must 
be non-negative
+         * @return this Builder
+         * @throws IllegalArgumentException if {@code count} is negative
+         */
+        public Builder flowFiles(final long count) {
+            requireNonNegative("flowFiles", count);
+            this.flowFileCount = OptionalLong.of(count);
+            return this;
+        }
+
+        /**
+         * Sets the byte count. Overwrites any previously set value.
+         *
+         * @param bytes the number of bytes remaining on the source; must be 
non-negative
+         * @return this Builder
+         * @throws IllegalArgumentException if {@code bytes} is negative
+         */
+        public Builder bytes(final long bytes) {
+            requireNonNegative("bytes", bytes);
+            this.byteCount = OptionalLong.of(bytes);
+            return this;
+        }
+
+        /**
+         * Sets the record count. Overwrites any previously set value.
+         *
+         * @param count the number of records remaining on the source; must be 
non-negative
+         * @return this Builder
+         * @throws IllegalArgumentException if {@code count} is negative
+         */
+        public Builder records(final long count) {
+            requireNonNegative("records", count);
+            this.recordCount = OptionalLong.of(count);
+            return this;
+        }
+
+        /**
+         * Sets the timestamp at which the component was last observed as 
fully caught up. Overwrites
+         * any previously set value.
+         *
+         * @param instant the moment at which the component was last fully 
caught up; may be null to clear
+         * @return this Builder
+         */
+        public Builder lastCaughtUp(final Instant instant) {
+            this.lastCaughtUp = Optional.ofNullable(instant);
+            return this;
+        }
+
+        /**
+         * Sets the {@link Precision} for this Backlog. Defaults to {@link 
Precision#EXACT} if not set.
+         *
+         * @param precision the precision; must not be null
+         * @return this Builder
+         */
+        public Builder precision(final Precision precision) {
+            this.precision = Objects.requireNonNull(precision, "precision must 
not be null");
+            return this;
+        }
+
+        /**
+         * @return a new immutable {@link Backlog} reflecting the values set 
on this Builder
+         */
+        public Backlog build() {
+            return new Backlog(this);
+        }
+
+        private static void requireNonNegative(final String fieldName, final 
long value) {
+            if (value < 0L) {
+                throw new IllegalArgumentException(fieldName + " must be 
non-negative but was " + value);
+            }
+        }
+    }
+
+    /**
+     * <p>
+     *     The precision of the numeric dimensions of a {@link Backlog}. 
Applies to the entire Backlog
+     *     rather than to individual dimensions.
+     * </p>
+     */
+    public enum Precision {
+
+        /**
+         * Numeric dimensions reflect exact counts.
+         */
+        EXACT,
+
+        /**
+         * Numeric dimensions are lower bounds; the real counts may be higher.
+         */
+        AT_LEAST
+    }
+}
diff --git 
a/src/main/java/org/apache/nifi/components/BacklogReportingException.java 
b/src/main/java/org/apache/nifi/components/BacklogReportingException.java
new file mode 100644
index 0000000..942c62c
--- /dev/null
+++ b/src/main/java/org/apache/nifi/components/BacklogReportingException.java
@@ -0,0 +1,53 @@
+/*
+ * 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.components;
+
+/**
+ * <p>
+ *     Thrown by a Connector or Processor that supports backlog reporting but 
cannot
+ *     determine its current backlog due to a transient or permanent failure. 
Typical causes
+ *     include I/O errors reaching the source system, authorization failures 
(the component is
+ *     allowed to consume data but is not authorized to call the offset or 
listing API), and
+ *     state errors that prevent computation.
+ * </p>
+ *
+ * <p>
+ *     This exception is distinct from a Connector or Processor not supporting 
backlog reporting
+ *     at all. Components that do not support backlog reporting simply do not 
implement
+ *     {@link org.apache.nifi.processor.BacklogReportingProcessor} or
+ *     {@link org.apache.nifi.components.connector.BacklogReportingConnector}. 
Components that do
+ *     support backlog reporting may return {@link java.util.Optional#empty()} 
when they cannot
+ *     determine a value right now, or throw this exception when an attempt to 
determine the
+ *     backlog fails. Callers should surface the cause rather than silently 
treat the failure as
+ *     "not supported".
+ * </p>
+ */
+public class BacklogReportingException extends Exception {
+
+    public BacklogReportingException(final String message) {
+        super(message);
+    }
+
+    public BacklogReportingException(final String message, final Throwable 
cause) {
+        super(message, cause);
+    }
+
+    public BacklogReportingException(final Throwable cause) {
+        super(cause);
+    }
+}
diff --git 
a/src/main/java/org/apache/nifi/components/connector/BacklogReportingConnector.java
 
b/src/main/java/org/apache/nifi/components/connector/BacklogReportingConnector.java
new file mode 100644
index 0000000..b71f8b6
--- /dev/null
+++ 
b/src/main/java/org/apache/nifi/components/connector/BacklogReportingConnector.java
@@ -0,0 +1,102 @@
+/*
+ * 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.components.connector;
+
+import org.apache.nifi.components.Backlog;
+import org.apache.nifi.components.BacklogReportingException;
+import org.apache.nifi.components.connector.components.FlowContext;
+import org.apache.nifi.components.connector.components.ProcessorFacade;
+
+import java.util.Optional;
+
+/**
+ * <p>
+ *     Optional capability interface implemented by {@link Connector}s that 
can report a
+ *     {@link Backlog} describing how much data remains on the source 
system(s) the Connector
+ *     pulls from but has not yet brought into NiFi. Implementing this 
interface is the opt-in
+ *     signal: Connectors that do not implement it are simply not asked for 
backlog, and the
+ *     framework's REST endpoints return HTTP {@code 409 Conflict} rather than 
treating "not
+ *     supported" the same as "supported but currently unknown".
+ * </p>
+ *
+ * <p>
+ *     This mirrors the {@code BacklogReportingProcessor} capability interface 
on the Processor
+ *     side: the {@code Connector} base interface stays focused on lifecycle 
and configuration,
+ *     and backlog reporting is layered on as a separate opt-in.
+ * </p>
+ *
+ * <p>
+ *     Implementations that do report backlog typically derive the value from 
a single source —
+ *     most often a single Processor's {@code BacklogReportingProcessor} 
report exposed via
+ *     {@link ProcessorFacade#getBacklog()}. Some Connectors may compose a 
flow-wide picture from
+ *     multiple sources: for example, summing the {@link Backlog} from 
multiple Processors, or, for
+ *     a List/Fetch pattern, also inspecting queues and FlowFile attributes to 
account for data that
+ *     has been "Listed" but not yet "Fetched".
+ * </p>
+ *
+ * <h2>Return value semantics</h2>
+ * <ul>
+ *     <li>
+ *         {@link Optional#empty()} means the Connector cannot report a {@link 
Backlog} right now,
+ *         even though it implements this interface. This is distinct from a 
hard failure.
+ *     </li>
+ *     <li>
+ *         <code>Optional.of(Backlog.caughtUp())</code> means the Connector is 
fully synchronized
+ *         with its source as of now.
+ *     </li>
+ *     <li>
+ *         <code>Optional.of(...)</code> with non-zero numeric dimensions 
means the Connector knows
+ *         that this much data remains on the source. See {@link 
Backlog.Precision} for whether the
+ *         numbers are exact or lower bounds.
+ *     </li>
+ * </ul>
+ *
+ * <p>
+ *     Hard failures — source unreachable, authorization denied, state errors 
that prevent
+ *     computation — are reported by throwing {@link 
BacklogReportingException}, not by returning
+ *     {@link Optional#empty()}. This keeps failures distinguishable from "no 
value available."
+ * </p>
+ *
+ * <p>
+ *     <b>Implementation Note:</b> This API is currently experimental, as it 
is under very active
+ *     development. As such, it is subject to change without notice between 
releases.
+ * </p>
+ */
+public interface BacklogReportingConnector {
+
+    /**
+     * <p>
+     *     Reports how much data remains on the source system(s) that this 
Connector pulls from but
+     *     has not yet brought into NiFi.
+     * </p>
+     *
+     * <p>
+     *     This method is invoked against the <b>active</b> {@link 
FlowContext}, because backlog is
+     *     a runtime property of the running flow. The working flow context is 
not used for backlog
+     *     reporting. Expected call frequency is low — typically on the order 
of once per minute,
+     *     sometimes once every few minutes. Implementations may cache results 
internally if computing
+     *     backlog puts non-trivial load on the source, but caching is not 
required.
+     * </p>
+     *
+     * @param activeFlowContext the active flow context against which to 
compute backlog
+     * @return the Connector's reported {@link Backlog}, or {@link 
Optional#empty()} if the Connector
+     *         cannot determine a value right now (for example, it has never 
polled the source)
+     * @throws BacklogReportingException if the Connector attempted to 
determine its backlog and failed
+     */
+    Optional<Backlog> getBacklog(final FlowContext activeFlowContext) throws 
BacklogReportingException;
+}
diff --git a/src/main/java/org/apache/nifi/components/connector/Connector.java 
b/src/main/java/org/apache/nifi/components/connector/Connector.java
index 2fcafb0..36d2998 100644
--- a/src/main/java/org/apache/nifi/components/connector/Connector.java
+++ b/src/main/java/org/apache/nifi/components/connector/Connector.java
@@ -32,7 +32,7 @@ import java.util.concurrent.CompletableFuture;
  *     A Connector is a component that encapsulates and manages a NiFi flow, 
in such a way that the flow
  *     can be treated as a single component. The Connector is responsible for 
managing the lifecycle of the flow,
  *     including starting and stopping the flow, as well as validating that 
the flow is correctly configured.
- *     The Connector exposes a single holistic configuration that is 
encapsulates the configuration of the
+ *     The Connector exposes a single holistic configuration that encapsulates 
the configuration of the
  *     sources, sinks, transformations, routing logic, and any other 
components that make up the flow.
  * </p>
  *
@@ -117,7 +117,7 @@ public interface Connector {
 
     /**
      * Validates that the Connector is valid according to its current 
configuration. Validity of a Connector may be
-     * defined simply as the all components being valid, or it may encompass 
more complex validation logic, such
+     * defined simply as all components being valid, or it may encompass more 
complex validation logic, such
      * as ensuring that a Source Processor is able to connect to a remote 
system, or that a Sink Processor
      * is able to write to a remote system.
      *
@@ -131,7 +131,7 @@ public interface Connector {
 
     /**
      * Validates the configuration for a specific configuration step. This 
allows the Connector to indicate any
-     * issues with syntactic configuration issues but is not as comprehensive 
as the overall validation provided
+     * syntax issues in the configuration but is not as comprehensive as the 
overall validation provided
      * by {@link #validate(FlowContext, ConnectorValidationContext)} due to 
the fact that it does not have access
      * to the full configuration of the Connector. This provides immediate 
feedback to users
      * as they are configuring each step.
@@ -146,8 +146,8 @@ public interface Connector {
 
     /**
      * Verifies the configuration for a specific configuration step. This 
allows the Connector to perform
-     * more comprehensive verification of the configuration for a step than 
does validation, such as attempting to connect to
-     * remote systems, sample data and ensure that it can be parsed correctly, 
etc.
+     * more comprehensive verification of the configuration for a step than 
does validation, such as connecting to
+     * remote systems or sampling data and ensuring that it can be parsed 
correctly.
      *
      * @param stepName the name of the configuration step being verified
      * @param propertyValueOverrides any overrides to the currently configured 
property values that should be used for verification
@@ -181,6 +181,7 @@ public interface Connector {
      *
      * @param stepName the name of the step
      * @param workingFlowContext the working flow context that is being used 
for the update
+     * @throws FlowUpdateException if there is an error updating the flow for 
the configured step
      */
     void onConfigurationStepConfigured(String stepName, FlowContext 
workingFlowContext) throws FlowUpdateException;
 
@@ -190,6 +191,7 @@ public interface Connector {
      *
      * @param workingFlowContext the working flow context that has been 
created for the update
      * @param activeFlowContext the active flow context that is currently in 
use
+     * @throws FlowUpdateException if the Connector cannot prepare the flow 
for the update
      */
     void prepareForUpdate(FlowContext workingFlowContext, FlowContext 
activeFlowContext) throws FlowUpdateException;
 
@@ -210,6 +212,7 @@ public interface Connector {
      *
      * @param workingFlowContext the working flow context that represents the 
updated configuration
      * @param activeFlowContext the flow context that represents the active 
flow
+     * @throws FlowUpdateException if the updated configuration cannot be 
applied to the active flow
      */
     void applyUpdate(FlowContext workingFlowContext, FlowContext 
activeFlowContext) throws FlowUpdateException;
 
diff --git 
a/src/main/java/org/apache/nifi/components/connector/components/ConnectionFacade.java
 
b/src/main/java/org/apache/nifi/components/connector/components/ConnectionFacade.java
index ff17d2b..79ea9b9 100644
--- 
a/src/main/java/org/apache/nifi/components/connector/components/ConnectionFacade.java
+++ 
b/src/main/java/org/apache/nifi/components/connector/components/ConnectionFacade.java
@@ -25,6 +25,12 @@ import org.apache.nifi.flowfile.FlowFile;
 import java.io.IOException;
 import java.util.function.Predicate;
 
+/**
+ * <p>
+ *     Facade exposing per-Connection operations to a Connector 
implementation. The framework constructs
+ *     and supplies these facades; Connector extensions do not implement this 
interface themselves.
+ * </p>
+ */
 public interface ConnectionFacade {
 
     VersionedConnection getDefinition();
@@ -49,4 +55,12 @@ public interface ConnectionFacade {
      */
     DropFlowFileSummary dropFlowFiles(Predicate<FlowFile> predicate) throws 
IOException;
 
+    /**
+     * Returns a read-only point-in-time snapshot of this connection's queue. 
See
+     * {@link QueueSnapshot} for the snapshot semantics.
+     *
+     * @return a non-null snapshot of the connection's queue
+     */
+    QueueSnapshot getQueueSnapshot();
+
 }
diff --git 
a/src/main/java/org/apache/nifi/components/connector/components/ProcessorFacade.java
 
b/src/main/java/org/apache/nifi/components/connector/components/ProcessorFacade.java
index bb57e3f..23f4f22 100644
--- 
a/src/main/java/org/apache/nifi/components/connector/components/ProcessorFacade.java
+++ 
b/src/main/java/org/apache/nifi/components/connector/components/ProcessorFacade.java
@@ -17,16 +17,26 @@
 
 package org.apache.nifi.components.connector.components;
 
+import org.apache.nifi.components.Backlog;
+import org.apache.nifi.components.BacklogReportingException;
 import org.apache.nifi.components.ConfigVerificationResult;
 import org.apache.nifi.components.ValidationResult;
 import org.apache.nifi.components.connector.InvocationFailedException;
 import org.apache.nifi.flow.VersionedExternalFlow;
 import org.apache.nifi.flow.VersionedParameterContext;
 import org.apache.nifi.flow.VersionedProcessor;
+import org.apache.nifi.processor.BacklogReportingProcessor;
 
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
+/**
+ * <p>
+ *     Facade exposing per-Processor operations to a Connector implementation. 
The framework constructs
+ *     and supplies these facades; Connector extensions do not implement this 
interface themselves.
+ * </p>
+ */
 public interface ProcessorFacade {
 
     VersionedProcessor getDefinition();
@@ -75,4 +85,32 @@ public interface ProcessorFacade {
      * @throws InvocationFailedException if unable to invoke the method
      */
     <T> T invokeConnectorMethod(String methodName, Map<String, Object> 
arguments, Class<T> returnType) throws InvocationFailedException;
+
+    /**
+     * Indicates whether the underlying Processor implements {@link 
BacklogReportingProcessor}. This
+     * is a cheap capability check that does not call into the Processor.
+     *
+     * @return {@code true} if the underlying Processor implements {@link 
BacklogReportingProcessor};
+     *         {@code false} otherwise
+     */
+    boolean reportsBacklog();
+
+    /**
+     * <p>
+     *     Returns the underlying Processor's reported {@link Backlog}. This 
is the bridge a
+     *     Connector uses to ask a Processor in its flow how much data remains 
on the source.
+     * </p>
+     *
+     * <p>
+     *     The return value and exception semantics mirror those of
+     *     {@link 
BacklogReportingProcessor#getBacklog(org.apache.nifi.processor.ProcessContext)}.
 If
+     *     the underlying Processor does not implement {@link 
BacklogReportingProcessor}, this method
+     *     returns {@link Optional#empty()}.
+     * </p>
+     *
+     * @return the Processor's reported {@link Backlog}, or {@link 
Optional#empty()} if the Processor does not
+     *         implement {@link BacklogReportingProcessor} or has nothing to 
report
+     * @throws BacklogReportingException if the Processor attempted to 
determine its backlog and failed
+     */
+    Optional<Backlog> getBacklog() throws BacklogReportingException;
 }
diff --git 
a/src/main/java/org/apache/nifi/components/connector/components/QueueSnapshot.java
 
b/src/main/java/org/apache/nifi/components/connector/components/QueueSnapshot.java
new file mode 100644
index 0000000..da520da
--- /dev/null
+++ 
b/src/main/java/org/apache/nifi/components/connector/components/QueueSnapshot.java
@@ -0,0 +1,68 @@
+/*
+ * 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.components.connector.components;
+
+import org.apache.nifi.controller.queue.QueueSize;
+import org.apache.nifi.flowfile.FlowFile;
+
+import java.util.List;
+
+/**
+ * <p>
+ *     A point-in-time view of a connection's queue, returned by
+ *     {@link ConnectionFacade#getQueueSnapshot()}. Bundles the connection's 
total {@link QueueSize}
+ *     together with the {@link FlowFile}s currently held in this node's 
active in-memory queue,
+ *     meaning the FlowFiles resident in memory on this node and available in 
poll order.
+ * </p>
+ *
+ * <p>
+ *     The active list and the {@link QueueSize} are captured atomically, so 
they describe the same
+ *     point in time. The active list is not always the entire queue. 
FlowFiles that have been
+ *     swapped out are excluded from {@link #getActiveFlowFiles()}. On 
load-balanced connections,
+ *     FlowFiles in flight between nodes are excluded as well. Those FlowFiles 
are still counted in
+ *     {@link #getQueueSize()}. Use {@link #isActiveListExhaustive()} to 
determine whether the
+ *     active list contains every FlowFile counted in the queue size.
+ * </p>
+ *
+ * <p>
+ *     {@link #getActiveFlowFiles()} returns the full active list without a 
caller-supplied limit, but
+ *     the list itself is bounded by the framework's maximum active in-memory 
queue size. The
+ *     snapshot does not clone the {@link FlowFile} instances; it references 
the existing active
+ *     FlowFiles.
+ * </p>
+ */
+public interface QueueSnapshot {
+
+    /**
+     * @return the total {@link QueueSize} of the connection at the time the 
snapshot was taken
+     */
+    QueueSize getQueueSize();
+
+    /**
+     * @return the active in-memory FlowFiles in poll order, never null; 
excludes FlowFiles that
+     *         have been swapped out and, for load-balanced connections, 
FlowFiles in flight between
+     *         nodes
+     */
+    List<FlowFile> getActiveFlowFiles();
+
+    /**
+     * @return {@code true} if {@link #getActiveFlowFiles()} contains every 
FlowFile counted in
+     *         {@link #getQueueSize()}; otherwise {@code false}
+     */
+    boolean isActiveListExhaustive();
+}
diff --git 
a/src/main/java/org/apache/nifi/processor/BacklogReportingProcessor.java 
b/src/main/java/org/apache/nifi/processor/BacklogReportingProcessor.java
new file mode 100644
index 0000000..24b3df0
--- /dev/null
+++ b/src/main/java/org/apache/nifi/processor/BacklogReportingProcessor.java
@@ -0,0 +1,97 @@
+/*
+ * 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.processor;
+
+import org.apache.nifi.components.Backlog;
+import org.apache.nifi.components.BacklogReportingException;
+
+import java.util.Optional;
+
+/**
+ * <p>
+ *     Optional capability interface implemented by Processors that can report 
a {@link Backlog}
+ *     describing how much data remains on the source system that the 
Processor has not yet pulled
+ *     into NiFi. Implementing this interface is the opt-in signal: Processors 
that do not implement
+ *     it are simply not asked for backlog.
+ * </p>
+ *
+ * <p>
+ *     The reported {@link Backlog} reflects only the data the Processor knows 
about from the source.
+ *     A Connector that composes a flow-wide backlog generally also accounts 
for FlowFiles already
+ *     enqueued in NiFi between processors; that composition is the 
Connector's responsibility, not
+ *     the Processor's.
+ * </p>
+ *
+ * <p>
+ *     The expected call frequency for {@link #getBacklog(ProcessContext)} is 
low — on the order of
+ *     once per minute, sometimes once every few minutes. Implementations may 
cache results internally
+ *     if computing backlog puts non-trivial load on the source, but caching 
is not required.
+ * </p>
+ *
+ * <h2>Return value semantics</h2>
+ * <ul>
+ *     <li>
+ *         {@link Optional#empty()} means the Processor cannot report a {@code 
Backlog} right now,
+ *         even though it implements this interface. This is distinct from a 
hard failure.
+ *     </li>
+ *     <li>
+ *         <code>Optional.of(Backlog.caughtUp())</code> means the Processor is 
fully
+ *         synchronized with its source as of now.
+ *     </li>
+ *     <li>
+ *         <code>Optional.of(...)</code> with non-zero numeric dimensions 
means the Processor knows that
+ *         this much data remains on the source. See {@link Backlog.Precision} 
for whether the
+ *         numbers are exact or lower bounds.
+ *     </li>
+ * </ul>
+ *
+ * <p>
+ *     Hard failures — source unreachable, authorization denied, state errors 
that prevent
+ *     computation — are reported by throwing {@link 
BacklogReportingException}, not by returning
+ *     {@link Optional#empty()}. This keeps failures distinguishable from "no 
value available."
+ * </p>
+ */
+public interface BacklogReportingProcessor {
+
+    /**
+     * Returns the Processor's current view of how much data remains on the 
source.
+     *
+     * <p>
+     *     The framework always supplies a non-null {@link ProcessContext}. 
Implementations may use
+     *     the supplied context to read property values and to build whatever 
transient clients they
+     *     need to query the source. Implementations are responsible for 
closing any transient
+     *     clients they open during the call.
+     * </p>
+     *
+     * <p>
+     *     The framework does not serialize calls to this method. Multiple 
{@code getBacklog}
+     *     invocations may run concurrently against the same Processor 
instance, so implementations
+     *     must be safe for concurrent use. Implementations that open per-call 
transient clients are
+     *     naturally isolated. Implementations that cache shared clients 
across calls must guard
+     *     them appropriately or rely on thread-safe SDK clients.
+     * </p>
+     *
+     * @param context a non-null {@link ProcessContext} usable to look up 
property values and to
+     *         construct transient clients to the source
+     * @return the Processor's reported {@link Backlog}, or {@link 
Optional#empty()} if the Processor
+     *         cannot determine a value right now (e.g. it has never polled 
the source)
+     * @throws BacklogReportingException if the Processor attempted to 
determine its backlog and
+     *         failed due to a transient or permanent error such as I/O 
failure or authorization denial
+     */
+    Optional<Backlog> getBacklog(final ProcessContext context) throws 
BacklogReportingException;
+}
diff --git a/src/test/java/org/apache/nifi/components/TestBacklog.java 
b/src/test/java/org/apache/nifi/components/TestBacklog.java
new file mode 100644
index 0000000..98b514e
--- /dev/null
+++ b/src/test/java/org/apache/nifi/components/TestBacklog.java
@@ -0,0 +1,144 @@
+/*
+ * 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.components;
+
+import org.apache.nifi.components.Backlog.Precision;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.OptionalLong;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class TestBacklog {
+
+    @Test
+    public void 
testPlusSumsDimensionsAndKeepsExactWhenBothSidesReportTheSameShape() {
+        final Backlog left = 
Backlog.builder().flowFiles(10L).bytes(100L).build();
+        final Backlog right = 
Backlog.builder().flowFiles(5L).bytes(50L).build();
+
+        final Backlog combined = left.plus(right);
+
+        assertEquals(OptionalLong.of(15L), combined.getFlowFileCount());
+        assertEquals(OptionalLong.of(150L), combined.getByteCount());
+        assertFalse(combined.getRecordCount().isPresent());
+        assertEquals(Precision.EXACT, combined.getPrecision());
+    }
+
+    @Test
+    public void 
testPlusDowngradesToAtLeastWhenOneSideOmitsADimensionTheOtherReports() {
+        // Left knows only flowFiles; right knows only bytes. The result 
should still carry the known
+        // numeric values, but the precision must be AT_LEAST because each 
side is "unknown" with
+        // respect to the dimension the other side reported. Treating 
"unknown" as zero would let the
+        // combined Backlog falsely claim EXACT completeness.
+        final Backlog left = Backlog.flowFiles(10L);
+        final Backlog right = Backlog.bytes(100L);
+
+        final Backlog combined = left.plus(right);
+
+        assertEquals(OptionalLong.of(10L), combined.getFlowFileCount());
+        assertEquals(OptionalLong.of(100L), combined.getByteCount());
+        assertFalse(combined.getRecordCount().isPresent());
+        assertEquals(Precision.AT_LEAST, combined.getPrecision());
+    }
+
+    @Test
+    public void 
testPlusDowngradesToAtLeastWhenAsymmetricEvenIfBothSidesAreExact() {
+        // Both sides are EXACT individually but report different sets of 
dimensions. The combined
+        // view cannot be EXACT because each side is silent about a dimension 
the other side knows.
+        final Backlog left = 
Backlog.builder().flowFiles(1L).bytes(2L).precision(Precision.EXACT).build();
+        final Backlog right = 
Backlog.builder().flowFiles(3L).bytes(4L).records(5L).precision(Precision.EXACT).build();
+
+        final Backlog combined = left.plus(right);
+
+        assertEquals(OptionalLong.of(4L), combined.getFlowFileCount());
+        assertEquals(OptionalLong.of(6L), combined.getByteCount());
+        assertEquals(OptionalLong.of(5L), combined.getRecordCount());
+        assertEquals(Precision.AT_LEAST, combined.getPrecision());
+    }
+
+    @Test
+    public void testPlusPropagatesAtLeastFromEitherOperand() {
+        final Backlog left = 
Backlog.builder().flowFiles(1L).precision(Precision.AT_LEAST).build();
+        final Backlog right = 
Backlog.builder().flowFiles(2L).precision(Precision.EXACT).build();
+
+        assertEquals(Precision.AT_LEAST, left.plus(right).getPrecision());
+        assertEquals(Precision.AT_LEAST, right.plus(left).getPrecision());
+    }
+
+    @Test
+    public void testPlusKeepsExactWhenCombinedWithTimestampOnlyBacklog() {
+        final Instant lastCaughtUp = Instant.parse("2025-01-01T00:00:00Z");
+        final Backlog countOnly = Backlog.flowFiles(10L);
+        final Backlog timestampOnly = Backlog.lastCaughtUp(lastCaughtUp);
+
+        final Backlog combinedWithTimestampOnRight = 
countOnly.plus(timestampOnly);
+        assertEquals(OptionalLong.of(10L), 
combinedWithTimestampOnRight.getFlowFileCount());
+        assertEquals(Precision.EXACT, 
combinedWithTimestampOnRight.getPrecision());
+        assertEquals(lastCaughtUp, 
combinedWithTimestampOnRight.getLastCaughtUp().orElseThrow());
+
+        final Backlog combinedWithTimestampOnLeft = 
timestampOnly.plus(countOnly);
+        assertEquals(OptionalLong.of(10L), 
combinedWithTimestampOnLeft.getFlowFileCount());
+        assertEquals(Precision.EXACT, 
combinedWithTimestampOnLeft.getPrecision());
+        assertEquals(lastCaughtUp, 
combinedWithTimestampOnLeft.getLastCaughtUp().orElseThrow());
+    }
+
+    @Test
+    public void 
testPlusUsesEarlierLastCaughtUpAndKeepsOnlySideWhenOtherMissing() {
+        final Instant earlier = Instant.parse("2025-01-01T00:00:00Z");
+        final Instant later = Instant.parse("2025-01-02T00:00:00Z");
+
+        final Backlog withEarlier = 
Backlog.builder().flowFiles(0L).lastCaughtUp(earlier).build();
+        final Backlog withLater = 
Backlog.builder().flowFiles(0L).lastCaughtUp(later).build();
+        assertEquals(earlier, 
withEarlier.plus(withLater).getLastCaughtUp().orElseThrow());
+
+        final Backlog withoutTimestamp = Backlog.flowFiles(0L);
+        assertEquals(later, 
withoutTimestamp.plus(withLater).getLastCaughtUp().orElseThrow());
+    }
+
+    @Test
+    public void testPlusOverflowOnLongSum() {
+        final Backlog left = Backlog.flowFiles(Long.MAX_VALUE);
+        final Backlog right = Backlog.flowFiles(1L);
+        assertThrows(ArithmeticException.class, () -> left.plus(right));
+    }
+
+    @Test
+    public void testCaughtUpFactoryProducesZerosAndTimestamp() {
+        final Backlog caughtUp = Backlog.caughtUp();
+        assertEquals(OptionalLong.of(0L), caughtUp.getFlowFileCount());
+        assertEquals(OptionalLong.of(0L), caughtUp.getByteCount());
+        assertEquals(OptionalLong.of(0L), caughtUp.getRecordCount());
+        assertTrue(caughtUp.getLastCaughtUp().isPresent());
+        assertEquals(Precision.EXACT, caughtUp.getPrecision());
+    }
+
+    @Test
+    public void testBuilderRejectsNegativeNumericDimensions() {
+        final IllegalArgumentException flowFilesException = 
assertThrows(IllegalArgumentException.class, () -> 
Backlog.builder().flowFiles(-1L));
+        assertEquals("flowFiles must be non-negative but was -1", 
flowFilesException.getMessage());
+
+        final IllegalArgumentException bytesException = 
assertThrows(IllegalArgumentException.class, () -> 
Backlog.builder().bytes(-1L));
+        assertEquals("bytes must be non-negative but was -1", 
bytesException.getMessage());
+
+        final IllegalArgumentException recordsException = 
assertThrows(IllegalArgumentException.class, () -> 
Backlog.builder().records(-1L));
+        assertEquals("records must be non-negative but was -1", 
recordsException.getMessage());
+    }
+}

Reply via email to