Skip to content

feat(sourcedb-to-spanner): Implement Neo4j batch data extraction pipeline. #4029#4030

Open
yogeshtewari wants to merge 2 commits into
GoogleCloudPlatform:mainfrom
yogeshtewari:main
Open

feat(sourcedb-to-spanner): Implement Neo4j batch data extraction pipeline. #4029#4030
yogeshtewari wants to merge 2 commits into
GoogleCloudPlatform:mainfrom
yogeshtewari:main

Conversation

@yogeshtewari

Copy link
Copy Markdown

Closes #4029

  • Register NEO4J as a supported source database dialect.
  • Add pipeline options for --neo4jUri, --neo4jUser, --neo4jPassword, and --neo4jReadChunkSize.
  • Add beam-sdks-java-io-neo4j dependency to sourcedb-to-spanner pom.xml.
  • Implement Neo4jSrcToSpSourceConnector and route dialect options to Neo4j.
  • Implement virtual schema discovery (Neo4jIoWrapper) mapping elements into GraphNode and GraphEdge virtual tables.
  • Implement Neo4jTableReader to partition ID ranges and run parallel Cypher query extractions on worker pools.
  • Implement Neo4jRowMapper, registering Jackson JavaTimeModule to support serializing date/time attributes.
  • Create unit tests for Neo4jIoWrapper and Neo4jIoWrapperFactory.

…line

- Register NEO4J as a supported source database dialect.
- Add pipeline options for --neo4jUri, --neo4jUser, --neo4jPassword, and --neo4jReadChunkSize.
- Add beam-sdks-java-io-neo4j dependency to sourcedb-to-spanner pom.xml.
- Implement Neo4jSrcToSpSourceConnector and route dialect options to Neo4j.
- Implement virtual schema discovery (Neo4jIoWrapper) mapping elements into GraphNode and GraphEdge virtual tables.
- Implement Neo4jTableReader to partition ID ranges and run parallel Cypher query extractions on worker pools.
- Implement Neo4jRowMapper, registering Jackson JavaTimeModule to support serializing date/time attributes.
- Create unit tests for Neo4jIoWrapper and Neo4jIoWrapperFactory.
Closes GoogleCloudPlatform#4029
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces comprehensive support for Neo4j as a source database within the sourcedb-to-spanner migration tool. It integrates Neo4j by adding necessary dependencies, defining new pipeline parameters for connection and data partitioning, and implementing the core logic for schema discovery and parallel data extraction. The changes enable the system to interpret Neo4j's graph structure as virtual tables, facilitating its migration capabilities.

Highlights

  • Neo4j Source Database Support: Implemented a new batch data extraction pipeline to support Neo4j as a source database for migration to Spanner.
  • Pipeline Options: Added new pipeline options for Neo4j connection details, including URI, username, password, and read chunk size, allowing users to configure the Neo4j source.
  • Virtual Schema Discovery: Introduced a virtual schema discovery mechanism (Neo4jIoWrapper) that maps Neo4j graph elements (nodes and edges) into GraphNode and GraphEdge virtual tables for processing.
  • Parallel Data Extraction: Developed a Neo4jTableReader to partition Neo4j ID ranges and execute parallel Cypher queries, optimizing data extraction from the graph database.
  • Row Mapping and Type Handling: Created a Neo4jRowMapper to translate Neo4j records into generic SourceRow objects, including support for serializing date/time attributes using Jackson's JavaTimeModule.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds support for Neo4j as a source database dialect in the sourcedb-to-spanner pipeline, introducing new configuration options, connector classes, schema references, and a custom reader to partition and query Neo4j nodes and edges. The review feedback highlights three key issues: a potential NotSerializableException in Neo4jRowMapper due to a non-serializable ObjectMapper field, an issue in Neo4jTableReader where catching all exceptions and returning 0L can silently hide critical connection errors, and a potential NullPointerException in Neo4jIoWrapperFactory from auto-unboxing a nullable Integer chunk size.

