This is an automated email from the ASF dual-hosted git repository. martinzink pushed a commit to branch api_1_1_enrichment in repository https://gitbox.apache.org/repos/asf/nifi-minifi-cpp.git
commit 1b89551e16c1ba0bb76139a6efc485d5f0cce646 Author: Martin Zink <[email protected]> AuthorDate: Mon Aug 24 16:01:56 2026 +0200 MINIFICPP-2867 Implement ForkEnrichment/JoinEnrichmentAttributes processors MINIFICPP-2889 minifi_process_session_release/minifi_process_session_add --- .../ubuntu_22_04_clang_arm_manifest.json | 166 ++++++++++ CONTROLLERS.md | 1 - PROCESSORS.md | 56 ++++ cmake/Fetchstduuid.cmake | 34 +++ cmake/MiNiFiOptions.cmake | 1 + .../cpp-extension-lib/include/api/core/FlowFile.h | 6 + .../include/api/core/ProcessSession.h | 6 + .../mocklib/include/MockProcessSession.h | 7 + .../mocklib/src/MockProcessSession.cpp | 14 + .../mocklib/src/mock-minifi-api.cpp | 13 + .../cpp-extension-lib/src/core/ProcessSession.cpp | 20 ++ libminifi/include/core/ProcessSession.h | 3 + libminifi/include/core/Processor.h | 8 + libminifi/include/utils/minifi-api-utils.h | 3 + libminifi/src/core/ProcessSession.cpp | 34 ++- libminifi/src/core/Processor.cpp | 17 ++ libminifi/src/core/extension/ApiVersion.cpp | 2 +- libminifi/src/minifi-api.cpp | 39 +++ libminifi/test/unit/ProcessSessionTests.cpp | 32 ++ minifi-api/include/minifi-api.h | 25 +- minifi-api/include/minifi-cpp/core/FlowFile.h | 7 + .../include/minifi-cpp/core/ProcessSession.h | 3 + minifi-api/minifi-api.def | 3 + .../minifi_enrichment/.cargo/config.toml | 5 + .../extensions/minifi_enrichment/.gitignore | 7 + .../extensions/minifi_enrichment/Cargo.toml | 21 ++ .../minifi_enrichment/features/enrichment.feature | 47 +++ .../features/enrichment_restart.feature | 71 +++++ .../minifi_enrichment/features/environment.py | 40 +++ .../minifi_enrichment/features/steps/steps.py | 21 ++ .../minifi_enrichment/src/lib.rs} | 32 +- .../minifi_enrichment/src/processors/attributes.rs | 30 ++ .../src/processors/fork_enrichment.rs | 132 ++++++++ .../fork_enrichment/fork_enrichment_def.rs | 27 ++ .../src/processors/join_enrichment_attributes.rs | 339 +++++++++++++++++++++ .../join_enrichment_attributes_def.rs | 48 +++ .../minifi_enrichment/src/processors/mod.rs} | 25 +- minifi_rust/minifi_native/src/api.rs | 2 + .../minifi_native/src/api/flow_file_store.rs | 194 ++++++++++++ .../minifi_native/src/api/process_session.rs | 24 ++ .../minifi_native/src/c_ffi/c_ffi_flow_file.rs | 32 +- .../src/c_ffi/c_ffi_process_session.rs | 50 ++- minifi_rust/minifi_native/src/lib.rs | 6 +- .../minifi_native/src/mock/mock_flow_file.rs | 10 + .../minifi_native/src/mock/mock_process_session.rs | 14 + 45 files changed, 1622 insertions(+), 55 deletions(-) diff --git a/.github/references/ubuntu_22_04_clang_arm_manifest.json b/.github/references/ubuntu_22_04_clang_arm_manifest.json index 5f72378a9..d4139e9fb 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' [...] + "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' [...] + "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 28fce43e1..c3ac2fccf 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<br/>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 4c7705b2a..68487069c 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/Fetchstduuid.cmake b/cmake/Fetchstduuid.cmake new file mode 100644 index 000000000..7c13d8e43 --- /dev/null +++ b/cmake/Fetchstduuid.cmake @@ -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. +# + +if (NOT stduuid_FOUND) + set(UUID_SYSTEM_GENERATOR "ON") + set(stduuid_FOUND "YES" CACHE STRING "" FORCE) + set(stduuid_INCLUDE_DIR "${CMAKE_BINARY_DIR}/_deps/stduuid/" CACHE STRING "" FORCE) + if(NOT EXISTS "${stduuid_INCLUDE_DIR}/stduuid/uuid.hpp") + file(DOWNLOAD "https://github.com/mariusbancila/stduuid/releases/download/v1.2.3/uuid.h" "${stduuid_INCLUDE_DIR}/stduuid/uuid.hpp" + EXPECTED_HASH SHA256=8b329afa7e099e632c2e992e02ddb9fc4627c772dfd5fd42b069752ea0f8ec7f) + endif() +endif() + +if(NOT TARGET stduuid::stduuid) + add_library(stduuid::stduuid INTERFACE IMPORTED) + set_target_properties(stduuid::stduuid PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${stduuid_INCLUDE_DIR}") +endif() diff --git a/cmake/MiNiFiOptions.cmake b/cmake/MiNiFiOptions.cmake index 7a898a765..a385c1623 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_EXTENSION_ENRICHMENT "Enables the build of enrichment extension" ON) 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 c2a5fb424..0c953f80c 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<minifi_flow_file, EnsureMovedFromDeleter>; +struct NoopStashedFlowFileDeleter { + void operator()(minifi_stashed_flow_file*) const noexcept {} +}; + +using StashedFlowFile = std::unique_ptr<minifi_stashed_flow_file, NoopStashedFlowFileDeleter>; + } // 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 7212046db..f8bd90081 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 ad3aeb109..e06f9f539 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<minifi_flow_file> 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 6af5178e5..30b53046f 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<minifi_flow_file>(ff.release())); } +api::core::StashedFlowFile MockProcessSession::stash(api::core::FlowFile ff) { + return api::core::StashedFlowFile{new minifi_stashed_flow_file{std::unique_ptr<minifi_flow_file>(ff.release())}}; +} + +api::core::FlowFile MockProcessSession::unstash(api::core::StashedFlowFile stashed_flow_file) { + const std::unique_ptr<minifi_stashed_flow_file> 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<MockOutputStream>(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 5647bfa11..e244ba51c 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 <stdexcept> +#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 41b8be081..0f232e5c8 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/libminifi/include/core/ProcessSession.h b/libminifi/include/core/ProcessSession.h index 72494dd1b..f88402d8d 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<core::FlowFile> &flow) override; + StashedFlowFile stash(std::shared_ptr<core::FlowFile> flow_file) override; + std::shared_ptr<core::FlowFile> unstash(StashedFlowFile stashed_flow_file) override; + std::shared_ptr<io::InputStream> getFlowFileContentStream(const core::FlowFile& flow_file) override; int64_t read(const std::shared_ptr<core::FlowFile>& flow_file, const io::InputStreamCallback& callback) override; diff --git a/libminifi/include/core/Processor.h b/libminifi/include/core/Processor.h index 4f48c0938..9e47a6118 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<void(logging::LOG_LEVEL level, const std::string& message)>& callback); void restore(const std::shared_ptr<FlowFile>& 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<FlowFile> flow_file); + std::shared_ptr<FlowFile> unstashFlowFile(const FlowFile* flow_file); + static constexpr auto DynamicProperties = std::array<DynamicPropertyDefinition, 0>{}; static constexpr auto OutputAttributes = std::array<OutputAttributeReference, 0>{}; @@ -159,6 +164,9 @@ class Processor : public ConnectableImpl, public ConfigurableComponentImpl, publ mutable std::mutex mutex_; std::atomic<std::chrono::steady_clock::time_point> yield_expiration_{}; + // Flow files stashed with this processor (owning), keyed by identity via unstashFlowFile. + std::vector<std::shared_ptr<FlowFile>> stashed_flow_files_; + // must hold the graphMutex void updateReachability(const std::lock_guard<std::mutex>& graph_lock, bool force = false); diff --git a/libminifi/include/utils/minifi-api-utils.h b/libminifi/include/utils/minifi-api-utils.h index f98b60813..54b77bfd7 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::core::logging::Logger>); MINIFI_API_MAP(minifi_flow_file, std::shared_ptr<minifi::core::FlowFile>); +// 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 4352f7b41..c1ca7356e 100644 --- a/libminifi/src/core/ProcessSession.cpp +++ b/libminifi/src/core/ProcessSession.cpp @@ -209,6 +209,35 @@ void ProcessSessionImpl::remove(const std::shared_ptr<core::FlowFile> &flow) { provenance_report_->drop(*flow, reason); } +StashedFlowFile ProcessSessionImpl::stash(std::shared_ptr<core::FlowFile> 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<core::FlowFile> 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<core::FlowFile>& 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 b20004a09..59675699f 100644 --- a/libminifi/src/core/Processor.cpp +++ b/libminifi/src/core/Processor.cpp @@ -543,6 +543,23 @@ void Processor::restore(const std::shared_ptr<FlowFile>& file) { impl_->restore(file); } +void Processor::stashFlowFile(std::shared_ptr<FlowFile> flow_file) { + const std::lock_guard<std::mutex> lock(mutex_); + stashed_flow_files_.push_back(std::move(flow_file)); +} + +std::shared_ptr<FlowFile> Processor::unstashFlowFile(const FlowFile* flow_file) { + const std::lock_guard<std::mutex> 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<Connection*, std::unordered_set<Processor*>>& Processor::reachable_processors() const { return reachable_processors_; } diff --git a/libminifi/src/core/extension/ApiVersion.cpp b/libminifi/src/core/extension/ApiVersion.cpp index e7930fc11..d9d6a91e2 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 0fae27f2b..c7a3b6eee 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<minifi::core::FlowFile>(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<std::shared_ptr<minifi::core::FlowFile>> 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<minifi::core::FlowFile>(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 8c7e99bd6..0bbfc4f0d 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<minifi::core::FlowFile> 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 f9d1fe078..177588833 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, + MINIFI_NULLABLE 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 951829b45..8a026478c 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<FlowFile> 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 5b77d8422..3820e7a0f 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<core::FlowFile> &flow) = 0; + virtual StashedFlowFile stash(std::shared_ptr<core::FlowFile> flow_file) = 0; + virtual std::shared_ptr<core::FlowFile> 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 a0a0b74ad..2670fe8e7 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/extensions/minifi_enrichment/.cargo/config.toml b/minifi_rust/extensions/minifi_enrichment/.cargo/config.toml new file mode 100644 index 000000000..cb8c02ddc --- /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 000000000..32c9b263b --- /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 000000000..71f132fd7 --- /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 000000000..1401abc46 --- /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 000000000..36d329101 --- /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 000000000..7e198c4ac --- /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 000000000..ff5817598 --- /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/minifi_native/src/c_ffi/c_ffi_flow_file.rs b/minifi_rust/extensions/minifi_enrichment/src/lib.rs similarity index 61% copy from minifi_rust/minifi_native/src/c_ffi/c_ffi_flow_file.rs copy to minifi_rust/extensions/minifi_enrichment/src/lib.rs index 580c04626..850e662ac 100644 --- a/minifi_rust/minifi_native/src/c_ffi/c_ffi_flow_file.rs +++ b/minifi_rust/extensions/minifi_enrichment/src/lib.rs @@ -15,25 +15,17 @@ // specific language governing permissions and limitations // under the License. -use crate::api::FlowFile; -use minifi_native_sys::minifi_flow_file; +mod processors; -pub struct CffiFlowFile<'a> { - ptr: *mut minifi_flow_file, - _lifetime: std::marker::PhantomData<&'a ()>, -} +use crate::processors::fork_enrichment::ForkEnrichment; +use crate::processors::join_enrichment_attributes::JoinEnrichmentAttributes; +use minifi_native::{ComplexProcessorType, MultiThreaded, SingleThreaded}; -impl CffiFlowFile<'_> { - pub(crate) fn new(ptr: *mut minifi_flow_file) -> Self { - Self { - ptr, - _lifetime: std::marker::PhantomData, - } - } - - pub(crate) fn get_ptr(&self) -> *mut minifi_flow_file { - self.ptr - } -} - -impl FlowFile for CffiFlowFile<'_> {} +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 000000000..e9149827d --- /dev/null +++ b/minifi_rust/extensions/minifi_enrichment/src/processors/attributes.rs @@ -0,0 +1,30 @@ +use minifi_native::OutputAttribute; +use strum_macros::{Display, EnumString, IntoStaticStr, VariantNames}; + +const ENRICHMENT_ROLE: &str = "enrichment.role"; +const ENRICHMENT_GROUP_ID: &str = "enrichment.group.id"; + +#[derive(Debug, Clone, Copy, PartialEq, Display, EnumString, VariantNames, IntoStaticStr)] +#[strum(serialize_all = "UPPERCASE", const_into_str)] +pub(crate) enum Role { + Original, + Enrichment, +} + +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 000000000..08caaf7d8 --- /dev/null +++ b/minifi_rust/extensions/minifi_enrichment/src/processors/fork_enrichment.rs @@ -0,0 +1,132 @@ +mod fork_enrichment_def; + +use crate::processors::attributes::{FORK_ROLE_ATTR, GROUP_ID_ATTR}; +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<Ctx: GetProperty, L: Logger>( + _context: &Ctx, + _logger: &L, + ) -> Result<Self, MinifiError> + where + Self: Sized, + { + Ok(Self { + }) + } +} + +impl Trigger for ForkEnrichment { + fn trigger<Context, Session, Lggr>( + &self, + _context: &mut Context, + session: &mut Session, + _logger: &Lggr, + ) -> Result<OnTriggerResult, MinifiError> + where + Context: ProcessContext, + Session: ProcessSession<FlowFile = Context::FlowFile>, + 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, "ORIGINAL")?; + session.set_attribute(&mut enrichment, FORK_ROLE_ATTR.name, "ENRICHMENT")?; + + 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 000000000..ed887b94e --- /dev/null +++ b/minifi_rust/extensions/minifi_enrichment/src/processors/fork_enrichment/fork_enrichment_def.rs @@ -0,0 +1,27 @@ +use crate::processors::attributes::{FORK_ROLE_ATTR, GROUP_ID_ATTR}; +use crate::processors::fork_enrichment::ForkEnrichment; +use minifi_native::{ + property_definitions, OutputAttribute, ProcessorDefinition, ProcessorInputRequirement, + PropertyDefinition, Relationship, +}; + +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' r [...] + 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 000000000..defc19a60 --- /dev/null +++ b/minifi_rust/extensions/minifi_enrichment/src/processors/join_enrichment_attributes.rs @@ -0,0 +1,339 @@ +use std::time::{Duration, Instant}; + +use minifi_native::macros::ComponentIdentifier; +use minifi_native::{ + FlowFileStore, GetProperty, Logger, MinifiError, MutTrigger, OnTriggerResult, ProcessContext, + ProcessSession, Schedule, warn, +}; + +use crate::processors::attributes::{FORK_ROLE_ATTR, GROUP_ID_ATTR, JOIN_ROLE_ATTR, 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<Duration>, + pending: FlowFileStore<String>, +} + +fn get_role<Session: ProcessSession>( + session: &Session, + flow_file: &Session::FlowFile, +) -> Option<Role> { + session + .get_attribute(flow_file, FORK_ROLE_ATTR.name)? + .parse() + .ok() +} + +fn get_role_and_group_id<Session: ProcessSession>( + flow_file: &Session::FlowFile, + session: &Session, +) -> 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: ProcessSession>( + 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")?; + + 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<Session>( + &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<Ctx: GetProperty, L: Logger>( + context: &Ctx, + _logger: &L, + ) -> Result<Self, MinifiError> + where + Self: Sized, + { + Ok(Self { + batch_size: context.get_property(&BATCH_SIZE)?.unwrap_or(usize::MAX), + timeout: context + .get_property(&TIMEOUT_PROP)? + .filter(|d| !d.is_zero()), + pending: FlowFileStore::new(), + }) + } +} + +impl JoinEnrichmentAttributes { + fn on_trigger<Session, Lggr>( + &mut self, + session: &mut Session, + logger: &Lggr, + now: Instant, + ) -> Result<OnTriggerResult, MinifiError> + 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(&flow_file, session) 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<Context, Session, Lggr>( + &mut self, + _context: &mut Context, + session: &mut Session, + logger: &Lggr, + ) -> Result<OnTriggerResult, MinifiError> + where + Context: ProcessContext, + Session: ProcessSession<FlowFile = Context::FlowFile>, + 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) + } + + 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 000000000..732dee0af --- /dev/null +++ b/minifi_rust/extensions/minifi_enrichment/src/processors/join_enrichment_attributes/join_enrichment_attributes_def.rs @@ -0,0 +1,48 @@ +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<Option<usize>> = Property::new( + "Batch Size", + "The maximum number of FlowFiles to process in each trigger", +); + +pub(crate) const TIMEOUT_PROP: Property<Option<Duration>> = Property::new( + "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.", +); + +impl ProcessorDefinition for JoinEnrichmentAttributes { + const DESCRIPTION: &'static str = "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)."; + const INPUT_REQUIREMENT: ProcessorInputRequirement = ProcessorInputRequirement::Required; + const SUPPORTS_DYNAMIC_PROPERTIES: bool = false; + const SUPPORTS_DYNAMIC_RELATIONSHIPS: bool = false; + const OUTPUT_ATTRIBUTES: &'static [OutputAttribute] = &[JOIN_ROLE_ATTR]; + const RELATIONSHIPS: &'static [Relationship] = &[INVALID, JOINED, ORIGINAL, TIMEOUT_REL]; + + const PROPERTIES: &[PropertyDefinition] = property_definitions![BATCH_SIZE, TIMEOUT_PROP]; +} diff --git a/minifi_rust/minifi_native/src/c_ffi/c_ffi_flow_file.rs b/minifi_rust/extensions/minifi_enrichment/src/processors/mod.rs similarity index 62% copy from minifi_rust/minifi_native/src/c_ffi/c_ffi_flow_file.rs copy to minifi_rust/extensions/minifi_enrichment/src/processors/mod.rs index 580c04626..c55651fe4 100644 --- a/minifi_rust/minifi_native/src/c_ffi/c_ffi_flow_file.rs +++ b/minifi_rust/extensions/minifi_enrichment/src/processors/mod.rs @@ -15,25 +15,6 @@ // specific language governing permissions and limitations // under the License. -use crate::api::FlowFile; -use minifi_native_sys::minifi_flow_file; - -pub struct CffiFlowFile<'a> { - ptr: *mut minifi_flow_file, - _lifetime: std::marker::PhantomData<&'a ()>, -} - -impl CffiFlowFile<'_> { - pub(crate) fn new(ptr: *mut minifi_flow_file) -> Self { - Self { - ptr, - _lifetime: std::marker::PhantomData, - } - } - - pub(crate) fn get_ptr(&self) -> *mut minifi_flow_file { - self.ptr - } -} - -impl FlowFile for CffiFlowFile<'_> {} +pub(crate) mod attributes; +pub(crate) mod fork_enrichment; +pub(crate) mod join_enrichment_attributes; diff --git a/minifi_rust/minifi_native/src/api.rs b/minifi_rust/minifi_native/src/api.rs index 49f3d88d5..b9be4db56 100644 --- a/minifi_rust/minifi_native/src/api.rs +++ b/minifi_rust/minifi_native/src/api.rs @@ -20,6 +20,7 @@ pub(crate) mod component_definition_traits; pub(crate) mod controller_service; pub(crate) mod errors; mod flow_file; +pub(crate) mod flow_file_store; pub(crate) mod logger; mod process_context; pub(crate) mod process_session; @@ -32,6 +33,7 @@ pub(crate) mod raw_processor; pub(crate) mod relationship; pub use flow_file::{FlowFile, GetId}; +pub use flow_file_store::FlowFileStore; pub use logger::{LogLevel, Logger}; pub use process_context::ProcessContext; pub use process_session::{InputStream, OutputStream, ProcessSession}; diff --git a/minifi_rust/minifi_native/src/api/flow_file_store.rs b/minifi_rust/minifi_native/src/api/flow_file_store.rs new file mode 100644 index 000000000..47d002f64 --- /dev/null +++ b/minifi_rust/minifi_native/src/api/flow_file_store.rs @@ -0,0 +1,194 @@ +// 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::any::Any; +use std::collections::HashMap; +use std::hash::Hash; +use std::time::{Duration, Instant}; + +use crate::MinifiError; +use crate::api::ProcessSession; + +struct StoredEntry { + flow_file: Box<dyn Any + Send>, + stored_at: Instant, +} + +pub struct FlowFileStore<K> { + stored: HashMap<K, StoredEntry>, +} + +impl<K: Eq + Hash> FlowFileStore<K> { + pub fn new() -> Self { + Self { + stored: HashMap::new(), + } + } + + pub fn store<S>( + &mut self, + session: &mut S, + key: K, + flow_file: S::FlowFile, + ) -> Result<(), MinifiError> + where + S: ProcessSession, + { + let stashed = session.stash(flow_file)?; + self.stored.insert( + key, + StoredEntry { + flow_file: Box::new(stashed), + stored_at: Instant::now(), + }, + ); + Ok(()) + } + + pub fn take<S>(&mut self, session: &mut S, key: &K) -> Result<Option<S::FlowFile>, MinifiError> + where + S: ProcessSession, + { + let Some(entry) = self.stored.remove(key) else { + return Ok(None); + }; + Ok(Some(Self::add_back(session, entry)?)) + } + + pub fn drain_expired<S>( + &mut self, + session: &mut S, + now: Instant, + max_age: Duration, + ) -> Result<Vec<S::FlowFile>, MinifiError> + where + S: ProcessSession, + K: Clone, + { + let expired_keys: Vec<K> = self + .stored + .iter() + .filter(|(_, entry)| now.saturating_duration_since(entry.stored_at) > max_age) + .map(|(key, _)| key.clone()) + .collect(); + + let mut expired = Vec::with_capacity(expired_keys.len()); + for key in expired_keys { + let entry = self.stored.remove(&key).expect("key came from the map"); + expired.push(Self::add_back(session, entry)?); + } + Ok(expired) + } + + fn add_back<S>(session: &mut S, entry: StoredEntry) -> Result<S::FlowFile, MinifiError> + where + S: ProcessSession, + { + let stashed = *entry + .flow_file + .downcast::<S::StashedFlowFile>() + .expect("stashed FlowFile type is stable within a processor instance"); + session.unstash(stashed) + } + + pub fn contains(&self, key: &K) -> bool { + self.stored.contains_key(key) + } + + pub fn len(&self) -> usize { + self.stored.len() + } + + pub fn is_empty(&self) -> bool { + self.stored.is_empty() + } +} + +impl<K: Eq + Hash> Default for FlowFileStore<K> { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MockFlowFile; + use crate::mock::MockProcessSession; + + #[test] + fn store_then_take_round_trips_a_flow_file() { + let mut session = MockProcessSession::new(); + let mut store: FlowFileStore<String> = FlowFileStore::new(); + + let mut ff = MockFlowFile::new(); + ff.attributes + .insert("enrichment.group.id".to_string(), "g1".to_string()); + let id = ff.id.clone(); + + store + .store(&mut session, "g1".to_string(), ff) + .expect("store should succeed"); + assert_eq!(store.len(), 1); + assert!(store.contains(&"g1".to_string())); + + let taken = store + .take(&mut session, &"g1".to_string()) + .expect("take should succeed") + .expect("a flow file should be stored under g1"); + assert_eq!(taken.id, id); + assert!(store.is_empty()); + } + + #[test] + fn take_missing_key_returns_none() { + let mut session = MockProcessSession::new(); + let mut store: FlowFileStore<String> = FlowFileStore::new(); + + let result = store + .take(&mut session, &"missing".to_string()) + .expect("take should not error"); + assert!(result.is_none()); + } + + #[test] + fn drain_expired_only_returns_entries_older_than_max_age() { + let mut session = MockProcessSession::new(); + let mut store: FlowFileStore<String> = FlowFileStore::new(); + + store + .store(&mut session, "g1".to_string(), MockFlowFile::new()) + .expect("store should succeed"); + + let max_age = Duration::from_secs(60); + // `now` right after storing: nothing has aged past max_age. + let just_now = Instant::now(); + let drained = store + .drain_expired(&mut session, just_now, max_age) + .expect("drain should succeed"); + assert!(drained.is_empty()); + assert_eq!(store.len(), 1); + + // `now` far in the future: the entry is older than max_age and comes back. + let future = just_now + Duration::from_secs(120); + let drained = store + .drain_expired(&mut session, future, max_age) + .expect("drain should succeed"); + assert_eq!(drained.len(), 1); + assert!(store.is_empty()); + } +} diff --git a/minifi_rust/minifi_native/src/api/process_session.rs b/minifi_rust/minifi_native/src/api/process_session.rs index eb121d3bf..383eb8109 100644 --- a/minifi_rust/minifi_native/src/api/process_session.rs +++ b/minifi_rust/minifi_native/src/api/process_session.rs @@ -31,11 +31,24 @@ pub enum IoState { pub trait ProcessSession { type FlowFile: FlowFile; + /// A handle to a flow file stashed with the session, storable across triggers. + /// + /// Produced by [`stash`](Self::stash) and consumed by [`unstash`](Self::unstash). + /// Unlike [`Self::FlowFile`] (which is bound to the session), this type is + /// `'static` so it can be held between `onTrigger` invocations - see + /// [`FlowFileStore`](crate::FlowFileStore). + type StashedFlowFile: 'static + Send; + fn create(&mut self) -> Result<Self::FlowFile, MinifiError>; fn get(&mut self) -> Option<Self::FlowFile>; + fn clone_ff(&mut self, flow_file: &Self::FlowFile) -> Result<Self::FlowFile, MinifiError>; fn transfer(&self, flow_file: Self::FlowFile, relationship: &str) -> Result<(), MinifiError>; fn remove(&mut self, flow_file: Self::FlowFile) -> Result<(), MinifiError>; + fn stash(&mut self, flow_file: Self::FlowFile) -> Result<Self::StashedFlowFile, MinifiError>; + + fn unstash(&mut self, stashed: Self::StashedFlowFile) -> Result<Self::FlowFile, MinifiError>; + fn set_attribute( &self, flow_file: &mut Self::FlowFile, @@ -66,4 +79,15 @@ pub trait ProcessSession { F: FnOnce(&mut dyn InputStream) -> Result<R, MinifiError>; fn get_flow_file_id(&self, flow_file: &Self::FlowFile) -> Result<String, MinifiError>; + + fn get_required_attribute( + &self, + flow_file: &Self::FlowFile, + attr_key: &str, + ) -> Result<String, MinifiError> { + self.get_attribute(flow_file, attr_key) + .ok_or(MinifiError::MissingRequiredAttribute( + attr_key.to_owned().into(), + )) + } } diff --git a/minifi_rust/minifi_native/src/c_ffi/c_ffi_flow_file.rs b/minifi_rust/minifi_native/src/c_ffi/c_ffi_flow_file.rs index 580c04626..9b1a9b012 100644 --- a/minifi_rust/minifi_native/src/c_ffi/c_ffi_flow_file.rs +++ b/minifi_rust/minifi_native/src/c_ffi/c_ffi_flow_file.rs @@ -16,7 +16,7 @@ // under the License. use crate::api::FlowFile; -use minifi_native_sys::minifi_flow_file; +use minifi_native_sys::{minifi_flow_file, minifi_stashed_flow_file}; pub struct CffiFlowFile<'a> { ptr: *mut minifi_flow_file, @@ -37,3 +37,33 @@ impl CffiFlowFile<'_> { } impl FlowFile for CffiFlowFile<'_> {} + +/// A handle to a flow file that has been stashed with a session and can be kept +/// across `onTrigger` invocations (see [`ProcessSession::stash`]). +/// +/// Unlike [`CffiFlowFile`] it carries no session lifetime, so it is `'static`. +/// The stashed flow file itself is owned by the processor/session on the minifi +/// side; this is only a token used to [`unstash`](crate::ProcessSession::unstash) +/// it again. There is deliberately no explicit free: a token dropped without +/// being unstashed simply leaves the stashed flow file in the processor, which +/// minifi reclaims when the processor is torn down. +/// +/// [`ProcessSession::stash`]: crate::ProcessSession::stash +pub struct CffiStashedFlowFile { + ptr: *mut minifi_stashed_flow_file, +} + +// SAFETY: the handle is only ever touched from the single processor instance +// that stashed it; the stashed flow file lives on the minifi side, not behind +// this pointer. +unsafe impl Send for CffiStashedFlowFile {} + +impl CffiStashedFlowFile { + pub(crate) fn new(ptr: *mut minifi_stashed_flow_file) -> Self { + Self { ptr } + } + + pub(crate) fn get_ptr(&self) -> *mut minifi_stashed_flow_file { + self.ptr + } +} diff --git a/minifi_rust/minifi_native/src/c_ffi/c_ffi_process_session.rs b/minifi_rust/minifi_native/src/c_ffi/c_ffi_process_session.rs index 9bed92bd6..c1b4e2b69 100644 --- a/minifi_rust/minifi_native/src/c_ffi/c_ffi_process_session.rs +++ b/minifi_rust/minifi_native/src/c_ffi/c_ffi_process_session.rs @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -use super::c_ffi_flow_file::CffiFlowFile; +use super::c_ffi_flow_file::{CffiFlowFile, CffiStashedFlowFile}; use crate::MinifiError; use crate::api::process_session::{IoState, OutputStream}; use crate::api::{InputStream, ProcessSession}; @@ -24,12 +24,14 @@ use crate::c_ffi::c_ffi_streams::{CffiInputStream, CffiOutputStream}; use minifi_native_sys::{ minifi_input_stream, minifi_input_stream_read, minifi_input_stream_size, minifi_io_status_MINIFI_IO_CANCEL, minifi_io_status_MINIFI_IO_ERROR, minifi_output_stream, - minifi_output_stream_write, minifi_process_session, minifi_process_session_create, - minifi_process_session_get, minifi_process_session_get_flow_file_attribute, + minifi_output_stream_write, minifi_process_session, minifi_process_session_clone, + minifi_process_session_create, minifi_process_session_get, + minifi_process_session_get_flow_file_attribute, minifi_process_session_get_flow_file_attributes, minifi_process_session_get_flow_file_id, minifi_process_session_read, minifi_process_session_remove, - minifi_process_session_set_flow_file_attribute, minifi_process_session_transfer, - minifi_process_session_write, minifi_status_MINIFI_STATUS_SUCCESS, minifi_string_view, + minifi_process_session_set_flow_file_attribute, minifi_process_session_stash, + minifi_process_session_transfer, minifi_process_session_unstash, minifi_process_session_write, + minifi_stashed_flow_file, minifi_status_MINIFI_STATUS_SUCCESS, minifi_string_view, }; use std::ffi::{CString, c_void}; use std::io::Read; @@ -163,6 +165,7 @@ impl<'a> CffiProcessSession<'a> { impl<'a> ProcessSession for CffiProcessSession<'a> { type FlowFile = CffiFlowFile<'a>; // FlowFile shouldn't outlive the Session + type StashedFlowFile = CffiStashedFlowFile; // detached token, storable across triggers fn create(&mut self) -> Result<Self::FlowFile, MinifiError> { let ff_ptr = unsafe { minifi_process_session_create(self.ptr, std::ptr::null_mut()) }; @@ -182,6 +185,15 @@ impl<'a> ProcessSession for CffiProcessSession<'a> { } } + fn clone_ff(&mut self, flow_file: &Self::FlowFile) -> Result<Self::FlowFile, MinifiError> { + let ff_ptr = unsafe { minifi_process_session_clone(self.ptr, flow_file.get_ptr()) }; + if ff_ptr.is_null() { + Err(MinifiError::UnknownError) + } else { + Ok(CffiFlowFile::new(ff_ptr)) + } + } + fn transfer(&self, flow_file: Self::FlowFile, relationship: &str) -> Result<(), MinifiError> { let c_relationship = CString::new(relationship)?; unsafe { @@ -216,6 +228,34 @@ impl<'a> ProcessSession for CffiProcessSession<'a> { } } + fn stash(&mut self, flow_file: Self::FlowFile) -> Result<Self::StashedFlowFile, MinifiError> { + let mut stashed: *mut minifi_stashed_flow_file = std::ptr::null_mut(); + unsafe { + match minifi_process_session_stash(self.ptr, flow_file.get_ptr(), &mut stashed) { + #[allow(non_upper_case_globals)] + minifi_status_MINIFI_STATUS_SUCCESS => Ok(CffiStashedFlowFile::new(stashed)), + err_code => Err(MinifiError::StatusError(( + "minifi_process_session_stash".into(), + NonZeroU32::new_unchecked(err_code), // SAFETY we checked against 0 (minifi_status_MINIFI_STATUS_SUCCESS) + ))), + } + } + } + + fn unstash(&mut self, stashed: Self::StashedFlowFile) -> Result<Self::FlowFile, MinifiError> { + let mut flow_file: *mut minifi_native_sys::minifi_flow_file = std::ptr::null_mut(); + unsafe { + match minifi_process_session_unstash(self.ptr, stashed.get_ptr(), &mut flow_file) { + #[allow(non_upper_case_globals)] + minifi_status_MINIFI_STATUS_SUCCESS => Ok(CffiFlowFile::new(flow_file)), + err_code => Err(MinifiError::StatusError(( + "minifi_process_session_unstash".into(), + NonZeroU32::new_unchecked(err_code), // SAFETY we checked against 0 (minifi_status_MINIFI_STATUS_SUCCESS) + ))), + } + } + } + fn set_attribute( &self, flow_file: &mut Self::FlowFile, diff --git a/minifi_rust/minifi_native/src/lib.rs b/minifi_rust/minifi_native/src/lib.rs index bbef73995..7b0b242ff 100644 --- a/minifi_rust/minifi_native/src/lib.rs +++ b/minifi_rust/minifi_native/src/lib.rs @@ -58,9 +58,9 @@ pub use api::process_session::IoState; pub use api::attribute::{GetAttribute, OutputAttribute}; pub use api::{ - DataSize, FlowFile, GetId, InputStream, NonBlankPath, OnTriggerResult, OutputStream, - ProcessContext, ProcessSession, ProcessorInputRequirement, PropertyConstraints, PropertyType, - Relationship, StandardPropertyValidator, + DataSize, FlowFile, FlowFileStore, GetId, InputStream, NonBlankPath, OnTriggerResult, + OutputStream, ProcessContext, ProcessSession, ProcessorInputRequirement, PropertyConstraints, + PropertyType, Relationship, StandardPropertyValidator, }; pub use minifi_native_macros as macros; diff --git a/minifi_rust/minifi_native/src/mock/mock_flow_file.rs b/minifi_rust/minifi_native/src/mock/mock_flow_file.rs index c998335a2..8abd11de2 100644 --- a/minifi_rust/minifi_native/src/mock/mock_flow_file.rs +++ b/minifi_rust/minifi_native/src/mock/mock_flow_file.rs @@ -57,6 +57,16 @@ impl MockFlowFile { } } + /// A deep copy with the same content and attributes but a fresh id, + /// mirroring `ProcessSession::clone_ff`. + pub fn deep_clone(&self) -> MockFlowFile { + Self { + content: RefCell::new(self.content.borrow().clone()), + attributes: self.attributes.clone(), + id: next_mock_flow_file_id(), + } + } + pub fn content_len(&self) -> usize { self.content.borrow().len() } diff --git a/minifi_rust/minifi_native/src/mock/mock_process_session.rs b/minifi_rust/minifi_native/src/mock/mock_process_session.rs index b43f582cf..29ac4d394 100644 --- a/minifi_rust/minifi_native/src/mock/mock_process_session.rs +++ b/minifi_rust/minifi_native/src/mock/mock_process_session.rs @@ -34,6 +34,7 @@ pub struct MockProcessSession { impl ProcessSession for MockProcessSession { type FlowFile = MockFlowFile; + type StashedFlowFile = MockFlowFile; fn create(&mut self) -> Result<Self::FlowFile, MinifiError> { Ok(Self::FlowFile::new()) @@ -41,6 +42,11 @@ impl ProcessSession for MockProcessSession { fn get(&mut self) -> Option<Self::FlowFile> { self.input_flow_files.pop() } + + fn clone_ff(&mut self, flow_file: &Self::FlowFile) -> Result<Self::FlowFile, MinifiError> { + Ok(flow_file.deep_clone()) + } + fn transfer(&self, flow_file: Self::FlowFile, relationship: &str) -> Result<(), MinifiError> { self.transferred_flow_files .borrow_mut() @@ -55,6 +61,14 @@ impl ProcessSession for MockProcessSession { Ok(()) } + fn stash(&mut self, flow_file: Self::FlowFile) -> Result<Self::StashedFlowFile, MinifiError> { + Ok(flow_file) + } + + fn unstash(&mut self, stashed: Self::StashedFlowFile) -> Result<Self::FlowFile, MinifiError> { + Ok(stashed) + } + fn set_attribute( &self, flow_file: &mut Self::FlowFile,
