diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AbstractKeyspaceIntegrationTestBase.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AbstractKeyspaceIntegrationTestBase.java index 82a05f00bf..43f3380698 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AbstractKeyspaceIntegrationTestBase.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/AbstractKeyspaceIntegrationTestBase.java @@ -352,7 +352,7 @@ protected boolean executeCqlStatement(SimpleStatement... statements) { */ private synchronized CqlSession createDriverSession() { if (cqlSession == null) { - int port = Integer.getInteger(IntegrationTestUtils.CASSANDRA_CQL_PORT_PROP); + int port = getCassandraCqlPort(); String dc; if (StargateTestResource.isDse() || StargateTestResource.isHcd()) { dc = "dc1"; @@ -369,6 +369,14 @@ private synchronized CqlSession createDriverSession() { return cqlSession; } + /** + * Gets the Cassandra CQL port. Subclasses can override this if the port is not available via + * standard system properties. + */ + protected int getCassandraCqlPort() { + return Integer.getInteger(IntegrationTestUtils.CASSANDRA_CQL_PORT_PROP); + } + /** Helper method for determining if lexical search is available for the database backend */ protected boolean isLexicalAvailableForDB() { return !"true".equals(System.getProperty("testing.db.lexical-disabled")); diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/SessionEvictionIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/SessionEvictionIntegrationTest.java new file mode 100644 index 0000000000..b144bccb6d --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/SessionEvictionIntegrationTest.java @@ -0,0 +1,204 @@ +package io.stargate.sgv2.jsonapi.api.v1; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +import com.github.dockerjava.api.DockerClient; +import io.quarkus.logging.Log; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.stargate.sgv2.jsonapi.testresource.DseTestResource; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +@QuarkusIntegrationTest +@QuarkusTestResource( + value = SessionEvictionIntegrationTest.SessionEvictionTestResource.class, + restrictToAnnotatedClass = true) +public class SessionEvictionIntegrationTest extends AbstractCollectionIntegrationTestBase { + + /** + * A specialized TestResource that spins up a new HCD/DSE container exclusively for this test + * class. + * + *

Unlike the standard {@link DseTestResource} used by other tests, this resource ensures a + * dedicated database instance. This isolation is crucial because this test involves destructive + * operations that would negatively impact other tests sharing a common database. + */ + public static class SessionEvictionTestResource extends DseTestResource { + + /** + * Holds the reference to the container started by this resource. + * + *

This field is {@code static} to act as a bridge between the {@link QuarkusTestResource} + * lifecycle (which manages the resource instance) and the test instance (where we need to + * access the container to perform operations). + */ + private static GenericContainer sessionEvictionCassandraContainer; + + /** + * Starts the container and captures the reference. + * + *

We override this method to capture the container instance created by the superclass into + * our static {@link #sessionEvictionCassandraContainer} field, making it accessible to the test + * methods. + */ + @Override + public Map start() { + Map props = super.start(); + sessionEvictionCassandraContainer = super.getCassandraContainer(); + return props; + } + + /** + * Overridden to strictly prevent system property pollution. + * + *

The standard {@link DseTestResource} publishes connection details (like CQL port) to + * global System Properties. Since this test runs in parallel with others, publishing our + * isolated container's details would overwrite the shared container's configuration, causing + * other tests to connect to this container (which we are about to kill), leading to random + * failures in the test suite. + */ + @Override + protected void exposeSystemProperties(Map props) { + // No-op: Do not expose system properties to avoid interfering with other tests running in + // parallel + } + + public static GenericContainer getSessionEvictionCassandraContainer() { + return sessionEvictionCassandraContainer; + } + } + + /** + * Overridden to ensure we connect to the isolated container created for this test. + * + *

The base class implementation relies on global system properties, which point to the shared + * database container. To ensure this test correctly interacts with its dedicated container, we + * must retrieve the port directly from the isolated container instance. + */ + @Override + protected int getCassandraCqlPort() { + GenericContainer container = + SessionEvictionTestResource.getSessionEvictionCassandraContainer(); + if (container == null) { + throw new IllegalStateException("Session eviction IT Cassandra container not started!"); + } + return container.getMappedPort(9042); + } + + @Test + public void testSessionEvictionOnAllNodesFailed() throws Exception { + + // 1. Insert initial data to ensure the database is healthy before the test + insertDoc( + """ + { + "insertOne": { + "document": { + "name": "before_crash" + } + } + } + """); + + // 2. Stop the container to simulate DB failure + // IMPORTANT: We use dockerClient.stopContainerCmd() instead of container.stop(). + // - container.stop() (Testcontainers) removes the container, so a restart would create a NEW + // container with a NEW random port. + // - dockerClient.stopContainerCmd() (Docker API) simply halts the process but keeps the + // container (and its port mapping) intact. + GenericContainer dbContainer = + SessionEvictionTestResource.getSessionEvictionCassandraContainer(); + DockerClient dockerClient = dbContainer.getDockerClient(); + String containerId = dbContainer.getContainerId(); + + Log.error("Stopping Database Container to simulate failure..."); + dockerClient.stopContainerCmd(containerId).exec(); + + try { + // 3. Verify failure: The application should receive a 500 error/AllNodesFailedException + givenHeadersPostJsonThen( + """ + { + "insertOne": { + "document": { + "name": "after_crash" + } + } + } + """) + .statusCode(500) + .body("errors[0].message", containsString("No node was available")); + + } finally { + // 4. Restart the container to simulate recovery + Log.error("Restarting Database Container to simulate recovery..."); + dockerClient.startContainerCmd(containerId).exec(); + } + + // 5. Wait for the database to become responsive again + waitForDbRecovery(); + + // 6. Verify Session Recovery: The application should have evicted the bad session + // and created a new one automatically. + insertDoc( + """ + { + "insertOne": { + "document": { + "name": "after_recovery" + } + } + } + """); + + Log.error("Test Passed: Session recovered after DB restart."); + } + + /** Polls the database until it becomes responsive again. */ + private void waitForDbRecovery() { + Log.error("Waiting for DB to recover..."); + long start = System.currentTimeMillis(); + long timeout = 60000; // 60 seconds timeout + + while (System.currentTimeMillis() - start < timeout) { + try { + // Perform a lightweight check (e.g., an empty find) to see if DB responds + String json = + """ + { + "find": { + "filter": {} + } + } + """; + + int statusCode = + given() + .port(getTestPort()) + .headers(getHeaders()) + .contentType(io.restassured.http.ContentType.JSON) + .body(json) + .when() + .post(CollectionResource.BASE_PATH, keyspaceName, collectionName) + .getStatusCode(); + + // 200 OK means the DB handled the request (even if empty result) + if (statusCode == 200) { + Log.error("DB recovered!"); + return; + } + } catch (Exception e) { + // Ignore connection errors and continue retrying + } + + try { + Thread.sleep(1000); // Poll every 1s + } catch (InterruptedException ignored) { + } + } + throw new RuntimeException("DB failed to recover within " + timeout + "ms"); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/testresource/DseTestResource.java b/src/test/java/io/stargate/sgv2/jsonapi/testresource/DseTestResource.java index 61e7fb685b..f234d9445d 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/testresource/DseTestResource.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/testresource/DseTestResource.java @@ -150,14 +150,24 @@ public Map start() { propsBuilder.put("stargate.jsonapi.embedding.providers.vertexai.enabled", "true"); propsBuilder.put( "stargate.jsonapi.embedding.providers.vertexai.models[0].parameters[0].required", "true"); + + // Prefer instance-specific configuration from 'env' to support parallel execution and + // isolation. Fall back to global system properties only if instance-specific values are + // missing. if (this.containerNetworkId.isPresent()) { - String host = System.getProperty("stargate.int-test.cassandra.host"); + String host = + env.getOrDefault( + "stargate.int-test.cassandra.host", + System.getProperty("stargate.int-test.cassandra.host")); propsBuilder.put("stargate.jsonapi.operations.database-config.cassandra-end-points", host); } else { - int port = Integer.getInteger(IntegrationTestUtils.CASSANDRA_CQL_PORT_PROP); - propsBuilder.put( - "stargate.jsonapi.operations.database-config.cassandra-port", String.valueOf(port)); + String port = + env.getOrDefault( + IntegrationTestUtils.CASSANDRA_CQL_PORT_PROP, + System.getProperty(IntegrationTestUtils.CASSANDRA_CQL_PORT_PROP)); + propsBuilder.put("stargate.jsonapi.operations.database-config.cassandra-port", port); } + if (isDse() || isHcd()) { propsBuilder.put("stargate.jsonapi.operations.database-config.local-datacenter", "dc1"); } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/testresource/StargateTestResource.java b/src/test/java/io/stargate/sgv2/jsonapi/testresource/StargateTestResource.java index 60627c6097..d758a30eb1 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/testresource/StargateTestResource.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/testresource/StargateTestResource.java @@ -3,6 +3,7 @@ import com.google.common.collect.ImmutableMap; import io.quarkus.test.common.DevServicesContext; import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import io.stargate.sgv2.jsonapi.api.v1.util.IntegrationTestUtils; import java.time.Duration; import java.util.Collections; import java.util.Map; @@ -60,7 +61,8 @@ public Map start() { "stargate.int-test.cassandra.host", cassandraContainer.getCurrentContainerInfo().getConfig().getHostName()); propsBuilder.put( - "stargate.int-test.cassandra.cql-port", cassandraContainer.getMappedPort(9042).toString()); + IntegrationTestUtils.CASSANDRA_CQL_PORT_PROP, + cassandraContainer.getMappedPort(9042).toString()); propsBuilder.put("stargate.int-test.cluster.persistence", getPersistenceModule()); // 2. Runtime Limits Config: Override default limits for testing purposes @@ -83,11 +85,21 @@ public Map start() { propsBuilder.put("stargate.jsonapi.operations.vectorize-enabled", "true"); ImmutableMap props = propsBuilder.build(); - props.forEach(System::setProperty); + exposeSystemProperties(props); LOG.info("Using props map for the integration tests: %s".formatted(props)); return props; } + /** + * Exposes the provided configuration properties as system properties. This makes the database + * connection and test-specific settings available to the whole test environment. + * + * @param props A map containing the properties to be set as system properties. + */ + protected void exposeSystemProperties(Map props) { + props.forEach(System::setProperty); + } + @Override public void stop() { if (null != cassandraContainer && !cassandraContainer.isShouldBeReused()) { @@ -110,6 +122,17 @@ public static String getPersistenceModule() { "testing.containers.cluster-persistence", "persistence-cassandra-4.0"); } + /** + * Provides access to the backend database container. This allows subclasses to perform operations + * directly on the container, such as stop/start or pause/unpause it to simulate failure + * scenarios. + * + * @return The generic container instance for the backend database. + */ + protected GenericContainer getCassandraContainer() { + return cassandraContainer; + } + public static boolean isDse() { String dse = System.getProperty("testing.containers.cluster-dse", null); return "true".equals(dse);