diff --git a/.github/references/ubuntu_22_04_clang_arm_manifest.json b/.github/references/ubuntu_22_04_clang_arm_manifest.json
index 5f72378a92..d4139e9fbc 100644
--- a/.github/references/ubuntu_22_04_clang_arm_manifest.json
+++ b/.github/references/ubuntu_22_04_clang_arm_manifest.json
@@ -4144,6 +4144,89 @@
"version": "1.0.0"
}
},
+{
+ "bundles": {
+ "componentManifest": {
+ "processors": [
+ {
+ "propertyDescriptors": {
+ "Max Batch Size": {
+ "name": "Max Batch Size",
+ "description": "The maximum number of flow files to process at a time. If unset, all FlowFiles will be processed at once.",
+ "validator": "NON_NEGATIVE_INTEGER_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "enrichment",
+ "description": "A clone of the incoming FlowFile will be routed to this relationship, after adding appropriate attributes."
+ },
+ {
+ "name": "original",
+ "description": "The incoming FlowFile will be routed to this relationship, after adding appropriate attributes."
+ }
+ ],
+ "typeDescription": "Used in conjunction with the JoinEnrichmentAttributes processor, this processor is responsible for adding the attributes that are necessary for the JoinEnrichmentAttributes processor to perform its function. Each incoming FlowFile will be cloned. The original FlowFile will have appropriate attributes added and then be transferred to the 'original' relationship. The clone will have appropriate attributes added and then be routed to the 'enrichment' relationship.",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "org.apache.nifi.minifi.enrichment.ForkEnrichment"
+ },
+ {
+ "propertyDescriptors": {
+ "Max Batch Size": {
+ "name": "Max Batch Size",
+ "description": "The maximum number of flow files to process at a time. If unset, all FlowFiles will be processed at once.",
+ "validator": "NON_NEGATIVE_INTEGER_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Timeout": {
+ "name": "Timeout",
+ "description": "Specifies the maximum amount of time to wait for the second FlowFile once the first arrives at the processor, after which point the first FlowFile will be routed to the 'timeout' relationship.",
+ "validator": "TIME_PERIOD_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "true",
+ "supportedRelationships": [
+ {
+ "name": "invalid",
+ "description": "Any FlowFiles without the requisite attributes will be routed here"
+ },
+ {
+ "name": "joined",
+ "description": "The resultant FlowFile with Records joined together from both the original and enrichment FlowFiles will be routed to this relationship"
+ },
+ {
+ "name": "original",
+ "description": "Both of the incoming FlowFiles ('original' and 'enrichment') will be routed to this Relationship. I.e., this is the 'original' version of both of these FlowFiles."
+ },
+ {
+ "name": "timeout",
+ "description": "If one of the incoming FlowFiles (i.e., the 'original' FlowFile or the 'enrichment' FlowFile) arrives to this Processor but the other does not arrive within the configured Timeout period, the FlowFile that did arrive is routed to this relationship."
+ }
+ ],
+ "typeDescription": "Rejoins the forked FlowFiles coming from ForkEnrichment processor, the resulting FlowFile will have the Original's content and all attributes from both of them (prioritizing Enrichment's).",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "org.apache.nifi.minifi.enrichment.JoinEnrichmentAttributes"
+ }
+ ]
+ },
+ "group": "org.apache.nifi.minifi",
+ "artifact": "minifi-enrichment",
+ "version": "1.0.0"
+ }
+},
{
"bundles": {
"componentManifest": {
@@ -12104,6 +12187,89 @@
"version": "1.0.0"
}
},
+{
+ "bundles": {
+ "componentManifest": {
+ "processors": [
+ {
+ "propertyDescriptors": {
+ "Batch Size": {
+ "name": "Batch Size",
+ "description": "The maximum number of FlowFiles to fork in each trigger",
+ "validator": "NON_NEGATIVE_INTEGER_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "enrichment",
+ "description": "A clone of the incoming FlowFile will be routed to this relationship, after adding appropriate attributes."
+ },
+ {
+ "name": "original",
+ "description": "The incoming FlowFile will be routed to this relationship, after adding appropriate attributes."
+ }
+ ],
+ "typeDescription": "Used in conjunction with the JoinEnrichmentAttributes processor, this processor is responsible for adding the attributes that are necessary for the JoinEnrichmentAttributes processor to perform its function. Each incoming FlowFile will be cloned. The original FlowFile will have appropriate attributes added and then be transferred to the 'original' relationship. The clone will have appropriate attributes added and then be routed to the 'enrichment' relationship.",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_enrichment.processors.fork_enrichment.ForkEnrichment"
+ },
+ {
+ "propertyDescriptors": {
+ "Batch Size": {
+ "name": "Batch Size",
+ "description": "The maximum number of FlowFiles to process in each trigger",
+ "validator": "NON_NEGATIVE_INTEGER_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Timeout": {
+ "name": "Timeout",
+ "description": "Specifies the maximum amount of time to wait for the second FlowFile once the first arrives at the processor, after which point the first FlowFile will be routed to the 'timeout' relationship.",
+ "validator": "TIME_PERIOD_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "true",
+ "supportedRelationships": [
+ {
+ "name": "invalid",
+ "description": "Any FlowFiles without the requisite attributes will be routed here"
+ },
+ {
+ "name": "joined",
+ "description": "The resultant FlowFile with Records joined together from both the original and enrichment FlowFiles will be routed to this relationship"
+ },
+ {
+ "name": "original",
+ "description": "Both of the incoming FlowFiles ('original' and 'enrichment') will be routed to this Relationship. I.e., this is the 'original' version of both of these FlowFiles."
+ },
+ {
+ "name": "timeout",
+ "description": "If one of the incoming FlowFiles (i.e., the 'original' FlowFile or the 'enrichment' FlowFile) arrives to this Processor but the other does not arrive within the configured Timeout period, the FlowFile that did arrive is routed to this relationship."
+ }
+ ],
+ "typeDescription": "Rejoins the forked FlowFiles coming from ForkEnrichment processor, the resulting FlowFile will have the Original's content and all attributes from both of them (prioritizing Enrichment's).",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_enrichment.processors.join_enrichment_attributes.JoinEnrichmentAttributes"
+ }
+ ]
+ },
+ "group": "org.apache.nifi.minifi.rust.enrichment",
+ "artifact": "minifi_enrichment",
+ "version": "0.1.0"
+ }
+},
{
"bundles": {
"componentManifest": {
diff --git a/CONTROLLERS.md b/CONTROLLERS.md
index 28fce43e1e..c3ac2fccf5 100644
--- a/CONTROLLERS.md
+++ b/CONTROLLERS.md
@@ -393,4 +393,3 @@ In the list below, the names of required properties appear in bold. Any other pr
| **Pretty Print XML** | false | true false | Specifies whether or not the XML should be pretty printed |
| **Name of Record Tag** | | | Specifies the name of the XML record tag wrapping the record fields. |
| **Name of Root Tag** | | | Specifies the name of the XML root tag wrapping the record set. |
-
diff --git a/PROCESSORS.md b/PROCESSORS.md
index 4c7705b2a6..68487069c5 100644
--- a/PROCESSORS.md
+++ b/PROCESSORS.md
@@ -46,12 +46,14 @@ limitations under the License.
- [FetchSFTP](#FetchSFTP)
- [FetchSmb](#FetchSmb)
- [FocusArchiveEntry](#FocusArchiveEntry)
+- [ForkEnrichment](#ForkEnrichment)
- [GenerateFlowFile](#GenerateFlowFile)
- [GetCouchbaseKey](#GetCouchbaseKey)
- [GetFile](#GetFile)
- [GetTCP](#GetTCP)
- [HashContent](#HashContent)
- [InvokeHTTP](#InvokeHTTP)
+- [JoinEnrichmentAttributes](#JoinEnrichmentAttributes)
- [JoltTransformJSON](#JoltTransformJSON)
- [ListAzureBlobStorage](#ListAzureBlobStorage)
- [ListAzureDataLakeStorage](#ListAzureDataLakeStorage)
@@ -1067,6 +1069,35 @@ In the list below, the names of required properties appear in bold. Any other pr
| success | success operational on the flow record |
+## ForkEnrichment
+
+### Description
+
+Used in conjunction with the JoinEnrichmentAttributes processor, this processor is responsible for adding the attributes that are necessary for the JoinEnrichmentAttributes processor to perform its function. Each incoming FlowFile will be cloned. The original FlowFile will have appropriate attributes added and then be transferred to the 'original' relationship. The clone will have appropriate attributes added and then be routed to the 'enrichment' relationship.
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|----------------|---------------|------------------|-----------------------------------------------------------------------------------------------------------|
+| Max Batch Size | | | The maximum number of flow files to process at a time. If unset, all FlowFiles will be processed at once. |
+
+### Relationships
+
+| Name | Description |
+|------------|------------------------------------------------------------------------------------------------------------|
+| enrichment | A clone of the incoming FlowFile will be routed to this relationship, after adding appropriate attributes. |
+| original | The incoming FlowFile will be routed to this relationship, after adding appropriate attributes. |
+
+### Output Attributes
+
+| Attribute | Relationship | Description |
+|---------------------|----------------------|---------------------------------------------------------------------------------------------------|
+| enrichment.role | enrichment, original | The role to use for enrichment. This will either be ORIGINAL or ENRICHMENT. |
+| enrichment.group.id | enrichment, original | The Group ID to use in order to correlate the 'original' FlowFile with the 'enrichment' FlowFile. |
+
+
## GenerateFlowFile
### Description
@@ -1277,6 +1308,31 @@ In the list below, the names of required properties appear in bold. Any other pr
| invokehttp.tx.id | success, response, retry, no retry | The transaction ID that is returned after reading the response |
+## JoinEnrichmentAttributes
+
+### Description
+
+Rejoins the forked FlowFiles coming from ForkEnrichment processor, the resulting FlowFile will have the Original's content and all attributes from both of them (prioritizing Enrichment's).
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|----------------|---------------|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Timeout | | | Specifies the maximum amount of time to wait for the second FlowFile once the first arrives at the processor, after which point the first FlowFile will be routed to the 'timeout' relationship. |
+| Max Batch Size | | | The maximum number of flow files to process at a time. If unset, all FlowFiles will be processed at once. |
+
+### Relationships
+
+| Name | Description |
+|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| invalid | Any FlowFiles without the requisite attributes will be routed here |
+| joined | The resultant FlowFile with Records joined together from both the original and enrichment FlowFiles will be routed to this relationship |
+| original | Both of the incoming FlowFiles ('original' and 'enrichment') will be routed to this Relationship. I.e., this is the 'original' version of both of these FlowFiles. |
+| timeout | If one of the incoming FlowFiles (i.e., the 'original' FlowFile or the 'enrichment' FlowFile) arrives to this Processor but the other does not arrive within the configured Timeout period, the FlowFile that did arrive is routed to this relationship. |
+
+
## JoltTransformJSON
### Description
diff --git a/cmake/MiNiFiOptions.cmake b/cmake/MiNiFiOptions.cmake
index 7a898a7658..80059106c6 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_dependent_option(MINIFI_EXTENSION_ENRICHMENT "Enables the build of enrichment (rust) extension." ON "MINIFI_RUST" OFF)
add_minifi_option(MINIFI_LMDB "Enables the LMDB extension." OFF)
set_minifi_cache_variable(CUSTOM_MALLOC OFF "Overwrite malloc implementation.")
diff --git a/extension-framework/cpp-extension-lib/include/api/core/FlowFile.h b/extension-framework/cpp-extension-lib/include/api/core/FlowFile.h
index c2a5fb4244..0c953f80ca 100644
--- a/extension-framework/cpp-extension-lib/include/api/core/FlowFile.h
+++ b/extension-framework/cpp-extension-lib/include/api/core/FlowFile.h
@@ -39,4 +39,10 @@ struct EnsureMovedFromDeleter {
using FlowFile = std::unique_ptr;
+struct NoopStashedFlowFileDeleter {
+ void operator()(minifi_stashed_flow_file*) const noexcept {}
+};
+
+using StashedFlowFile = std::unique_ptr;
+
} // namespace org::apache::nifi::minifi::api::core
diff --git a/extension-framework/cpp-extension-lib/include/api/core/ProcessSession.h b/extension-framework/cpp-extension-lib/include/api/core/ProcessSession.h
index 7212046db2..f8bd900816 100644
--- a/extension-framework/cpp-extension-lib/include/api/core/ProcessSession.h
+++ b/extension-framework/cpp-extension-lib/include/api/core/ProcessSession.h
@@ -38,12 +38,15 @@ class ProcessSession {
ProcessSession& operator=(const ProcessSession&) = delete;
ProcessSession& operator=(ProcessSession&&) = delete;
+ virtual FlowFile clone(const FlowFile& flow_file) = 0;
virtual FlowFile create(const FlowFile* parent = nullptr) = 0;
virtual FlowFile get() = 0;
virtual void penalize(FlowFile& ff) = 0;
virtual void transfer(FlowFile ff, const minifi::core::Relationship& relationship) = 0;
virtual void remove(FlowFile ff) = 0;
+ virtual StashedFlowFile stash(FlowFile ff) = 0;
+ virtual FlowFile unstash(StashedFlowFile stashed_flow_file) = 0;
virtual void write(FlowFile& flow, const io::OutputStreamCallback& callback) = 0;
virtual void read(FlowFile& flow, const io::InputStreamCallback& callback) = 0;
@@ -64,10 +67,13 @@ class CffiProcessSession : public ProcessSession {
explicit CffiProcessSession(minifi_process_session* impl): impl_(impl) {}
FlowFile create(const FlowFile* parent = nullptr) override;
+ FlowFile clone(const FlowFile& flow_file) override;
FlowFile get() override;
void penalize(FlowFile& ff) override;
void transfer(FlowFile ff, const minifi::core::Relationship& relationship) override;
void remove(FlowFile ff) override;
+ StashedFlowFile stash(FlowFile ff) override;
+ FlowFile unstash(StashedFlowFile stashed_flow_file) override;
void write(FlowFile& flow, const io::OutputStreamCallback& callback) override;
void read(FlowFile& flow, const io::InputStreamCallback& callback) override;
diff --git a/extension-framework/cpp-extension-lib/mocklib/include/MockProcessSession.h b/extension-framework/cpp-extension-lib/mocklib/include/MockProcessSession.h
index ad3aeb1099..e06f9f5395 100644
--- a/extension-framework/cpp-extension-lib/mocklib/include/MockProcessSession.h
+++ b/extension-framework/cpp-extension-lib/mocklib/include/MockProcessSession.h
@@ -45,6 +45,10 @@ struct minifi_flow_file {
std::string id;
};
+struct minifi_stashed_flow_file {
+ std::unique_ptr flow_file;
+};
+
namespace org::apache::nifi::minifi::mock {
class MockProcessSession : public api::core::ProcessSession {
@@ -57,9 +61,12 @@ class MockProcessSession : public api::core::ProcessSession {
api::core::FlowFile create(const api::core::FlowFile* parent) override;
api::core::FlowFile get() override;
+ api::core::FlowFile clone(const api::core::FlowFile& flow_file) override;
void penalize(api::core::FlowFile& ff) override;
void transfer(api::core::FlowFile ff, const minifi::core::Relationship& relationship) override;
void remove(api::core::FlowFile ff) override;
+ api::core::StashedFlowFile stash(api::core::FlowFile ff) override;
+ api::core::FlowFile unstash(api::core::StashedFlowFile stashed_flow_file) override;
void write(api::core::FlowFile& ff, const io::OutputStreamCallback& callback) override;
void read(api::core::FlowFile& ff, const io::InputStreamCallback& callback) override;
void setAttribute(api::core::FlowFile& ff, std::string_view key, std::string value) override;
diff --git a/extension-framework/cpp-extension-lib/mocklib/src/MockProcessSession.cpp b/extension-framework/cpp-extension-lib/mocklib/src/MockProcessSession.cpp
index 6af5178e5e..30b53046f8 100644
--- a/extension-framework/cpp-extension-lib/mocklib/src/MockProcessSession.cpp
+++ b/extension-framework/cpp-extension-lib/mocklib/src/MockProcessSession.cpp
@@ -33,6 +33,11 @@ api::core::FlowFile MockProcessSession::get() {
return api::core::FlowFile{ff.release()};
}
+api::core::FlowFile MockProcessSession::clone(const api::core::FlowFile& flow_file) {
+ return api::core::FlowFile{new minifi_flow_file(*flow_file.get())};
+}
+
+
void MockProcessSession::penalize(api::core::FlowFile& ff) {
ff->is_penalized = true;
}
@@ -45,6 +50,15 @@ void MockProcessSession::remove(api::core::FlowFile ff) {
removed_flow_files_.push_back(std::unique_ptr(ff.release()));
}
+api::core::StashedFlowFile MockProcessSession::stash(api::core::FlowFile ff) {
+ return api::core::StashedFlowFile{new minifi_stashed_flow_file{std::unique_ptr(ff.release())}};
+}
+
+api::core::FlowFile MockProcessSession::unstash(api::core::StashedFlowFile stashed_flow_file) {
+ const std::unique_ptr owned{stashed_flow_file.release()};
+ return api::core::FlowFile{owned->flow_file.release()};
+}
+
void MockProcessSession::write(api::core::FlowFile& ff, const io::OutputStreamCallback& callback) {
const auto stream = std::make_shared(ff->content);
callback(stream);
diff --git a/extension-framework/cpp-extension-lib/mocklib/src/mock-minifi-api.cpp b/extension-framework/cpp-extension-lib/mocklib/src/mock-minifi-api.cpp
index 5647bfa118..e244ba51c9 100644
--- a/extension-framework/cpp-extension-lib/mocklib/src/mock-minifi-api.cpp
+++ b/extension-framework/cpp-extension-lib/mocklib/src/mock-minifi-api.cpp
@@ -17,6 +17,7 @@
#include
+#include "MockProcessSession.h"
#include "minifi-api.h"
extern "C" {
@@ -75,6 +76,14 @@ minifi_status minifi_process_session_remove(minifi_process_session*, MINIFI_OWNE
throw std::runtime_error("Not implemented");
}
+minifi_status minifi_process_session_stash(minifi_process_session*, MINIFI_OWNED minifi_flow_file*, minifi_stashed_flow_file**) {
+ throw std::runtime_error("Not implemented");
+}
+
+minifi_status minifi_process_session_unstash(minifi_process_session*, MINIFI_OWNED minifi_stashed_flow_file*, minifi_flow_file**) {
+ throw std::runtime_error("Not implemented");
+}
+
minifi_status minifi_process_session_read(minifi_process_session*, minifi_flow_file*, int64_t (*)(void* user_ctx, minifi_input_stream*), void*) {
throw std::runtime_error("Not implemented");
}
@@ -146,4 +155,8 @@ minifi_status minifi_process_context_report_metrics(minifi_process_context*, siz
throw std::runtime_error("Not implemented");
}
+minifi_flow_file* minifi_process_session_clone(minifi_process_session*, minifi_flow_file*) {
+ throw std::runtime_error("Not implemented");
+}
+
} // extern "C"
diff --git a/extension-framework/cpp-extension-lib/src/core/ProcessSession.cpp b/extension-framework/cpp-extension-lib/src/core/ProcessSession.cpp
index 41b8be081c..0f232e5c83 100644
--- a/extension-framework/cpp-extension-lib/src/core/ProcessSession.cpp
+++ b/extension-framework/cpp-extension-lib/src/core/ProcessSession.cpp
@@ -77,6 +77,10 @@ FlowFile CffiProcessSession::create(const FlowFile* parent) {
return FlowFile{minifi_process_session_create(impl_, parent ? parent->get() : nullptr)};
}
+FlowFile CffiProcessSession::clone(const FlowFile& flow_file) {
+ return FlowFile{minifi_process_session_clone(impl_, flow_file.get())};
+}
+
void CffiProcessSession::penalize(FlowFile& ff) {
if (MINIFI_STATUS_SUCCESS != minifi_process_session_penalize(impl_, ff.get())) {
throw minifi::Exception(minifi::FILE_OPERATION_EXCEPTION, "Failed to penalize flowfile");
@@ -96,6 +100,22 @@ void CffiProcessSession::remove(FlowFile ff) {
}
}
+StashedFlowFile CffiProcessSession::stash(FlowFile ff) {
+ minifi_stashed_flow_file* stashed = nullptr;
+ if (MINIFI_STATUS_SUCCESS != minifi_process_session_stash(impl_, ff.release(), &stashed)) {
+ throw minifi::Exception(minifi::FILE_OPERATION_EXCEPTION, "Failed to stash flowfile");
+ }
+ return StashedFlowFile{stashed};
+}
+
+FlowFile CffiProcessSession::unstash(StashedFlowFile stashed_flow_file) {
+ minifi_flow_file* ff = nullptr;
+ if (MINIFI_STATUS_SUCCESS != minifi_process_session_unstash(impl_, stashed_flow_file.release(), &ff)) {
+ throw minifi::Exception(minifi::FILE_OPERATION_EXCEPTION, "Failed to unstash flowfile");
+ }
+ return FlowFile{ff};
+}
+
void CffiProcessSession::write(FlowFile& flow_file, const io::OutputStreamCallback& callback) {
const auto status = minifi_process_session_write(
impl_,
diff --git a/extensions/libarchive/BinFiles.cpp b/extensions/libarchive/BinFiles.cpp
index 584eff4335..835282ce4b 100644
--- a/extensions/libarchive/BinFiles.cpp
+++ b/extensions/libarchive/BinFiles.cpp
@@ -215,6 +215,7 @@ bool BinFiles::resurrectFlowFiles(core::ProcessSession &session) {
for (auto &file : flow_files) {
std::string group_id = getGroupId(file);
if (!binManager_.offer(group_id, file)) {
+ session.add(file);
session.transfer(file, Failure);
had_failure = true;
}
diff --git a/extensions/libarchive/tests/MergeFileTests.cpp b/extensions/libarchive/tests/MergeFileTests.cpp
index 2ab3fc53a7..9de83dc1f4 100644
--- a/extensions/libarchive/tests/MergeFileTests.cpp
+++ b/extensions/libarchive/tests/MergeFileTests.cpp
@@ -32,6 +32,7 @@
#include "core/ProcessSession.h"
#include "core/ProcessSessionFactory.h"
#include "FlowController.h"
+#include "FlowFileRecord.h"
#include "Connection.h"
#include "../../include/core/FlowFile.h"
#include "MergeContent.h"
@@ -924,3 +925,19 @@ TEST_CASE("Empty MergeContent doesnt yield when processing readybins") {
auto second_trigger_results = controller.trigger();
CHECK_FALSE(merge_content->isYield());
}
+
+TEST_CASE("MergeContent routes a restored flow file rejected by its bin to failure") {
+ minifi::test::SingleProcessorTestController controller{minifi::test::utils::make_processor("mergeContent")};
+ const auto merge_content = controller.getProcessor();
+ REQUIRE(controller.plan->setProperty(merge_content, minifi::processors::MergeContent::MergeStrategy,
+ minifi::processors::merge_content_options::MERGE_STRATEGY_DEFRAGMENT));
+
+ auto restored_flow_file = std::make_shared();
+ restored_flow_file->addAttribute(minifi::processors::BinFiles::FRAGMENT_ID_ATTRIBUTE, "group-1");
+ restored_flow_file->addAttribute(minifi::processors::BinFiles::FRAGMENT_COUNT_ATTRIBUTE, "0");
+ merge_content->restore(restored_flow_file);
+
+ minifi::test::ProcessorTriggerResult results;
+ REQUIRE_NOTHROW(results = controller.trigger());
+ CHECK(results.at(minifi::processors::MergeContent::Failure).size() == 1);
+}
diff --git a/extensions/standard-processors/processors/DefragmentText.cpp b/extensions/standard-processors/processors/DefragmentText.cpp
index a66eea44f9..1f93ac60c6 100644
--- a/extensions/standard-processors/processors/DefragmentText.cpp
+++ b/extensions/standard-processors/processors/DefragmentText.cpp
@@ -59,8 +59,10 @@ void DefragmentText::onSchedule(core::ProcessContext& context, core::ProcessSess
void DefragmentText::onTrigger(core::ProcessContext&, core::ProcessSession& session) {
auto flowFiles = flow_file_store_.getNewFlowFiles();
for (auto& file : flowFiles) {
- if (file)
+ if (file) {
+ session.add(file);
processNextFragment(session, gsl::not_null(file));
+ }
}
{
std::shared_ptr original_flow_file = session.get();
diff --git a/extensions/standard-processors/tests/unit/DefragmentTextTests.cpp b/extensions/standard-processors/tests/unit/DefragmentTextTests.cpp
index 017c2d1b9a..e25692a7b0 100644
--- a/extensions/standard-processors/tests/unit/DefragmentTextTests.cpp
+++ b/extensions/standard-processors/tests/unit/DefragmentTextTests.cpp
@@ -23,6 +23,7 @@
#include "unit/ReadFromFlowFileTestProcessor.h"
#include "UpdateAttribute.h"
#include "DefragmentText.h"
+#include "FlowFileRecord.h"
#include "TextFragmentUtils.h"
#include "serialization/PayloadSerializer.h"
#include "serialization/FlowFileSerializer.h"
@@ -425,3 +426,49 @@ TEST_CASE("DefragmentText with offset attributes", "[defragmenttextoffsetattribu
CHECK(read_from_success_relationship.get().readFlowFileWithContent("%cat,octopus"));
}
}
+
+TEST_CASE("DefragmentText routes a restored out-of-order fragment to failure", "[defragmenttextrestore]") {
+ TestController testController;
+ auto plan = testController.createPlan();
+ auto input_1 = plan->addProcessor("input_1");
+ auto defrag_text_flow_files = plan->addProcessor("defrag_text_flow_files");
+ auto read_from_failure_relationship = plan->addProcessor("read_from_failure_relationship");
+ auto read_from_success_relationship = plan->addProcessor("read_from_success_relationship");
+
+ plan->addConnection(input_1, FragmentGenerator::Success, defrag_text_flow_files);
+ plan->addConnection(defrag_text_flow_files, DefragmentText::Failure, read_from_failure_relationship);
+ plan->addConnection(defrag_text_flow_files, DefragmentText::Success, read_from_success_relationship);
+
+ read_from_failure_relationship.get().disableClearOnTrigger();
+ read_from_success_relationship.get().disableClearOnTrigger();
+ read_from_failure_relationship->setAutoTerminatedRelationships(std::array{ReadFromFlowFileTestProcessor::Success});
+ read_from_success_relationship->setAutoTerminatedRelationships(std::array{ReadFromFlowFileTestProcessor::Success});
+ plan->setProperty(defrag_text_flow_files, DefragmentText::Pattern, "%");
+ input_1.get().setBaseNameAttribute("input_1");
+ input_1.get().setPostNameAttribute("log");
+ input_1.get().setAbsolutePathAttribute("/tmp/input/input_1.log");
+
+ // First trigger: "foo" is flushed to success and "%bar" is left in the buffer, so the buffer
+ // is non-empty and expects the next fragment to start at offset 7.
+ input_1.get().setFragments({"foo%bar"});
+ testController.runSession(plan);
+ plan->reset();
+ REQUIRE(read_from_success_relationship.get().numberOfFlowFilesRead() == 1);
+
+ // A fragment recovered from the flow file repository after a restart is handed to the processor
+ // via restore(), so it never passed through a session. Its offset does not continue the buffered
+ // fragment, which sends it down the transfer-to-failure path in processNextFragment().
+ auto restored_fragment = std::make_shared();
+ restored_fragment->addAttribute(core::SpecialFlowAttribute::ABSOLUTE_PATH, "/tmp/input/input_1.log");
+ restored_fragment->addAttribute(textfragmentutils::BASE_NAME_ATTRIBUTE, "input_1");
+ restored_fragment->addAttribute(textfragmentutils::POST_NAME_ATTRIBUTE, "log");
+ restored_fragment->addAttribute(textfragmentutils::OFFSET_ATTRIBUTE, "9999");
+ defrag_text_flow_files->restore(restored_fragment);
+
+ REQUIRE_NOTHROW(testController.runSession(plan));
+
+ // Both the flushed buffer and the out-of-order fragment end up on failure.
+ CHECK(read_from_failure_relationship.get().numberOfFlowFilesRead() == 2);
+ CHECK(read_from_failure_relationship.get().readFlowFileWithContent("%bar"));
+ CHECK(read_from_success_relationship.get().numberOfFlowFilesRead() == 1);
+}
diff --git a/libminifi/include/core/ProcessSession.h b/libminifi/include/core/ProcessSession.h
index 72494dd1b7..f88402d8d3 100644
--- a/libminifi/include/core/ProcessSession.h
+++ b/libminifi/include/core/ProcessSession.h
@@ -79,6 +79,9 @@ class ProcessSessionImpl : public ReferenceContainerImpl, public virtual Process
void remove(const std::shared_ptr &flow) override;
+ StashedFlowFile stash(std::shared_ptr flow_file) override;
+ std::shared_ptr unstash(StashedFlowFile stashed_flow_file) override;
+
std::shared_ptr getFlowFileContentStream(const core::FlowFile& flow_file) override;
int64_t read(const std::shared_ptr& flow_file, const io::InputStreamCallback& callback) override;
diff --git a/libminifi/include/core/Processor.h b/libminifi/include/core/Processor.h
index 4f48c09383..9e47a61189 100644
--- a/libminifi/include/core/Processor.h
+++ b/libminifi/include/core/Processor.h
@@ -123,6 +123,11 @@ class Processor : public ConnectableImpl, public ConfigurableComponentImpl, publ
void setLoggerCallback(const std::function& callback);
void restore(const std::shared_ptr& file) override;
+ // Stashing keeps a flow file owned by this processor across onTrigger invocations (used by the
+ // stable C API's stash/unstash). The owning shared_ptr lives here; sessions hand it in and out.
+ void stashFlowFile(std::shared_ptr flow_file);
+ std::shared_ptr unstashFlowFile(const FlowFile* flow_file);
+
static constexpr auto DynamicProperties = std::array{};
static constexpr auto OutputAttributes = std::array{};
@@ -159,6 +164,9 @@ class Processor : public ConnectableImpl, public ConfigurableComponentImpl, publ
mutable std::mutex mutex_;
std::atomic yield_expiration_{};
+ // Flow files stashed with this processor (owning), keyed by identity via unstashFlowFile.
+ std::vector> stashed_flow_files_;
+
// must hold the graphMutex
void updateReachability(const std::lock_guard& graph_lock, bool force = false);
diff --git a/libminifi/include/utils/minifi-api-utils.h b/libminifi/include/utils/minifi-api-utils.h
index f98b608131..54b77bfd75 100644
--- a/libminifi/include/utils/minifi-api-utils.h
+++ b/libminifi/include/utils/minifi-api-utils.h
@@ -73,6 +73,9 @@ MINIFI_API_MAP(minifi_extension, minifi::core::extension::Extension);
MINIFI_API_MAP(minifi_extension_context, minifi::core::extension::Extension::Context);
MINIFI_API_MAP(minifi_logger, std::shared_ptr);
MINIFI_API_MAP(minifi_flow_file, std::shared_ptr);
+// A stashed flow file handle is an opaque, non-owning identity token: the raw FlowFile whose owning
+// shared_ptr lives in the processor's stash. See core::StashedFlowFile / Processor::stashFlowFile.
+MINIFI_API_MAP(minifi_stashed_flow_file, minifi::core::FlowFile);
#undef MINIFI_API_MAP
diff --git a/libminifi/src/core/ProcessSession.cpp b/libminifi/src/core/ProcessSession.cpp
index 4352f7b41c..c1ca7356ef 100644
--- a/libminifi/src/core/ProcessSession.cpp
+++ b/libminifi/src/core/ProcessSession.cpp
@@ -209,6 +209,35 @@ void ProcessSessionImpl::remove(const std::shared_ptr &flow) {
provenance_report_->drop(*flow, reason);
}
+StashedFlowFile ProcessSessionImpl::stash(std::shared_ptr flow_file) {
+ logger_->log_trace("Stashing flow file with UUID: {}", flow_file->getUUIDStr());
+ const auto uuid = flow_file->getUUID();
+ // The session forgets this flow file: on commit it is neither routed, persisted, nor deleted.
+ updated_flowfiles_.erase(uuid);
+ updated_relationships_.erase(uuid);
+ added_flowfiles_.erase(uuid);
+ std::erase_if(deleted_flowfiles_, [&uuid](const auto& ff) { return ff->getUUID() == uuid; });
+
+ core::FlowFile* identity = flow_file.get();
+ process_context_->getProcessor().stashFlowFile(std::move(flow_file));
+ return StashedFlowFile{identity};
+}
+
+std::shared_ptr ProcessSessionImpl::unstash(StashedFlowFile stashed_flow_file) {
+ auto record = process_context_->getProcessor().unstashFlowFile(stashed_flow_file.flow_file);
+ if (!record) {
+ return nullptr;
+ }
+ logger_->log_trace("Unstashing flow file with UUID: {}", record->getUUIDStr());
+ const utils::Identifier uuid = record->getUUID();
+ if (updated_flowfiles_.contains(uuid)) {
+ throw Exception(ExceptionType::PROCESSOR_EXCEPTION, "Mustn't unstash a file that was provided by this session");
+ }
+ added_flowfiles_[uuid].flow_file = record;
+ record->setDeleted(false);
+ return record;
+}
+
void ProcessSessionImpl::putAttribute(core::FlowFile& flow_file, std::string_view key, const std::string& value) {
flow_file.setAttribute(key, value);
std::string details = fmt::format("{} modify flow record {} attribute {}:{}", process_context_->getProcessor().getName(), flow_file.getUUIDStr(), key, value);
@@ -233,8 +262,11 @@ void ProcessSessionImpl::transfer(const std::shared_ptr& flow, c
utils::Identifier uuid = flow->getUUID();
if (auto it = added_flowfiles_.find(uuid); it != added_flowfiles_.end()) {
it->second.rel = &*relationships_.insert(relationship).first;
- } else {
+ } else if (updated_flowfiles_.contains(uuid)) {
updated_relationships_[uuid] = &*relationships_.insert(relationship).first;
+ } else {
+ throw Exception(PROCESS_SESSION_EXCEPTION,
+ "Cannot transfer flow file " + flow->getUUIDStr() + " that was not obtained from or added to this session");
}
flow->setDeleted(false);
}
diff --git a/libminifi/src/core/Processor.cpp b/libminifi/src/core/Processor.cpp
index b20004a092..59675699f0 100644
--- a/libminifi/src/core/Processor.cpp
+++ b/libminifi/src/core/Processor.cpp
@@ -543,6 +543,23 @@ void Processor::restore(const std::shared_ptr& file) {
impl_->restore(file);
}
+void Processor::stashFlowFile(std::shared_ptr flow_file) {
+ const std::lock_guard lock(mutex_);
+ stashed_flow_files_.push_back(std::move(flow_file));
+}
+
+std::shared_ptr Processor::unstashFlowFile(const FlowFile* flow_file) {
+ const std::lock_guard lock(mutex_);
+ for (auto it = stashed_flow_files_.begin(); it != stashed_flow_files_.end(); ++it) {
+ if (it->get() == flow_file) {
+ auto result = std::move(*it);
+ stashed_flow_files_.erase(it);
+ return result;
+ }
+ }
+ return nullptr;
+}
+
const std::unordered_map>& Processor::reachable_processors() const {
return reachable_processors_;
}
diff --git a/libminifi/src/core/extension/ApiVersion.cpp b/libminifi/src/core/extension/ApiVersion.cpp
index e7930fc116..d9d6a91e2f 100644
--- a/libminifi/src/core/extension/ApiVersion.cpp
+++ b/libminifi/src/core/extension/ApiVersion.cpp
@@ -22,7 +22,7 @@
namespace org::apache::nifi::minifi::core::extension {
static uint32_t agent_api_version{MINIFI_API_VERSION};
-static uint32_t min_supported_api_version{MINIFI_API_VERSION};
+static uint32_t min_supported_api_version = 2; // Since version 2 everything was additive only
uint32_t getAgentApiVersion() {
return agent_api_version;
diff --git a/libminifi/src/minifi-api.cpp b/libminifi/src/minifi-api.cpp
index 0fae27f2b1..c7a3b6eee8 100644
--- a/libminifi/src/minifi-api.cpp
+++ b/libminifi/src/minifi-api.cpp
@@ -442,6 +442,15 @@ MINIFI_OWNED minifi_flow_file* minifi_process_session_create(minifi_process_sess
return nullptr;
}
+MINIFI_OWNED minifi_flow_file* minifi_process_session_clone(minifi_process_session* session, minifi_flow_file* flow_file) {
+ gsl_Assert(session);
+ gsl_Assert(flow_file);
+ if (const auto ff = toCpp(session)->clone(*toRawFlowFile(flow_file))) {
+ return toC(new std::shared_ptr(ff)); // NOLINT(cppcoreguidelines-owning-memory)
+ }
+ return nullptr;
+}
+
minifi_status minifi_process_session_penalize(minifi_process_session* session, minifi_flow_file* flowfile) {
gsl_Assert(session);
gsl_Assert(flowfile);
@@ -479,6 +488,36 @@ minifi_status minifi_process_session_remove(minifi_process_session* session, MIN
}
}
+enum minifi_status minifi_process_session_stash(struct minifi_process_session* session, MINIFI_OWNED struct minifi_flow_file* flow_file, struct minifi_stashed_flow_file** stashed_flow_file) {
+ gsl_Assert(session);
+ gsl_Assert(flow_file);
+ gsl_Assert(stashed_flow_file);
+ const std::unique_ptr> owned{toCpp(flow_file)};
+ try {
+ *stashed_flow_file = toC(toCpp(session)->stash(std::move(*owned)).flow_file);
+ return MINIFI_STATUS_SUCCESS;
+ } catch (...) {
+ return MINIFI_STATUS_UNKNOWN_ERROR;
+ }
+}
+
+minifi_status minifi_process_session_unstash(struct minifi_process_session* session, MINIFI_OWNED struct minifi_stashed_flow_file* stashed_flow_file, struct minifi_flow_file** flow_file) {
+ gsl_Assert(session);
+ gsl_Assert(flow_file);
+ gsl_Assert(stashed_flow_file);
+ try {
+ auto unstashed = toCpp(session)->unstash(minifi::core::StashedFlowFile{toCpp(stashed_flow_file)});
+ if (!unstashed) {
+ return MINIFI_STATUS_UNKNOWN_ERROR;
+ }
+ *flow_file = toC(new std::shared_ptr(std::move(unstashed))); // NOLINT(cppcoreguidelines-owning-memory)
+ return MINIFI_STATUS_SUCCESS;
+ }
+ catch (...) {
+ return MINIFI_STATUS_UNKNOWN_ERROR;
+ }
+}
+
minifi_status minifi_process_session_read(minifi_process_session* session, minifi_flow_file* flowfile, int64_t(*cb)(void* user_ctx, minifi_input_stream*), void* user_ctx) {
gsl_Assert(session);
gsl_Assert(flowfile);
diff --git a/libminifi/test/unit/ProcessSessionTests.cpp b/libminifi/test/unit/ProcessSessionTests.cpp
index 8c7e99bd61..0bbfc4f0dd 100644
--- a/libminifi/test/unit/ProcessSessionTests.cpp
+++ b/libminifi/test/unit/ProcessSessionTests.cpp
@@ -166,3 +166,35 @@ TEST_CASE("Stable C API does not leak the flow file handle", "[minifi-api][flowf
// FlowFile alive and this assertion fails.
REQUIRE(weak_flow_file.expired());
}
+
+TEST_CASE("Stable C API: stash keeps a flow file across a commit and unstash brings it back", "[minifi-api][flowfilehandle]") {
+ Fixture fixture;
+ auto& process_session = fixture.processSession();
+ auto* c_session = minifi::utils::toC(&process_session);
+
+ auto created = process_session.create();
+ process_session.transfer(created, Success);
+ process_session.commit();
+
+ std::weak_ptr weak_flow_file = created;
+ created.reset();
+
+ auto* handle = minifi_process_session_get(c_session);
+ REQUIRE(handle != nullptr);
+
+ // Stash detaches the flow file from the session; the returned stashed handle keeps it alive
+ // across the commit (it is neither routed nor deleted).
+ minifi_stashed_flow_file* stashed = nullptr;
+ REQUIRE(minifi_process_session_stash(c_session, handle, &stashed) == MINIFI_STATUS_SUCCESS);
+ REQUIRE(stashed != nullptr);
+ REQUIRE_NOTHROW(process_session.commit());
+ REQUIRE_FALSE(weak_flow_file.expired());
+
+ // Unstash hands it back as a MINIFI_OWNED handle; removing it and committing frees it for good.
+ minifi_flow_file* unstashed = nullptr;
+ REQUIRE(minifi_process_session_unstash(c_session, stashed, &unstashed) == MINIFI_STATUS_SUCCESS);
+ REQUIRE(unstashed != nullptr);
+ REQUIRE(minifi_process_session_remove(c_session, unstashed) == MINIFI_STATUS_SUCCESS);
+ process_session.commit();
+ REQUIRE(weak_flow_file.expired());
+}
diff --git a/minifi-api/include/minifi-api.h b/minifi-api/include/minifi-api.h
index f9d1fe078b..0cf11cc98b 100644
--- a/minifi-api/include/minifi-api.h
+++ b/minifi-api/include/minifi-api.h
@@ -43,7 +43,7 @@ extern "C" {
#define MINIFI_PROXY_CONFIGURATION_SERVICE_INTERFACE_PROPERTY_TYPE "org.apache.nifi.minifi.controllers.ProxyConfigurationServiceInterface"
enum : uint32_t {
- MINIFI_API_VERSION = 2
+ MINIFI_API_VERSION = 3
};
enum minifi_io_status : int64_t {
@@ -100,6 +100,8 @@ struct minifi_output_stream;
struct minifi_extension;
struct minifi_extension_context;
+struct minifi_stashed_flow_file; // MINIFI_API_VERSION >= 3
+
enum minifi_status : uint32_t {
MINIFI_STATUS_SUCCESS = 0,
MINIFI_STATUS_UNKNOWN_ERROR = 1,
@@ -243,12 +245,33 @@ bool minifi_logger_should_log(struct minifi_logger*, enum minifi_log_level);
MINIFI_OWNED struct minifi_flow_file* minifi_process_session_get(struct minifi_process_session*);
MINIFI_OWNED struct minifi_flow_file* minifi_process_session_create(struct minifi_process_session* session,
MINIFI_NULLABLE struct minifi_flow_file* parent_flowfile);
+MINIFI_OWNED struct minifi_flow_file* minifi_process_session_clone(struct minifi_process_session* session,
+ struct minifi_flow_file* flow_file);
enum minifi_status minifi_process_session_penalize(struct minifi_process_session* session, struct minifi_flow_file* flowfile);
enum minifi_status minifi_process_session_transfer(struct minifi_process_session* session, MINIFI_OWNED struct minifi_flow_file* flowfile,
struct minifi_string_view relationship_name);
enum minifi_status minifi_process_session_remove(struct minifi_process_session* session, MINIFI_OWNED struct minifi_flow_file* flowfile);
+// stash/unstash let an extension keep a flow file across onTrigger invocations without the legacy
+// self-relationship trick. The flow file's record/content stay in the repositories throughout; what
+// moves is where it is owned.
+//
+// stash: takes a MINIFI_OWNED flow file (e.g. one from get()) out of the current session - on commit
+// it is neither routed, persisted, nor deleted - and hands its ownership to the processor, which
+// outlives the session. It writes an opaque handle to *stashed_flow_file identifying the stashed
+// flow file. The handle is a lightweight token, not a new allocation: there is nothing to free.
+// unstash: hands a stashed flow file back to a (later) session. It writes a MINIFI_OWNED flow file to
+// *flow_file, which is owned by that session again and must be transferred or removed as usual.
+// The stashed_flow_file token is consumed and must not be used again.
+//
+// Because the processor owns stashed flow files, there is no explicit free: a token that is never
+// unstashed simply leaves its flow file in the processor, which reclaims it when the processor is
+// torn down (the record/content stay in the repositories and are recovered on next startup).
+enum minifi_status minifi_process_session_stash(struct minifi_process_session* session, MINIFI_OWNED struct minifi_flow_file* flow_file, struct minifi_stashed_flow_file** stashed_flow_file);
+enum minifi_status minifi_process_session_unstash(struct minifi_process_session* session, MINIFI_OWNED struct minifi_stashed_flow_file* stashed_flow_file, struct minifi_flow_file** flow_file);
+
+
enum minifi_status minifi_process_session_read(struct minifi_process_session*, struct minifi_flow_file*,
int64_t (*cb)(void* user_ctx, struct minifi_input_stream*), void* user_ctx);
enum minifi_status minifi_process_session_write(struct minifi_process_session*, struct minifi_flow_file*,
diff --git a/minifi-api/include/minifi-cpp/core/FlowFile.h b/minifi-api/include/minifi-cpp/core/FlowFile.h
index 951829b45f..8a026478c5 100644
--- a/minifi-api/include/minifi-cpp/core/FlowFile.h
+++ b/minifi-api/include/minifi-cpp/core/FlowFile.h
@@ -81,4 +81,11 @@ class FlowFile : public virtual CoreComponent, public virtual ReferenceContainer
static std::shared_ptr create();
};
+// A non-owning token identifying a flow file stashed with a processor (see
+// ProcessSession::stash / Processor::stashFlowFile). The owning shared_ptr lives in the processor;
+// this only records the flow file's identity so unstash() can retrieve it.
+struct StashedFlowFile {
+ core::FlowFile* flow_file;
+};
+
} // namespace org::apache::nifi::minifi::core
diff --git a/minifi-api/include/minifi-cpp/core/ProcessSession.h b/minifi-api/include/minifi-cpp/core/ProcessSession.h
index 5b77d84229..3820e7a0f1 100644
--- a/minifi-api/include/minifi-cpp/core/ProcessSession.h
+++ b/minifi-api/include/minifi-cpp/core/ProcessSession.h
@@ -83,6 +83,9 @@ class ProcessSession : public virtual ReferenceContainer {
// Penalize the flow
virtual void penalize(const std::shared_ptr &flow) = 0;
+ virtual StashedFlowFile stash(std::shared_ptr flow_file) = 0;
+ virtual std::shared_ptr unstash(StashedFlowFile stashed_flow_file) = 0;
+
virtual bool outgoingConnectionsFull(const std::string& relationship) = 0;
/**
diff --git a/minifi-api/minifi-api.def b/minifi-api/minifi-api.def
index a0a0b74add..2670fe8e75 100644
--- a/minifi-api/minifi-api.def
+++ b/minifi-api/minifi-api.def
@@ -14,6 +14,7 @@ EXPORTS
minifi_process_context_get_ssl_data_from_property
minifi_process_context_report_metrics
minifi_process_context_set_trigger_when_empty
+ minifi_process_session_clone
minifi_process_session_create
minifi_process_session_get
minifi_process_session_get_flow_file_attribute
@@ -24,7 +25,9 @@ EXPORTS
minifi_process_session_read
minifi_process_session_remove
minifi_process_session_set_flow_file_attribute
+ minifi_process_session_stash
minifi_process_session_transfer
+ minifi_process_session_unstash
minifi_process_session_write
minifi_register_controller_service
minifi_register_extension
diff --git a/minifi_rust/CMakeLists.txt b/minifi_rust/CMakeLists.txt
index 0ecc1441de..3182e491bf 100644
--- a/minifi_rust/CMakeLists.txt
+++ b/minifi_rust/CMakeLists.txt
@@ -39,6 +39,10 @@ if (NOT ENABLE_TEST_PROCESSORS)
set_target_properties(cargo-build_minifi_rs_playground PROPERTIES EXCLUDE_FROM_ALL TRUE)
endif()
+if (NOT MINIFI_EXTENSION_ENRICHMENT)
+ set_target_properties(cargo-build_minifi_enrichment PROPERTIES EXCLUDE_FROM_ALL TRUE)
+endif()
+
include(CTest)
find_program(CARGO_NEXTEST_EXECUTABLE cargo-nextest)
diff --git a/minifi_rust/extensions/minifi_enrichment/.cargo/config.toml b/minifi_rust/extensions/minifi_enrichment/.cargo/config.toml
new file mode 100644
index 0000000000..cb8c02ddc4
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/.cargo/config.toml
@@ -0,0 +1,5 @@
+[target.aarch64-apple-darwin]
+rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
+
+[target.x86_64-apple-darwin]
+rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
diff --git a/minifi_rust/extensions/minifi_enrichment/.gitignore b/minifi_rust/extensions/minifi_enrichment/.gitignore
new file mode 100644
index 0000000000..32c9b263bd
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/.gitignore
@@ -0,0 +1,7 @@
+target
+output
+features/.venv
+features/output
+integration-test/features/.venv
+integration-test/features/linux_so
+integration-test/.venv
diff --git a/minifi_rust/extensions/minifi_enrichment/Cargo.toml b/minifi_rust/extensions/minifi_enrichment/Cargo.toml
new file mode 100644
index 0000000000..71f132fd7f
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "minifi_enrichment"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+minifi_native = { path = "../../minifi_native" }
+strum = "0.28.0"
+walkdir = "2.5.0"
+rand = "0.10.0"
+hex = "0.4.3"
+strum_macros = "0.28.0"
+lipsum = "0.9.1"
+uuid = { version = "1.24.0", features = ["v4"] }
+
+[dev-dependencies]
+tempfile = "3.22.0"
+filetime = "0.2.26"
diff --git a/minifi_rust/extensions/minifi_enrichment/features/enrichment.feature b/minifi_rust/extensions/minifi_enrichment/features/enrichment.feature
new file mode 100644
index 0000000000..1401abc46a
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/features/enrichment.feature
@@ -0,0 +1,47 @@
+# 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.
+
+@MINIFI_EXTENSION_ENRICHMENT @SUPPORTS_WINDOWS
+Feature: ForkEnrichment and JoinEnrichmentAttributes
+
+ Scenario: Merges correctly
+ Given a GenerateFlowFile processor with the "Custom Text" property set to "original_${literal("content")}"
+ And the scheduling period of the GenerateFlowFile processor is set to "1 hour"
+ And the "Data Format" property of the GenerateFlowFile processor is set to "Text"
+ And the "Unique FlowFiles" property of the GenerateFlowFile processor is set to "false"
+
+ And a ForkEnrichment processor
+ And a JoinEnrichmentAttributes processor
+
+ And a ReplaceText processor with the "Evaluation Mode" property set to "Entire text"
+ And the "Replacement Strategy" property of the ReplaceText processor is set to "Always Replace"
+ And the "Replacement Value" property of the ReplaceText processor is set to "replaced_content"
+
+ And an UpdateAttribute processor with the "extra_prop" property set to "foo"
+
+ And a LogAttribute processor with the "Log Payload" property set to "true"
+
+ And the "success" relationship of the GenerateFlowFile processor is connected to the ForkEnrichment
+ And the "original" relationship of the ForkEnrichment processor is connected to the JoinEnrichmentAttributes
+ And the "enrichment" relationship of the ForkEnrichment processor is connected to the ReplaceText
+ And the "success" relationship of the ReplaceText processor is connected to the UpdateAttribute
+ And the "success" relationship of the UpdateAttribute processor is connected to the JoinEnrichmentAttributes
+ And the "joined" relationship of the JoinEnrichmentAttributes processor is connected to the LogAttribute
+ And JoinEnrichmentAttributes's original relationship is auto-terminated
+ And LogAttribute's success relationship is auto-terminated
+ When the MiNiFi instance starts up
+ Then the Minifi logs contain the following message: "key:enrichment.role value:JOINED" in less than 10 seconds
+ And the Minifi logs contain the following message: "key:extra_prop value:foo" in less than 1 second
+ And the Minifi logs contain the following message: "original_content" in less than 1 second
diff --git a/minifi_rust/extensions/minifi_enrichment/features/enrichment_restart.feature b/minifi_rust/extensions/minifi_enrichment/features/enrichment_restart.feature
new file mode 100644
index 0000000000..36d3291013
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/features/enrichment_restart.feature
@@ -0,0 +1,71 @@
+# 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.
+
+@MINIFI_EXTENSION_ENRICHMENT
+Feature: JoinEnrichmentAttributes recovers released FlowFiles after an agent restart
+
+ # JoinEnrichmentAttributes releases the first half of a pair and holds it in memory until the
+ # other half arrives. The released FlowFile is not routed, persisted-as-moved, nor deleted on
+ # commit, so its record stays in the (default, RocksDB-backed) flow file repository. When the
+ # agent is restarted the held FlowFile must be recovered from disk and re-enqueued, so that the
+ # join still completes once its pair finally arrives.
+ #
+ # The two halves are produced independently with GetFile + UpdateAttribute (instead of
+ # ForkEnrichment) so their arrival can be separated across the restart: the ORIGINAL is present
+ # at startup, and its ENRICHMENT pair is only delivered after the restart.
+
+ Scenario: A released FlowFile held across an agent restart is recovered and joined with its late-arriving pair
+ Given a GetFile processor with the name "GetOriginal" and the "Input Directory" property set to "/tmp/original_input"
+ And the scheduling period of the GetOriginal processor is set to "100 ms"
+ And a UpdateAttribute processor with the name "TagOriginal" and the "enrichment.role" property set to "ORIGINAL"
+ And the "enrichment.group.id" property of the TagOriginal processor is set to "group-1"
+
+ And a GetFile processor with the name "GetEnrichment" and the "Input Directory" property set to "/tmp/enrichment_input"
+ And the scheduling period of the GetEnrichment processor is set to "100 ms"
+ And a UpdateAttribute processor with the name "TagEnrichment" and the "enrichment.role" property set to "ENRICHMENT"
+ And the "enrichment.group.id" property of the TagEnrichment processor is set to "group-1"
+
+ And a JoinEnrichmentAttributes processor
+ And a PutFile processor with the "Directory" property set to "/tmp/output"
+
+ And the "success" relationship of the GetOriginal processor is connected to the TagOriginal
+ And the "success" relationship of the TagOriginal processor is connected to the JoinEnrichmentAttributes
+ And the "success" relationship of the GetEnrichment processor is connected to the TagEnrichment
+ And the "success" relationship of the TagEnrichment processor is connected to the JoinEnrichmentAttributes
+ And the "joined" relationship of the JoinEnrichmentAttributes processor is connected to the PutFile
+
+ And JoinEnrichmentAttributes's original relationship is auto-terminated
+ And JoinEnrichmentAttributes's invalid relationship is auto-terminated
+ And JoinEnrichmentAttributes's timeout relationship is auto-terminated
+ And PutFile's success relationship is auto-terminated
+ And PutFile's failure relationship is auto-terminated
+
+ # Only the ORIGINAL half exists at startup; the ENRICHMENT half arrives after the restart.
+ And a directory at "/tmp/original_input" has a file with the content "original_content"
+
+ When the MiNiFi instance starts up
+ # JoinEnrichmentAttributes gets the ORIGINAL, releases it and holds it waiting for its pair,
+ # so nothing is joined yet.
+ Then no files are placed in the "/tmp/output" directory in 5 seconds of running time
+
+ # Graceful stop destroys JoinEnrichmentAttributes while it still holds the released ORIGINAL,
+ # then restart brings the agent back with the persistent repositories intact.
+ When MiNiFi is stopped
+ And MiNiFi is restarted
+
+ # The released ORIGINAL is recovered from the flow file repository and re-enqueued. Once its
+ # ENRICHMENT pair arrives, the join produces a single FlowFile with the ORIGINAL's content.
+ And a file with the content "enrichment_content" is placed in "/tmp/enrichment_input"
+ Then a single file with the content "original_content" is placed in the "/tmp/output" directory in less than 60 seconds
diff --git a/minifi_rust/extensions/minifi_enrichment/features/environment.py b/minifi_rust/extensions/minifi_enrichment/features/environment.py
new file mode 100644
index 0000000000..7e198c4acc
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/features/environment.py
@@ -0,0 +1,40 @@
+# 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
+#
+# https://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.
+
+import os
+
+from minifi_behave.core.hooks import (
+ add_extension_to_minifi_container,
+ common_after_scenario,
+ common_before_scenario,
+)
+
+
+def before_all(context):
+ dir_path = os.path.dirname(os.path.realpath(__file__))
+ build_path = os.path.normpath(os.path.join(dir_path, "../../../target/release/"))
+ deps_build_path = os.path.normpath(os.path.join(dir_path, "../../../target/release/deps/"))
+ add_extension_to_minifi_container("minifi_enrichment", [build_path, deps_build_path], context)
+
+
+def before_scenario(context, scenario):
+ context.minifi_container_image = "apacheminificpp:minifi_enrichment"
+ common_before_scenario(context, scenario)
+
+
+def after_scenario(context, scenario):
+ common_after_scenario(context, scenario)
diff --git a/minifi_rust/extensions/minifi_enrichment/features/steps/steps.py b/minifi_rust/extensions/minifi_enrichment/features/steps/steps.py
new file mode 100644
index 0000000000..ff5817598b
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/features/steps/steps.py
@@ -0,0 +1,21 @@
+# 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.
+
+from minifi_behave.steps import (
+ checking_steps, # noqa: F401
+ configuration_steps, # noqa: F401
+ core_steps, # noqa: F401
+ flow_building_steps, # noqa: F401
+)
diff --git a/minifi_rust/extensions/minifi_enrichment/src/lib.rs b/minifi_rust/extensions/minifi_enrichment/src/lib.rs
new file mode 100644
index 0000000000..850e662ac4
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/src/lib.rs
@@ -0,0 +1,31 @@
+// 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
+//
+// https://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.
+
+mod processors;
+
+use crate::processors::fork_enrichment::ForkEnrichment;
+use crate::processors::join_enrichment_attributes::JoinEnrichmentAttributes;
+use minifi_native::{ComplexProcessorType, MultiThreaded, SingleThreaded};
+
+minifi_native::declare_minifi_extension!(
+ group_name: "org.apache.nifi.minifi.rust.enrichment",
+ processors: [
+ (ComplexProcessorType, MultiThreaded, ForkEnrichment),
+ (ComplexProcessorType, SingleThreaded, JoinEnrichmentAttributes),
+ ],
+ controllers: []
+);
diff --git a/minifi_rust/extensions/minifi_enrichment/src/processors/attributes.rs b/minifi_rust/extensions/minifi_enrichment/src/processors/attributes.rs
new file mode 100644
index 0000000000..bc5129aae1
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/src/processors/attributes.rs
@@ -0,0 +1,56 @@
+// 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
+//
+// https://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.
+
+use minifi_native::OutputAttribute;
+use strum_macros::{EnumString, IntoStaticStr};
+
+const ENRICHMENT_ROLE: &str = "enrichment.role";
+const ENRICHMENT_GROUP_ID: &str = "enrichment.group.id";
+
+/// The roles ForkEnrichment writes and JoinEnrichmentAttributes pairs on.
+///
+/// `EnumString` parses the attribute back; `IntoStaticStr` writes it, so the wire strings
+/// (`ORIGINAL` / `ENRICHMENT`) have exactly one definition.
+#[derive(Debug, Clone, Copy, PartialEq, EnumString, IntoStaticStr)]
+#[strum(serialize_all = "UPPERCASE", const_into_str)]
+pub(crate) enum Role {
+ Original,
+ Enrichment,
+}
+
+/// The role a joined FlowFile gets. Deliberately not a [`Role`] variant: `Role` is the set of roles
+/// this processor *pairs* on, and a `JOINED` flow file fed back in must not parse as a pairable
+/// half - it should fall through to the `invalid` relationship.
+pub(crate) const JOINED_ROLE: &str = "JOINED";
+
+pub(crate) const FORK_ROLE_ATTR: OutputAttribute = OutputAttribute {
+ name: ENRICHMENT_ROLE,
+ relationships: &["enrichment", "original"],
+ description: "The role to use for enrichment. This will either be ORIGINAL or ENRICHMENT.",
+};
+
+pub(crate) const GROUP_ID_ATTR: OutputAttribute = OutputAttribute {
+ name: ENRICHMENT_GROUP_ID,
+ relationships: &["enrichment", "original"],
+ description: "The Group ID to use in order to correlate the 'original' FlowFile with the 'enrichment' FlowFile.",
+};
+
+pub(crate) const JOIN_ROLE_ATTR: OutputAttribute = OutputAttribute {
+ name: ENRICHMENT_ROLE,
+ relationships: &["joined"],
+ description: "JOINED",
+};
diff --git a/minifi_rust/extensions/minifi_enrichment/src/processors/fork_enrichment.rs b/minifi_rust/extensions/minifi_enrichment/src/processors/fork_enrichment.rs
new file mode 100644
index 0000000000..eb65771d56
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/src/processors/fork_enrichment.rs
@@ -0,0 +1,151 @@
+// 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
+//
+// https://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.
+
+mod fork_enrichment_def;
+
+use crate::processors::attributes::{FORK_ROLE_ATTR, GROUP_ID_ATTR, Role};
+use crate::processors::fork_enrichment::fork_enrichment_def::{ENRICHMENT, ORIGINAL};
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ GetProperty, Logger, MinifiError, OnTriggerResult, ProcessContext, ProcessSession, Schedule,
+ Trigger,
+};
+use uuid::Uuid;
+
+#[derive(ComponentIdentifier)]
+pub(crate) struct ForkEnrichment {}
+
+impl Schedule for ForkEnrichment {
+ fn schedule(
+ _context: &Ctx,
+ _logger: &L,
+ ) -> Result
+ where
+ Self: Sized,
+ {
+ Ok(Self {})
+ }
+}
+
+impl Trigger for ForkEnrichment {
+ fn trigger(
+ &self,
+ _context: &mut Context,
+ session: &mut Session,
+ _logger: &Lggr,
+ ) -> Result
+ where
+ Context: ProcessContext,
+ Session: ProcessSession,
+ Lggr: Logger,
+ {
+ let Some(mut original) = session.get() else {
+ return Ok(OnTriggerResult::Yield);
+ };
+ let mut enrichment = session.clone_ff(&original)?;
+ session.set_attribute(&mut original, FORK_ROLE_ATTR.name, Role::Original.into())?;
+ session.set_attribute(
+ &mut enrichment,
+ FORK_ROLE_ATTR.name,
+ Role::Enrichment.into(),
+ )?;
+
+ let group_id = Uuid::new_v4().to_string();
+ session.set_attribute(&mut original, GROUP_ID_ATTR.name, &group_id)?;
+ session.set_attribute(&mut enrichment, GROUP_ID_ATTR.name, &group_id)?;
+
+ session.transfer(original, ORIGINAL.name)?;
+ session.transfer(enrichment, ENRICHMENT.name)?;
+
+ Ok(OnTriggerResult::Ok)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use minifi_native::{
+ ComponentIdentifier, MockFlowFile, MockLogger, MockProcessContext, MockProcessSession,
+ };
+
+ #[test]
+ fn component_id() {
+ assert_eq!(
+ ForkEnrichment::CLASS_NAME,
+ "minifi_enrichment::processors::fork_enrichment::ForkEnrichment"
+ );
+ assert_eq!(ForkEnrichment::GROUP_NAME, "minifi_enrichment");
+ }
+
+ #[test]
+ fn forks_into_original_and_enrichment_sharing_a_group_id() {
+ let logger = MockLogger::new();
+ let mut context = MockProcessContext::new();
+ let processor = ForkEnrichment::schedule(&context, &logger).unwrap();
+
+ let mut session = MockProcessSession::new();
+ session
+ .input_flow_files
+ .push(MockFlowFile::with_content(b"hello"));
+
+ processor
+ .trigger(&mut context, &mut session, &logger)
+ .expect("trigger should succeed");
+
+ let transferred = session.transferred_flow_files.borrow();
+ assert_eq!(transferred.len(), 2);
+
+ let original = transferred
+ .iter()
+ .find(|t| t.relationship == ORIGINAL.name)
+ .expect("an original FlowFile should be transferred");
+ let enrichment = transferred
+ .iter()
+ .find(|t| t.relationship == ENRICHMENT.name)
+ .expect("an enrichment FlowFile should be transferred");
+
+ assert_eq!(
+ original
+ .flow_file
+ .attributes
+ .get(FORK_ROLE_ATTR.name)
+ .unwrap(),
+ "ORIGINAL"
+ );
+ assert_eq!(
+ enrichment
+ .flow_file
+ .attributes
+ .get(FORK_ROLE_ATTR.name)
+ .unwrap(),
+ "ENRICHMENT"
+ );
+
+ let orig_group = original
+ .flow_file
+ .attributes
+ .get(GROUP_ID_ATTR.name)
+ .unwrap();
+ let enr_group = enrichment
+ .flow_file
+ .attributes
+ .get(GROUP_ID_ATTR.name)
+ .unwrap();
+ assert_eq!(orig_group, enr_group);
+ assert!(!orig_group.is_empty());
+ }
+}
diff --git a/minifi_rust/extensions/minifi_enrichment/src/processors/fork_enrichment/fork_enrichment_def.rs b/minifi_rust/extensions/minifi_enrichment/src/processors/fork_enrichment/fork_enrichment_def.rs
new file mode 100644
index 0000000000..18b2508c08
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/src/processors/fork_enrichment/fork_enrichment_def.rs
@@ -0,0 +1,44 @@
+// 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
+//
+// https://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.
+
+use crate::processors::attributes::{FORK_ROLE_ATTR, GROUP_ID_ATTR};
+use crate::processors::fork_enrichment::ForkEnrichment;
+use minifi_native::{
+ OutputAttribute, ProcessorDefinition, ProcessorInputRequirement, PropertyDefinition,
+ Relationship, property_definitions,
+};
+
+pub(crate) const ORIGINAL: Relationship = Relationship {
+ name: "original",
+ description: "The incoming FlowFile will be routed to this relationship, after adding appropriate attributes.",
+};
+
+pub(crate) const ENRICHMENT: Relationship = Relationship {
+ name: "enrichment",
+ description: "A clone of the incoming FlowFile will be routed to this relationship, after adding appropriate attributes.",
+};
+
+impl ProcessorDefinition for ForkEnrichment {
+ const DESCRIPTION: &'static str = "Used in conjunction with the JoinEnrichmentAttributes processor, this processor is responsible for adding the attributes that are necessary for the JoinEnrichmentAttributes processor to perform its function. Each incoming FlowFile will be cloned. The original FlowFile will have appropriate attributes added and then be transferred to the 'original' relationship. The clone will have appropriate attributes added and then be routed to the 'enrichment' relationship.";
+ const INPUT_REQUIREMENT: ProcessorInputRequirement = ProcessorInputRequirement::Required;
+ const SUPPORTS_DYNAMIC_PROPERTIES: bool = false;
+ const SUPPORTS_DYNAMIC_RELATIONSHIPS: bool = false;
+ const OUTPUT_ATTRIBUTES: &'static [OutputAttribute] = &[FORK_ROLE_ATTR, GROUP_ID_ATTR];
+ const RELATIONSHIPS: &'static [Relationship] = &[ORIGINAL, ENRICHMENT];
+
+ const PROPERTIES: &[PropertyDefinition] = property_definitions![];
+}
diff --git a/minifi_rust/extensions/minifi_enrichment/src/processors/join_enrichment_attributes.rs b/minifi_rust/extensions/minifi_enrichment/src/processors/join_enrichment_attributes.rs
new file mode 100644
index 0000000000..b8a5badd05
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/src/processors/join_enrichment_attributes.rs
@@ -0,0 +1,383 @@
+// 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
+//
+// https://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.
+
+use std::time::{Duration, Instant};
+
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ FlowFileStore, Logger, MinifiError, MutTrigger, OnTriggerResult, ProcessContext,
+ ProcessSession, Schedule, ScheduleContext, warn,
+};
+
+use crate::processors::attributes::{
+ FORK_ROLE_ATTR, GROUP_ID_ATTR, JOIN_ROLE_ATTR, JOINED_ROLE, Role,
+};
+use crate::processors::join_enrichment_attributes::join_enrichment_attributes_def::{
+ BATCH_SIZE, INVALID, JOINED, ORIGINAL, TIMEOUT_PROP, TIMEOUT_REL,
+};
+
+mod join_enrichment_attributes_def;
+
+#[derive(ComponentIdentifier)]
+pub(crate) struct JoinEnrichmentAttributes {
+ batch_size: usize,
+ timeout: Option,
+ pending: FlowFileStore,
+}
+
+fn get_role(
+ session: &Session,
+ flow_file: &Session::FlowFile,
+) -> Option {
+ session
+ .get_attribute(flow_file, FORK_ROLE_ATTR.name)?
+ .parse()
+ .ok()
+}
+
+fn get_role_and_group_id(
+ session: &Session,
+ flow_file: &Session::FlowFile,
+) -> Option<(Role, String)> {
+ let role = get_role(session, flow_file)?;
+ let group_id = session
+ .get_required_attribute(flow_file, GROUP_ID_ATTR.name)
+ .ok()?;
+ Some((role, group_id))
+}
+
+fn join(
+ session: &mut Session,
+ original_ff: Session::FlowFile,
+ enrichment_ff: Session::FlowFile,
+) -> Result<(), MinifiError> {
+ let mut joined_ff = session.clone_ff(&original_ff)?;
+
+ let mut enrichment_attrs: Vec<(String, String)> = Vec::new();
+ session.for_each_attribute(&enrichment_ff, |key, value| {
+ enrichment_attrs.push((key.to_string(), value.to_string()));
+ });
+ for (key, value) in &enrichment_attrs {
+ session.set_attribute(&mut joined_ff, key, value)?;
+ }
+
+ session.set_attribute(&mut joined_ff, JOIN_ROLE_ATTR.name, JOINED_ROLE)?;
+
+ session.transfer(original_ff, ORIGINAL.name)?;
+ session.transfer(enrichment_ff, ORIGINAL.name)?;
+ session.transfer(joined_ff, JOINED.name)?;
+ Ok(())
+}
+
+impl JoinEnrichmentAttributes {
+ fn handle_flow_file(
+ &mut self,
+ incoming: Session::FlowFile,
+ session: &mut Session,
+ role: Role,
+ group_id: String,
+ ) -> Result<(), MinifiError>
+ where
+ Session: ProcessSession,
+ {
+ let Some(pending) = self.pending.take(session, &group_id)? else {
+ return self.pending.store(session, group_id, incoming);
+ };
+
+ match (role, get_role(session, &pending)) {
+ (Role::Original, Some(Role::Enrichment)) => join(session, incoming, pending),
+ (Role::Enrichment, Some(Role::Original)) => join(session, pending, incoming),
+ _ => {
+ session.transfer(incoming, INVALID.name)?;
+ session.transfer(pending, INVALID.name)?;
+ Ok(())
+ }
+ }
+ }
+}
+
+impl Schedule for JoinEnrichmentAttributes {
+ fn schedule(
+ context: &Ctx,
+ _logger: &L,
+ ) -> Result
+ where
+ Self: Sized,
+ {
+ let timeout = context
+ .get_property(&TIMEOUT_PROP)?
+ .filter(|d| !d.is_zero());
+
+ if timeout.is_some() {
+ context.set_trigger_when_empty(true)?;
+ }
+
+ Ok(Self {
+ batch_size: context.get_property(&BATCH_SIZE)?.unwrap_or(usize::MAX),
+ timeout,
+ pending: FlowFileStore::new(),
+ })
+ }
+}
+
+impl JoinEnrichmentAttributes {
+ fn on_trigger(
+ &mut self,
+ session: &mut Session,
+ logger: &Lggr,
+ now: Instant,
+ ) -> Result
+ where
+ Session: ProcessSession,
+ Lggr: Logger,
+ {
+ for _ in 0..self.batch_size {
+ let Some(flow_file) = session.get() else {
+ break;
+ };
+
+ let Some((role, group_id)) = get_role_and_group_id(session, &flow_file) else {
+ warn!(logger, "Missing required attribute");
+ session.transfer(flow_file, INVALID.name)?;
+ continue;
+ };
+
+ self.handle_flow_file(flow_file, session, role, group_id)?;
+ }
+
+ if let Some(timeout) = self.timeout {
+ for timed_out in self.pending.drain_expired(session, now, timeout)? {
+ session.transfer(timed_out, TIMEOUT_REL.name)?;
+ }
+ }
+
+ Ok(OnTriggerResult::Ok)
+ }
+}
+
+impl MutTrigger for JoinEnrichmentAttributes {
+ fn trigger(
+ &mut self,
+ _context: &mut Context,
+ session: &mut Session,
+ logger: &Lggr,
+ ) -> Result
+ where
+ Context: ProcessContext,
+ Session: ProcessSession,
+ Lggr: Logger,
+ {
+ self.on_trigger(session, logger, Instant::now())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use minifi_native::{MockFlowFile, MockLogger, MockProcessContext, MockProcessSession};
+
+ fn flow_file(role: &str, group_id: &str, content: &[u8]) -> MockFlowFile {
+ let mut ff = MockFlowFile::with_content(content);
+ ff.attributes
+ .insert(FORK_ROLE_ATTR.name.to_string(), role.to_string());
+ ff.attributes
+ .insert(GROUP_ID_ATTR.name.to_string(), group_id.to_string());
+ ff
+ }
+
+ fn scheduled() -> (JoinEnrichmentAttributes, MockProcessContext, MockLogger) {
+ let logger = MockLogger::new();
+ let context = MockProcessContext::new();
+ let processor = JoinEnrichmentAttributes::schedule(&context, &logger).unwrap();
+ (processor, context, logger)
+ }
+
+ #[test]
+ fn a_configured_timeout_requests_triggering_on_an_empty_input() {
+ let logger = MockLogger::new();
+ let mut context = MockProcessContext::new();
+ context.properties.insert(TIMEOUT_PROP.name(), "60 s");
+
+ let processor = JoinEnrichmentAttributes::schedule(&context, &logger).unwrap();
+
+ assert!(context.trigger_when_empty.get());
+ assert_eq!(processor.timeout, Some(Duration::from_secs(60)));
+ }
+
+ #[test]
+ fn without_a_timeout_we_do_not_ask_to_be_triggered_on_an_empty_input() {
+ let (processor, context, _logger) = scheduled();
+ assert!(!context.trigger_when_empty.get());
+ assert_eq!(processor.timeout, None);
+ }
+
+ fn with_timeout(timeout: Duration) -> JoinEnrichmentAttributes {
+ JoinEnrichmentAttributes {
+ batch_size: usize::MAX,
+ timeout: Some(timeout),
+ pending: FlowFileStore::new(),
+ }
+ }
+
+ #[test]
+ fn joins_original_and_enrichment_arriving_on_separate_triggers() {
+ let (mut processor, mut context, logger) = scheduled();
+
+ // Trigger 1: only the original arrives -> stored, nothing transferred.
+ let mut session1 = MockProcessSession::new();
+ session1
+ .input_flow_files
+ .push(flow_file("ORIGINAL", "g1", b"original-content"));
+ processor
+ .trigger(&mut context, &mut session1, &logger)
+ .unwrap();
+ assert_eq!(session1.num_of_transferred_flow_files(), 0);
+
+ // Trigger 2: the enrichment arrives (with an extra attribute) -> join.
+ let mut enrichment = flow_file("ENRICHMENT", "g1", b"enrichment-content");
+ enrichment
+ .attributes
+ .insert("enriched".to_string(), "yes".to_string());
+ let mut session2 = MockProcessSession::new();
+ session2.input_flow_files.push(enrichment);
+ processor
+ .trigger(&mut context, &mut session2, &logger)
+ .unwrap();
+
+ let transferred = session2.transferred_flow_files.borrow();
+ assert_eq!(transferred.len(), 3);
+ assert_eq!(
+ transferred
+ .iter()
+ .filter(|t| t.relationship == ORIGINAL.name)
+ .count(),
+ 2
+ );
+
+ let joined = transferred
+ .iter()
+ .find(|t| t.relationship == JOINED.name)
+ .expect("a joined FlowFile should be transferred");
+ // Original's content is preserved.
+ assert!(joined.flow_file.content_eq("original-content"));
+ // Enrichment's attributes are merged in, and the role is set to JOINED.
+ assert_eq!(joined.flow_file.attributes.get("enriched").unwrap(), "yes");
+ assert_eq!(
+ joined
+ .flow_file
+ .attributes
+ .get(JOIN_ROLE_ATTR.name)
+ .unwrap(),
+ "JOINED"
+ );
+ }
+
+ #[test]
+ fn missing_required_attribute_routes_to_invalid() {
+ let (mut processor, mut context, logger) = scheduled();
+
+ let mut session = MockProcessSession::new();
+ session
+ .input_flow_files
+ .push(MockFlowFile::with_content(b"no attributes"));
+ processor
+ .trigger(&mut context, &mut session, &logger)
+ .unwrap();
+
+ let transferred = session.transferred_flow_files.borrow();
+ assert_eq!(transferred.len(), 1);
+ assert_eq!(transferred[0].relationship, INVALID.name);
+ }
+
+ #[test]
+ fn duplicate_role_for_a_group_routes_both_to_invalid() {
+ let (mut processor, mut context, logger) = scheduled();
+
+ // Trigger 1: first original stored.
+ let mut session1 = MockProcessSession::new();
+ session1
+ .input_flow_files
+ .push(flow_file("ORIGINAL", "g1", b"first"));
+ processor
+ .trigger(&mut context, &mut session1, &logger)
+ .unwrap();
+ assert_eq!(session1.num_of_transferred_flow_files(), 0);
+
+ // Trigger 2: a second original for the same group -> both invalid.
+ let mut session2 = MockProcessSession::new();
+ session2
+ .input_flow_files
+ .push(flow_file("ORIGINAL", "g1", b"second"));
+ processor
+ .trigger(&mut context, &mut session2, &logger)
+ .unwrap();
+
+ let transferred = session2.transferred_flow_files.borrow();
+ assert_eq!(transferred.len(), 2);
+ assert!(transferred.iter().all(|t| t.relationship == INVALID.name));
+ }
+
+ #[test]
+ fn unpaired_half_is_routed_to_timeout_after_the_timeout_elapses() {
+ let logger = MockLogger::new();
+ let mut processor = with_timeout(Duration::from_secs(60));
+ let start = Instant::now();
+
+ // Trigger 1: the original arrives and is stored; nothing is emitted yet.
+ let mut session1 = MockProcessSession::new();
+ session1
+ .input_flow_files
+ .push(flow_file("ORIGINAL", "g1", b"lonely"));
+ processor.on_trigger(&mut session1, &logger, start).unwrap();
+ assert_eq!(session1.num_of_transferred_flow_files(), 0);
+
+ // Trigger 2, long past the timeout, with no pair: the stored half is
+ // handed back and routed to `timeout`.
+ let mut session2 = MockProcessSession::new();
+ processor
+ .on_trigger(&mut session2, &logger, start + Duration::from_secs(120))
+ .unwrap();
+
+ let transferred = session2.transferred_flow_files.borrow();
+ assert_eq!(transferred.len(), 1);
+ assert_eq!(transferred[0].relationship, TIMEOUT_REL.name);
+ assert!(transferred[0].flow_file.content_eq("lonely"));
+ assert!(processor.pending.is_empty());
+ }
+
+ #[test]
+ fn half_within_the_timeout_window_is_kept() {
+ let logger = MockLogger::new();
+ let mut processor = with_timeout(Duration::from_secs(60));
+ let start = Instant::now();
+
+ let mut session1 = MockProcessSession::new();
+ session1
+ .input_flow_files
+ .push(flow_file("ORIGINAL", "g1", b"waiting"));
+ processor.on_trigger(&mut session1, &logger, start).unwrap();
+
+ // A later trigger still inside the timeout window: nothing is emitted and
+ // the half is still pending its pair.
+ let mut session2 = MockProcessSession::new();
+ processor
+ .on_trigger(&mut session2, &logger, start + Duration::from_secs(1))
+ .unwrap();
+ assert_eq!(session2.num_of_transferred_flow_files(), 0);
+ assert_eq!(processor.pending.len(), 1);
+ }
+}
diff --git a/minifi_rust/extensions/minifi_enrichment/src/processors/join_enrichment_attributes/join_enrichment_attributes_def.rs b/minifi_rust/extensions/minifi_enrichment/src/processors/join_enrichment_attributes/join_enrichment_attributes_def.rs
new file mode 100644
index 0000000000..add4aa6c57
--- /dev/null
+++ b/minifi_rust/extensions/minifi_enrichment/src/processors/join_enrichment_attributes/join_enrichment_attributes_def.rs
@@ -0,0 +1,65 @@
+// 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
+//
+// https://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.
+
+use crate::processors::attributes::JOIN_ROLE_ATTR;
+use crate::processors::join_enrichment_attributes::JoinEnrichmentAttributes;
+use minifi_native::{
+ OutputAttribute, ProcessorDefinition, ProcessorInputRequirement, Property, PropertyDefinition,
+ Relationship, property_definitions,
+};
+use std::time::Duration;
+
+pub(crate) const INVALID: Relationship = Relationship {
+ name: "invalid",
+ description: "Any FlowFiles without the requisite attributes will be routed here",
+};
+
+pub(crate) const JOINED: Relationship = Relationship {
+ name: "joined",
+ description: "The resultant FlowFile with Records joined together from both the original and enrichment FlowFiles will be routed to this relationship",
+};
+
+pub(crate) const ORIGINAL: Relationship = Relationship {
+ name: "original",
+ description: "Both of the incoming FlowFiles ('original' and 'enrichment') will be routed to this Relationship. I.e., this is the 'original' version of both of these FlowFiles.",
+};
+
+pub(crate) const TIMEOUT_REL: Relationship = Relationship {
+ name: "timeout",
+ description: "If one of the incoming FlowFiles (i.e., the 'original' FlowFile or the 'enrichment' FlowFile) arrives to this Processor but the other does not arrive within the configured Timeout period, the FlowFile that did arrive is routed to this relationship.",
+};
+
+pub(crate) const BATCH_SIZE: Property