diff --git a/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/BUILD.bazel b/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/BUILD.bazel new file mode 100644 index 0000000000000..e5cd4f6f6863e --- /dev/null +++ b/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/BUILD.bazel @@ -0,0 +1,27 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "java_export") +load("//java:version.bzl", "SE_VERSION") + +java_export( + name = "jdbc", + srcs = glob(["*.java"]), + maven_coordinates = "org.seleniumhq.selenium:selenium-session-queue-jdbc:%s" % SE_VERSION, + pom_template = "//java/src/org/openqa/selenium:template-pom", + tags = [ + "release-artifact", + ], + visibility = [ + "//visibility:public", + ], + exports = [ + "//java/src/org/openqa/selenium/grid", + ], + deps = [ + "//java:auto-service", + "//java/src/org/openqa/selenium/grid", + "//java/src/org/openqa/selenium/json", + "//java/src/org/openqa/selenium/remote", + artifact("com.beust:jcommander"), + artifact("com.google.guava:guava"), + ], +) diff --git a/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcBackedSessionQueue.java b/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcBackedSessionQueue.java new file mode 100644 index 0000000000000..59db4b3fabe3b --- /dev/null +++ b/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcBackedSessionQueue.java @@ -0,0 +1,435 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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.openqa.selenium.grid.sessionqueue.jdbc; + +import static org.openqa.selenium.remote.tracing.Tags.EXCEPTION; + +import java.io.Closeable; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.openqa.selenium.Capabilities; +import org.openqa.selenium.SessionNotCreatedException; +import org.openqa.selenium.grid.config.Config; +import org.openqa.selenium.grid.config.ConfigException; +import org.openqa.selenium.grid.data.CreateSessionResponse; +import org.openqa.selenium.grid.data.RequestId; +import org.openqa.selenium.grid.data.SessionRequest; +import org.openqa.selenium.grid.data.SessionRequestCapability; +import org.openqa.selenium.grid.log.LoggingOptions; +import org.openqa.selenium.grid.security.Secret; +import org.openqa.selenium.grid.security.SecretOptions; +import org.openqa.selenium.grid.sessionqueue.NewSessionQueue; +import org.openqa.selenium.internal.Either; +import org.openqa.selenium.internal.Require; +import org.openqa.selenium.json.Json; +import org.openqa.selenium.remote.http.HttpResponse; +import org.openqa.selenium.remote.tracing.AttributeKey; +import org.openqa.selenium.remote.tracing.AttributeMap; +import org.openqa.selenium.remote.tracing.Span; +import org.openqa.selenium.remote.tracing.Status; +import org.openqa.selenium.remote.tracing.Tracer; + +public class JdbcBackedSessionQueue extends NewSessionQueue implements Closeable { + private static final Logger LOG = Logger.getLogger(JdbcBackedSessionQueue.class.getName()); + private static final String TABLE_NAME = "session_queue"; + private static final Json JSON = new Json(); + private static final String DATABASE_STATEMENT = AttributeKey.DATABASE_STATEMENT.getKey(); + private static final String DATABASE_OPERATION = AttributeKey.DATABASE_OPERATION.getKey(); + private static final String DATABASE_USER = AttributeKey.DATABASE_USER.getKey(); + private static final String DATABASE_CONNECTION_STRING = + AttributeKey.DATABASE_CONNECTION_STRING.getKey(); + private static String jdbcUser; + private static String jdbcUrl; + private final Connection connection; + + public JdbcBackedSessionQueue( + Tracer tracer, Secret registrationSecret, Connection jdbcConnection) { + super(tracer, registrationSecret); + this.connection = Require.nonNull("JDBC Connection Object", jdbcConnection); + ensureTableExists(); + } + + public static NewSessionQueue create(Config config) { + Tracer tracer = new LoggingOptions(config).getTracer(); + Secret secret = new SecretOptions(config).getRegistrationSecret(); + Connection connection; + try { + JdbcSessionQueueOptions sessionQueueOptions = new JdbcSessionQueueOptions(config); + jdbcUser = sessionQueueOptions.getJdbcUser(); + jdbcUrl = sessionQueueOptions.getJdbcUrl(); + connection = sessionQueueOptions.getJdbcConnection(); + } catch (SQLException e) { + throw new ConfigException(e); + } + return new JdbcBackedSessionQueue(tracer, secret, connection); + } + + @Override + public boolean isReady() { + try { + return !connection.isClosed(); + } catch (SQLException e) { + return false; + } + } + + @Override + public boolean peekEmpty() { + try (Span span = tracer.getCurrentContext().createSpan("SELECT COUNT(*) FROM session_queue")) { + AttributeMap attributeMap = tracer.createAttributeMap(); + setCommonSpanAttributes(span); + setCommonEventAttributes(attributeMap); + + String sql = "SELECT COUNT(*) FROM " + TABLE_NAME; + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + String statementStr = stmt.toString(); + span.setAttribute(DATABASE_STATEMENT, statementStr); + span.setAttribute(DATABASE_OPERATION, "select"); + attributeMap.put(DATABASE_STATEMENT, statementStr); + attributeMap.put(DATABASE_OPERATION, "select"); + + ResultSet rs = stmt.executeQuery(); + if (rs.next()) { + boolean isEmpty = rs.getInt(1) == 0; + attributeMap.put("queue.empty", isEmpty); + span.addEvent("Checked queue emptiness", attributeMap); + return isEmpty; + } + } catch (SQLException e) { + span.setAttribute("error", true); + span.setStatus(Status.CANCELLED); + EXCEPTION.accept(attributeMap, e); + attributeMap.put( + AttributeKey.EXCEPTION_MESSAGE.getKey(), + "Unable to check if queue is empty: " + e.getMessage()); + span.addEvent(AttributeKey.EXCEPTION_EVENT.getKey(), attributeMap); + LOG.log(Level.SEVERE, "Failed to check if queue is empty", e); + } + } + return false; + } + + @Override + public HttpResponse addToQueue(SessionRequest request) { + Require.nonNull("SessionRequest to add", request); + + try (Span span = + tracer + .getCurrentContext() + .createSpan( + "INSERT into session_queue (request_id, payload, enqueue_time) values (?, ?, ?)")) { + AttributeMap attributeMap = tracer.createAttributeMap(); + setCommonSpanAttributes(span); + setCommonEventAttributes(attributeMap); + + String sql = + "INSERT INTO " + TABLE_NAME + " (request_id, payload, enqueue_time) VALUES (?, ?, ?)"; + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, request.getRequestId().toString()); + stmt.setString(2, JSON.toJson(request)); + stmt.setTimestamp(3, Timestamp.from(request.getEnqueued())); + + String statementStr = stmt.toString(); + span.setAttribute(DATABASE_STATEMENT, statementStr); + span.setAttribute(DATABASE_OPERATION, "insert"); + attributeMap.put(DATABASE_STATEMENT, statementStr); + attributeMap.put(DATABASE_OPERATION, "insert"); + + int rowCount = stmt.executeUpdate(); + attributeMap.put("rows.added", rowCount); + span.addEvent("Inserted into the database", attributeMap); + + HttpResponse resp = new HttpResponse(); + resp.setStatus(rowCount > 0 ? 200 : 500); + return resp; + } catch (SQLException e) { + span.setAttribute("error", true); + span.setStatus(Status.CANCELLED); + EXCEPTION.accept(attributeMap, e); + attributeMap.put( + AttributeKey.EXCEPTION_MESSAGE.getKey(), + "Unable to add session request to the database: " + e.getMessage()); + span.addEvent(AttributeKey.EXCEPTION_EVENT.getKey(), attributeMap); + + HttpResponse resp = new HttpResponse(); + resp.setStatus(500); + return resp; + } + } + } + + @Override + public boolean retryAddToQueue(SessionRequest request) { + HttpResponse response = addToQueue(request); + return response.getStatus() == 200; + } + + @Override + public Optional remove(RequestId requestId) { + Require.nonNull("RequestId to remove", requestId); + + try (Span span = + tracer + .getCurrentContext() + .createSpan("SELECT and DELETE session request from session_queue")) { + AttributeMap attributeMap = tracer.createAttributeMap(); + setCommonSpanAttributes(span); + setCommonEventAttributes(attributeMap); + + String select = "SELECT payload FROM " + TABLE_NAME + " WHERE request_id = ?"; + String delete = "DELETE FROM " + TABLE_NAME + " WHERE request_id = ?"; + + try (PreparedStatement selectStmt = connection.prepareStatement(select)) { + selectStmt.setString(1, requestId.toString()); + + String statementStr = selectStmt.toString(); + span.setAttribute(DATABASE_STATEMENT, statementStr); + span.setAttribute(DATABASE_OPERATION, "select"); + attributeMap.put(DATABASE_STATEMENT, statementStr); + attributeMap.put(DATABASE_OPERATION, "select"); + + ResultSet rs = selectStmt.executeQuery(); + if (rs.next()) { + String payload = rs.getString("payload"); + + try (PreparedStatement deleteStmt = connection.prepareStatement(delete)) { + deleteStmt.setString(1, requestId.toString()); + + String deleteStatementStr = deleteStmt.toString(); + span.setAttribute(DATABASE_STATEMENT, deleteStatementStr); + span.setAttribute(DATABASE_OPERATION, "delete"); + attributeMap.put(DATABASE_STATEMENT, deleteStatementStr); + attributeMap.put(DATABASE_OPERATION, "delete"); + + int rowCount = deleteStmt.executeUpdate(); + attributeMap.put("rows.deleted", rowCount); + span.addEvent("Removed session request from queue", attributeMap); + + return Optional.of(JSON.toType(payload, SessionRequest.class)); + } + } else { + attributeMap.put("request.found", false); + span.addEvent("Session request not found in queue", attributeMap); + } + } catch (SQLException e) { + span.setAttribute("error", true); + span.setStatus(Status.CANCELLED); + EXCEPTION.accept(attributeMap, e); + attributeMap.put( + AttributeKey.EXCEPTION_MESSAGE.getKey(), + "Unable to remove session request from queue: " + e.getMessage()); + span.addEvent(AttributeKey.EXCEPTION_EVENT.getKey(), attributeMap); + LOG.log(Level.SEVERE, "Failed to remove session request from queue", e); + } + } + return Optional.empty(); + } + + @Override + public List getNextAvailable(Map stereotypes) { + try (Span span = + tracer + .getCurrentContext() + .createSpan("SELECT next available session request from session_queue")) { + AttributeMap attributeMap = tracer.createAttributeMap(); + setCommonSpanAttributes(span); + setCommonEventAttributes(attributeMap); + + String sql = "SELECT payload FROM " + TABLE_NAME + " ORDER BY enqueue_time ASC LIMIT 1"; + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + String statementStr = stmt.toString(); + span.setAttribute(DATABASE_STATEMENT, statementStr); + span.setAttribute(DATABASE_OPERATION, "select"); + attributeMap.put(DATABASE_STATEMENT, statementStr); + attributeMap.put(DATABASE_OPERATION, "select"); + + ResultSet rs = stmt.executeQuery(); + if (rs.next()) { + String payload = rs.getString("payload"); + SessionRequest request = JSON.toType(payload, SessionRequest.class); + attributeMap.put("requests.found", 1); + span.addEvent("Retrieved next available session request", attributeMap); + return List.of(request); + } else { + attributeMap.put("requests.found", 0); + span.addEvent("No session requests available", attributeMap); + } + } catch (SQLException e) { + span.setAttribute("error", true); + span.setStatus(Status.CANCELLED); + EXCEPTION.accept(attributeMap, e); + attributeMap.put( + AttributeKey.EXCEPTION_MESSAGE.getKey(), + "Unable to get next available session request: " + e.getMessage()); + span.addEvent(AttributeKey.EXCEPTION_EVENT.getKey(), attributeMap); + LOG.log(Level.SEVERE, "Failed to get next available session request", e); + } + } + return List.of(); + } + + @Override + public boolean complete( + RequestId reqId, Either result) { + return remove(reqId).isPresent(); + } + + @Override + public int clearQueue() { + try (Span span = + tracer.getCurrentContext().createSpan("DELETE all session requests from session_queue")) { + AttributeMap attributeMap = tracer.createAttributeMap(); + setCommonSpanAttributes(span); + setCommonEventAttributes(attributeMap); + + String sql = "DELETE FROM " + TABLE_NAME; + try (Statement stmt = connection.createStatement()) { + String statementStr = sql; + span.setAttribute(DATABASE_STATEMENT, statementStr); + span.setAttribute(DATABASE_OPERATION, "delete"); + attributeMap.put(DATABASE_STATEMENT, statementStr); + attributeMap.put(DATABASE_OPERATION, "delete"); + + int rowCount = stmt.executeUpdate(sql); + attributeMap.put("rows.deleted", rowCount); + span.addEvent("Cleared all session requests from queue", attributeMap); + return rowCount; + } catch (SQLException e) { + span.setAttribute("error", true); + span.setStatus(Status.CANCELLED); + EXCEPTION.accept(attributeMap, e); + attributeMap.put( + AttributeKey.EXCEPTION_MESSAGE.getKey(), + "Unable to clear session queue: " + e.getMessage()); + span.addEvent(AttributeKey.EXCEPTION_EVENT.getKey(), attributeMap); + LOG.log(Level.SEVERE, "Failed to clear session queue", e); + return 0; + } + } + } + + @Override + public List getQueueContents() { + try (Span span = + tracer.getCurrentContext().createSpan("SELECT all session requests from session_queue")) { + AttributeMap attributeMap = tracer.createAttributeMap(); + setCommonSpanAttributes(span); + setCommonEventAttributes(attributeMap); + + String sql = "SELECT request_id, payload FROM " + TABLE_NAME + " ORDER BY enqueue_time ASC"; + List contents = new ArrayList<>(); + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + String statementStr = stmt.toString(); + span.setAttribute(DATABASE_STATEMENT, statementStr); + span.setAttribute(DATABASE_OPERATION, "select"); + attributeMap.put(DATABASE_STATEMENT, statementStr); + attributeMap.put(DATABASE_OPERATION, "select"); + + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + String requestIdStr = rs.getString("request_id"); + String payload = rs.getString("payload"); + + try { + RequestId requestId = new RequestId(UUID.fromString(requestIdStr)); + SessionRequest request = JSON.toType(payload, SessionRequest.class); + + SessionRequestCapability capability = + new SessionRequestCapability(requestId, request.getDesiredCapabilities()); + contents.add(capability); + } catch (Exception e) { + LOG.log( + Level.WARNING, "Failed to parse session request from queue: " + requestIdStr, e); + } + } + + attributeMap.put("queue.contents.size", contents.size()); + span.addEvent("Retrieved queue contents", attributeMap); + return contents; + } catch (SQLException e) { + span.setAttribute("error", true); + span.setStatus(Status.CANCELLED); + EXCEPTION.accept(attributeMap, e); + attributeMap.put( + AttributeKey.EXCEPTION_MESSAGE.getKey(), + "Unable to get queue contents: " + e.getMessage()); + span.addEvent(AttributeKey.EXCEPTION_EVENT.getKey(), attributeMap); + LOG.log(Level.SEVERE, "Failed to get queue contents", e); + } + } + return List.of(); + } + + @Override + public void close() { + try { + if (!connection.isClosed()) { + connection.close(); + } + } catch (SQLException e) { + LOG.log(Level.WARNING, "Failed to close JDBC connection for SessionQueue", e); + } + } + + private void ensureTableExists() { + String sql = + "CREATE TABLE IF NOT EXISTS " + + TABLE_NAME + + " (" + + "request_id VARCHAR(64) PRIMARY KEY," + + "payload CLOB NOT NULL," + + "enqueue_time TIMESTAMP NOT NULL" + + ")"; + try (Statement stmt = connection.createStatement()) { + stmt.execute(sql); + } catch (SQLException e) { + LOG.log(Level.SEVERE, "Failed to create session_queue table", e); + } + } + + private void setCommonSpanAttributes(Span span) { + span.setAttribute("span.kind", Span.Kind.CLIENT.toString()); + if (jdbcUser != null) { + span.setAttribute(DATABASE_USER, jdbcUser); + } + if (jdbcUrl != null) { + span.setAttribute(DATABASE_CONNECTION_STRING, jdbcUrl); + } + } + + private void setCommonEventAttributes(AttributeMap attributeMap) { + if (jdbcUser != null) { + attributeMap.put(DATABASE_USER, jdbcUser); + } + if (jdbcUrl != null) { + attributeMap.put(DATABASE_CONNECTION_STRING, jdbcUrl); + } + } +} diff --git a/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcException.java b/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcException.java new file mode 100644 index 0000000000000..cd67f8d505461 --- /dev/null +++ b/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcException.java @@ -0,0 +1,38 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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.openqa.selenium.grid.sessionqueue.jdbc; + +import org.openqa.selenium.WebDriverException; + +public class JdbcException extends WebDriverException { + public JdbcException() { + super(); + } + + public JdbcException(String message) { + super(message); + } + + public JdbcException(Throwable cause) { + super(cause); + } + + public JdbcException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcSessionQueueFlags.java b/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcSessionQueueFlags.java new file mode 100644 index 0000000000000..edbbe2ecf9e1e --- /dev/null +++ b/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcSessionQueueFlags.java @@ -0,0 +1,58 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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.openqa.selenium.grid.sessionqueue.jdbc; + +import static org.openqa.selenium.grid.config.StandardGridRoles.SESSION_QUEUE_ROLE; + +import com.beust.jcommander.Parameter; +import com.google.auto.service.AutoService; +import java.util.Collections; +import java.util.Set; +import org.openqa.selenium.grid.config.ConfigValue; +import org.openqa.selenium.grid.config.HasRoles; +import org.openqa.selenium.grid.config.Role; + +@AutoService(HasRoles.class) +public class JdbcSessionQueueFlags implements HasRoles { + + @Parameter( + names = "--sessionqueue-jdbc-url", + description = "Database URL for session queue JDBC connection.") + @ConfigValue( + section = "sessionqueue", + name = "jdbc-url", + example = "\"jdbc:postgresql://localhost:5432/selenium_queue\"") + private String jdbcUrl; + + @Parameter( + names = "--sessionqueue-jdbc-user", + description = "Username for the session queue JDBC connection") + @ConfigValue(section = "sessionqueue", name = "jdbc-user", example = "selenium_user") + private String username; + + @Parameter( + names = "--sessionqueue-jdbc-password", + description = "Password for the session queue JDBC connection") + @ConfigValue(section = "sessionqueue", name = "jdbc-password", example = "secure_password") + private String password; + + @Override + public Set getRoles() { + return Collections.singleton(SESSION_QUEUE_ROLE); + } +} diff --git a/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcSessionQueueOptions.java b/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcSessionQueueOptions.java new file mode 100644 index 0000000000000..4ef782ab07abd --- /dev/null +++ b/java/src/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcSessionQueueOptions.java @@ -0,0 +1,68 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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.openqa.selenium.grid.sessionqueue.jdbc; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.NoSuchElementException; +import org.openqa.selenium.grid.config.Config; +import org.openqa.selenium.internal.Require; + +public class JdbcSessionQueueOptions { + + static final String SESSION_QUEUE_SECTION = "sessionqueue"; + + private final String jdbcUrl; + private final String jdbcUser; + private final String jdbcPassword; + + public JdbcSessionQueueOptions(Config config) { + Require.nonNull("Config", config); + + try { + this.jdbcUrl = config.get(SESSION_QUEUE_SECTION, "jdbc-url").orElse(""); + this.jdbcUser = config.get(SESSION_QUEUE_SECTION, "jdbc-user").orElse(""); + this.jdbcPassword = config.get(SESSION_QUEUE_SECTION, "jdbc-password").orElse(""); + + if (jdbcUrl.isEmpty()) { + throw new JdbcException( + "Missing JDBC Url value. Add sessionqueue option value --sessionqueue-jdbc-url" + + " "); + } + } catch (NoSuchElementException e) { + throw new JdbcException( + "Missing sessionqueue options. Check and add all the following options \n" + + " --sessionqueue-jdbc-url \n" + + " --sessionqueue-jdbc-user \n" + + " --sessionqueue-jdbc-password "); + } + } + + public Connection getJdbcConnection() throws SQLException { + return DriverManager.getConnection(jdbcUrl, jdbcUser, jdbcPassword); + } + + public String getJdbcUrl() { + return jdbcUrl; + } + + public String getJdbcUser() { + return jdbcUser; + } +} diff --git a/java/test/org/openqa/selenium/grid/sessionqueue/jdbc/BUILD.bazel b/java/test/org/openqa/selenium/grid/sessionqueue/jdbc/BUILD.bazel new file mode 100644 index 0000000000000..f5f370203b84c --- /dev/null +++ b/java/test/org/openqa/selenium/grid/sessionqueue/jdbc/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "JUNIT5_DEPS", "java_test_suite") + +java_test_suite( + name = "MediumTests", + size = "medium", + srcs = glob(["*Test.java"]), + deps = [ + "//java/src/org/openqa/selenium/events/local", + "//java/src/org/openqa/selenium/grid/sessionqueue/jdbc", + "//java/src/org/openqa/selenium/remote", + "//java/test/org/openqa/selenium/remote/tracing:tracing-support", + "//java/test/org/openqa/selenium/testing:test-base", + artifact("io.opentelemetry:opentelemetry-api"), + artifact("org.junit.jupiter:junit-jupiter-api"), + artifact("org.assertj:assertj-core"), + artifact("org.hsqldb:hsqldb"), + ] + JUNIT5_DEPS, +) diff --git a/java/test/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcBackedSessionQueueTest.java b/java/test/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcBackedSessionQueueTest.java new file mode 100644 index 0000000000000..d4b575edb3f9d --- /dev/null +++ b/java/test/org/openqa/selenium/grid/sessionqueue/jdbc/JdbcBackedSessionQueueTest.java @@ -0,0 +1,416 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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.openqa.selenium.grid.sessionqueue.jdbc; + +import static org.assertj.core.api.Assertions.*; +import static org.openqa.selenium.remote.http.HttpMethod.POST; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.SessionNotCreatedException; +import org.openqa.selenium.grid.config.Config; +import org.openqa.selenium.grid.config.ConfigException; +import org.openqa.selenium.grid.config.MapConfig; +import org.openqa.selenium.grid.data.RequestId; +import org.openqa.selenium.grid.data.SessionRequest; +import org.openqa.selenium.grid.data.SessionRequestCapability; +import org.openqa.selenium.grid.security.Secret; +import org.openqa.selenium.grid.sessionqueue.NewSessionQueue; +import org.openqa.selenium.internal.Either; +import org.openqa.selenium.remote.http.Contents; +import org.openqa.selenium.remote.http.HttpRequest; +import org.openqa.selenium.remote.http.HttpResponse; +import org.openqa.selenium.remote.tracing.DefaultTestTracer; +import org.openqa.selenium.remote.tracing.Tracer; + +class JdbcBackedSessionQueueTest { + private static Connection connection; + private static final Tracer tracer = DefaultTestTracer.createTracer(); + private static final Secret secret = new Secret("test-secret"); + + @BeforeAll + public static void createDB() throws SQLException { + connection = DriverManager.getConnection("jdbc:hsqldb:mem:sessionqueue", "SA", ""); + Statement createStatement = connection.createStatement(); + createStatement.executeUpdate( + "CREATE TABLE session_queue (request_id VARCHAR(64) PRIMARY KEY, payload CLOB NOT NULL," + + " enqueue_time TIMESTAMP NOT NULL)"); + } + + @AfterAll + public static void killDBConnection() throws SQLException { + connection.close(); + } + + @Test + void shouldThrowIllegalArgumentExceptionIfConnectionObjectIsNull() { + assertThatThrownBy(() -> new JdbcBackedSessionQueue(tracer, secret, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void canAddAndRemoveSessionRequest() { + JdbcBackedSessionQueue queue = getSessionQueue(); + RequestId requestId = new RequestId(UUID.randomUUID()); + + // Create a proper HttpRequest for SessionRequest constructor + HttpRequest httpRequest = new HttpRequest(POST, "/session"); + httpRequest.setContent(Contents.utf8String("{\"capabilities\":{\"browserName\":\"chrome\"}}")); + + SessionRequest request = new SessionRequest(requestId, httpRequest, Instant.now()); + + HttpResponse response = queue.addToQueue(request); + assertThat(response.getStatus()).isEqualTo(200); + + Optional removed = queue.remove(requestId); + assertThat(removed).isPresent(); + assertThat(removed.get().getRequestId()).isEqualTo(requestId); + } + + @Test + void getNextAvailableReturnsOldest() { + JdbcBackedSessionQueue queue = getSessionQueue(); + queue.clearQueue(); + + RequestId requestId1 = new RequestId(UUID.randomUUID()); + HttpRequest httpRequest1 = new HttpRequest(POST, "/session"); + httpRequest1.setContent( + Contents.utf8String("{\"capabilities\":{\"browserName\":\"firefox\"}}")); + SessionRequest request1 = new SessionRequest(requestId1, httpRequest1, Instant.now()); + + RequestId requestId2 = new RequestId(UUID.randomUUID()); + HttpRequest httpRequest2 = new HttpRequest(POST, "/session"); + httpRequest2.setContent(Contents.utf8String("{\"capabilities\":{\"browserName\":\"chrome\"}}")); + SessionRequest request2 = + new SessionRequest(requestId2, httpRequest2, Instant.now().plusSeconds(1)); + + queue.addToQueue(request1); + queue.addToQueue(request2); + + // Use getNextAvailable instead of getNextMatchingRequest + var next = queue.getNextAvailable(Map.of()); + assertThat(next).isNotEmpty(); + assertThat(next.get(0).getRequestId()).isEqualTo(requestId1); + } + + @Test + void clearRemovesAllRequests() { + JdbcBackedSessionQueue queue = getSessionQueue(); + queue.clearQueue(); + + RequestId requestId = new RequestId(UUID.randomUUID()); + HttpRequest httpRequest = new HttpRequest(POST, "/session"); + httpRequest.setContent(Contents.utf8String("{\"capabilities\":{\"browserName\":\"chrome\"}}")); + SessionRequest request = new SessionRequest(requestId, httpRequest, Instant.now()); + + queue.addToQueue(request); + queue.clearQueue(); + + var next = queue.getNextAvailable(Map.of()); + assertThat(next).isEmpty(); + } + + @Test + void getQueueContentsReturnsAllRequests() { + JdbcBackedSessionQueue queue = getSessionQueue(); + queue.clearQueue(); + + // Add multiple requests + RequestId requestId1 = new RequestId(UUID.randomUUID()); + HttpRequest httpRequest1 = new HttpRequest(POST, "/session"); + httpRequest1.setContent( + Contents.utf8String("{\"capabilities\":{\"firstMatch\":[{\"browserName\":\"firefox\"}]}}")); + SessionRequest request1 = new SessionRequest(requestId1, httpRequest1, Instant.now()); + + RequestId requestId2 = new RequestId(UUID.randomUUID()); + HttpRequest httpRequest2 = new HttpRequest(POST, "/session"); + httpRequest2.setContent( + Contents.utf8String("{\"capabilities\":{\"firstMatch\":[{\"browserName\":\"chrome\"}]}}")); + SessionRequest request2 = + new SessionRequest(requestId2, httpRequest2, Instant.now().plusSeconds(1)); + + queue.addToQueue(request1); + queue.addToQueue(request2); + + // Get queue contents + var contents = queue.getQueueContents(); + assertThat(contents).hasSize(2); + + // Verify first request (oldest) + SessionRequestCapability first = contents.get(0); + assertThat(first.getRequestId()).isEqualTo(requestId1); + assertThat(first.getDesiredCapabilities()).isNotNull(); + + // Verify second request + SessionRequestCapability second = contents.get(1); + assertThat(second.getRequestId()).isEqualTo(requestId2); + assertThat(second.getDesiredCapabilities()).isNotNull(); + } + + @Test + void getQueueContentsReturnsEmptyListWhenQueueIsEmpty() { + JdbcBackedSessionQueue queue = getSessionQueue(); + queue.clearQueue(); + + var contents = queue.getQueueContents(); + assertThat(contents).isEmpty(); + } + + @Test + void peekEmptyReturnsTrueWhenQueueIsEmpty() { + JdbcBackedSessionQueue queue = getSessionQueue(); + queue.clearQueue(); + + assertThat(queue.peekEmpty()).isTrue(); + } + + @Test + void peekEmptyReturnsFalseWhenQueueHasRequests() { + JdbcBackedSessionQueue queue = getSessionQueue(); + queue.clearQueue(); + + RequestId requestId = new RequestId(UUID.randomUUID()); + HttpRequest httpRequest = new HttpRequest(POST, "/session"); + httpRequest.setContent(Contents.utf8String("{\"capabilities\":{\"browserName\":\"chrome\"}}")); + SessionRequest request = new SessionRequest(requestId, httpRequest, Instant.now()); + + queue.addToQueue(request); + assertThat(queue.peekEmpty()).isFalse(); + } + + @Test + void isReadyReturnsTrueWhenConnectionIsOpen() { + JdbcBackedSessionQueue queue = getSessionQueue(); + assertThat(queue.isReady()).isTrue(); + } + + @Test + void removeReturnsEmptyWhenRequestNotFound() { + JdbcBackedSessionQueue queue = getSessionQueue(); + queue.clearQueue(); + + RequestId nonExistentId = new RequestId(UUID.randomUUID()); + Optional removed = queue.remove(nonExistentId); + assertThat(removed).isEmpty(); + } + + @Test + void retryAddToQueueDelegatesToAddToQueue() { + JdbcBackedSessionQueue queue = getSessionQueue(); + RequestId requestId = new RequestId(UUID.randomUUID()); + + HttpRequest httpRequest = new HttpRequest(POST, "/session"); + httpRequest.setContent(Contents.utf8String("{\"capabilities\":{\"browserName\":\"chrome\"}}")); + SessionRequest request = new SessionRequest(requestId, httpRequest, Instant.now()); + + boolean result = queue.retryAddToQueue(request); + assertThat(result).isTrue(); + + // Verify it was actually added + Optional removed = queue.remove(requestId); + assertThat(removed).isPresent(); + } + + @Test + void completeDoesNotThrowException() { + JdbcBackedSessionQueue queue = getSessionQueue(); + RequestId requestId = new RequestId(UUID.randomUUID()); + + // complete() method should not throw any exception - requires Either parameter + assertThatCode( + () -> queue.complete(requestId, Either.left(new SessionNotCreatedException("test")))) + .doesNotThrowAnyException(); + } + + @Test + void getNextAvailableReturnsEmptyWhenQueueIsEmpty() { + JdbcBackedSessionQueue queue = getSessionQueue(); + queue.clearQueue(); + + var next = queue.getNextAvailable(Map.of()); + assertThat(next).isEmpty(); + } + + @Test + void clearQueueReturnsCorrectRowCount() { + JdbcBackedSessionQueue queue = getSessionQueue(); + queue.clearQueue(); // Start with empty queue + + // Add multiple requests + RequestId requestId1 = new RequestId(UUID.randomUUID()); + HttpRequest httpRequest1 = new HttpRequest(POST, "/session"); + httpRequest1.setContent(Contents.utf8String("{\"capabilities\":{\"browserName\":\"chrome\"}}")); + SessionRequest request1 = new SessionRequest(requestId1, httpRequest1, Instant.now()); + + RequestId requestId2 = new RequestId(UUID.randomUUID()); + HttpRequest httpRequest2 = new HttpRequest(POST, "/session"); + httpRequest2.setContent( + Contents.utf8String("{\"capabilities\":{\"browserName\":\"firefox\"}}")); + SessionRequest request2 = new SessionRequest(requestId2, httpRequest2, Instant.now()); + + queue.addToQueue(request1); + queue.addToQueue(request2); + + // Clear queue and verify row count + int deletedRows = queue.clearQueue(); + assertThat(deletedRows).isEqualTo(2); + + // Verify queue is actually empty + assertThat(queue.peekEmpty()).isTrue(); + } + + @Test + void addToQueueHandlesDuplicateRequestIds() { + JdbcBackedSessionQueue queue = getSessionQueue(); + RequestId requestId = new RequestId(UUID.randomUUID()); + + HttpRequest httpRequest1 = new HttpRequest(POST, "/session"); + httpRequest1.setContent(Contents.utf8String("{\"capabilities\":{\"browserName\":\"chrome\"}}")); + SessionRequest request1 = new SessionRequest(requestId, httpRequest1, Instant.now()); + + HttpRequest httpRequest2 = new HttpRequest(POST, "/session"); + httpRequest2.setContent( + Contents.utf8String("{\"capabilities\":{\"browserName\":\"firefox\"}}")); + SessionRequest request2 = new SessionRequest(requestId, httpRequest2, Instant.now()); + + // First add should succeed + HttpResponse response1 = queue.addToQueue(request1); + assertThat(response1.getStatus()).isEqualTo(200); + + // Second add with same ID should fail due to primary key constraint + HttpResponse response2 = queue.addToQueue(request2); + assertThat(response2.getStatus()).isEqualTo(500); + } + + @Test + void getQueueContentsHandlesLargeQueue() { + JdbcBackedSessionQueue queue = getSessionQueue(); + queue.clearQueue(); + + // Add multiple requests to test ordering + int numRequests = 5; + RequestId[] requestIds = new RequestId[numRequests]; + + for (int i = 0; i < numRequests; i++) { + requestIds[i] = new RequestId(UUID.randomUUID()); + HttpRequest httpRequest = new HttpRequest(POST, "/session"); + httpRequest.setContent( + Contents.utf8String("{\"capabilities\":{\"browserName\":\"browser" + i + "\"}}")); + SessionRequest request = + new SessionRequest(requestIds[i], httpRequest, Instant.now().plusSeconds(i)); + queue.addToQueue(request); + } + + var contents = queue.getQueueContents(); + assertThat(contents).hasSize(numRequests); + + // Verify ordering (oldest first) + for (int i = 0; i < numRequests; i++) { + assertThat(contents.get(i).getRequestId()).isEqualTo(requestIds[i]); + } + } + + @Test + void closeConnectionDoesNotThrowException() { + JdbcBackedSessionQueue queue = getSessionQueue(); + + // close() method should not throw any exception + assertThatCode(() -> queue.close()).doesNotThrowAnyException(); + } + + @Test + void createWithValidConfigReturnsNewSessionQueue() { + // Create a config with JDBC settings + Map configMap = + Map.of( + "sessionqueue", + Map.of( + "jdbc-url", "jdbc:hsqldb:mem:testqueue", + "jdbc-user", "SA", + "jdbc-password", ""), + "logging", Map.of("tracing", false), + "server", Map.of("registration-secret", "test-secret")); + + Config config = new MapConfig(configMap); + + // Test that create method returns a NewSessionQueue instance + NewSessionQueue queue = JdbcBackedSessionQueue.create(config); + + assertThat(queue).isNotNull(); + assertThat(queue).isInstanceOf(JdbcBackedSessionQueue.class); + assertThat(queue.isReady()).isTrue(); + + // Test basic functionality + assertThat(queue.peekEmpty()).isTrue(); + + // Clean up + if (queue instanceof JdbcBackedSessionQueue) { + ((JdbcBackedSessionQueue) queue).close(); + } + } + + @Test + void createWithInvalidJdbcUrlThrowsConfigException() { + // Create a config with invalid JDBC URL + Map configMap = + Map.of( + "sessionqueue", + Map.of( + "jdbc-url", "invalid:jdbc:url", + "jdbc-user", "SA", + "jdbc-password", ""), + "logging", Map.of("tracing", false), + "server", Map.of("registration-secret", "test-secret")); + + Config config = new MapConfig(configMap); + + // Test that create method throws ConfigException for invalid JDBC URL + assertThatThrownBy(() -> JdbcBackedSessionQueue.create(config)) + .isInstanceOf(ConfigException.class) + .hasCauseInstanceOf(SQLException.class); + } + + @Test + void createWithMissingConfigThrowsException() { + // Create a config missing required JDBC settings + Map configMap = + Map.of( + "logging", Map.of("tracing", false), + "server", Map.of("registration-secret", "test-secret")); + + Config config = new MapConfig(configMap); + + // Test that create method throws JdbcException for missing config + assertThatThrownBy(() -> JdbcBackedSessionQueue.create(config)) + .isInstanceOf(JdbcException.class) + .hasMessageContaining("Missing JDBC Url value"); + } + + private JdbcBackedSessionQueue getSessionQueue() { + return new JdbcBackedSessionQueue(tracer, secret, connection); + } +}