Skip to content

Testcontainer pinned to neo4j:5.26.1-community, which has a confirmed dynamic-relationship-type query-plan-cache bug (neo4j/neo4j#13597) #5

Description

@jimador

Summary

@EnableDrivineTestConfig's built-in Neo4j testcontainer is hardcoded to neo4j:5.26.1-community (DrivineTestContainer.NEO4J_VERSION = "5.26.1", in org.drivine.test.DrivineTestContainer). That version has a confirmed upstream Neo4j bug that affects any app using Cypher's dynamic relationship-type syntax (CREATE (a)-[r:$($relType)]->(b)), which is exactly what DrivineNamedEntityDataRepository's generated merge_named_entity_relationship.cypher / create_named_entity_relationship.cypher use for every relationship write.

The bug

Upstream: neo4j/neo4j#13597 ("Dynamic relationships do not work correctly as the parameters change")

A dynamic relationship-type parameter gets baked into the server's query-plan cache on first execution and is silently reused for every later execution of the same query text, even when a different $relType value is bound — no error, just the wrong relationship type written. Confirmed affected: 5.26.15.26.3 and 2025.01.0 (community edition; there's an unconfirmed report that Enterprise Edition is not affected). Confirmed fixed in 2026.05-community (current latest as of this report).

Because mergeRelationship/createRelationship use exactly this dynamic-type pattern and get called once per relationship over the life of a long-running PersistenceManager, any consumer of drivine4j on an affected Neo4j version risks silently mis-typed relationships in production once more than one distinct relationship type has ever been written on a given connection/session.

Minimal repro (plain Neo4j Python driver, no Drivine involved — isolates this to the Neo4j server, not this library)

from neo4j import GraphDatabase

driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
stmt = "MERGE (a:Entity {id: $from}) MERGE (b:Entity {id: $to}) CREATE (a)-[r:$($relType)]->(b)"

with driver.session() as s:
    s.run("MATCH (n) DETACH DELETE n")
    s.run(stmt, {"from": "e1", "to": "e2", "relType": "FIRST_TYPE"})
    s.run(stmt, {"from": "e3", "to": "e4", "relType": "SECOND_TYPE"})
    print(sorted(r["t"] for r in s.run("MATCH ()-[r]->() RETURN DISTINCT type(r) AS t")))
    # actual on 5.26.1-community: ['FIRST_TYPE']            <- SECOND_TYPE silently dropped
    # expected / actual on 2026.05-community: ['FIRST_TYPE', 'SECOND_TYPE']

Calling CALL db.clearQueryCaches() between the two writes on the buggy version makes the second write correct — confirming it's the query-plan cache baking in the first-ever-bound value for that literal query text.

What we did downstream

We initially prototyped a Testcontainers ImageNameSubstitutor to intercept and rewrite the neo4j:5.26.1-community image request without touching drivine4j — that works, but it's a bit of a sledgehammer (global JVM-wide image substitution) for what turned out to have a much simpler answer already built into this library:

test.neo4j.use-local / USE_LOCAL_NEO4J=true already does exactly what we need — it tells DrivineTestConfiguration to skip the built-in testcontainer and use whatever's in the Spring Environment for that datasource as-is. So we run our own org.testcontainers.containers.Neo4jContainer (pinned to neo4j:2026.05-community), one per test JVM via a Kotlin object/by lazy singleton, and wire its actual host/port/password in via a @DynamicPropertySource method on each test class:

object Neo4jTestContainer {
    const val PASSWORD = "test-password"
    val instance: Neo4jContainer<*> by lazy {
        Neo4jContainer(DockerImageName.parse("neo4j:2026.05-community").asCompatibleSubstituteFor(DockerImageName.parse("neo4j")))
            .withAdminPassword(PASSWORD)
            .also { it.start() }
    }
    fun registerProperties(registry: DynamicPropertyRegistry) {
        registry.add("test.neo4j.use-local") { "true" }
        registry.add("database.datasources.neo.host") { instance.host }
        registry.add("database.datasources.neo.port") { instance.getMappedPort(7687) }
        registry.add("database.datasources.neo.password") { PASSWORD }
    }
}

// per test class:
companion object {
    @JvmStatic
    @DynamicPropertySource
    fun neo4jProperties(registry: DynamicPropertyRegistry) = Neo4jTestContainer.registerProperties(registry)
}

Verified: with this in place, @EnableDrivineTestConfig's own built-in testcontainer is never started at all (confirmed via logs — only our neo4j:2026.05-community container starts, shared across all IT classes in a module's test JVM), every existing test stays green, and the dynamic-relationship-type repro above passes.

Ask

No library change is strictly required — test.neo4j.use-local already solves this for anyone who needs a different Neo4j version. But it might be worth:

  1. Bumping the default NEO4J_VERSION in DrivineTestContainer past the fixed line, so the out-of-the-box experience (no use-local needed) isn't silently exposed to a data-corrupting bug.
  2. Calling out test.neo4j.use-local + a custom container in TEST_CONTAINERS.md as the documented path for pinning a specific Neo4j version, since right now it reads primarily as a "point at your already-running local Neo4j" feature rather than "bring your own testcontainer instance too."

Happy to open a PR for either if useful.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions