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

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


The following commit(s) were added to refs/heads/main by this push:
     new 89be46fe9 MINIFICPP-2849 Implement LMDB based content repository 
(#2201)
89be46fe9 is described below

commit 89be46fe983b2900bd2f1d8ed2f8f89dd26c7893
Author: Gabor Gyimesi <[email protected]>
AuthorDate: Tue Sep 1 12:06:44 2026 +0200

    MINIFICPP-2849 Implement LMDB based content repository (#2201)
---
 .github/workflows/ci.yml                           |   9 +-
 CMakeLists.txt                                     |   5 +-
 CONFIGURE.md                                       |  16 +-
 bootstrap/cli.py                                   |   1 +
 bootstrap/minifi_option.py                         |   8 +-
 cmake/GetLMDB.cmake                                |  24 ++
 cmake/LMDB.cmake                                   |  46 ++++
 cmake/MiNiFiOptions.cmake                          |   2 +
 conanfile.py                                       |   4 +
 conf/minifi.properties.in                          |   3 +
 core-framework/src/core/BufferedContentSession.cpp |   2 +-
 extensions/lmdb/CMakeLists.txt                     |  37 +++
 extensions/lmdb/LmdbContentRepository.cpp          | 299 +++++++++++++++++++++
 extensions/lmdb/LmdbContentRepository.h            |  92 +++++++
 extensions/lmdb/LmdbStream.cpp                     | 126 +++++++++
 extensions/lmdb/LmdbStream.h                       |  66 +++++
 extensions/lmdb/tests/CMakeLists.txt               |  34 +++
 .../lmdb/tests/LmdbContentRepositoryTests.cpp      | 259 ++++++++++++++++++
 extensions/lmdb/tests/LmdbContentSessionTests.cpp  | 293 ++++++++++++++++++++
 extensions/lmdb/tests/LmdbStreamTests.cpp          | 289 ++++++++++++++++++++
 .../rocksdb-repos/DatabaseContentRepository.h      |   2 +-
 .../rocksdb-repos/tests/ContentSessionTests.cpp    |  10 +-
 .../tests/DBContentRepositoryTests.cpp             |  14 +-
 extensions/rocksdb-repos/tests/RepoTests.cpp       |   4 +-
 extensions/sql/tests/SQLTestPlan.h                 |   2 +-
 .../include/core/repository/FileSystemRepository.h |   2 +-
 libminifi/src/Configuration.cpp                    |   1 +
 libminifi/src/core/RepositoryFactory.cpp           |   2 +
 libminifi/test/libtest/unit/TestBase.h             |   1 +
 libminifi/test/unit/FileSystemRepositoryTests.cpp  |  12 +-
 minifi-api/include/minifi-cpp/core/StreamManager.h |   2 +-
 .../include/minifi-cpp/properties/Configuration.h  |   2 +
 thirdparty/lmdb/all/conandata.yml                  |  23 ++
 thirdparty/lmdb/all/conanfile.py                   |  97 +++++++
 thirdparty/lmdb/all/patches/add-cmake-file.patch   |  23 ++
 .../lmdb/all/patches/fix-windows-symbols.patch     | 112 ++++++++
 thirdparty/lmdb/config.yml                         |  18 ++
 37 files changed, 1909 insertions(+), 33 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c9d2b132b..e5891e029 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,8 +18,8 @@ env:
   CONAN_PASSWORD_NIFI_CONAN: ${{ secrets.CONAN_ACCESS_TOKEN }}
   DOCKER_CMAKE_FLAGS: -DDOCKER_VERIFY_THREAD=3 -DUSE_SHARED_LIBS= 
-DSTRICT_GSL_CHECKS=AUDIT -DCI_BUILD=ON -DENABLE_AWS=ON -DENABLE_KAFKA=ON 
-DENABLE_MQTT=ON -DENABLE_AZURE=ON -DENABLE_SQL=ON \
     -DENABLE_SPLUNK=ON -DENABLE_GCP=ON -DENABLE_OPC=ON 
-DENABLE_PYTHON_SCRIPTING=ON -DENABLE_LUA_SCRIPTING=ON -DENABLE_KUBERNETES=ON 
-DENABLE_TEST_PROCESSORS=ON -DENABLE_PROMETHEUS=ON \
-    -DENABLE_ELASTICSEARCH=ON -DENABLE_GRAFANA_LOKI=ON -DENABLE_COUCHBASE=ON 
-DENABLE_LLAMACPP=ON -DDOCKER_BUILD_ONLY=ON -DMINIFI_PERFORMANCE_TESTS=ON 
-DMINIFI_RUST=OFF -DDOCKER_USE_CONAN=ON \
-    -DDOCKER_NIFI_CONAN_USER=${{ secrets.CONAN_USERNAME }} 
-DDOCKER_NIFI_CONAN_PASSWORD=${{ secrets.CONAN_ACCESS_TOKEN }}
+    -DENABLE_ELASTICSEARCH=ON -DENABLE_GRAFANA_LOKI=ON -DENABLE_COUCHBASE=ON 
-DENABLE_LLAMACPP=ON -DMINIFI_LMDB=ON -DDOCKER_BUILD_ONLY=ON 
-DMINIFI_PERFORMANCE_TESTS=ON -DMINIFI_RUST=OFF \
+    -DDOCKER_USE_CONAN=ON -DDOCKER_NIFI_CONAN_USER=${{ secrets.CONAN_USERNAME 
}} -DDOCKER_NIFI_CONAN_PASSWORD=${{ secrets.CONAN_ACCESS_TOKEN }}
 jobs:
   macos_xcode:
     name: "macOS 26 aarch64"
@@ -65,6 +65,7 @@ jobs:
         -DENABLE_SQL=ON
         -DMINIFI_RUST=ON
         -DENABLE_TEST_PROCESSORS=ON
+        -DMINIFI_LMDB=ON
         -DFORCE_COLORED_OUTPUT=OFF
         -DLIBC_STATIC=OFF
         -DMINIFI_ADVANCED_ASAN_BUILD=OFF
@@ -187,6 +188,7 @@ jobs:
         -DENABLE_TEST_PROCESSORS=ON
         -DENABLE_WEL=ON
         -DMINIFI_RUST=ON
+        -DMINIFI_LMDB=ON
         -DFORCE_COLORED_OUTPUT=ON
         -DINSTALLER_MERGE_MODULES=OFF
         -DMINIFI_FAIL_ON_WARNINGS=ON
@@ -288,10 +290,10 @@ jobs:
         -DENABLE_EXECUTE_PROCESS=ON
         -DENABLE_GCP=ON
         -DENABLE_GRAFANA_LOKI=ON
+        -DENABLE_KAFKA=ON
         -DENABLE_KUBERNETES=ON
         -DENABLE_LIBARCHIVE=ON
         -DENABLE_LLAMACPP=ON
-        -DENABLE_KAFKA=ON
         -DENABLE_LUA_SCRIPTING=ON
         -DENABLE_LZMA=ON
         -DENABLE_MQTT=ON
@@ -306,6 +308,7 @@ jobs:
         -DENABLE_SQL=ON
         -DENABLE_SYSTEMD=ON
         -DMINIFI_RUST=ON
+        -DMINIFI_LMDB=ON
         -DENABLE_TEST_PROCESSORS=ON
         -DFORCE_COLORED_OUTPUT=ON
         -DMINIFI_FAIL_ON_WARNINGS=ON
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 8523e368c..1d860d38b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -744,15 +744,16 @@ endif()
 
 cpack_add_component_group(extensions DISPLAY_NAME "Extensions" EXPANDED)
 set(EXTENSIONS_ENABLED_BY_DEFAULT (
+    minifi-archive-extensions
     minifi-aws
     minifi-azure
     minifi-civet-extensions
     minifi-elasticsearch
     minifi-gcp
     minifi-grafana-loki
-    minifi-archive-extensions
-    minifi-mqtt-extensions
     minifi-kafka
+    minifi-lmdb
+    minifi-mqtt-extensions
     minifi-pdh
     minifi-prometheus
     minifi-rocksdb-repos
diff --git a/CONFIGURE.md b/CONFIGURE.md
index 40cb44b7b..691096889 100644
--- a/CONFIGURE.md
+++ b/CONFIGURE.md
@@ -625,7 +625,7 @@ The Flow File Repository can be configured with the 
`nifi.flowfile.repository.cl
     # in minifi.properties
     nifi.flowfile.repository.class.name=NoOpRepository  # 
VolatileFlowFileRepository can also be used which is an alias for NoOpRepository
 
-The Content Repository can be configured with the 
`nifi.content.repository.class.name` property. If not specified, it uses the 
`DatabaseContentRepository` class by default, which persists the content in a 
RocksDB database. `DatabaseContentRepository` is also the default value 
specified in the minifi.properties file. Alternatively it can be configured to 
use a `VolatileContentRepository` that keeps the state in memory (so the state 
gets lost upon restart), or the `FileSystemRepository` to [...]
+The Content Repository can be configured with the 
`nifi.content.repository.class.name` property. If not specified, it uses the 
`DatabaseContentRepository` class by default, which persists the content in a 
RocksDB database. `DatabaseContentRepository` is also the default value 
specified in the minifi.properties file. Alternatively it can be configured to 
use a `VolatileContentRepository` that keeps the state in memory (so the state 
gets lost upon restart), the `FileSystemRepository` to ke [...]
 
 **NOTE:** RocksDB database has a limit of 4GB for the size of a database 
object. Due to this if you expect to process larger flow files than 4GB you 
should use the `FileSystemRepository`. The downside of using 
`FileSystemRepository` is that it does not have the transactional guarantees of 
the RocksDB repository implementation.
 
@@ -745,6 +745,20 @@ RocksDB options can also be overridden for a specific 
repository using the `nifi
     nifi.provenance.repository.rocksdb.options.use_direct_reads=false
     
nifi.state.storage.rocksdb.options.use_direct_io_for_flush_and_compaction=false
 
+### Configuring LMDB content repository
+
+Note: LMDB repository is currently experimental
+
+There is an alternative content repository that can be used: the LMDB 
database. When `LmdbContentRepository` is set, LMDB is used for storing 
content, which is a memory-mapped, fast (especially for reads), low-footprint, 
key-value database. It can be a good alternative on memory-limited edge 
devices, but each use case should be evaluated separately. The caveats of using 
this database are the following:
+
+- Single writer only — concurrent writes are serialized, which can be a 
bottleneck for write-heavy workloads
+- Database file never shrinks automatically — occasional large flow files 
could keep the database size permanently high on disk and in the reserved 
virtual address space
+- No compression - stores everything uncompressed on disk
+- The maximum database size must be set upfront, which is done in the 
minifi.properties file with the following property, with the current default 
value set to 10 GB:
+
+    # in minifi.properties
+    nifi.content.repository.lmdb.max.db.size=10 GB
+
 #### Shared database
 
 It is also possible to use a single database to store multiple repositories 
with the `minifidb://` scheme.
diff --git a/bootstrap/cli.py b/bootstrap/cli.py
index 4899a5856..9838bf75b 100644
--- a/bootstrap/cli.py
+++ b/bootstrap/cli.py
@@ -87,6 +87,7 @@ def run_conan_install(minifi_options: MinifiOptions, 
package_manager: PackageMan
             "ENABLE_LLAMACPP",
             "ENABLE_AWS",
             "ENABLE_SQL",
+            "MINIFI_LMDB",
             "PORTABLE",
             "SKIP_TESTS",
         ],
diff --git a/bootstrap/minifi_option.py b/bootstrap/minifi_option.py
index 70e78c607..c9640c176 100644
--- a/bootstrap/minifi_option.py
+++ b/bootstrap/minifi_option.py
@@ -45,6 +45,7 @@ class MinifiOptions:
             "DOCKER_CREATE_RPM",
             "SKIP_TESTS",
             "PORTABLE",
+            "AWS_ENABLE_UNITY_BUILD",
         ]
         self.use_ninja = CMakeCacheValue(
             "Specifies if CMake should use the Ninja generator or the system 
default",
@@ -62,6 +63,7 @@ class MinifiOptions:
             self.use_ninja.value = cache_values["USE_NINJA"].value
         if "USE_CONAN" in cache_values:
             self.use_conan.value = cache_values["USE_CONAN"].value
+        minifi_prefixed_extension_options = ["MINIFI_RUST", "MINIFI_LMDB"]
         self.bool_options = {
             name: cache_value
             for name, cache_value in cache_values.items()
@@ -71,12 +73,14 @@ class MinifiOptions:
         self.build_options = {
             name: cache_value
             for name, cache_value in self.bool_options.items()
-            if "MINIFI" in name or name in additional_build_options
+            if ("MINIFI" in name or name in additional_build_options) and name 
not in minifi_prefixed_extension_options
         }
         self.build_options["USE_NINJA"] = self.use_ninja
         self.build_options["USE_CONAN"] = self.use_conan
         self.extension_options = {
-            name: cache_value for name, cache_value in 
self.bool_options.items() if "ENABLE" in name
+            name: cache_value
+            for name, cache_value in self.bool_options.items()
+            if name.startswith("ENABLE") or name in 
minifi_prefixed_extension_options
         }
         self.multi_choice_options = [
             cache_value
diff --git a/cmake/GetLMDB.cmake b/cmake/GetLMDB.cmake
new file mode 100644
index 000000000..316847765
--- /dev/null
+++ b/cmake/GetLMDB.cmake
@@ -0,0 +1,24 @@
+# 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.
+
+if(MINIFI_LMDB_SOURCE STREQUAL "CONAN")
+    message("Using Conan to install LMDB")
+    find_package(lmdb REQUIRED)
+elseif(MINIFI_LMDB_SOURCE STREQUAL "BUILD")
+    message("Using CMake to build LMDB from source")
+    include(LMDB)
+endif()
diff --git a/cmake/LMDB.cmake b/cmake/LMDB.cmake
new file mode 100644
index 000000000..194186c8a
--- /dev/null
+++ b/cmake/LMDB.cmake
@@ -0,0 +1,46 @@
+# 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.
+
+include(FetchContent)
+
+set(PATCH_FILE_1 
"${CMAKE_SOURCE_DIR}/thirdparty/lmdb/all/patches/add-cmake-file.patch")
+if (WIN32)
+    set(PATCH_FILE_2 
"${CMAKE_SOURCE_DIR}/thirdparty/lmdb/all/patches/fix-windows-symbols.patch")
+    set(PC ${Bash_EXECUTABLE}  -c "set -x &&\
+        (\\\"${Patch_EXECUTABLE}\\\" -p0 -R -s -f --dry-run -i 
\\\"${PATCH_FILE_1}\\\" || \\\"${Patch_EXECUTABLE}\\\" -p0 -N -i 
\\\"${PATCH_FILE_1}\\\") &&\
+        (\\\"${Patch_EXECUTABLE}\\\" -p0 -R -s -f --dry-run -i 
\\\"${PATCH_FILE_2}\\\" || \\\"${Patch_EXECUTABLE}\\\" -p0 -N -i 
\\\"${PATCH_FILE_2}\\\")")
+else()
+    set(PC ${Bash_EXECUTABLE}  -c "set -x &&\
+        (\\\"${Patch_EXECUTABLE}\\\" -p0 -R -s -f --dry-run -i 
\\\"${PATCH_FILE_1}\\\" || \\\"${Patch_EXECUTABLE}\\\" -p0 -N -i 
\\\"${PATCH_FILE_1}\\\")")
+endif()
+
+FetchContent_Declare(
+        lmdb
+        URL      
https://github.com/LMDB/lmdb/archive/refs/tags/LMDB_1.0.1.tar.gz
+        URL_HASH 
SHA256=7ce1db4b8c13f60e0881f12537340c73fb5e0125dba4daa6649a2314341855db
+        PATCH_COMMAND "${PC}"
+        SOURCE_SUBDIR "libraries/liblmdb"
+        SYSTEM
+)
+
+FetchContent_MakeAvailable(lmdb)
+
+set(LMDB_INCLUDE_DIR "${lmdb_SOURCE_DIR}/libraries/liblmdb")
+
+if (NOT TARGET lmdb::lmdb)
+    add_library(lmdb::lmdb ALIAS lmdb)
+endif()
diff --git a/cmake/MiNiFiOptions.cmake b/cmake/MiNiFiOptions.cmake
index a8d1d5373..7a898a765 100644
--- a/cmake/MiNiFiOptions.cmake
+++ b/cmake/MiNiFiOptions.cmake
@@ -120,6 +120,7 @@ add_minifi_option(ENABLE_CONTROLLER "Enables the build of 
MiNiFi controller bina
 add_minifi_option(ENABLE_LLAMACPP "Enables llama.cpp support." ON)
 add_minifi_option(ENABLE_OPC "Instructs the build system to enable the OPC 
extension" ON)
 add_minifi_option(MINIFI_RUST "Enables the build of rust based extensions." 
OFF)
+add_minifi_option(MINIFI_LMDB "Enables the LMDB extension." OFF)
 
 set_minifi_cache_variable(CUSTOM_MALLOC OFF "Overwrite malloc implementation.")
 set_property(CACHE CUSTOM_MALLOC PROPERTY STRINGS "jemalloc" "mimalloc" 
"rpmalloc" OFF)
@@ -194,6 +195,7 @@ add_minifi_multi_option(MINIFI_WINFLEXBISON_SOURCE 
"Retrieves winflexbison from
 add_minifi_multi_option(MINIFI_AWS_SDK_CPP_SOURCE "Retrieves AWS SDK for C++ 
from provided source" "BUILD;CONAN" "${MINIFI_DEFAULT_DEPENDENCY_SOURCE}")
 add_minifi_multi_option(MINIFI_IODBC_SOURCE "Retrieves iODBC from provided 
source" "BUILD;CONAN" "${MINIFI_DEFAULT_DEPENDENCY_SOURCE}")
 add_minifi_multi_option(MINIFI_SOCI_SOURCE "Retrieves SOCI from provided 
source" "BUILD;CONAN" "${MINIFI_DEFAULT_DEPENDENCY_SOURCE}")
+add_minifi_multi_option(MINIFI_LMDB_SOURCE "Retrieves LMDB from provided 
source" "BUILD;CONAN" "${MINIFI_DEFAULT_DEPENDENCY_SOURCE}")
 
 # Docker options
 
diff --git a/conanfile.py b/conanfile.py
index d1c3d5be6..8d4c7cab2 100644
--- a/conanfile.py
+++ b/conanfile.py
@@ -122,6 +122,7 @@ class MiNiFiCppMain(ConanFile):
         "enable_llamacpp": [True, False],
         "enable_aws": [True, False],
         "enable_sql": [True, False],
+        "minifi_lmdb": [True, False],
         "skip_tests": [True, False],
         "portable": [True, False],
     }
@@ -149,6 +150,7 @@ class MiNiFiCppMain(ConanFile):
         "enable_llamacpp": False,
         "enable_aws": False,
         "enable_sql": False,
+        "minifi_lmdb": False,
         "skip_tests": False,
         "portable": True,
     }
@@ -210,6 +212,8 @@ class MiNiFiCppMain(ConanFile):
             self.requires("aws-sdk-cpp/1.11.807@minifi/develop")
         if self.options.enable_all or self.options.enable_sql:
             self.requires("soci/4.1.4@minifi/develop")
+        if self.options.enable_all or self.options.minifi_lmdb:
+            self.requires("lmdb/1.0.1@minifi/develop")
 
         if self.options.custom_malloc == "jemalloc":
             self.requires("jemalloc/5.3.1")
diff --git a/conf/minifi.properties.in b/conf/minifi.properties.in
index afcb7f9d9..722fc1ca7 100644
--- a/conf/minifi.properties.in
+++ b/conf/minifi.properties.in
@@ -54,6 +54,9 @@ nifi.content.repository.class.name=DatabaseContentRepository
 # nifi.database.content.repository.rocksdb.compaction.period=2 min
 # nifi.database.content.repository.optimize.for.small.db.cache.size=8 MB
 
+## Relates to the internal workings of the LMDB backend
+# nifi.content.repository.lmdb.max.db.size=10 GB
+
 # setting this value to "0" enables synchronous deletion
 # nifi.database.content.repository.purge.period = 1 sec
 
diff --git a/core-framework/src/core/BufferedContentSession.cpp 
b/core-framework/src/core/BufferedContentSession.cpp
index 7fbb90f84..c9fde53d9 100644
--- a/core-framework/src/core/BufferedContentSession.cpp
+++ b/core-framework/src/core/BufferedContentSession.cpp
@@ -69,7 +69,7 @@ std::shared_ptr<io::BaseStream> 
BufferedContentSession::read(const std::shared_p
 
 void BufferedContentSession::commit() {
   for (const auto& resource : managed_resources_) {
-    auto outStream = repository_->write(*resource.first);
+    auto outStream = repository_->write(*resource.first, false);
     if (outStream == nullptr) {
       throw Exception(REPOSITORY_EXCEPTION, "Couldn't open the underlying 
resource for write: " + resource.first->getContentFullPath());
     }
diff --git a/extensions/lmdb/CMakeLists.txt b/extensions/lmdb/CMakeLists.txt
new file mode 100644
index 000000000..63ef8b2c4
--- /dev/null
+++ b/extensions/lmdb/CMakeLists.txt
@@ -0,0 +1,37 @@
+#
+# 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.
+#
+
+if (NOT (ENABLE_ALL OR MINIFI_LMDB))
+    return()
+endif()
+
+include(GetLMDB)
+
+include(${CMAKE_SOURCE_DIR}/extensions/ExtensionHeader.txt)
+
+file(GLOB SOURCES  "*.cpp")
+
+add_minifi_library(minifi-lmdb SHARED ${SOURCES})
+
+target_include_directories(minifi-lmdb PUBLIC ${LMDB_INCLUDE_DIR})
+target_link_libraries(minifi-lmdb PUBLIC lmdb::lmdb)
+target_link_libraries(minifi-lmdb PUBLIC minifi-api minifi-extension-framework)
+target_link_libraries(minifi-lmdb PRIVATE $<LINK_ONLY:core-minifi>)
+
+register_extension(minifi-lmdb "LMDB" LMDB "This enables persistent 
repositories using LMDB" "extensions/lmdb/tests")
diff --git a/extensions/lmdb/LmdbContentRepository.cpp 
b/extensions/lmdb/LmdbContentRepository.cpp
new file mode 100644
index 000000000..3e135991d
--- /dev/null
+++ b/extensions/lmdb/LmdbContentRepository.cpp
@@ -0,0 +1,299 @@
+/**
+ * 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.
+ */
+
+#include "LmdbContentRepository.h"
+
+#include <filesystem>
+#include <iterator>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "LmdbStream.h"
+#include "core/Resource.h"
+#include "lmdb.h"
+#include "minifi-cpp/Exception.h"
+#include "minifi-cpp/utils/gsl.h"
+#include "utils/Locations.h"
+#include "minifi-cpp/utils/Literals.h"
+#include "utils/file/FileUtils.h"
+
+namespace org::apache::nifi::minifi::core::repository {
+
+LmdbContentRepository::Session::Session(std::shared_ptr<LmdbContentRepository> 
repository) : BufferedContentSession(std::move(repository)) {}
+
+void LmdbContentRepository::Session::commit() {
+  auto lmdb_content_repository = 
std::dynamic_pointer_cast<LmdbContentRepository>(repository_);
+  if (!lmdb_content_repository) {
+    throw Exception(REPOSITORY_EXCEPTION, "Session's repository is not an 
LmdbContentRepository");
+  }
+
+  const auto writeResource = [&lmdb_content_repository](const 
std::shared_ptr<ResourceClaim>& resource_claim, const 
std::shared_ptr<io::BaseStream>& stream, bool is_append) {
+    auto outStream = lmdb_content_repository->write(*resource_claim, 
is_append);
+    if (outStream == nullptr) {
+      throw Exception(REPOSITORY_EXCEPTION, "Couldn't open the underlying 
resource for write: " + resource_claim->getContentFullPath());
+    }
+    const auto size = stream->size();
+    auto lmdb_out_stream = 
std::dynamic_pointer_cast<io::LmdbStream>(outStream);
+    if (lmdb_out_stream == nullptr) {
+      throw Exception(REPOSITORY_EXCEPTION, "Couldn't cast output stream to 
LmdbStream for commit: " + resource_claim->getContentFullPath());
+    }
+    if (outStream->write(stream->getBuffer()) != size) {
+      throw Exception(REPOSITORY_EXCEPTION, "Failed to write " + 
std::string(is_append ? "appended" : "new") + " resource: " + 
resource_claim->getContentFullPath());
+    }
+    if (!lmdb_out_stream->commit()) {
+      throw Exception(REPOSITORY_EXCEPTION, "Failed to commit " + 
std::string(is_append ? "appended" : "new") + " resource: " + 
resource_claim->getContentFullPath());
+    }
+  };
+
+  for (const auto& resource : managed_resources_) {
+    writeResource(resource.first, resource.second, false);
+  }
+
+  for (const auto& resource : append_state_) {
+    writeResource(resource.first, resource.second.stream, true);
+  }
+
+  managed_resources_.clear();
+  append_state_.clear();
+}
+
+bool LmdbContentRepository::initialize(const 
std::shared_ptr<minifi::Configure>& configuration) {
+  if (const int rc = mdb_env_create(&lmdb_env_); rc != MDB_SUCCESS) {
+    logger_->log_error("Failed to create LMDB environment: {}", 
mdb_strerror(rc));
+    return false;
+  }
+
+  // Reserve virtual address space for the DB file (max size it can grow to)
+  std::expected<uint64_t, std::error_code> max_db_size;
+  auto max_db_size_str_opt = 
configuration->get(Configure::nifi_content_repository_lmdb_max_db_size);
+  if (!max_db_size_str_opt || max_db_size_str_opt->empty()) {
+    max_db_size = 10_GiB;
+  } else {
+    max_db_size = parsing::parseDataSize(*max_db_size_str_opt);
+  }
+
+  if (!max_db_size) {
+    logger_->log_error("Invalid max DB size configuration for LMDB Content 
Repository: {}", *max_db_size_str_opt);
+    mdb_env_close(lmdb_env_);
+    lmdb_env_ = nullptr;
+    return false;
+  }
+
+  logger_->log_info("Setting LMDB max DB size to {} bytes", *max_db_size);
+  if (const auto rc = mdb_env_set_mapsize(lmdb_env_, 
gsl::narrow<size_t>(*max_db_size)); rc != MDB_SUCCESS) {
+    logger_->log_error("Failed to set LMDB map size: {}", mdb_strerror(rc));
+    mdb_env_close(lmdb_env_);
+    lmdb_env_ = nullptr;
+    return false;
+  }
+
+  const auto working_dir = utils::getMinifiDir();
+
+  std::string value;
+  if 
(configuration->get(Configure::nifi_dbcontent_repository_directory_default, 
value) && !value.empty()) {
+    directory_ = value;
+  } else {
+    directory_ = (working_dir / "lmdbcontentrepository").string();
+  }
+
+  if (std::filesystem::exists(directory_)) {
+    logger_->log_info("Using existing LMDB Content Repository directory at 
{}", directory_);
+  } else {
+    logger_->log_info("Creating LMDB Content Repository directory at {}", 
directory_);
+    if (!std::filesystem::create_directories(directory_)) {
+      logger_->log_error("Failed to create LMDB Content Repository directory 
at {}", directory_);
+      mdb_env_close(lmdb_env_);
+      lmdb_env_ = nullptr;
+      return false;
+    }
+  }
+
+  if (const int rc = mdb_env_open(lmdb_env_, directory_.c_str(), MDB_NOTLS, 
0664)) {
+    logger_->log_error("Failed to open LMDB environment: {}", 
mdb_strerror(rc));
+    mdb_env_close(lmdb_env_);
+    lmdb_env_ = nullptr;
+    return false;
+  }
+
+  MDB_txn* init_txn = nullptr;
+  if (const int rc = mdb_txn_begin(lmdb_env_, nullptr, 0, &init_txn); rc != 
MDB_SUCCESS) {
+    logger_->log_error("Failed to begin LMDB transaction during initialize: 
{}", mdb_strerror(rc));
+    mdb_env_close(lmdb_env_);
+    lmdb_env_ = nullptr;
+    return false;
+  }
+  if (const int rc = mdb_dbi_open(init_txn, nullptr, 0, &lmdb_handle_); rc != 
MDB_SUCCESS) {
+    logger_->log_error("Failed to open LMDB database: {}", mdb_strerror(rc));
+    mdb_txn_abort(init_txn);
+    mdb_env_close(lmdb_env_);
+    lmdb_env_ = nullptr;
+    return false;
+  }
+
+  if (const int rc = mdb_txn_commit(init_txn); rc != MDB_SUCCESS) {
+    logger_->log_error("Failed to commit LMDB transaction during initialize: 
{}", mdb_strerror(rc));
+    mdb_env_close(lmdb_env_);
+    lmdb_env_ = nullptr;
+    return false;
+  }
+
+  return true;
+}
+
+void LmdbContentRepository::start() {}
+void LmdbContentRepository::stop() {}
+
+std::shared_ptr<ContentSession> LmdbContentRepository::createSession() {
+  return std::make_shared<Session>(sharedFromThis<LmdbContentRepository>());
+}
+
+std::shared_ptr<io::BaseStream> LmdbContentRepository::write(const 
minifi::ResourceClaim& claim, bool append) {
+  return std::make_shared<io::LmdbStream>(claim.getContentFullPath(), 
lmdb_env_, &lmdb_handle_, true, append);
+}
+
+std::shared_ptr<io::BaseStream> LmdbContentRepository::read(const 
minifi::ResourceClaim& claim) {
+  return std::make_shared<io::LmdbStream>(claim.getContentFullPath(), 
lmdb_env_, &lmdb_handle_, false);
+}
+
+bool LmdbContentRepository::exists(const minifi::ResourceClaim& streamId) {
+  const auto path = streamId.getContentFullPath();
+  MDB_val key{path.size(), const_cast<char*>(path.data())};
+  MDB_val value{};
+
+  MDB_txn* txn = nullptr;
+  if (const int rc = mdb_txn_begin(lmdb_env_, nullptr, MDB_RDONLY, &txn); rc 
!= MDB_SUCCESS) {
+    logger_->log_error("Failed to begin LMDB read transaction in exists: {}", 
mdb_strerror(rc));
+    return false;
+  }
+  auto guard = gsl::finally([txn] { mdb_txn_abort(txn); });
+
+  const auto rc = mdb_get(txn, lmdb_handle_, &key, &value);
+  if (rc != MDB_SUCCESS && rc != MDB_NOTFOUND) {
+    logger_->log_error("Failed to get value from LMDB database: {}", 
mdb_strerror(rc));
+  }
+  return rc == MDB_SUCCESS;
+}
+
+bool LmdbContentRepository::removeKey(const std::string& content_path) {
+  MDB_val key{content_path.size(), const_cast<char*>(content_path.data())};
+
+  MDB_txn* txn = nullptr;
+  if (const int rc = mdb_txn_begin(lmdb_env_, nullptr, 0, &txn); rc != 
MDB_SUCCESS) {
+    logger_->log_error("Failed to begin LMDB write transaction in removeKey: 
{}", mdb_strerror(rc));
+    return false;
+  }
+  int rc = mdb_del(txn, lmdb_handle_, &key, nullptr);
+
+  if (rc == MDB_SUCCESS) {
+    if (const int rc = mdb_txn_commit(txn); rc != MDB_SUCCESS) {
+      logger_->log_error("Failed to commit LMDB transaction during delete: 
{}", mdb_strerror(rc));
+      return false;
+    }
+    return true;
+  } else if (rc == MDB_NOTFOUND) {
+    logger_->log_debug("Key {} not found in LMDB database during delete", 
content_path);
+    mdb_txn_abort(txn);
+    return true;
+  } else {
+    logger_->log_error("Failed to delete key '{}' from LMDB database: {}", 
content_path, mdb_strerror(rc));
+    mdb_txn_abort(txn);
+    return false;
+  }
+}
+
+void LmdbContentRepository::clearOrphans() {
+  std::vector<std::string> keys_to_be_deleted;
+
+  {
+    MDB_txn* txn = nullptr;
+    if (const int rc = mdb_txn_begin(lmdb_env_, nullptr, MDB_RDONLY, &txn); rc 
!= MDB_SUCCESS) {
+      logger_->log_error("Failed to begin LMDB read transaction in 
clearOrphans: {}", mdb_strerror(rc));
+      return;
+    }
+    auto txn_guard = gsl::finally([txn] { mdb_txn_abort(txn); });
+
+    MDB_cursor* cursor = nullptr;
+    if (const int rc = mdb_cursor_open(txn, lmdb_handle_, &cursor); rc != 
MDB_SUCCESS) {
+      logger_->log_error("Failed to open LMDB cursor in clearOrphans: {}", 
mdb_strerror(rc));
+      return;
+    }
+    auto cursor_guard = gsl::finally([cursor] { mdb_cursor_close(cursor); });
+
+    MDB_val key{};
+    MDB_val val{};
+    int rc = mdb_cursor_get(cursor, &key, &val, MDB_FIRST);
+
+    {
+      std::lock_guard<std::mutex> lock(count_map_mutex_);
+      while (rc == MDB_SUCCESS) {
+        std::string key_string = std::string(static_cast<char*>(key.mv_data), 
key.mv_size);
+
+        auto claim_it = count_map_.find(key_string);
+        if (claim_it == count_map_.end() || claim_it->second == 0) {
+          logger_->log_debug("Deleting orphan resource {}", key_string);
+          keys_to_be_deleted.push_back(key_string);
+        }
+        rc = mdb_cursor_get(cursor, &key, &val, MDB_NEXT);
+      }
+    }
+
+    if (rc != MDB_NOTFOUND) {
+      logger_->log_error("Failed to iterate over LMDB database: {}", 
mdb_strerror(rc));
+      return;
+    }
+  }
+
+  std::vector<std::string> failed_deletions;
+  for (const auto& key : keys_to_be_deleted) {
+    auto delete_result = removeKey(key);
+    if (!delete_result) {
+      logger_->log_warn("Failed to delete orphan resource {} from LMDB 
database", key);
+      failed_deletions.push_back(key);
+    }
+  }
+
+  std::lock_guard<std::mutex> lock(purge_list_mutex_);
+  purge_list_.insert(purge_list_.end(), 
std::make_move_iterator(failed_deletions.begin()), 
std::make_move_iterator(failed_deletions.end()));
+}
+
+MDB_stat LmdbContentRepository::getDbStat() const {
+  MDB_stat stat{};
+  MDB_txn* txn = nullptr;
+  if (const int rc = mdb_txn_begin(lmdb_env_, nullptr, MDB_RDONLY, &txn); rc 
!= MDB_SUCCESS) {
+    logger_->log_error("Failed to begin LMDB read transaction in getDbStat: 
{}", mdb_strerror(rc));
+    return stat;
+  }
+  if (const int rc = mdb_stat(txn, lmdb_handle_, &stat); rc != MDB_SUCCESS) {
+    logger_->log_error("Failed to read LMDB database stats: {}", 
mdb_strerror(rc));
+  }
+  mdb_txn_abort(txn);
+  return stat;
+}
+
+uint64_t LmdbContentRepository::getRepositorySize() const {
+  return utils::file::path_size(directory_);
+}
+
+uint64_t LmdbContentRepository::getRepositoryEntryCount() const {
+  return getDbStat().ms_entries;
+}
+
+REGISTER_RESOURCE_AS(LmdbContentRepository, InternalResource, 
("LmdbContentRepository", "lmdbcontentrepository"));
+
+}  // namespace org::apache::nifi::minifi::core::repository
diff --git a/extensions/lmdb/LmdbContentRepository.h 
b/extensions/lmdb/LmdbContentRepository.h
new file mode 100644
index 000000000..fba4191a8
--- /dev/null
+++ b/extensions/lmdb/LmdbContentRepository.h
@@ -0,0 +1,92 @@
+/**
+ * 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.
+ */
+#pragma once
+
+#include <memory>
+#include <string>
+#include <array>
+#include <string_view>
+
+#include "core/BufferedContentSession.h"
+#include "core/ContentRepository.h"
+#include "core/ClassName.h"
+#include "core/logging/LoggerFactory.h"
+#include "minifi-cpp/core/PropertyDefinition.h"
+#include "minifi-cpp/utils/Export.h"
+#include "minifi-cpp/utils/Id.h"
+#include "lmdb.h"
+
+namespace org::apache::nifi::minifi::core::repository {
+
+class LmdbContentRepository : public core::ContentRepositoryImpl {
+ public:
+  explicit LmdbContentRepository(std::string_view name = 
className<LmdbContentRepository>(), const utils::Identifier& uuid = {})
+      : core::ContentRepositoryImpl(name, uuid) {}
+
+  LmdbContentRepository(const LmdbContentRepository&) = delete;
+  LmdbContentRepository(LmdbContentRepository&&) = delete;
+  LmdbContentRepository& operator=(const LmdbContentRepository&) = delete;
+  LmdbContentRepository& operator=(LmdbContentRepository&&) = delete;
+
+  ~LmdbContentRepository() noexcept override {
+    LmdbContentRepository::stop();
+    if (lmdb_env_) {
+      mdb_dbi_close(lmdb_env_, lmdb_handle_);
+      mdb_env_close(lmdb_env_);
+    }
+  }
+
+  EXTENSIONAPI static constexpr auto Properties = 
std::array<core::PropertyReference, 0>{};
+  EXTENSIONAPI static constexpr bool SupportsDynamicProperties = false;
+  EXTENSIONAPI static constexpr bool SupportsDynamicRelationships = false;
+
+  class Session : public BufferedContentSession {
+   public:
+    explicit Session(std::shared_ptr<LmdbContentRepository> repository);
+
+    void commit() override;
+  };
+
+  std::shared_ptr<ContentSession> createSession() override;
+  bool initialize(const std::shared_ptr<minifi::Configure>& configuration) 
override;
+  std::shared_ptr<io::BaseStream> write(const minifi::ResourceClaim& claim, 
bool append) override;
+  std::shared_ptr<io::BaseStream> read(const minifi::ResourceClaim& claim) 
override;
+
+  bool close(const minifi::ResourceClaim& claim) override { return 
remove(claim); }
+
+  bool exists(const minifi::ResourceClaim& streamId) override;
+
+  void clearOrphans() override;
+
+  void start() override;
+  void stop() override;
+
+  uint64_t getRepositorySize() const override;
+  uint64_t getRepositoryEntryCount() const override;
+
+ protected:
+  bool removeKey(const std::string& content_path) override;
+
+ private:
+  MDB_stat getDbStat() const;
+
+  MDB_env* lmdb_env_{nullptr};
+  MDB_dbi lmdb_handle_{};
+  std::shared_ptr<logging::Logger> 
logger_{logging::LoggerFactory<LmdbContentRepository>::getLogger()};
+};
+
+}  // namespace org::apache::nifi::minifi::core::repository
diff --git a/extensions/lmdb/LmdbStream.cpp b/extensions/lmdb/LmdbStream.cpp
new file mode 100644
index 000000000..0fbd695b2
--- /dev/null
+++ b/extensions/lmdb/LmdbStream.cpp
@@ -0,0 +1,126 @@
+/**
+ *
+ * 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.
+ */
+
+#include "LmdbStream.h"
+
+#include <cstring>
+#include <algorithm>
+#include <string>
+#include <utility>
+
+#include "io/validation.h"
+
+namespace org::apache::nifi::minifi::io {
+
+LmdbStream::LmdbStream(std::string path, MDB_env* lmdb_env, MDB_dbi* 
lmdb_handle, bool write_enable, bool append)
+    : BaseStreamImpl(),
+      path_(std::move(path)),
+      write_enable_(write_enable),
+      lmdb_env_(lmdb_env),
+      lmdb_handle_(lmdb_handle),
+      exists_(loadValue()) {
+  if (write_enable_ && !append) {
+    if (exists_) {
+      logger_->log_warn("Overwriting pre-existing LMDB content for key '{}' on 
a non-append write", path_);
+    }
+    value_.clear();
+  }
+}
+
+bool LmdbStream::loadValue() {
+  MDB_val key{path_.size(), const_cast<char*>(path_.data())};
+  MDB_val value{};
+
+  MDB_txn* txn = nullptr;
+  if (const int rc = mdb_txn_begin(lmdb_env_, nullptr, MDB_RDONLY, &txn); rc 
!= MDB_SUCCESS) {
+    logger_->log_error("Failed to begin LMDB read transaction in loadValue: 
{}", mdb_strerror(rc));
+    return false;
+  }
+  auto guard = gsl::finally([txn] { mdb_txn_abort(txn); });
+
+  const auto rc = mdb_get(txn, *lmdb_handle_, &key, &value);
+  if (rc == MDB_SUCCESS) {
+    value_ = std::string(static_cast<char*>(value.mv_data), value.mv_size);
+    return true;
+  } else if (rc != MDB_NOTFOUND) {
+    logger_->log_error("Failed to get value from LMDB database: {}", 
mdb_strerror(rc));
+  }
+  return false;
+}
+
+void LmdbStream::close() {
+  commit();
+}
+
+bool LmdbStream::commit() {
+  if (!write_enable_ || !dirty_) { return false; }
+
+  MDB_txn* txn = nullptr;
+  auto rc = mdb_txn_begin(lmdb_env_, nullptr, 0, &txn);
+  if (rc != MDB_SUCCESS) {
+    logger_->log_error("Failed to begin LMDB transaction in commit: {}", 
mdb_strerror(rc));
+    return false;
+  }
+
+  MDB_val key{path_.size(), const_cast<char*>(path_.data())};
+  MDB_val val{value_.size(), const_cast<char*>(value_.data())};
+  rc = mdb_put(txn, *lmdb_handle_, &key, &val, 0);
+  if (rc != MDB_SUCCESS) {
+    logger_->log_error("Failed to put value in LMDB database during commit: 
{}", mdb_strerror(rc));
+    mdb_txn_abort(txn);
+    return false;
+  }
+
+  rc = mdb_txn_commit(txn);
+  if (rc != MDB_SUCCESS) {
+    logger_->log_error("Failed to commit LMDB transaction during commit: {}", 
mdb_strerror(rc));
+    return false;
+  }
+
+  dirty_ = false;
+  return true;
+}
+
+void LmdbStream::seek(size_t offset) {
+  offset_ = offset;
+}
+
+size_t LmdbStream::tell() const {
+  return offset_;
+}
+
+size_t LmdbStream::write(const uint8_t* value, size_t size) {
+  if (!write_enable_) { return STREAM_ERROR; }
+  if (size != 0 && value == nullptr) { return STREAM_ERROR; }
+  value_.append(reinterpret_cast<const char*>(value), size);
+  dirty_ = true;
+  return size;
+}
+
+size_t LmdbStream::read(std::span<std::byte> buf) {
+  if (!exists_) { return STREAM_ERROR; }
+  if (buf.empty()) { return 0; }
+  if (offset_ >= value_.size()) { return 0; }
+
+  const auto bytes_to_read = std::min(buf.size(), value_.size() - offset_);
+  std::memcpy(buf.data(), value_.data() + offset_, bytes_to_read);
+  offset_ += bytes_to_read;
+  return bytes_to_read;
+}
+
+}  // namespace org::apache::nifi::minifi::io
diff --git a/extensions/lmdb/LmdbStream.h b/extensions/lmdb/LmdbStream.h
new file mode 100644
index 000000000..85e0b9501
--- /dev/null
+++ b/extensions/lmdb/LmdbStream.h
@@ -0,0 +1,66 @@
+/**
+ *
+ * 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.
+ */
+#pragma once
+
+#include <cstdint>
+#include <memory>
+#include <string>
+
+#include "core/logging/LoggerFactory.h"
+#include "io/BaseStream.h"
+#include "lmdb.h"
+
+namespace org::apache::nifi::minifi::io {
+
+class LmdbStream : public io::BaseStreamImpl {
+ public:
+  explicit LmdbStream(std::string path, MDB_env* lmdb_env, MDB_dbi* 
lmdb_handle, bool write_enable, bool append = true);
+
+  ~LmdbStream() override { close(); }
+
+  void close() final;
+  void seek(size_t offset) override;
+
+  size_t tell() const override;
+
+  size_t size() const override { return value_.size(); }
+
+  using BaseStream::read;
+  using BaseStream::write;
+
+  size_t read(std::span<std::byte> buf) override;
+  size_t write(const uint8_t* value, size_t size) override;
+
+  bool commit();
+
+ private:
+  bool loadValue();
+
+  std::string path_;
+  bool write_enable_;
+  std::string value_;
+  MDB_env* lmdb_env_;
+  MDB_dbi* lmdb_handle_;
+  bool exists_;
+  size_t offset_ = 0;
+  bool dirty_ = false;
+
+  std::shared_ptr<core::logging::Logger> logger_ = 
core::logging::LoggerFactory<LmdbStream>::getLogger();
+};
+
+}  // namespace org::apache::nifi::minifi::io
diff --git a/extensions/lmdb/tests/CMakeLists.txt 
b/extensions/lmdb/tests/CMakeLists.txt
new file mode 100644
index 000000000..918f946f3
--- /dev/null
+++ b/extensions/lmdb/tests/CMakeLists.txt
@@ -0,0 +1,34 @@
+#
+# 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.
+#
+
+file(GLOB LMDB_UNIT_TESTS  "*.cpp")
+SET(LMDB_TEST_COUNT 0)
+FOREACH(testfile ${LMDB_UNIT_TESTS})
+    get_filename_component(testfilename "${testfile}" NAME_WE)
+    add_minifi_executable("${testfilename}" "${testfile}")
+    target_include_directories(${testfilename} BEFORE PRIVATE 
"${CMAKE_SOURCE_DIR}/extensions/lmdb/")
+    target_include_directories(${testfilename} SYSTEM BEFORE PRIVATE 
"${LMDB_THIRDPARTY_ROOT}/include")
+    createTests("${testfilename}")
+    target_link_libraries(${testfilename} Catch2WithMain)
+    target_link_libraries(${testfilename} minifi-lmdb)
+    target_link_libraries(${testfilename} minifi-standard-processors)
+    MATH(EXPR LMDB_TEST_COUNT "${LMDB_TEST_COUNT}+1")
+    add_test(NAME "${testfilename}" COMMAND "${testfilename}" 
WORKING_DIRECTORY ${TEST_DIR})
+ENDFOREACH()
+message("-- Finished building ${LMDB_TEST_COUNT} LMDB related test file(s)...")
diff --git a/extensions/lmdb/tests/LmdbContentRepositoryTests.cpp 
b/extensions/lmdb/tests/LmdbContentRepositoryTests.cpp
new file mode 100644
index 000000000..88d79fc09
--- /dev/null
+++ b/extensions/lmdb/tests/LmdbContentRepositoryTests.cpp
@@ -0,0 +1,259 @@
+/**
+ *
+ * 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.
+ */
+
+#include <filesystem>
+#include <fstream>
+
+#include "LmdbContentRepository.h"
+#include "ResourceClaim.h"
+#include "properties/Configure.h"
+#include "unit/Catch.h"
+#include "unit/ContentRepositoryDependentTests.h"
+#include "unit/TestBase.h"
+
+namespace org::apache::nifi::minifi::test {
+
+class LmdbContentRepositoryTests : TestController {
+ public:
+  LmdbContentRepositoryTests() {
+    auto configuration = 
std::make_shared<org::apache::nifi::minifi::ConfigureImpl>();
+    
configuration->set(minifi::Configure::nifi_dbcontent_repository_directory_default,
 createTempDirectory().string());
+    REQUIRE(content_repo_->initialize(configuration));
+  }
+
+ protected:
+  static constexpr std::string_view test_content_ = "well hello there";
+  std::shared_ptr<core::repository::LmdbContentRepository> content_repo_ = 
std::make_shared<core::repository::LmdbContentRepository>();
+
+  void writeContent(const minifi::ResourceClaim& claim) {
+    auto stream = content_repo_->write(claim, false);
+    stream->write(as_bytes(std::span(test_content_)));
+    stream->close();
+  }
+};
+
+TEST_CASE("Invalid or empty dbsize configuration value is set", "[lmdb]") {
+  TestController controller;
+  
LogTestController::getInstance().setDebug<core::repository::LmdbContentRepository>();
+  auto db_path = controller.createTempDirectory().string();
+  auto configuration = 
std::make_shared<org::apache::nifi::minifi::ConfigureImpl>();
+  auto content_repo = 
std::make_shared<core::repository::LmdbContentRepository>();
+  
configuration->set(minifi::Configure::nifi_dbcontent_repository_directory_default,
 db_path);
+  SECTION("Invalid value") {
+    
configuration->set(minifi::Configure::nifi_content_repository_lmdb_max_db_size, 
"invalid");
+    REQUIRE_FALSE(content_repo->initialize(configuration));
+    REQUIRE(LogTestController::getInstance().contains("Invalid max DB size 
configuration for LMDB Content Repository: invalid"));
+  }
+  SECTION("Empty value") {
+    
configuration->set(minifi::Configure::nifi_content_repository_lmdb_max_db_size, 
"");
+    REQUIRE(content_repo->initialize(configuration));
+    REQUIRE(LogTestController::getInstance().contains("Setting LMDB max DB 
size to 10737418240 bytes"));
+  }
+}
+
+TEST_CASE("Valid dbsize configuration value is set", "[lmdb]") {
+  TestController controller;
+  
LogTestController::getInstance().setDebug<core::repository::LmdbContentRepository>();
+  auto configuration = 
std::make_shared<org::apache::nifi::minifi::ConfigureImpl>();
+  auto content_repo = 
std::make_shared<core::repository::LmdbContentRepository>();
+  
configuration->set(minifi::Configure::nifi_dbcontent_repository_directory_default,
 controller.createTempDirectory().string());
+  
configuration->set(minifi::Configure::nifi_content_repository_lmdb_max_db_size, 
"100 MB");
+  REQUIRE(content_repo->initialize(configuration));
+  REQUIRE(LogTestController::getInstance().contains("Setting LMDB max DB size 
to 104857600 bytes"));
+}
+
+TEST_CASE("Initialize succeeds when target directory already exists", 
"[lmdb]") {
+  TestController controller;
+  auto db_path = controller.createTempDirectory();
+  REQUIRE(std::filesystem::exists(db_path));
+  auto configuration = 
std::make_shared<org::apache::nifi::minifi::ConfigureImpl>();
+  auto content_repo = 
std::make_shared<core::repository::LmdbContentRepository>();
+  
configuration->set(minifi::Configure::nifi_dbcontent_repository_directory_default,
 db_path.string());
+  REQUIRE(content_repo->initialize(configuration));
+}
+
+TEST_CASE("Initialize fails and is safe to destroy when the directory path is 
a regular file", "[lmdb]") {
+  TestController controller;
+  const auto file_path = controller.createTempDirectory() / "not_a_directory";
+  {
+    std::ofstream file(file_path);
+    file << "this is a regular file, not a directory";
+  }
+  REQUIRE(std::filesystem::is_regular_file(file_path));
+
+  auto configuration = 
std::make_shared<org::apache::nifi::minifi::ConfigureImpl>();
+  
configuration->set(minifi::Configure::nifi_dbcontent_repository_directory_default,
 file_path.string());
+
+  auto content_repo = 
std::make_shared<core::repository::LmdbContentRepository>();
+  REQUIRE_FALSE(content_repo->initialize(configuration));
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "Key does not exist in empty 
database", "[lmdb]") {
+  auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  REQUIRE_FALSE(content_repo_->exists(*claim));
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "Written value exists", "[lmdb]") 
{
+  auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  writeContent(*claim);
+  REQUIRE(content_repo_->exists(*claim));
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "exists returns false for 
unrelated claim when other content was written", "[lmdb]") {
+  auto written_claim = 
std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  writeContent(*written_claim);
+  auto unrelated_claim = 
std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  REQUIRE(content_repo_->exists(*written_claim));
+  REQUIRE_FALSE(content_repo_->exists(*unrelated_claim));
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "Empty content claim does not 
exist", "[lmdb]") {
+  auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  auto write_stream = content_repo_->write(*claim, false);
+  write_stream->close();
+  REQUIRE_FALSE(content_repo_->exists(*claim));
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "Multiple claims coexist 
independently", "[lmdb]") {
+  auto claim1 = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  auto claim2 = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  auto claim3 = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  writeContent(*claim1);
+  writeContent(*claim2);
+  writeContent(*claim3);
+  REQUIRE(content_repo_->exists(*claim1));
+  REQUIRE(content_repo_->exists(*claim2));
+  REQUIRE(content_repo_->exists(*claim3));
+  REQUIRE(content_repo_->getRepositoryEntryCount() == 3);
+  REQUIRE(content_repo_->remove(*claim2));
+  REQUIRE(content_repo_->exists(*claim1));
+  REQUIRE_FALSE(content_repo_->exists(*claim2));
+  REQUIRE(content_repo_->exists(*claim3));
+  REQUIRE(content_repo_->getRepositoryEntryCount() == 2);
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "Reading nonexistent claim 
returns STREAM_ERROR", "[lmdb]") {
+  auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  auto read_stream = content_repo_->read(*claim);
+  std::vector<std::byte> buffer(8);
+  
REQUIRE(minifi::io::isError(read_stream->read(as_writable_bytes(std::span(buffer)))));
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "Read written value", "[lmdb]") {
+  auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  writeContent(*claim);
+  auto read_stream = content_repo_->read(*claim);
+  std::vector<std::byte> buffer(test_content_.size());
+  auto bytes_read = read_stream->read(as_writable_bytes(std::span(buffer)));
+  read_stream->close();
+  REQUIRE(bytes_read == test_content_.size());
+  REQUIRE(std::string_view(reinterpret_cast<const char*>(buffer.data()), 
buffer.size()) == test_content_);
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "Removing a nonexistent claim 
succeeds", "[lmdb]") {
+  auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  REQUIRE_FALSE(content_repo_->exists(*claim));
+  REQUIRE(content_repo_->remove(*claim));
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "Removing an existing value", 
"[lmdb]") {
+  auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  writeContent(*claim);
+  REQUIRE(content_repo_->exists(*claim));
+  REQUIRE(content_repo_->remove(*claim));
+  REQUIRE_FALSE(content_repo_->exists(*claim));
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "clearOrphans is a no-op on an 
empty repository", "[lmdb]") {
+  REQUIRE(content_repo_->getRepositoryEntryCount() == 0);
+  content_repo_->clearOrphans();
+  REQUIRE(content_repo_->getRepositoryEntryCount() == 0);
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "Clear orphan values", "[lmdb]") {
+  auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  writeContent(*claim);
+  REQUIRE(content_repo_->exists(*claim));
+  content_repo_->reset();
+  content_repo_->clearOrphans();
+  REQUIRE_FALSE(content_repo_->exists(*claim));
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "Empty repository reports zero 
entry count", "[lmdb]") {
+  REQUIRE(content_repo_->getRepositoryEntryCount() == 0);
+}
+
+TEST_CASE_METHOD(LmdbContentRepositoryTests, "Written value updates repository 
stats", "[lmdb]") {
+  auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo_);
+  auto start_size = content_repo_->getRepositorySize();
+  writeContent(*claim);
+  REQUIRE(content_repo_->getRepositoryEntryCount() == 1);
+  auto end_size = content_repo_->getRepositorySize();
+  REQUIRE(end_size > start_size);
+}
+
+TEST_CASE("Content persists across LmdbContentRepository re-initialization", 
"[lmdb]") {
+  TestController controller;
+  auto db_path = controller.createTempDirectory();
+  auto configuration = 
std::make_shared<org::apache::nifi::minifi::ConfigureImpl>();
+  
configuration->set(minifi::Configure::nifi_dbcontent_repository_directory_default,
 db_path.string());
+
+  std::string claim_path;
+  static constexpr std::string_view content = "persisted content";
+  {
+    auto content_repo = 
std::make_shared<core::repository::LmdbContentRepository>();
+    REQUIRE(content_repo->initialize(configuration));
+    auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo);
+    claim_path = claim->getContentFullPath();
+    auto stream = content_repo->write(*claim, false);
+    stream->write(as_bytes(std::span(content)));
+    stream->close();
+    // ensure the content is not deleted on resource claim destruction
+    content_repo->incrementStreamCount(*claim);
+  }
+
+  auto reopened_repo = 
std::make_shared<core::repository::LmdbContentRepository>();
+  REQUIRE(reopened_repo->initialize(configuration));
+  auto reopened_claim = 
std::make_shared<minifi::ResourceClaimImpl>(claim_path, reopened_repo);
+  REQUIRE(reopened_repo->exists(*reopened_claim));
+  auto read_stream = reopened_repo->read(*reopened_claim);
+  std::vector<std::byte> buffer(content.size());
+  REQUIRE(read_stream->read(as_writable_bytes(std::span(buffer))) == 
content.size());
+  REQUIRE(std::string_view(reinterpret_cast<const char*>(buffer.data()), 
buffer.size()) == content);
+}
+
+TEST_CASE("ProcessSession::read reads the flowfile from offset to size", 
"[lmdb]") {
+  
ContentRepositoryDependentTests::testReadOnSmallerClonedFlowFiles(std::make_shared<core::repository::LmdbContentRepository>());
+}
+
+TEST_CASE("ProcessSession::append should append to the flowfile and set its 
size correctly", "[lmdb]") {
+  
ContentRepositoryDependentTests::testAppendToUnmanagedFlowFile(std::make_shared<core::repository::LmdbContentRepository>());
+  
ContentRepositoryDependentTests::testAppendToManagedFlowFile(std::make_shared<core::repository::LmdbContentRepository>());
+}
+
+TEST_CASE("ProcessSession::read can read zero length flowfiles without crash", 
"[lmdb]") {
+  
ContentRepositoryDependentTests::testReadFromZeroLengthFlowFile(std::make_shared<core::repository::LmdbContentRepository>());
+}
+
+TEST_CASE("ProcessSession::write can be cancelled", "[lmdb]") {
+  
ContentRepositoryDependentTests::testOkWrite(std::make_shared<core::repository::LmdbContentRepository>());
+  
ContentRepositoryDependentTests::testErrWrite(std::make_shared<core::repository::LmdbContentRepository>());
+  
ContentRepositoryDependentTests::testCancelWrite(std::make_shared<core::repository::LmdbContentRepository>());
+}
+
+}  // namespace org::apache::nifi::minifi::test
diff --git a/extensions/lmdb/tests/LmdbContentSessionTests.cpp 
b/extensions/lmdb/tests/LmdbContentSessionTests.cpp
new file mode 100644
index 000000000..747d4f524
--- /dev/null
+++ b/extensions/lmdb/tests/LmdbContentSessionTests.cpp
@@ -0,0 +1,293 @@
+/**
+ *
+ * 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.
+ */
+
+#include <array>
+#include <memory>
+#include <string>
+
+#include "LmdbContentRepository.h"
+#include "catch2/matchers/catch_matchers_string.hpp"
+#include "core/repository/VolatileContentRepository.h"
+#include "unit/Catch.h"
+#include "unit/TestBase.h"
+#include "utils/ConfigurationUtils.h"
+
+namespace org::apache::nifi::minifi::test {
+
+class LmdbContentSessionController : public TestController {
+ public:
+  LmdbContentSessionController() : 
content_repository_(std::make_shared<core::repository::LmdbContentRepository>())
 {
+    auto content_repo_path = createTempDirectory();
+    auto config = std::make_shared<ConfigureImpl>();
+    config->set(Configure::nifi_dbcontent_repository_directory_default, 
content_repo_path.string());
+    content_repository_->initialize(config);
+  }
+
+  ~LmdbContentSessionController() override { log.reset(); }
+
+  LmdbContentSessionController(LmdbContentSessionController&&) = delete;
+  LmdbContentSessionController(const LmdbContentSessionController&) = delete;
+  LmdbContentSessionController& operator=(LmdbContentSessionController&&) = 
delete;
+  LmdbContentSessionController& operator=(const LmdbContentSessionController&) 
= delete;
+
+  std::shared_ptr<core::ContentRepository> content_repository_;
+};
+
+namespace {
+
+std::shared_ptr<io::OutputStream> operator<<(const 
std::shared_ptr<io::OutputStream>& stream, const std::string& str) {
+  REQUIRE(stream->write(reinterpret_cast<const uint8_t*>(str.data()), 
str.length()) == str.length());
+  return stream;
+}
+
+std::shared_ptr<io::InputStream> operator>>(const 
std::shared_ptr<io::InputStream>& stream, std::string& str) {
+  str.clear();
+  std::array<std::byte, utils::configuration::DEFAULT_BUFFER_SIZE> buffer{};
+  while (true) {
+    const auto ret = stream->read(buffer);
+    REQUIRE_FALSE(io::isError(ret));
+    if (ret == 0) { break; }
+    str += std::string{reinterpret_cast<char*>(buffer.data()), ret};
+  }
+  return stream;
+}
+
+void requireNoCreate(const std::shared_ptr<ResourceClaim>&) {
+  REQUIRE(false);
+}
+
+std::shared_ptr<ResourceClaim> makeCommittedClaim(const 
std::shared_ptr<core::ContentRepository>& content_repository, const 
std::string& content) {
+  auto session = content_repository->createSession();
+  auto claim = session->create();
+  session->write(claim) << content;
+  session->commit();
+  return claim;
+}
+
+}  // namespace
+
+TEST_CASE("Writing a previously committed claim from a new session throws", 
"[lmdb]") {
+  LmdbContentSessionController controller;
+  auto old_claim = makeCommittedClaim(controller.content_repository_, "data");
+
+  auto session = controller.content_repository_->createSession();
+  REQUIRE_THROWS(session->write(old_claim));
+}
+
+TEST_CASE("Reading a previously committed claim from a new session succeeds", 
"[lmdb]") {
+  LmdbContentSessionController controller;
+  auto old_claim = makeCommittedClaim(controller.content_repository_, "data");
+
+  auto session = controller.content_repository_->createSession();
+  REQUIRE_NOTHROW(session->read(old_claim));
+}
+
+TEST_CASE("Reading a claim after appending to it in the same session throws", 
"[lmdb]") {
+  LmdbContentSessionController controller;
+  auto old_claim = makeCommittedClaim(controller.content_repository_, "data");
+
+  auto session = controller.content_repository_->createSession();
+  session->append(old_claim, 4, requireNoCreate) << "-addendum";
+  REQUIRE_THROWS(session->read(old_claim));
+}
+
+TEST_CASE("Append with on_copy callback produces a new claim with concatenated 
content when the append lock is held", "[lmdb]") {
+  LmdbContentSessionController controller;
+  auto old_claim = makeCommittedClaim(controller.content_repository_, "data");
+
+  auto blocking_session = controller.content_repository_->createSession();
+  blocking_session->append(old_claim, 4, requireNoCreate) << "-addendum";
+
+  std::shared_ptr<ResourceClaim> copied_claim;
+  {
+    auto other_session = controller.content_repository_->createSession();
+    other_session->append(old_claim, 4, [&](auto new_claim) { copied_claim = 
std::move(new_claim); }) << "-some extra content";
+    other_session->commit();
+  }
+  REQUIRE(copied_claim);
+
+  std::string read_content;
+  read_content.resize(controller.content_repository_->size(*copied_claim));
+  
controller.content_repository_->read(*copied_claim)->read(as_writable_bytes(std::span(read_content)));
+  REQUIRE(read_content == "data-some extra content");
+}
+
+TEST_CASE("Session commits a write to a new claim", "[lmdb]") {
+  LmdbContentSessionController controller;
+
+  std::shared_ptr<ResourceClaim> claim;
+  {
+    auto session = controller.content_repository_->createSession();
+    claim = session->create();
+    session->write(claim) << "hello content!";
+
+    std::string buffered_content;
+    session->read(claim) >> buffered_content;
+    REQUIRE(buffered_content == "hello content!");
+
+    session->commit();
+  }
+
+  std::string content;
+  controller.content_repository_->read(*claim) >> content;
+  REQUIRE(content == "hello content!");
+}
+
+TEST_CASE("Session commits multiple appends from offsets on a new claim", 
"[lmdb]") {
+  LmdbContentSessionController controller;
+
+  std::shared_ptr<ResourceClaim> claim;
+  {
+    auto session = controller.content_repository_->createSession();
+    claim = session->create();
+    session->append(claim, 0, requireNoCreate) << "beginning";
+    session->append(claim, 9, requireNoCreate) << "-end";
+    session->commit();
+  }
+
+  std::string content;
+  controller.content_repository_->read(*claim) >> content;
+  REQUIRE(content == "beginning-end");
+}
+
+TEST_CASE("Session commits a write followed by an append on a new claim", 
"[lmdb]") {
+  LmdbContentSessionController controller;
+
+  std::shared_ptr<ResourceClaim> claim;
+  {
+    auto session = controller.content_repository_->createSession();
+    claim = session->create();
+    session->write(claim) << "first";
+    session->append(claim, 5, requireNoCreate) << "-last";
+    session->commit();
+  }
+
+  std::string content;
+  controller.content_repository_->read(*claim) >> content;
+  REQUIRE(content == "first-last");
+}
+
+TEST_CASE("Session commits a write that overwrites an earlier write on the 
same claim", "[lmdb]") {
+  LmdbContentSessionController controller;
+
+  std::shared_ptr<ResourceClaim> claim;
+  {
+    auto session = controller.content_repository_->createSession();
+    claim = session->create();
+    session->write(claim) << "beginning";
+    session->write(claim) << "overwritten";
+    session->commit();
+  }
+
+  std::string content;
+  controller.content_repository_->read(*claim) >> content;
+  REQUIRE(content == "overwritten");
+}
+
+TEST_CASE("Session commits an append to a previously committed claim", 
"[lmdb]") {
+  LmdbContentSessionController controller;
+  auto old_claim = makeCommittedClaim(controller.content_repository_, "data");
+
+  {
+    auto session = controller.content_repository_->createSession();
+    session->append(old_claim, 4, requireNoCreate) << "-addendum";
+    session->commit();
+  }
+
+  std::string content;
+  controller.content_repository_->read(*old_claim) >> content;
+  REQUIRE(content == "data-addendum");
+}
+
+TEST_CASE("Session rollback discards new claims", "[lmdb]") {
+  LmdbContentSessionController controller;
+  std::shared_ptr<core::ContentRepository> content_repository = 
controller.content_repository_;
+
+  std::shared_ptr<ResourceClaim> claim;
+  {
+    auto session = content_repository->createSession();
+    claim = session->create();
+    session->write(claim) << "discarded";
+    session->rollback();
+  }
+  REQUIRE_FALSE(content_repository->exists(*claim));
+}
+
+TEST_CASE("Session rollback leaves a previously committed claim unchanged", 
"[lmdb]") {
+  LmdbContentSessionController controller;
+  auto old_claim = makeCommittedClaim(controller.content_repository_, "data");
+
+  {
+    auto session = controller.content_repository_->createSession();
+    session->append(old_claim, 4, requireNoCreate) << "-addendum";
+    session->rollback();
+  }
+
+  std::string content;
+  controller.content_repository_->read(*old_claim) >> content;
+  REQUIRE(content == "data");
+}
+
+TEST_CASE("Buffered session reads buffered content before commit", "[lmdb]") {
+  LmdbContentSessionController controller;
+
+  auto session = controller.content_repository_->createSession();
+  auto claim = session->create();
+  session->write(claim) << "uncommitted-data";
+
+  std::string content;
+  session->read(claim) >> content;
+  REQUIRE(content == "uncommitted-data");
+
+  REQUIRE_FALSE(controller.content_repository_->exists(*claim));
+}
+
+TEST_CASE("Empty new claim commit does not throw", "[lmdb]") {
+  LmdbContentSessionController controller;
+
+  auto session = controller.content_repository_->createSession();
+  auto claim = session->create();
+  REQUIRE_NOTHROW(session->commit());
+
+  REQUIRE(controller.content_repository_->exists(*claim));
+  REQUIRE(controller.content_repository_->size(*claim) == 0);
+}
+
+TEST_CASE("Rollback after a commit on a different session leaves committed 
content untouched", "[lmdb]") {
+  LmdbContentSessionController controller;
+
+  std::shared_ptr<ResourceClaim> claim;
+  {
+    auto first_session = controller.content_repository_->createSession();
+    claim = first_session->create();
+    first_session->write(claim) << "committed";
+    first_session->commit();
+  }
+
+  {
+    auto rolled_back_session = controller.content_repository_->createSession();
+    rolled_back_session->append(claim, 9, requireNoCreate) << "-rolled-back";
+    rolled_back_session->rollback();
+  }
+
+  std::string content;
+  controller.content_repository_->read(*claim) >> content;
+  REQUIRE(content == "committed");
+}
+
+}  // namespace org::apache::nifi::minifi::test
diff --git a/extensions/lmdb/tests/LmdbStreamTests.cpp 
b/extensions/lmdb/tests/LmdbStreamTests.cpp
new file mode 100644
index 000000000..04ba95776
--- /dev/null
+++ b/extensions/lmdb/tests/LmdbStreamTests.cpp
@@ -0,0 +1,289 @@
+/**
+ *
+ * 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.
+ */
+
+#include "LmdbStream.h"
+#include "lmdb.h"
+#include "unit/Catch.h"
+#include "unit/TestBase.h"
+
+namespace org::apache::nifi::minifi::test {
+
+class LmdbStreamTest : TestController {
+ public:
+  LmdbStreamTest() : db_path_(createTempDirectory().string()) {
+    if (const int rc = mdb_env_create(&lmdb_env_); rc != MDB_SUCCESS) {
+      throw std::runtime_error("Failed to create LMDB environment: " + 
std::string(mdb_strerror(rc)));
+    }
+    if (const int rc = mdb_env_set_mapsize(lmdb_env_, 100ULL * 1024 * 1024); 
rc != MDB_SUCCESS) {
+      throw std::runtime_error("Failed to set LMDB map size: " + 
std::string(mdb_strerror(rc)));
+    }
+
+    if (const int rc = mdb_env_open(lmdb_env_, db_path_.c_str(), MDB_NOTLS, 
0664); rc != MDB_SUCCESS) {
+      throw std::runtime_error("Failed to open LMDB environment " + db_path_ + 
": " + std::string(mdb_strerror(rc)));
+    }
+
+    MDB_txn* init_txn = nullptr;
+    mdb_txn_begin(lmdb_env_, nullptr, 0, &init_txn);
+    if (const auto rc = mdb_dbi_open(init_txn, nullptr, 0, &lmdb_handle_); rc 
!= MDB_SUCCESS) {
+      mdb_txn_abort(init_txn);
+      mdb_env_close(lmdb_env_);
+      throw std::runtime_error("Failed to open LMDB database: " + 
std::string(mdb_strerror(rc)));
+    }
+    mdb_txn_commit(init_txn);
+  }
+
+  LmdbStreamTest(const LmdbStreamTest&) = delete;
+  LmdbStreamTest& operator=(const LmdbStreamTest&) = delete;
+  LmdbStreamTest(LmdbStreamTest&&) = delete;
+  LmdbStreamTest& operator=(LmdbStreamTest&&) = delete;
+
+  ~LmdbStreamTest() override {
+    mdb_dbi_close(lmdb_env_, lmdb_handle_);
+    mdb_env_close(lmdb_env_);
+  }
+
+  std::optional<std::string> readValue(const std::string& key) {
+    MDB_val db_key{key.size(), const_cast<char*>(key.data())};
+    MDB_val db_value{};
+
+    MDB_txn* txn = nullptr;
+    mdb_txn_begin(lmdb_env_, nullptr, MDB_RDONLY, &txn);
+    auto guard = gsl::finally([txn] { mdb_txn_abort(txn); });
+    const auto result = mdb_get(txn, lmdb_handle_, &db_key, &db_value);
+
+    if (result == MDB_SUCCESS) { return 
std::string(static_cast<char*>(db_value.mv_data), db_value.mv_size); }
+    return std::nullopt;
+  }
+
+ protected:
+  std::string db_path_;
+  MDB_env* lmdb_env_{nullptr};
+  MDB_dbi lmdb_handle_{};
+};
+
+namespace {
+
+size_t writeString(io::LmdbStream& stream, const std::string& content) {
+  return stream.write(reinterpret_cast<const uint8_t*>(content.data()), 
content.size());
+}
+
+std::string bytesToString(const std::vector<std::byte>& buf, size_t len) {
+  return {reinterpret_cast<const char*>(buf.data()), len};
+}
+
+}  // namespace
+
+TEST_CASE_METHOD(LmdbStreamTest, "Simple write tests") {
+  std::string content;
+  SECTION("Non-empty value") {
+    content = "banana";
+  }
+  SECTION("Empty value") {
+    content = "";
+  }
+
+  {
+    io::LmdbStream stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+    REQUIRE_FALSE(minifi::io::isError(writeString(stream, content)));
+  }
+  auto val = readValue(db_path_);
+  REQUIRE(val.has_value());
+  CHECK(val->size() == content.size());
+  CHECK(*val == content);
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Multiple write test") {
+  std::string content = "banana";
+  std::string expected_content;
+
+  {
+    io::LmdbStream stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+    for (int i = 0; i < 5; ++i) {
+      REQUIRE_FALSE(minifi::io::isError(writeString(stream, content)));
+      expected_content += content;
+    }
+  }
+  auto val = readValue(db_path_);
+  REQUIRE(val.has_value());
+  CHECK(val->size() == expected_content.size());
+  CHECK(*val == expected_content);
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Simple read tests") {
+  std::string content;
+  SECTION("Non-empty value") {
+    content = "banana";
+  }
+  SECTION("Empty value") {
+    content = "";
+  }
+
+  io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+  REQUIRE_FALSE(minifi::io::isError(writeString(write_stream, content)));
+  write_stream.close();
+
+  io::LmdbStream read_stream(db_path_, lmdb_env_, &lmdb_handle_, false);
+  std::vector<std::byte> buffer(content.size());
+  REQUIRE_FALSE(minifi::io::isError(read_stream.read(buffer)));
+  REQUIRE(bytesToString(buffer, buffer.size()) == content);
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Read in chunks") {
+  std::string content = "banana";
+  io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+  REQUIRE_FALSE(minifi::io::isError(writeString(write_stream, content)));
+  write_stream.close();
+
+  io::LmdbStream read_stream(db_path_, lmdb_env_, &lmdb_handle_, false);
+  std::vector<std::byte> buffer(2);
+
+  REQUIRE_FALSE(minifi::io::isError(read_stream.read(buffer)));
+  REQUIRE(bytesToString(buffer, buffer.size()) == "ba");
+
+  REQUIRE_FALSE(minifi::io::isError(read_stream.read(buffer)));
+  REQUIRE(bytesToString(buffer, buffer.size()) == "na");
+
+  std::vector<std::byte> buffer2(5);
+  const auto read_result = read_stream.read(buffer2);
+  REQUIRE_FALSE(minifi::io::isError(read_result));
+  REQUIRE(bytesToString(buffer2, read_result) == "na");
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Reading a nonexistent key returns 
STREAM_ERROR") {
+  io::LmdbStream read_stream(db_path_, lmdb_env_, &lmdb_handle_, false);
+  std::vector<std::byte> buffer(8);
+  REQUIRE(minifi::io::isError(read_stream.read(buffer)));
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Reading after EOF returns zero") {
+  io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+  REQUIRE_FALSE(minifi::io::isError(writeString(write_stream, "abc")));
+  write_stream.close();
+
+  io::LmdbStream read_stream(db_path_, lmdb_env_, &lmdb_handle_, false);
+  std::vector<std::byte> buffer(3);
+  REQUIRE(read_stream.read(buffer) == 3);
+  REQUIRE(read_stream.read(buffer) == 0);
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Reading into an empty buffer returns zero") {
+  io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+  REQUIRE_FALSE(minifi::io::isError(writeString(write_stream, "abc")));
+  write_stream.close();
+
+  io::LmdbStream read_stream(db_path_, lmdb_env_, &lmdb_handle_, false);
+  std::vector<std::byte> empty_buffer;
+  REQUIRE(read_stream.read(empty_buffer) == 0);
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "seek and tell control read offset") {
+  io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+  REQUIRE_FALSE(minifi::io::isError(writeString(write_stream, "banana")));
+  write_stream.close();
+
+  io::LmdbStream read_stream(db_path_, lmdb_env_, &lmdb_handle_, false);
+  read_stream.seek(2);
+  REQUIRE(read_stream.tell() == 2);
+  std::vector<std::byte> buffer(3);
+  REQUIRE(read_stream.read(buffer) == 3);
+  REQUIRE(bytesToString(buffer, buffer.size()) == "nan");
+  REQUIRE(read_stream.tell() == 5);
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "size reflects buffered writes before 
commit") {
+  io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+  REQUIRE(write_stream.size() == 0);
+  REQUIRE_FALSE(minifi::io::isError(writeString(write_stream, "hello")));
+  REQUIRE(write_stream.size() == 5);
+  REQUIRE_FALSE(minifi::io::isError(writeString(write_stream, "!")));
+  REQUIRE(write_stream.size() == 6);
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Writing to a read-only stream returns 
STREAM_ERROR") {
+  io::LmdbStream read_stream(db_path_, lmdb_env_, &lmdb_handle_, false);
+  REQUIRE(minifi::io::isError(writeString(read_stream, "anything")));
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Writing nullptr with a non-zero length 
returns STREAM_ERROR") {
+  io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+  REQUIRE(minifi::io::isError(write_stream.write(static_cast<const 
uint8_t*>(nullptr), 4)));
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Writing zero bytes is a no-op without 
error") {
+  io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+  const std::array<uint8_t, 1> dummy{};
+  REQUIRE(write_stream.write(dummy.data(), 0) == 0);
+  REQUIRE(write_stream.size() == 0);
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "commit on a read-only stream returns false") 
{
+  io::LmdbStream read_stream(db_path_, lmdb_env_, &lmdb_handle_, false);
+  REQUIRE_FALSE(read_stream.commit());
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Repeated commit is a no-op after the first") 
{
+  io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+  REQUIRE_FALSE(minifi::io::isError(writeString(write_stream, "banana")));
+  REQUIRE(write_stream.commit());
+  REQUIRE_FALSE(write_stream.commit());
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Destructor commits buffered writes") {
+  {
+    io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+    REQUIRE_FALSE(minifi::io::isError(writeString(write_stream, 
"destroyed-write")));
+    // Stream goes out of scope here; the destructor must commit
+  }
+  auto val = readValue(db_path_);
+  REQUIRE(val.has_value());
+  REQUIRE(*val == "destroyed-write");
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "Reopening an existing key in write mode 
appends to existing value") {
+  {
+    io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+    REQUIRE_FALSE(minifi::io::isError(writeString(write_stream, "first-")));
+  }
+
+  io::LmdbStream reopened_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+  REQUIRE(reopened_stream.size() == 6);
+  REQUIRE_FALSE(minifi::io::isError(writeString(reopened_stream, "second")));
+  REQUIRE(reopened_stream.commit());
+
+  auto val = readValue(db_path_);
+  REQUIRE(val.has_value());
+  REQUIRE(*val == "first-second");
+}
+
+TEST_CASE_METHOD(LmdbStreamTest, "A non-append write overwrites a pre-existing 
value instead of concatenating") {
+  {
+    io::LmdbStream write_stream(db_path_, lmdb_env_, &lmdb_handle_, true);
+    REQUIRE_FALSE(minifi::io::isError(writeString(write_stream, "old 
conent")));
+  }
+
+  io::LmdbStream overwrite_stream(db_path_, lmdb_env_, &lmdb_handle_, true, 
false);
+  REQUIRE(overwrite_stream.size() == 0);
+  REQUIRE_FALSE(minifi::io::isError(writeString(overwrite_stream, "new 
content")));
+  REQUIRE(overwrite_stream.commit());
+
+  auto val = readValue(db_path_);
+  REQUIRE(val.has_value());
+  REQUIRE(*val == "new content");
+}
+
+}  // namespace org::apache::nifi::minifi::test
diff --git a/extensions/rocksdb-repos/DatabaseContentRepository.h 
b/extensions/rocksdb-repos/DatabaseContentRepository.h
index 7dd24898a..54438d161 100644
--- a/extensions/rocksdb-repos/DatabaseContentRepository.h
+++ b/extensions/rocksdb-repos/DatabaseContentRepository.h
@@ -64,7 +64,7 @@ class DatabaseContentRepository : public 
core::ContentRepositoryImpl {
 
   std::shared_ptr<ContentSession> createSession() override;
   bool initialize(const std::shared_ptr<minifi::Configure> &configuration) 
override;
-  std::shared_ptr<io::BaseStream> write(const minifi::ResourceClaim &claim, 
bool append = false) override;
+  std::shared_ptr<io::BaseStream> write(const minifi::ResourceClaim &claim, 
bool append) override;
   std::shared_ptr<io::BaseStream> read(const minifi::ResourceClaim &claim) 
override;
 
   bool close(const minifi::ResourceClaim &claim) override {
diff --git a/extensions/rocksdb-repos/tests/ContentSessionTests.cpp 
b/extensions/rocksdb-repos/tests/ContentSessionTests.cpp
index f1e5d9d19..43e74a5dc 100644
--- a/extensions/rocksdb-repos/tests/ContentSessionTests.cpp
+++ b/extensions/rocksdb-repos/tests/ContentSessionTests.cpp
@@ -34,14 +34,14 @@ template<typename ContentRepositoryClass>
 class ContentSessionController : public TestController {
  public:
   ContentSessionController()
-      : contentRepository(std::make_shared<ContentRepositoryClass>()) {
+      : content_repository_(std::make_shared<ContentRepositoryClass>()) {
     auto contentRepoPath = createTempDirectory();
     auto config = std::make_shared<minifi::ConfigureImpl>();
     
config->set(minifi::Configure::nifi_dbcontent_repository_directory_default, 
contentRepoPath.string());
-    contentRepository->initialize(config);
+    content_repository_->initialize(config);
   }
 
-  ~ContentSessionController() {
+  ~ContentSessionController() override {
     log.reset();
   }
 
@@ -50,7 +50,7 @@ class ContentSessionController : public TestController {
   ContentSessionController& operator=(ContentSessionController&&) = delete;
   ContentSessionController& operator=(const ContentSessionController&) = 
delete;
 
-  std::shared_ptr<core::ContentRepository> contentRepository;
+  std::shared_ptr<core::ContentRepository> content_repository_;
 };
 
 std::shared_ptr<minifi::io::OutputStream> 
operator<<(std::shared_ptr<minifi::io::OutputStream> stream, const std::string& 
str) {
@@ -80,7 +80,7 @@ void NO_CREATE(const std::shared_ptr<minifi::ResourceClaim>&) 
{
 template<typename ContentRepositoryClass>
 void test_template() {
   ContentSessionController<ContentRepositoryClass> controller;
-  std::shared_ptr<core::ContentRepository> contentRepository = 
controller.contentRepository;
+  std::shared_ptr<core::ContentRepository> contentRepository = 
controller.content_repository_;
 
   std::shared_ptr<minifi::ResourceClaim> oldClaim;
   {
diff --git a/extensions/rocksdb-repos/tests/DBContentRepositoryTests.cpp 
b/extensions/rocksdb-repos/tests/DBContentRepositoryTests.cpp
index 60b5155ff..68a3657d3 100644
--- a/extensions/rocksdb-repos/tests/DBContentRepositoryTests.cpp
+++ b/extensions/rocksdb-repos/tests/DBContentRepositoryTests.cpp
@@ -49,7 +49,7 @@ TEST_CASE("Write Claim", "[TestDBCR1]") {
 
 
   auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo);
-  auto stream = content_repo->write(*claim);
+  auto stream = content_repo->write(*claim, false);
 
   const std::string content = "well hello there";
 
@@ -95,7 +95,7 @@ TEST_CASE("Delete Claim", "[TestDBCR2]") {
 
 
   auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo);
-  auto stream = content_repo->write(*claim);
+  auto stream = content_repo->write(*claim, false);
 
   stream->write("well hello there");
 
@@ -154,7 +154,7 @@ TEST_CASE("Append Claim", "[TestDBCR1]") {
   const std::string content = "well hello there";
 
   auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo);
-  content_repo->write(*claim)->write(as_bytes(std::span(content)));
+  content_repo->write(*claim, false)->write(as_bytes(std::span(content)));
 
   // requesting append before content end fails
   CHECK(content_repo->lockAppend(*claim, 0) == nullptr);
@@ -192,7 +192,7 @@ TEST_CASE("Test Empty Claim", "[TestDBCR3]") {
   REQUIRE(content_repo->initialize(configuration));
 
   auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo);
-  auto stream = content_repo->write(*claim);
+  auto stream = content_repo->write(*claim, false);
 
   // we're writing nothing to the stream.
 
@@ -228,7 +228,7 @@ TEST_CASE("Delete NonExistent Claim", "[TestDBCR4]") {
 
   auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo);
   auto claim2 = std::make_shared<minifi::ResourceClaimImpl>(content_repo);
-  auto stream = content_repo->write(*claim);
+  auto stream = content_repo->write(*claim, false);
 
   stream->write("well hello there");
 
@@ -268,7 +268,7 @@ TEST_CASE("Delete Remove Count Claim", "[TestDBCR5]") {
 
   auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo);
   auto claim2 = std::make_shared<minifi::ResourceClaimImpl>(content_repo);
-  auto stream = content_repo->write(*claim);
+  auto stream = content_repo->write(*claim, false);
 
   stream->write("well hello there");
 
@@ -346,7 +346,7 @@ TEST_CASE("DBContentRepository can clear orphan entries") {
     REQUIRE(content_repo->initialize(configuration));
 
     minifi::ResourceClaimImpl claim(content_repo);
-    content_repo->write(claim)->write("hi");
+    content_repo->write(claim, false)->write("hi", false);
     // ensure that the content is not deleted during resource claim destruction
     content_repo->incrementStreamCount(claim);
   }
diff --git a/extensions/rocksdb-repos/tests/RepoTests.cpp 
b/extensions/rocksdb-repos/tests/RepoTests.cpp
index f3a8fd76f..4041f5771 100644
--- a/extensions/rocksdb-repos/tests/RepoTests.cpp
+++ b/extensions/rocksdb-repos/tests/RepoTests.cpp
@@ -460,7 +460,7 @@ TEST_CASE("FlowFileRepository triggers content repo orphan 
clear") {
     auto content_repo = 
std::make_shared<core::repository::FileSystemRepository>();
     REQUIRE(content_repo->initialize(config));
     minifi::ResourceClaimImpl claim(content_repo);
-    content_repo->write(claim)->write("hi");
+    content_repo->write(claim, false)->write("hi", false);
     // ensure that the content is not deleted during resource claim destruction
     content_repo->incrementStreamCount(claim);
   }
@@ -506,7 +506,7 @@ TEST_CASE("FlowFileRepository synchronously pushes existing 
flow files") {
     auto ff = std::make_shared<minifi::FlowFileRecordImpl>();
     ff_id = ff->getUUID();
     ff->setConnection(conn.get());
-    content_repo->write(*claim)->write("hello");
+    content_repo->write(*claim, false)->write("hello", false);
     ff->setResourceClaim(claim);
     auto stream = std::make_unique<minifi::io::BufferStream>();
     ff->Serialize(*stream);
diff --git a/extensions/sql/tests/SQLTestPlan.h 
b/extensions/sql/tests/SQLTestPlan.h
index 1068190e7..7c31bfc0a 100644
--- a/extensions/sql/tests/SQLTestPlan.h
+++ b/extensions/sql/tests/SQLTestPlan.h
@@ -66,7 +66,7 @@ class SQLTestPlan {
     }
     if (content) {
       auto claim = 
std::make_shared<minifi::ResourceClaimImpl>(plan_->getContentRepo());
-      auto content_stream = plan_->getContentRepo()->write(*claim);
+      auto content_stream = plan_->getContentRepo()->write(*claim, false);
       const auto ret = content_stream->write(reinterpret_cast<const 
uint8_t*>(content->c_str()), content->length());
       REQUIRE(ret == content->length());
       flow_file->setOffset(0);
diff --git a/libminifi/include/core/repository/FileSystemRepository.h 
b/libminifi/include/core/repository/FileSystemRepository.h
index 4882125f2..b953f9c94 100644
--- a/libminifi/include/core/repository/FileSystemRepository.h
+++ b/libminifi/include/core/repository/FileSystemRepository.h
@@ -43,7 +43,7 @@ class FileSystemRepository : public ContentRepositoryImpl {
 
   bool initialize(const std::shared_ptr<Configure>& configuration) override;
   bool exists(const ResourceClaim& streamId) override;
-  std::shared_ptr<io::BaseStream> write(const ResourceClaim& claim, bool 
append = false) override;
+  std::shared_ptr<io::BaseStream> write(const ResourceClaim& claim, bool 
append) override;
   std::shared_ptr<io::BaseStream> read(const ResourceClaim& claim) override;
 
   bool close(const ResourceClaim& claim) override {
diff --git a/libminifi/src/Configuration.cpp b/libminifi/src/Configuration.cpp
index 17dba361b..491627d07 100644
--- a/libminifi/src/Configuration.cpp
+++ b/libminifi/src/Configuration.cpp
@@ -55,6 +55,7 @@ const std::unordered_map<std::string_view, 
gsl::not_null<const core::PropertyVal
   {Configuration::nifi_provenance_repository_rocksdb_read_verify_checksums, 
gsl::make_not_null(&core::StandardPropertyValidators::BOOLEAN_VALIDATOR)},
   {Configuration::nifi_rocksdb_state_storage_read_verify_checksums, 
gsl::make_not_null(&core::StandardPropertyValidators::BOOLEAN_VALIDATOR)},
   {Configuration::nifi_dbcontent_optimize_for_small_db_cache_size, 
gsl::make_not_null(&core::StandardPropertyValidators::DATA_SIZE_VALIDATOR)},
+  {Configuration::nifi_content_repository_lmdb_max_db_size, 
gsl::make_not_null(&core::StandardPropertyValidators::DATA_SIZE_VALIDATOR)},
   {Configuration::nifi_dbcontent_repository_purge_period, 
gsl::make_not_null(&core::StandardPropertyValidators::TIME_PERIOD_VALIDATOR)},
   {Configuration::nifi_remote_input_secure, 
gsl::make_not_null(&core::StandardPropertyValidators::BOOLEAN_VALIDATOR)},
   {Configuration::nifi_security_need_ClientAuth, 
gsl::make_not_null(&core::StandardPropertyValidators::BOOLEAN_VALIDATOR)},
diff --git a/libminifi/src/core/RepositoryFactory.cpp 
b/libminifi/src/core/RepositoryFactory.cpp
index 914798186..85dcab107 100644
--- a/libminifi/src/core/RepositoryFactory.cpp
+++ b/libminifi/src/core/RepositoryFactory.cpp
@@ -50,6 +50,8 @@ std::unique_ptr<ContentRepository> 
createContentRepository(const std::string& co
   logger.log_critical("Could not create the configured content repository 
({})", configuration_class_name);
   if (class_name_lc == "databasecontentrepository") {
     logger.log_error("To use DatabaseContentRepository MiNiFi needs RocksDB 
extension, please check the extension path configured in minifi.properties");
+  } else if (class_name_lc == "lmdbcontentrepository") {
+    logger.log_error("To use LmdbContentRepository MiNiFi needs LMDB 
extension, please check the extension path configured in minifi.properties");
   }
 
   throw std::runtime_error("Support for the provided configuration class could 
not be found, check logs for more details");
diff --git a/libminifi/test/libtest/unit/TestBase.h 
b/libminifi/test/libtest/unit/TestBase.h
index 9e5557194..d1e87f743 100644
--- a/libminifi/test/libtest/unit/TestBase.h
+++ b/libminifi/test/libtest/unit/TestBase.h
@@ -400,6 +400,7 @@ class TestController {
   };
 
   TestController();
+  virtual ~TestController() = default;
 
   std::shared_ptr<TestPlan> createPlan(PlanConfig config);
 
diff --git a/libminifi/test/unit/FileSystemRepositoryTests.cpp 
b/libminifi/test/unit/FileSystemRepositoryTests.cpp
index 9ce8140c8..b54a7b3f5 100644
--- a/libminifi/test/unit/FileSystemRepositoryTests.cpp
+++ b/libminifi/test/unit/FileSystemRepositoryTests.cpp
@@ -81,7 +81,7 @@ TEST_CASE("FileSystemRepository can clear orphan entries") {
     REQUIRE(content_repo->initialize(configuration));
 
     minifi::ResourceClaimImpl claim(content_repo);
-    content_repo->write(claim)->write("hi");
+    content_repo->write(claim, false)->write("hi", false);
     // ensure that the content is not deleted during resource claim destruction
     content_repo->incrementStreamCount(claim);
   }
@@ -107,7 +107,7 @@ TEST_CASE("FileSystemRepository can retry removing entry 
that previously failed
   REQUIRE(content_repo->initialize(configuration));
   {
     minifi::ResourceClaimImpl claim(content_repo);
-    content_repo->write(claim)->write("hi");
+    content_repo->write(claim, false)->write("hi", false);
     auto files = minifi::utils::file::list_dir_all(dir, 
testController.getLogger());
     REQUIRE(files.size() == 1);
     // ensure that the content is not deleted during resource claim destruction
@@ -118,7 +118,7 @@ TEST_CASE("FileSystemRepository can retry removing entry 
that previously failed
   REQUIRE(minifi::utils::file::list_dir_all(dir, 
testController.getLogger()).size() == 1);
   {
     minifi::ResourceClaimImpl claim(content_repo);
-    content_repo->write(claim)->write("hi");
+    content_repo->write(claim, false)->write("hi", false);
     REQUIRE(minifi::utils::file::list_dir_all(dir, 
testController.getLogger()).size() == 2);
   }
 
@@ -139,7 +139,7 @@ TEST_CASE("FileSystemRepository removes non-existing 
resource file from purge li
   std::string filename;
   {
     minifi::ResourceClaimImpl claim(content_repo);
-    content_repo->write(claim)->write("hi");
+    content_repo->write(claim, false)->write("hi", false);
     auto files = minifi::utils::file::list_dir_all(dir, 
testController.getLogger());
     REQUIRE(files.size() == 1);
     // ensure that the content is not deleted during resource claim destruction
@@ -152,7 +152,7 @@ TEST_CASE("FileSystemRepository removes non-existing 
resource file from purge li
   REQUIRE(minifi::utils::file::list_dir_all(dir, 
testController.getLogger()).empty());
   {
     minifi::ResourceClaimImpl claim(content_repo);
-    content_repo->write(claim)->write("hi");
+    content_repo->write(claim, false)->write("hi", false);
     REQUIRE(minifi::utils::file::list_dir_all(dir, 
testController.getLogger()).size() == 1);
   }
 
@@ -173,7 +173,7 @@ TEST_CASE("Append Claim") {
   const std::string content = "well hello there";
 
   auto claim = std::make_shared<minifi::ResourceClaimImpl>(content_repo);
-  content_repo->write(*claim)->write(as_bytes(std::span(content)));
+  content_repo->write(*claim, false)->write(as_bytes(std::span(content)));
 
   // requesting append before content end fails
   CHECK(content_repo->lockAppend(*claim, 0) == nullptr);
diff --git a/minifi-api/include/minifi-cpp/core/StreamManager.h 
b/minifi-api/include/minifi-cpp/core/StreamManager.h
index 4e3cc8e4a..c106e227d 100644
--- a/minifi-api/include/minifi-cpp/core/StreamManager.h
+++ b/minifi-api/include/minifi-cpp/core/StreamManager.h
@@ -53,7 +53,7 @@ class StreamManager {
    * @param streamId stream identifier
    * @return stream pointer.
    */
-  virtual std::shared_ptr<io::BaseStream> write(const T &streamId, bool append 
= false) = 0;
+  virtual std::shared_ptr<io::BaseStream> write(const T &streamId, bool 
append) = 0;
 
   /**
    * Queries the stream and locks it to be appended to
diff --git a/minifi-api/include/minifi-cpp/properties/Configuration.h 
b/minifi-api/include/minifi-cpp/properties/Configuration.h
index 44e50ea0f..e54fe73d2 100644
--- a/minifi-api/include/minifi-cpp/properties/Configuration.h
+++ b/minifi-api/include/minifi-cpp/properties/Configuration.h
@@ -78,6 +78,8 @@ class Configuration : public virtual Properties {
   static constexpr const char 
*nifi_rocksdb_state_storage_read_verify_checksums = 
"nifi.rocksdb.state.storage.read.verify.checksums";
   static constexpr const char *nifi_dbcontent_optimize_for_small_db_cache_size 
= "nifi.database.content.repository.optimize.for.small.db.cache.size";
 
+  static constexpr const char *nifi_content_repository_lmdb_max_db_size = 
"nifi.content.repository.lmdb.max.db.size";
+
   static constexpr const char *nifi_remote_input_secure = 
"nifi.remote.input.secure";
   static constexpr const char *nifi_security_need_ClientAuth = 
"nifi.security.need.ClientAuth";
   static constexpr const char *nifi_sensitive_props_additional_keys = 
"nifi.sensitive.props.additional.keys";
diff --git a/thirdparty/lmdb/all/conandata.yml 
b/thirdparty/lmdb/all/conandata.yml
new file mode 100644
index 000000000..229f4e5b9
--- /dev/null
+++ b/thirdparty/lmdb/all/conandata.yml
@@ -0,0 +1,23 @@
+# 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.
+
+sources:
+  "1.0.1":
+    url: 
"https://git.openldap.org/openldap/openldap/-/archive/LMDB_1.0.1/openldap-LMDB_1.0.1.tar.gz";
+    sha256: "63a3cdcca69f4fd403b61fd5c5a51a119f1549d7b375b33f624e64df2933d336"
+patches:
+  "1.0.1":
+    - patch_file: "patches/fix-windows-symbols.patch"
+    - patch_file: "patches/add-cmake-file.patch"
diff --git a/thirdparty/lmdb/all/conanfile.py b/thirdparty/lmdb/all/conanfile.py
new file mode 100644
index 000000000..d8d7d31cc
--- /dev/null
+++ b/thirdparty/lmdb/all/conanfile.py
@@ -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.
+
+# Recipe based on 
https://github.com/conan-io/conan-center-index/blob/master/recipes/lmdb/all/conanfile.py
+
+from conan import ConanFile
+from conan.tools.cmake import CMake, CMakeToolchain, cmake_layout
+from conan.tools.files import copy, get, export_conandata_patches, 
apply_conandata_patches
+import os
+
+required_conan_version = ">=1.46.0"
+
+
+class lmdbConan(ConanFile):
+    name = "lmdb"
+    license = "OLDAP-2.8"
+    url = "https://github.com/conan-io/conan-center-index";
+    homepage = "https://symas.com/lmdb/";
+    description = "Fast and compat memory-mapped key-value database"
+    topics = ("database", "key-value", "memory-mapped")
+
+    settings = "os", "arch", "compiler", "build_type"
+    options = {
+        "shared": [True, False],
+        "fPIC": [True, False],
+    }
+    default_options = {
+        "shared": False,
+        "fPIC": True,
+    }
+
+    def config_options(self):
+        if self.settings.os == "Windows":
+            del self.options.fPIC
+
+    def configure(self):
+        if self.options.shared:
+            try:
+                del self.options.fPIC
+            except Exception:
+                pass
+        try:
+            del self.settings.compiler.libcxx
+        except Exception:
+            pass
+        try:
+            del self.settings.compiler.cppstd
+        except Exception:
+            pass
+
+    def export_sources(self):
+        export_conandata_patches(self)
+
+    def layout(self):
+        cmake_layout(self, src_folder="src")
+
+    def source(self):
+        get(self, **self.conan_data["sources"][self.version], 
destination=self.source_folder, strip_root=True)
+        apply_conandata_patches(self)
+
+    def generate(self):
+        tc = CMakeToolchain(self)
+        tc.variables["LMDB_SRC_DIR"] = os.path.join(self.source_folder, 
"libraries", "liblmdb").replace("\\", "/")
+        tc.generate()
+
+    def build(self):
+        cmake = CMake(self)
+        cmake.configure(build_script_folder=os.path.join(self.source_folder, 
"libraries", "liblmdb"))
+        cmake.build()
+
+    def package(self):
+        copy(self, "LICENSE", src=os.path.join(self.source_folder, 
"libraries", "liblmdb"), dst=os.path.join(self.package_folder, "licenses"))
+        cmake = CMake(self)
+        cmake.install()
+
+    def package_info(self):
+        self.cpp_info.set_property("pkg_config_name", "lmdb")
+        self.cpp_info.libs = ["lmdb"]
+
+        if self.settings.os in ["Linux", "FreeBSD"]:
+            self.cpp_info.system_libs = ["pthread"]
+
+        bin_path = os.path.join(self.package_folder, "bin")
+        self.output.info(f"Appending PATH environment variable: {bin_path}")
+        self.env_info.PATH.append(bin_path)
diff --git a/thirdparty/lmdb/all/patches/add-cmake-file.patch 
b/thirdparty/lmdb/all/patches/add-cmake-file.patch
new file mode 100644
index 000000000..43d243aa2
--- /dev/null
+++ b/thirdparty/lmdb/all/patches/add-cmake-file.patch
@@ -0,0 +1,23 @@
+lmdb only comes with a Makefile which only works on Linux so we create our own 
CMakeLists.txt file to be platform independent
+
+diff -rupN orig/libraries/liblmdb/CMakeLists.txt 
patched/libraries/liblmdb/CMakeLists.txt
+--- /dev/null  1970-01-01 00:00:00.000000000 +0000
++++ libraries/liblmdb/CMakeLists.txt   2026-06-12 11:51:51.369241534 +0200
+@@ -0,0 +1,17 @@
++cmake_minimum_required(VERSION 3.26)
++project(lmdb C)
++
++find_package(Threads REQUIRED)
++
++add_library(lmdb STATIC mdb.c midl.c)
++target_include_directories(lmdb PUBLIC
++    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
++    $<INSTALL_INTERFACE:include>
++)
++target_link_libraries(lmdb PUBLIC Threads::Threads)
++set_target_properties(lmdb PROPERTIES ARCHIVE_OUTPUT_DIRECTORY 
"${CMAKE_BINARY_DIR}/lib")
++
++include(GNUInstallDirs)
++
++install(TARGETS lmdb ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
++install(FILES lmdb.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
diff --git a/thirdparty/lmdb/all/patches/fix-windows-symbols.patch 
b/thirdparty/lmdb/all/patches/fix-windows-symbols.patch
new file mode 100644
index 000000000..0bb868103
--- /dev/null
+++ b/thirdparty/lmdb/all/patches/fix-windows-symbols.patch
@@ -0,0 +1,112 @@
+diff --git a/libraries/liblmdb/mdb.c b/libraries/liblmdb/mdb.c
+index 23c86eb..2c5296a 100644
+--- a/libraries/liblmdb/mdb.c
++++ b/libraries/liblmdb/mdb.c
+@@ -42,6 +42,7 @@
+ #include <malloc.h>
+ #include <windows.h>
+ #include <wchar.h>                            /* get wcscpy() */
++#include <winternl.h>
+ 
+ /* We use native NT APIs to setup the memory map, so that we can
+  * let the DB file grow incrementally instead of always preallocating
+@@ -52,31 +53,31 @@
+  * NTDLL.DLL at runtime, to avoid buildtime dependencies on any
+  * NTDLL import libraries.
+  */
+-typedef NTSTATUS (WINAPI NtCreateSectionFunc)
++typedef NTSTATUS (WINAPI MDB_NtCreateSectionFunc)
+   (OUT PHANDLE sh, IN ACCESS_MASK acc,
+   IN void * oa OPTIONAL,
+   IN PLARGE_INTEGER ms OPTIONAL,
+   IN ULONG pp, IN ULONG aa, IN HANDLE fh OPTIONAL);
+ 
+-static NtCreateSectionFunc *NtCreateSection;
++static MDB_NtCreateSectionFunc *MDB_NtCreateSection;
+ 
+ typedef enum _SECTION_INHERIT {
+       ViewShare = 1,
+       ViewUnmap = 2
+ } SECTION_INHERIT;
+ 
+-typedef NTSTATUS (WINAPI NtMapViewOfSectionFunc)
++typedef NTSTATUS (WINAPI MDB_NtMapViewOfSectionFunc)
+   (IN PHANDLE sh, IN HANDLE ph,
+   IN OUT PVOID *addr, IN ULONG_PTR zbits,
+   IN SIZE_T cs, IN OUT PLARGE_INTEGER off OPTIONAL,
+   IN OUT PSIZE_T vs, IN SECTION_INHERIT ih,
+   IN ULONG at, IN ULONG pp);
+ 
+-static NtMapViewOfSectionFunc *NtMapViewOfSection;
++static MDB_NtMapViewOfSectionFunc *MDB_NtMapViewOfSection;
+ 
+-typedef NTSTATUS (WINAPI NtCloseFunc)(HANDLE h);
++typedef NTSTATUS (WINAPI MDB_NtCloseFunc)(HANDLE h);
+ 
+-static NtCloseFunc *NtClose;
++static MDB_NtCloseFunc *MDB_NtClose;
+ 
+ /** getpid() returns int; MinGW defines pid_t but MinGW64 typedefs it
+  *  as int64 which is wrong. MSVC doesn't define it at all, so just
+@@ -5093,22 +5094,22 @@ mdb_env_map(MDB_env *env, void *addr)
+       LARGE_INTEGER fsize;
+       fsize.LowPart = msize & 0xffffffff;
+       fsize.HighPart = msize >> 16 >> 16;
+-      rc = NtCreateSection(&mh, access, NULL, &fsize, secprot, SEC_RESERVE, 
env->me_fd);
++      rc = MDB_NtCreateSection(&mh, access, NULL, &fsize, secprot, 
SEC_RESERVE, env->me_fd);
+ #else
+-      rc = NtCreateSection(&mh, access, NULL, NULL, secprot, SEC_RESERVE, 
env->me_fd);
++      rc = MDB_NtCreateSection(&mh, access, NULL, NULL, secprot, SEC_RESERVE, 
env->me_fd);
+ #endif
+       if (rc)
+               return mdb_nt2win32(rc);
+       map = addr;
+       if (MDB_REMAPPING(env->me_flags))
+               msize = NUM_METAS * env->me_psize;
+-      rc = NtMapViewOfSection(mh, GetCurrentProcess(), &map, 0, 0, NULL, 
&msize, ViewUnmap, alloctype, pageprot);
++      rc = MDB_NtMapViewOfSection(mh, GetCurrentProcess(), &map, 0, 0, NULL, 
&msize, ViewUnmap, alloctype, pageprot);
+ #if MDB_RPAGE_CACHE
+       if (MDB_REMAPPING(env->me_flags))
+               env->me_fmh = mh;
+       else
+ #endif
+-              NtClose(mh);
++              MDB_NtClose(mh);
+       if (rc)
+               return mdb_nt2win32(rc);
+       env->me_map = map;
+@@ -5468,18 +5469,18 @@ mdb_env_open2(MDB_env *env, int prev)
+       else
+               env->me_pidquery = PROCESS_QUERY_INFORMATION;
+       /* Grab functions we need from NTDLL */
+-      if (!NtCreateSection) {
++      if (!MDB_NtCreateSection) {
+               HMODULE h = GetModuleHandleW(L"NTDLL.DLL");
+               if (!h)
+                       return MDB_PROBLEM;
+-              NtClose = (NtCloseFunc *)GetProcAddress(h, "NtClose");
+-              if (!NtClose)
++              MDB_NtClose = (MDB_NtCloseFunc *)GetProcAddress(h, "NtClose");
++              if (!MDB_NtClose)
+                       return MDB_PROBLEM;
+-              NtMapViewOfSection = (NtMapViewOfSectionFunc 
*)GetProcAddress(h, "NtMapViewOfSection");
+-              if (!NtMapViewOfSection)
++              MDB_NtMapViewOfSection = (MDB_NtMapViewOfSectionFunc 
*)GetProcAddress(h, "NtMapViewOfSection");
++              if (!MDB_NtMapViewOfSection)
+                       return MDB_PROBLEM;
+-              NtCreateSection = (NtCreateSectionFunc *)GetProcAddress(h, 
"NtCreateSection");
+-              if (!NtCreateSection)
++              MDB_NtCreateSection = (MDB_NtCreateSectionFunc 
*)GetProcAddress(h, "NtCreateSection");
++              if (!MDB_NtCreateSection)
+                       return MDB_PROBLEM;
+       }
+       env->me_ovs = 0;
+@@ -6951,7 +6952,7 @@ mdb_rpage_get(MDB_txn *txn, pgno_t pg0, int numpgs, 
MDB_page **ret, MDB_page **e
+ #define CHK_OFF(off)          off.QuadPart
+ #define MAP(rc,env,addr,len,off)      \
+       addr = NULL; \
+-      rc = NtMapViewOfSection(env->me_fmh, GetCurrentProcess(), (void 
**)&addr, 0, \
++      rc = MDB_NtMapViewOfSection(env->me_fmh, GetCurrentProcess(), (void 
**)&addr, 0, \
+               len, &off, &len, ViewUnmap, (env->me_flags & MDB_RDONLY) ? 0 : 
MEM_RESERVE, PAGE_READONLY); \
+       if (rc) rc = mdb_nt2win32(rc)
+ #else
diff --git a/thirdparty/lmdb/config.yml b/thirdparty/lmdb/config.yml
new file mode 100644
index 000000000..1a66a28dc
--- /dev/null
+++ b/thirdparty/lmdb/config.yml
@@ -0,0 +1,18 @@
+# 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.
+
+versions:
+  "1.0.1":
+    folder: "all"

Reply via email to