This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 67eb01f4da Fixes #8704 : Error and execution lineage with Neo4j
logging and execution information (#8718)
67eb01f4da is described below
commit 67eb01f4da2d9785748c0bffcd158ca2c9cd41c7
Author: Bart Maertens <[email protected]>
AuthorDate: Mon Oct 5 21:10:22 2026 +0200
Fixes #8704 : Error and execution lineage with Neo4j logging and execution
information (#8718)
- Read registration dates written as a string by Neo4j logging, which made
the Error lineage tab fail with "Cannot coerce STRING to LocalDateTime".
- Only follow Execution nodes of the execution information location in the
Error lineage and Execution lineage tabs. Neo4j logging writes nodes with
the same IDs, which showed every path more than once.
- Find the root of the Execution lineage by its missing parent relationship
instead of a missing parentId. Neo4j logging nodes have no parentId, so an
execution could become its own root, which Neo4j 5 rejects in
shortestPath.
- Register workflow actions with a name. The Neo4j location rejected the
nameless registration, so actions and their data never reached the graph
and the workflow wasn't connected to the pipelines its actions ran.
Adds unit tests, a Neo4j container IT (NeoLoggingLineageIT) and a Hop
integration test, neo4j-error-lineage-8704.
---
.../engines/local/LocalWorkflowEngine.java | 1 +
.../neo4j/datasets/neo4j-error-lineage-8704.csv | 2 +
.../metadata/dataset/neo4j-error-lineage-8704.json | 16 ++
.../metadata/execution-info-location/neo4j.json | 13 +
.../pipeline-run-configuration/local-neo4j.json | 21 ++
.../neo4j-error-lineage-8704-validation.json | 28 +++
.../workflow-run-configuration/local-neo4j.json | 12 +
.../error-lineage-8704-failing.hpl | 94 ++++++++
.../error-lineage-8704-run.hwf | 89 +++++++
.../main-neo4j-error-lineage-8704.hwf | 187 ++++++++++++++
.../neo4j-error-lineage-8704-validation.hpl | 149 ++++++++++++
.../path/base/NeoExecutionViewerTabBase.java | 23 +-
.../apache/hop/neo4j/logging/util/LoggingCore.java | 31 ++-
.../path/NeoExecutionViewerTabBaseTest.java | 20 ++
.../neo4j/execution/path/NeoLoggingLineageIT.java | 268 +++++++++++++++++++++
.../hop/neo4j/logging/util/LoggingCoreTest.java | 94 ++++++++
16 files changed, 1043 insertions(+), 5 deletions(-)
diff --git
a/engine/src/main/java/org/apache/hop/workflow/engines/local/LocalWorkflowEngine.java
b/engine/src/main/java/org/apache/hop/workflow/engines/local/LocalWorkflowEngine.java
index 84def4818c..16d133dae7 100644
---
a/engine/src/main/java/org/apache/hop/workflow/engines/local/LocalWorkflowEngine.java
+++
b/engine/src/main/java/org/apache/hop/workflow/engines/local/LocalWorkflowEngine.java
@@ -467,6 +467,7 @@ public class LocalWorkflowEngine extends Workflow
implements IWorkflowEngine<Wor
iLocation.registerExecution(
ExecutionBuilder.of()
.withId(action.getLogChannel().getLogChannelId())
+ .withName(actionMeta.getName())
.withParentId(workflow.getLogChannelId())
.withExecutionStartDate(new Date())
.withRegistrationDate(new Date())
diff --git a/integration-tests/neo4j/datasets/neo4j-error-lineage-8704.csv
b/integration-tests/neo4j/datasets/neo4j-error-lineage-8704.csv
new file mode 100644
index 0000000000..de5b557f3e
--- /dev/null
+++ b/integration-tests/neo4j/datasets/neo4j-error-lineage-8704.csv
@@ -0,0 +1,2 @@
+errorLineage
+error-lineage-8704-run > error-lineage-8704-failing.hpl >
error-lineage-8704-failing > Fail on purpose
diff --git
a/integration-tests/neo4j/metadata/dataset/neo4j-error-lineage-8704.json
b/integration-tests/neo4j/metadata/dataset/neo4j-error-lineage-8704.json
new file mode 100644
index 0000000000..e45a7193a8
--- /dev/null
+++ b/integration-tests/neo4j/metadata/dataset/neo4j-error-lineage-8704.json
@@ -0,0 +1,16 @@
+{
+ "base_filename": "neo4j-error-lineage-8704.csv",
+ "name": "neo4j-error-lineage-8704",
+ "description": "The error lineage of a failed run, as written by the Neo4j
execution information location",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": 0,
+ "field_format": "",
+ "field_name": "errorLineage"
+ }
+ ],
+ "folder_name": ""
+}
diff --git
a/integration-tests/neo4j/metadata/execution-info-location/neo4j.json
b/integration-tests/neo4j/metadata/execution-info-location/neo4j.json
new file mode 100644
index 0000000000..6fcc876dcd
--- /dev/null
+++ b/integration-tests/neo4j/metadata/execution-info-location/neo4j.json
@@ -0,0 +1,13 @@
+{
+ "executionInfoLocation": {
+ "neo4j-location": {
+ "pluginName": "Neo4j location",
+ "pluginId": "neo4j-location",
+ "connection": "Test"
+ }
+ },
+ "dataLoggingDelay": "2000",
+ "name": "neo4j",
+ "description": "Writes execution information to the Test Neo4j connection",
+ "dataLoggingInterval": "5000"
+}
diff --git
a/integration-tests/neo4j/metadata/pipeline-run-configuration/local-neo4j.json
b/integration-tests/neo4j/metadata/pipeline-run-configuration/local-neo4j.json
new file mode 100644
index 0000000000..723b95065c
--- /dev/null
+++
b/integration-tests/neo4j/metadata/pipeline-run-configuration/local-neo4j.json
@@ -0,0 +1,21 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "feedback_size": "50000",
+ "sample_size": "100",
+ "sample_type_in_gui": "Last",
+ "wait_time": "20",
+ "rowset_size": "10000",
+ "safe_mode": false,
+ "show_feedback": false,
+ "topo_sort": false,
+ "gather_metrics": false,
+ "transactional": false
+ }
+ },
+ "defaultSelection": false,
+ "configurationVariables": [],
+ "name": "local-neo4j",
+ "description": "Runs pipelines locally and writes execution information to
Neo4j",
+ "executionInfoLocationName": "neo4j"
+}
diff --git
a/integration-tests/neo4j/metadata/unit-test/neo4j-error-lineage-8704-validation.json
b/integration-tests/neo4j/metadata/unit-test/neo4j-error-lineage-8704-validation.json
new file mode 100644
index 0000000000..7b2bcf55ed
--- /dev/null
+++
b/integration-tests/neo4j/metadata/unit-test/neo4j-error-lineage-8704-validation.json
@@ -0,0 +1,28 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "errorLineage",
+ "data_set_field": "errorLineage"
+ }
+ ],
+ "field_order": [
+ "errorLineage"
+ ],
+ "transform_name": "ERROR LINEAGE",
+ "data_set_name": "neo4j-error-lineage-8704"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "neo4j-error-lineage-8704-validation",
+ "description": "Verifies the Neo4j execution information location connects a
workflow to the pipelines its actions run, see issue #8704",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename":
"./tests/neo4j-error-lineage-8704/neo4j-error-lineage-8704-validation.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git
a/integration-tests/neo4j/metadata/workflow-run-configuration/local-neo4j.json
b/integration-tests/neo4j/metadata/workflow-run-configuration/local-neo4j.json
new file mode 100644
index 0000000000..96c8e21843
--- /dev/null
+++
b/integration-tests/neo4j/metadata/workflow-run-configuration/local-neo4j.json
@@ -0,0 +1,12 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "safe_mode": false,
+ "transactional": false
+ }
+ },
+ "defaultSelection": false,
+ "name": "local-neo4j",
+ "description": "Runs workflows locally and writes execution information to
Neo4j",
+ "executionInfoLocationName": "neo4j"
+}
diff --git
a/integration-tests/neo4j/tests/neo4j-error-lineage-8704/error-lineage-8704-failing.hpl
b/integration-tests/neo4j/tests/neo4j-error-lineage-8704/error-lineage-8704-failing.hpl
new file mode 100644
index 0000000000..13888436e2
--- /dev/null
+++
b/integration-tests/neo4j/tests/neo4j-error-lineage-8704/error-lineage-8704-failing.hpl
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+ <info>
+ <name>error-lineage-8704-failing</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>A pipeline which fails on purpose, see issue
#8704</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/10/01 10:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/01 10:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>1 row</from>
+ <to>Fail on purpose</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>1 row</name>
+ <type>RowGenerator</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ </fields>
+ <limit>1</limit>
+ <never_ending>N</never_ending>
+ <interval_in_ms>5000</interval_in_ms>
+ <row_time_field>now</row_time_field>
+ <last_time_field>FiveSecondsAgo</last_time_field>
+ <attributes/>
+ <GUI>
+ <xloc>144</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Fail on purpose</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>320</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git
a/integration-tests/neo4j/tests/neo4j-error-lineage-8704/error-lineage-8704-run.hwf
b/integration-tests/neo4j/tests/neo4j-error-lineage-8704/error-lineage-8704-run.hwf
new file mode 100644
index 0000000000..842c524571
--- /dev/null
+++
b/integration-tests/neo4j/tests/neo4j-error-lineage-8704/error-lineage-8704-run.hwf
@@ -0,0 +1,89 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>error-lineage-8704-run</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Runs a failing pipeline so the Neo4j execution information
location records an error lineage, see issue #8704</description>
+ <extended_description/>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/10/01 10:00:00.000</created_date>
+ <modified_date>2026/10/01 10:00:00.000</modified_date>
+ <workflow_version/>
+ <parameters/>
+ <actions>
+ <action>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <DayOfMonth>1</DayOfMonth>
+ <weekDay>1</weekDay>
+ <minutes>0</minutes>
+ <hour>12</hour>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <xloc>96</xloc>
+ <yloc>128</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>error-lineage-8704-failing.hpl</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <clear_files>N</clear_files>
+ <clear_rows>N</clear_rows>
+ <create_parent_folder>N</create_parent_folder>
+ <exec_per_row>N</exec_per_row>
+
<filename>${PROJECT_HOME}/tests/neo4j-error-lineage-8704/error-lineage-8704-failing.hpl</filename>
+ <loglevel>Basic</loglevel>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <params_from_previous>N</params_from_previous>
+ <run_configuration>local-neo4j</run_configuration>
+ <set_append_logfile>N</set_append_logfile>
+ <set_logfile>N</set_logfile>
+ <wait_until_finished>Y</wait_until_finished>
+ <parallel>N</parallel>
+ <xloc>320</xloc>
+ <yloc>128</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>error-lineage-8704-failing.hpl</to>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ </hops>
+ <notepads/>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/neo4j/tests/neo4j-error-lineage-8704/main-neo4j-error-lineage-8704.hwf
b/integration-tests/neo4j/tests/neo4j-error-lineage-8704/main-neo4j-error-lineage-8704.hwf
new file mode 100644
index 0000000000..cd266f6f42
--- /dev/null
+++
b/integration-tests/neo4j/tests/neo4j-error-lineage-8704/main-neo4j-error-lineage-8704.hwf
@@ -0,0 +1,187 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>main-neo4j-error-lineage-8704</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Runs a failing pipeline with the Neo4j execution information
location and checks the error lineage, see issue #8704.</description>
+ <extended_description>Until issue #8704 the Neo4j execution information
location rejected the first registration of
+every workflow action because it had no name. The action never made it into
the graph, so the pipeline
+it executed wasn't connected to the workflow and the Error lineage tab of the
workflow stayed empty.
+
+The test leaves the execution graph behind, so the Execution information
perspective can be checked in
+Hop GUI afterwards. The next run starts by cleaning the
database.</extended_description>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/10/01 10:00:00.000</created_date>
+ <modified_date>2026/10/01 10:00:00.000</modified_date>
+ <workflow_version/>
+ <parameters/>
+ <actions>
+ <action>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <DayOfMonth>1</DayOfMonth>
+ <weekDay>1</weekDay>
+ <minutes>0</minutes>
+ <hour>12</hour>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <xloc>96</xloc>
+ <yloc>128</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <connections>
+ <connection>Test</connection>
+ </connections>
+ <name>Check Neo4j Connections</name>
+ <description/>
+ <type>NEO4J_CHECK_CONNECTIONS</type>
+ <attributes/>
+ <xloc>224</xloc>
+ <yloc>128</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <filename>${PROJECT_HOME}/tests/shared/cleanup-database.hwf</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Nothing</loglevel>
+ <set_append_logfile>N</set_append_logfile>
+ <create_parent_folder>N</create_parent_folder>
+ <wait_until_finished>Y</wait_until_finished>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <run_configuration>local</run_configuration>
+ <name>cleanup-database.hwf</name>
+ <description/>
+ <type>WORKFLOW</type>
+ <attributes/>
+ <xloc>384</xloc>
+ <yloc>128</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+
<filename>${PROJECT_HOME}/tests/neo4j-error-lineage-8704/error-lineage-8704-run.hwf</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <logfile/>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <set_append_logfile>N</set_append_logfile>
+ <create_parent_folder>N</create_parent_folder>
+ <wait_until_finished>Y</wait_until_finished>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <run_configuration>local-neo4j</run_configuration>
+ <name>error-lineage-8704-run.hwf</name>
+ <description>Fails on purpose, writing execution information to
Neo4j</description>
+ <type>WORKFLOW</type>
+ <attributes/>
+ <xloc>576</xloc>
+ <yloc>128</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Expected a failure</name>
+ <description/>
+ <type>ABORT</type>
+ <attributes/>
+ <always_log_rows>N</always_log_rows>
+ <parallel>N</parallel>
+ <xloc>576</xloc>
+ <yloc>272</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <test_names>
+ <test_name>
+ <name>neo4j-error-lineage-8704-validation</name>
+ </test_name>
+ </test_names>
+ <name>Run neo4j-error-lineage-8704 Tests</name>
+ <description/>
+ <type>RunPipelineTests</type>
+ <attributes/>
+ <xloc>800</xloc>
+ <yloc>128</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Check Neo4j Connections</to>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Check Neo4j Connections</from>
+ <to>cleanup-database.hwf</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>cleanup-database.hwf</from>
+ <to>error-lineage-8704-run.hwf</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>error-lineage-8704-run.hwf</from>
+ <to>Expected a failure</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>error-lineage-8704-run.hwf</from>
+ <to>Run neo4j-error-lineage-8704 Tests</to>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ </hops>
+ <notepads/>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/neo4j/tests/neo4j-error-lineage-8704/neo4j-error-lineage-8704-validation.hpl
b/integration-tests/neo4j/tests/neo4j-error-lineage-8704/neo4j-error-lineage-8704-validation.hpl
new file mode 100644
index 0000000000..bf2ca5997b
--- /dev/null
+++
b/integration-tests/neo4j/tests/neo4j-error-lineage-8704/neo4j-error-lineage-8704-validation.hpl
@@ -0,0 +1,149 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+ <info>
+ <name>neo4j-error-lineage-8704-validation</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Reads the error lineage of a failed run from the Neo4j
execution information location, see issue #8704</description>
+ <extended_description>The WHERE clause is the one from
NeoExecutionViewerTabBase.buildPathToFailedCypher(), used by the
+Error lineage tab of the Execution information perspective. The variable
length pattern is bounded to 20
+hops so Neo4j doesn't return an UnboundedVariableLengthPattern notification,
which the Cypher transform
+reports as an error.
+
+Until issue #8704 the Neo4j location rejected the first registration of every
workflow action, because
+it had no name. The action never made it into the graph, so the pipeline it
ran wasn't connected to the
+workflow and the error lineage of the workflow was
empty.</extended_description>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/10/01 10:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/01 10:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>1 row</from>
+ <to>Look up error lineage</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Look up error lineage</from>
+ <to>ERROR LINEAGE</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>1 row</name>
+ <type>RowGenerator</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ </fields>
+ <limit>1</limit>
+ <never_ending>N</never_ending>
+ <interval_in_ms>5000</interval_in_ms>
+ <row_time_field>now</row_time_field>
+ <last_time_field>FiveSecondsAgo</last_time_field>
+ <attributes/>
+ <GUI>
+ <xloc>144</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Look up error lineage</name>
+ <type>Neo4jCypherOutput</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <connection>Test</connection>
+ <cypher>MATCH (top:Execution { name : 'error-lineage-8704-run',
executionType : 'Workflow' })
+MATCH p = shortestPath((top)-[:EXECUTES*1..20]->(child:Execution))
+WHERE child.failed = true
+ AND child.id <> top.id
+ AND NOT (child)-[:EXECUTES]->()
+ AND all(n IN nodes(p) WHERE n.type IS NULL)
+RETURN reduce(lineage = '', n IN nodes(p) | lineage + CASE WHEN lineage = ''
THEN '' ELSE ' > ' END + n.name) AS errorLineage
+ORDER BY size(RELATIONSHIPS(p))
+LIMIT 10</cypher>
+ <batch_size>1</batch_size>
+ <read_only>Y</read_only>
+ <nr_retries_on_error>5</nr_retries_on_error>
+ <retry>Y</retry>
+ <cypher_from_field>N</cypher_from_field>
+ <cypher_field/>
+ <unwind>N</unwind>
+ <unwind_map/>
+ <returning_graph>N</returning_graph>
+ <return_graph_field/>
+ <mappings>
+ </mappings>
+ <returns>
+ <return>
+ <name>errorLineage</name>
+ <type>String</type>
+ <source_type>String</source_type>
+ </return>
+ </returns>
+ <attributes/>
+ <GUI>
+ <xloc>352</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>ERROR LINEAGE</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>560</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/path/base/NeoExecutionViewerTabBase.java
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/path/base/NeoExecutionViewerTabBase.java
index a2ff9d85bf..13fec12673 100644
---
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/path/base/NeoExecutionViewerTabBase.java
+++
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/path/base/NeoExecutionViewerTabBase.java
@@ -70,10 +70,23 @@ public abstract class NeoExecutionViewerTabBase {
return
buildPathToRootCypher(StringUtils.isNotEmpty(viewer.getExecution().getParentId()));
}
+ /**
+ * Neo4j logging (NEO4J_LOGGING_CONNECTION) writes Execution nodes with the
same IDs as the
+ * execution information location, so the same execution can show up twice
in a path. Neo4j
+ * logging always sets a type property and the execution information
location never does, so this
+ * keeps a path to the nodes of the execution information location.
+ */
+ public static final String EXECUTION_INFORMATION_NODES_ONLY =
+ "all(n IN nodes(p) WHERE n.type IS NULL) ";
+
/**
* Cypher that walks from a child execution to the root parent using
directed EXECUTES
* relationships. The cartesian {@code MATCH (top:Execution)} form is
avoided because it does not
* scale on a busy logging graph and is a common timeout on Neo4j 5.
+ *
+ * <p>The root is the execution nothing executes, not the one without a
parentId: Neo4j logging
+ * writes Execution nodes with the same ID but without a parentId, so the
child itself could be
+ * picked as root, and Neo4j 5 rejects a shortestPath that starts and ends
on the same node.
*/
public static String buildPathToRootCypher(boolean hasParent) {
if (!hasParent) {
@@ -81,9 +94,12 @@ public abstract class NeoExecutionViewerTabBase {
}
return "MATCH (child:Execution {id: $executionId }) "
+ Const.CR
- + "MATCH p = shortestPath((top:Execution)-[:EXECUTES*]->(child)) "
+ + "MATCH p = (top:Execution)-[:EXECUTES*]->(child) "
+ + Const.CR
+ + "WHERE NOT ()-[:EXECUTES]->(top) "
+ Const.CR
- + "WHERE top.parentId IS NULL "
+ + "AND "
+ + EXECUTION_INFORMATION_NODES_ONLY
+ Const.CR
+ "RETURN p "
+ Const.CR
@@ -112,6 +128,9 @@ public abstract class NeoExecutionViewerTabBase {
+ Const.CR
+ "AND NOT (child)-[:EXECUTES]->() "
+ Const.CR
+ + "AND "
+ + EXECUTION_INFORMATION_NODES_ONLY
+ + Const.CR
+ "RETURN p "
+ Const.CR
+ "ORDER BY size(RELATIONSHIPS(p)) "
diff --git
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/logging/util/LoggingCore.java
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/logging/util/LoggingCore.java
index 2091eb82e5..9f42148384 100644
---
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/logging/util/LoggingCore.java
+++
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/logging/util/LoggingCore.java
@@ -20,6 +20,8 @@ package org.apache.hop.neo4j.logging.util;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
@@ -50,6 +52,9 @@ import org.neo4j.driver.types.Node;
public class LoggingCore {
+ /** The format Neo4j logging stores the registration date of an execution
in. */
+ public static final String REGISTRATION_DATE_FORMAT =
"yyyy/MM/dd'T'HH:mm:ss";
+
public static final boolean isEnabled(IVariables space) {
String connectionName =
space.getVariable(Defaults.NEO4J_LOGGING_CONNECTION);
return StringUtils.isNotEmpty(connectionName)
@@ -90,7 +95,7 @@ public class LoggingCore {
execPars.put("root",
loggingObject.getLogChannelId().equals(rootLogChannelId));
execPars.put(
"registrationDate",
- new SimpleDateFormat("yyyy/MM/dd'T'HH:mm:ss")
+ new SimpleDateFormat(REGISTRATION_DATE_FORMAT)
.format(loggingObject.getRegistrationDate()));
StringBuilder execCypher = new StringBuilder();
@@ -236,15 +241,35 @@ public class LoggingCore {
return value.asBoolean();
}
+ /**
+ * The Neo4j execution information location stores dates as a local date
time, Neo4j logging
+ * stores the registration date as a string in {@link
#REGISTRATION_DATE_FORMAT}. Both write
+ * Execution nodes, so either can show up here.
+ */
public static Date getDateValue(Node node, String name) {
Value value = node.get(name);
if (value == null || value.isNull()) {
return null;
}
- LocalDateTime localDateTime = value.asLocalDateTime();
- if (localDateTime == null) {
+ return switch (value.type().name()) {
+ case "LOCAL_DATE_TIME" -> toDate(value.asLocalDateTime());
+ case "DATE_TIME" -> Date.from(value.asZonedDateTime().toInstant());
+ case "STRING" -> parseRegistrationDate(value.asString());
+ default -> null;
+ };
+ }
+
+ private static Date parseRegistrationDate(String registrationDate) {
+ try {
+ return toDate(
+ LocalDateTime.parse(
+ registrationDate,
DateTimeFormatter.ofPattern(REGISTRATION_DATE_FORMAT)));
+ } catch (DateTimeParseException e) {
return null;
}
+ }
+
+ private static Date toDate(LocalDateTime localDateTime) {
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
}
diff --git
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/path/NeoExecutionViewerTabBaseTest.java
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/path/NeoExecutionViewerTabBaseTest.java
index 2672cd3509..779defc92e 100644
---
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/path/NeoExecutionViewerTabBaseTest.java
+++
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/path/NeoExecutionViewerTabBaseTest.java
@@ -36,6 +36,26 @@ class NeoExecutionViewerTabBaseTest {
assertNoSizeOfPatternExpression(cypher);
}
+ @Test
+ void lineageCypherFindsTheRootByItsMissingParentRelationship() {
+ String cypher = NeoExecutionViewerTabBase.buildPathToRootCypher(true);
+
+ // Neo4j logging nodes have no parentId, and shortestPath fails when start
and end coincide
+ assertTrue(cypher.contains("WHERE NOT ()-[:EXECUTES]->(top)"));
+ assertFalse(cypher.contains("parentId IS NULL"));
+ assertFalse(cypher.contains("shortestPath"));
+ }
+
+ @Test
+ void pathCyphersSkipTheNodesOfNeo4jLogging() {
+ assertTrue(
+ NeoExecutionViewerTabBase.buildPathToRootCypher(true)
+ .contains("all(n IN nodes(p) WHERE n.type IS NULL)"));
+ assertTrue(
+ NeoExecutionViewerTabBase.buildPathToFailedCypher()
+ .contains("all(n IN nodes(p) WHERE n.type IS NULL)"));
+ }
+
@Test
void errorPathCypherUsesALeafPredicateAndBooleanFailed() {
String cypher = NeoExecutionViewerTabBase.buildPathToFailedCypher();
diff --git
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/path/NeoLoggingLineageIT.java
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/path/NeoLoggingLineageIT.java
new file mode 100644
index 0000000000..f44cbc4042
--- /dev/null
+++
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/path/NeoLoggingLineageIT.java
@@ -0,0 +1,268 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.neo4j.execution.path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.abort;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.logging.LoggingHierarchy;
+import org.apache.hop.core.logging.LoggingObject;
+import org.apache.hop.core.logging.LoggingObjectType;
+import org.apache.hop.neo4j.execution.path.base.NeoExecutionViewerTabBase;
+import org.apache.hop.neo4j.logging.util.LoggingCore;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.neo4j.driver.AuthTokens;
+import org.neo4j.driver.Driver;
+import org.neo4j.driver.GraphDatabase;
+import org.neo4j.driver.Record;
+import org.neo4j.driver.Session;
+import org.neo4j.driver.types.Node;
+import org.testcontainers.DockerClientFactory;
+import org.testcontainers.containers.Neo4jContainer;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * Reads the error lineage and the lineage of the Execution information
perspective from a graph
+ * written by both the Neo4j execution information location and Neo4j logging,
see issue #8704.
+ *
+ * <p>Both write Execution nodes keyed on the log channel ID. The execution
information location
+ * merges on the ID alone and stores registrationDate as a local date time.
Neo4j logging merges on
+ * name, type and ID, so it creates a second node next to the one of the
execution information
+ * location, and stores registrationDate as a string. Later state updates of
the execution
+ * information location match both nodes. The perspective used to walk through
the nodes of Neo4j
+ * logging as well, which failed with "Cannot coerce STRING to LocalDateTime"
and showed every path
+ * more than once.
+ *
+ * <p>Skipped when Docker is unavailable or the Neo4j container cannot start
in time.
+ */
+class NeoLoggingLineageIT {
+
+ private static final String WORKFLOW_ID = "it-8704-workflow";
+ private static final String PIPELINE_ID = "it-8704-pipeline";
+ private static final String FAILED_ID = "it-8704-failed";
+ private static final String OK_ID = "it-8704-ok";
+
+ private static Neo4jContainer<?> neo4j;
+ private static Driver driver;
+ private static ILogChannel log;
+
+ @BeforeAll
+ static void setUp() {
+ assumeTrue(
+ DockerClientFactory.instance().isDockerAvailable(),
+ "Docker is required for NeoLoggingLineageIT");
+
+ neo4j =
+ new Neo4jContainer<>(DockerImageName.parse("neo4j:5.26"))
+ .withoutAuthentication()
+ .withEnv("NEO4J_server_memory_heap_initial__size", "256m")
+ .withEnv("NEO4J_server_memory_heap_max__size", "512m")
+ .withEnv("NEO4J_server_memory_pagecache_size", "64m")
+ .withStartupTimeout(Duration.ofMinutes(5));
+
+ try {
+ neo4j.start();
+ } catch (Exception e) {
+ abort("Neo4j container did not become ready: " + e.getMessage());
+ }
+
+ driver = GraphDatabase.driver(neo4j.getBoltUrl(), AuthTokens.none());
+
+ try (Session session = driver.session()) {
+ // What the Neo4j execution information location registers: a workflow
which executes a
+ // pipeline with one failed and one successful transform.
+ //
+ session.executeWrite(
+ tx -> {
+ tx.run(
+ """
+ CREATE (workflow:Execution { id : $workflowId, name : 'main',\
+ executionType : 'Workflow', registrationDate : $date })
+ CREATE (pipeline:Execution { id : $pipelineId, name : 'load',\
+ executionType : 'Pipeline', parentId : $workflowId,
registrationDate : $date })
+ CREATE (failed:Execution { id : $failedId, name : 'Fail',\
+ executionType : 'Transform', parentId : $pipelineId,
registrationDate : $date })
+ CREATE (ok:Execution { id : $okId, name : 'Rows',\
+ executionType : 'Transform', parentId : $pipelineId,
registrationDate : $date })
+ CREATE (workflow)-[:EXECUTES]->(pipeline)
+ CREATE (pipeline)-[:EXECUTES]->(failed)
+ CREATE (pipeline)-[:EXECUTES]->(ok)
+ """,
+ Map.of(
+ "workflowId", WORKFLOW_ID,
+ "pipelineId", PIPELINE_ID,
+ "failedId", FAILED_ID,
+ "okId", OK_ID,
+ "date", LocalDateTime.of(2026, 9, 30, 14, 15, 16)));
+ return null;
+ });
+
+ // What Neo4j logging writes at the end of the top level workflow,
through the real code.
+ //
+ LoggingObject workflow = loggingObject(LoggingObjectType.WORKFLOW,
WORKFLOW_ID, "main", null);
+ LoggingObject action =
+ loggingObject(LoggingObjectType.ACTION, "it-8704-action",
"load.hpl", workflow);
+ LoggingObject pipeline =
+ loggingObject(LoggingObjectType.PIPELINE, PIPELINE_ID, "load",
action);
+ LoggingObject failed =
+ loggingObject(LoggingObjectType.TRANSFORM, FAILED_ID, "Fail",
pipeline);
+ LoggingObject ok = loggingObject(LoggingObjectType.TRANSFORM, OK_ID,
"Rows", pipeline);
+ List<LoggingHierarchy> hierarchies = new ArrayList<>();
+ for (LoggingObject loggingObject : List.of(workflow, action, pipeline,
failed, ok)) {
+ hierarchies.add(new LoggingHierarchy(WORKFLOW_ID, loggingObject));
+ }
+
+ log = mock(ILogChannel.class);
+ session.executeWrite(
+ tx -> {
+ LoggingCore.writeHierarchies(log, null, tx, hierarchies,
WORKFLOW_ID);
+ return null;
+ });
+
+ // The final state updates of the execution information location merge
on the ID only, so
+ // they reach the nodes of Neo4j logging as well.
+ //
+ session.executeWrite(
+ tx -> {
+ tx.run(
+ """
+ MATCH (e:Execution) WHERE e.id IN [$workflowId, $pipelineId,
$failedId]
+ SET e.failed = true
+ """,
+ Map.of(
+ "workflowId", WORKFLOW_ID, "pipelineId", PIPELINE_ID,
"failedId", FAILED_ID));
+ tx.run(
+ "MATCH (e:Execution { id : $okId }) SET e.failed = false",
Map.of("okId", OK_ID));
+ return null;
+ });
+ }
+ }
+
+ private static LoggingObject loggingObject(
+ LoggingObjectType type, String id, String name, LoggingObject parent) {
+ LoggingObject loggingObject = new LoggingObject(name);
+ loggingObject.setObjectType(type);
+ loggingObject.setLogChannelId(id);
+ loggingObject.setObjectName(name);
+ loggingObject.setParent(parent);
+ loggingObject.setRegistrationDate(new Date());
+ return loggingObject;
+ }
+
+ @AfterAll
+ static void tearDown() {
+ if (driver != null) {
+ driver.close();
+ }
+ if (neo4j != null) {
+ neo4j.stop();
+ }
+ }
+
+ @Test
+ void neo4jLoggingWroteStringDatesNextToTheExecutionInformation() {
+ verify(log, never()).logError(anyString(), any(Throwable.class));
+
+ try (Session session = driver.session()) {
+ long stringDates =
+ session.executeRead(
+ tx ->
+ tx.run(
+ """
+ MATCH (e:Execution { id : $failedId })
+ WHERE valueType(e.registrationDate) STARTS WITH
'STRING'
+ RETURN count(e)
+ """,
+ Map.of("failedId", FAILED_ID))
+ .single()
+ .get(0)
+ .asLong());
+ assertEquals(1, stringDates);
+ }
+ }
+
+ @Test
+ void errorLineageIsOnePathThroughTheExecutionInformation() {
+ List<List<Node>> paths =
+ readPaths(NeoExecutionViewerTabBase.buildPathToFailedCypher(),
WORKFLOW_ID);
+
+ assertEquals(1, paths.size());
+ assertExecutionInformation(paths.get(0));
+ assertEquals(
+ List.of(WORKFLOW_ID, PIPELINE_ID, FAILED_ID),
+ paths.get(0).stream().map(node -> node.get("id").asString()).toList());
+ }
+
+ @Test
+ void lineageIsOnePathFromTheRootThroughTheExecutionInformation() {
+ // Neo4j logging writes a second node for the failed transform without a
parentId. Picking
+ // roots by a missing parentId made that node its own root, which Neo4j 5
rejects for a
+ // shortestPath.
+ //
+ List<List<Node>> paths =
+ readPaths(NeoExecutionViewerTabBase.buildPathToRootCypher(true),
FAILED_ID);
+
+ assertEquals(1, paths.size());
+ assertExecutionInformation(paths.get(0));
+ assertEquals(
+ List.of(WORKFLOW_ID, PIPELINE_ID, FAILED_ID),
+ paths.get(0).stream().map(node -> node.get("id").asString()).toList());
+ }
+
+ private static List<List<Node>> readPaths(String cypher, String executionId)
{
+ try (Session session = driver.session()) {
+ List<Record> records =
+ session.executeRead(tx -> tx.run(cypher, Map.of("executionId",
executionId)).list());
+ List<List<Node>> paths = new ArrayList<>();
+ for (Record record : records) {
+ List<Node> path = new ArrayList<>();
+ record.get(0).asPath().nodes().forEach(path::add);
+ paths.add(path);
+ }
+ return paths;
+ }
+ }
+
+ /** Checks the nodes and reads every date the way the Error lineage and
Lineage tabs do. */
+ private static void assertExecutionInformation(List<Node> path) {
+ for (Node node : path) {
+ String id = node.get("id").asString();
+ assertTrue(node.get("type").isNull(), "Execution " + id + " was written
by Neo4j logging");
+ assertNotNull(
+ LoggingCore.getDateValue(node, "registrationDate"),
+ "No registration date for execution " + id);
+ }
+ }
+}
diff --git
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/logging/util/LoggingCoreTest.java
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/logging/util/LoggingCoreTest.java
new file mode 100644
index 0000000000..cf39440ce7
--- /dev/null
+++
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/logging/util/LoggingCoreTest.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.neo4j.logging.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.neo4j.driver.Value;
+import org.neo4j.driver.Values;
+import org.neo4j.driver.internal.InternalNode;
+import org.neo4j.driver.types.Node;
+
+/**
+ * The registration date of an Execution node is written by both the Neo4j
execution information
+ * location (a local date time) and Neo4j logging (a string), see issue #8704.
+ */
+class LoggingCoreTest {
+
+ private static Node execution(Value registrationDate) {
+ return new InternalNode(1, List.of("Execution"),
Map.of("registrationDate", registrationDate));
+ }
+
+ private static Date toDate(LocalDateTime localDateTime) {
+ return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
+ }
+
+ @Test
+ void localDateTimeWrittenByTheExecutionInformationLocation() {
+ LocalDateTime registered = LocalDateTime.of(2026, 9, 30, 14, 15, 16);
+
+ assertEquals(
+ toDate(registered),
+ LoggingCore.getDateValue(execution(Values.value(registered)),
"registrationDate"));
+ }
+
+ @Test
+ void stringWrittenByNeo4jLogging() {
+ // The format LoggingCore.writeHierarchies() uses
+ //
+ assertEquals(
+ toDate(LocalDateTime.of(2026, 9, 30, 14, 15, 16)),
+ LoggingCore.getDateValue(
+ execution(Values.value("2026/09/30T14:15:16")),
"registrationDate"));
+ }
+
+ @Test
+ void dateTimeWithATimeZone() {
+ ZonedDateTime registered = ZonedDateTime.of(2026, 9, 30, 14, 15, 16, 0,
ZoneOffset.UTC);
+
+ assertEquals(
+ Date.from(registered.toInstant()),
+ LoggingCore.getDateValue(execution(Values.value(registered)),
"registrationDate"));
+ }
+
+ @Test
+ void unparsableStringIsNull() {
+ assertNull(LoggingCore.getDateValue(execution(Values.value("not a date")),
"registrationDate"));
+ }
+
+ @Test
+ void otherTypeIsNull() {
+ assertNull(LoggingCore.getDateValue(execution(Values.value(42)),
"registrationDate"));
+ }
+
+ @Test
+ void missingPropertyIsNull() {
+ assertNull(LoggingCore.getDateValue(execution(Values.NULL),
"registrationDate"));
+ assertNull(
+ LoggingCore.getDateValue(new InternalNode(1, List.of("Execution"),
Map.of()), "other"));
+ }
+}