diff --git a/driver/src/main/java/org/neo4j/driver/QueryConfig.java b/driver/src/main/java/org/neo4j/driver/QueryConfig.java index 0239c527d8..cc207258ff 100644 --- a/driver/src/main/java/org/neo4j/driver/QueryConfig.java +++ b/driver/src/main/java/org/neo4j/driver/QueryConfig.java @@ -19,11 +19,16 @@ package org.neo4j.driver; import static java.util.Objects.requireNonNull; +import static org.neo4j.driver.internal.util.Preconditions.checkArgument; import java.io.Serial; import java.io.Serializable; +import java.time.Duration; +import java.util.Collections; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import org.neo4j.driver.internal.util.Extract; /** * Query configuration used by {@link Driver#executableQuery(String)} and its variants. @@ -55,6 +60,16 @@ public final class QueryConfig implements Serializable { * The flag indicating if default bookmark manager should be used. */ private final boolean useDefaultBookmarkManager; + /** + * The transaction timeout. + * @since 5.16 + */ + private final Duration timeout; + /** + * The transaction metadata. + * @since 5.16 + */ + private final Map metadata; /** * Returns default config value. @@ -71,6 +86,8 @@ private QueryConfig(Builder builder) { this.impersonatedUser = builder.impersonatedUser; this.bookmarkManager = builder.bookmarkManager; this.useDefaultBookmarkManager = builder.useDefaultBookmarkManager; + this.timeout = builder.timeout; + this.metadata = builder.metadata; } /** @@ -121,6 +138,26 @@ public Optional bookmarkManager(BookmarkManager defaultBookmark return useDefaultBookmarkManager ? Optional.of(defaultBookmarkManager) : Optional.ofNullable(bookmarkManager); } + /** + * Get the configured transaction timeout. + * + * @return an {@link Optional} containing the configured timeout or {@link Optional#empty()} otherwise + * @since 5.16 + */ + public Optional timeout() { + return Optional.ofNullable(timeout); + } + + /** + * Get the configured transaction metadata. + * + * @return metadata or empty map when it is not configured + * @since 5.16 + */ + public Map metadata() { + return metadata; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -130,12 +167,15 @@ public boolean equals(Object o) { && routing == that.routing && Objects.equals(database, that.database) && Objects.equals(impersonatedUser, that.impersonatedUser) - && Objects.equals(bookmarkManager, that.bookmarkManager); + && Objects.equals(bookmarkManager, that.bookmarkManager) + && Objects.equals(timeout, that.timeout) + && Objects.equals(metadata, that.metadata); } @Override public int hashCode() { - return Objects.hash(routing, database, impersonatedUser, bookmarkManager, useDefaultBookmarkManager); + return Objects.hash( + routing, database, impersonatedUser, bookmarkManager, useDefaultBookmarkManager, timeout, metadata); } @Override @@ -145,7 +185,9 @@ public String toString() { + database + '\'' + ", impersonatedUser='" + impersonatedUser + '\'' + ", bookmarkManager=" + bookmarkManager + ", useDefaultBookmarkManager=" - + useDefaultBookmarkManager + '}'; + + useDefaultBookmarkManager + '\'' + ", timeout='" + + timeout + '\'' + ", metadata=" + + metadata + '}'; } /** @@ -157,6 +199,8 @@ public static final class Builder { private String impersonatedUser; private BookmarkManager bookmarkManager; private boolean useDefaultBookmarkManager = true; + private Duration timeout; + private Map metadata = Collections.emptyMap(); private Builder() {} @@ -216,6 +260,44 @@ public Builder withBookmarkManager(BookmarkManager bookmarkManager) { return this; } + /** + * Set the transaction timeout. Transactions that execute longer than the configured timeout will be terminated by the database. + *

+ * This functionality allows user code to limit query/transaction execution time. + * The specified timeout overrides the default timeout configured in the database using the {@code db.transaction.timeout} setting ({@code dbms.transaction.timeout} before Neo4j 5.0). + * Values higher than {@code db.transaction.timeout} will be ignored and will fall back to the default for server versions between 4.2 and 5.2 (inclusive). + *

+ * The provided value should not represent a negative duration. + * {@link Duration#ZERO} will make the transaction execute indefinitely. + * + * @param timeout the timeout. + * @return this builder. + * @since 5.16 + */ + public Builder withTimeout(Duration timeout) { + if (timeout != null) { + checkArgument(!timeout.isNegative(), "Transaction timeout should not be negative"); + } + + this.timeout = timeout; + return this; + } + + /** + * Set the transaction metadata. + * + * @param metadata the metadata, must not be {@code null}. + * @return this builder. + * @since 5.16 + */ + public Builder withMetadata(Map metadata) { + requireNonNull(metadata, "Metadata should not be null"); + metadata.values() + .forEach(Extract::assertParameter); // Just assert valid parameters but don't create a value map yet + this.metadata = Map.copyOf(metadata); // Create a defensive copy + return this; + } + /** * Create a config instance from this builder. * diff --git a/driver/src/main/java/org/neo4j/driver/TransactionConfig.java b/driver/src/main/java/org/neo4j/driver/TransactionConfig.java index 201a262af5..8df0feceb4 100644 --- a/driver/src/main/java/org/neo4j/driver/TransactionConfig.java +++ b/driver/src/main/java/org/neo4j/driver/TransactionConfig.java @@ -215,7 +215,7 @@ public Builder withDefaultTimeout() { * @param metadata the metadata. * @return this builder. */ - public Builder withMetadata(Map metadata) { + public Builder withMetadata(Map metadata) { requireNonNull(metadata, "Transaction metadata should not be null"); metadata.values() .forEach(Extract::assertParameter); // Just assert valid parameters but don't create a value map yet diff --git a/driver/src/main/java/org/neo4j/driver/internal/InternalExecutableQuery.java b/driver/src/main/java/org/neo4j/driver/internal/InternalExecutableQuery.java index e0a52796bc..bd14de8355 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/InternalExecutableQuery.java +++ b/driver/src/main/java/org/neo4j/driver/internal/InternalExecutableQuery.java @@ -82,8 +82,11 @@ public T execute(Collector recordCollector, ResultFinish return resultFinisher.finish(result.keys(), finishedValue, summary); }; var accessMode = config.routing().equals(RoutingControl.WRITE) ? AccessMode.WRITE : AccessMode.READ; + var transactionConfigBuilder = TransactionConfig.builder(); + config.timeout().ifPresent(transactionConfigBuilder::withTimeout); + transactionConfigBuilder.withMetadata(config.metadata()); return session.execute( - accessMode, txCallback, TransactionConfig.empty(), TelemetryApi.EXECUTABLE_QUERY, false); + accessMode, txCallback, transactionConfigBuilder.build(), TelemetryApi.EXECUTABLE_QUERY, false); } } diff --git a/driver/src/test/java/org/neo4j/driver/QueryConfigTest.java b/driver/src/test/java/org/neo4j/driver/QueryConfigTest.java index 7691fae806..5b4f04461a 100644 --- a/driver/src/test/java/org/neo4j/driver/QueryConfigTest.java +++ b/driver/src/test/java/org/neo4j/driver/QueryConfigTest.java @@ -23,7 +23,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import java.io.Serializable; +import java.time.Duration; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -56,7 +61,6 @@ void shouldUpdateRouting(RoutingControl routing) { } @Test - @SuppressWarnings("WriteOnlyObject") void shouldNotAllowNullRouting() { assertThrows(NullPointerException.class, () -> QueryConfig.builder().withRouting(null)); } @@ -102,9 +106,41 @@ void shouldAllowNullBookmarkManager() { assertTrue(config.bookmarkManager(mock(BookmarkManager.class)).isEmpty()); } + @Test + void shouldHaveEmptyMetadataByDefault() { + assertEquals(Collections.emptyMap(), QueryConfig.defaultConfig().metadata()); + } + + @Test + void shouldUpdateMetadata() { + var metadata = Map.of("k1", "v1", "k2", 0); + var config = QueryConfig.builder().withMetadata(metadata).build(); + + assertEquals(metadata, config.metadata()); + } + + @Test + void shouldHaveNullTimeoutByDefault() { + assertTrue(QueryConfig.defaultConfig().timeout().isEmpty()); + } + + @ParameterizedTest + @MethodSource("timeoutDurations") + void shouldUpdateTimeout(Duration timeout) { + var config = QueryConfig.builder().withTimeout(timeout).build(); + assertEquals(timeout, config.timeout().orElse(null)); + } + + static Stream timeoutDurations() { + return Stream.of(null, Duration.ZERO, Duration.ofMillis(5)); + } + @Test void shouldSerialize() throws Exception { - var originalConfig = QueryConfig.defaultConfig(); + var originalConfig = QueryConfig.builder() + .withTimeout(Duration.ofSeconds(1)) + .withMetadata(Map.of("k1", "v1", "k2", 1)) + .build(); var deserializedConfig = TestUtil.serializeAndReadBack(originalConfig, QueryConfig.class); var defaultManager = mock(BookmarkManager.class); @@ -113,6 +149,8 @@ void shouldSerialize() throws Exception { assertEquals(originalConfig.impersonatedUser(), deserializedConfig.impersonatedUser()); assertEquals( originalConfig.bookmarkManager(defaultManager), deserializedConfig.bookmarkManager(defaultManager)); + assertEquals(originalConfig.timeout(), deserializedConfig.timeout()); + assertEquals(originalConfig.metadata(), deserializedConfig.metadata()); } record ResultWithSummary(T value, ResultSummary summary) {} diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ExecuteQuery.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ExecuteQuery.java index b9477197a5..05031aa522 100644 --- a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ExecuteQuery.java +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ExecuteQuery.java @@ -19,8 +19,11 @@ package neo4j.org.testkit.backend.messages.requests; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.io.Serializable; +import java.time.Duration; import java.util.Collections; import java.util.Map; +import java.util.Optional; import java.util.concurrent.CompletionStage; import lombok.Getter; import lombok.Setter; @@ -65,6 +68,13 @@ public TestkitResponse process(TestkitState testkitState) { bookmarkManagerId.equals("-1") ? null : testkitState.getBookmarkManager(bookmarkManagerId); configBuilder.withBookmarkManager(bookmarkManager); } + + Optional.ofNullable(data.getConfig().getTimeout()) + .map(Duration::ofMillis) + .ifPresent(configBuilder::withTimeout); + + Optional.ofNullable(data.getConfig().getTxMeta()).ifPresent(configBuilder::withMetadata); + var params = data.getParams() != null ? data.getParams() : Collections.emptyMap(); var eagerResult = driver.executableQuery(data.getCypher()) .withParameters(params) @@ -123,5 +133,9 @@ public static class QueryConfigData { private String routing; private String impersonatedUser; private String bookmarkManagerId; + private Long timeout; + + @JsonDeserialize(using = TestkitCypherParamDeserializer.class) + private Map txMeta; } }