Comment on lines +33 to +44
public class Neo4jRowMapper implements Serializable {

private final SourceSchemaReference sourceSchemaReference;
private final SourceTableSchema sourceTableSchema;
private final ObjectMapper objectMapper;

public Neo4jRowMapper(
SourceSchemaReference sourceSchemaReference, SourceTableSchema sourceTableSchema) {
this.sourceSchemaReference = sourceSchemaReference;
this.sourceTableSchema = sourceTableSchema;
this.objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ObjectMapper class is not serializable. Since Neo4jRowMapper implements Serializable, having a non-transient, non-static ObjectMapper instance field will cause a NotSerializableException if the mapper is ever serialized. Additionally, instantiating a new ObjectMapper and registering modules is an expensive operation.

To resolve this, declare ObjectMapper as a private static final constant. This ensures thread-safe, performant reuse and avoids serialization issues.

Suggested change
public class Neo4jRowMapper implements Serializable {
private final SourceSchemaReference sourceSchemaReference;
private final SourceTableSchema sourceTableSchema;
private final ObjectMapper objectMapper;
public Neo4jRowMapper(
SourceSchemaReference sourceSchemaReference, SourceTableSchema sourceTableSchema) {
this.sourceSchemaReference = sourceSchemaReference;
this.sourceTableSchema = sourceTableSchema;
this.objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
}
public class Neo4jRowMapper implements Serializable {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().registerModule(new JavaTimeModule());
private final SourceSchemaReference sourceSchemaReference;
private final SourceTableSchema sourceTableSchema;
public Neo4jRowMapper(
SourceSchemaReference sourceSchemaReference, SourceTableSchema sourceTableSchema) {
this.sourceSchemaReference = sourceSchemaReference;
this.sourceTableSchema = sourceTableSchema;
}

Comment on lines +66 to +81
private long fetchMaxId(String query) {
try (Driver driver = GraphDatabase.driver(uri, AuthTokens.basic(user, password));
Session session = driver.session()) {
Result result = session.run(query);
if (result.hasNext()) {
Record record = result.next();
Value val = record.get(0);
if (!val.isNull()) {
return val.asLong();
}
}
} catch (Exception e) {
LOG.warn("Error querying max ID from Neo4j (defaulting to 0):", e);
}
return 0L;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Catching Exception and silently returning 0L with a warning hides critical connection, authentication, or query errors during pipeline submission. This can lead to empty migrations or late failures on workers.

Instead, throw a RuntimeException to fail fast. If the database is empty, max(id(n)) returns null (which is a NullValue in Neo4j), and val.isNull() correctly handles this by returning 0L without throwing an exception. Additionally, log the error message before throwing the exception to ensure the failure is recorded in Dataflow environments.

Suggested change
private long fetchMaxId(String query) {
try (Driver driver = GraphDatabase.driver(uri, AuthTokens.basic(user, password));
Session session = driver.session()) {
Result result = session.run(query);
if (result.hasNext()) {
Record record = result.next();
Value val = record.get(0);
if (!val.isNull()) {
return val.asLong();
}
}
} catch (Exception e) {
LOG.warn("Error querying max ID from Neo4j (defaulting to 0):", e);
}
return 0L;
}
private long fetchMaxId(String query) {
try (Driver driver = GraphDatabase.driver(uri, AuthTokens.basic(user, password));
Session session = driver.session()) {
Result result = session.run(query);
if (result.hasNext()) {
Record record = result.next();
Value val = record.get(0);
if (!val.isNull()) {
return val.asLong();
}
}
} catch (Exception e) {
LOG.error("Failed to fetch max ID from Neo4j using query: {}", query, e);
throw new RuntimeException("Failed to fetch max ID from Neo4j using query: " + query, e);
}
return 0L;
}
References
  1. When throwing exceptions in environments where the caller or global exception handler might not log them (such as certain Dataflow templates), log the error message before throwing the exception to ensure the failure is recorded.

Comment on lines +42 to +55
public static Neo4jIoWrapperFactory fromPipelineOptions(SourceDbToSpannerOptions options) {
Preconditions.checkArgument(
options.getSourceDbDialect().equals(SourceDbToSpannerOptions.NEO4J_SOURCE_DIALECT),
"Unexpected Dialect " + options.getSourceDbDialect() + " for Neo4j Source");
Preconditions.checkNotNull(options.getNeo4jUri(), "Neo4j URI must be specified");
Preconditions.checkNotNull(options.getNeo4jUser(), "Neo4j Username must be specified");
Preconditions.checkNotNull(options.getNeo4jPassword(), "Neo4j Password must be specified");

return new Neo4jIoWrapperFactory(
options.getNeo4jUri(),
options.getNeo4jUser(),
options.getNeo4jPassword(),
options.getNeo4jReadChunkSize());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The options.getNeo4jReadChunkSize() method returns an Integer object, which can be null. Passing it directly to the Neo4jIoWrapperFactory constructor (which expects a primitive int) will cause a NullPointerException due to auto-unboxing.

Add a defensive null check and fall back to the default chunk size of 100000 if it is null.

  public static Neo4jIoWrapperFactory fromPipelineOptions(SourceDbToSpannerOptions options) {
    Preconditions.checkArgument(
        options.getSourceDbDialect().equals(SourceDbToSpannerOptions.NEO4J_SOURCE_DIALECT),
        "Unexpected Dialect " + options.getSourceDbDialect() + " for Neo4j Source");
    Preconditions.checkNotNull(options.getNeo4jUri(), "Neo4j URI must be specified");
    Preconditions.checkNotNull(options.getNeo4jUser(), "Neo4j Username must be specified");
    Preconditions.checkNotNull(options.getNeo4jPassword(), "Neo4j Password must be specified");

    Integer chunkSize = options.getNeo4jReadChunkSize();
    int readChunkSize = (chunkSize != null) ? chunkSize : 100000;

    return new Neo4jIoWrapperFactory(
        options.getNeo4jUri(),
        options.getNeo4jUser(),
        options.getNeo4jPassword(),
        readChunkSize);
  }
References
  1. Ensure that mandatory parameters are validated for nullability at the entry point or in upstream components to prevent NullPointerExceptions (especially during auto-unboxing) in downstream transforms, avoiding the need for redundant null checks.

@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 41.00000% with 118 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.34%. Comparing base (b3660ae) to head (9642e44).

Files with missing lines Patch % Lines
...rt/v2/source/neo4j/iowrapper/Neo4jTableReader.java 10.66% 67 Missing ⚠️
...port/v2/source/neo4j/rowmapper/Neo4jRowMapper.java 0.00% 35 Missing ⚠️
.../v2/source/neo4j/iowrapper/Neo4jPartitionTask.java 0.00% 8 Missing ⚠️
...t/v2/source/neo4j/Neo4jSrcToSpSourceConnector.java 0.00% 4 Missing ⚠️
...oud/teleport/v2/source/SourceConnectorFactory.java 0.00% 2 Missing and 1 partial ⚠️
.../source/neo4j/iowrapper/Neo4jIoWrapperFactory.java 94.44% 1 Missing ⚠️

❌ Your patch check has failed because the patch coverage (41.00%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #4030      +/-   ##
============================================
+ Coverage     55.69%   63.34%   +7.64%     
+ Complexity     6787     2521    -4266     
============================================
  Files          1124      543     -581     
  Lines         68447    30919   -37528     
  Branches       7721     3436    -4285     
============================================
- Hits          38123    19586   -18537     
+ Misses        27849    10319   -17530     
+ Partials       2475     1014    -1461     
Components Coverage Δ
spanner-templates 87.48% <41.00%> (-0.17%) ⬇️
spanner-import-export ∅ <ø> (∅)
spanner-live-forward-migration 89.23% <ø> (-0.04%) ⬇️
spanner-live-reverse-replication 83.41% <ø> (-0.03%) ⬇️
spanner-bulk-migration 91.51% <41.00%> (-0.95%) ⬇️
gcs-spanner-dv 90.00% <ø> (+1.48%) ⬆️
Files with missing lines Coverage Δ
...ort/v2/reader/io/schema/SourceSchemaReference.java 84.61% <100.00%> (+4.61%) ⬆️
...port/v2/source/neo4j/iowrapper/Neo4jIoWrapper.java 100.00% <100.00%> (ø)
.../source/neo4j/iowrapper/Neo4jMappingsProvider.java 100.00% <100.00%> (ø)
...t/v2/source/neo4j/schema/Neo4jSchemaReference.java 100.00% <100.00%> (ø)
...ort/v2/spanner/migrations/constants/Constants.java 0.00% <ø> (ø)
.../source/neo4j/iowrapper/Neo4jIoWrapperFactory.java 94.44% <94.44%> (ø)
...oud/teleport/v2/source/SourceConnectorFactory.java 84.00% <0.00%> (-16.00%) ⬇️
...t/v2/source/neo4j/Neo4jSrcToSpSourceConnector.java 0.00% <0.00%> (ø)
.../v2/source/neo4j/iowrapper/Neo4jPartitionTask.java 0.00% <0.00%> (ø)
...port/v2/source/neo4j/rowmapper/Neo4jRowMapper.java 0.00% <0.00%> (ø)
... and 1 more

... and 613 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@bharadwaj-aditya
bharadwaj-aditya self-requested a review July 15, 2026 20:19

@bharadwaj-aditya bharadwaj-aditya left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of major questions here

  1. Schema seems to be hardcoded. I would assume different labels will go to different spanner tables. How is that handled ?
  2. It does not look like Neo4jIO is being used here. We should rely on the library if it has all the support we need.


void setGcsOutputDirectory(String value);

@TemplateParameter.Text(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should move into the ConfigFileReader. Please don't add source specific parameters to the job

public Neo4jIoWrapper(
String neo4jUri,
String neo4jUser,
String neo4jPassword,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will this work with secret manager as well ?


SourceSchemaReference schemaReference =
SourceSchemaReference.ofNeo4j(
Neo4jSchemaReference.builder().setDatabaseName("neo4j").build());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There can be multiple database / graph names in a neo4j instance. This should come from config.

.setTableName("GraphNode")
.setPrimaryKeyColumns(ImmutableList.of("id"))
.addSourceColumnNameToSourceColumnType(
"id", new SourceColumnType("STRING", new Long[0], null))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema seems hard coded here. There can be multiple label in a database. Ideally this should be fetched by something like db.labels() or some equivalent.

// 2. Construct Virtual Edge Table Schema
SourceTableSchema edgeTableSchema =
SourceTableSchema.builder(typeMapper)
.setTableName("GraphEdge")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use constants for such parameters where possible.

public static ImmutableMap<String, UnifiedTypeMapping> getMapping() {
return ImmutableMap.<String, UnifiedTypeMapping>builder()
.put("STRING", UnifiedMappingProvider.getMapping(UnifiedMappingProvider.Type.STRING))
.put("JSON", UnifiedMappingProvider.getMapping(UnifiedMappingProvider.Type.JSON))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about other types ?

import java.io.Serializable;

/** Describes a bounded node/edge ID query partition task. */
public class Neo4jPartitionTask implements Serializable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This seems like a partition definition. Why is this names task ?

}
}
} catch (Exception e) {
LOG.warn("Error querying max ID from Neo4j (defaulting to 0):", e);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should be throwing an exception here. If we want to ignore some specific exception, please add more specific handling


List<Neo4jPartitionTask> partitionTasks = new ArrayList<>();
for (long i = 0; i <= maxId; i += chunkSize) {
partitionTasks.add(new Neo4jPartitionTask(elementType, i, i + chunkSize));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the most efficient way to fetch data from neo4j ? It seems like we're traversing all the nodes to find them.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you not use neo4j IO here ?

}

public String getName() {
return "Database." + databaseName();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it fine ot hardcode this ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request]: Add support for Neo4J to Spanner Graph data migration

2 participants