diff --git a/CHANGES.txt b/CHANGES.txt index 5e6d18b99..d552f3e43 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,6 @@ 0.5.0 ----- + * Add sidecar.instance.id Spark conf to append an instanceId query parameter to outbound sidecar requests, fixing 421 errors when Sidecar is behind a load balancer; contact points can also declare their own per-instance id via a "host[:port]=" suffix (CASSANALYTICS-177) * CDC logs NPE for deleted column values (CASSANALYTICS-178) * Add Cassandra 6.0 support (CASSANALYTICS-37) * Eliminate redundant filesystem lookups in SSTable direct streaming (CASSANALYTICS-104) diff --git a/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstance.java b/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstance.java index 1d2d14829..26b977b02 100644 --- a/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstance.java +++ b/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstance.java @@ -32,4 +32,20 @@ public interface SidecarInstance * @return the hostname where the Cassandra Sidecar instance is running */ String hostname(); + + /** + * Returns the identifier of the specific Cassandra instance that requests sent to this Sidecar + * endpoint should be routed to, or {@code null} when no per-instance identifier is configured. + * + *

When non-null, this value is used to populate the {@code instanceId} query parameter on outbound + * requests so the Sidecar can resolve the correct local Cassandra instance even when a shared address + * (for example a load balancer) hides the real target from the {@code Host} header. When {@code null}, + * the client falls back to the job-level {@code instanceId} configured on the HTTP client, if any. + * + * @return the per-instance identifier, or {@code null} when not set + */ + default Integer instanceId() + { + return null; + } } diff --git a/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstanceImpl.java b/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstanceImpl.java index 751218b16..f3bbf3b1b 100644 --- a/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstanceImpl.java +++ b/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/client/SidecarInstanceImpl.java @@ -27,22 +27,45 @@ public class SidecarInstanceImpl implements SidecarInstance { protected int port; protected String hostname; + protected Integer instanceId; /** - * Constructs a new Sidecar instance with the given {@code port} and {@code hostname} + * Constructs a new Sidecar instance with the given {@code port} and {@code hostname} and no + * per-instance identifier (requests fall back to the job-level {@code instanceId}, if any). * * @param hostname the host name where Sidecar is running * @param port the port where Sidecar is running */ public SidecarInstanceImpl(String hostname, int port) + { + this(hostname, port, null); + } + + /** + * Constructs a new Sidecar instance with the given {@code hostname}, {@code port} and per-instance + * {@code instanceId}. + * + * @param hostname the host name where Sidecar is running + * @param port the port where Sidecar is running + * @param instanceId the identifier of the Cassandra instance that requests sent to this Sidecar + * endpoint should be routed to, or {@code null} to fall back to the job-level + * {@code instanceId} + */ + public SidecarInstanceImpl(String hostname, int port, Integer instanceId) { if (port < 1 || port > 65535) { throw new IllegalArgumentException(String.format("Invalid port number for the Sidecar service: %d", port)); } + if (instanceId != null && instanceId < 0) + { + throw new IllegalArgumentException(String.format("Invalid instanceId for the Sidecar service: %d", + instanceId)); + } this.port = port; this.hostname = Objects.requireNonNull(hostname, "The Sidecar hostname must be non-null"); + this.instanceId = instanceId; } /** @@ -63,6 +86,15 @@ public String hostname() return hostname; } + /** + * {@inheritDoc} + */ + @Override + public Integer instanceId() + { + return instanceId; + } + /** * {@inheritDoc} */ @@ -78,7 +110,7 @@ public boolean equals(Object o) return false; } SidecarInstanceImpl that = (SidecarInstanceImpl) o; - return port == that.port && Objects.equals(hostname, that.hostname); + return port == that.port && Objects.equals(hostname, that.hostname) && Objects.equals(instanceId, that.instanceId); } /** @@ -87,7 +119,7 @@ public boolean equals(Object o) @Override public int hashCode() { - return Objects.hash(port, hostname); + return Objects.hash(port, hostname, instanceId); } /** @@ -99,6 +131,7 @@ public String toString() return "SidecarInstanceImpl{" + "port=" + port + ", hostname='" + hostname + '\'' + + ", instanceId=" + instanceId + '}'; } } diff --git a/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/common/http/SidecarQueryParamNames.java b/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/common/http/SidecarQueryParamNames.java new file mode 100644 index 000000000..6b615d261 --- /dev/null +++ b/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/common/http/SidecarQueryParamNames.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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.apache.cassandra.sidecar.common.http; + +/** + * Custom query parameter names for sidecar HTTP requests. + */ +public final class SidecarQueryParamNames +{ + /** + * {@code "instanceId"} query parameter. When present on an outbound sidecar request it carries + * the job-level instance identifier supplied by the client (see the Spark conf key + * {@code spark.cassandra_analytics.sidecar.instance.id}). + * + *

Requires a Sidecar server >= 0.2.0 (see {@code AbstractHandler#host}, introduced in + * CASSSIDECAR-208); older servers do not resolve this parameter and requests will fall back to + * Host-header-based instance resolution. + */ + public static final String INSTANCE_ID = "instanceId"; + + private SidecarQueryParamNames() + { + } +} diff --git a/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/common/response/TokenRangeReplicasResponse.java b/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/common/response/TokenRangeReplicasResponse.java index 3c79c4ce8..eee0878d7 100644 --- a/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/common/response/TokenRangeReplicasResponse.java +++ b/analytics-sidecar-client-common/src/main/java/org/apache/cassandra/sidecar/common/response/TokenRangeReplicasResponse.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.apache.cassandra.sidecar.common.request.TokenRangeReplicasRequest; +import org.jetbrains.annotations.Nullable; /** * Class response for the {@link TokenRangeReplicasRequest} @@ -179,13 +180,16 @@ public static class ReplicaMetadata private final String address; private final int port; private final String datacenter; + @Nullable + private final Integer sidecarInstanceId; public ReplicaMetadata(@JsonProperty("state") String state, @JsonProperty("status") String status, @JsonProperty("fqdn") String fqdn, @JsonProperty("address") String address, @JsonProperty("port") int port, - @JsonProperty("datacenter") String datacenter) + @JsonProperty("datacenter") String datacenter, + @JsonProperty("sidecarInstanceId") @Nullable Integer sidecarInstanceId) { this.state = state; this.status = status; @@ -193,6 +197,7 @@ public ReplicaMetadata(@JsonProperty("state") String state, this.address = address; this.port = port; this.datacenter = datacenter; + this.sidecarInstanceId = sidecarInstanceId; } /** @@ -249,6 +254,18 @@ public String datacenter() return datacenter; } + /** + * @return the id of the Sidecar instance that manages this replica, or {@code null} when the Sidecar + * that served the request does not manage this replica. Used to route per-replica requests through a + * load balancer via the {@code instanceId} query parameter. + */ + @JsonProperty("sidecarInstanceId") + @Nullable + public Integer sidecarInstanceId() + { + return sidecarInstanceId; + } + /** * {@inheritDoc} */ @@ -261,6 +278,7 @@ public String toString() ", address='" + address + '\'' + ", port='" + port + '\'' + ", datacenter='" + datacenter + '\'' + + ", sidecarInstanceId=" + sidecarInstanceId + '}'; } } diff --git a/analytics-sidecar-client/src/test/java/org/apache/cassandra/sidecar/client/HttpClientConfigTest.java b/analytics-sidecar-client/src/test/java/org/apache/cassandra/sidecar/client/HttpClientConfigTest.java index a2c1db659..0f0ae41b1 100644 --- a/analytics-sidecar-client/src/test/java/org/apache/cassandra/sidecar/client/HttpClientConfigTest.java +++ b/analytics-sidecar-client/src/test/java/org/apache/cassandra/sidecar/client/HttpClientConfigTest.java @@ -159,4 +159,5 @@ void testCassandraRole() HttpClientConfig config = new HttpClientConfig.Builder<>().cassandraRole("custom_role").build(); assertThat(config.cassandraRole()).isEqualTo("custom_role"); } + } diff --git a/analytics-sidecar-client/src/test/java/org/apache/cassandra/sidecar/client/SidecarInstanceImplTest.java b/analytics-sidecar-client/src/test/java/org/apache/cassandra/sidecar/client/SidecarInstanceImplTest.java index 8307eba75..eac97872e 100644 --- a/analytics-sidecar-client/src/test/java/org/apache/cassandra/sidecar/client/SidecarInstanceImplTest.java +++ b/analytics-sidecar-client/src/test/java/org/apache/cassandra/sidecar/client/SidecarInstanceImplTest.java @@ -18,6 +18,11 @@ package org.apache.cassandra.sidecar.client; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + /** * Unit tests for the {@link SidecarInstanceImpl} class */ @@ -28,4 +33,39 @@ protected SidecarInstance newInstance(String hostname, int port) { return new SidecarInstanceImpl(hostname, port); } + + @Test + void testInstanceIdDefaultsToNull() + { + assertThat(new SidecarInstanceImpl("localhost", 8080).instanceId()).isNull(); + } + + @Test + void testInstanceIdIsRetained() + { + assertThat(new SidecarInstanceImpl("localhost", 8080, 2).instanceId()).isEqualTo(2); + assertThat(new SidecarInstanceImpl("localhost", 8080, 0).instanceId()).isEqualTo(0); + } + + @Test + void testNegativeInstanceIdRejected() + { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new SidecarInstanceImpl("localhost", 8080, -1)) + .withMessageContaining("Invalid instanceId for the Sidecar service: -1"); + } + + @Test + void testEqualityDistinguishesInstanceId() + { + SidecarInstance a = new SidecarInstanceImpl("localhost", 8080, 1); + SidecarInstance b = new SidecarInstanceImpl("localhost", 8080, 2); + SidecarInstance c = new SidecarInstanceImpl("localhost", 8080, 1); + SidecarInstance noId = new SidecarInstanceImpl("localhost", 8080); + + assertThat(a).isEqualTo(c); + assertThat(a).hasSameHashCodeAs(c); + assertThat(a).isNotEqualTo(b); + assertThat(a).isNotEqualTo(noId); + } } diff --git a/analytics-sidecar-vertx-client/src/main/java/org/apache/cassandra/sidecar/client/VertxHttpClient.java b/analytics-sidecar-vertx-client/src/main/java/org/apache/cassandra/sidecar/client/VertxHttpClient.java index f4c3890df..c756dfbdc 100644 --- a/analytics-sidecar-vertx-client/src/main/java/org/apache/cassandra/sidecar/client/VertxHttpClient.java +++ b/analytics-sidecar-vertx-client/src/main/java/org/apache/cassandra/sidecar/client/VertxHttpClient.java @@ -58,6 +58,7 @@ import org.apache.cassandra.sidecar.common.request.UploadableRequest; import static org.apache.cassandra.sidecar.common.http.SidecarHttpHeaderNames.AUTH_ROLE; +import static org.apache.cassandra.sidecar.common.http.SidecarQueryParamNames.INSTANCE_ID; import static org.apache.cassandra.sidecar.common.utils.StringUtils.isNullOrEmpty; /** @@ -252,6 +253,17 @@ protected HttpRequest vertxRequest(SidecarInstance sidecarInstance, Requ sidecarInstance.hostname(), request.requestURI()); + // Use the id resolved for the specific instance this request is being sent to, so requests + // fanned out across multiple instances each get the correct id. + Integer instanceId = sidecarInstance.instanceId(); + if (instanceId != null) + { + vertxRequest = vertxRequest.addQueryParam(INSTANCE_ID, String.valueOf(instanceId)); + LOGGER.debug("Appended {}={} to request uri. instance={}:{}, originalUri={}, finalUri={}", + INSTANCE_ID, instanceId, sidecarInstance.hostname(), sidecarInstance.port(), + request.requestURI(), vertxRequest.uri()); + } + vertxRequest = applyHeaders(vertxRequest, request.headers()); Map customHeaders = context.customHeaders(); diff --git a/analytics-sidecar-vertx-client/src/test/java/org/apache/cassandra/sidecar/client/VertxHttpClientTest.java b/analytics-sidecar-vertx-client/src/test/java/org/apache/cassandra/sidecar/client/VertxHttpClientTest.java index 8d8d0952d..1698541f5 100644 --- a/analytics-sidecar-vertx-client/src/test/java/org/apache/cassandra/sidecar/client/VertxHttpClientTest.java +++ b/analytics-sidecar-vertx-client/src/test/java/org/apache/cassandra/sidecar/client/VertxHttpClientTest.java @@ -22,11 +22,15 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import io.netty.handler.codec.http.HttpMethod; import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.ext.web.client.HttpRequest; +import org.apache.cassandra.sidecar.common.request.Request; + import static org.apache.cassandra.sidecar.common.http.SidecarHttpHeaderNames.AUTH_ROLE; +import static org.apache.cassandra.sidecar.common.http.SidecarQueryParamNames.INSTANCE_ID; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -56,16 +60,67 @@ public void testAuthHeaderSet() HttpClientConfig config = httpClientConfigBuilder().cassandraRole("custom_role").build(); try (VertxHttpClient client = new VertxHttpClient(vertx, config)) { - SidecarInstance instance = mock(SidecarInstance.class); - when(instance.port()).thenReturn(9043); - when(instance.hostname()).thenReturn("localhost"); RequestContext context = new RequestContext.Builder().ringRequest().build(); - HttpRequest request = client.vertxRequest(instance, context); + HttpRequest request = client.vertxRequest(mockInstance(), context); assertThat(request.headers()).isNotEmpty(); assertThat(request.headers().get(AUTH_ROLE)).isEqualTo("custom_role"); } } + @Test + public void testInstanceIdQueryParamAppended() + { + HttpClientConfig config = httpClientConfigBuilder().build(); + try (VertxHttpClient client = new VertxHttpClient(vertx, config)) + { + RequestContext context = new RequestContext.Builder().ringRequest().build(); + HttpRequest request = client.vertxRequest(mockInstance(42), context); + assertThat(request.queryParams().get(INSTANCE_ID)).isEqualTo("42"); + } + } + + @Test + public void testInstanceIdQueryParamNotAppendedWhenNull() + { + HttpClientConfig config = httpClientConfigBuilder().build(); + try (VertxHttpClient client = new VertxHttpClient(vertx, config)) + { + RequestContext context = new RequestContext.Builder().ringRequest().build(); + HttpRequest request = client.vertxRequest(mockInstance(), context); + assertThat(request.queryParams().contains(INSTANCE_ID)).isFalse(); + } + } + + @Test + public void testInstanceIdQueryParamAppendedWithExistingQueryParams() + { + HttpClientConfig config = httpClientConfigBuilder().build(); + try (VertxHttpClient client = new VertxHttpClient(vertx, config)) + { + Request mockRequest = mock(Request.class); + when(mockRequest.method()).thenReturn(HttpMethod.GET); + when(mockRequest.requestURI()).thenReturn("/api/v1/ring?existingParam=value"); + RequestContext context = new RequestContext.Builder().request(mockRequest).build(); + HttpRequest request = client.vertxRequest(mockInstance(7), context); + assertThat(request.queryParams().get("existingParam")).isEqualTo("value"); + assertThat(request.queryParams().get(INSTANCE_ID)).isEqualTo("7"); + } + } + + private SidecarInstance mockInstance() + { + return mockInstance(null); + } + + private SidecarInstance mockInstance(Integer instanceId) + { + SidecarInstance instance = mock(SidecarInstance.class); + when(instance.port()).thenReturn(9043); + when(instance.hostname()).thenReturn("localhost"); + when(instance.instanceId()).thenReturn(instanceId); + return instance; + } + private HttpClientConfig.Builder httpClientConfigBuilder() { return new HttpClientConfig.Builder<>() diff --git a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/common/model/CassandraInstance.java b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/common/model/CassandraInstance.java index fed2f2d59..f546c9d34 100644 --- a/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/common/model/CassandraInstance.java +++ b/cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/common/model/CassandraInstance.java @@ -55,4 +55,20 @@ public interface CassandraInstance extends TokenOwner * @return status of the node */ NodeStatus nodeStatus(); + + /** + * Returns the identifier of the specific Cassandra instance that a shared Sidecar endpoint should route + * requests to, or {@code null} when not configured. + * + *

This is only meaningful when a single Sidecar endpoint (for example, one fronted by a load balancer) + * fronts more than one Cassandra instance: the id disambiguates which local instance a request targets, + * since the endpoint alone (hostname/Host header) cannot. When {@code null}, callers fall back to a + * job-level default, if any. + * + * @return the per-instance Sidecar routing id, or {@code null} when not set + */ + default @Nullable Integer sidecarInstanceId() + { + return null; + } } diff --git a/cassandra-analytics-core/src/main/java/org/apache/cassandra/clients/AnalyticsSidecarClient.java b/cassandra-analytics-core/src/main/java/org/apache/cassandra/clients/AnalyticsSidecarClient.java index 2096a60a9..6f15602bf 100644 --- a/cassandra-analytics-core/src/main/java/org/apache/cassandra/clients/AnalyticsSidecarClient.java +++ b/cassandra-analytics-core/src/main/java/org/apache/cassandra/clients/AnalyticsSidecarClient.java @@ -45,20 +45,7 @@ public static SidecarClient from(SidecarInstancesProvider sidecarInstancesProvid Vertx vertx = Vertx.vertx(new VertxOptions().setUseDaemonThread(true) .setWorkerPoolSize(conf.getMaxHttpConnections())); - String userAgent = transportModeBasedWriterUserAgent(conf.getTransportInfo().getTransport()); - HttpClientConfig httpClientConfig = new HttpClientConfig.Builder<>() - .timeoutMillis(conf.getHttpResponseTimeoutMs()) - .idleTimeoutMillis(conf.getHttpConnectionTimeoutMs()) - .userAgent(userAgent) - .keyStoreInputStream(conf.getKeyStore()) - .keyStorePassword(conf.getKeyStorePassword()) - .keyStoreType(conf.getKeyStoreTypeOrDefault()) - .trustStoreInputStream(conf.getTrustStore()) - .trustStorePassword(conf.getTrustStorePasswordOrDefault()) - .trustStoreType(conf.getTrustStoreTypeOrDefault()) - .ssl(conf.hasKeystoreAndKeystorePassword()) - .cassandraRole(conf.getCassandraRole()) - .build(); + HttpClientConfig httpClientConfig = buildHttpClientConfig(conf); StartupValidator.instance().register(new SslValidation(conf)); StartupValidator.instance().register(new BulkWriterKeyStoreValidation(conf)); @@ -74,6 +61,24 @@ public static SidecarClient from(SidecarInstancesProvider sidecarInstancesProvid return Sidecar.buildClient(sidecarConfig, vertx, httpClientConfig, sidecarInstancesProvider); } + static HttpClientConfig buildHttpClientConfig(BulkSparkConf conf) + { + String userAgent = transportModeBasedWriterUserAgent(conf.getTransportInfo().getTransport()); + return new HttpClientConfig.Builder<>() + .timeoutMillis(conf.getHttpResponseTimeoutMs()) + .idleTimeoutMillis(conf.getHttpConnectionTimeoutMs()) + .userAgent(userAgent) + .keyStoreInputStream(conf.getKeyStore()) + .keyStorePassword(conf.getKeyStorePassword()) + .keyStoreType(conf.getKeyStoreTypeOrDefault()) + .trustStoreInputStream(conf.getTrustStore()) + .trustStorePassword(conf.getTrustStorePasswordOrDefault()) + .trustStoreType(conf.getTrustStoreTypeOrDefault()) + .ssl(conf.hasKeystoreAndKeystorePassword()) + .cassandraRole(conf.getCassandraRole()) + .build(); + } + static String transportModeBasedWriterUserAgent(DataTransport transport) { switch (transport) diff --git a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/CassandraClusterInfo.java b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/CassandraClusterInfo.java index 3f7523bc0..2cbce5da3 100644 --- a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/CassandraClusterInfo.java +++ b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/CassandraClusterInfo.java @@ -45,6 +45,7 @@ import o.a.c.sidecar.client.shaded.common.response.SchemaResponse; import o.a.c.sidecar.client.shaded.common.response.TimeSkewResponse; import o.a.c.sidecar.client.shaded.common.response.TokenRangeReplicasResponse; +import o.a.c.sidecar.client.shaded.common.response.TokenRangeReplicasResponse.ReplicaMetadata; import org.apache.cassandra.bridge.CassandraBridge; import org.apache.cassandra.bridge.CassandraBridgeFactory; import org.apache.cassandra.bridge.CassandraVersion; @@ -253,7 +254,8 @@ void validateTimeSkewWithLocalNow(Range range, Instant localNow) thr .stream() .flatMap(Collection::stream) .distinct() // remove duplications - .map(replica -> new SidecarInstanceImpl(replica.nodeName(), getCassandraContext().sidecarPort())) + .map(replica -> new SidecarInstanceImpl(replica.nodeName(), getCassandraContext().sidecarPort(), + replica.sidecarInstanceId())) .collect(Collectors.toList()); timeSkew = getCassandraContext().getSidecarClient().timeSkew(instances).get(); } @@ -484,9 +486,61 @@ protected WriteAvailability determineWriteAvailability(RingInstance instance) private TokenRangeMapping getTokenRangeReplicasFromSidecar() { - return TokenRangeMapping.create(this::getTokenRangesAndReplicaSets, - this::getPartitioner, - metadata -> new RingInstance(metadata, clusterId())); + // Fallback for Sidecar versions that don't yet report a per-replica id in the ring/token-range-replicas + // response (see resolveSidecarInstanceId). Resolved from the contact points actually in effect for this + // cluster (getCluster() already picks the right source: conf.sidecarContactPoints() for a plain job, or + // conf.coordinatedWriteConf().cluster(clusterId).sidecarContactPoints() for a coordinated-write job). + // Deriving this from conf.sidecarContactPoints() directly would silently miss (or NPE on) coordinated + // writes, since their contact points don't live there. + Map instanceIdsByHostname = sidecarInstanceIdsByHostname(getCassandraContext().getCluster()); + TokenRangeMapping topology = + TokenRangeMapping.create(this::getTokenRangesAndReplicaSets, + this::getPartitioner, + metadata -> new RingInstance(metadata, clusterId(), + resolveSidecarInstanceId(metadata, instanceIdsByHostname))); + return topology; + } + + /** + * Resolves the Sidecar instance id to associate with a ring replica, preferring the id that Sidecar itself + * reports for that replica ({@link ReplicaMetadata#sidecarInstanceId()}), since Sidecar resolves it from its + * own local instance configuration keyed by the replica's address. Falls back to the statically-configured + * {@code host[:port]=} contact-point lookup only when talking to an older Sidecar that does not yet + * populate the field, in which case the {@link #sidecarInstanceIdsByHostname} limitations apply (see below). + * + * @param metadata the replica metadata returned by Sidecar for a ring entry + * @param instanceIdsByHostname the static fallback lookup built by {@link #sidecarInstanceIdsByHostname} + * @return the resolved Sidecar instance id, or {@code null} when neither source resolves one + */ + @VisibleForTesting + static Integer resolveSidecarInstanceId(ReplicaMetadata metadata, Map instanceIdsByHostname) + { + Integer dynamicId = metadata.sidecarInstanceId(); + return dynamicId != null ? dynamicId : instanceIdsByHostname.get(metadata.fqdn()); + } + + /** + * Builds a hostname (nodeName/fqdn) to per-instance Sidecar routing id lookup from the given contact points, + * e.g. ones declared as {@code "host:port="} (see {@link SidecarInstanceFactory#createFromString}). + * + *

This is only used as a fallback (see {@link #resolveSidecarInstanceId}) when Sidecar does not report a + * per-replica id itself. As a static, address-keyed lookup it has real limitations: it can only represent a + * 1:1 Sidecar-to-instance topology, where every instance has its own distinct address. If two or more + * instances share the same address with different ids configured, building this map throws + * {@code IllegalStateException: Duplicate key} — it cannot express that topology at all. The address must + * also match exactly (IP vs hostname, case) what the ring reports for that instance ({@code + * ReplicaMetadata#fqdn()}); a mismatch silently drops the configured id rather than resolving it. None of + * this applies when Sidecar reports the id dynamically, since that is resolved server-side per instance. + * + * @param contactPoints the Sidecar contact points in effect for this cluster + * @return a map of hostname to configured Sidecar instance id; entries with no configured id are omitted + */ + @VisibleForTesting + static Map sidecarInstanceIdsByHostname(Set contactPoints) + { + return contactPoints.stream() + .filter(instance -> instance.instanceId() != null) + .collect(Collectors.toMap(SidecarInstance::hostname, SidecarInstance::instanceId)); } public String getVersionFromFeature() diff --git a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/RingInstance.java b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/RingInstance.java index 5e5ed60e9..4a8fc63dd 100644 --- a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/RingInstance.java +++ b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/RingInstance.java @@ -36,13 +36,25 @@ public class RingInstance implements CassandraInstance, Serializable { - private static final long serialVersionUID = 4399143234683369652L; + private static final long serialVersionUID = 4399143234683369653L; private RingEntry ringEntry; private @Nullable String clusterId; + private @Nullable Integer sidecarInstanceId; public RingInstance(ReplicaMetadata replica, @Nullable String clusterId) + { + this(replica, clusterId, null); + } + + /** + * @param sidecarInstanceId the id of the Cassandra instance that a shared Sidecar endpoint fronting this + * instance should route requests to, or {@code null} when not configured for this + * instance (see {@link CassandraInstance#sidecarInstanceId()}) + */ + public RingInstance(ReplicaMetadata replica, @Nullable String clusterId, @Nullable Integer sidecarInstanceId) { this.clusterId = clusterId; + this.sidecarInstanceId = sidecarInstanceId; this.ringEntry = new RingEntry.Builder() .fqdn(replica.fqdn()) .address(replica.address()) @@ -61,8 +73,15 @@ public RingInstance(RingEntry ringEntry) @VisibleForTesting public RingInstance(RingEntry ringEntry, @Nullable String clusterId) + { + this(ringEntry, clusterId, null); + } + + @VisibleForTesting + public RingInstance(RingEntry ringEntry, @Nullable String clusterId, @Nullable Integer sidecarInstanceId) { this.clusterId = clusterId; + this.sidecarInstanceId = sidecarInstanceId; this.ringEntry = ringEntry; } @@ -122,13 +141,21 @@ public NodeStatus nodeStatus() return NodeStatus.fromNameIgnoreCase(ringEntry.status()); } + @Override + @Nullable + public Integer sidecarInstanceId() + { + return sidecarInstanceId; + } + /** * Custom equality that compares the token, fully qualified domain name, the rack, the port, the datacenter * and the clusterId * - * Note that node state, status and IP address are not part of the calculation. The IP address is excluded - * because a node can come back with a different IP address (e.g. a pod replacement in Kubernetes) while - * remaining the same logical instance. + * Note that node state, status, IP address and sidecarInstanceId are not part of the calculation. The IP + * address is excluded because a node can come back with a different IP address (e.g. a pod replacement in + * Kubernetes) while remaining the same logical instance. sidecarInstanceId is excluded because it is routing + * metadata derived from configuration, not part of the instance's identity. * * @param other the other instance * @return true if both instances are equal, false otherwise @@ -171,7 +198,7 @@ public int hashCode() @Override public String toString() { - return "RingInstance{cluster='" + clusterId + "', " + ringEntry.toString() + '}'; + return "RingInstance{cluster='" + clusterId + "', sidecarInstanceId=" + sidecarInstanceId + ", " + ringEntry.toString() + '}'; } public RingEntry ringEntry() @@ -194,6 +221,7 @@ private void writeObject(ObjectOutputStream out) throws IOException out.writeObject(ringEntry.load()); out.writeObject(ringEntry.owns()); out.writeObject(clusterId); + out.writeObject(sidecarInstanceId); } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException @@ -211,6 +239,7 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE String load = (String) in.readObject(); String owns = (String) in.readObject(); String clusterId = (String) in.readObject(); + Integer sidecarInstanceId = (Integer) in.readObject(); ringEntry = new RingEntry.Builder().datacenter(datacenter) .address(address) .port(port) @@ -224,5 +253,6 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE .owns(owns) .build(); this.clusterId = clusterId; + this.sidecarInstanceId = sidecarInstanceId; } } diff --git a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/SidecarDataTransferApi.java b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/SidecarDataTransferApi.java index 9199b01ca..2d98c5d27 100644 --- a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/SidecarDataTransferApi.java +++ b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/SidecarDataTransferApi.java @@ -161,6 +161,6 @@ protected String getUploadId(String sessionID, String jobId) protected SidecarInstanceImpl toSidecarInstance(CassandraInstance instance) { - return new SidecarInstanceImpl(instance.nodeName(), sidecarPort); + return new SidecarInstanceImpl(instance.nodeName(), sidecarPort, instance.sidecarInstanceId()); } } diff --git a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/WriterOptions.java b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/WriterOptions.java index 5440016ac..134df9e89 100644 --- a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/WriterOptions.java +++ b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/bulkwriter/WriterOptions.java @@ -30,6 +30,9 @@ public enum WriterOptions implements WriterOption SIDECAR_INSTANCES, // The option specifies the initial contact points of sidecar servers to discover the cluster topology // Note that the addresses can include port; when port is present, it takes precedence over SIDECAR_PORT + // Each address may also carry a trailing "=" suffix (e.g. "host:9043=2") to declare that instance's + // own per-instance Sidecar routing id - see SidecarInstanceFactory#createFromString. When an address has no + // such suffix, no instanceId is sent for that instance unless Sidecar reports one for it in the ring response. SIDECAR_CONTACT_POINTS, /** * The option specifies the configuration (in JSON) for coordinated write. diff --git a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/common/SidecarInstanceFactory.java b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/common/SidecarInstanceFactory.java index 129826b50..4dde5fa03 100644 --- a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/common/SidecarInstanceFactory.java +++ b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/common/SidecarInstanceFactory.java @@ -38,6 +38,11 @@ private SidecarInstanceFactory() /** * Create SidecarInstance object by parsing the input string, which is IP address or hostname and optionally includes port + *

The input may also carry an optional per-instance id as a trailing {@code "="} suffix, e.g. + * {@code "host:9043=2"}. The id identifies which local Cassandra instance the receiving Sidecar should route + * requests to; it is used to populate the {@code instanceId} query parameter per instance. {@code '='} cannot + * appear in a hostname, IPv4/IPv6 address or port, so the suffix is unambiguous. When absent, no per-instance + * {@code instanceId} is sent for that instance (unless Sidecar reports one for it in the ring/token-range response). * @param input hostname string that can optionally includes the port. If port is present, the defaultPort param is ignored. * @param defaultPort port value used when the input string contains no port * @return SidecarInstanceImpl @@ -46,22 +51,42 @@ public static SidecarInstanceImpl createFromString(String input, int defaultPort { Preconditions.checkArgument(StringUtils.isNotEmpty(input), "Unable to create sidecar instance from empty input"); - String hostname = input; + String address = input; + Integer instanceId = null; + // Optional per-instance id, expressed as a trailing "=" suffix (e.g. "host:9043=2"). + int equalsIndex = input.lastIndexOf('='); + if (equalsIndex >= 0) + { + String instanceIdStr = input.substring(equalsIndex + 1).trim(); + try + { + instanceId = Integer.parseInt(instanceIdStr); + } + catch (NumberFormatException e) + { + throw new IllegalArgumentException( + String.format("Invalid sidecar instanceId '%s' in '%s'; expected a non-negative integer", instanceIdStr, input), e); + } + Preconditions.checkArgument(instanceId >= 0, "Sidecar instanceId must be non-negative; got %s in '%s'", instanceId, input); + address = input.substring(0, equalsIndex); + } + + String hostname = address; int port = defaultPort; // has port in the string. The former matches ipv6 and the latter matches ipv4 and hostnames // ipv6 with port example: [2024:a::1]:8080 - if (input.contains("]:") || (!input.startsWith("[") && input.contains(":"))) + if (address.contains("]:") || (!address.startsWith("[") && address.contains(":"))) { - int index = input.lastIndexOf(':'); - hostname = input.substring(0, index); // includes ']' if it is ipv6 - String portStr = input.substring(index + 1); + int index = address.lastIndexOf(':'); + hostname = address.substring(0, index); // includes ']' if it is ipv6 + String portStr = address.substring(index + 1); port = Integer.parseInt(portStr); } Preconditions.checkState(port != -1, "Unable to resolve port from %s", input); - LOGGER.info("Create sidecar instance. hostname={} port={}", hostname, port); - return new SidecarInstanceImpl(hostname, port); + LOGGER.info("Create sidecar instance. hostname={} port={} instanceId={}", hostname, port, instanceId); + return new SidecarInstanceImpl(hostname, port, instanceId); } /** diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/CassandraClusterInfoTest.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/CassandraClusterInfoTest.java index 99b06712f..597c7a41c 100644 --- a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/CassandraClusterInfoTest.java +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/CassandraClusterInfoTest.java @@ -21,8 +21,12 @@ import java.time.Duration; import java.time.Instant; +import java.util.Map; +import java.util.Set; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -36,12 +40,16 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import o.a.c.sidecar.client.shaded.client.SidecarInstance; import o.a.c.sidecar.client.shaded.common.response.NodeSettings; import o.a.c.sidecar.client.shaded.common.response.TimeSkewResponse; +import o.a.c.sidecar.client.shaded.common.response.TokenRangeReplicasResponse.ReplicaMetadata; import org.apache.cassandra.spark.bulkwriter.token.TokenRangeMapping; +import org.apache.cassandra.spark.common.SidecarInstanceFactory; import org.apache.cassandra.spark.exception.TimeSkewTooLargeException; import static org.apache.cassandra.spark.TestUtils.range; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -120,6 +128,115 @@ public static CassandraClusterInfo mockClusterInfoForTimeSkewTest(int allowanceM return new MockClusterInfoForTimeSkew(allowanceMinutes, remoteNow); } + @Test + void testSidecarInstanceIdsByHostnameFromPlainContactPoints() + { + Set contactPoints = new HashSet<>(Arrays.asList( + SidecarInstanceFactory.createFromString("cassandra1:9043=1", 9043), + SidecarInstanceFactory.createFromString("cassandra2:9043=2", 9043), + SidecarInstanceFactory.createFromString("cassandra3:9043", 9043))); + + Map byHostname = CassandraClusterInfo.sidecarInstanceIdsByHostname(contactPoints); + + assertThat(byHostname).containsEntry("cassandra1", 1).containsEntry("cassandra2", 2); + assertThat(byHostname) + .describedAs("contact point with no '=' suffix has no entry") + .doesNotContainKey("cassandra3"); + } + + @Test + void testSidecarInstanceIdsByHostnameEmptyWhenNoneConfigured() + { + Set contactPoints = new HashSet<>(Arrays.asList( + SidecarInstanceFactory.createFromString("cassandra1:9043", 9043), + SidecarInstanceFactory.createFromString("cassandra2:9043", 9043))); + + assertThat(CassandraClusterInfo.sidecarInstanceIdsByHostname(contactPoints)).isEmpty(); + } + + @Test + void testSidecarInstanceIdsByHostnameThrowsWhenSharedHostnameHasDifferentIds() + { + // Reproduces a Sidecar deployment shared/load-balanced across multiple Cassandra instances: they are + // only reachable through the same address (e.g. one LB VIP), each meant to carry its own id. The + // current hostname-keyed lookup cannot represent this - it can only associate a single id per hostname - + // so instead of silently picking one id, it fails fast while building the lookup. + Set contactPoints = new HashSet<>(Arrays.asList( + SidecarInstanceFactory.createFromString("sidecar-lb:9043=1"), + SidecarInstanceFactory.createFromString("sidecar-lb:9043=2"), + SidecarInstanceFactory.createFromString("sidecar-lb:9043=3"))); + + assertThatThrownBy(() -> CassandraClusterInfo.sidecarInstanceIdsByHostname(contactPoints)) + .describedAs("a single shared Sidecar endpoint fronting multiple instances (e.g. behind a load balancer) " + + "cannot be expressed by a hostname->id map keyed on hostname alone") + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageContaining("Duplicate key"); + } + + @Test + void testSidecarInstanceIdsByHostnameMissesWhenAddressFormatDiffersFromRingFqdn() + { + // The lookup is keyed on the literal contact-point address string. If the ring later reports this same + // physical instance under a different string (e.g. its fqdn, when the contact point was configured by + // IP), the two never match: getTokenRangeReplicasFromSidecar's instanceIdsByHostname.get(metadata.fqdn()) + // misses, and the configured id is silently dropped for that instance - not an exception, just a null. + Set contactPoints = Collections.singleton( + SidecarInstanceFactory.createFromString("10.0.0.5:9043=1")); + + Map byHostname = CassandraClusterInfo.sidecarInstanceIdsByHostname(contactPoints); + + assertThat(byHostname).containsEntry("10.0.0.5", 1); + assertThat(byHostname) + .describedAs("the ring would report this same instance by its fqdn, not its IP - the lookup key must " + + "match exactly, so the configured id is invisible under the fqdn key") + .doesNotContainKey("node1.example.com"); + } + + @Test + void testResolveSidecarInstanceIdPrefersDynamicIdFromSidecar() + { + ReplicaMetadata metadata = new ReplicaMetadata("NORMAL", "UP", "dc1-i0", "10.0.0.5", 9042, "dc1", 7); + Map instanceIdsByHostname = Collections.singletonMap("dc1-i0", 99); + + assertThat(CassandraClusterInfo.resolveSidecarInstanceId(metadata, instanceIdsByHostname)) + .describedAs("the id Sidecar resolves for the replica itself must win over the static contact-point fallback") + .isEqualTo(7); + } + + @Test + void testResolveSidecarInstanceIdFallsBackToStaticMapWhenSidecarDoesNotReportOne() + { + ReplicaMetadata metadata = new ReplicaMetadata("NORMAL", "UP", "dc1-i0", "10.0.0.5", 9042, "dc1", null); + Map instanceIdsByHostname = Collections.singletonMap("dc1-i0", 99); + + assertThat(CassandraClusterInfo.resolveSidecarInstanceId(metadata, instanceIdsByHostname)) + .describedAs("an older Sidecar that doesn't populate sidecarInstanceId falls back to the static, " + + "operator-configured contact-point lookup") + .isEqualTo(99); + } + + @Test + void testResolveSidecarInstanceIdNullWhenNeitherSourceResolves() + { + ReplicaMetadata metadata = new ReplicaMetadata("NORMAL", "UP", "dc1-i0", "10.0.0.5", 9042, "dc1", null); + + assertThat(CassandraClusterInfo.resolveSidecarInstanceId(metadata, Collections.emptyMap())).isNull(); + } + + @Test + void testResolveSidecarInstanceIdDoesNotDependOnAddressKeyMatching() + { + // Two replicas reported under the same address string - the static, hostname-keyed fallback cannot + // express two different ids for one key (see testSidecarInstanceIdsByHostnameThrowsWhenSharedHostnameHasDifferentIds). + // Resolution here doesn't go through that lookup at all when Sidecar reports the id itself, so it isn't + // subject to that limitation. + ReplicaMetadata replicaManagedByInstance1 = new ReplicaMetadata("NORMAL", "UP", "shared-lb", "10.0.0.5", 9042, "dc1", 1); + ReplicaMetadata replicaManagedByInstance2 = new ReplicaMetadata("NORMAL", "UP", "shared-lb", "10.0.0.5", 9042, "dc1", 2); + + assertThat(CassandraClusterInfo.resolveSidecarInstanceId(replicaManagedByInstance1, Collections.emptyMap())).isEqualTo(1); + assertThat(CassandraClusterInfo.resolveSidecarInstanceId(replicaManagedByInstance2, Collections.emptyMap())).isEqualTo(2); + } + private BulkSparkConf mockBulkSparkWithSidecarConf(int requestTimeoutSeconds, long maxRetryDelayMillis, int retryCount) { BulkSparkConf conf = mock(BulkSparkConf.class); diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/RingInstanceSerializationTest.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/RingInstanceSerializationTest.java index c2ff45901..fd8c831cb 100644 --- a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/RingInstanceSerializationTest.java +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/RingInstanceSerializationTest.java @@ -67,7 +67,8 @@ public void testRingSerializesFromReplicaMetadata() dataCenter + "-i" + index, "127.0." + dcOffset + "." + index, 7000, - dataCenter); + dataCenter, + null); RingInstance ring = new RingInstance(metadata, "test-cluster"); @@ -75,4 +76,29 @@ public void testRingSerializesFromReplicaMetadata() RingInstance deserialized = deserialize(bytes, RingInstance.class); assertThat(deserialized).isEqualTo(ring); } + + @Test + public void testSidecarInstanceIdSurvivesSerialization() + { + int dcOffset = 0; + String dataCenter = "DC1"; + int index = 0; + ReplicaMetadata metadata = new ReplicaMetadata("NORMAL", + "UP", + dataCenter + "-i" + index, + "127.0." + dcOffset + "." + index, + 7000, + dataCenter, + null); + + RingInstance ring = new RingInstance(metadata, "test-cluster", 2); + + byte[] bytes = serialize(ring); + RingInstance deserialized = deserialize(bytes, RingInstance.class); + // sidecarInstanceId is excluded from equals/hashCode, so it must be checked explicitly: + // this is exactly the field that would silently revert to null if serialization dropped it, + // which would reintroduce the misrouting bug once the instance travels to an executor. + assertThat(deserialized).isEqualTo(ring); + assertThat(deserialized.sidecarInstanceId()).isEqualTo(2); + } } diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/RingInstanceTest.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/RingInstanceTest.java index b73cc272a..0aecd7157 100644 --- a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/RingInstanceTest.java +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/RingInstanceTest.java @@ -178,16 +178,42 @@ public void testToString() { RingEntry ringEntry = mockRingEntry(); RingInstance instanceWithoutClusterId = new RingInstance(ringEntry); - assertThat(instanceWithoutClusterId.toString()).isEqualTo("RingInstance{cluster='null', " + + assertThat(instanceWithoutClusterId.toString()).isEqualTo("RingInstance{cluster='null', sidecarInstanceId=null, " + "RingEntry{datacenter='DATACENTER1', address='127.0.0.1', port=0, rack='Rack', " + "status='UP', state='NORMAL', load='0', owns='', token='0', fqdn='DATACENTER1-i1', hostId=''}}"); RingInstance instanceWithClusterId = new RingInstance(ringEntry, "clusterId"); - assertThat(instanceWithClusterId.toString()).isEqualTo("RingInstance{cluster='clusterId', " + + assertThat(instanceWithClusterId.toString()).isEqualTo("RingInstance{cluster='clusterId', sidecarInstanceId=null, " + "RingEntry{datacenter='DATACENTER1', address='127.0.0.1', port=0, rack='Rack', " + "status='UP', state='NORMAL', load='0', owns='', token='0', fqdn='DATACENTER1-i1', hostId=''}}"); } + @Test + public void testSidecarInstanceIdDefaultsToNull() + { + RingInstance instance = new RingInstance(mockRingEntry()); + assertThat(instance.sidecarInstanceId()).isNull(); + } + + @Test + public void testSidecarInstanceIdIsRetained() + { + RingInstance instance = new RingInstance(mockRingEntry(), null, 2); + assertThat(instance.sidecarInstanceId()).isEqualTo(2); + } + + @Test + public void testEqualsAndHashcodeIgnoreSidecarInstanceId() + { + RingEntry ringEntry = mockRingEntry(); + RingInstance instanceWithId = new RingInstance(ringEntry, null, 1); + RingInstance instanceWithDifferentId = new RingInstance(ringEntry, null, 2); + RingInstance instanceWithoutId = new RingInstance(ringEntry); + + assertThat(instanceWithId).isEqualTo(instanceWithDifferentId).isEqualTo(instanceWithoutId); + assertThat(instanceWithId.hashCode()).isEqualTo(instanceWithDifferentId.hashCode()).isEqualTo(instanceWithoutId.hashCode()); + } + @NotNull private static RingEntry mockRingEntry() diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/SidecarDataTransferApiTest.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/SidecarDataTransferApiTest.java new file mode 100644 index 000000000..f2d4baeea --- /dev/null +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/SidecarDataTransferApiTest.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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.apache.cassandra.spark.bulkwriter; + +import org.junit.jupiter.api.Test; + +import o.a.c.sidecar.client.shaded.client.SidecarInstanceImpl; +import o.a.c.sidecar.client.shaded.common.response.data.RingEntry; +import org.apache.cassandra.bridge.CassandraBridge; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link SidecarDataTransferApi} + */ +class SidecarDataTransferApiTest +{ + @Test + void testToSidecarInstanceCarriesPerInstanceId() + { + SidecarDataTransferApi api = api(); + RingInstance instance = ringInstance("dc1-i0", 2); + + SidecarInstanceImpl sidecarInstance = api.toSidecarInstance(instance); + + assertThat(sidecarInstance.hostname()).isEqualTo("dc1-i0"); + assertThat(sidecarInstance.port()).isEqualTo(9043); + assertThat(sidecarInstance.instanceId()) + .describedAs("upload/commit/cleanup requests must carry the target instance's own id, " + + "not a single job-wide value, or a multi-node write silently misroutes") + .isEqualTo(2); + } + + @Test + void testToSidecarInstanceFallsBackToNullWhenNoPerInstanceIdConfigured() + { + SidecarDataTransferApi api = api(); + RingInstance instance = ringInstance("dc1-i1", null); + + SidecarInstanceImpl sidecarInstance = api.toSidecarInstance(instance); + + assertThat(sidecarInstance.instanceId()).isNull(); + } + + private static SidecarDataTransferApi api() + { + CassandraContext context = mock(CassandraContext.class, RETURNS_DEEP_STUBS); + when(context.sidecarPort()).thenReturn(9043); + return new SidecarDataTransferApi(context, mock(CassandraBridge.class), mock(JobInfo.class)); + } + + private static RingInstance ringInstance(String fqdn, Integer sidecarInstanceId) + { + return new RingInstance(new RingEntry.Builder() + .datacenter("dc1") + .address(fqdn) + .port(7000) + .status("UP") + .state("NORMAL") + .token("0") + .fqdn(fqdn) + .rack("rack") + .owns("") + .load("") + .hostId("") + .build(), null, sidecarInstanceId); + } +} diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TokenRangeMappingUtils.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TokenRangeMappingUtils.java index 4864144e3..f1252f809 100644 --- a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TokenRangeMappingUtils.java +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TokenRangeMappingUtils.java @@ -261,7 +261,7 @@ public static TokenRangeReplicasResponse mockSimpleTokenRangeReplicasResponse(lo String address = "localhost" + i; int port = 9042; String addressWithPort = address + ":" + port; - ReplicaMetadata rm = new ReplicaMetadata("NORMAL", "UP", address, address, 9042, "dc1"); + ReplicaMetadata rm = new ReplicaMetadata("NORMAL", "UP", address, address, 9042, "dc1", null); replicaMetadata.put(addressWithPort, rm); startToken = endToken; } diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/token/TokenRangeMappingTest.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/token/TokenRangeMappingTest.java index 92f9abad0..0b7babe58 100644 --- a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/token/TokenRangeMappingTest.java +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/token/TokenRangeMappingTest.java @@ -93,7 +93,8 @@ void testCreateTokenRangeMappingWithPending() metadata.fqdn(), metadata.address(), metadata.port(), - metadata.datacenter()); + metadata.datacenter(), + metadata.sidecarInstanceId()); response.replicaMetadata().put(key, updatedMetadata); if (state.isPending) { diff --git a/cassandra-analytics-core/src/test/spark3/org/apache/cassandra/spark/common/SidecarInstanceFactoryTest.java b/cassandra-analytics-core/src/test/spark3/org/apache/cassandra/spark/common/SidecarInstanceFactoryTest.java index 277175a7f..6c8a6663c 100644 --- a/cassandra-analytics-core/src/test/spark3/org/apache/cassandra/spark/common/SidecarInstanceFactoryTest.java +++ b/cassandra-analytics-core/src/test/spark3/org/apache/cassandra/spark/common/SidecarInstanceFactoryTest.java @@ -47,9 +47,46 @@ void testCreateSidecarInstance() "[2024:a::1]", 8888); } + @Test + void testCreateSidecarInstanceWithInstanceId() + { + assertSidecarInstance(SidecarInstanceFactory.createFromString("localhost:8888=2", 9999), + "localhost", 8888, 2); + assertSidecarInstance(SidecarInstanceFactory.createFromString("127.0.0.1:8888=0", 9999), + "127.0.0.1", 8888, 0); + // no explicit port: default port applies, id still parsed + assertSidecarInstance(SidecarInstanceFactory.createFromString("localhost=3", 9999), + "localhost", 9999, 3); + // ipv6 with port and id + assertSidecarInstance(SidecarInstanceFactory.createFromString("[2024:a::1]:8888=7", 9999), + "[2024:a::1]", 8888, 7); + // no id: instanceId is null (falls back to the job-level value) + assertSidecarInstance(SidecarInstanceFactory.createFromString("localhost:8888", 9999), + "localhost", 8888, null); + } + + @Test + void testCreateSidecarInstanceWithInvalidInstanceId() + { + assertThatThrownBy(() -> SidecarInstanceFactory.createFromString("localhost:8888=abc", 9999)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid sidecar instanceId"); + + assertThatThrownBy(() -> SidecarInstanceFactory.createFromString("localhost:8888=-1", 9999)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-negative"); + } + private void assertSidecarInstance(SidecarInstance sidecarInstance, String expectedHostname, int expectedPort) { assertThat(sidecarInstance.hostname()).isEqualTo(expectedHostname); assertThat(sidecarInstance.port()).isEqualTo(expectedPort); } + + private void assertSidecarInstance(SidecarInstance sidecarInstance, String expectedHostname, int expectedPort, + Integer expectedInstanceId) + { + assertSidecarInstance(sidecarInstance, expectedHostname, expectedPort); + assertThat(sidecarInstance.instanceId()).isEqualTo(expectedInstanceId); + } } diff --git a/cassandra-analytics-core/src/test/spark4/org/apache/cassandra/spark/common/SidecarInstanceFactoryTest.java b/cassandra-analytics-core/src/test/spark4/org/apache/cassandra/spark/common/SidecarInstanceFactoryTest.java index 277175a7f..6c8a6663c 100644 --- a/cassandra-analytics-core/src/test/spark4/org/apache/cassandra/spark/common/SidecarInstanceFactoryTest.java +++ b/cassandra-analytics-core/src/test/spark4/org/apache/cassandra/spark/common/SidecarInstanceFactoryTest.java @@ -47,9 +47,46 @@ void testCreateSidecarInstance() "[2024:a::1]", 8888); } + @Test + void testCreateSidecarInstanceWithInstanceId() + { + assertSidecarInstance(SidecarInstanceFactory.createFromString("localhost:8888=2", 9999), + "localhost", 8888, 2); + assertSidecarInstance(SidecarInstanceFactory.createFromString("127.0.0.1:8888=0", 9999), + "127.0.0.1", 8888, 0); + // no explicit port: default port applies, id still parsed + assertSidecarInstance(SidecarInstanceFactory.createFromString("localhost=3", 9999), + "localhost", 9999, 3); + // ipv6 with port and id + assertSidecarInstance(SidecarInstanceFactory.createFromString("[2024:a::1]:8888=7", 9999), + "[2024:a::1]", 8888, 7); + // no id: instanceId is null (falls back to the job-level value) + assertSidecarInstance(SidecarInstanceFactory.createFromString("localhost:8888", 9999), + "localhost", 8888, null); + } + + @Test + void testCreateSidecarInstanceWithInvalidInstanceId() + { + assertThatThrownBy(() -> SidecarInstanceFactory.createFromString("localhost:8888=abc", 9999)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid sidecar instanceId"); + + assertThatThrownBy(() -> SidecarInstanceFactory.createFromString("localhost:8888=-1", 9999)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-negative"); + } + private void assertSidecarInstance(SidecarInstance sidecarInstance, String expectedHostname, int expectedPort) { assertThat(sidecarInstance.hostname()).isEqualTo(expectedHostname); assertThat(sidecarInstance.port()).isEqualTo(expectedPort); } + + private void assertSidecarInstance(SidecarInstance sidecarInstance, String expectedHostname, int expectedPort, + Integer expectedInstanceId) + { + assertSidecarInstance(sidecarInstance, expectedHostname, expectedPort); + assertThat(sidecarInstance.instanceId()).isEqualTo(expectedInstanceId); + } } diff --git a/cassandra-analytics-sidecar-client/src/main/java/org/apache/cassandra/clients/Sidecar.java b/cassandra-analytics-sidecar-client/src/main/java/org/apache/cassandra/clients/Sidecar.java index 78798416a..1a9269c27 100644 --- a/cassandra-analytics-sidecar-client/src/main/java/org/apache/cassandra/clients/Sidecar.java +++ b/cassandra-analytics-sidecar-client/src/main/java/org/apache/cassandra/clients/Sidecar.java @@ -235,7 +235,7 @@ else if (gossipInfoResponses.size() < gossipInfoFutures.size()) public static SidecarInstance toSidecarInstance(CassandraInstance instance, int sidecarPort) { - return new SidecarInstanceImpl(instance.nodeName(), sidecarPort); + return new SidecarInstanceImpl(instance.nodeName(), sidecarPort, instance.sidecarInstanceId()); } public static final class ClientConfig