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.git
The following commit(s) were added to refs/heads/main by this push:
new db732669a52 NIFI-16238: Checkpoint write-ahead logs when the journal
reaches a co… (#11575)
db732669a52 is described below
commit db732669a521b3df05b4ee1f2ef1cb1d37c78846
Author: Mark Payne <[email protected]>
AuthorDate: Fri Aug 21 05:48:07 2026 -0400
NIFI-16238: Checkpoint write-ahead logs when the journal reaches a co…
(#11575)
Journals could grow without bound between interval-based checkpoints. A
maximum journal size now triggers a checkpoint so storage is reclaimed even
when updates keep arriving.
---
.../java/org/apache/nifi/util/NiFiProperties.java | 16 +++
.../apache/nifi/wali/LengthDelimitedJournal.java | 17 +++
.../nifi/wali/SequentialAccessWriteAheadLog.java | 86 ++++++++++-
.../org/apache/nifi/wali/WriteAheadJournal.java | 9 ++
.../nifi/wali/TestLengthDelimitedJournal.java | 69 +++++++++
.../wali/TestSequentialAccessWriteAheadLog.java | 121 +++++++++++++++-
.../src/main/asciidoc/administration-guide.adoc | 11 +-
.../repository/WriteAheadFlowFileRepository.java | 7 +-
.../local/WriteAheadLocalStateProvider.java | 16 ++-
.../tasks/NiFiPropertiesDiagnosticTask.java | 1 +
.../TestWriteAheadFlowFileRepository.java | 67 +++++++++
.../local/TestWriteAheadLocalStateProvider.java | 51 +++++++
.../nifi-framework/nifi-resources/pom.xml | 1 +
.../src/main/resources/conf/nifi.properties | 1 +
.../src/main/resources/conf/state-management.xml | 5 +
.../processors/tests/system/AppendLocalState.java | 93 ++++++++++++
.../services/org.apache.nifi.processor.Processor | 1 +
.../repositories/WriteAheadJournalSizeIT.java | 157 +++++++++++++++++++++
.../default/state-management-bounded-journal.xml | 38 +++++
19 files changed, 753 insertions(+), 14 deletions(-)
diff --git
a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
index 2c42ca01859..ad89e7dd6b9 100644
---
a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
+++
b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
@@ -102,6 +102,7 @@ public class NiFiProperties extends ApplicationProperties {
public static final String FLOWFILE_REPOSITORY_ALWAYS_SYNC =
"nifi.flowfile.repository.always.sync";
public static final String FLOWFILE_REPOSITORY_DIRECTORY =
"nifi.flowfile.repository.directory";
public static final String FLOWFILE_REPOSITORY_CHECKPOINT_INTERVAL =
"nifi.flowfile.repository.checkpoint.interval";
+ public static final String FLOWFILE_REPOSITORY_CHECKPOINT_MAX_JOURNAL_SIZE
= "nifi.flowfile.repository.checkpoint.max.journal.size";
public static final String FLOWFILE_SWAP_MANAGER_IMPLEMENTATION =
"nifi.swap.manager.implementation";
public static final String QUEUE_SWAP_THRESHOLD =
"nifi.queue.swap.threshold";
@@ -629,6 +630,21 @@ public class NiFiProperties extends ApplicationProperties {
return getProperty(FLOWFILE_REPOSITORY_CHECKPOINT_INTERVAL,
DEFAULT_FLOWFILE_CHECKPOINT_INTERVAL);
}
+ /**
+ * Returns the amount of storage that the FlowFile Repository's journal is
allowed to consume before the repository is checkpointed,
+ * expressed as a data size such as <code>100 MB</code>.
+ *
+ * @return the configured maximum journal size, or <code>null</code> if
the repository is to be checkpointed only on the configured interval
+ */
+ public String getFlowFileRepositoryMaxJournalSize() {
+ final String maxJournalSize =
getProperty(FLOWFILE_REPOSITORY_CHECKPOINT_MAX_JOURNAL_SIZE);
+ if (maxJournalSize == null || maxJournalSize.isBlank()) {
+ return null;
+ }
+
+ return maxJournalSize.trim();
+ }
+
/**
* @return the restore directory or null if not configured
*/
diff --git
a/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/LengthDelimitedJournal.java
b/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/LengthDelimitedJournal.java
index 71b66d3d228..7d955b974ed 100644
---
a/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/LengthDelimitedJournal.java
+++
b/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/LengthDelimitedJournal.java
@@ -48,6 +48,7 @@ import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.atomic.AtomicLong;
public class LengthDelimitedJournal<T> implements WriteAheadJournal<T> {
private static final Logger logger =
LoggerFactory.getLogger(LengthDelimitedJournal.class);
@@ -74,6 +75,8 @@ public class LengthDelimitedJournal<T> implements
WriteAheadJournal<T> {
private int transactionCount;
private boolean headerWritten = false;
+ private final AtomicLong bytesWritten = new AtomicLong(0L);
+
private volatile Throwable poisonCause = null;
private volatile boolean closed = false;
private final ByteBuffer transactionPreamble = ByteBuffer.allocate(12); //
guarded by synchronized block
@@ -172,6 +175,7 @@ public class LengthDelimitedJournal<T> implements
WriteAheadJournal<T> {
}
outStream.flush();
+ bytesWritten.addAndGet(outStream.size());
} catch (final Throwable t) {
poison(t);
@@ -299,6 +303,9 @@ public class LengthDelimitedJournal<T> implements
WriteAheadJournal<T> {
}
}
+ // The overflow file is fully written and synced at this point, so
its length is stable.
+ final long overflowFileLength = (overflowFile == null) ? 0L :
overflowFile.length();
+
final ByteArrayOutputStream baos =
bados.getByteArrayOutputStream();
final OutputStream out = getOutputStream();
@@ -318,6 +325,11 @@ public class LengthDelimitedJournal<T> implements
WriteAheadJournal<T> {
out.write(transactionPreamble.array());
baos.writeTo(out);
out.flush();
+
+ // A transaction consumes a single-byte marker, the
fixed-length preamble, and the serialized records. Storage for an
+ // overflow file is counted only now that the transaction
referencing it has been written, so that a failed update,
+ // whose overflow file is deleted, does not inflate the
count.
+ bytesWritten.addAndGet(1L + transactionPreamble.capacity()
+ baos.size() + overflowFileLength);
} catch (final Throwable t) {
// While the outer Throwable that wraps this "catch" will
call Poison, it is imperative that we call poison()
// before the synchronized block is excited. Otherwise,
another thread could potentially corrupt the journal before
@@ -612,6 +624,11 @@ public class LengthDelimitedJournal<T> implements
WriteAheadJournal<T> {
return true;
}
+ @Override
+ public long getBytesWritten() {
+ return bytesWritten.get();
+ }
+
@Override
public synchronized JournalSummary getSummary() {
if (transactionCount < 1) {
diff --git
a/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/SequentialAccessWriteAheadLog.java
b/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/SequentialAccessWriteAheadLog.java
index 2f4ff152051..dacce156e3f 100644
---
a/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/SequentialAccessWriteAheadLog.java
+++
b/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/SequentialAccessWriteAheadLog.java
@@ -36,6 +36,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.BooleanSupplier;
import java.util.regex.Pattern;
/**
@@ -53,6 +54,14 @@ import java.util.regex.Pattern;
* that records are recovered correctly if two threads simultaneously update
the write-ahead log
* with updates for the same record.
* </p>
+ *
+ * <p>
+ * A maximum journal size may be provided. When it is, an update that causes
the amount of storage consumed by the
+ * journal to reach that size triggers a checkpoint, which rolls over to a new
journal and reclaims the storage that
+ * was consumed by the previous one. The size is a trigger rather than a
strict cap, because the update that crosses
+ * the threshold is always written in full before the journal is rolled over.
When no maximum journal size is provided,
+ * the journal grows until the owner of this repository chooses to checkpoint.
+ * </p>
*/
public class SequentialAccessWriteAheadLog<T> implements
WriteAheadRepository<T> {
private static final int PARTITION_INDEX = 0;
@@ -65,6 +74,7 @@ public class SequentialAccessWriteAheadLog<T> implements
WriteAheadRepository<T>
private final File journalsDirectory;
protected final SerDeFactory<T> serdeFactory;
private final SyncListener syncListener;
+ private final Long maxJournalBytes;
private final Set<String> recoveredSwapLocations = new HashSet<>();
private final ReadWriteLock journalRWLock = new ReentrantReadWriteLock();
@@ -88,6 +98,25 @@ public class SequentialAccessWriteAheadLog<T> implements
WriteAheadRepository<T>
}
public SequentialAccessWriteAheadLog(final File storageDirectory, final
SerDeFactory<T> serdeFactory, final SyncListener syncListener) throws
IOException {
+ this(storageDirectory, serdeFactory, syncListener, null);
+ }
+
+ /**
+ * Creates a Write-Ahead Log that optionally checkpoints whenever its
journal reaches a given size.
+ *
+ * @param storageDirectory the directory in which the snapshot and
journals are stored
+ * @param serdeFactory the factory for creating the
serializer/deserializer to use for records
+ * @param syncListener the listener to notify when data is synchronized to
disk
+ * @param maxJournalBytes the amount of storage that the journal may
consume before an update triggers a checkpoint, or
+ * <code>null</code> if the journal is to grow until the caller
explicitly checkpoints. When provided, the value must be
+ * greater than 0.
+ * @throws IOException if unable to create or access the storage directory
+ */
+ public SequentialAccessWriteAheadLog(final File storageDirectory, final
SerDeFactory<T> serdeFactory, final SyncListener syncListener, final Long
maxJournalBytes) throws IOException {
+ if (maxJournalBytes != null && maxJournalBytes <= 0) {
+ throw new IllegalArgumentException("Maximum Journal Size must be
greater than 0 bytes but was " + maxJournalBytes);
+ }
+
if (!storageDirectory.exists() && !storageDirectory.mkdirs()) {
throw new IOException("Directory " + storageDirectory + " does not
exist and cannot be created");
}
@@ -109,6 +138,7 @@ public class SequentialAccessWriteAheadLog<T> implements
WriteAheadRepository<T>
this.serdeFactory = serdeFactory;
this.syncListener = (syncListener == null) ?
SyncListener.NOP_SYNC_LISTENER : syncListener;
+ this.maxJournalBytes = maxJournalBytes;
}
@Override
@@ -117,6 +147,7 @@ public class SequentialAccessWriteAheadLog<T> implements
WriteAheadRepository<T>
throw new IllegalStateException("Cannot update repository until
record recovery has been performed");
}
+ final boolean maxJournalSizeReached;
journalReadLock.lock();
try {
journal.update(records, recordLookup);
@@ -127,10 +158,25 @@ public class SequentialAccessWriteAheadLog<T> implements
WriteAheadRepository<T>
}
snapshot.update(records);
+
+ maxJournalSizeReached = maxJournalBytes != null &&
journal.getBytesWritten() >= maxJournalBytes;
} finally {
journalReadLock.unlock();
}
+ // The checkpoint requires the write lock, so it must be performed
only after the read lock has been released. Because the
+ // journal may be rolled over by another thread in the meantime, the
checkpoint verifies that the journal is still large
+ // enough to warrant rolling over.
+ if (maxJournalSizeReached) {
+ logger.debug("Checkpointing Write-Ahead Log at {} because its
journal has reached the maximum size of {} bytes", storageDirectory,
maxJournalBytes);
+ try {
+ checkpointIfJournalExceedsLimit();
+ } catch (final Exception e) {
+ logger.error("Failed to checkpoint Write-Ahead Log at {} after
its journal reached the maximum size of {} bytes; the update was already
applied",
+ storageDirectory, maxJournalBytes, e);
+ }
+ }
+
return PARTITION_INDEX;
}
@@ -252,12 +298,41 @@ public class SequentialAccessWriteAheadLog<T> implements
WriteAheadRepository<T>
}
private int checkpoint(final Set<String> swapLocations) throws IOException
{
+ return checkpoint(swapLocations, () -> true);
+ }
+
+ /**
+ * Checkpoints only if the journal still consumes at least the configured
maximum number of bytes. Because the update that triggered
+ * this call must release the journal read lock before a checkpoint can
acquire the write lock, another thread may have already
+ * rolled the journal over. In that case, no checkpoint is performed.
+ *
+ * @throws IOException if unable to write the snapshot or create the new
journal
+ */
+ private void checkpointIfJournalExceedsLimit() throws IOException {
+ checkpoint(null, () -> journal != null && journal.getBytesWritten() >=
maxJournalBytes);
+ }
+
+ /**
+ * Writes a snapshot of the current state of the repository and rolls over
to a new journal.
+ *
+ * @param swapLocations the swap locations to include in the snapshot, or
<code>null</code> to use the swap locations that are already known
+ * @param checkpointRequired evaluated while the journal write lock is
held; if it returns <code>false</code>, no checkpoint is performed. This allows
a checkpoint
+ * that was triggered by the size of a journal to be abandoned
if that journal has already been rolled over by another thread
+ * @return the number of records that are stored in the snapshot
+ * @throws IOException if unable to write the snapshot or create the new
journal
+ */
+ private int checkpoint(final Set<String> swapLocations, final
BooleanSupplier checkpointRequired) throws IOException {
final SnapshotCapture<T> snapshotCapture;
final long startNanos = System.nanoTime();
final File[] existingJournals;
+ long checkpointedJournalBytes = 0L;
journalWriteLock.lock();
try {
+ if (!checkpointRequired.getAsBoolean()) {
+ return snapshot.getRecordCount();
+ }
+
if (journal != null) {
final JournalSummary journalSummary = journal.getSummary();
if (journalSummary.getTransactionCount() == 0 &&
journal.isHealthy()) {
@@ -266,6 +341,8 @@ public class SequentialAccessWriteAheadLog<T> implements
WriteAheadRepository<T>
return snapshot.getRecordCount();
}
+ checkpointedJournalBytes = journal.getBytesWritten();
+
try {
journal.fsync();
} catch (final Exception e) {
@@ -314,14 +391,14 @@ public class SequentialAccessWriteAheadLog<T> implements
WriteAheadRepository<T>
snapshot.writeSnapshot(snapshotCapture);
for (final File existingJournal : existingJournals) {
- final WriteAheadJournal journal = new
LengthDelimitedJournal<>(existingJournal, serdeFactory, streamPool,
nextTransactionId);
- journal.dispose();
+ final WriteAheadJournal<T> existingWriteAheadJournal = new
LengthDelimitedJournal<>(existingJournal, serdeFactory, streamPool,
nextTransactionId);
+ existingWriteAheadJournal.dispose();
}
final long totalNanos = System.nanoTime() - startNanos;
final long millis = TimeUnit.NANOSECONDS.toMillis(totalNanos);
- logger.info("Checkpointed Write-Ahead Log with {} Records and {} Swap
Files in {} milliseconds (Stop-the-world time = {} milliseconds), max
Transaction ID {}",
- snapshotCapture.getRecords().size(),
snapshotCapture.getSwapLocations().size(), millis, stopTheWorldMillis,
snapshotCapture.getMaxTransactionId());
+ logger.info("Checkpointed Write-Ahead Log with {} Records and {} Swap
Files in {} milliseconds (Stop-the-world time = {} milliseconds), max
Transaction ID {}, reclaiming {} bytes of storage",
+ snapshotCapture.getRecords().size(),
snapshotCapture.getSwapLocations().size(), millis, stopTheWorldMillis,
snapshotCapture.getMaxTransactionId(), checkpointedJournalBytes);
return snapshotCapture.getRecords().size();
}
@@ -337,4 +414,5 @@ public class SequentialAccessWriteAheadLog<T> implements
WriteAheadRepository<T>
journalWriteLock.unlock();
}
}
+
}
diff --git
a/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/WriteAheadJournal.java
b/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/WriteAheadJournal.java
index a9c339ec984..7baaaba81d1 100644
---
a/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/WriteAheadJournal.java
+++
b/nifi-commons/nifi-write-ahead-log/src/main/java/org/apache/nifi/wali/WriteAheadJournal.java
@@ -48,6 +48,15 @@ public interface WriteAheadJournal<T> extends Closeable {
*/
JournalSummary getSummary();
+ /**
+ * Returns the amount of storage that has been consumed by this journal.
This includes the journal file itself as well as any
+ * overflow files that the journal references, so that the value reflects
the amount of disk space that must be reclaimed by
+ * checkpointing the journal.
+ *
+ * @return the number of bytes that have been written to storage for this
journal
+ */
+ long getBytesWritten();
+
/**
* @return <code>true</code> if the journal is healthy and can be written
to, <code>false</code> if either the journal has been closed or is poisoned
*/
diff --git
a/nifi-commons/nifi-write-ahead-log/src/test/java/org/apache/nifi/wali/TestLengthDelimitedJournal.java
b/nifi-commons/nifi-write-ahead-log/src/test/java/org/apache/nifi/wali/TestLengthDelimitedJournal.java
index 66c5713166a..9610210dc2b 100644
---
a/nifi-commons/nifi-write-ahead-log/src/test/java/org/apache/nifi/wali/TestLengthDelimitedJournal.java
+++
b/nifi-commons/nifi-write-ahead-log/src/test/java/org/apache/nifi/wali/TestLengthDelimitedJournal.java
@@ -61,6 +61,10 @@ public class TestLengthDelimitedJournal {
private ObjectPool<ByteArrayDataOutputStream> streamPool;
private static final int BUFFER_SIZE = 4096;
+ // Large enough that the reference written for an overflow file, which
contains the absolute path of that file, does not itself
+ // exceed the threshold and cause a second overflow file to be created.
+ private static final int OVERFLOW_THRESHOLD_BYTES = 1024;
+
@BeforeEach
public void setupJournal() throws IOException {
Files.deleteIfExists(journalFile.toPath());
@@ -77,6 +81,71 @@ public class TestLengthDelimitedJournal {
stream -> stream.getByteArrayOutputStream().reset());
}
+ @Test
+ public void testBytesWrittenMatchesJournalFileForInlineUpdates() throws
IOException {
+ try (final LengthDelimitedJournal<DummyRecord> journal = new
LengthDelimitedJournal<>(journalFile, serdeFactory, streamPool, 0L)) {
+ assertEquals(0L, journal.getBytesWritten());
+
+ journal.writeHeader();
+ assertEquals(journalFile.length(), journal.getBytesWritten());
+
+ for (int i = 0; i < 5; i++) {
+ final long bytesBeforeUpdate = journal.getBytesWritten();
+ journal.update(Collections.singleton(new
DummyRecord(String.valueOf(i), UpdateType.CREATE)), key -> null);
+
+ assertTrue(journal.getBytesWritten() > bytesBeforeUpdate);
+ assertEquals(journalFile.length(), journal.getBytesWritten());
+ }
+ }
+ }
+
+ @Test
+ public void testBytesWrittenIncludesOverflowFiles() throws IOException {
+ // A small in-heap serialization threshold causes the journal to write
the bulk of the update to an overflow file and to
+ // write only a reference to that file into the journal itself.
+ try (final LengthDelimitedJournal<DummyRecord> journal = new
LengthDelimitedJournal<>(journalFile, serdeFactory, streamPool, 0L,
OVERFLOW_THRESHOLD_BYTES)) {
+ journal.writeHeader();
+
+ final List<DummyRecord> records = new ArrayList<>();
+ for (int i = 0; i < 1_000; i++) {
+ records.add(new DummyRecord(String.valueOf(i),
UpdateType.CREATE));
+ }
+
+ journal.update(records, key -> null);
+
+ final Set<File> overflowFiles = serde.getExternalFileReferences();
+ assertEquals(1, overflowFiles.size());
+
+ long overflowBytes = 0L;
+ for (final File overflowFile : overflowFiles) {
+ overflowBytes += overflowFile.length();
+ }
+
+ assertTrue(overflowBytes > 0L);
+ assertEquals(journalFile.length() + overflowBytes,
journal.getBytesWritten());
+ }
+ }
+
+ @Test
+ public void testBytesWrittenUnchangedWhenUpdateFails() throws IOException {
+ try (final LengthDelimitedJournal<DummyRecord> journal = new
LengthDelimitedJournal<>(journalFile, serdeFactory, streamPool, 0L,
OVERFLOW_THRESHOLD_BYTES)) {
+ journal.writeHeader();
+ final long headerBytes = journal.getBytesWritten();
+
+ final List<DummyRecord> records = new ArrayList<>();
+ for (int i = 0; i < 1_000; i++) {
+ records.add(new DummyRecord(String.valueOf(i),
UpdateType.CREATE));
+ }
+
+ // Fail the update after enough records have been serialized that
an overflow file has been created. The overflow file is
+ // removed when the update fails, so its contents must not be
counted against the journal.
+ serde.setThrowIOEAfterNSerializeEdits(500);
+ assertThrows(IOException.class, () -> journal.update(records, key
-> null));
+
+ assertEquals(headerBytes, journal.getBytesWritten());
+ }
+ }
+
@Test
public void testHandlingOfTrailingNulBytes() throws IOException {
try (final LengthDelimitedJournal<DummyRecord> journal = new
LengthDelimitedJournal<>(journalFile, serdeFactory, streamPool, 0L)) {
diff --git
a/nifi-commons/nifi-write-ahead-log/src/test/java/org/apache/nifi/wali/TestSequentialAccessWriteAheadLog.java
b/nifi-commons/nifi-write-ahead-log/src/test/java/org/apache/nifi/wali/TestSequentialAccessWriteAheadLog.java
index 11317010019..3c65e806039 100644
---
a/nifi-commons/nifi-write-ahead-log/src/test/java/org/apache/nifi/wali/TestSequentialAccessWriteAheadLog.java
+++
b/nifi-commons/nifi-write-ahead-log/src/test/java/org/apache/nifi/wali/TestSequentialAccessWriteAheadLog.java
@@ -26,6 +26,7 @@ import org.wali.DummyRecord;
import org.wali.DummyRecordSerde;
import org.wali.SerDeFactory;
import org.wali.SingletonSerDeFactory;
+import org.wali.SyncListener;
import org.wali.UpdateType;
import org.wali.WriteAheadRepository;
@@ -35,6 +36,7 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.NumberFormat;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
@@ -42,11 +44,13 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -188,12 +192,9 @@ public class TestSequentialAccessWriteAheadLog {
}
private SequentialAccessWriteAheadLog<DummyRecord>
createRecoveryRepo(TestInfo testInfo) throws IOException {
- final File targetDir = new File("target");
- final File storageDir = new File(targetDir,
testInfo.getTestMethod().get().getName());
-
final DummyRecordSerde serde = new DummyRecordSerde();
final SerDeFactory<DummyRecord> serdeFactory = new
SingletonSerDeFactory<>(serde);
- final SequentialAccessWriteAheadLog<DummyRecord> repo = new
SequentialAccessWriteAheadLog<>(storageDir, serdeFactory);
+ final SequentialAccessWriteAheadLog<DummyRecord> repo = new
SequentialAccessWriteAheadLog<>(getStorageDirectory(testInfo), serdeFactory);
return repo;
}
@@ -203,13 +204,21 @@ public class TestSequentialAccessWriteAheadLog {
}
private SequentialAccessWriteAheadLog<DummyRecord> createWriteRepo(final
TestInfo testInfo, final DummyRecordSerde serde) throws IOException {
- final File targetDir = new File("target");
- final File storageDir = new File(targetDir,
testInfo.getTestMethod().get().getName());
+ return createWriteRepo(testInfo, serde, null);
+ }
+
+ private SequentialAccessWriteAheadLog<DummyRecord> createWriteRepo(final
TestInfo testInfo, final DummyRecordSerde serde, final Long maxJournalBytes)
throws IOException {
+ return createWriteRepo(testInfo, serde, maxJournalBytes,
SyncListener.NOP_SYNC_LISTENER);
+ }
+
+ private SequentialAccessWriteAheadLog<DummyRecord> createWriteRepo(final
TestInfo testInfo, final DummyRecordSerde serde, final Long maxJournalBytes,
+ final SyncListener syncListener) throws IOException {
+ final File storageDir = getStorageDirectory(testInfo);
deleteRecursively(storageDir);
assertTrue(storageDir.mkdirs());
final SerDeFactory<DummyRecord> serdeFactory = new
SingletonSerDeFactory<>(serde);
- final SequentialAccessWriteAheadLog<DummyRecord> repo = new
SequentialAccessWriteAheadLog<>(storageDir, serdeFactory);
+ final SequentialAccessWriteAheadLog<DummyRecord> repo = new
SequentialAccessWriteAheadLog<>(storageDir, serdeFactory, syncListener,
maxJournalBytes);
final Collection<DummyRecord> recovered = repo.recoverRecords();
assertNotNull(recovered);
@@ -218,6 +227,104 @@ public class TestSequentialAccessWriteAheadLog {
return repo;
}
+ private File getStorageDirectory(final TestInfo testInfo) {
+ return new File("target", testInfo.getTestMethod().get().getName());
+ }
+
+ private List<File> getJournalFiles(final TestInfo testInfo) {
+ final File[] journalFiles = new File(getStorageDirectory(testInfo),
"journals").listFiles(file -> file.getName().endsWith(".journal"));
+ assertNotNull(journalFiles);
+
+ return Arrays.asList(journalFiles);
+ }
+
+ @Test
+ public void testMaximumJournalSizeMustBePositive() {
+ final SerDeFactory<DummyRecord> serdeFactory = new
SingletonSerDeFactory<>(new DummyRecordSerde());
+ final File storageDir = new File("target",
"testMaximumJournalSizeMustBePositive");
+
+ assertThrows(IllegalArgumentException.class, () -> new
SequentialAccessWriteAheadLog<>(storageDir, serdeFactory,
SyncListener.NOP_SYNC_LISTENER, 0L));
+ assertThrows(IllegalArgumentException.class, () -> new
SequentialAccessWriteAheadLog<>(storageDir, serdeFactory,
SyncListener.NOP_SYNC_LISTENER, -1L));
+ }
+
+ @Test
+ public void testJournalRolledOverWhenMaximumJournalSizeReached(final
TestInfo testInfo) throws IOException {
+ // Determine how much storage the journal consumes for its header and
for a single-record update, so that the maximum journal
+ // size can be set to a value that is reached by a known number of
updates.
+ final SequentialAccessWriteAheadLog<DummyRecord> sizingRepo =
createWriteRepo(testInfo);
+ final File sizingJournalFile = getJournalFiles(testInfo).getFirst();
+ final long headerBytes = sizingJournalFile.length();
+ sizingRepo.update(Collections.singleton(new DummyRecord("0",
UpdateType.CREATE)), false);
+ final long updateBytes = sizingJournalFile.length() - headerBytes;
+ sizingRepo.shutdown();
+
+ final int updatesPerJournal = 3;
+ final SequentialAccessWriteAheadLog<DummyRecord> repo =
createWriteRepo(testInfo, new DummyRecordSerde(), headerBytes +
updatesPerJournal * updateBytes);
+ final File firstJournalFile = getJournalFiles(testInfo).getFirst();
+
+ for (int i = 0; i < updatesPerJournal - 1; i++) {
+ repo.update(Collections.singleton(new
DummyRecord(String.valueOf(i), UpdateType.CREATE)), false);
+ assertEquals(List.of(firstJournalFile), getJournalFiles(testInfo));
+ }
+
+ repo.update(Collections.singleton(new
DummyRecord(String.valueOf(updatesPerJournal - 1), UpdateType.CREATE)), false);
+
+ final List<File> rolledOverJournalFiles = getJournalFiles(testInfo);
+ assertEquals(1, rolledOverJournalFiles.size());
+
+ final File secondJournalFile = rolledOverJournalFiles.getFirst();
+ assertNotEquals(firstJournalFile, secondJournalFile);
+ assertFalse(firstJournalFile.exists());
+ assertEquals(headerBytes, secondJournalFile.length());
+
+ repo.shutdown();
+
+ final SequentialAccessWriteAheadLog<DummyRecord> recoveryRepo =
createRecoveryRepo(testInfo);
+ final Collection<DummyRecord> recovered =
recoveryRepo.recoverRecords();
+ assertEquals(updatesPerJournal, recovered.size());
+ recoveryRepo.shutdown();
+ }
+
+ @Test
+ public void testUpdateSucceedsWhenAutomaticCheckpointFails(final TestInfo
testInfo) throws IOException {
+ final SequentialAccessWriteAheadLog<DummyRecord> sizingRepo =
createWriteRepo(testInfo);
+ final File sizingJournalFile = getJournalFiles(testInfo).getFirst();
+ final long headerBytes = sizingJournalFile.length();
+ sizingRepo.update(Collections.singleton(new DummyRecord("0",
UpdateType.CREATE)), false);
+ final long updateBytes = sizingJournalFile.length() - headerBytes;
+ sizingRepo.shutdown();
+
+ final AtomicBoolean failCheckpoint = new AtomicBoolean(false);
+ final SyncListener failingCheckpointListener = new SyncListener() {
+ @Override
+ public void onSync(final int partitionIndex) {
+ }
+
+ @Override
+ public void onGlobalSync() {
+ if (failCheckpoint.get()) {
+ throw new RuntimeException("Injected checkpoint failure");
+ }
+ }
+ };
+
+ final int updatesPerJournal = 3;
+ final SequentialAccessWriteAheadLog<DummyRecord> repo =
createWriteRepo(testInfo, new DummyRecordSerde(), headerBytes +
updatesPerJournal * updateBytes, failingCheckpointListener);
+ failCheckpoint.set(true);
+
+ for (int i = 0; i < updatesPerJournal; i++) {
+ repo.update(Collections.singleton(new
DummyRecord(String.valueOf(i), UpdateType.CREATE)), false);
+ }
+
+ repo.shutdown();
+
+ failCheckpoint.set(false);
+ final SequentialAccessWriteAheadLog<DummyRecord> recoveryRepo =
createRecoveryRepo(testInfo);
+ final Collection<DummyRecord> recovered =
recoveryRepo.recoverRecords();
+ assertEquals(updatesPerJournal, recovered.size());
+ recoveryRepo.shutdown();
+ }
+
/**
* This test is designed to update the repository in several different
wants, testing CREATE, UPDATE, SWAP IN, SWAP OUT, and DELETE
* update types, as well as testing updates with single records and with
multiple records in a transaction. It also verifies that we
diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc
b/nifi-docs/src/main/asciidoc/administration-guide.adoc
index 5ba8082df22..0ffda8071ea 100644
--- a/nifi-docs/src/main/asciidoc/administration-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc
@@ -2287,7 +2287,15 @@ Otherwise, NiFi will fail to startup.
==== Local State Provider
By default, the Local State Provider is configured to be a
`WriteAheadLocalStateProvider` that persists the data to the
-`$NIFI_HOME/state/local` directory.
+`$NIFI_HOME/state/local` directory. It writes each state update to a journal
and periodically checkpoints, which writes a snapshot
+of all state and removes the journal that the snapshot now covers. The
`Checkpoint Interval` property controls how much time passes
+between checkpoints.
+
+Because the journal grows with every state update, a long checkpoint interval
combined with frequent state updates can consume a
+significant amount of disk space. The optional `Maximum Journal Size` property
bounds this growth by triggering a checkpoint as soon
+as the journal reaches the configured size, such as `100 MB`. The value is a
threshold that triggers a checkpoint rather than a strict
+limit on the size of the journal, because the update that reaches the
threshold is written in full before the checkpoint is performed.
+If the property is not specified, checkpoints occur only on the `Checkpoint
Interval`.
==== ZooKeeper Cluster State Provider
@@ -2983,6 +2991,7 @@ NOTE: Switching repository implementations should only be
done on an instance wi
|*Property*|*Description*
|`nifi.flowfile.repository.directory`*|The location of the FlowFile
Repository. The default value is `./flowfile_repository`.
|`nifi.flowfile.repository.checkpoint.interval`| The FlowFile Repository
checkpoint interval. The default value is `20 secs`.
+|`nifi.flowfile.repository.checkpoint.max.journal.size`| The amount of storage
that the repository's journal is allowed to consume before a checkpoint is
performed, such as `100 MB`. This bounds the amount of disk space that the
repository consumes between checkpoints, regardless of how many FlowFiles are
updated. The value is a threshold that triggers a checkpoint rather than a
strict limit on the size of the journal, because the update that reaches the
threshold is written in full befo [...]
|`nifi.flowfile.repository.always.sync`|If set to `true`, any change to the
repository will be synchronized to the disk, meaning that NiFi will ask the
operating system not to cache the information. This is very expensive and can
significantly reduce NiFi performance. However, if it is `false`, there could
be the potential for data loss if either there is a sudden power loss or the
operating system crashes. The default value is `false`.
|====
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/WriteAheadFlowFileRepository.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/WriteAheadFlowFileRepository.java
index 96345a89cd5..f18d5b89159 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/WriteAheadFlowFileRepository.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/WriteAheadFlowFileRepository.java
@@ -102,6 +102,7 @@ public class WriteAheadFlowFileRepository implements
FlowFileRepository, SyncLis
private final int maxCharactersToCache;
private final long truncationThreshold;
private final boolean truncationEnabled;
+ private final Long maximumJournalBytes;
private volatile Collection<SerializedRepositoryRecord> recoveredRecords =
null;
private final Set<ResourceClaim> orphanedResourceClaims =
Collections.synchronizedSet(new HashSet<>());
@@ -150,6 +151,7 @@ public class WriteAheadFlowFileRepository implements
FlowFileRepository, SyncLis
maxCharactersToCache = 0;
truncationThreshold = Long.MAX_VALUE;
truncationEnabled = false;
+ maximumJournalBytes = null;
}
public WriteAheadFlowFileRepository(final NiFiProperties nifiProperties) {
@@ -171,6 +173,9 @@ public class WriteAheadFlowFileRepository implements
FlowFileRepository, SyncLis
checkpointDelayMillis =
FormatUtils.getTimeDuration(nifiProperties.getFlowFileRepositoryCheckpointInterval(),
TimeUnit.MILLISECONDS);
+ final String maximumJournalSize =
nifiProperties.getFlowFileRepositoryMaxJournalSize();
+ maximumJournalBytes = (maximumJournalSize == null) ? null :
DataUnit.parseDataSize(maximumJournalSize, DataUnit.B).longValue();
+
checkpointExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
final Thread t = Executors.defaultThreadFactory().newThread(r);
t.setName("Checkpoint FlowFile Repository");
@@ -202,7 +207,7 @@ public class WriteAheadFlowFileRepository implements
FlowFileRepository, SyncLis
// delete backup. On restore, if no files exist in partition's
directory, would have to check backup directory
this.serdeFactory = serdeFactory;
- wal = new
SequentialAccessWriteAheadLog<>(flowFileRepositoryPaths.get(0), serdeFactory,
this);
+ wal = new
SequentialAccessWriteAheadLog<>(flowFileRepositoryPaths.get(0), serdeFactory,
this, maximumJournalBytes);
logger.info("Initialized FlowFile Repository");
}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/local/WriteAheadLocalStateProvider.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/local/WriteAheadLocalStateProvider.java
index 1ba08c83c5a..1eb68ac2595 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/local/WriteAheadLocalStateProvider.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/local/WriteAheadLocalStateProvider.java
@@ -25,12 +25,14 @@ import org.apache.nifi.controller.state.StandardStateMap;
import org.apache.nifi.controller.state.StateMapSerDe;
import org.apache.nifi.controller.state.StateMapUpdate;
import org.apache.nifi.controller.state.providers.AbstractStateProvider;
+import org.apache.nifi.processor.DataUnit;
import org.apache.nifi.processor.util.StandardValidators;
import org.apache.nifi.wali.SequentialAccessWriteAheadLog;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.wali.SerDe;
import org.wali.SerDeFactory;
+import org.wali.SyncListener;
import org.wali.UpdateType;
import org.wali.WriteAheadRepository;
@@ -99,6 +101,13 @@ public class WriteAheadLocalStateProvider extends
AbstractStateProvider {
.required(true)
.build();
+ static final PropertyDescriptor MAXIMUM_JOURNAL_SIZE = new
PropertyDescriptor.Builder()
+ .name("Maximum Journal Size")
+ .description("The amount of data that may be written to the
write-ahead log's journal before a checkpoint is performed. If not specified,
checkpoints occur only on the Checkpoint Interval.")
+ .addValidator(StandardValidators.DATA_SIZE_VALIDATOR)
+ .required(false)
+ .build();
+
private WriteAheadRepository<StateMapUpdate> writeAheadLog;
private AtomicLong versionGenerator;
@@ -130,7 +139,11 @@ public class WriteAheadLocalStateProvider extends
AbstractStateProvider {
}
versionGenerator = new AtomicLong(-1L);
- writeAheadLog = new SequentialAccessWriteAheadLog<>(basePath, new
SerdeFactory(serde));
+
+ final Double maximumJournalSize =
context.getProperty(MAXIMUM_JOURNAL_SIZE).asDataSize(DataUnit.B);
+ final Long maximumJournalBytes = (maximumJournalSize == null) ? null :
maximumJournalSize.longValue();
+
+ writeAheadLog = new SequentialAccessWriteAheadLog<>(basePath, new
SerdeFactory(serde), SyncListener.NOP_SYNC_LISTENER, maximumJournalBytes);
final Collection<StateMapUpdate> updates =
writeAheadLog.recoverRecords();
long maxRecordVersion = EMPTY_VERSION;
@@ -165,6 +178,7 @@ public class WriteAheadLocalStateProvider extends
AbstractStateProvider {
properties.add(ALWAYS_SYNC);
properties.add(CHECKPOINT_INTERVAL);
properties.add(NUM_PARTITIONS);
+ properties.add(MAXIMUM_JOURNAL_SIZE);
return properties;
}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/diagnostics/bootstrap/tasks/NiFiPropertiesDiagnosticTask.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/diagnostics/bootstrap/tasks/NiFiPropertiesDiagnosticTask.java
index 27b0cb63805..6cfb081dc59 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/diagnostics/bootstrap/tasks/NiFiPropertiesDiagnosticTask.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/diagnostics/bootstrap/tasks/NiFiPropertiesDiagnosticTask.java
@@ -44,6 +44,7 @@ public class NiFiPropertiesDiagnosticTask implements
DiagnosticTask {
"nifi.content.repository.archive.max.retention.period",
"nifi.content.repository.archive.max.usage.percentage",
"nifi.flowfile.repository.checkpoint.interval",
+ "nifi.flowfile.repository.checkpoint.max.journal.size",
"nifi.flowfile.repository.always.sync",
"nifi.components.status.snapshot.frequency",
"nifi.bored.yield.duration",
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 25062eb8583..7033e93dc52 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
@@ -64,6 +64,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
@@ -76,6 +77,7 @@ import java.util.function.Predicate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -627,6 +629,71 @@ public class TestWriteAheadFlowFileRepository {
assertEquals(claim2.getResourceClaim(), swappedOutClaims.get(0));
}
+ /**
+ * Verifies that configuring a maximum journal size causes the repository
to checkpoint as soon as its journal reaches that size, even
+ * though the checkpoint interval has not elapsed, and that the FlowFiles
written before the checkpoint remain recoverable.
+ */
+ @Test
+ public void testJournalRolledOverWhenMaximumJournalSizeReached() throws
IOException {
+ final NiFiProperties boundedJournalProperties =
NiFiProperties.createBasicNiFiProperties(
+
TestWriteAheadFlowFileRepository.class.getResource("/conf/nifi.properties").getFile(),
+
Map.of(NiFiProperties.FLOWFILE_REPOSITORY_CHECKPOINT_MAX_JOURNAL_SIZE, "8 KB"));
+
+ final TestQueueProvider queueProvider = new TestQueueProvider();
+ final FlowFileQueue queue = createMockQueue(queueProvider);
+ final int flowFileCount = 100;
+
+ try (final WriteAheadFlowFileRepository repo = new
WriteAheadFlowFileRepository(boundedJournalProperties)) {
+ repo.initialize(new StandardResourceClaimManager());
+ repo.loadFlowFiles(queueProvider);
+
+ createFlowFiles(repo, queue, flowFileCount);
+
+ final List<File> journalFiles = getJournalFiles();
+ assertEquals(1, journalFiles.size());
+ assertNotEquals("0.journal", journalFiles.getFirst().getName());
+ }
+
+ try (final WriteAheadFlowFileRepository recoveredRepo = new
WriteAheadFlowFileRepository(boundedJournalProperties)) {
+ recoveredRepo.initialize(new StandardResourceClaimManager());
+ assertEquals(flowFileCount,
recoveredRepo.loadFlowFiles(queueProvider));
+ }
+ }
+
+ private FlowFileQueue createMockQueue(final TestQueueProvider
queueProvider) {
+ final Connection connection = Mockito.mock(Connection.class);
+ when(connection.getIdentifier()).thenReturn("1234");
+
+ final FlowFileQueue queue = Mockito.mock(FlowFileQueue.class);
+ when(queue.getIdentifier()).thenReturn("1234");
+ when(connection.getFlowFileQueue()).thenReturn(queue);
+
+ queueProvider.addConnection(connection);
+
+ return queue;
+ }
+
+ private void createFlowFiles(final WriteAheadFlowFileRepository
repository, final FlowFileQueue queue, final int flowFileCount) throws
IOException {
+ for (int i = 0; i < flowFileCount; i++) {
+ final FlowFileRecord flowFile = new
StandardFlowFileRecord.Builder()
+ .id(i + 1L)
+ .addAttribute("uuid", UUID.randomUUID().toString())
+ .build();
+
+ final StandardRepositoryRecord record = new
StandardRepositoryRecord(queue);
+ record.setWorking(flowFile, false);
+ record.setDestination(queue);
+ repository.updateRepository(List.of(record));
+ }
+ }
+
+ private List<File> getJournalFiles() {
+ final File[] journalFiles = new
File("target/test-repo/journals").listFiles(file ->
file.getName().endsWith(".journal"));
+ assertNotNull(journalFiles);
+
+ return Arrays.asList(journalFiles);
+ }
+
@Test
public void testRestartWithOneRecord() throws IOException {
final Path path = Paths.get("target/test-repo");
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/local/TestWriteAheadLocalStateProvider.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/local/TestWriteAheadLocalStateProvider.java
index af3c32e7933..fb11a2eef0f 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/local/TestWriteAheadLocalStateProvider.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/local/TestWriteAheadLocalStateProvider.java
@@ -32,15 +32,20 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.wali.WriteAheadRepository;
+import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import javax.net.ssl.SSLContext;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
public class TestWriteAheadLocalStateProvider extends
AbstractTestStateProvider {
@TempDir
@@ -104,13 +109,59 @@ public class TestWriteAheadLocalStateProvider extends
AbstractTestStateProvider
}
}
+ /**
+ * Verifies that the Maximum Journal Size property causes the Provider to
checkpoint as soon as its journal reaches the configured size,
+ * even though the Checkpoint Interval has not elapsed, and that the state
written before the checkpoint remains recoverable.
+ */
+ @Test
+ public void testJournalRolledOverWhenMaximumJournalSizeReached() throws
IOException {
+ final Path storageDirectory =
temporaryDirectory.resolve("bounded-journal");
+ final String testComponentId = "test-bounded-journal-component";
+
+ final WriteAheadLocalStateProvider boundedProvider =
initializeProvider(storageDirectory.toString(), "4 KB");
+ try {
+ updateStateRepeatedly(boundedProvider, testComponentId, 100);
+
+ final List<File> journalFiles = getJournalFiles(storageDirectory);
+ assertEquals(1, journalFiles.size());
+ assertNotEquals("0.journal", journalFiles.getFirst().getName());
+ } finally {
+ boundedProvider.shutdown();
+ }
+
+ final WriteAheadLocalStateProvider recoveredProvider =
initializeProvider(storageDirectory.toString(), "4 KB");
+ try {
+ assertEquals(Collections.singletonMap("iteration", "99"),
recoveredProvider.getState(testComponentId).toMap());
+ } finally {
+ recoveredProvider.shutdown();
+ }
+ }
+
+ private void updateStateRepeatedly(final StateProvider stateProvider,
final String stateComponentId, final int iterations) throws IOException {
+ for (int i = 0; i < iterations; i++) {
+ stateProvider.setState(Collections.singletonMap("iteration",
String.valueOf(i)), stateComponentId);
+ }
+ }
+
+ private List<File> getJournalFiles(final Path storageDirectory) {
+ final File[] journalFiles =
storageDirectory.resolve("journals").toFile().listFiles(file ->
file.getName().endsWith(".journal"));
+ assertNotNull(journalFiles);
+
+ return Arrays.asList(journalFiles);
+ }
+
private WriteAheadLocalStateProvider initializeProvider(final String
storageDirectory) throws IOException {
+ return initializeProvider(storageDirectory, null);
+ }
+
+ private WriteAheadLocalStateProvider initializeProvider(final String
storageDirectory, final String maximumJournalSize) throws IOException {
final WriteAheadLocalStateProvider newProvider = new
WriteAheadLocalStateProvider();
final Map<PropertyDescriptor, PropertyValue> properties = new
HashMap<>();
properties.put(WriteAheadLocalStateProvider.PATH, new
StandardPropertyValue(storageDirectory, null, ParameterLookup.EMPTY));
properties.put(WriteAheadLocalStateProvider.ALWAYS_SYNC, new
StandardPropertyValue("false", null, ParameterLookup.EMPTY));
properties.put(WriteAheadLocalStateProvider.CHECKPOINT_INTERVAL, new
StandardPropertyValue("2 mins", null, ParameterLookup.EMPTY));
properties.put(WriteAheadLocalStateProvider.NUM_PARTITIONS, new
StandardPropertyValue("16", null, ParameterLookup.EMPTY));
+ properties.put(WriteAheadLocalStateProvider.MAXIMUM_JOURNAL_SIZE, new
StandardPropertyValue(maximumJournalSize, null, ParameterLookup.EMPTY));
newProvider.initialize(new StateProviderInitializationContext() {
@Override
diff --git a/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
b/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
index fe8268e7500..819cd4ef672 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
+++ b/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
@@ -58,6 +58,7 @@
<nifi.flowfile.repository.wal.implementation>org.apache.nifi.wali.SequentialAccessWriteAheadLog</nifi.flowfile.repository.wal.implementation>
<nifi.flowfile.repository.directory>./flowfile_repository</nifi.flowfile.repository.directory>
<nifi.flowfile.repository.checkpoint.interval>20
secs</nifi.flowfile.repository.checkpoint.interval>
+ <nifi.flowfile.repository.checkpoint.max.journal.size />
<nifi.flowfile.repository.always.sync>false</nifi.flowfile.repository.always.sync>
<nifi.flowfile.repository.retain.orphaned.flowfiles>true</nifi.flowfile.repository.retain.orphaned.flowfiles>
<nifi.swap.manager.implementation>org.apache.nifi.controller.FileSystemSwapManager</nifi.swap.manager.implementation>
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
b/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
index fba5d1be311..772e4792c1e 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
+++
b/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
@@ -73,6 +73,7 @@
nifi.flowfile.repository.implementation=${nifi.flowfile.repository.implementatio
nifi.flowfile.repository.wal.implementation=${nifi.flowfile.repository.wal.implementation}
nifi.flowfile.repository.directory=${nifi.flowfile.repository.directory}
nifi.flowfile.repository.checkpoint.interval=${nifi.flowfile.repository.checkpoint.interval}
+nifi.flowfile.repository.checkpoint.max.journal.size=${nifi.flowfile.repository.checkpoint.max.journal.size}
nifi.flowfile.repository.always.sync=${nifi.flowfile.repository.always.sync}
nifi.flowfile.repository.retain.orphaned.flowfiles=${nifi.flowfile.repository.retain.orphaned.flowfiles}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/state-management.xml
b/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/state-management.xml
index 1fd40ea6a3d..74c7b413ad3 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/state-management.xml
+++
b/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/state-management.xml
@@ -30,6 +30,11 @@
operating system crashes. The default value is false.
Partitions - The number of partitions.
Checkpoint Interval - The amount of time between checkpoints.
+
+ This Provider also supports the following optional property:
+
+ Maximum Journal Size - The amount of data that may be written to the
write-ahead log's journal, such as "100 MB", before a checkpoint is performed.
If not specified, checkpoints occur
+ only on the Checkpoint Interval.
-->
<local-provider>
<id>local-provider</id>
diff --git
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/AppendLocalState.java
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/AppendLocalState.java
new file mode 100644
index 00000000000..7265eea1e3f
--- /dev/null
+++
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/AppendLocalState.java
@@ -0,0 +1,93 @@
+/*
+ * 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.processors.tests.system;
+
+import org.apache.nifi.annotation.behavior.Stateful;
+import org.apache.nifi.annotation.configuration.DefaultSchedule;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.components.state.StateManager;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.processor.AbstractProcessor;
+import org.apache.nifi.processor.DataUnit;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.Relationship;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.processor.util.StandardValidators;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@DefaultSchedule(period = "10 mins")
+@Stateful(scopes = Scope.LOCAL, description = "Stores the number of the most
recent state update along with a fixed-size value")
+public class AppendLocalState extends AbstractProcessor {
+ static final PropertyDescriptor UPDATE_COUNT = new
PropertyDescriptor.Builder()
+ .name("Update Count")
+ .description("The number of state updates to perform each time the
Processor is triggered.")
+ .addValidator(StandardValidators.POSITIVE_INTEGER_VALIDATOR)
+ .defaultValue("500")
+ .required(true)
+ .build();
+
+ static final PropertyDescriptor VALUE_SIZE = new
PropertyDescriptor.Builder()
+ .name("Value Size")
+ .description("The size of the value that is stored in state alongside
the update number.")
+ .addValidator(StandardValidators.createDataSizeBoundsValidator(1, 1024
* 1024))
+ .defaultValue("64 B")
+ .required(true)
+ .build();
+
+ public static final Relationship REL_SUCCESS = new Relationship.Builder()
+ .name("success")
+ .build();
+
+ @Override
+ public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+ return List.of(UPDATE_COUNT, VALUE_SIZE);
+ }
+
+ @Override
+ public Set<Relationship> getRelationships() {
+ return Set.of(REL_SUCCESS);
+ }
+
+ @Override
+ public void onTrigger(final ProcessContext context, final ProcessSession
session) throws ProcessException {
+ FlowFile flowFile = session.get();
+ if (flowFile == null) {
+ flowFile = session.create();
+ }
+
+ final int updateCount = context.getProperty(UPDATE_COUNT).asInteger();
+ final int valueSize =
context.getProperty(VALUE_SIZE).asDataSize(DataUnit.B).intValue();
+ final String value = "A".repeat(valueSize);
+
+ final StateManager stateManager = context.getStateManager();
+ for (int i = 0; i < updateCount; i++) {
+ try {
+ stateManager.setState(Map.of("update", String.valueOf(i),
"value", value), Scope.LOCAL);
+ } catch (final IOException e) {
+ throw new ProcessException("Failed to update local state", e);
+ }
+ }
+
+ session.transfer(flowFile, REL_SUCCESS);
+ }
+}
diff --git
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
index b3167115e44..2d0e1afa8ab 100644
---
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
+++
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
@@ -13,6 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+org.apache.nifi.processors.tests.system.AppendLocalState
org.apache.nifi.processors.tests.system.AssetReadingProcessor
org.apache.nifi.processors.tests.system.BacklogReportingTestProcessor
org.apache.nifi.processors.tests.system.ClassloaderIsolationWithServiceProperty
diff --git
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/repositories/WriteAheadJournalSizeIT.java
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/repositories/WriteAheadJournalSizeIT.java
new file mode 100644
index 00000000000..556e7cdc9ef
--- /dev/null
+++
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/repositories/WriteAheadJournalSizeIT.java
@@ -0,0 +1,157 @@
+/*
+ * 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.NiFiInstance;
+import org.apache.nifi.tests.system.NiFiSystemIT;
+import org.apache.nifi.toolkit.client.NiFiClientException;
+import org.apache.nifi.web.api.dto.StateMapDTO;
+import org.apache.nifi.web.api.entity.ConnectionEntity;
+import org.apache.nifi.web.api.entity.ProcessorEntity;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies that the FlowFile Repository and the local State Provider are
checkpointed when their write-ahead log's journal reaches the
+ * configured maximum size. Both are configured with a checkpoint interval
that cannot elapse while the test is running, so a checkpoint
+ * that the test observes can only have been triggered by the size of the
journal.
+ */
+public class WriteAheadJournalSizeIT extends NiFiSystemIT {
+ private static final String MAXIMUM_JOURNAL_SIZE = "20 KB";
+ private static final long MAXIMUM_JOURNAL_BYTES = 20 * 1024L;
+
+ private static final int FLOWFILE_COUNT = 1000;
+ private static final int STATE_UPDATE_COUNT = 500;
+ private static final int STATE_VALUE_SIZE = 64;
+
+ @Override
+ protected Map<String, String> getNifiPropertiesOverrides() {
+ return Map.of("nifi.flowfile.repository.checkpoint.interval", "1 hour",
+ "nifi.flowfile.repository.checkpoint.max.journal.size",
MAXIMUM_JOURNAL_SIZE,
+ "nifi.state.management.configuration.file",
"conf/state-management-bounded-journal.xml");
+ }
+
+ @Test
+ public void
testFlowFileRepositoryCheckpointedWhenMaximumJournalSizeReached() throws
NiFiClientException, IOException, InterruptedException {
+ final File flowFileRepositoryDirectory = new
File(getNiFiInstance().getInstanceDirectory(), "flowfile_repository");
+ final long initialTransactionId =
getMaximumJournalTransactionId(flowFileRepositoryDirectory);
+
+ final ProcessorEntity generate =
getClientUtil().createProcessor("GenerateFlowFile");
+ final ProcessorEntity terminate =
getClientUtil().createProcessor("TerminateFlowFile");
+ getClientUtil().updateProcessorProperties(generate, Map.of("Batch
Size", "1", "File Size", "0 B", "Max FlowFiles",
String.valueOf(FLOWFILE_COUNT)));
+ getClientUtil().updateProcessorSchedulingPeriod(generate, "0 sec");
+
+ final ConnectionEntity connection =
getClientUtil().createConnection(generate, terminate, "success");
+
+ // Each invocation of the Processor creates a single FlowFile and
commits its session, so each invocation results in its own small
+ // update to the FlowFile Repository.
+ getClientUtil().startProcessor(generate);
+ waitForQueueCount(connection.getId(), FLOWFILE_COUNT);
+ getClientUtil().stopProcessor(generate);
+ getClientUtil().waitForStoppedProcessor(generate.getId());
+
+ assertJournalCheckpointed(flowFileRepositoryDirectory,
initialTransactionId);
+
+ // The FlowFiles that were written before and after the checkpoint
must all still be accounted for.
+ assertEquals(FLOWFILE_COUNT,
getConnectionQueueSize(connection.getId()));
+ }
+
+ @Test
+ public void
testLocalStateProviderCheckpointedWhenMaximumJournalSizeReached() throws
NiFiClientException, IOException, InterruptedException {
+ final File localStateDirectory = new
File(getNiFiInstance().getInstanceDirectory(), "state/local");
+ final long initialTransactionId =
getMaximumJournalTransactionId(localStateDirectory);
+
+ final ProcessorEntity appendState =
getClientUtil().createProcessor("AppendLocalState");
+ getClientUtil().updateProcessorProperties(appendState, Map.of("Update
Count", String.valueOf(STATE_UPDATE_COUNT), "Value Size", STATE_VALUE_SIZE + "
B"));
+ getClientUtil().setAutoTerminatedRelationships(appendState, "success");
+
+ // Each of the Processor's state updates is written to the State
Provider individually, so the Processor produces many small updates.
+ getClientUtil().runProcessorOnce(appendState);
+ getClientUtil().waitForStoppedProcessor(appendState.getId());
+
+ assertJournalCheckpointed(localStateDirectory, initialTransactionId);
+
+ final Map<String, String> expectedState = Map.of("update",
String.valueOf(STATE_UPDATE_COUNT - 1), "value", "A".repeat(STATE_VALUE_SIZE));
+ assertEquals(expectedState, getLocalState(appendState.getId()));
+
+ // Restarting proves that the state that was checkpointed and the
state that was written to the new journal afterward are both recovered.
+ final NiFiInstance nifiInstance = getNiFiInstance();
+ nifiInstance.stop();
+ nifiInstance.start(true);
+ setupClient();
+
+ assertEquals(expectedState, getLocalState(appendState.getId()));
+ }
+
+ /**
+ * Waits until the write-ahead log in the given directory has rolled over
to a new journal and then asserts that the journals that were
+ * checkpointed have been removed and that the storage consumed by the
remaining journal is within the configured maximum. A journal is
+ * named for the identifier of the first transaction that it holds, so a
larger transaction identifier means a newer journal.
+ *
+ * @param storageDirectory the storage directory of the write-ahead log
+ * @param initialTransactionId the largest journal transaction identifier
that existed before the test performed any updates
+ */
+ private void assertJournalCheckpointed(final File storageDirectory, final
long initialTransactionId) throws InterruptedException {
+ waitFor(() -> getMaximumJournalTransactionId(storageDirectory) >
initialTransactionId);
+
+ final List<File> journalFiles = listJournalFiles(storageDirectory);
+ assertEquals(1, journalFiles.size(), "Expected the journals that were
checkpointed to be removed but found " + journalFiles);
+
+ long journalBytes = 0L;
+ for (final File journalFile : journalFiles) {
+ journalBytes += journalFile.length();
+ }
+
+ assertTrue(journalBytes <= MAXIMUM_JOURNAL_BYTES, "Expected the
journal storage to be bounded by " + MAXIMUM_JOURNAL_BYTES + " bytes but it
consumed " + journalBytes + " bytes");
+ }
+
+ private long getMaximumJournalTransactionId(final File storageDirectory) {
+ long maximumTransactionId = -1L;
+
+ for (final File journalFile : listJournalFiles(storageDirectory)) {
+ final String filename = journalFile.getName();
+ final long transactionId = Long.parseLong(filename.substring(0,
filename.indexOf(".")));
+ maximumTransactionId = Math.max(maximumTransactionId,
transactionId);
+ }
+
+ return maximumTransactionId;
+ }
+
+ private List<File> listJournalFiles(final File storageDirectory) {
+ final File[] journalFiles = new File(storageDirectory,
"journals").listFiles(file -> file.getName().endsWith(".journal"));
+ return (journalFiles == null) ? List.of() : List.of(journalFiles);
+ }
+
+ private Map<String, String> getLocalState(final String processorId) throws
NiFiClientException, IOException {
+ final StateMapDTO localState =
getNifiClient().getProcessorClient().getProcessorState(processorId).getComponentState().getLocalState();
+ assertNotNull(localState);
+
+ final Map<String, String> state = new HashMap<>();
+ localState.getState().forEach(entry -> state.put(entry.getKey(),
entry.getValue()));
+
+ return state;
+ }
+}
diff --git
a/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/default/state-management-bounded-journal.xml
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/default/state-management-bounded-journal.xml
new file mode 100644
index 00000000000..a332b22dc8e
--- /dev/null
+++
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/default/state-management-bounded-journal.xml
@@ -0,0 +1,38 @@
+<!--
+ 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.
+-->
+<!--
+ Configures the local State Provider to checkpoint whenever its journal
reaches 20 KB. The Checkpoint Interval is long enough that it
+ cannot elapse during a test, so any checkpoint that a test observes must
have been triggered by the Maximum Journal Size. A test selects
+ this configuration by overriding the
nifi.state.management.configuration.file property.
+-->
+<stateManagement>
+ <local-provider>
+ <id>local-provider</id>
+
<class>org.apache.nifi.controller.state.providers.local.WriteAheadLocalStateProvider</class>
+ <property name="Directory">./state/local</property>
+ <property name="Always Sync">false</property>
+ <property name="Partitions">16</property>
+ <property name="Checkpoint Interval">1 hour</property>
+ <property name="Maximum Journal Size">20 KB</property>
+ </local-provider>
+ <cluster-provider>
+ <id>zk-provider</id>
+
<class>org.apache.nifi.controller.state.providers.zookeeper.ZooKeeperStateProvider</class>
+ <property name="Connect String">localhost:62181</property>
+ <property name="Root Node">/nifi-integration-test</property>
+ <property name="Session Timeout">30 seconds</property>
+ <property name="Access Control">Open</property>
+ </cluster-provider>
+</stateManagement>