diff --git a/.circleci/config.yml b/.circleci/config.yml index 68e5c11d0..02564dfc0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -173,6 +173,9 @@ jobs: JDK_VERSION: "11" SPARK_VERSION: "3" SCALA_VERSION: "2.12" + # No downstream job of this one tests Cassandra 6.0, so skip its dtest jar build + # (build-dtest-jars.sh reads this var to filter CANDIDATE_BRANCHES). + BRANCHES: "cassandra-4.0 cassandra-4.1 cassandra-5.0" command: | ./scripts/build-dependencies.sh ./gradlew --no-daemon --max-workers=2 codeCheckTasks @@ -201,7 +204,8 @@ jobs: SCALA_VERSION: "2.13" # JDK17 only targets Cassandra 5.0+; skip 4.0 / 4.1 dtest jar builds # (build-dtest-jars.sh reads this var to filter CANDIDATE_BRANCHES). - BRANCHES: "cassandra-5.0" + # Only the Spark 4 matrix tests Cassandra 6.0, so its dtest jar is built here and nowhere else. + BRANCHES: "cassandra-5.0 cassandra-6.0" command: | ./scripts/build-dependencies.sh ./gradlew --no-daemon --max-workers=2 codeCheckTasks @@ -351,6 +355,20 @@ workflows: sstable_format: ["bti"] cassandra: ["5.0"] + # Cassandra 6.0 on Spark 4 / Scala 2.13 / JDK 17 + BTI sstable format. + # Cassandra 6.0 needs JDK17 or later, so it has no JDK11 job. + - unit: + requires: + - build-deps-jdk17 + matrix: + alias: unit-c6-spark4 + parameters: + spark: ["4"] + scala: ["2.13"] + jdk: ["17"] + sstable_format: ["bti"] + cassandra: ["6.0"] + # ---- Integration tests ---- # Cassandra 4.x: Scala 2.12, full patch versions for dtest jar lookup - int-test: @@ -387,3 +405,16 @@ workflows: scala: ["2.13"] jdk: ["17"] cassandra: ["5.0.7"] + + # Cassandra 6.0 on Spark 4 / Scala 2.13 / JDK 17. + # The value is the dtest jar version, which is the Cassandra base.version at the pinned tag. + - int-test: + requires: + - build-deps-jdk17 + matrix: + alias: int-c6-spark4 + parameters: + spark: ["4"] + scala: ["2.13"] + jdk: ["17"] + cassandra: ["6.0-alpha2"] diff --git a/CHANGES.txt b/CHANGES.txt index c3cda0a5a..ed4bbb987 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,6 @@ 0.5.0 ----- + * Add Cassandra 6.0 support (CASSANALYTICS-37) * CDC reader stats silently dropped in SidecarCdcBuilder (CASSANALYTICS-191) * Add CapturePublishedSchema metric to SidecarCdcStats (CASSANALYTICS-189) * Expand list of architecture that supports unaligned access in FastByteOperations (CASSANALYTICS-188) diff --git a/DEV-README.md b/DEV-README.md index 0b17b1de1..b877188e5 100644 --- a/DEV-README.md +++ b/DEV-README.md @@ -90,6 +90,36 @@ create a temporary alias for every node except the first: for i in {2..20}; do sudo ifconfig lo0 alias "127.0.0.${i}"; done ``` +### Host names + +A multi-node test passes `localhost2` through `localhost20` to the bulk reader as sidecar contact points, so each +name must resolve. `LocalhostResolver` maps the name inside Sidecar only; the Spark side uses the system resolver. +Add an entry for every node except the first: + +```shell +sudo bash -c 'for i in {2..20}; do echo "127.0.0.${i} localhost${i}"; done >> /etc/hosts' +``` + +Without these entries an upstream DNS server can answer `localhost2` with a public address. The bulk reader then +connects to that address and the test fails with `java.net.ConnectException: Operation timed out` in +`CassandraDataLayer.initialize`. + +### Topology-change tests skip on Cassandra 6.0 + +Twenty-eight test classes under `expansion`, `shrink`, `replacement` and `movement` pause a topology change with a +ByteBuddy hook, then run the bulk writer while the node is in the transitional state. The hooks target +`StorageService.bootstrap(Collection, long)`, `StorageService.unbootstrap()` and `RangeRelocator.stream()`. + +CEP-21 Transactional Cluster Metadata removed all three in Cassandra 6.0. The work now belongs to +`org.apache.cassandra.tcm.sequences`: `BootstrapAndJoin.bootstrap(...)` for a join, `BootstrapAndReplace` for a +replacement, `UnbootstrapAndLeave.executeNext()` with `LeaveStreams` for a decommission, and `Move` for a token move. +A hook that fails to install is silent, so each class waited two minutes for a latch that never counted down. + +`ResiliencyTestBase.assumeTopologyChangeHooksSupported()` now skips these classes on 6.0 and later. The four base +classes call it from `beforeClusterProvisioning()`. Bulk write during a topology change is therefore untested on +6.0. To close the gap, retarget each hook at the sequence types named above, and keep the 4.0 and 5.0 targets for +the older runs. + ## IntelliJ The project is well-supported in IntelliJ. @@ -106,4 +136,19 @@ Major Cassandra versions require dedicated bridge modules (e.g. `cassandra-five- Cassandra as a library, and correctly read sstable files. Significant part of code may be based on previous module version. To minimise code duplication, Gradle build copies all source files from previous dependent module. If implementation of given component has to be updated, copy the file with the same name to a target module and -modify its body. \ No newline at end of file +modify its body. + +Each major version owns five modules. The table shows the current set: + +| Cassandra | Shaded library | Bridge | Types | Avro converter | Spark SQL converter | +|-----------|---------------------|----------------------------|---------------------------|-------------------------------------|----------------------------------------------| +| 4.0, 4.1 | `cassandra-four-zero` | `cassandra-four-zero-bridge` | `cassandra-four-zero-types` | `cassandra-four-zero-avro-converter` | `cassandra-analytics-spark-four-zero-converter` | +| 5.0 | `cassandra-five-zero` | `cassandra-five-zero-bridge` | `cassandra-five-zero-types` | `cassandra-five-zero-avro-converter` | `cassandra-analytics-spark-five-zero-converter` | +| 6.0 | `cassandra-six-zero` | `cassandra-six-zero-bridge` | `cassandra-six-zero-types` | `cassandra-six-zero-avro-converter` | `cassandra-analytics-spark-six-zero-converter` | + +The copy-forward chain runs four-zero, then five-zero, then six-zero. A six-zero module therefore inherits the +compiled classes of both earlier majors, and Gradle never recompiles an inherited class. A Cassandra API that +changed between two majors is silent at build time and fails at runtime with `NoSuchMethodError`. After you add a +new major version, compile the union of the inherited sources against the new shaded jar and confirm that the +resulting error set matches the error set that the previous major produces. Every error the new version adds names +a file you must copy forward and override. \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7551f06ed..ce0cf0e04 100644 --- a/build.gradle +++ b/build.gradle @@ -66,8 +66,8 @@ ext.dependencyLocation = (System.getenv("CASSANDRA_DEP_DIR") ?: "${rootDir}/depe // - cassandraVersionEnumMap values must match the implemented_versions default // - cassandraFullVersionMap values must match the supported_versions default // NOTE: Both maps must ALSO stay in sync with the values in build-dtest-jars.sh -ext.cassandraVersionEnumMap = ["4.0": "FOURZERO", "4.1": "FOURONE", "5.0": "FIVEZERO"] -ext.cassandraFullVersionMap = ["4.0": "4.0.17", "4.1": "4.1.4", "5.0": "5.0.7"] +ext.cassandraVersionEnumMap = ["4.0": "FOURZERO", "4.1": "FOURONE", "5.0": "FIVEZERO", "6.0": "SIXZERO"] +ext.cassandraFullVersionMap = ["4.0": "4.0.17", "4.1": "4.1.4", "5.0": "5.0.7", "6.0": "6.0-alpha2"] // Shared helper: sets implemented_versions and supported_versions system properties on a Test task. // When majorMinor is provided (e.g. "4.0"), uses that version directly. @@ -77,7 +77,12 @@ ext.applyCassandraVersionFilter = { Test task, String majorMinor = null -> if (!majorMinor) { def ver = System.getenv("CASSANDRA_VERSION") if (!ver) return null - majorMinor = ver.split("\\.").take(2).join(".") + // Match the leading major.minor pair only, so that a pre-release such as 6.0-alpha2 resolves to 6.0 + def versionMatcher = ver =~ /^(\d+)\.(\d+)/ + if (!versionMatcher) { + throw new GradleException("Cannot parse CASSANDRA_VERSION: ${ver}") + } + majorMinor = "${versionMatcher[0][1]}.${versionMatcher[0][2]}" } def enumName = cassandraVersionEnumMap[majorMinor] diff --git a/cassandra-analytics-cdc-sidecar/build.gradle b/cassandra-analytics-cdc-sidecar/build.gradle index f4eb8d422..2a2044c8c 100644 --- a/cassandra-analytics-cdc-sidecar/build.gradle +++ b/cassandra-analytics-cdc-sidecar/build.gradle @@ -59,6 +59,16 @@ configurations { fivezeroTypes { description = 'Cassandra 5.0 Data Types' } + + sixzero { + description = 'Cassandra 6.0 dependency' + } + sixzeroBridge { + description = 'Bridge to Cassandra 6.0' + } + sixzeroTypes { + description = 'Cassandra 6.0 Data Types' + } } dependencies { @@ -72,6 +82,10 @@ dependencies { fivezeroBridge(project(path: ':cassandra-five-zero-bridge')) fivezeroTypes(project(path: ':cassandra-five-zero-types')) + sixzero(project(path: ':cassandra-six-zero', configuration: 'shadow')) + sixzeroBridge(project(path: ':cassandra-six-zero-bridge')) + sixzeroTypes(project(path: ':cassandra-six-zero-types')) + implementation project(":cassandra-analytics-common") implementation project(':cassandra-analytics-cdc') implementation project(":cassandra-analytics-cdc-codec") @@ -118,6 +132,10 @@ jar { dependsOn(configurations.fivezeroBridge) dependsOn(configurations.fivezeroTypes) + dependsOn(configurations.sixzero) + dependsOn(configurations.sixzeroBridge) + dependsOn(configurations.sixzeroTypes) + exclude('META-INF/*') into('bridges') { from(configurations.fourzero.singleFile) @@ -127,6 +145,10 @@ jar { from(configurations.fivezero.singleFile) from(configurations.fivezeroBridge.singleFile) from(configurations.fivezeroTypes.singleFile) + + from(configurations.sixzero.singleFile) + from(configurations.sixzeroBridge.singleFile) + from(configurations.sixzeroTypes.singleFile) } } diff --git a/cassandra-analytics-cdc/build.gradle b/cassandra-analytics-cdc/build.gradle index 5a3918cc4..d540f5fd7 100644 --- a/cassandra-analytics-cdc/build.gradle +++ b/cassandra-analytics-cdc/build.gradle @@ -60,6 +60,19 @@ configurations { fivezeroAvroConverter { description = 'Avro converter for Cassandra 5.0 Data Types' } + + sixzero { + description = 'Cassandra 6.0 dependency' + } + sixzeroBridge { + description = 'Bridge to Cassandra 6.0' + } + sixzeroTypes { + description = 'Cassandra 6.0 Data Types' + } + sixzeroAvroConverter { + description = 'Avro converter for Cassandra 6.0 Data Types' + } } dependencies { @@ -78,6 +91,11 @@ dependencies { fivezeroTypes(project(path: ':cassandra-five-zero-types')) fivezeroAvroConverter(project(":cassandra-five-zero-avro-converter")) + sixzero(project(path: ':cassandra-six-zero', configuration: 'shadow')) + sixzeroBridge(project(path: ':cassandra-six-zero-bridge')) + sixzeroTypes(project(path: ':cassandra-six-zero-types')) + sixzeroAvroConverter(project(":cassandra-six-zero-avro-converter")) + implementation "com.google.guava:guava:${guavaVersion}" implementation "org.apache.commons:commons-lang3:${project.commonsLang3Version}" @@ -127,6 +145,11 @@ jar { dependsOn(configurations.fivezeroTypes) dependsOn(configurations.fivezeroAvroConverter) + dependsOn(configurations.sixzero) + dependsOn(configurations.sixzeroBridge) + dependsOn(configurations.sixzeroTypes) + dependsOn(configurations.sixzeroAvroConverter) + exclude('META-INF/*') into('bridges') { from(configurations.fourzero.singleFile) @@ -138,6 +161,11 @@ jar { from(configurations.fivezeroBridge.singleFile) from(configurations.fivezeroTypes.singleFile) from(configurations.fivezeroAvroConverter.singleFile) + + from(configurations.sixzero.singleFile) + from(configurations.sixzeroBridge.singleFile) + from(configurations.sixzeroTypes.singleFile) + from(configurations.sixzeroAvroConverter.singleFile) } } @@ -159,7 +187,7 @@ def configureCdcTestTask = { Test task, String majorMinor = null -> // Full version format to match CDC's TestVersionSupplier; tests both versions for backward compat. // 4.1 intentionally excluded from gradlew defaults to keep local iteration fast; // use testCassandra41 for targeted 4.1 runs. CI covers 4.1 via CASSANDRA_VERSION env var. - task.systemProperty "cassandra.sidecar.versions_to_test", "4.0.17,5.0.7" + task.systemProperty "cassandra.sidecar.versions_to_test", "4.0.17,5.0.7,6.0-alpha2" } task.minHeapSize = '1024m' diff --git a/cassandra-analytics-cdc/src/test/java/org/apache/cassandra/cdc/test/TestVersionSupplier.java b/cassandra-analytics-cdc/src/test/java/org/apache/cassandra/cdc/test/TestVersionSupplier.java index e6034ffd3..89ab4316d 100644 --- a/cassandra-analytics-cdc/src/test/java/org/apache/cassandra/cdc/test/TestVersionSupplier.java +++ b/cassandra-analytics-cdc/src/test/java/org/apache/cassandra/cdc/test/TestVersionSupplier.java @@ -32,7 +32,9 @@ private TestVersionSupplier() public static Stream testVersions() { - String versions = System.getProperty("cassandra.sidecar.versions_to_test", "4.0.17,5.0.7"); + // Keep in sync with configureCdcTestTask in cassandra-analytics-cdc/build.gradle, which sets the same + // property; this default applies only outside Gradle, for example in an IDE + String versions = System.getProperty("cassandra.sidecar.versions_to_test", "4.0.17,5.0.7,6.0-alpha2"); return Arrays.stream(versions.split(",")) .map(String::trim) .map(v -> CassandraVersion.fromVersion(v).orElseThrow(() -> new IllegalArgumentException("Unsupported version: " + v))); diff --git a/cassandra-analytics-common/src/main/java/org/apache/cassandra/bridge/CassandraVersion.java b/cassandra-analytics-common/src/main/java/org/apache/cassandra/bridge/CassandraVersion.java index c37116e4e..bdeab3c10 100644 --- a/cassandra-analytics-common/src/main/java/org/apache/cassandra/bridge/CassandraVersion.java +++ b/cassandra-analytics-common/src/main/java/org/apache/cassandra/bridge/CassandraVersion.java @@ -37,13 +37,14 @@ * * NOTE: The following values need to stay in sync with: * - build.gradle: - * - ext.cassandraVersionEnumMap = ["4.0": "FOURZERO", "4.1": "FOURONE", "5.0": "FIVEZERO"] - * - ext.cassandraFullVersionMap = ["4.0": "4.0.17", "4.1": "4.1.4", "5.0": "5.0.7"] + * - ext.cassandraVersionEnumMap = ["4.0": "FOURZERO", "4.1": "FOURONE", "5.0": "FIVEZERO", "6.0": "SIXZERO"] + * - ext.cassandraFullVersionMap = ["4.0": "4.0.17", "4.1": "4.1.4", "5.0": "5.0.7", "6.0": "6.0-alpha2"] * - build-dtest-jars.sh: * - CANDIDATE_BRANCHES=( * "cassandra-4.0:cassandra-4.0.17" * "cassandra-4.1:99d9faeef57c9cf5240d11eac9db5b283e45a4f9" * "cassandra-5.0:cassandra-5.0.7" + * "cassandra-6.0:cassandra-6.0-alpha2" */ public enum CassandraVersion { @@ -72,7 +73,13 @@ public enum CassandraVersion // Cassandra 5.0 native sstable versions "big-oa", "bti-da", - }, 40); + }, 40), + SIXZERO(60, "6.0", "six-zero", new String[]{"big", "bti"}, + new String[] { + // Cassandra 6.0 native sstable versions + "big-pa", + "bti-ea", + }, 40); private final int number; private final String name; @@ -177,7 +184,7 @@ public boolean canRead(CassandraVersion other) // FOURONE is intentionally excluded from local-dev defaults to keep iteration fast; // CI covers 4.1 via explicit CASSANDRA_VERSION env var or per-version Gradle tasks (e.g. testCassandra41). String providedVersionsOrDefault = System.getProperty("cassandra.analytics.bridges.implemented_versions", - String.join(",", FOURZERO.name(), FIVEZERO.name())); + String.join(",", FOURZERO.name(), FIVEZERO.name(), SIXZERO.name())); implementedVersions = Arrays.stream(providedVersionsOrDefault.split(",")) .map(CassandraVersion::valueOf) .filter(v -> v.sstableFormats().contains(configuredSSTableFormat)) @@ -185,7 +192,7 @@ public boolean canRead(CassandraVersion other) // NOTE: These default versions must stay in sync with cassandraFullVersionMap in build.gradle. String providedSupportedVersionsOrDefault = System.getProperty("cassandra.analytics.bridges.supported_versions", - "cassandra-4.0.17,cassandra-5.0.7"); + "cassandra-4.0.17,cassandra-5.0.7,cassandra-6.0-alpha2"); supportedVersions = Arrays.stream(providedSupportedVersionsOrDefault.split(",")) .filter(version -> CassandraVersion.fromVersion(version) .filter(v -> v.sstableFormats().contains(configuredSSTableFormat)) diff --git a/cassandra-analytics-common/src/main/java/org/apache/cassandra/bridge/CassandraVersionFeatures.java b/cassandra-analytics-common/src/main/java/org/apache/cassandra/bridge/CassandraVersionFeatures.java index d2d59eaed..5d656934e 100644 --- a/cassandra-analytics-common/src/main/java/org/apache/cassandra/bridge/CassandraVersionFeatures.java +++ b/cassandra-analytics-common/src/main/java/org/apache/cassandra/bridge/CassandraVersionFeatures.java @@ -19,11 +19,13 @@ package org.apache.cassandra.bridge; +import java.util.List; import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -32,7 +34,14 @@ public class CassandraVersionFeatures implements Comparable VERSION_PATTERNS = ImmutableList.of(VERSION_PATTERN_4, + VERSION_PATTERN_3, + VERSION_PATTERN_NO_PATCH); protected final int majorVersion; protected final int minorVersion; @@ -80,10 +89,11 @@ private static String getCassandraVersionCode(String cassandraVersion) // E.g if cassandra version = cassandra-1.2.11-v1, we return 11; // or if cassandra version = cassandra-4.0-SNAPSHOT, we return 0 + // or if cassandra version = cassandra-6.0-alpha2, we return 0 private static String getCassandraMinorVersionCode(String cassandraVersion) { Matcher matcher = matchVersion(cassandraVersion); - if (matchesSnapshot(matcher.group(3))) + if (!hasPatchNumber(matcher)) { return "0"; } @@ -94,10 +104,16 @@ private static String getCassandraMinorVersionCode(String cassandraVersion) // E.g if cassandra version = cassandra-1.2.11-v1, we return -v1; // or if cassandra version = cassandra-1.2.11.2-tag, we return 2; // or if cassandra version = cassandra-4.0-SNAPSHOT, we return SNAPSHOT + // or if cassandra version = cassandra-6.0-alpha2, we return alpha2 + // or if cassandra version = 6.0, we return null private static String getCassandraVersionSuffix(String cassandraVersion) { Matcher matcher = matchVersion(cassandraVersion); - if (matchesSnapshot(matcher.group(3)) || matchesSnapshot(matcher.group(4))) + if (!hasPatchNumber(matcher)) + { + return matcher.group(3); + } + if (matchesSnapshot(matcher.group(4))) { return "SNAPSHOT"; } @@ -105,33 +121,32 @@ private static String getCassandraVersionSuffix(String cassandraVersion) return matcher.group(4); } + // True when group 3 is the patch number and group 4 the suffix; false when group 3 is the suffix + private static boolean hasPatchNumber(Matcher matcher) + { + return matcher.groupCount() > 3; + } + private static boolean matchesSnapshot(String snapshot) { return "SNAPSHOT".equals(snapshot) || "-SNAPSHOT".equals(snapshot); } /** - * Returns a matched matcher using VERSION_PATTERN; throws if no match + * Returns a matched matcher using the first of {@link #VERSION_PATTERNS} that matches; throws if none match */ private static Matcher matchVersion(String cassandraVersion) { - Matcher matcher = VERSION_PATTERN_4.matcher(cassandraVersion); - - if (!matcher.find()) + for (Pattern pattern : VERSION_PATTERNS) { - matcher = VERSION_PATTERN_3.matcher(cassandraVersion); - - if (!matcher.find()) + Matcher matcher = pattern.matcher(cassandraVersion); + if (matcher.find()) { - matcher = VERSION_PATTERN_SNAPSHOT.matcher(cassandraVersion); - if (!matcher.find()) - { - throw new RuntimeException("cassandraVersion does not match version pattern, pattern=" + VERSION_PATTERN_3 - + ", version=" + cassandraVersion); - } + return matcher; } } - return matcher; + throw new RuntimeException("cassandraVersion does not match any version pattern, patterns=" + VERSION_PATTERNS + + ", version=" + cassandraVersion); } public int getMajorVersion() diff --git a/cassandra-analytics-common/src/main/java/org/apache/cassandra/cdc/api/CommitLog.java b/cassandra-analytics-common/src/main/java/org/apache/cassandra/cdc/api/CommitLog.java index 66adeb44c..8a6541008 100644 --- a/cassandra-analytics-common/src/main/java/org/apache/cassandra/cdc/api/CommitLog.java +++ b/cassandra-analytics-common/src/main/java/org/apache/cassandra/cdc/api/CommitLog.java @@ -54,12 +54,8 @@ static Optional> extractVersionAndSegmentId(@NotNull String try { int version = matcher.group(2) == null ? 6 : Integer.parseInt(matcher.group(2)); - // versions are present in C* code-base in CommitLogDescriptor - if (version != 6 && version != 7 && version != 8) - { - throw new IllegalStateException("Unknown commitlog version " + version); - } - // logic taken from org.apache.cassandra.db.commitlog.CommitLogDescriptor.getMessagingVersion() + // logic taken from org.apache.cassandra.db.commitlog.CommitLogDescriptor.getMessagingVersion(). + // The commit log versions are in CommitLogDescriptor, the messaging versions in MessagingService. int messagingVersion; switch (version) { @@ -69,8 +65,14 @@ static Optional> extractVersionAndSegmentId(@NotNull String case 7: messagingVersion = 12; break; - default: + case 8: messagingVersion = 13; + break; + case 9: + messagingVersion = 14; + break; + default: + throw new IllegalStateException("Unknown commitlog version " + version); } return Optional.of(Pair.of(messagingVersion, Long.parseLong(matcher.group(3)))); } diff --git a/cassandra-analytics-core/build.gradle b/cassandra-analytics-core/build.gradle index 20a98d7df..749271eea 100644 --- a/cassandra-analytics-core/build.gradle +++ b/cassandra-analytics-core/build.gradle @@ -67,6 +67,19 @@ configurations { fivezeroSparkSql { description = 'Cassandra 5.0 SparkSQL Converter' } + + sixzero { + description = 'Cassandra 6.0 dependency' + } + sixzeroBridge { + description = 'Bridge to Cassandra 6.0' + } + sixzeroTypes { + description = 'Cassandra 6.0 Data Types' + } + sixzeroSparkSql { + description = 'Cassandra 6.0 SparkSQL Converter' + } } dependencies { @@ -85,6 +98,11 @@ dependencies { fivezeroTypes(project(path: ':cassandra-five-zero-types')) fivezeroSparkSql(project(path: ':cassandra-analytics-spark-five-zero-converter')) + sixzero(project(path: ':cassandra-six-zero', configuration: 'shadow')) + sixzeroBridge(project(path: ':cassandra-six-zero-bridge')) + sixzeroTypes(project(path: ':cassandra-six-zero-types')) + sixzeroSparkSql(project(path: ':cassandra-analytics-spark-six-zero-converter')) + api(project(':cassandra-analytics-spark-converter')) // Including newer JNA which works with "Modern" Linux/GLIBC. @@ -139,6 +157,11 @@ jar { dependsOn(configurations.fivezeroSparkSql) dependsOn(configurations.fivezeroTypes) + dependsOn(configurations.sixzero) + dependsOn(configurations.sixzeroBridge) + dependsOn(configurations.sixzeroSparkSql) + dependsOn(configurations.sixzeroTypes) + writeBuildVersion(version, project.projectDir) exclude('META-INF/*') @@ -152,6 +175,11 @@ jar { from(configurations.fivezeroBridge.singleFile) from(configurations.fivezeroTypes.singleFile) from(configurations.fivezeroSparkSql.singleFile) + + from(configurations.sixzero.singleFile) + from(configurations.sixzeroBridge.singleFile) + from(configurations.sixzeroTypes.singleFile) + from(configurations.sixzeroSparkSql.singleFile) } } diff --git a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/KryoRegister.java b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/KryoRegister.java index 929df833e..bf10c30bb 100644 --- a/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/KryoRegister.java +++ b/cassandra-analytics-core/src/main/java/org/apache/cassandra/spark/KryoRegister.java @@ -61,7 +61,8 @@ public class KryoRegister implements KryoRegistrator public static final Map> KRYO_REGISTRATORS = Map.of(CassandraVersion.FOURZERO, V40.class, CassandraVersion.FOURONE, V41.class, - CassandraVersion.FIVEZERO, V50.class); + CassandraVersion.FIVEZERO, V50.class, + CassandraVersion.SIXZERO, V60.class); static { @@ -156,4 +157,12 @@ public V50() super(CassandraVersion.FIVEZERO); } } + + public static class V60 extends KryoRegister + { + public V60() + { + super(CassandraVersion.SIXZERO); + } + } } diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/bridge/BridgeVersionConsistencyTest.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/bridge/BridgeVersionConsistencyTest.java new file mode 100644 index 000000000..5f06e4ee6 --- /dev/null +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/bridge/BridgeVersionConsistencyTest.java @@ -0,0 +1,64 @@ +/* + * 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.bridge; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import org.apache.cassandra.spark.data.CqlField; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Guards the Gradle copy-forward that builds each bridge jar. + *

+ * A bridge jar carries the compiled classes of the version below it, and a class of the version below wins + * whenever this version's module holds no source file of the same name. The build reports nothing, so a class + * that names its own Cassandra version keeps naming the older one. These tests load each bridge from its + * embedded jar, the way a Spark job does, and ask every such class which version it belongs to. + */ +class BridgeVersionConsistencyTest +{ + @ParameterizedTest + @MethodSource("org.apache.cassandra.bridge.VersionRunner#versions") + void testBridgeComesFromItsOwnJar(CassandraVersion version) + { + assertThat(CassandraBridgeFactory.get(version).getVersion().jarBaseName()).isEqualTo(version.jarBaseName()); + } + + @ParameterizedTest + @MethodSource("org.apache.cassandra.bridge.VersionRunner#versions") + void testTypesReportTheBridgeVersion(CassandraVersion version) + { + CassandraBridge bridge = CassandraBridgeFactory.get(version); + // Several Cassandra versions share one jar, and the classes of that jar all name the version that built it + CassandraVersion implemented = bridge.getVersion(); + for (CqlField.NativeType type : bridge.allTypes()) + { + assertThat(type.version()).describedAs(type.name()).isEqualTo(implemented); + } + + // A complex type lives in its own package and so needs an override of its own + assertThat(bridge.tuple(bridge.aInt(), bridge.text()).version()).isEqualTo(implemented); + assertThat(bridge.list(bridge.aInt()).version()).isEqualTo(implemented); + assertThat(bridge.set(bridge.aInt()).version()).isEqualTo(implemented); + assertThat(bridge.map(bridge.aInt(), bridge.text()).version()).isEqualTo(implemented); + } +} diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/bridge/CassandraVersionFeaturesTest.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/bridge/CassandraVersionFeaturesTest.java index f5c1c1983..f525109e5 100644 --- a/cassandra-analytics-core/src/test/java/org/apache/cassandra/bridge/CassandraVersionFeaturesTest.java +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/bridge/CassandraVersionFeaturesTest.java @@ -38,6 +38,20 @@ public void testCassandraVersions() testCassandraVersion("cassandra-4.0-SNAPSHOT", 40, 0, "SNAPSHOT"); testCassandraVersion("cassandra-2.0.9-loadtest-SNAPSHOT", 20, 9, "-loadtest-SNAPSHOT"); + testCassandraVersion("6.0-alpha2", 60, 0, "alpha2"); + testCassandraVersion("cassandra-6.0-alpha2", 60, 0, "alpha2"); + testCassandraVersion("cassandra-6.0-rc1", 60, 0, "rc1"); + testCassandraVersion("cassandra-6.0.0", 60, 0, ""); + + // A node built from the pre-release branch reports the release version with a -SNAPSHOT tail + testCassandraVersion("6.0-alpha2-SNAPSHOT", 60, 0, "alpha2"); + testCassandraVersion("cassandra-6.0-alpha2-SNAPSHOT", 60, 0, "alpha2"); + + // A caller can name a major version alone, as the integration tests do + testCassandraVersion("5.0", 50, 0, null); + testCassandraVersion("6.0", 60, 0, null); + testCassandraVersion("cassandra-6.0", 60, 0, null); + testCassandraVersion("qwerty-cassandra-1.2.11-v1", 12, 11, "-v1"); testCassandraVersion("qwerty-cassandra-1.2.11.2-tag", 12, 11, "2"); testCassandraVersion("qwerty-cassandra-4.0-SNAPSHOT", 40, 0, "SNAPSHOT"); diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/TestUtils.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/TestUtils.java index e2ca6a45f..0686c423e 100644 --- a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/TestUtils.java +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/TestUtils.java @@ -309,7 +309,10 @@ public static Gen tombstoneVersions() public static List tombstoneTestableVersions() { // Tombstone SSTable writing and SSTable-to-JSON conversion are not implemented for Cassandra version 3.0 - List tombstoneTestableVersions = ImmutableList.of(CassandraVersion.FOURZERO, CassandraVersion.FOURONE, CassandraVersion.FIVEZERO); + List tombstoneTestableVersions = ImmutableList.of(CassandraVersion.FOURZERO, + CassandraVersion.FOURONE, + CassandraVersion.FIVEZERO, + CassandraVersion.SIXZERO); return filterTestableVersions(tombstoneTestableVersions); } diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/SSTables.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/SSTables.java index 97e27b4f1..c9bc6786f 100644 --- a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/SSTables.java +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/SSTables.java @@ -58,6 +58,8 @@ private enum ComponentType .put("nb", new CassandraVersionFeatures(40, 0, null)) .put("da", new CassandraVersionFeatures(50, 0, null)) .put("oa", new CassandraVersionFeatures(50, 0, null)) + .put("ea", new CassandraVersionFeatures(60, 0, null)) + .put("pa", new CassandraVersionFeatures(60, 0, null)) .build(); private SSTables() diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/SortedSSTableWriterTest.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/SortedSSTableWriterTest.java index fba73361e..188e746ea 100644 --- a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/SortedSSTableWriterTest.java +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/SortedSSTableWriterTest.java @@ -140,6 +140,17 @@ public void canCreateWriterForVersion(String version) throws IOException assertThat(baseFileName).matches("da-\\d+-bti"); } break; + case 60: + // Format is "pa--big" or "ea--bti" + if ("big".equals(CassandraVersion.configuredSSTableFormat())) + { + assertThat(baseFileName).matches("pa-\\d+-big"); + } + else + { + assertThat(baseFileName).matches("ea-\\d+-bti"); + } + break; default: throw new UnsupportedOperationException("Unsupported version: " + version); } diff --git a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TableSchemaTestCommon.java b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TableSchemaTestCommon.java index d7a58d67a..af4ab9203 100644 --- a/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TableSchemaTestCommon.java +++ b/cassandra-analytics-core/src/test/java/org/apache/cassandra/spark/bulkwriter/TableSchemaTestCommon.java @@ -452,6 +452,7 @@ private String getCompression() { switch (cassandraVersion.charAt(0)) { + case '6': case '5': case '4': return "ZstdCompressor"; diff --git a/cassandra-analytics-integration-framework/src/main/java/org/apache/cassandra/sidecar/testing/SharedClusterIntegrationTestBase.java b/cassandra-analytics-integration-framework/src/main/java/org/apache/cassandra/sidecar/testing/SharedClusterIntegrationTestBase.java index 0577e4290..e4b867f31 100644 --- a/cassandra-analytics-integration-framework/src/main/java/org/apache/cassandra/sidecar/testing/SharedClusterIntegrationTestBase.java +++ b/cassandra-analytics-integration-framework/src/main/java/org/apache/cassandra/sidecar/testing/SharedClusterIntegrationTestBase.java @@ -250,6 +250,13 @@ protected void tearDown() throws Exception { try { + // An assumption in beforeClusterProvisioning() aborts setup() before it provisions the cluster, and + // every hook below would then throw a NullPointerException + if (cluster == null) + { + logger.info("Skipping tear down; the cluster was never provisioned"); + return; + } beforeSidecarStop(); stopSidecar(); beforeClusterShutdown(); diff --git a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/BulkRoundtripSSTableVersionBridgeTestBase.java b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/BulkRoundtripSSTableVersionBridgeTestBase.java index 97b17c8a7..985698f23 100644 --- a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/BulkRoundtripSSTableVersionBridgeTestBase.java +++ b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/BulkRoundtripSSTableVersionBridgeTestBase.java @@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test; +import org.apache.cassandra.bridge.CassandraVersion; import org.apache.cassandra.distributed.api.ConsistencyLevel; import org.apache.cassandra.distributed.api.IInstance; import org.apache.cassandra.sidecar.testing.QualifiedName; @@ -111,10 +112,10 @@ protected void beforeClusterProvisioning() System.setProperty("cassandra.analytics.bridges.sstable_format", sstableFormat()); if ("bti".equals(sstableFormat())) { - // BTI (bti-da) is a Cassandra 5.0+ format; skip on older versions. + // BTI is a Cassandra 5.0+ format; skip on older versions. Semver version = new Semver(testVersion.version(), Semver.SemverType.LOOSE); assumeTrue(version.isGreaterThanOrEqualTo(new Semver("5.0", Semver.SemverType.LOOSE)), - "BTI format (bti-da) requires Cassandra 5.0+, but test version is " + testVersion.version()); + "BTI format requires Cassandra 5.0+, but test version is " + testVersion.version()); } } @@ -238,34 +239,33 @@ private void flushKeyspace() cluster.stream().forEach(instance -> instance.nodetool("flush", TEST_KEYSPACE)); } + private void assertExpectedSSTableFormat(QualifiedName table) + { + String format = sstableFormat(); + assertSSTableFormatOnDisk(table, format, expectedSSTableVersion(format)); + } + /** - * Asserts the on-disk SSTables for the given table match the format produced by this test class: - * {@code bti-da} for the BTI variant (5.x only), otherwise {@code big} with the version expected for the - * Cassandra version under test ({@code oa} for 5.x, {@code nb} for 4.x). + * @return the newest version in {@code format} that the Cassandra version under test writes natively, without + * the format prefix */ - private void assertExpectedSSTableFormat(QualifiedName table) + private String expectedSSTableVersion(String format) { - if ("bti".equals(sstableFormat())) - { - assertSSTableFormatOnDisk(table, "bti", "da"); - } - else - { - String version = testVersion.version(); - String expectedSSTableVersion; - if (version.startsWith("5.")) - { - expectedSSTableVersion = "oa"; - } - else if (version.startsWith("4.")) - { - expectedSSTableVersion = "nb"; - } - else - { - throw new IllegalStateException("Unsupported Cassandra version for SSTable format assertion: " + version); - } - assertSSTableFormatOnDisk(table, "big", expectedSSTableVersion); - } + String version = testVersion.version(); + CassandraVersion underTest = CassandraVersion.fromVersion(version) + .orElseThrow(() -> new IllegalStateException( + "Unsupported Cassandra version for SSTable format assertion: " + version)); + String prefix = format + '-'; + // A version can introduce no new SSTable version, as Cassandra 4.1 did, so take the newest version at or + // below the version under test + return Arrays.stream(CassandraVersion.values()) + .filter(candidate -> candidate.versionNumber() <= underTest.versionNumber()) + .sorted(Comparator.comparingInt(CassandraVersion::versionNumber)) + .flatMap(candidate -> candidate.getNativeSSTableVersions().stream()) + .filter(sstableVersion -> sstableVersion.startsWith(prefix)) + .reduce((earlier, later) -> later) + .map(sstableVersion -> sstableVersion.substring(prefix.length())) + .orElseThrow(() -> new IllegalStateException( + "Cassandra " + version + " writes no native " + format + " SSTable version")); } } diff --git a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/ResiliencyTestBase.java b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/ResiliencyTestBase.java index 136adfbb4..196dbd118 100644 --- a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/ResiliencyTestBase.java +++ b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/ResiliencyTestBase.java @@ -34,6 +34,7 @@ import com.google.common.collect.Range; +import org.apache.cassandra.bridge.CassandraVersion; import org.apache.cassandra.distributed.api.ConsistencyLevel; import org.apache.cassandra.distributed.api.ICluster; import org.apache.cassandra.distributed.api.IInstance; @@ -51,6 +52,7 @@ import static org.apache.cassandra.testing.TestUtils.TEST_KEYSPACE; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; /** * Base class for resiliency tests. Contains helper methods for data generation and validation @@ -59,6 +61,33 @@ public abstract class ResiliencyTestBase extends SharedClusterSparkIntegrationTe { public static final String QUERY_ALL_ROWS = "SELECT * FROM %s"; + /** + * Skips the test class when the version under test declares none of the internals that the ByteBuddy hooks of a + * topology-change test intercept: {@code StorageService.bootstrap(Collection, long)}, + * {@code StorageService.unbootstrap()} and {@code RangeRelocator.stream()}. CEP-21 Transactional Cluster Metadata + * removed all three in Cassandra 6.0, in favour of {@code org.apache.cassandra.tcm.sequences}. A hook that fails + * to install is silent, so the test would instead wait on a latch that never counts down. + * + *

Call this from {@link #beforeClusterProvisioning()}, which runs before the cluster starts.

+ */ + protected void assumeTopologyChangeHooksSupported() + { + String version = testVersion.version(); + CassandraVersion underTest = CassandraVersion.fromVersion(version) + .orElseThrow(() -> new IllegalStateException( + "Unsupported Cassandra version for topology-change tests: " + version)); + boolean supported = underTest.versionNumber() < CassandraVersion.SIXZERO.versionNumber(); + if (!supported) + { + // An aborted @BeforeAll produces no test event, so Gradle reports nothing + logger.warn("Skipping {}: a topology-change test intercepts Cassandra internals that CEP-21 removed " + + "in 6.0, and the test version is {}", getClass().getSimpleName(), version); + } + assumeTrue(supported, + "A topology-change test intercepts Cassandra internals that CEP-21 removed in 6.0, " + + "but the test version is " + version); + } + public Set getDataForRange(Range range, int rowCount) { // Iterate through all data entries; filter only entries that belong to range; convert to strings diff --git a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/expansion/JoiningTestBase.java b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/expansion/JoiningTestBase.java index 7d3458176..406a7c4b8 100644 --- a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/expansion/JoiningTestBase.java +++ b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/expansion/JoiningTestBase.java @@ -75,6 +75,12 @@ protected void beforeTestStart() expectedInstanceData = generateExpectedInstanceData(cluster, newInstances, ROW_COUNT); } + @Override + protected void beforeClusterProvisioning() + { + assumeTopologyChangeHooksSupported(); + } + @Override protected void afterClusterProvisioned() { diff --git a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/movement/NodeMovementTestBase.java b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/movement/NodeMovementTestBase.java index b663d6ed5..be74d1091 100644 --- a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/movement/NodeMovementTestBase.java +++ b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/movement/NodeMovementTestBase.java @@ -76,6 +76,12 @@ protected void beforeTestStart() expectedInstanceData = generateExpectedInstanceData(cluster, Collections.singletonList(movingNode), ROW_COUNT); } + @Override + protected void beforeClusterProvisioning() + { + assumeTopologyChangeHooksSupported(); + } + @Override protected void afterClusterProvisioned() { diff --git a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/replacement/HostReplacementTestBase.java b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/replacement/HostReplacementTestBase.java index 9cbbeba5d..efa07be6c 100644 --- a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/replacement/HostReplacementTestBase.java +++ b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/replacement/HostReplacementTestBase.java @@ -61,6 +61,12 @@ abstract class HostReplacementTestBase extends ResiliencyTestBase List newNodes; List removedNodeAddresses; + @Override + protected void beforeClusterProvisioning() + { + assumeTopologyChangeHooksSupported(); + } + @Override protected void afterClusterProvisioned() { diff --git a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/shrink/LeavingTestBase.java b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/shrink/LeavingTestBase.java index 11a6f2b9f..db7a8803d 100644 --- a/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/shrink/LeavingTestBase.java +++ b/cassandra-analytics-integration-tests/src/test/java/org/apache/cassandra/analytics/shrink/LeavingTestBase.java @@ -82,6 +82,12 @@ protected void beforeTestStart() expectedInstanceData = generateExpectedInstanceData(cluster, leavingNodes, ROW_COUNT); } + @Override + protected void beforeClusterProvisioning() + { + assumeTopologyChangeHooksSupported(); + } + @Override protected void afterClusterProvisioned() { diff --git a/cassandra-analytics-spark-six-zero-converter/build.gradle b/cassandra-analytics-spark-six-zero-converter/build.gradle new file mode 100644 index 000000000..89c1de10b --- /dev/null +++ b/cassandra-analytics-spark-six-zero-converter/build.gradle @@ -0,0 +1,46 @@ +/* + * 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. + */ + +plugins { + id('java-library') +} + +configurations { + all*.exclude(group: 'org.slf4j', module: 'slf4j-log4j12') + all*.exclude(group: 'log4j', module: 'log4j') + implCassV5 +} + +dependencies { + implCassV5 project(':cassandra-analytics-spark-five-zero-converter') + compileOnly project(":cassandra-analytics-common") + compileOnly project(":cassandra-six-zero-types") + compileOnly project(":cassandra-analytics-spark-converter") + compileOnly(project(path: ':cassandra-six-zero', configuration: 'shadow')) + compileOnly(group: "${sparkGroupId}", name: "spark-core_${scalaMajorVersion}", version: "${project.rootProject.sparkVersion}") + compileOnly(group: "${sparkGroupId}", name: "spark-sql_${scalaMajorVersion}", version: "${project.rootProject.sparkVersion}") +} + +jar { + archiveFileName = "six-zero-sparksql.jar" + dependsOn ':cassandra-analytics-spark-five-zero-converter:build' + // copy classes from the V5 implementation and exclude duplicates that we override + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from { configurations.implCassV5.collect { it.isDirectory() ? it : zipTree(it) } } +} diff --git a/cassandra-five-zero-bridge/src/test/java/org/apache/cassandra/bridge/CassandraSchemaTests.java b/cassandra-five-zero-bridge/src/test/java/org/apache/cassandra/bridge/CassandraSchemaTests.java index 0a5f13506..875be958e 100644 --- a/cassandra-five-zero-bridge/src/test/java/org/apache/cassandra/bridge/CassandraSchemaTests.java +++ b/cassandra-five-zero-bridge/src/test/java/org/apache/cassandra/bridge/CassandraSchemaTests.java @@ -26,6 +26,7 @@ import org.apache.cassandra.schema.Schema; import org.apache.cassandra.spark.data.CqlTable; +import org.apache.cassandra.spark.utils.TableIdentifier; import org.apache.cassandra.spark.utils.test.TestSchema; import org.apache.cassandra.spark.data.partitioner.Partitioner; @@ -92,4 +93,56 @@ public void testUpdateCdcSchema() assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable1)).isFalse(); assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable2)).isFalse(); } + + @Test + public void testUnregisterNonCdcTables() + { + Schema schema = Schema.instance; + + TestSchema nonCdcSchema = TestSchema.builder(BRIDGE) + .withPartitionKey("a", BRIDGE.uuid()) + .withColumn("b", BRIDGE.text()) + .build(); + CqlTable nonCdcTable = nonCdcSchema.buildTable(); + TableIdentifier nonCdcId = TableIdentifier.of(nonCdcTable.keyspace(), nonCdcTable.table()); + + TestSchema cdcSchema = TestSchema.builder(BRIDGE) + .withKeyspace(nonCdcTable.keyspace()) + .withPartitionKey("a", BRIDGE.uuid()) + .withColumn("b", BRIDGE.text()) + .withCdc(true) + .build(); + CqlTable cdcTable = cdcSchema.buildTable(); + TableIdentifier cdcId = TableIdentifier.of(cdcTable.keyspace(), cdcTable.table()); + + // register both tables (as if they'd been found to share partition-key structure) + CassandraSchema.updateCdcSchema(schema, ImmutableSet.of(nonCdcTable, cdcTable), Partitioner.Murmur3Partitioner, (keyspace, table) -> null); + assertThat(CassandraSchema.has(schema, nonCdcTable.keyspace(), nonCdcTable.table())).isTrue(); + assertThat(CassandraSchema.has(schema, cdcTable.keyspace(), cdcTable.table())).isTrue(); + + // a later refresh determines nonCdcTable is no longer at risk — unregister it + CassandraSchema.unregisterNonCdcTables(schema, ImmutableSet.of(nonCdcId)); + assertThat(CassandraSchema.has(schema, nonCdcTable.keyspace(), nonCdcTable.table())).isFalse(); + // the CDC-enabled table must be completely unaffected + assertThat(CassandraSchema.has(schema, cdcTable.keyspace(), cdcTable.table())).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cdcTable)).isTrue(); + + // idempotent: unregistering an already-unregistered table is a no-op, not an error + CassandraSchema.unregisterNonCdcTables(schema, ImmutableSet.of(nonCdcId)); + assertThat(CassandraSchema.has(schema, nonCdcTable.keyspace(), nonCdcTable.table())).isFalse(); + + // refuses to unregister a table that is currently CDC-enabled + CassandraSchema.unregisterNonCdcTables(schema, ImmutableSet.of(cdcId)); + assertThat(CassandraSchema.has(schema, cdcTable.keyspace(), cdcTable.table())).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cdcTable)).isTrue(); + + // unregistering an unknown table (never registered) is a no-op, not an error + CassandraSchema.unregisterNonCdcTables(schema, ImmutableSet.of(TableIdentifier.of("unknown_ks", "unknown_table"))); + + // the table comes back at risk: registering it again reuses the column family store that the + // metadata-only removal left with the keyspace instance + CassandraSchema.updateCdcSchema(schema, ImmutableSet.of(nonCdcTable, cdcTable), Partitioner.Murmur3Partitioner, (keyspace, table) -> null); + assertThat(CassandraSchema.has(schema, nonCdcTable.keyspace(), nonCdcTable.table())).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cdcTable)).isTrue(); + } } diff --git a/cassandra-five-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaUpdater.java b/cassandra-five-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaUpdater.java index 399eabe9f..098f64129 100644 --- a/cassandra-five-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaUpdater.java +++ b/cassandra-five-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaUpdater.java @@ -19,6 +19,7 @@ package org.apache.cassandra.bridge; +import org.apache.cassandra.db.Keyspace; import org.apache.cassandra.schema.KeyspaceMetadata; import org.apache.cassandra.schema.Schema; import org.apache.cassandra.schema.SchemaTransformations; @@ -46,6 +47,40 @@ public static void load(Schema schema, KeyspaceMetadata keyspaceMetadata, Types schema.transform(SchemaTransformations.addTypes(userTypes, true)); } + /** + * Replaces the metadata of an existing keyspace with metadata that holds fewer tables. + * + *

Cassandra 4.0's {@code Schema.load} added or reloaded, whereas + * {@link SchemaTransformations#addKeyspace} only adds and otherwise throws + * {@code AlreadyExistsException}, so a caller that means to replace needs a transformation of its own. + * + *

{@code Schema.alterKeyspace} gives every dropped table to {@code Keyspace.dropCf}, which interrupts + * compactions and recycles commit log segments: machinery that a client-mode process never started, and + * that throws while {@code CompactionManager} initializes with no compaction threads. + * {@code Keyspace.isInitialized()} gates that work, so clear the flag for the commit and only the metadata + * changes, which is all the bridge's mirrored schema holds. {@code Schema.reload} drops the table's + * metadata reference either way, so deserialization throws {@code UnknownTableException} again. The column + * family store of the removed table stays with the keyspace instance, and {@code Keyspace.initCf} reloads + * that store if the table returns. + */ + public static void removeTables(Schema schema, KeyspaceMetadata keyspaceMetadata) + { + // Keyspace.setInitialized and unsetInitialized synchronize on Schema.instance, and so does + // CassandraSchema.update; hold the same monitor, so no other schema change sees the cleared flag + synchronized (Schema.instance) + { + Keyspace.unsetInitialized(); + try + { + schema.transform(st -> st.withAddedOrUpdated(keyspaceMetadata)); + } + finally + { + Keyspace.setInitialized(); + } + } + } + public static void updateTable(Schema schema, KeyspaceMetadata keyspaceMetadata, TableMetadata tableMetadata) { schema.transform(st -> st.withAddedOrUpdated(keyspaceMetadata.withSwapped(keyspaceMetadata.tables.withSwapped(tableMetadata)))); diff --git a/cassandra-four-zero-bridge/src/test/java/org/apache/cassandra/bridge/CassandraSchemaTests.java b/cassandra-four-zero-bridge/src/test/java/org/apache/cassandra/bridge/CassandraSchemaTests.java index 7d39071ef..875be958e 100644 --- a/cassandra-four-zero-bridge/src/test/java/org/apache/cassandra/bridge/CassandraSchemaTests.java +++ b/cassandra-four-zero-bridge/src/test/java/org/apache/cassandra/bridge/CassandraSchemaTests.java @@ -138,5 +138,11 @@ public void testUnregisterNonCdcTables() // unregistering an unknown table (never registered) is a no-op, not an error CassandraSchema.unregisterNonCdcTables(schema, ImmutableSet.of(TableIdentifier.of("unknown_ks", "unknown_table"))); + + // the table comes back at risk: registering it again reuses the column family store that the + // metadata-only removal left with the keyspace instance + CassandraSchema.updateCdcSchema(schema, ImmutableSet.of(nonCdcTable, cdcTable), Partitioner.Murmur3Partitioner, (keyspace, table) -> null); + assertThat(CassandraSchema.has(schema, nonCdcTable.keyspace(), nonCdcTable.table())).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cdcTable)).isTrue(); } } diff --git a/cassandra-four-zero-types/src/main/java/org/apache/cassandra/bridge/CassandraSchema.java b/cassandra-four-zero-types/src/main/java/org/apache/cassandra/bridge/CassandraSchema.java index bf90bbf02..3d3c4b552 100644 --- a/cassandra-four-zero-types/src/main/java/org/apache/cassandra/bridge/CassandraSchema.java +++ b/cassandra-four-zero-types/src/main/java/org/apache/cassandra/bridge/CassandraSchema.java @@ -437,7 +437,9 @@ private static void unregisterNonCdcTable(@NotNull Schema schema, @NotNull Strin // writes/compactions on the tables it mirrors, so a full Keyspace.dropCf() would // pull in unrelated production machinery (e.g. lazily initializing // CompactionManager's thread pools) with no corresponding benefit here. - SchemaUpdater.load(s, ks.get().withSwapped(ks.get().tables.without(table))); + // Replacing the keyspace metadata needs removeTables, not load: load only adds from + // Cassandra 5.0 on, and throws AlreadyExistsException for a keyspace that exists. + SchemaUpdater.removeTables(s, ks.get().withSwapped(ks.get().tables.without(table))); }); } diff --git a/cassandra-four-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaUpdater.java b/cassandra-four-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaUpdater.java index 0e6fbcac2..4a2c6a8dc 100644 --- a/cassandra-four-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaUpdater.java +++ b/cassandra-four-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaUpdater.java @@ -45,6 +45,14 @@ public static void load(Schema schema, KeyspaceMetadata keyspaceMetadata, Types schema.load(keyspaceMetadata); } + /** + * Replaces the metadata of an existing keyspace with metadata that holds fewer tables + */ + public static void removeTables(Schema schema, KeyspaceMetadata keyspaceMetadata) + { + schema.load(keyspaceMetadata); + } + public static void updateTable(Schema schema, KeyspaceMetadata keyspaceMetadata, TableMetadata tableMetadata) { schema.load(keyspaceMetadata.withSwapped(keyspaceMetadata.tables.withSwapped(tableMetadata))); diff --git a/cassandra-six-zero-avro-converter/build.gradle b/cassandra-six-zero-avro-converter/build.gradle new file mode 100644 index 000000000..6877c5e63 --- /dev/null +++ b/cassandra-six-zero-avro-converter/build.gradle @@ -0,0 +1,47 @@ +/* + * 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. + */ + +plugins { + id('java-library') +} + +configurations { + all*.exclude(group: 'org.slf4j', module: 'slf4j-log4j12') + all*.exclude(group: 'log4j', module: 'log4j') + implCassV5 +} + +dependencies { + implCassV5 project(':cassandra-five-zero-avro-converter') + compileOnly project(':cassandra-bridge') + compileOnly project(":cassandra-analytics-cdc") + compileOnly project(":cassandra-analytics-common") + compileOnly project(":cassandra-six-zero-types") + compileOnly project(":cassandra-avro-converter") + compileOnly(project(path: ':cassandra-six-zero', configuration: 'shadow')) + compileOnly "org.apache.avro:avro:${avroVersion}" +} + +jar { + archiveFileName = "six-zero-avro.jar" + dependsOn ':cassandra-five-zero-avro-converter:build' + // copy classes from the V5 implementation and exclude duplicates that we override + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from { configurations.implCassV5.collect { it.isDirectory() ? it : zipTree(it) } } +} diff --git a/cassandra-six-zero-bridge/build.gradle b/cassandra-six-zero-bridge/build.gradle new file mode 100644 index 000000000..fdb738fea --- /dev/null +++ b/cassandra-six-zero-bridge/build.gradle @@ -0,0 +1,151 @@ +import java.nio.file.Paths + +/* + * 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. + */ + +plugins { + id('java-library') +} + +apply from: "${project.rootDir}/gradle/common/javaOptions.gradle" + +// The jar task copies the Cassandra 5.0 main classes forward, but it does not copy test classes. Name the +// Cassandra 5.0 bridge test directory as a second test source directory, so that the same tests run against +// Cassandra 6.0. +def fiveZeroTestDir = project(':cassandra-five-zero-bridge').file('src/test/java') + +// Every test below needs a Cassandra 6.0 change, and this module holds its own copy of each one. Unlike the +// jar copy-forward, a source directory does not override, so name each Cassandra 5.0 copy here to keep the +// compiler from seeing two classes of the same name. +def overriddenTests = [ + 'org/apache/cassandra/bridge/CassandraSchemaTests.java', + 'org/apache/cassandra/bridge/SSTableWriterImplementationTest.java', + 'org/apache/cassandra/spark/reader/SchemaBuilderTests.java', +].collect { new File(fiveZeroTestDir, it).absolutePath } + +sourceSets { + test { + java { + srcDir fiveZeroTestDir + } + } +} + +// Both copies share the same relative path, so an exclude pattern on the source set would drop both. +// Filter by absolute path instead. +tasks.named('compileTestJava') { + exclude { element -> element.file.absolutePath in overriddenTests } +} + +configurations { + all*.exclude(group: 'org.slf4j', module: 'slf4j-log4j12') + all*.exclude(group: 'log4j', module: 'log4j') + implCassV5 +} + +dependencies { + // base this module on the Cassandra 5.x bridge, whose jar already carries the 4.x classes forward + implCassV5 project(':cassandra-five-zero-bridge') + compileOnly project(':cassandra-five-zero-bridge') + compileOnly project(':cassandra-four-zero-bridge') + // test runtime dependencies are needed to make V4 and V5 main classes visible during unit test execution. + // Declare every version-specific module here, newest first, and keep them all in testCompileOnly. + // Gradle builds testCompileClasspath from testCompileOnly before testImplementation, and preserves + // declaration order within a configuration. The first entry on the compile classpath wins, and each of + // these modules holds a class of the same name for every class that a later version overrides. V4 first + // resolves CassandraSchema, ReaderUtils and SummaryDbUtils to the V4 bodies, which take different + // arguments and name the pre-5.0 package of IndexSummary. + testCompileOnly project(':cassandra-six-zero-types') + testCompileOnly project(':cassandra-five-zero-types') + testCompileOnly project(':cassandra-five-zero-bridge') + testRuntimeOnly project(':cassandra-five-zero-bridge') + testCompileOnly project(':cassandra-four-zero-bridge') + testRuntimeOnly project(':cassandra-four-zero-bridge') + testCompileOnly project(':cassandra-four-zero-types') + testRuntimeOnly project(':cassandra-four-zero-types') + + compileOnly project(":cassandra-analytics-common") + compileOnly project(":cassandra-six-zero-types") + // some classes from cassandra-six-zero-types will be present in the V4 and V5 types modules + compileOnly project(":cassandra-five-zero-types") + compileOnly project(":cassandra-four-zero-types") + compileOnly(project(':cassandra-bridge')) + compileOnly project(":cassandra-analytics-spark-converter") + + compileOnly(project(path: ':cassandra-six-zero', configuration: 'shadow')) + + compileOnly(group: 'org.slf4j', name: 'slf4j-api', version: "${project.slf4jApiVersion}") + compileOnly(group: 'com.esotericsoftware', name: 'kryo-shaded', version: "${project.kryoVersion}") + compileOnly(group: 'com.google.guava', name: 'guava', version: "${project.guavaVersion}") + compileOnly(group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: "${project.jacksonVersion}") + compileOnly(group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: "${project.jacksonVersion}") + compileOnly(group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: "${project.jacksonVersion}") + + testImplementation(testFixtures(project(':cassandra-bridge'))) + testImplementation project(":cassandra-analytics-common") + testImplementation project(":cassandra-six-zero-types") + testImplementation project(":cassandra-five-zero-types") + testImplementation(project(':cassandra-bridge')) + testImplementation project(":cassandra-analytics-spark-converter") + testImplementation project(":cassandra-analytics-spark-six-zero-converter") + testImplementation(project(path: ':cassandra-six-zero', configuration: 'shadow')) + testImplementation("org.junit.jupiter:junit-jupiter-api:${project.junitVersion}") + testImplementation("org.junit.jupiter:junit-jupiter-params:${project.junitVersion}") + testImplementation("org.junit.jupiter:junit-jupiter-engine:${project.junitVersion}") + testImplementation("org.assertj:assertj-core:${assertjCoreVersion}") + testImplementation(group: 'org.quicktheories', name: 'quicktheories', version: "${project.rootProject.quickTheoriesVersion}") + testImplementation("org.mockito:mockito-core:${project.rootProject.mockitoVersion}") + testImplementation(group: "${sparkGroupId}", name: "spark-core_${scalaMajorVersion}", version: "${project.rootProject.sparkVersion}") + testImplementation(group: "${sparkGroupId}", name: "spark-sql_${scalaMajorVersion}", version: "${project.rootProject.sparkVersion}") + testImplementation(group: 'com.github.luben', name: 'zstd-jni', version: '1.5.0-4') + + testRuntimeOnly(group: 'net.java.dev.jna', name: 'jna', version: "${jnaVersion}") + testRuntimeOnly(group: 'net.java.dev.jna', name: 'jna-platform', version: "${jnaVersion}") +} + +jar { + archiveFileName = "six-zero-bridge.jar" + // trigger types testClasses and/or jar, so that dependent classes will be visible + // note that it should not depend on 'build', which depends on 'test' + dependsOn ':cassandra-six-zero-types:testClasses' + dependsOn ':cassandra-five-zero-bridge:jar' + // copy classes from the V5 implementation and exclude duplicates that we override + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from { configurations.implCassV5.collect { it.isDirectory() ? it : zipTree(it) } } +} + +test { + systemProperty "cassandra.analytics.bridges.sstable_format", System.getProperty("cassandra.analytics.bridges.sstable_format", "big") + useJUnitPlatform() +} + +// automatically run BIG and BTI tests for the Cassandra 6.x bridge +tasks.register('testBti', Test) { + doFirst { + jvmArgs(project.ext.JDK_OPTIONS) + println("JVM arguments for $project.name are $allJvmArgs") + } + + systemProperty "cassandra.analytics.bridges.sstable_format", "bti" + useJUnitPlatform() +} + +check { + dependsOn(tasks.testBti) +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/bridge/AbstractCdcBridgeImplementation.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/bridge/AbstractCdcBridgeImplementation.java new file mode 100644 index 000000000..9f0645b3d --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/bridge/AbstractCdcBridgeImplementation.java @@ -0,0 +1,344 @@ +/* + * 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.bridge; + +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Iterables; + +import org.apache.cassandra.cdc.FourZeroCommitLog; +import org.apache.cassandra.cdc.FourZeroMutation; +import org.apache.cassandra.cdc.api.CassandraSource; +import org.apache.cassandra.cdc.api.CommitLog; +import org.apache.cassandra.cdc.api.CommitLogInstance; +import org.apache.cassandra.cdc.api.CommitLogMarkers; +import org.apache.cassandra.cdc.api.CommitLogReader; +import org.apache.cassandra.cdc.api.Marker; +import org.apache.cassandra.cdc.api.RangeTombstoneData; +import org.apache.cassandra.cdc.api.Row; +import org.apache.cassandra.cdc.api.TableIdLookup; +import org.apache.cassandra.cdc.scanner.CdcSortedStreamScanner; +import org.apache.cassandra.cdc.scanner.CdcStreamScanner; +import org.apache.cassandra.cdc.state.CdcState; +import org.apache.cassandra.cdc.stats.ICdcStats; +import org.apache.cassandra.cql3.ColumnIdentifier; +import org.apache.cassandra.db.Clustering; +import org.apache.cassandra.db.DbUtils; +import org.apache.cassandra.db.DecoratedKey; +import org.apache.cassandra.db.Mutation; +import org.apache.cassandra.db.commitlog.BufferingCommitLogReader; +import org.apache.cassandra.db.commitlog.FourZeroPartitionUpdateWrapper; +import org.apache.cassandra.db.commitlog.PartitionUpdateWrapper; +import org.apache.cassandra.db.partitions.PartitionUpdate; +import org.apache.cassandra.db.rows.BTreeRow; +import org.apache.cassandra.db.rows.Rows; +import org.apache.cassandra.schema.ColumnMetadata; +import org.apache.cassandra.schema.Schema; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.spark.data.CqlField; +import org.apache.cassandra.spark.data.CqlTable; +import org.apache.cassandra.spark.data.CqlType; +import org.apache.cassandra.spark.data.partitioner.Partitioner; +import org.apache.cassandra.spark.utils.AsyncExecutor; +import org.apache.cassandra.spark.utils.ByteBufferUtils; +import org.apache.cassandra.spark.utils.TableIdentifier; +import org.apache.cassandra.spark.utils.TimeProvider; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A copy of the four-zero class, unchanged except for this comment. Cassandra 6.0 retypes + * {@code Schema.instance} to {@code SchemaProvider}, and a {@code getstatic} encodes the field type, so the + * inherited four-zero class throws {@code NoSuchFieldError: instance}. This copy recompiles the two reads. + */ +public abstract class AbstractCdcBridgeImplementation extends CdcBridge +{ + public void log(CqlTable cqlTable, CommitLogInstance log, Row row, long timestamp) + { + log(TimeProvider.DEFAULT, cqlTable, log, row, timestamp); + } + + public CommitLogInstance createCommitLogInstance(Path path) + { + return new FourZeroCommitLog(path); + } + + /** + * Returns a TableIdLookup that resolves table IDs via the current Schema instance. + * Creates a new lambda on each call to avoid permanently pinning Schema.instance + * to a static field, which would prevent GC of the entire schema graph. + */ + public TableIdLookup internalTableIdLookup() + { + return (keyspace, table) -> { + TableMetadata tm = Schema.instance.getTableMetadata(keyspace, table); + if (tm == null) + { + throw new NoSuchElementException(); + } + return tm.id.asUUID(); + }; + } + + public void updateCdcSchema(@NotNull Set cdcTables, @NotNull Partitioner partitioner, @NotNull TableIdLookup tableIdLookup) + { + CassandraSchema.updateCdcSchema(cdcTables, partitioner, tableIdLookup); + } + + public void unregisterNonCdcTables(@NotNull Set tables) + { + CassandraSchema.unregisterNonCdcTables(tables); + } + + public CommitLogReader.Result readLog(@NotNull CommitLog log, + @Nullable TokenRange tokenRange, + @NotNull CommitLogMarkers markers, + int partitionId, + @NotNull ICdcStats stats, + @Nullable AsyncExecutor executor, + @Nullable Consumer listener, + @Nullable Long startTimestampMicros, + boolean readCommitLogHeader) + { + try (BufferingCommitLogReader reader = new BufferingCommitLogReader(log, + tokenRange, + markers, + partitionId, + stats, + executor, + listener, // only for testing + startTimestampMicros, + readCommitLogHeader)) + { + return reader.result(); + } + } + + public CdcStreamScanner openCdcStreamScanner(Collection updates, + @NotNull CdcState endState, + Random random, + CassandraSource cassandraSource, + double traceSampleRate) + { + return new CdcSortedStreamScanner(updates.stream().map(a -> (FourZeroPartitionUpdateWrapper) a).collect(Collectors.toList()), + endState, + ThreadLocalRandom.current(), + cassandraSource, + traceSampleRate); + } + + + @VisibleForTesting + public void log(TimeProvider timeProvider, CqlTable cqlTable, CommitLogInstance log, Row row, long timestamp) + { + final Mutation mutation = makeMutation(timeProvider, cqlTable, row, timestamp); + log.add(FourZeroMutation.wrap(mutation)); + } + + @NotNull + @VisibleForTesting + public static Mutation makeMutation(TimeProvider timeProvider, CqlTable cqlTable, Row row, long timestamp) + { + final TableMetadata table = Schema.instance.getTableMetadata(cqlTable.keyspace(), cqlTable.table()); + assert table != null; + + final org.apache.cassandra.db.rows.Row.Builder rowBuilder = BTreeRow.sortedBuilder(); + if (row.isInsert()) + { + rowBuilder.addPrimaryKeyLivenessInfo(DbUtils.livenessInfo(timestamp, timeProvider.nowInSeconds())); + } + org.apache.cassandra.db.rows.Row staticRow = Rows.EMPTY_STATIC_ROW; + + // build partition key + final List partitionKeys = cqlTable.partitionKeys(); + final ByteBuffer partitionKey = ByteBufferUtils.buildPartitionKey(partitionKeys, + partitionKeys.stream() + .map(f -> row.get(f.position())) + .toArray()); + + final DecoratedKey decoratedPartitionKey = table.partitioner.decorateKey(partitionKey); + // create a mutation and return early + if (isPartitionDeletion(cqlTable, row)) + { + PartitionUpdate delete = DbUtils.fullPartitionDeletion(table, partitionKey, timestamp, timeProvider.nowInSeconds()); + return new Mutation(delete); + } + + final List clusteringKeys = cqlTable.clusteringKeys(); + + // create a mutation with rangetombstones + if (row.rangeTombstones() != null && !row.rangeTombstones().isEmpty()) + { + return makeRangeTombstone(cqlTable, table, decoratedPartitionKey, timestamp, timeProvider, row); + } + + // When the test row data (IRow) defines no regular row, noRegularRow is true. It happens when clustering keys are defined, but not set. + boolean noRegularRow = false; + // build clustering key + if (clusteringKeys.isEmpty()) + { + rowBuilder.newRow(Clustering.EMPTY); + } + else if (clusteringKeys.stream().allMatch(f -> row.get(f.position()) == null)) + { + // clustering key is defined, but not set ==> no regular row + noRegularRow = true; + } + else + { + rowBuilder.newRow(Clustering.make( + clusteringKeys.stream() + .map(f -> f.serialize(row.get(f.position()))) + .toArray(ByteBuffer[]::new)) + ); + } + + if (row.isDeleted()) + { + rowBuilder.addRowDeletion(org.apache.cassandra.db.rows.Row.Deletion.regular( + DbUtils.deletionTime(timestamp, timeProvider.nowInSeconds()))); + } + else + { + BiConsumer rowBuildFunc = (builder, field) -> { + final CqlType type = (CqlType) field.type(); + final ColumnMetadata cd = table.getColumn(new ColumnIdentifier(field.name(), false)); + Object value = row.get(field.position()); + if (value != UNSET_MARKER) // if unset, do not add the cell + { + if (value == null) + { + if (cd.isComplex()) + { + type.addComplexTombstone(builder, cd, timestamp); + } + else + { + type.addTombstone(builder, cd, timestamp); + } + } + else if (value instanceof CollectionElement) + { + CollectionElement ce = (CollectionElement) value; + if (ce.value == null) + { + type.addTombstone(builder, cd, timestamp, ce.cellPath); + } + else + { + type.addCell(builder, cd, timestamp, row.ttl(), timeProvider.nowInSeconds(), ce.value, ce.cellPath); + } + } + else + { + type.addCell(builder, cd, timestamp, row.ttl(), timeProvider.nowInSeconds(), value); + } + } + }; + + if (!cqlTable.staticColumns().isEmpty()) + { + org.apache.cassandra.db.rows.Row.Builder staticRowBuilder = BTreeRow.sortedBuilder(); + staticRowBuilder.newRow(Clustering.STATIC_CLUSTERING); + for (final CqlField field : cqlTable.staticColumns()) + { + rowBuildFunc.accept(staticRowBuilder, field); + } + staticRow = staticRowBuilder.build(); // replace the empty row with the new static row built + } + + // build value cells + for (final CqlField field : cqlTable.valueColumns()) + { + rowBuildFunc.accept(rowBuilder, field); + } + } + + return new Mutation(PartitionUpdate.singleRowUpdate(table, decoratedPartitionKey, + noRegularRow ? null : rowBuilder.build(), // regular row + staticRow)); // static row + } + + protected static Mutation makeRangeTombstone(CqlTable cqlTable, + TableMetadata table, + DecoratedKey decoratedPartitionKey, + long timestamp, + TimeProvider timeProvider, + Row row) + { + final List clusteringKeys = cqlTable.clusteringKeys(); + PartitionUpdate.SimpleBuilder pub = DbUtils.partitionUpdateBuilderWithNow(table, decoratedPartitionKey, timeProvider.nowInSeconds()) + .timestamp(timestamp); + for (RangeTombstoneData rt : row.rangeTombstones()) + { + // range tombstone builder is built when partition update builder builds + PartitionUpdate.SimpleBuilder.RangeTombstoneBuilder rangeTombstoneBuilder = pub.addRangeTombstone(); + rangeTombstoneBuilder = rt.open.inclusive + ? rangeTombstoneBuilder.inclStart() + : rangeTombstoneBuilder.exclStart(); // returns the same ref. just to make compiler happy + Object[] startValues = clusteringKeys.stream() + .map(f -> { + Object v = rt.open.values[f.position() - cqlTable.numPartitionKeys()]; + return v == null ? null : f.serialize(v); + }) + .filter(Objects::nonNull) + .toArray(ByteBuffer[]::new); + rangeTombstoneBuilder.start(startValues); + rangeTombstoneBuilder = rt.close.inclusive ? rangeTombstoneBuilder.inclEnd() : rangeTombstoneBuilder.exclEnd(); + Object[] endValues = clusteringKeys.stream() + .map(f -> { + Object v = rt.close.values[f.position() - cqlTable.numPartitionKeys()]; + return v == null ? null : f.serialize(v); + }) + .filter(Objects::nonNull) + .toArray(ByteBuffer[]::new); + rangeTombstoneBuilder.end(endValues); + } + return new Mutation(pub.build()); + } + + @VisibleForTesting + protected static boolean isPartitionDeletion(CqlTable cqlTable, Row row) + { + final List clusteringKeys = cqlTable.clusteringKeys(); + final List valueFields = cqlTable.valueColumns(); + final List staticFields = cqlTable.staticColumns(); + for (CqlField f : Iterables.concat(clusteringKeys, valueFields, staticFields)) + { + if (row.get(f.position()) != null) + { + return false; + } + } + return true; + } +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/bridge/CassandraBridgeImplementation.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/bridge/CassandraBridgeImplementation.java new file mode 100644 index 000000000..f9760f746 --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/bridge/CassandraBridgeImplementation.java @@ -0,0 +1,850 @@ +/* + * 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.bridge; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.OutputStream; +import java.io.Serializable; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.AbstractMap; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.Serializer; +import org.apache.cassandra.analytics.reader.common.IndexIterator; +import org.apache.cassandra.analytics.stats.Stats; +import org.apache.cassandra.db.DecoratedKey; +import org.apache.cassandra.db.marshal.AbstractType; +import org.apache.cassandra.db.marshal.ByteBufferAccessor; +import org.apache.cassandra.db.marshal.CompositeType; +import org.apache.cassandra.db.rows.UnfilteredRowIterator; +import org.apache.cassandra.dht.IPartitioner; +import org.apache.cassandra.dht.Murmur3Partitioner; +import org.apache.cassandra.dht.RandomPartitioner; +import org.apache.cassandra.io.compress.ICompressor; +import org.apache.cassandra.io.compress.LZ4Compressor; +import org.apache.cassandra.io.sstable.CQLSSTableWriter; +import org.apache.cassandra.io.sstable.Descriptor; +import org.apache.cassandra.io.sstable.ISSTableScanner; +import org.apache.cassandra.io.sstable.SSTableTombstoneWriter; +import org.apache.cassandra.io.sstable.format.SSTableFormat; +import org.apache.cassandra.io.sstable.format.SSTableReader; +import org.apache.cassandra.io.sstable.format.bti.BtiReaderUtils; +import org.apache.cassandra.io.sstable.metadata.MetadataComponent; +import org.apache.cassandra.io.sstable.metadata.MetadataType; +import org.apache.cassandra.io.sstable.metadata.StatsMetadata; +import org.apache.cassandra.io.util.File; +import org.apache.cassandra.io.util.FileOutputStreamPlus; +import org.apache.cassandra.schema.Schema; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.schema.TableMetadataRef; +import org.apache.cassandra.spark.data.CassandraTypes; +import org.apache.cassandra.spark.data.CqlField; +import org.apache.cassandra.spark.data.CqlTable; +import org.apache.cassandra.spark.data.CqlType; +import org.apache.cassandra.spark.data.ReplicationFactor; +import org.apache.cassandra.spark.data.SSTable; +import org.apache.cassandra.spark.data.SSTablesSupplier; +import org.apache.cassandra.spark.data.TypeConverter; +import org.apache.cassandra.spark.data.complex.CqlTuple; +import org.apache.cassandra.spark.data.complex.CqlUdt; +import org.apache.cassandra.spark.data.partitioner.Partitioner; +import org.apache.cassandra.spark.reader.BtiIndexReader; +import org.apache.cassandra.spark.reader.CompactionStreamScanner; +import org.apache.cassandra.spark.reader.IndexEntry; +import org.apache.cassandra.spark.reader.BigIndexReader; +import org.apache.cassandra.spark.reader.ReaderUtils; +import org.apache.cassandra.spark.reader.RowData; +import org.apache.cassandra.spark.reader.SchemaBuilder; +import org.apache.cassandra.spark.reader.StreamScanner; +import org.apache.cassandra.spark.reader.SummaryDbUtils; +import org.apache.cassandra.spark.sparksql.CellIterator; +import org.apache.cassandra.spark.sparksql.RowIterator; +import org.apache.cassandra.spark.sparksql.filters.PartitionKeyFilter; +import org.apache.cassandra.spark.sparksql.filters.PruneColumnFilter; +import org.apache.cassandra.spark.sparksql.filters.SparkRangeFilter; +import org.apache.cassandra.spark.sparksql.filters.SSTableTimeRangeFilter; +import org.apache.cassandra.spark.utils.Pair; +import org.apache.cassandra.spark.utils.SparkClassLoaderOverride; +import org.apache.cassandra.spark.utils.TimeProvider; +import org.apache.cassandra.tools.JsonTransformer; +import org.apache.cassandra.tools.Util; +import org.apache.cassandra.util.CompressionUtil; +import org.apache.cassandra.util.IntWrapper; +import org.apache.cassandra.utils.BloomFilter; +import org.apache.cassandra.utils.CompressionUtilImplementation; +import org.apache.cassandra.utils.FBUtilities; +import org.apache.cassandra.utils.FilterDbUtils; +import org.apache.cassandra.utils.IFilter; +import org.apache.cassandra.utils.TimeUUID; +import org.apache.cassandra.utils.TokenUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("unused") +public class CassandraBridgeImplementation extends CassandraBridge +{ + private static final Logger LOGGER = LoggerFactory.getLogger(CassandraBridgeImplementation.class); + + private final Map, Serializer> kryoSerializers; + + static + { + setup(); + } + + public static synchronized void setup() + { + CassandraTypesImplementation.setup(BridgeInitializationParameters.fromEnvironment()); + } + + public CassandraBridgeImplementation() + { + // Cassandra-version-specific Kryo serializers + kryoSerializers = new LinkedHashMap<>(); + kryoSerializers.put(CqlField.class, new CqlField.Serializer(cassandraTypes())); + kryoSerializers.put(CqlTable.class, new CqlTable.Serializer(cassandraTypes())); + kryoSerializers.put(CqlUdt.class, new CqlUdt.Serializer(cassandraTypes())); + } + + public CassandraTypes cassandraTypes() + { + return CassandraTypesImplementation.INSTANCE; + } + + @Override + public AbstractMap.SimpleEntry getPartitionKey(@NotNull CqlTable table, + @NotNull Partitioner partitioner, + @NotNull List keys) + { + Preconditions.checkArgument(table.partitionKeys().size() > 0); + ByteBuffer partitionKey = buildPartitionKey(table, keys); + BigInteger partitionKeyTokenValue = hash(partitioner, partitionKey); + return new AbstractMap.SimpleEntry<>(partitionKey, partitionKeyTokenValue); + } + + @VisibleForTesting + public static ByteBuffer buildPartitionKey(@NotNull CqlTable table, @NotNull List keys) + { + List> partitionKeyColumnTypes = partitionKeyColumnTypes(table); + if (table.partitionKeys().size() == 1) + { + // Single partition key + return partitionKeyColumnTypes.get(0).fromString(keys.get(0)); + } + else + { + // Composite partition key + ByteBuffer[] buffers = new ByteBuffer[keys.size()]; + for (int index = 0; index < buffers.length; index++) + { + buffers[index] = partitionKeyColumnTypes.get(index).fromString(keys.get(index)); + } + return CompositeType.build(ByteBufferAccessor.instance, buffers); + } + } + + @VisibleForTesting + public static List> partitionKeyColumnTypes(@NotNull CqlTable table) + { + return table.partitionKeys().stream() + .map(CqlField::type) + .map(type -> (CqlType) type) + .map(type -> type.dataType(true)) + .collect(Collectors.toList()); + } + + @Override + public StreamScanner getCompactionScanner(@NotNull CqlTable table, + @NotNull Partitioner partitioner, + @NotNull SSTablesSupplier ssTables, + @Nullable SparkRangeFilter sparkRangeFilter, + @NotNull Collection partitionKeyFilters, + @NotNull SSTableTimeRangeFilter sstableTimeRangeFilter, + @Nullable PruneColumnFilter columnFilter, + @NotNull TimeProvider timeProvider, + boolean readIndexOffset, + boolean useIncrementalRepair, + @NotNull Stats stats) + { + // NOTE: Need to use SchemaBuilder to init keyspace if not already set in Cassandra Schema instance + SchemaBuilder schemaBuilder = new SchemaBuilder(table, partitioner); + TableMetadata metadata = schemaBuilder.tableMetaData(); + return new CompactionStreamScanner(metadata, partitioner, timeProvider, ssTables.openAll((ssTable, isRepairPrimary) -> { + return org.apache.cassandra.spark.reader.SSTableReader.builder(metadata, ssTable) + .withSparkRangeFilter(sparkRangeFilter) + .withPartitionKeyFilters(partitionKeyFilters) + .withTimeRangeFilter(sstableTimeRangeFilter) + .withColumnFilter(columnFilter) + .withReadIndexOffset(readIndexOffset) + .withStats(stats) + .useIncrementalRepair(useIncrementalRepair) + .isRepairPrimary(isRepairPrimary) + .build(); + })); + } + + @Override + public StreamScanner getPartitionSizeIterator(@NotNull CqlTable table, + @NotNull Partitioner partitioner, + @NotNull SSTablesSupplier ssTables, + @Nullable SparkRangeFilter rangeFilter, + @NotNull TimeProvider timeProvider, + @NotNull Stats stats, + @NotNull ExecutorService executor) + { + //NOTE: need to use SchemaBuilder to init keyspace if not already set in C* Schema instance + SchemaBuilder schemaBuilder = new SchemaBuilder(table, partitioner); + TableMetadata metadata = schemaBuilder.tableMetaData(); + return new IndexIterator<>(ssTables, stats, ((ssTable, isRepairPrimary, consumer) -> { + if (ssTable.isBigFormat()) + { + return new BigIndexReader(ssTable, metadata, rangeFilter, stats, consumer); + } + return new BtiIndexReader(ssTable, metadata, rangeFilter, stats, consumer); + })); + } + + @Override + public CassandraVersion getVersion() + { + return CassandraVersion.SIXZERO; + } + + @Override + public BigInteger hash(Partitioner partitioner, ByteBuffer key) + { + switch (partitioner) + { + case RandomPartitioner: + return RandomPartitioner.instance.getToken(key).getTokenValue(); + case Murmur3Partitioner: + return BigInteger.valueOf((long) Murmur3Partitioner.instance.getToken(key).getTokenValue()); + default: + throw new UnsupportedOperationException("Unexpected partitioner: " + partitioner); + } + } + + @Override + public UUID getTimeUUID() + { + return TimeUUID.Generator.nextTimeUUID().asUUID(); + } + + @Override + public CqlTable buildSchema(String createStatement, + String keyspace, + ReplicationFactor replicationFactor, + Partitioner partitioner, + Set udts, + @Nullable UUID tableId, + int indexCount, + boolean enableCdc) + { + return new SchemaBuilder(createStatement, keyspace, replicationFactor, partitioner, cassandraTypes -> udts, tableId, indexCount, enableCdc).build(); + } + + @Override + public CompressionUtil compressionUtil() + { + return CompressionUtilImplementation.INSTANCE; + } + + @Override + public long lastRepairTime(String keyspace, String table, SSTable ssTable) throws IOException + { + Map componentMap = ReaderUtils.deserializeStatsMetadata(keyspace, table, ssTable, EnumSet.of(MetadataType.STATS)); + StatsMetadata statsMetadata = (StatsMetadata) componentMap.get(MetadataType.STATS); + if (statsMetadata == null) + { + throw new IllegalStateException("Could not read StatsMetadata"); + } + return statsMetadata.repairedAt; + } + + @Override + public List overlaps(SSTable ssTable, + Partitioner partitioner, + int minIndexInterval, + int maxIndexInterval, + List ranges) + { + SSTableSummary summary = getSSTableSummary(partitioner, ssTable, minIndexInterval, maxIndexInterval); + TokenRange sstableRange = TokenRange.closed(summary.firstToken, summary.lastToken); + return ranges.stream() + .map(range -> range.isConnected(sstableRange)) + .collect(Collectors.toList()); + } + + @Override + public Tokenizer tokenizer(Partitioner partitioner) + { + IPartitioner iPartitioner = getPartitioner(partitioner); + return partitionKey -> { + DecoratedKey decoratedKey = iPartitioner.decorateKey(partitionKey); + return TokenUtils.tokenToBigInteger(decoratedKey.getToken()); + }; + } + + @Override + public List encodePartitionKeys(Partitioner partitioner, String keyspace, String createTableStmt, List> keys) + { + CqlTable table = new SchemaBuilder(createTableStmt, keyspace, ReplicationFactor.simpleStrategy(1), partitioner).build(); + return keys.stream().map(key -> buildPartitionKey(table, key)).collect(Collectors.toList()); + } + + @Override + public org.apache.cassandra.bridge.BloomFilter openBloomFilter(Partitioner partitioner, + String keyspace, + String table, + SSTable ssTable) throws IOException + { + IPartitioner iPartitioner = getPartitioner(partitioner); + Descriptor descriptor = ReaderUtils.constructDescriptor(keyspace, table, ssTable); + // closing `SharedCloseableImpl` instances is known to cause SIGSEGV errors + BloomFilter filter = openBloomFilter(descriptor, ssTable); + return partitionKey -> { + DecoratedKey decoratedKey = iPartitioner.decorateKey(partitionKey); + return filter.isPresent(decoratedKey); + }; + } + + @Override + public void rebuildBloomFilter(@NotNull Partitioner partitioner, + @NotNull CqlTable cqltable, + @NotNull SSTable ssTable, + @NotNull Path directory) throws IOException + { + String keyspace = cqltable.keyspace(); + String table = cqltable.table(); + IPartitioner iPartitioner = getPartitioner(partitioner); + SchemaBuilder schemaBuilder = new SchemaBuilder(cqltable, partitioner); + TableMetadata tableMetadata = schemaBuilder.tableMetaData(); + + if (tableMetadata.params.bloomFilterFpChance == 1.0) + { + return; // bloom filter has been disabled for the table + } + + Descriptor descriptor = ReaderUtils.constructDescriptor(keyspace, table, ssTable); + File filterFile = new File(directory, descriptor.fileFor(SSTableFormat.Components.FILTER).name()); + try (IFilter filter = FilterDbUtils.buildBloomFilter(cqltable, ssTable, tableMetadata)) + { + Function tracker = bytes -> { + DecoratedKey key = iPartitioner.decorateKey(bytes); + filter.add(key); + return false; + }; + if (ssTable.isBtiFormat()) + { + BtiReaderUtils.readPrimaryIndex(ssTable, iPartitioner, descriptor, + tableMetadata.params.crcCheckChance, tracker); + } + else + { + try (InputStream primaryIndex = ssTable.openPrimaryIndexStream()) + { + if (primaryIndex == null) + { + throw new IOException("Could not read Index.db file"); + } + ReaderUtils.readPrimaryIndex(primaryIndex, tracker); + } + } + + try (FileOutputStreamPlus stream = filterFile.newOutputStream(File.WriteMode.OVERWRITE)) + { + filter.serialize(stream, descriptor.version.hasOldBfFormat()); + stream.flush(); + stream.sync(); + } + } + catch (Exception e) + { + LOGGER.error("Failed to rebuild bloom filter for sstable {}/{}", directory, ssTable.getDataFileName(), e); + // Remove potentially corrupted bloom filter. It will be rebuilt by Cassandra during sstable import. + Files.deleteIfExists(filterFile.toPath()); + } + } + + private BloomFilter openBloomFilter(Descriptor descriptor, SSTable ssTable) throws IOException + { + return ReaderUtils.readFilter(ssTable, descriptor); + } + + @Override + public List contains(Partitioner partitioner, String keyspace, String table, SSTable ssTable, List partitionKeys) throws IOException + { + if (partitionKeys.isEmpty()) + { + return Collections.emptyList(); + } + + IPartitioner iPartitioner = getPartitioner(partitioner); + List decoratedKeys = partitionKeys.stream().map(iPartitioner::decorateKey).collect(Collectors.toList()); + Descriptor descriptor = ReaderUtils.constructDescriptor(keyspace, table, ssTable); + BloomFilter filter = openBloomFilter(descriptor, ssTable); + List result = decoratedKeys.stream().map(filter::isPresent).collect(Collectors.toList()); + if (result.stream().noneMatch(found -> found)) + { + // no matches in the bloom filter, so we can exit early + return result; + } + + // sorted by token with index into original partitionKeys list + List> sortedByTokens = IntStream.range(0, decoratedKeys.size()) + .mapToObj(idx -> { + DecoratedKey key = decoratedKeys.get(idx); + BigInteger token = TokenUtils.tokenToBigInteger(key.getToken()); + return Pair.of(token, idx); + }) + .sorted(Comparator.comparing(Pair::getLeft)) + .collect(Collectors.toList()); + + IntWrapper position = new IntWrapper(); + Function consumer = buffer -> { + DecoratedKey key = iPartitioner.decorateKey(buffer); + BigInteger token = TokenUtils.tokenToBigInteger(key.getToken()); + + Pair current = sortedByTokens.get(position.value); + int compare = token.compareTo(current.getLeft()); + while (compare > 0) + { + // we passed without finding the key + result.set(current.getRight(), false); + position.value++; + if (position.value >= decoratedKeys.size()) + { + // if we've found all the keys we can exit early + return true; + } + current = sortedByTokens.get(position.value); + compare = token.compareTo(current.getLeft()); + } + + ByteBuffer currentKey = partitionKeys.get(current.getRight()); + if (compare == 0 && buffer.equals(currentKey)) // token and key matches + { + result.set(current.getRight(), true); + position.value++; + } + + // if we've found all the keys we can exit early + return position.value >= decoratedKeys.size(); + }; + + if (ssTable.isBtiFormat()) + { + BtiReaderUtils.readPrimaryIndex(ssTable, iPartitioner, descriptor, 1.0, consumer); + } + else + { + try (InputStream primaryIndex = ssTable.openPrimaryIndexStream()) + { + if (primaryIndex == null) + { + throw new IOException("Could not read Index.db file"); + } + + ReaderUtils.readPrimaryIndex(primaryIndex, consumer); + } + } + + // mark as false for the rest of the keys we didn't reach + IntStream.range(position.value, sortedByTokens.size()) + .forEach(i -> result.set(sortedByTokens.get(i).getRight(), false)); + + return result; + } + + @Override + public void readPartitionKeys(Partitioner partitioner, + String keyspace, + String createStmt, + SSTablesSupplier ssTables, + @Nullable TokenRange tokenRange, + @Nullable List partitionKeys, + @Nullable String[] requiredColumns, + @NotNull SSTableTimeRangeFilter sstableTimeRangeFilter, + Consumer> rowConsumer) throws IOException + { + IPartitioner iPartitioner = getPartitioner(partitioner); + SchemaBuilder schemaBuilder = new SchemaBuilder(createStmt, keyspace, ReplicationFactor.simpleStrategy(1), partitioner); + TableMetadata metadata = schemaBuilder.tableMetaData(); + CqlTable table = schemaBuilder.build(); + List tokens = partitionKeys == null ? Collections.emptyList() : toTokens(partitioner, partitionKeys); + List partitionKeyFilters = partitionKeys == null ? Collections.emptyList() : + IntStream + .range(0, partitionKeys.size()) + .mapToObj(i -> PartitionKeyFilter.create(partitionKeys.get(i), tokens.get(i))) + .sorted() + .collect(Collectors.toList()); + + try (CellIterator it = new CellIterator(0, + table, + Stats.DoNothingStats.INSTANCE, + TypeConverter.IDENTITY, + partitionKeyFilters, + sstableTimeRangeFilter, + (t) -> PruneColumnFilter.of(requiredColumns), + (partitionId1, partitionKeyFilters1, timeRangeFilter1, columnFilter1) -> + new CompactionStreamScanner( + metadata, + partitioner, + TimeProvider.DEFAULT, + ssTables.openAll((ssTable, isRepairPrimary) -> + org.apache.cassandra.spark.reader.SSTableReader.builder(metadata, ssTable) + .withPartitionKeyFilters(partitionKeyFilters1) + .withTimeRangeFilter(timeRangeFilter1) + .build()) + )) + { + @Override + public boolean isInPartition(int partitionId, BigInteger token, ByteBuffer partitionKey) + { + return true; + } + + @Override + public boolean equals(CqlField field, Object obj1, Object obj2) + { + return Objects.equals(obj1, obj2); + } + }) + { + RowIterator> rowIterator = RowIterator.rowMapIterator(it, Stats.DoNothingStats.INSTANCE, requiredColumns); + + while (rowIterator.next()) + { + rowConsumer.accept(rowIterator.get()); + } + } + } + + @Override + public synchronized void writeSSTable(Partitioner partitioner, + String keyspace, + String table, + Path directory, + String createStatement, + String insertStatement, + String updateStatement, + boolean upsert, + Set udts, + Consumer writer) + { + CQLSSTableWriter.Builder builder = CQLSSTableWriter.builder() + .inDirectory(directory.toFile().getAbsolutePath()) + .forTable(createStatement) + .withPartitioner(getPartitioner(partitioner)) + .using(upsert ? updateStatement : insertStatement) + .withBufferSizeInMB(128); + + for (CqlField.CqlUdt udt : udts) + { + // Add user-defined types to CQL writer + String statement = udt.createStatement(cassandraTypes(), keyspace); + builder.withType(statement); + } + + try (CQLSSTableWriter ssTable = builder.build()) + { + writer.accept(values -> { + try + { + ssTable.addRow(values); + } + catch (IOException exception) + { + throw new RuntimeException(exception); + } + }); + } + catch (IOException exception) + { + throw new RuntimeException(exception); + } + } + + public static IPartitioner getPartitioner(Partitioner partitioner) + { + return CassandraTypesImplementation.getPartitioner(partitioner); + } + + @Override + public SSTableWriter getSSTableWriter(String inDirectory, + String partitioner, + String createStatement, + String insertStatement, + @NotNull Set userDefinedTypeStatements, + int bufferSizeMB) + { + return new SSTableWriterImplementation(inDirectory, partitioner, createStatement, insertStatement, + userDefinedTypeStatements, bufferSizeMB); + } + + @Override + public SSTableSummary getSSTableSummary(@NotNull String keyspace, + @NotNull String table, + @NotNull SSTable ssTable) + { + TableMetadata metadata = Schema.instance.getTableMetadata(keyspace, table); + if (metadata == null) + { + throw new RuntimeException("Could not create table metadata needed for reading SSTable summaries for keyspace: " + keyspace); + } + return getSSTableSummary(metadata.partitioner, ssTable, metadata.params.minIndexInterval, metadata.params.maxIndexInterval); + } + + @Override + public SSTableSummary getSSTableSummary(@NotNull Partitioner partitioner, + @NotNull SSTable ssTable, + int minIndexInterval, + int maxIndexInterval) + { + return getSSTableSummary(getPartitioner(partitioner), ssTable, minIndexInterval, maxIndexInterval); + } + + protected SSTableSummary getSSTableSummary(@NotNull IPartitioner partitioner, + @NotNull SSTable ssTable, + int minIndexInterval, + int maxIndexInterval) + { + try + { + SummaryDbUtils.Summary summary = SummaryDbUtils.readSummary(ssTable, partitioner, minIndexInterval, maxIndexInterval); + Pair keys = summary == null ? null : Pair.of(summary.first(), summary.last()); + if (summary == null) + { + keys = ReaderUtils.keysFromIndex(partitioner, ssTable); + } + if (keys == null) + { + throw new RuntimeException("Could not load SSTable first or last tokens for SSTable: " + ssTable.getDataFileName()); + } + DecoratedKey first = keys.left; + DecoratedKey last = keys.right; + BigInteger firstToken = ReaderUtils.tokenToBigInteger(first.getToken()); + BigInteger lastToken = ReaderUtils.tokenToBigInteger(last.getToken()); + return new SSTableSummary(firstToken, lastToken, getSSTablePrefix(ssTable.getDataFileName())); + } + catch (final IOException exception) + { + throw new RuntimeException(exception); + } + } + + private String getSSTablePrefix(String dataFileName) + { + return dataFileName.substring(0, dataFileName.lastIndexOf('-') + 1); + } + + // Version-Specific Test Utility Methods + + @Override + @VisibleForTesting + public void writeTombstoneSSTable(Partitioner partitioner, + Path directory, + String createStatement, + String deleteStatement, + Consumer consumer) + { + File cassFile = new File(directory.toFile()); + try (SSTableTombstoneWriter writer = SSTableTombstoneWriter.builder() + .inDirectory(cassFile) + .forTable(createStatement) + .withPartitioner(getPartitioner(partitioner)) + .using(deleteStatement) + .withBufferSizeInMB(128) + .build()) + { + consumer.accept(values -> { + try + { + writer.addRow(values); + } + catch (IOException exception) + { + throw new RuntimeException(exception); + } + }); + } + catch (IOException exception) + { + throw new RuntimeException(exception); + } + } + + @Override + @VisibleForTesting + public void sstableToJson(Path dataDbFile, OutputStream output) throws FileNotFoundException + { + if (!Files.exists(dataDbFile)) + { + throw new FileNotFoundException("Cannot find file " + dataDbFile.toAbsolutePath()); + } + File file = new File(dataDbFile.toFile()); + if (!Descriptor.isValidFile(file)) + { + throw new RuntimeException("Invalid sstable file"); + } + + Descriptor desc = Descriptor.fromFileWithComponent(file, false).left; + try + { + TableMetadataRef metadata = TableMetadataRef.forOfflineTools(Util.metadataFromSSTable(desc)); + SSTableReader ssTable = SSTableReader.openNoValidation(null, desc, metadata); + ISSTableScanner currentScanner = ssTable.getScanner(); + Stream partitions = Util.iterToStream(currentScanner); + // Cassandra 6.0 adds tombstonesOnly and nowInSeconds to JsonTransformer.toJson. Pass false to keep + // the 5.0 output, which holds every partition, and the now that sstabledump passes. + JsonTransformer.toJson(currentScanner, partitions, false, false, metadata.get(), + FBUtilities.nowInSeconds(), output); + } + catch (IOException exception) + { + throw new RuntimeException(exception); + } + } + + @Override + @VisibleForTesting + public Object toTupleValue(CqlField.CqlTuple type, Object[] values) + { + return CqlTuple.toTupleValue(getVersion(), (CqlTuple) type, values); + } + + @Override + @VisibleForTesting + public Object toUserTypeValue(CqlField.CqlUdt type, Map values) + { + return CqlUdt.toUserTypeValue(getVersion(), (CqlUdt) type, values); + } + + // Compression Utils + + private static final ICompressor COMPRESSOR = LZ4Compressor.create(Collections.emptyMap()); + + @Override + public ByteBuffer compress(byte[] bytes) throws IOException + { + ByteBuffer input = COMPRESSOR.preferredBufferType().allocate(bytes.length); + input.put(bytes); + input.flip(); + return compress(input); + } + + @Override + public ByteBuffer compress(ByteBuffer input) throws IOException + { + int length = input.remaining(); // Store uncompressed length as 4 byte int + // 4 extra bytes to store uncompressed length + ByteBuffer output = COMPRESSOR.preferredBufferType().allocate(4 + COMPRESSOR.initialCompressedBufferLength(length)); + output.putInt(length); + COMPRESSOR.compress(input, output); + output.flip(); + return output; + } + + @Override + public ByteBuffer uncompress(byte[] bytes) throws IOException + { + ByteBuffer input = COMPRESSOR.preferredBufferType().allocate(bytes.length); + input.put(bytes); + input.flip(); + return uncompress(input); + } + + @Override + public ByteBuffer uncompress(ByteBuffer input) throws IOException + { + ByteBuffer output = COMPRESSOR.preferredBufferType().allocate(input.getInt()); + COMPRESSOR.uncompress(input, output); + output.flip(); + return output; + } + + // Kryo/Java (De-)Serialization + + @Override + public void kryoRegister(Kryo kryo) + { + kryoSerializers.forEach(kryo::register); + } + + @Override + public void javaSerialize(ObjectOutputStream out, Serializable object) + { + try + { + out.writeObject(object); + } + catch (IOException exception) + { + throw new RuntimeException(exception); + } + } + + @Override + public T javaDeserialize(ObjectInputStream in, Class type) + { + try (SparkClassLoaderOverride override = new SparkClassLoaderOverride(in, getClass().getClassLoader())) + { + return type.cast(in.readObject()); + } + catch (IOException | ClassNotFoundException exception) + { + throw new RuntimeException(exception); + } + } + + public static String baseFilename(Descriptor descriptor) + { + // note that descriptor.baseFilename() contains the directory portion in the string. We do not include the directory portion + return descriptor.baseFile().name(); + } +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/bridge/SSTableWriterImplementation.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/bridge/SSTableWriterImplementation.java new file mode 100644 index 000000000..37aa7ece9 --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/bridge/SSTableWriterImplementation.java @@ -0,0 +1,159 @@ +/* + * 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.bridge; + +import java.io.IOException; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import javax.annotation.concurrent.NotThreadSafe; + +import com.google.common.annotations.VisibleForTesting; + +import org.apache.cassandra.config.Config; +import org.apache.cassandra.dht.IPartitioner; +import org.apache.cassandra.dht.Murmur3Partitioner; +import org.apache.cassandra.dht.RandomPartitioner; +import org.apache.cassandra.exceptions.InvalidRequestException; +import org.apache.cassandra.io.sstable.CQLSSTableWriter; +import org.apache.cassandra.io.sstable.format.SSTableReader; +import org.jetbrains.annotations.NotNull; + +@NotThreadSafe // The underlying CQLSSTableWriter is not thread-safe +public class SSTableWriterImplementation implements SSTableWriter +{ + static + { + Config.setClientMode(true); + } + + private final CQLSSTableWriter writer; + private Consumer> producedSSTablesListener; + + public SSTableWriterImplementation(String inDirectory, + String partitioner, + String createStatement, + String insertStatement, + @NotNull Set userDefinedTypeStatements, + int bufferSizeMB) + { + this(inDirectory, determineSupportedPartitioner(partitioner), createStatement, insertStatement, userDefinedTypeStatements, bufferSizeMB); + } + + @VisibleForTesting + public SSTableWriterImplementation(String inDirectory, + IPartitioner partitioner, + String createStatement, + String insertStatement, + @NotNull Set userDefinedTypeStatements, + int bufferSizeMB) + { + this.writer = configureBuilder(inDirectory, + createStatement, + insertStatement, + bufferSizeMB, + userDefinedTypeStatements, + this::onSSTablesProduced, + partitioner) + .build(); + } + + private static IPartitioner determineSupportedPartitioner(String partitioner) + { + return partitioner.toLowerCase().contains("random") + ? RandomPartitioner.instance + : Murmur3Partitioner.instance; + } + + private void onSSTablesProduced(Collection sstables) + { + Objects.requireNonNull(producedSSTablesListener, "producedSSTablesListener is not set"); + Set sstableDescriptors = sstables + .stream() + .map(sstable -> { + String baseFilename = CassandraBridgeImplementation.baseFilename(sstable.descriptor); + // TODO: for now, the sstableReader is closed immediately, + // TODO (CONTI): we can potentially read from the reader to validate the underlying sstable, + // TODO (CONTI): replacing org.apache.cassandra.spark.bulkwriter.SortedSSTableWriter.validateSSTables + sstable.selfRef().close(); + return new SSTableDescriptor(baseFilename); + }) + .collect(Collectors.toSet()); + + producedSSTablesListener.accept(sstableDescriptors); + } + + @Override + public void addRow(Map values) throws IOException + { + try + { + writer.addRow(values); + } + catch (InvalidRequestException exception) + { + throw new RuntimeException(exception); + } + } + + @Override + public void setSSTablesProducedListener(Consumer> listener) + { + producedSSTablesListener = Objects.requireNonNull(listener); + } + + @Override + public void close() throws IOException + { + writer.close(); + } + + @VisibleForTesting + static CQLSSTableWriter.Builder configureBuilder(String inDirectory, + String createStatement, + String insertStatement, + int bufferSizeMB, + Set udts, + Consumer> producedSSTablesListener, + IPartitioner cassPartitioner) + { + CQLSSTableWriter.Builder builder = CQLSSTableWriter.builder(); + + for (String udt : udts) + { + builder.withType(udt); + } + + return builder.inDirectory(inDirectory) + .forTable(createStatement) + .withPartitioner(cassPartitioner) + .using(insertStatement) + // The data frame to write is always sorted, + // see org.apache.cassandra.spark.bulkwriter.CassandraBulkSourceRelation.insert + .sorted() + .withSSTableProducedListener(producedSSTablesListener) + .openSSTableOnProduced() + .withMaxSSTableSizeInMiB(bufferSizeMB); + } +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/cdc/scanner/CdcSortedStreamScanner.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/cdc/scanner/CdcSortedStreamScanner.java new file mode 100644 index 000000000..0040e2cc9 --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/cdc/scanner/CdcSortedStreamScanner.java @@ -0,0 +1,300 @@ +/* + * 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.cdc.scanner; + +import java.util.Collection; +import java.util.PriorityQueue; +import java.util.Queue; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import javax.annotation.concurrent.NotThreadSafe; + +import com.google.common.base.Preconditions; + +import org.apache.cassandra.cdc.api.CassandraSource; +import org.apache.cassandra.cdc.msg.CdcEvent; +import org.apache.cassandra.cdc.msg.FourZeroCdcEventBuilder; +import org.apache.cassandra.cdc.state.CdcState; +import org.apache.cassandra.db.commitlog.FourZeroPartitionUpdateWrapper; +import org.apache.cassandra.db.commitlog.PartitionUpdateWrapper; +import org.apache.cassandra.db.partitions.UnfilteredPartitionIterator; +import org.apache.cassandra.db.rows.RangeTombstoneMarker; +import org.apache.cassandra.db.rows.Row; +import org.apache.cassandra.db.rows.Rows; +import org.apache.cassandra.db.rows.Unfiltered; +import org.apache.cassandra.db.rows.UnfilteredRowIterator; +import org.jetbrains.annotations.NotNull; + +/** + * A scanner that is backed by a sorted collection of {@link PartitionUpdateWrapper}. + * Not thread safe. (And not a stream). + *

+ * A copy of the four-zero class, unchanged except for this comment. Cassandra 6.0 declares + * {@code LivenessInfo} as an interface where 5.0 declared a class, so the inherited four-zero class calls + * {@code primaryKeyLivenessInfo().isEmpty()} through {@code invokevirtual} and throws + * {@code IncompatibleClassChangeError}. This copy recompiles that call. + */ +@NotThreadSafe +public class CdcSortedStreamScanner implements AutoCloseable, CdcStreamScanner +{ + final Queue updates; + private final UnfilteredPartitionIterator partitionIterator; + + private UnfilteredRowIterator currentPartition = null; + private CdcEvent event; + protected FourZeroCdcEventBuilder rangeDeletionBuilder; + private final Random random; + private final CdcState endState; + private final CassandraSource cassandraSource; + private final Double samplingRate; + + protected CdcSortedStreamScanner(@NotNull Collection updates, + @NotNull CdcState endState) + { + this(updates, endState, ThreadLocalRandom.current(), CassandraSource.DEFAULT, 0.0); + } + + public CdcSortedStreamScanner(@NotNull Collection updates, + @NotNull CdcState endState, + @NotNull Random random, + CassandraSource cassandraSource, + Double samplingRate) + { + this.updates = new PriorityQueue<>(PartitionUpdateWrapper::compareTo); + this.updates.addAll(updates); + this.partitionIterator = new HybridUnfilteredPartitionIterator(this); + this.endState = endState; + this.random = random; + this.cassandraSource = cassandraSource; + this.samplingRate = samplingRate; + } + + public CdcState endState() + { + return this.endState; + } + + public CdcEvent data() + { + Preconditions.checkState(event != null, + "No data available. Make sure hasNext is called before this method!"); + CdcEvent data = event; + event = null; // reset to null + return data; + } + + /** + * Prepare the {@link #data()} to be fetched. + * Briefly, the procedure is to iterate through the rows/rangetombstones in each partition. + * A CdcEvent is produced for each row. + * For range tombstones, a CdcEvent is produced for all markers/bounds combined within the same partition. + * Caller must call this method before calling {@link #data()}. + * + * @return true if there are more data; otherwise, false. + */ + public boolean next() + { + while (true) + { + if (allExhausted()) + { + return false; + } + // sampling CDC events for tracking + String trackingId = null; + if (random.nextDouble() < samplingRate()) + { + trackingId = UUID.randomUUID().toString(); + } + + if (currentPartition == null) + { + currentPartition = partitionIterator.next(); + + // it is a Cassandra partition deletion + if (!currentPartition.partitionLevelDeletion().isLive()) + { + event = makePartitionTombstone(currentPartition, trackingId); + currentPartition = null; + return true; + } + + // the partition contains no other rows but only a static row + Row staticRow = currentPartition.staticRow(); + if (!currentPartition.hasNext() && staticRow != null && staticRow != Rows.EMPTY_STATIC_ROW) + { + event = makeStaticRow(staticRow, currentPartition, trackingId); + currentPartition = null; // reset + return true; + } + } + + if (!currentPartition.hasNext()) + { + // The current partition is exhausted. Clean up and advance to the next partition by `continue`. + currentPartition = null; // reset + // Publish any range deletion for the partition + if (rangeDeletionBuilder != null) + { + event = rangeDeletionBuilder.build(); + rangeDeletionBuilder = null; // reset + return true; + } + else + { + continue; + } + } + + // An unfiltered can either be a Row or RangeTombstoneMarker + Unfiltered unfiltered = currentPartition.next(); + + if (unfiltered.isRow()) + { + Row row = (Row) unfiltered; + event = makeRow(row, currentPartition, trackingId); + return true; + } + else if (unfiltered.isRangeTombstoneMarker()) + { + // Range tombstone can get complicated. + // - In the most simple case, that is a DELETE statement with a single clustering key range, we expect + // the UnfilteredRowIterator with 2 markers, i.e. open and close range tombstone markers + // - In a slightly more complicated case, it contains IN operator (on prior clustering keys), we expect + // the UnfilteredRowIterator with 2 * N markers, where N is the number of values specified for IN. + // - In the most complicated case, client could comopse a complex partition update with a BATCH statement. + // It could have those further scenarios: (only discussing the statements applying to the same partition key) + // - Multiple disjoint ranges => we should expect 2 * N markers, where N is the number of ranges. + // - Overlapping ranges with the same timestamp => we should expect 2 markers, considering the + // overlapping ranges are merged into a single one. (as the boundary is omitted) + // - Overlapping ranges with different timestamp ==> we should expect 3 markers, i.e. open bound, + // boundary and end bound + // - Ranges mixed with INSERT! => The order of the unfiltered (i.e. Row/RangeTombstoneMarker) is determined + // by comparing the row clustering with the bounds of the ranges. See o.a.c.d.r.RowAndDeletionMergeIterator + RangeTombstoneMarker rangeTombstoneMarker = (RangeTombstoneMarker) unfiltered; + // We encode the ranges within the same spark row. Therefore, it needs to keep the markers when + // iterating through the partition, and _only_ generate a spark row with range tombstone info when + // exhausting the partition / UnfilteredRowIterator. + handleRangeTombstone(rangeTombstoneMarker, currentPartition, trackingId); + // continue to consume the next unfiltered row/marker + } + else + { + // As of Cassandra 4, the unfiltered kind can either be row or range tombstone marker, see o.a.c.db.rows.Unfiltered.Kind + // Having the else branch only for completeness. + throw new IllegalStateException("Encountered unknown Unfiltered kind."); + } + } + } + + public void advanceToNextColumn() + { + throw new UnsupportedOperationException("not implemented!"); + } + + private boolean allExhausted() + { + return !partitionIterator.hasNext() // no next partition + && currentPartition == null // current partition has exhausted + && rangeDeletionBuilder == null; // no range deletion being built + } + + public void close() + { + updates.clear(); + } + + public CdcEvent makeRow(Row row, UnfilteredRowIterator partition, String trackingId) + { + return makeRow(row, partition, false, trackingId); + } + + private CdcEvent makeStaticRow(Row row, UnfilteredRowIterator partition, String trackingId) + { + return makeRow(row, partition, true, trackingId); + } + + private CdcEvent makeRow(Row row, UnfilteredRowIterator partition, boolean isStaticOnly, String trackingId) + { + // It is a Cassandra row deletion + if (!row.deletion().isLive()) + { + return buildRowDelete(row, partition, trackingId); + } + + // Empty primaryKeyLivenessInfo == update; non-empty == insert + // The cql row could also be a deletion kind. + // Here, it only _assumes_ UPDATE/INSERT, and the kind is updated accordingly on build. + if (row.primaryKeyLivenessInfo().isEmpty()) + { + return buildUpdate(row, partition, isStaticOnly, trackingId); + } + + return buildInsert(row, partition, isStaticOnly, trackingId); + } + + /* Rate for sampling CDC events [0.0..1.0]*/ + public Double samplingRate() + { + return samplingRate; + } + + public CdcEvent buildRowDelete(Row row, UnfilteredRowIterator partition, String trackingId) + { + return FourZeroCdcEventBuilder.build(CdcEvent.Kind.ROW_DELETE, partition, row, trackingId, cassandraSource); + } + + public CdcEvent buildUpdate(Row row, UnfilteredRowIterator partition, boolean isStaticOnly, String trackingId) + { + FourZeroCdcEventBuilder builder = FourZeroCdcEventBuilder.of(CdcEvent.Kind.UPDATE, partition, trackingId, cassandraSource); + if (!isStaticOnly) + { + builder.withRow(row); + } + return builder.build(); + } + + public CdcEvent buildInsert(Row row, UnfilteredRowIterator partition, boolean isStaticOnly, String trackingId) + { + FourZeroCdcEventBuilder builder = FourZeroCdcEventBuilder.of(CdcEvent.Kind.INSERT, partition, trackingId, cassandraSource); + if (!isStaticOnly) + { + builder.withRow(row); + } + return builder.build(); + } + + public CdcEvent makePartitionTombstone(UnfilteredRowIterator partition, String trackingId) + { + return FourZeroCdcEventBuilder.of(CdcEvent.Kind.PARTITION_DELETE, partition, trackingId, cassandraSource) + .build(); + } + + public void handleRangeTombstone(RangeTombstoneMarker marker, UnfilteredRowIterator partition, String trackingId) + { + if (rangeDeletionBuilder == null) + { + rangeDeletionBuilder = FourZeroCdcEventBuilder.of(CdcEvent.Kind.RANGE_DELETE, partition, trackingId, cassandraSource); + } + rangeDeletionBuilder.addRangeTombstoneMarker(marker); + } +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/db/DbUtils.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/db/DbUtils.java new file mode 100644 index 000000000..c1fa02da1 --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/db/DbUtils.java @@ -0,0 +1,63 @@ +/* + * 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.db; + +import java.nio.ByteBuffer; + +import com.google.common.annotations.VisibleForTesting; + +import org.apache.cassandra.db.partitions.PartitionUpdate; +import org.apache.cassandra.schema.TableMetadata; + +public class DbUtils +{ + private DbUtils() + { + throw new IllegalStateException(getClass() + " is static utility class and shall not be instantiated"); + } + + @VisibleForTesting + public static DeletionTime deletionTime(long markedForDeleteAt, long localDeletionTime) + { + return DeletionTime.build(markedForDeleteAt, localDeletionTime); + } + + /** + * Cassandra 6.0 drops the {@code nowInSeconds} argument of {@code LivenessInfo.create}, which 5.0 + * discarded. The parameter stays here so that callers are the same across bridge versions. + */ + @VisibleForTesting + public static LivenessInfo livenessInfo(long timestamp, long nowInSeconds) + { + return LivenessInfo.create(timestamp); + } + + @VisibleForTesting + public static PartitionUpdate fullPartitionDeletion(TableMetadata metadata, ByteBuffer key, long timestamp, long nowInSec) + { + return PartitionUpdate.fullPartitionDelete(metadata, key, timestamp, nowInSec); + } + + @VisibleForTesting + public static PartitionUpdate.SimpleBuilder partitionUpdateBuilderWithNow(TableMetadata metadata, DecoratedKey key, long nowInSec) + { + return PartitionUpdate.simpleBuilder(metadata, key).nowInSec(nowInSec); + } +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/io/sstable/SSTableTombstoneWriter.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/io/sstable/SSTableTombstoneWriter.java new file mode 100644 index 000000000..86000d8f0 --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/io/sstable/SSTableTombstoneWriter.java @@ -0,0 +1,523 @@ +/* + * 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.io.sstable; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.SortedSet; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import com.google.common.annotations.VisibleForTesting; + +import org.apache.cassandra.bridge.CassandraSchema; +import org.apache.cassandra.bridge.SchemaUpdater; +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.cql3.ColumnSpecification; +import org.apache.cassandra.cql3.QueryOptions; +import org.apache.cassandra.cql3.QueryProcessor; +import org.apache.cassandra.cql3.RowUpdateBuilder; +import org.apache.cassandra.cql3.functions.types.TypeCodec; +import org.apache.cassandra.cql3.statements.DeleteStatement; +import org.apache.cassandra.cql3.statements.ModificationStatement; +import org.apache.cassandra.cql3.statements.schema.CreateTableStatement; +import org.apache.cassandra.cql3.statements.schema.CreateTypeStatement; +import org.apache.cassandra.db.Clustering; +import org.apache.cassandra.db.Slice; +import org.apache.cassandra.db.Slices; +import org.apache.cassandra.db.SystemKeyspace; +import org.apache.cassandra.dht.IPartitioner; +import org.apache.cassandra.dht.Murmur3Partitioner; +import org.apache.cassandra.exceptions.InvalidRequestException; +import org.apache.cassandra.io.sstable.format.SSTableFormat; +import org.apache.cassandra.io.util.File; +import org.apache.cassandra.schema.KeyspaceMetadata; +import org.apache.cassandra.schema.KeyspaceParams; +import org.apache.cassandra.schema.Keyspaces; +import org.apache.cassandra.schema.SchemaConstants; +import org.apache.cassandra.schema.SchemaTransformation; +import org.apache.cassandra.schema.SchemaTransformations; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.schema.TableMetadataRef; +import org.apache.cassandra.schema.Tables; +import org.apache.cassandra.schema.Types; +import org.apache.cassandra.schema.UserFunctions; +import org.apache.cassandra.schema.Views; +import org.apache.cassandra.service.ClientState; +import org.apache.cassandra.tcm.ClusterMetadata; +import org.apache.cassandra.tcm.ClusterMetadataService; +import org.apache.cassandra.tcm.serialization.Version; +import org.apache.cassandra.transport.ProtocolVersion; +import org.apache.cassandra.utils.ByteBufferUtil; +import org.apache.cassandra.utils.JavaDriverUtils; + +/** + * Re-write of CQLSSTableWriter for writing tombstones to an SSTable for testing + * Used for testing purpose only + */ +@VisibleForTesting +public final class SSTableTombstoneWriter implements Closeable +{ + private static final ByteBuffer UNSET_VALUE = ByteBufferUtil.UNSET_BYTE_BUFFER; + + static + { + DatabaseDescriptor.clientInitialization(false); + // Partitioner is not set in client mode. Cassandra 6.0 makes setPartitionerUnsafe touch + // StorageService.instance, so take the setter that only assigns the partitioner + if (DatabaseDescriptor.getPartitioner() == null) + { + DatabaseDescriptor.setOnlyPartitionerUnsafe(Murmur3Partitioner.instance); + } + // build() commits schema, which reads ClusterMetadata.current(). The call is idempotent, so it costs + // nothing when CassandraTypesImplementation.setup has already run + ClusterMetadataService.initializeForClients(); + } + + private final AbstractSSTableSimpleWriter writer; + private final DeleteStatement delete; + private final List boundNames; + private final List typeCodecs; + + // The 5.0 constructor also took a ClusteringComparator, used only to build the slices of a range + // tombstone, which Cassandra 6.0 builds through DeleteStatement.createSlices instead + private SSTableTombstoneWriter(AbstractSSTableSimpleWriter writer, + DeleteStatement delete, + List boundNames) + { + this.writer = writer; + this.delete = delete; + this.boundNames = boundNames; + this.typeCodecs = boundNames.stream().map(bn -> JavaDriverUtils.codecFor(JavaDriverUtils.driverType(bn.type))) + .collect(Collectors.toList()); + } + + /** + * Returns a new builder for a SSTableTombstoneWriter + * + * @return the new builder + */ + public static Builder builder() + { + return new Builder(); + } + + /** + * Adds a new row to the writer. + * + * This is a shortcut for {@code addRow(Arrays.asList(values))}. + * + * @param values the row values (corresponding to the bind variables of the + * deletion statement used when creating by this writer) + * @throws IOException when adding a row with the given {@code values} fails + */ + public void addRow(Object... values) throws InvalidRequestException, IOException + { + addRow(Arrays.asList(values)); + } + + /** + * Adds a new row to the writer. + *

+ * Each provided value type should correspond to the types of the CQL column the value is for. + * The correspondence between java type and CQL type is the same one than the one documented at + * www.datastax.com/drivers/java/2.0/apidocs/com/datastax/driver/core/DataType.Name.html#asJavaClass(). + *

+ * If you prefer providing the values directly as binary, use + * + * @param values the row values (corresponding to the bind variables of the + * deletion statement used when creating by this writer) + */ + private void addRow(List values) throws InvalidRequestException, IOException + { + int size = Math.min(values.size(), boundNames.size()); + List rawValues = new ArrayList<>(size); + + for (int index = 0; index < size; index++) + { + Object value = values.get(index); + rawValues.add(serialize(value, typeCodecs.get(index))); + } + + rawAddRow(rawValues); + } + + /** + * Adds a new row to the writer given already serialized values. + * + * This is a shortcut for {@code rawAddRow(Arrays.asList(values))}. + * + * @param values the row values (corresponding to the bind variables of the + * deletion statement used when creating by this writer) as binary + */ + private void rawAddRow(List values) throws InvalidRequestException, IOException + { + if (values.size() != boundNames.size()) + { + throw new InvalidRequestException( + String.format("Invalid number of arguments, expecting %d values but got %d", + boundNames.size(), values.size())); + } + + QueryOptions options = QueryOptions.forInternalCalls(null, values); + ClientState state = ClientState.forInternalCalls(); + List keys = delete.buildPartitionKeyNames(options, state); + + long now = System.currentTimeMillis(); + // NOTE: We ask indexes to not validate values (the last 'false' arg below) because that + // triggers a 'Keyspace.open' and that forces a lot of initialization that we don't want + // Cassandra 6.0 removes UpdateParameters; RegularRowUpdateBuilder is what 6.0's own CQLSSTableWriter + // uses. It derives the updated columns itself, and takes a null rather than an empty prefetched-row map. + RowUpdateBuilder params = + new RowUpdateBuilder.RegularRowUpdateBuilder(delete.metadata, + state, + options, + delete.getTimestamp(TimeUnit.MILLISECONDS.toMicros(now), options), + options.getNowInSec(TimeUnit.MILLISECONDS.toSeconds(now)), + delete.getTimeToLive(options), + null); + + if (delete.hasSlices()) + { + // Write out range tombstones. Cassandra 6.0 removes StatementRestrictions.getClusteringColumnsBounds. + Slices slices = delete.createSlices(options); + + try + { + for (ByteBuffer key : keys) + { + for (Slice slice : slices) + { + delete.addUpdateForKey(writer.getUpdateFor(key), slice, params); + } + } + return; + } + catch (SSTableSimpleUnsortedWriter.SyncException exception) + { + // If we use a BufferedWriter and had a problem writing to disk, the IOException has been + // wrapped in a SyncException (see BufferedWriter below). We want to extract that IOException. + throw (IOException) exception.getCause(); + } + } + + SortedSet> clusterings = delete.createClustering(options, state); + try + { + for (ByteBuffer key : keys) + { + for (Clustering clustering : clusterings) + { + delete.addUpdateForKey(writer.getUpdateFor(key), clustering, params); + } + } + } + catch (SSTableSimpleUnsortedWriter.SyncException exception) + { + // If we use a BufferedWriter and had a problem writing to disk, the IOException has been + // wrapped in a SyncException (see BufferedWriter below). We want to extract that IOException. + throw (IOException) exception.getCause(); + } + } + + /** + * Close this writer. + *

+ * This method should be called, otherwise the produced SSTables are not + * guaranteed to be complete (and won't be in practice). + */ + public void close() throws IOException + { + writer.close(); + } + + @SuppressWarnings("unchecked") + private ByteBuffer serialize(Object value, TypeCodec codec) + { + if (value == null || value == UNSET_VALUE) + { + return (ByteBuffer) value; + } + + return codec.serialize(value, ProtocolVersion.CURRENT); + } + + /** + * A Builder for a SSTableTombstoneWriter object + */ + public static class Builder + { + private File directory; + + SSTableFormat formatType = null; + + private CreateTableStatement.Raw schemaStatement; + private final List typeStatements; + private ModificationStatement.Parsed deleteStatement; + private IPartitioner partitioner; + + private long bufferSizeInMB = 128; + + Builder() + { + this.typeStatements = new ArrayList<>(); + } + + /** + * The directory where to write the SSTables (mandatory option). + * + * This is a mandatory option. + * + * @param directory the directory to use, which should exists and be writable + * @return this builder + * @throws IllegalArgumentException if {@code directory} doesn't exist or is not writable + */ + public Builder inDirectory(File directory) + { + if (!directory.exists()) + { + throw new IllegalArgumentException(directory + " doesn't exists"); + } + if (!directory.isWritable()) + { + throw new IllegalArgumentException(directory + " exists but is not writable"); + } + + this.directory = directory; + return this; + } + + /** + * The schema (CREATE TABLE statement) for the table for which SSTable are to be created. + *

+ * Please note that the provided CREATE TABLE statement must use a fully-qualified + * table name, one that include the keyspace name. + *

+ * This is a mandatory option. + * + * @param schema the schema of the table for which SSTables are to be created + * @return this builder + * @throws IllegalArgumentException if {@code schema} is not a valid CREATE TABLE statement + * or does not have a fully-qualified table name + */ + public Builder forTable(String schema) + { + schemaStatement = QueryProcessor.parseStatement(schema, CreateTableStatement.Raw.class, "CREATE TABLE"); + return this; + } + + /** + * The partitioner to use. + *

+ * By default, {@code Murmur3Partitioner} will be used. If this is not the partitioner used + * by the cluster for which the SSTables are created, you need to use this method to + * provide the correct partitioner. + * + * @param partitioner the partitioner to use + * @return this builder + */ + public Builder withPartitioner(IPartitioner partitioner) + { + this.partitioner = partitioner; + return this; + } + + /** + * The DELETE statement defining the values to remove for a given CQL row. + *

+ * Please note that the provided DELETE statement must use a fully-qualified + * table name, one that include the keyspace name. Moreover, said statement must use + * bind variables since these variables will be bound to values by the resulting writer. + *

+ * This is a mandatory option. + * + * @param delete a delete statement that defines the order of column values to use + * @return this builder + * @throws IllegalArgumentException if {@code deleteStatement} is not a valid deletion statement, + * does not have a fully-qualified table name or have no bind variables + */ + public Builder using(String delete) + { + deleteStatement = QueryProcessor.parseStatement(delete, ModificationStatement.Parsed.class, "DELETE"); + return this; + } + + /** + * The size of the buffer to use. + *

+ * This defines how much data will be buffered before being written as + * a new SSTable. This correspond roughly to the data size that will have the created + * SSTable. + *

+ * The default is 128MB, which should be reasonable for a 1GB heap. If you experience + * OOM while using the writer, you should lower this value. + * + * @param size the size to use in MB + * @return this builder + */ + public Builder withBufferSizeInMB(int size) + { + bufferSizeInMB = size; + return this; + } + + public SSTableTombstoneWriter build() + { + if (directory == null) + { + throw new IllegalStateException("No ouptut directory specified, you should provide a directory with inDirectory()"); + } + if (schemaStatement == null) + { + throw new IllegalStateException("Missing schema, you should provide the schema for the SSTable to create with forTable()"); + } + if (deleteStatement == null) + { + throw new IllegalStateException("No delete statement specified, you should provide a delete statement through using()"); + } + + TableMetadata tableMetadata = CassandraSchema.apply(schema -> { + // Cassandra 6.0 commits a schema change through Transactional Cluster Metadata: submit + // replaces transform. SchemaUpdater.submit also opens the keyspaces; see its javadoc. + if (schema.getKeyspaceMetadata(SchemaConstants.SYSTEM_KEYSPACE_NAME) == null) + { + SchemaUpdater.submit(schema, SchemaTransformations.addKeyspace(SystemKeyspace.metadata(), false)); + } + + String keyspaceName = schemaStatement.keyspace(); + + if (schema.getKeyspaceMetadata(keyspaceName) == null) + { + KeyspaceMetadata ksm = KeyspaceMetadata.create(keyspaceName, + KeyspaceParams.simple(1), + Tables.none(), + Views.none(), + Types.none(), + UserFunctions.none()); + SchemaUpdater.submit(schema, SchemaTransformations.addKeyspace(ksm, false)); + } + + KeyspaceMetadata ksm = schema.getKeyspaceMetadata(keyspaceName); + + TableMetadata table = ksm.tables.getNullable(schemaStatement.table()); + if (table == null) + { + Types types = createTypes(keyspaceName); + table = createTable(types); + TableMetadata finalTable = table; + // Cassandra 6.0 adds compatibleWith(ClusterMetadata) to SchemaTransformation, so it is no + // longer a functional interface. The check below copies SchemaTransformations' own. + SchemaUpdater.submit(schema, new SchemaTransformation() + { + @Override + public Keyspaces apply(ClusterMetadata metadata) + { + return metadata.schema + .getKeyspaces() + .withAddedOrUpdated(ksm.withSwapped(ksm.tables.with(finalTable)).withSwapped(types)); + } + + @Override + public boolean compatibleWith(ClusterMetadata metadata) + { + return metadata.directory.commonSerializationVersion.isAtLeast(Version.V0); + } + }); + } + return table; + }); + + DeleteStatement preparedDelete = prepareDelete(); + TableMetadataRef ref = TableMetadataRef.forOfflineTools(tableMetadata); + AbstractSSTableSimpleWriter writer = new SSTableSimpleUnsortedWriter(directory, ref, + preparedDelete.updatedColumns(), + bufferSizeInMB); + + if (formatType != null) + { + writer.setSSTableFormatType(formatType); + } + + return new SSTableTombstoneWriter(writer, preparedDelete, preparedDelete.getBindVariables()); + } + + private Types createTypes(String keyspace) + { + Types.RawBuilder builder = Types.rawBuilder(keyspace); + for (CreateTypeStatement.Raw st : typeStatements) + { + st.addToRawBuilder(builder); + } + return builder.build(); + } + + /** + * Creates the table according to schema statement + * + * @param types types this table should be created with + */ + private TableMetadata createTable(Types types) + { + ClientState state = ClientState.forInternalCalls(); + CreateTableStatement statement = schemaStatement.prepare(state); + statement.validate(ClientState.forInternalCalls()); + + // Cassandra 6.0 adds a UserFunctions argument; 5.0 passed no functions + TableMetadata.Builder builder = statement.builder(types, UserFunctions.none()); + if (partitioner != null) + { + builder.partitioner(partitioner); + } + + return builder.build(); + } + + /** + * Prepares delete statement for writing data to SSTable + * + * @return prepared Delete statement and it's bound names + */ + private DeleteStatement prepareDelete() + { + ClientState state = ClientState.forInternalCalls(); + DeleteStatement delete = (DeleteStatement) deleteStatement.prepare(state); + delete.validate(state); + + if (delete.hasConditions()) + { + throw new IllegalArgumentException("Conditional statements are not supported"); + } + if (delete.isCounter()) + { + throw new IllegalArgumentException("Counter update statements are not supported"); + } + if (delete.getBindVariables().isEmpty()) + { + throw new IllegalArgumentException("Provided delete statement has no bind variables"); + } + + return delete; + } + } +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/io/sstable/format/bti/BtiReaderUtils.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/io/sstable/format/bti/BtiReaderUtils.java new file mode 100644 index 000000000..9626330cd --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/io/sstable/format/bti/BtiReaderUtils.java @@ -0,0 +1,352 @@ +/* + * 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.io.sstable.format.bti; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import com.google.common.collect.ImmutableSet; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.bridge.TokenRange; +import org.apache.cassandra.db.DecoratedKey; +import org.apache.cassandra.dht.IPartitioner; +import org.apache.cassandra.dht.Range; +import org.apache.cassandra.dht.Token; +import org.apache.cassandra.io.compress.CompressionMetadata; +import org.apache.cassandra.io.sstable.Component; +import org.apache.cassandra.io.sstable.Descriptor; +import org.apache.cassandra.io.sstable.format.SSTableFormat; +import org.apache.cassandra.io.sstable.format.SSTableReader; +import org.apache.cassandra.io.util.ChannelProxy; +import org.apache.cassandra.io.util.File; +import org.apache.cassandra.io.util.FileHandle; +import org.apache.cassandra.io.util.ReadOnlyInputStreamFileChannel; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.schema.TableMetadataRef; +import org.apache.cassandra.spark.data.FileType; +import org.apache.cassandra.spark.data.SSTable; +import org.apache.cassandra.spark.reader.IndexConsumer; +import org.apache.cassandra.spark.reader.IndexEntry; +import org.apache.cassandra.spark.reader.ReaderUtils; +import org.apache.cassandra.spark.reader.SSTableCache; +import org.apache.cassandra.spark.sparksql.filters.PartitionKeyFilter; +import org.apache.cassandra.spark.sparksql.filters.SparkRangeFilter; +import org.apache.cassandra.spark.utils.streaming.BufferingInputStream; +import org.apache.cassandra.utils.FilterFactory; +import org.apache.cassandra.utils.TokenUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static org.apache.cassandra.spark.reader.BigIndexReader.calculateCompressedSize; + +public class BtiReaderUtils +{ + private static final Logger LOGGER = LoggerFactory.getLogger(BtiReaderUtils.class); + + private static final Set indexComponents = ImmutableSet.of(BtiFormat.Components.DATA, + BtiFormat.Components.PARTITION_INDEX, + BtiFormat.Components.ROW_INDEX); + + private BtiReaderUtils() + { + throw new IllegalStateException(getClass() + " is static utility class and shall not be instantiated"); + } + + public static boolean primaryIndexContainsAnyKey(@NotNull SSTable ssTable, + @NotNull TableMetadata metadata, + @NotNull Descriptor descriptor, + @NotNull List filters) throws IOException + { + final AtomicBoolean exists = new AtomicBoolean(false); + withPartitionIndex(ssTable, descriptor, metadata, (dataFileHandle, partitionFileHandle, rowFileHandle, partitionIndex) -> { + TableMetadataRef metadataRef = TableMetadataRef.forOfflineTools(metadata); + BtiTableReader btiTableReader = new BtiTableReader.Builder(descriptor) + .setDataFile(dataFileHandle) + .setPartitionIndex(partitionIndex) + .setRowIndexFile(rowFileHandle) + .setComponents(indexComponents) + .setTableMetadataRef(metadataRef) + .setFilter(FilterFactory.AlwaysPresent) + .build(null, false, false); + + // Cassandra 6.0 replaces keyReader() with keyReader(boolean detailed), which BtiTableReader ignores + try (PartitionIterator iter = btiTableReader.keyReader(false)) + { + while (!iter.isExhausted()) + { + ByteBuffer buffer = iter.key(); + boolean anyMatch = filters.stream().anyMatch(filter -> filter.matches(buffer)); + if (anyMatch) + { + exists.set(true); + return; + } + iter.advance(); + } + } + finally + { + btiTableReader.selfRef().release(); + } + exists.set(false); + }); + return exists.get(); + } + + @Nullable + public static Long startOffsetInDataFile(@NotNull SSTable ssTable, + @NotNull TableMetadata metadata, + @NotNull Descriptor descriptor, + @NotNull TokenRange tokenRange) + { + final AtomicReference offset = new AtomicReference<>(null); + + Token tokenStart = TokenUtils.bigIntegerToToken(metadata.partitioner, tokenRange.lowerEndpoint()); + Token tokenEnd = TokenUtils.bigIntegerToToken(metadata.partitioner, tokenRange.upperEndpoint()); + Range range = new Range<>(tokenStart, tokenEnd); + + try + { + withPartitionIndex(ssTable, descriptor, metadata, (dataFileHandle, partitionFileHandle, rowFileHandle, partitionIndex) -> { + TableMetadataRef metadataRef = TableMetadataRef.forOfflineTools(metadata); + BtiTableReader btiTableReader = new BtiTableReader.Builder(descriptor) + .setDataFile(dataFileHandle) + .setPartitionIndex(partitionIndex) + .setRowIndexFile(rowFileHandle) + .setComponents(indexComponents) + .setTableMetadataRef(metadataRef) + .setFilter(FilterFactory.AlwaysPresent) + .build(null, false, false); + try + { + List positions = + btiTableReader.getPositionsForRanges(Collections.singletonList(range)); + if (!positions.isEmpty()) + { + // we should receive zero or one position + offset.set(positions.get(0).lowerPosition); + } + } + finally + { + btiTableReader.selfRef().release(); + } + }); + } + catch (IOException e) + { + LOGGER.warn("Failed to lookup start offset for token range {} in sstable {}", + tokenRange, ssTable, e); + } + return offset.get(); + } + + public static void consumePrimaryIndex(@NotNull SSTable ssTable, + @NotNull TableMetadata metadata, + @NotNull Descriptor descriptor, + @Nullable SparkRangeFilter range, + @NotNull IndexConsumer consumer) throws IOException + { + long dataFileLength = ssTable.length(FileType.DATA); + TableMetadataRef metadataRef = TableMetadataRef.forOfflineTools(metadata); + org.apache.cassandra.spark.reader.CompressionMetadata compressionMetadata = SSTableCache.INSTANCE.compressionMetadata( + ssTable, descriptor.version.hasMaxCompressedLength(), metadata.params.crcCheckChance); + + withPartitionIndex(ssTable, descriptor, metadata, (dataFileHandle, partitionFileHandle, rowFileHandle, partitionIndex) -> { + BtiTableReader btiTableReader = new BtiTableReader.Builder(descriptor) + .setDataFile(dataFileHandle) + .setPartitionIndex(partitionIndex) + .setRowIndexFile(rowFileHandle) + .setComponents(indexComponents) + .setTableMetadataRef(metadataRef) + .setFilter(FilterFactory.AlwaysPresent) + .build(null, false, false); + // See the comment on keyReader(false) in primaryIndexContainsAnyKey + try (PartitionIterator iter = btiTableReader.keyReader(false)) + { + ByteBuffer prevKey = null; + long prevPos = 0; + BigInteger prevToken = null; + boolean started = false; + while (!iter.isExhausted()) + { + ByteBuffer key = iter.key(); + long pos = iter.dataPosition(); + DecoratedKey decoratedKey = metadata.partitioner.decorateKey(key); + BigInteger token = ReaderUtils.tokenToBigInteger(decoratedKey.getToken()); + + // TODO: Implement reporting statistics. + if (prevKey != null && (range == null || range.overlaps(prevToken))) + { + // we reached the end of the file, so consume last key if overlaps + started = true; + long uncompressed = pos - prevPos; + long compressed = compressionMetadata == null + ? uncompressed + : calculateCompressedSize(compressionMetadata, dataFileLength, prevPos, pos - 1); + consumer.accept(new IndexEntry(prevKey, prevToken, uncompressed, compressed)); + } + else if (started) + { + // we have gone passed the range we care about so exit early + return; + } + + prevKey = key; + prevPos = pos; + prevToken = token; + + iter.advance(); + } + + if (prevKey != null && (range == null || range.overlaps(prevToken))) + { + // we reached the end of the file, so consume last key if overlaps + long end = (compressionMetadata == null ? dataFileLength : compressionMetadata.getDataLength()); + long uncompressed = end - prevPos; + long compressed = compressionMetadata == null + ? uncompressed + : calculateCompressedSize(compressionMetadata, dataFileLength, prevPos, end - 1); + consumer.accept(new IndexEntry(prevKey, prevToken, uncompressed, compressed)); + } + } + finally + { + btiTableReader.selfRef().release(); + } + }); + } + + public static void readPrimaryIndex(@NotNull SSTable sstable, + @NotNull IPartitioner partitioner, + @NotNull Descriptor descriptor, + double crcCheckChance, + @NotNull Function tracker) throws IOException + { + withPartitionIndex(sstable, descriptor, partitioner, crcCheckChance, + (dataFileHandle, partitionFileHandle, rowFileHandle, partitionIndex) -> { + try (PartitionIterator iter = PartitionIterator.create(partitionIndex, partitioner, + rowFileHandle, dataFileHandle, + descriptor.version)) + { + while (!iter.isExhausted()) + { + ByteBuffer key = iter.key(); + if (tracker.apply(key)) + { + // exit early if tracker returns true + return; + } + iter.advance(); + } + } + }); + } + + private static void withPartitionIndex(@NotNull SSTable sstable, + @NotNull Descriptor descriptor, + @NotNull TableMetadata metadata, + @NotNull BtiPartitionIndexConsumer consumer) throws IOException + { + withPartitionIndex(sstable, descriptor, metadata.partitioner, metadata.params.crcCheckChance, consumer); + } + + /** + * Open the BTI SSTable components. The partitionIndex component is always opened in order to create PartitionIndex + * @param sstable sstable + * @param descriptor sstable descriptor + * @param partitioner partitioner + * @param crcCheckChance crc check chance + * @param consumer BtiPartitionIndexConsumer + * @throws IOException on I/O errors + */ + private static void withPartitionIndex(@NotNull SSTable sstable, + @NotNull Descriptor descriptor, + @NotNull IPartitioner partitioner, + double crcCheckChance, + @NotNull BtiPartitionIndexConsumer consumer) throws IOException + { + File file = new File(sstable.getDataFileName()); + try (CompressionMetadata compression = getCompressionMetadata(sstable, crcCheckChance, descriptor); + FileHandle dataFileHandle = createFileHandle(file, + sstable.openDataStream(), + sstable.length(FileType.DATA), + compression); + FileHandle partitionFileHandle = createFileHandle(file, + sstable.openPrimaryIndexStream(), + sstable.length(FileType.PARTITIONS_INDEX), + null); + FileHandle rowFileHandle = createFileHandle(file, + sstable.openRowIndexStream(), + sstable.length(FileType.ROWS_INDEX), + null); + PartitionIndex partitionIndex = PartitionIndex.load(partitionFileHandle, partitioner, false)) + { + consumer.accept(dataFileHandle, partitionFileHandle, rowFileHandle, partitionIndex); + } + } + + private static FileHandle createFileHandle(File file, InputStream stream, long size, CompressionMetadata compression) throws IOException + { + if (stream == null) + { + throw new FileNotFoundException("Cannot find file " + file.absolutePath()); + } + ReadOnlyInputStreamFileChannel fileChannel = new ReadOnlyInputStreamFileChannel((BufferingInputStream) stream, size); + ChannelProxy proxy = new ChannelProxy(file, fileChannel); + FileHandle.Builder builder = new FileHandle.Builder(file); + if (compression != null) + { + builder.withCompressionMetadata(compression); + } + return builder.complete(f -> proxy); + } + + private static CompressionMetadata getCompressionMetadata(SSTable ssTable, + double crcCheckChance, + Descriptor descriptor) throws IOException + { + org.apache.cassandra.spark.reader.CompressionMetadata compressionMetadata = SSTableCache.INSTANCE.compressionMetadata( + ssTable, descriptor.version.hasMaxCompressedLength(), crcCheckChance); + if (compressionMetadata != null) + { + return compressionMetadata.toInternal(descriptor.fileFor(SSTableFormat.Components.COMPRESSION_INFO), + ssTable.length(FileType.DATA)); + } + return null; + } + + public interface BtiPartitionIndexConsumer + { + void accept(FileHandle dataFile, FileHandle partitionFile, FileHandle rowFile, PartitionIndex partitionIndex) throws IOException; + } +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/io/util/RebufferingChannelInputStream.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/io/util/RebufferingChannelInputStream.java new file mode 100644 index 000000000..a6f30816a --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/io/util/RebufferingChannelInputStream.java @@ -0,0 +1,75 @@ +/* + * 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.io.util; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; + +import org.apache.cassandra.utils.memory.MemoryUtil; + +public class RebufferingChannelInputStream extends RebufferingInputStream +{ + private static final int DEFAULT_BUFFER_SIZE = 16384; + + private final ReadableByteChannel channel; + + public RebufferingChannelInputStream(InputStream inputStream) + { + this(inputStream, DEFAULT_BUFFER_SIZE); + } + + public RebufferingChannelInputStream(InputStream inputStream, int bufferSize) + { + super(ByteBuffer.allocate(bufferSize <= 0 ? DEFAULT_BUFFER_SIZE : bufferSize)); + this.channel = Channels.newChannel(inputStream); + this.buffer.limit(0); + } + + protected void reBuffer() throws IOException + { + buffer.clear(); + channel.read(buffer); + buffer.flip(); + } + + @Override + public void close() throws IOException + { + try + { + super.close(); + } + finally + { + try + { + // Cassandra 6.0 moves clean(ByteBuffer) from FileUtils to MemoryUtil, with the same body + MemoryUtil.clean(buffer); + } + finally + { + channel.close(); + } + } + } +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/AbstractStreamScanner.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/AbstractStreamScanner.java new file mode 100644 index 000000000..95e2add35 --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/AbstractStreamScanner.java @@ -0,0 +1,472 @@ +/* + * 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.reader; + +import java.io.Closeable; +import java.io.IOException; +import java.math.BigInteger; +import java.util.Iterator; + +import com.google.common.base.Preconditions; + +import org.apache.cassandra.db.Clustering; +import org.apache.cassandra.db.ClusteringPrefix; +import org.apache.cassandra.db.DeletionTime; +import org.apache.cassandra.db.partitions.UnfilteredPartitionIterator; +import org.apache.cassandra.db.rows.Cell; +import org.apache.cassandra.db.rows.ColumnData; +import org.apache.cassandra.db.rows.ComplexColumnData; +import org.apache.cassandra.db.rows.Row; +import org.apache.cassandra.db.rows.Unfiltered; +import org.apache.cassandra.db.rows.UnfilteredRowIterator; +import org.apache.cassandra.dht.Murmur3Partitioner; +import org.apache.cassandra.dht.RandomPartitioner; +import org.apache.cassandra.schema.ColumnMetadata; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.spark.data.partitioner.Partitioner; +import org.apache.cassandra.spark.reader.common.SSTableStreamException; +import org.apache.cassandra.spark.utils.TimeProvider; +import org.apache.cassandra.utils.ByteBufferUtil; +import org.jetbrains.annotations.NotNull; + +public abstract class AbstractStreamScanner implements StreamScanner, Closeable +{ + // All partitions in the SSTable + private UnfilteredPartitionIterator allPartitions; + // A single partition, containing rows and/or range tombstones + private UnfilteredRowIterator partition; + // The static row of the current partition, which may be empty + @SuppressWarnings("FieldCanBeLocal") + private Row staticRow; + // Current atom (row or range tombstone) being processed + private Unfiltered unfiltered; + // If processing a row this holds the state of iterating that row + private Iterator columns; + // State of processing data for a single column in a row (which may be multi-celled in the case of complex columns) + protected ColumnDataState columnData; + + @NotNull + final TableMetadata metadata; + + @NotNull + protected final TimeProvider timeProvider; + + protected final RowData rowData = new RowData(); + + AbstractStreamScanner(@NotNull TableMetadata metadata, + @NotNull Partitioner partitionerType, + @NotNull TimeProvider timeProvider) + { + this.metadata = metadata.unbuild() + .partitioner(partitionerType == Partitioner.Murmur3Partitioner + ? Murmur3Partitioner.instance + : RandomPartitioner.instance) + .build(); + this.timeProvider = timeProvider; + + // Counter tables are not supported + if (metadata.isCounter()) + { + throw new IllegalArgumentException( + String.format("Streaming reads of SSTables from counter tables are not supported, " + + "rejecting stream of data from %s.%s", + metadata.keyspace, metadata.name)); + } + } + + @Override + public RowData data() + { + return rowData; + } + + /* Abstract methods */ + + abstract UnfilteredPartitionIterator initializePartitions(); + + @Override + public abstract void close() throws IOException; + + /** + * Handles the row tombstone + * @param token token of the partition that the row belongs to + * @param row row tombstone + */ + protected abstract void handleRowTombstone(BigInteger token, Row row); + + /** + * Handles the partition tombstone + * @param token token of the partition + * @param partition partition tombstone + */ + protected abstract void handlePartitionTombstone(BigInteger token, UnfilteredRowIterator partition); + + + /** + * Handle the cell tombstone + * @param token token of the partition that the cell belongs to + */ + protected abstract void handleCellTombstone(BigInteger token); + + /** + * Handle the cell tombstone in complex type, e.g. UDT and collections + * @param token token of the partition that the cell belongs to + * @param cell cell tombstone + */ + protected abstract void handleCellTombstoneInComplex(BigInteger token, Cell cell); + + @Override + public void advanceToNextColumn() + { + columnData.consume(); + } + + @Override + public boolean hasMoreColumns() + { + return columns != null && columns.hasNext(); + } + + // CHECKSTYLE IGNORE: Long method + @Override + public boolean next() throws IOException + { + if (allPartitions == null) + { + allPartitions = initializePartitions(); + } + + while (true) + { + if (partition == null) + { + try + { + // We've exhausted the partition iterator + if (allPartitions.hasNext()) + { + // Advance to next partition + partition = allPartitions.next(); + + BigInteger token = ReaderUtils.tokenToBigInteger(partition.partitionKey().getToken()); + if (partition.partitionLevelDeletion().isLive()) + { + // Reset rid with new partition key + rowData.setPartitionKeyCopy(partition.partitionKey().getKey(), token); + } + else + { + // There's a partition-level delete + handlePartitionTombstone(token, partition); + return true; + } + } + else + { + return false; + } + } + catch (SSTableStreamException exception) + { + throw exception.getIOException(); + } + + // If the partition has a non-empty static row, grab its columns, + // so we process those before moving onto its atoms (the Unfiltered instances) + staticRow = partition.staticRow(); + if (!staticRow.isEmpty()) + { + columns = staticRow.iterator(); + prepareColumnData(); + return true; + } + } + + // We may be in the midst of processing some multi-cell column data, + // if so, we'll resume that where we left off + if (columnData != null && columnData.hasData()) + { + return true; + } + + // Continue to process columns of the last read row, which may be static + if (columns != null && columns.hasNext()) + { + prepareColumnData(); + return true; + } + + // Current row was exhausted (or none were present), so move to the next atom + columns = null; + try + { + // Advance to next unfiltered + if (partition.hasNext()) + { + unfiltered = partition.next(); + } + else + { + // Current partition is exhausted + partition = null; + unfiltered = null; + } + } + catch (SSTableStreamException exception) + { + throw exception.getIOException(); + } + + if (unfiltered != null) + { + if (unfiltered.isRow()) + { + Row row = (Row) unfiltered; + + // There is a CQL row level delete + if (!row.deletion().isLive()) + { + handleRowTombstone(rowData.getToken(), row); + return true; + } + + // For non-compact tables, set up a ClusteringColumnDataState to emit a RowData that emulates a + // pre-3.0 CQL row marker. This is necessary for backwards compatibility with 2.1 & 2.0 output, + // and also for tables with only primary key columns defined. + // An empty PKLI is the 3.0 equivalent of having no row marker (e.g. row modifications via + // UPDATE not INSERT) so we don't emit a fake row marker in that case. + if (!row.primaryKeyLivenessInfo().isEmpty()) + { + if (TableMetadata.Flag.isCQLTable(metadata.flags)) + { + columnData = new ClusteringColumnDataState(row.clustering()); + } + columns = row.iterator(); + return true; + } + + // The row's actual columns may be empty, in which case we'll simply skip over them during the next + // iteration and move to the next unfiltered. So then only the row marker and/or row deletion (if + // either are present) will get emitted + columns = row.iterator(); + } + else if (unfiltered.isRangeTombstoneMarker()) + { + throw new IllegalStateException("Encountered RangeTombstoneMarker. " + + "It should have been purged in CompactionIterator"); + } + else + { + // As of Cassandra 4, the unfiltered kind can either be row or range tombstone marker, + // see o.a.c.db.rows.Unfiltered.Kind; having the else branch only for completeness + throw new IllegalStateException("Encountered unknown Unfiltered kind"); + } + } + } + } + + /** + * Prepare the columnData to be consumed the next + */ + private void prepareColumnData() + { + ColumnData data = columns.next(); + if (data.column().isComplex()) + { + columnData = new ComplexDataState(data.column().isStatic() ? Clustering.STATIC_CLUSTERING + : unfiltered.clustering(), + (ComplexColumnData) data); + } + else + { + columnData = new SimpleColumnDataState(data.column().isStatic() ? Clustering.STATIC_CLUSTERING + : unfiltered.clustering(), + data); + } + } + + private interface ColumnDataState + { + /** + * Indicate whether the column has data + * + * @return true if it has data to be consumed + */ + boolean hasData(); + + /** + * Consume the data in the column + */ + void consume(); + } + + /** + * Maps clustering values to column data, to emulate CQL row markers which were removed in Cassandra 3.0, + * but which we must still emit RowData for in order to preserve backwards compatibility + * and to handle tables containing only primary key columns + */ + protected final class ClusteringColumnDataState implements ColumnDataState + { + private boolean consumed = false; + private final ClusteringPrefix clustering; + + ClusteringColumnDataState(ClusteringPrefix clustering) + { + this.clustering = clustering; + } + + @Override + public boolean hasData() + { + return !consumed; + } + + @Override + public void consume() + { + if (!consumed) + { + rowData.setColumnNameCopy(ReaderUtils.encodeCellName(metadata, + clustering, + ByteBufferUtil.EMPTY_BYTE_BUFFER, + null)); + rowData.setValueCopy(ByteBufferUtil.EMPTY_BYTE_BUFFER); + consumed = true; + } + else + { + throw new UnsupportedOperationException(); + } + } + } + + /** + * Holds current processing state of any simple column data + */ + private final class SimpleColumnDataState implements ColumnDataState + { + private ClusteringPrefix clustering; + private final Cell cell; + + private SimpleColumnDataState(ClusteringPrefix clustering, ColumnData data) + { + Preconditions.checkArgument(data.column().isSimple(), "The type of the ColumnData should be simple"); + this.clustering = clustering; + this.cell = (Cell) data; + } + + @Override + public boolean hasData() + { + return (clustering != null); + } + + @Override + public void consume() + { + boolean isStatic = cell.column().isStatic(); + rowData.setColumnNameCopy(ReaderUtils.encodeCellName(metadata, + isStatic ? Clustering.STATIC_CLUSTERING : clustering, + cell.column().name.bytes, + null)); + if (cell.isTombstone()) + { + handleCellTombstone(rowData.getToken()); + } + else + { + rowData.setValueCopy(cell.buffer()); + } + rowData.setTimestamp(cell.timestamp()); + // Null out clustering so hasData will return false + clustering = null; + } + } + + /** + * Holds current processing state of any complex column data + */ + private final class ComplexDataState implements ColumnDataState + { + private final ColumnMetadata column; + private ClusteringPrefix clustering; + private final Iterator> cells; + private final int cellCount; + private final DeletionTime deletionTime; + + private ComplexDataState(ClusteringPrefix clustering, ComplexColumnData data) + { + this.clustering = clustering; + this.column = data.column(); + this.cells = data.iterator(); + this.cellCount = data.cellsCount(); + this.deletionTime = data.complexDeletion(); + } + + @Override + public boolean hasData() + { + return clustering != null && cells.hasNext(); + } + + @Override + public void consume() + { + rowData.setColumnNameCopy(ReaderUtils.encodeCellName(metadata, + clustering, + column.name.bytes, + ByteBufferUtil.EMPTY_BYTE_BUFFER)); + // The complex data is live, but there could be element deletion inside; check for it later in the block + if (deletionTime.isLive()) + { + AbstractComplexTypeBuffer buffer = AbstractComplexTypeBuffer.newBuffer(column.type, cellCount); + long maxTimestamp = Long.MIN_VALUE; + while (cells.hasNext()) + { + Cell cell = cells.next(); + // Re: isLive vs. isTombstone - isLive considers TTL so that if a cell is expiring soon, + // it is handled as tombstone + if (cell.isLive(timeProvider.referenceEpochInSeconds())) + { + buffer.addCell(cell); + } + else + { + handleCellTombstoneInComplex(rowData.getToken(), cell); + } + // In the case the cell is deleted, the deletion time is also the cell's timestamp + maxTimestamp = Math.max(maxTimestamp, cell.timestamp()); + } + + rowData.setValueCopy(buffer.build()); + rowData.setTimestamp(maxTimestamp); + } + else + { + // The entire collection/UDT is deleted + handleCellTombstone(rowData.getToken()); + rowData.setTimestamp(deletionTime.markedForDeleteAt()); + } + + // Null out clustering to indicate no data + clustering = null; + } + } +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/CompressionMetadata.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/CompressionMetadata.java new file mode 100644 index 000000000..c2d3db460 --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/CompressionMetadata.java @@ -0,0 +1,303 @@ +/* + * 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.reader; + +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import com.google.common.base.Preconditions; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; + +import org.apache.cassandra.db.compression.CompressionDictionary; +import org.apache.cassandra.db.compression.CompressionDictionary.DictId; +import org.apache.cassandra.io.compress.ICompressor; +import org.apache.cassandra.io.compress.IDictionaryCompressor; +import org.apache.cassandra.io.util.File; +import org.apache.cassandra.schema.CompressionParams; +import org.apache.cassandra.spark.reader.common.AbstractCompressionMetadata; +import org.apache.cassandra.spark.reader.common.BigLongArray; +import org.apache.cassandra.utils.concurrent.Ref; +import org.jetbrains.annotations.Nullable; + +/** + * Holds metadata about compressed file + */ +// CompressionMetadata is mocked in IndexReaderTests and mockito does not support mocking final classes +// CHECKSTYLE IGNORE: FinalClass +@SuppressWarnings("UnstableApiUsage") +public class CompressionMetadata extends AbstractCompressionMetadata implements AutoCloseable +{ + /** + * Holds one dictionary instance per dictionary id, in place of the {@code CompressionDictionaryManager} that a + * node runs and that an offline reader has none of. One instance per id matters for more than memory: + * {@code ZstdDictionaryCompressor} keys its compressors, and the native zstd tables they own, by dictionary + * equality, which is equality of the dictionary id alone. Two equal instances would share one compressor while + * their reference counts moved apart, and the count that frees the tables would be the count of whichever + * instance built the compressor. + * + *

This structure owns the primary reference of each dictionary and releases it on removal. Every reader + * holds a reference of its own, so the tables survive a removal that happens mid-read. + */ + private static final Cache DICTIONARIES = + CacheBuilder.newBuilder() + .maximumSize(SSTableCache.propOrDefault("sbr.cache.compressionDictionary.maxEntries", 16)) + .expireAfterAccess(SSTableCache.propOrDefault("sbr.cache.compressionDictionary.expireAfterMins", 60), + TimeUnit.MINUTES) + .removalListener(notification -> { + CompressionDictionary dictionary = notification.getValue(); + if (dictionary != null) + { + dictionary.close(); + } + }) + .build(); + + private final CompressionParams parameters; + private final double crcCheckChance; // CRC check chance defined on table level + @Nullable + private final CompressionDictionary dictionary; + @Nullable + private final Ref dictionaryRef; + private volatile ICompressor resolvedCompressor; + + private CompressionMetadata(long dataLength, + BigLongArray chunkOffsets, + CompressionParams parameters, + double crcCheckChance, + @Nullable CompressionDictionary deserializedDictionary) + { + super(dataLength, chunkOffsets); + this.parameters = parameters; + this.crcCheckChance = crcCheckChance; + if (deserializedDictionary == null) + { + this.dictionary = null; + this.dictionaryRef = null; + } + else + { + CompressionDictionary shared = intern(deserializedDictionary); + Ref ref = shared.tryRef(); + if (ref == null) + { + // A removal released the shared instance between the two calls. Keep the instance that this reader + // deserialized: it decompresses the same bytes, and it only misses the shared compressor + shared = deserializedDictionary; + ref = shared.tryRef(); + Preconditions.checkState(ref != null, "Compression dictionary %s is released", shared.dictId()); + } + this.dictionary = shared; + this.dictionaryRef = ref; + } + } + + /** + * Releases the primary reference of every dictionary held here. Visible for testing. + */ + static void evictDictionaries() + { + DICTIONARIES.invalidateAll(); + } + + private static CompressionDictionary intern(CompressionDictionary dictionary) + { + try + { + return DICTIONARIES.get(dictionary.dictId(), () -> dictionary); + } + catch (ExecutionException exception) + { + // The loader returns a value that the caller already holds, so it cannot fail + throw new AssertionError(exception); + } + } + + static CompressionMetadata fromInputStream(InputStream inStream, boolean hasCompressedLength, double crcCheckChance) throws IOException + { + long dataLength; + BigLongArray chunkOffsets; + + DataInputStream inData = new DataInputStream(inStream); + + String compressorName = inData.readUTF(); + int optionCount = inData.readInt(); + Map options = new HashMap<>(optionCount); + for (int option = 0; option < optionCount; ++option) + { + options.put(inData.readUTF(), inData.readUTF()); + } + + int chunkLength = inData.readInt(); + int minCompressRatio = 2147483647; + if (hasCompressedLength) + { + minCompressRatio = inData.readInt(); + } + + CompressionParams params = new CompressionParams(compressorName, chunkLength, minCompressRatio, options); + + dataLength = inData.readLong(); + + int chunkCount = inData.readInt(); + chunkOffsets = new BigLongArray(chunkCount); + + for (int chunk = 0; chunk < chunkCount; chunk++) + { + try + { + chunkOffsets.set(chunk, inData.readLong()); + } + catch (EOFException exception) + { + throw new EOFException(String.format("Corrupted compression index: read %d but expected %d chunks.", + chunk, chunkCount)); + } + } + + // Cassandra 6.0 writes a self-contained dictionary section after the chunk offsets, hence the SSTable + // versions big-pa and bti-ea. A null CompressionDictionaryManager selects the offline decode, and the + // constructor takes over what the manager would own. deserialize returns null on end of file, which covers + // every SSTable that an earlier Cassandra version wrote. + CompressionDictionary dictionary = CompressionDictionary.deserialize(inData, null); + + return new CompressionMetadata(dataLength, chunkOffsets, params, crcCheckChance, dictionary); + } + + @Nullable + CompressionDictionary dictionary() + { + return dictionary; + } + + /** + * Acquires a reference that keeps the dictionary's native zstd tables valid until the caller closes it. A + * reader that decompresses through {@link #compressor} needs one for as long as it reads, because the primary + * reference goes away when this metadata leaves {@link SSTableCache}. + * + * @return null when this SSTable holds no dictionary + */ + @Nullable + Ref acquireDictionaryRef() + { + if (dictionary == null) + { + return null; + } + Ref ref = dictionary.tryRef(); + Preconditions.checkState(ref != null, "Compression dictionary %s is released", dictionary.dictId()); + return ref; + } + + /** + * Releases this metadata's reference to the compression dictionary. The native zstd tables go away when the + * last reference does, which is either this one, the primary reference that {@link #DICTIONARIES} owns, or a + * reference that a reader holds. The call is idempotent, and does nothing for an SSTable with no dictionary. + */ + @Override + public void close() + { + if (dictionaryRef != null) + { + dictionaryRef.close(); + } + } + + /** + * Repeats the two branches of the package-private {@code io.compress.CompressionMetadata.resolveCompressor} + * through the public API, memoised because every chunk asks for the compressor and because attaching a + * dictionary allocates native state + */ + ICompressor compressor() + { + ICompressor result = resolvedCompressor; + if (result != null) + { + return result; + } + + synchronized (this) + { + if (resolvedCompressor == null) + { + ICompressor tableCompressor = parameters.getSstableCompressor(); + // A CompressionInfo component exists only for a compressed table, so the params name a compressor + Preconditions.checkState(tableCompressor != null, "Compression parameters name no compressor: %s", parameters); + resolvedCompressor = resolveCompressor(tableCompressor, dictionary); + } + return resolvedCompressor; + } + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static ICompressor resolveCompressor(ICompressor compressor, @Nullable CompressionDictionary dictionary) + { + if (dictionary == null) + { + return compressor; + } + + if (compressor instanceof IDictionaryCompressor) + { + IDictionaryCompressor dictionaryCompressor = (IDictionaryCompressor) compressor; + if (dictionaryCompressor.canConsumeDictionary(dictionary)) + { + return dictionaryCompressor.getOrCopyWithDictionary(dictionary); + } + } + + // The table now compresses without a dictionary, or with a dictionary of another kind, so build the + // compressor that matches the dictionary that this SSTable holds + return dictionary.kind().createCompressor(dictionary); + } + + @Override + protected int chunkLength() + { + return parameters.chunkLength(); + } + + @Override + protected double crcCheckChance() + { + return crcCheckChance; + } + + /** + * @return Cassandra internal {@code CompressionMetadata}, which can be used to construct {@code FileHandle}. + */ + public org.apache.cassandra.io.compress.CompressionMetadata toInternal(File file, long compressedFileLength) + { + AlignedReadonlyLongArrayMemory memory = new AlignedReadonlyLongArrayMemory(chunkOffsets); + return new org.apache.cassandra.io.compress.CompressionMetadata(file, + parameters, + memory, + memory.size(), + getDataLength(), + compressedFileLength, + dictionary); + } +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/SSTableCache.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/SSTableCache.java new file mode 100644 index 000000000..b1e3493f3 --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/SSTableCache.java @@ -0,0 +1,240 @@ +/* + * 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.reader; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.db.DecoratedKey; +import org.apache.cassandra.io.sstable.Descriptor; +import org.apache.cassandra.io.sstable.metadata.MetadataComponent; +import org.apache.cassandra.io.sstable.metadata.MetadataType; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.spark.data.FileType; +import org.apache.cassandra.spark.data.SSTable; +import org.apache.cassandra.spark.utils.Pair; +import org.apache.cassandra.spark.utils.ThrowableUtils; +import org.apache.cassandra.utils.BloomFilter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Basic cache to reduce wasteful requests on the DataLayer for cacheable SSTable metadata, + * useful when running many Spark tasks on the same Spark worker + * + *

Cassandra 6.0 needs its own copy of this class, because a {@link CompressionMetadata} of 6.0 holds a + * reference to a compression dictionary and must release it when this class stops holding the metadata. + */ +@SuppressWarnings("UnstableApiUsage") +public class SSTableCache +{ + private static final Logger LOGGER = LoggerFactory.getLogger(SSTableCache.class); + + public static final SSTableCache INSTANCE = new SSTableCache(); + private final Cache summary = buildCache(propOrDefault("sbr.cache.summary.maxEntries", 4096), + propOrDefault("sbr.cache.summary.expireAfterMins", 15)); + private final Cache> index = buildCache(propOrDefault("sbr.cache.index.maxEntries", 128), + propOrDefault("sbr.cache.index.expireAfterMins", 60)); + private final Cache> stats = buildCache(propOrDefault("sbr.cache.stats.maxEntries", 16384), + propOrDefault("sbr.cache.stats.expireAfterMins", 60)); + private final Cache filter = buildCache(propOrDefault("sbr.cache.filter.maxEntries", 16384), + propOrDefault("sbr.cache.filter.expireAfterMins", 60)); + // if compression is disabled then the CompressionInfo.db file will not exist + // therefore we can cache as Optional to a) avoid null errors in the cache and b) record that the component does not exist + private final Cache> compressionMetadata = buildCompressionMetadataCache( + propOrDefault("sbr.cache.compressionInfo.maxEntries", 128), + propOrDefault("sbr.cache.compressionInfo.expireAfterMins", 15)); + + static int propOrDefault(String name, int defaultValue) + { + return propOrDefault(name, defaultValue, Integer::parseInt); + } + + private static long propOrDefault(String name, long defaultValue) + { + return propOrDefault(name, defaultValue, Long::parseLong); + } + + private static boolean propOrDefault(String name, boolean defaultValue) + { + return propOrDefault(name, defaultValue, Boolean::parseBoolean); + } + + private static T propOrDefault(String name, T defaultValue, Function parser) + { + String str = System.getProperty(name); + if (str != null) + { + try + { + return parser.apply(str); + } + catch (NumberFormatException exception) + { + LOGGER.error("NumberFormatException for prop {} ", name, exception); + } + } + return defaultValue; + } + + private Cache buildCache(int size, int expireAfterMins) + { + return CacheBuilder.newBuilder() + .expireAfterAccess(expireAfterMins, TimeUnit.MINUTES) + .maximumSize(size) + .build(); + } + + /** + * Cassandra 6.0 gives a {@link CompressionMetadata} a reference to a compression dictionary, which owns native + * zstd tables. Release that reference here, so the tables go away once every reader of the dictionary has also + * released its own reference. + */ + private Cache> buildCompressionMetadataCache(int size, int expireAfterMins) + { + return CacheBuilder.newBuilder() + .expireAfterAccess(expireAfterMins, TimeUnit.MINUTES) + .maximumSize(size) + .>removalListener(notification -> { + Optional metadata = notification.getValue(); + if (metadata != null) + { + metadata.ifPresent(CompressionMetadata::close); + } + }) + .build(); + } + + public SummaryDbUtils.Summary keysFromSummary(@NotNull TableMetadata metadata, + @NotNull SSTable ssTable) throws IOException + { + return get(summary, ssTable, () -> SummaryDbUtils.readSummary(metadata, ssTable)); + } + + @Nullable + public Pair keysFromIndex(@NotNull TableMetadata metadata, + @NotNull SSTable ssTable) throws IOException + { + return get(index, ssTable, () -> ReaderUtils.keysFromIndex(metadata, ssTable)); + } + + public Map componentMapFromStats(@NotNull SSTable ssTable, + Descriptor descriptor) throws IOException + { + return get(stats, ssTable, () -> ReaderUtils.deserializeStatsMetadata(ssTable, descriptor)); + } + + public BloomFilter bloomFilter(@NotNull SSTable ssTable, Descriptor descriptor) throws IOException + { + return get(filter, ssTable, () -> ReaderUtils.readFilter(ssTable, descriptor.version.hasOldBfFormat())); + } + + @Nullable + public CompressionMetadata compressionMetadata(@NotNull SSTable ssTable, boolean hasMaxCompressedLength, double crcCheckChance) throws IOException + { + if (propOrDefault("sbr.cache.compressionInfo.enabled", true)) + { + long maxSize = propOrDefault("sbr.cache.compressionInfo.maxSize", 0L); + if (maxSize <= 0 || ssTable.length(FileType.COMPRESSION_INFO) < maxSize) + { + return get(compressionMetadata, ssTable, () -> readCompressionMetadata(ssTable, hasMaxCompressedLength, crcCheckChance)).orElse(null); + } + } + // An operator who turns the memoisation off, or who caps the CompressionInfo size, gets an instance that + // nobody holds and so nobody closes. Ref's reaper releases the dictionary reference of such an instance + // after garbage collection, and logs LEAK DETECTED for each one + return readCompressionMetadata(ssTable, hasMaxCompressedLength, crcCheckChance).orElse(null); + } + + private static Optional readCompressionMetadata(@NotNull SSTable ssTable, + boolean hasMaxCompressedLength, + double crcCheckChance) throws IOException + { + try (InputStream cis = ssTable.openCompressionStream()) + { + if (cis != null) + { + return Optional.of(CompressionMetadata.fromInputStream(cis, hasMaxCompressedLength, crcCheckChance)); + } + } + return Optional.empty(); + } + + boolean containsSummary(@NotNull SSTable ssTable) + { + return contains(summary, ssTable); + } + + boolean containsIndex(@NotNull SSTable ssTable) + { + return contains(index, ssTable); + } + + boolean containsStats(@NotNull SSTable ssTable) + { + return contains(stats, ssTable); + } + + boolean containsCompressionMetadata(@NotNull SSTable ssTable) + { + return contains(compressionMetadata, ssTable); + } + + boolean containsFilter(@NotNull SSTable ssTable) + { + return contains(filter, ssTable); + } + + private static boolean contains(@NotNull Cache cache, @NotNull SSTable ssTable) + { + return cache.getIfPresent(ssTable) != null; + } + + private static T get(@NotNull Cache cache, + @NotNull SSTable ssTable, + @NotNull Callable callable) throws IOException + { + try + { + return cache.get(ssTable, callable); + } + catch (ExecutionException exception) + { + throw toIOException(exception); + } + } + + private static IOException toIOException(Throwable throwable) + { + IOException ioException = ThrowableUtils.rootCause(throwable, IOException.class); + return ioException != null ? ioException : new IOException(ThrowableUtils.rootCause(throwable)); + } +} diff --git a/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/SSTableReader.java b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/SSTableReader.java new file mode 100644 index 000000000..eb5e53fcf --- /dev/null +++ b/cassandra-six-zero-bridge/src/main/java/org/apache/cassandra/spark/reader/SSTableReader.java @@ -0,0 +1,987 @@ +/* + * 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.reader; + +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.IOError; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import com.google.common.base.Preconditions; +import com.google.common.base.Suppliers; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Streams; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.cassandra.bridge.TokenRange; +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.cql3.ColumnIdentifier; +import org.apache.cassandra.db.DecoratedKey; +import org.apache.cassandra.db.DeletionTime; +import org.apache.cassandra.db.RegularAndStaticColumns; +import org.apache.cassandra.db.SerializationHeader; +import org.apache.cassandra.db.UnfilteredDeserializer; +import org.apache.cassandra.db.compression.CompressionDictionary; +import org.apache.cassandra.db.filter.ColumnFilter; +import org.apache.cassandra.db.marshal.AbstractType; +import org.apache.cassandra.db.marshal.UTF8Type; +import org.apache.cassandra.db.rows.DeserializationHelper; +import org.apache.cassandra.db.rows.EncodingStats; +import org.apache.cassandra.db.rows.Row; +import org.apache.cassandra.db.rows.Unfiltered; +import org.apache.cassandra.db.rows.UnfilteredRowIterator; +import org.apache.cassandra.io.sstable.Descriptor; +import org.apache.cassandra.io.sstable.ISSTableScanner; +import org.apache.cassandra.io.sstable.SSTableSimpleIterator; +import org.apache.cassandra.io.sstable.format.SSTableFormat; +import org.apache.cassandra.io.sstable.format.Version; +import org.apache.cassandra.io.sstable.format.bti.BtiReaderUtils; +import org.apache.cassandra.io.sstable.indexsummary.IndexSummary; +import org.apache.cassandra.io.sstable.metadata.MetadataComponent; +import org.apache.cassandra.io.sstable.metadata.MetadataType; +import org.apache.cassandra.io.sstable.metadata.StatsMetadata; +import org.apache.cassandra.io.sstable.metadata.ValidationMetadata; +import org.apache.cassandra.io.util.DataInputPlus; +import org.apache.cassandra.io.util.DataInputStreamPlus; +import org.apache.cassandra.schema.ColumnMetadata; +import org.apache.cassandra.schema.DroppedColumn; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.service.ActiveRepairService; +import org.apache.cassandra.spark.data.SSTable; +import org.apache.cassandra.analytics.reader.common.RawInputStream; +import org.apache.cassandra.spark.reader.common.SSTableStreamException; +import org.apache.cassandra.spark.sparksql.filters.PartitionKeyFilter; +import org.apache.cassandra.spark.sparksql.filters.PruneColumnFilter; +import org.apache.cassandra.spark.sparksql.filters.SparkRangeFilter; +import org.apache.cassandra.analytics.stats.Stats; +import org.apache.cassandra.spark.sparksql.filters.SSTableTimeRangeFilter; +import org.apache.cassandra.spark.utils.ByteBufferUtils; +import org.apache.cassandra.spark.utils.Pair; +import org.apache.cassandra.spark.utils.ThrowableUtils; +import org.apache.cassandra.utils.ByteBufferUtil; +import org.apache.cassandra.utils.concurrent.Ref; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("unused") +public class SSTableReader implements SparkSSTableReader, Scannable +{ + private static final Logger LOGGER = LoggerFactory.getLogger(SSTableReader.class); + + private final TableMetadata metadata; + @NotNull + private final SSTable ssTable; + private final StatsMetadata statsMetadata; + @NotNull + private final Version version; + @NotNull + private final DecoratedKey first; + @NotNull + private final DecoratedKey last; + @NotNull + private final BigInteger firstToken; + @NotNull + private final BigInteger lastToken; + private final SerializationHeader header; + private final DeserializationHelper helper; + @NotNull + private final AtomicReference reader = new AtomicReference<>(null); + @Nullable + private final SparkRangeFilter sparkRangeFilter; + @NotNull + private final List partitionKeyFilters; + @NotNull + private final SSTableTimeRangeFilter sstableTimeRangeFilter; + @NotNull + private final Stats stats; + @Nullable + private Long startOffset = null; + private Long openedNanos = null; + @NotNull + private final Function isRepaired; + + public static class Builder + { + @NotNull + final TableMetadata metadata; + @NotNull + final SSTable ssTable; + @Nullable + PruneColumnFilter columnFilter = null; + boolean readIndexOffset = true; + @NotNull + Stats stats = Stats.DoNothingStats.INSTANCE; + boolean useIncrementalRepair = true; + boolean isRepairPrimary = false; + Function isRepaired = stats -> stats.repairedAt != ActiveRepairService.UNREPAIRED_SSTABLE; + @Nullable + SparkRangeFilter sparkRangeFilter = null; + @NotNull + final List partitionKeyFilters = new ArrayList<>(); + @NotNull + SSTableTimeRangeFilter sstableTimeRangeFilter = SSTableTimeRangeFilter.ALL; + + Builder(@NotNull TableMetadata metadata, @NotNull SSTable ssTable) + { + this.metadata = metadata; + this.ssTable = ssTable; + } + + public Builder withSparkRangeFilter(@Nullable SparkRangeFilter sparkRangeFilter) + { + this.sparkRangeFilter = sparkRangeFilter; + return this; + } + + public Builder withPartitionKeyFilters(@Nullable Collection partitionKeyFilters) + { + if (partitionKeyFilters != null) + { + this.partitionKeyFilters.addAll(partitionKeyFilters); + } + return this; + } + + public Builder withPartitionKeyFilter(@NotNull PartitionKeyFilter partitionKeyFilter) + { + partitionKeyFilters.add(partitionKeyFilter); + return this; + } + + public Builder withTimeRangeFilter(@Nullable SSTableTimeRangeFilter sstableTimeRangeFilter) + { + if (sstableTimeRangeFilter != null) + { + this.sstableTimeRangeFilter = sstableTimeRangeFilter; + } + return this; + } + + public Builder withColumnFilter(@Nullable PruneColumnFilter columnFilter) + { + this.columnFilter = columnFilter; + return this; + } + + public Builder withReadIndexOffset(boolean readIndexOffset) + { + this.readIndexOffset = readIndexOffset; + return this; + } + + public Builder withStats(@NotNull Stats stats) + { + this.stats = stats; + return this; + } + + public Builder useIncrementalRepair(boolean useIncrementalRepair) + { + this.useIncrementalRepair = useIncrementalRepair; + return this; + } + + public Builder isRepairPrimary(boolean isRepairPrimary) + { + this.isRepairPrimary = isRepairPrimary; + return this; + } + + public Builder withIsRepairedFunction(Function isRepaired) + { + this.isRepaired = isRepaired; + return this; + } + + public SSTableReader build() throws IOException + { + return new SSTableReader(metadata, + ssTable, + sparkRangeFilter, + partitionKeyFilters, + sstableTimeRangeFilter, + columnFilter, + readIndexOffset, + stats, + useIncrementalRepair, + isRepairPrimary, + isRepaired); + } + } + + public static Builder builder(@NotNull TableMetadata metadata, @NotNull SSTable ssTable) + { + return new Builder(metadata, ssTable); + } + + // CHECKSTYLE IGNORE: Constructor with many parameters + public SSTableReader(@NotNull TableMetadata metadata, + @NotNull SSTable ssTable, + @Nullable SparkRangeFilter sparkRangeFilter, + @NotNull List partitionKeyFilters, + @NotNull SSTableTimeRangeFilter sstableTimeRangeFilter, + @Nullable PruneColumnFilter columnFilter, + boolean readIndexOffset, + @NotNull Stats stats, + boolean useIncrementalRepair, + boolean isRepairPrimary, + @NotNull Function isRepaired) throws IOException + { + long startTimeNanos = System.nanoTime(); + long now; + this.ssTable = ssTable; + this.stats = stats; + this.isRepaired = isRepaired; + this.sparkRangeFilter = sparkRangeFilter; + + Descriptor descriptor = ReaderUtils.constructDescriptor(metadata.keyspace, metadata.name, ssTable); + this.version = descriptor.version; + + SummaryDbUtils.Summary summary = null; + Pair keys = null; + try + { + now = System.nanoTime(); + if (ssTable.isBigFormat()) + { + summary = SSTableCache.INSTANCE.keysFromSummary(metadata, ssTable); + stats.readSummaryDb(ssTable, System.nanoTime() - now); + keys = Pair.of(summary.first(), summary.last()); + } + } + catch (IOException exception) + { + LOGGER.warn("Failed to read Summary.db file ssTable='{}'", ssTable, exception); + } + + if (keys == null) + { + if (ssTable.isBigFormat()) + { + LOGGER.warn("Could not load first and last key from Summary.db file, so attempting Index.db fileName={}", + ssTable.getDataFileName()); + } + now = System.nanoTime(); + keys = SSTableCache.INSTANCE.keysFromIndex(metadata, ssTable); + stats.readIndexDb(ssTable, System.nanoTime() - now); + } + + if (keys == null) + { + throw new IOException("Could not load SSTable first or last tokens"); + } + + this.first = keys.left; + this.last = keys.right; + this.firstToken = ReaderUtils.tokenToBigInteger(first.getToken()); + this.lastToken = ReaderUtils.tokenToBigInteger(last.getToken()); + TokenRange readerRange = range(); + + List matchingKeyFilters = partitionKeyFilters.stream() + .filter(filter -> readerRange.contains(filter.token())) + .collect(Collectors.toList()); + boolean overlapsSparkRange = sparkRangeFilter == null || SparkSSTableReader.overlaps(this, sparkRangeFilter.tokenRange()); + if (!overlapsSparkRange // SSTable doesn't overlap with Spark worker token range + || (matchingKeyFilters.isEmpty() && !partitionKeyFilters.isEmpty())) // No matching partition key filters overlap with SSTable + { + this.partitionKeyFilters = Collections.emptyList(); + stats.skippedSSTable(sparkRangeFilter, partitionKeyFilters, firstToken, lastToken); + LOGGER.info("Ignoring SSTableReader with firstToken={} lastToken={}, does not overlap with any filter", + firstToken, lastToken); + statsMetadata = null; + header = null; + helper = null; + this.metadata = null; + this.sstableTimeRangeFilter = SSTableTimeRangeFilter.ALL; + return; + } + + if (!matchingKeyFilters.isEmpty()) + { + List matchInBloomFilter = + ReaderUtils.filterKeyInBloomFilter(ssTable, metadata.partitioner, descriptor, matchingKeyFilters); + this.partitionKeyFilters = ImmutableList.copyOf(matchInBloomFilter); + + // Check if required keys are actually present + if (matchInBloomFilter.isEmpty() || !ReaderUtils.anyFilterKeyInIndex(ssTable, metadata, descriptor, matchInBloomFilter)) + { + if (matchInBloomFilter.isEmpty()) + { + stats.missingInBloomFilter(); + } + else + { + stats.missingInIndex(); + } + LOGGER.info("Ignoring SSTable {}, no match found in index file for key filters", + this.ssTable.getDataFileName()); + statsMetadata = null; + header = null; + helper = null; + this.metadata = null; + this.sstableTimeRangeFilter = SSTableTimeRangeFilter.ALL; + return; + } + } + else + { + this.partitionKeyFilters = ImmutableList.copyOf(partitionKeyFilters); + } + + Map componentMap = SSTableCache.INSTANCE.componentMapFromStats(ssTable, descriptor); + + ValidationMetadata validation = (ValidationMetadata) componentMap.get(MetadataType.VALIDATION); + if (validation != null && !validation.partitioner.equals(metadata.partitioner.getClass().getName())) + { + throw new IllegalStateException("Partitioner in ValidationMetadata does not match TableMetaData: " + + validation.partitioner + " vs. " + metadata.partitioner.getClass().getName()); + } + + this.statsMetadata = (StatsMetadata) componentMap.get(MetadataType.STATS); + if (!sstableTimeRangeFilter.overlaps(statsMetadata.minTimestamp, statsMetadata.maxTimestamp)) + { + LOGGER.info("Ignoring SSTableReader with minTimestamp={} maxTimestamp={}, does not overlap with filter {}", + this.statsMetadata.minTimestamp, this.statsMetadata.maxTimestamp, sstableTimeRangeFilter); + header = null; + helper = null; + this.metadata = null; + this.sstableTimeRangeFilter = SSTableTimeRangeFilter.ALL; + return; + } + else + { + this.sstableTimeRangeFilter = sstableTimeRangeFilter; + } + + SerializationHeader.Component headerComp = (SerializationHeader.Component) componentMap.get(MetadataType.HEADER); + if (headerComp == null) + { + throw new IOException("Cannot read SSTable if cannot deserialize stats header info"); + } + + if (useIncrementalRepair && !isRepairPrimary && isRepaired()) + { + stats.skippedRepairedSSTable(ssTable, statsMetadata.repairedAt); + LOGGER.info("Ignoring repaired SSTable on non-primary repair replica ssTable='{}' repairedAt={}", + ssTable, statsMetadata.repairedAt); + header = null; + helper = null; + this.metadata = null; + return; + } + + Set columnNames = Streams.concat(metadata.columns().stream(), + metadata.staticColumns().stream()) + .map(column -> column.name.toString()) + .collect(Collectors.toSet()); + // Cassandra 6.0 gives every column a uniqueId, and TableMetadata.Builder assigns one only when no column + // already has one, so a dropped column added here must carry an explicit id. Seed the map with the + // table's own dropped columns, so that the ids they hold stay accounted for. + Map droppedColumns = new HashMap<>(metadata.droppedColumns); + int knownDroppedColumns = droppedColumns.size(); + // Almost every SSTable adds no dropped column, so pay for the id scan only when one does + TableMetadata schemaMetadata = metadata; + Supplier nextUniqueId = Suppliers.memoize(() -> new AtomicInteger(nextUniqueId(schemaMetadata))); + addDroppedColumns(droppedColumns, + nextUniqueId, + metadata.keyspace, + metadata.name, + ssTable, + headerComp.getRegularColumns(), + columnNames, + ColumnMetadata.Kind.REGULAR); + addDroppedColumns(droppedColumns, + nextUniqueId, + metadata.keyspace, + metadata.name, + ssTable, + headerComp.getStaticColumns(), + columnNames, + ColumnMetadata.Kind.STATIC); + if (droppedColumns.size() > knownDroppedColumns) + { + LOGGER.info("Rebuilding table metadata with dropped columns numDroppedColumns={} ssTable='{}'", + droppedColumns.size(), ssTable); + metadata = metadata.unbuild().droppedColumns(droppedColumns).build(); + } + + this.header = headerComp.toHeader(metadata); + this.helper = new DeserializationHelper(metadata, + getSSTableVersion(ssTable).correspondingMessagingVersion(), + DeserializationHelper.Flag.FROM_REMOTE, + buildColumnFilter(metadata, columnFilter)); + this.metadata = metadata; + + if (readIndexOffset) + { + if (summary != null) + { + // BIG format + SummaryDbUtils.Summary finalSummary = summary; + extractRange(sparkRangeFilter, partitionKeyFilters) + .ifPresent(range -> readOffsets(finalSummary.summary(), range)); + } + else + { + // BTI format + extractRange(sparkRangeFilter, partitionKeyFilters) + .ifPresent(range -> { + startOffset = BtiReaderUtils.startOffsetInDataFile(ssTable, + this.metadata, + descriptor, + range); + }); + } + } + else + { + LOGGER.warn("Reading SSTable without looking up start/end offset, performance will potentially be degraded"); + } + + // Open SSTableStreamReader so opened in parallel inside thread pool + // and buffered + ready to go when CompactionIterator starts reading + reader.set(new SSTableStreamReader()); + stats.openedSSTable(ssTable, System.nanoTime() - startTimeNanos); + this.openedNanos = System.nanoTime(); + } + + /** + * Return the first column uniqueId that no column of the table uses, continuing the single sequence that + * Cassandra 6.0 numbers the live and the dropped columns in + */ + private static int nextUniqueId(TableMetadata metadata) + { + int highest = ColumnMetadata.NO_UNIQUE_ID; + // columns() covers the partition key, clustering, regular and static columns + for (ColumnMetadata column : metadata.columns()) + { + highest = Math.max(highest, column.uniqueId); + } + for (DroppedColumn dropped : metadata.droppedColumns.values()) + { + highest = Math.max(highest, dropped.column.uniqueId); + } + int nextId = Math.max(0, highest + 1); + // TableMetadata sizes its by-id column array at one slot per addressable column and indexes it by + // uniqueId, so the ids must run 0..size-1 for each id assigned from here to land inside the array + int addressableColumns = metadata.regularAndStaticAndDroppedColumns().size() + + metadata.partitionKeyColumns().size() + + metadata.clusteringColumns().size(); + Preconditions.checkState(nextId <= addressableColumns, + "Column uniqueIds of %s.%s exceed the by-id array: nextId=%s columns=%s", + metadata.keyspace, metadata.name, nextId, addressableColumns); + return nextId; + } + + /** + * Add one dropped column for every column of the SSTable that the table schema does not hold, taking each + * uniqueId from {@code nextUniqueId} + */ + private static void addDroppedColumns(Map droppedColumns, + Supplier nextUniqueId, + String keyspace, + String table, + SSTable ssTable, + Map> columns, + Set columnNames, + ColumnMetadata.Kind kind) + { + for (Map.Entry> entry : columns.entrySet()) + { + String colName = UTF8Type.instance.getString((entry.getKey())); + if (!columnNames.contains(colName) && !droppedColumns.containsKey(entry.getKey())) + { + AbstractType type = entry.getValue(); + LOGGER.warn("Dropped column found colName={} sstable='{}'", colName, ssTable); + ColumnMetadata column = new ColumnMetadata(keyspace, + table, + ColumnIdentifier.getInterned(colName, true), + type, + nextUniqueId.get().getAndIncrement(), + ColumnMetadata.NO_POSITION, + kind, + null); // TODO: Honor server-side column masking. + long droppedTime = TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()) + - TimeUnit.MINUTES.toMicros(60); + droppedColumns.put(entry.getKey(), new DroppedColumn(column, droppedTime)); + } + } + } + + /** + * Merge all the partition key filters to give the token range we care about. + * If no partition key filters, then use the Spark worker token range. + * + * @param sparkRangeFilter optional spark range filter + * @param partitionKeyFilters list of partition key filters + * @return the token range we care about for this Spark worker + */ + public static Optional extractRange(@Nullable SparkRangeFilter sparkRangeFilter, + @NotNull List partitionKeyFilters) + { + Optional partitionKeyRange = partitionKeyFilters.stream() + .map(PartitionKeyFilter::tokenRange) + .reduce(TokenRange::merge); + return partitionKeyRange.isPresent() + ? partitionKeyRange + : Optional.ofNullable(sparkRangeFilter != null ? sparkRangeFilter.tokenRange() : null); + } + + /** + * Read Data.db offsets by binary searching Summary.db into Index.db, then reading offsets in Index.db + * + * @param indexSummary Summary.db index summary + * @param range token range we care about for this Spark worker + */ + private void readOffsets(IndexSummary indexSummary, TokenRange range) + { + try + { + // If start is null we failed to find an overlapping token in the Index.db file, + // this is unlikely as we already pre-filter the SSTable based on the start-end token range. + // But in this situation we read the entire Data.db file to be safe, even if it hits performance. + startOffset = IndexDbUtils.findDataDbOffset(indexSummary, range, metadata.partitioner, ssTable, stats); + if (startOffset == null) + { + LOGGER.error("Failed to find Data.db start offset, performance will be degraded sstable='{}'", ssTable); + } + } + catch (IOException exception) + { + LOGGER.warn("IOException finding SSTable offsets, cannot skip directly to start offset in Data.db. " + + "Performance will be degraded.", exception); + } + } + + /** + * Build a ColumnFilter if we need to prune any columns for more efficient deserialization of the SSTable + * + * @param metadata TableMetadata object + * @param columnFilter prune column filter + * @return ColumnFilter if and only if we can prune any columns when deserializing the SSTable, + * otherwise return null + */ + @Nullable + private static ColumnFilter buildColumnFilter(TableMetadata metadata, @Nullable PruneColumnFilter columnFilter) + { + if (columnFilter == null) + { + return null; + } + List include = metadata.columns().stream() + .filter(column -> columnFilter.includeColumn(column.name.toString())) + .collect(Collectors.toList()); + if (include.size() == metadata.columns().size()) + { + return null; // No columns pruned + } + return ColumnFilter.allRegularColumnsBuilder(metadata, false) + .addAll(include) + .build(); + } + + public SSTable sstable() + { + return ssTable; + } + + public boolean ignore() + { + return reader.get() == null; + } + + @Override + public int hashCode() + { + return Objects.hash(metadata.keyspace, metadata.name, ssTable); + } + + @Override + public boolean equals(Object other) + { + return other instanceof SSTableReader + && this.metadata.keyspace.equals(((SSTableReader) other).metadata.keyspace) + && this.metadata.name.equals(((SSTableReader) other).metadata.name) + && this.ssTable.equals(((SSTableReader) other).ssTable); + } + + public boolean isRepaired() + { + return isRepaired.apply(statsMetadata); + } + + public DecoratedKey first() + { + return first; + } + + public DecoratedKey last() + { + return last; + } + + public long getMinTimestamp() + { + return statsMetadata.minTimestamp; + } + + public long getMaxTimestamp() + { + return statsMetadata.maxTimestamp; + } + + public StatsMetadata getSSTableMetadata() + { + return statsMetadata; + } + + @Override + public ISSTableScanner scanner() + { + ISSTableScanner result = reader.getAndSet(null); + if (result == null) + { + throw new IllegalStateException("SSTableStreamReader cannot be re-used"); + } + return result; + } + + @Override + @NotNull + public BigInteger firstToken() + { + return firstToken; + } + + @Override + @NotNull + public BigInteger lastToken() + { + return lastToken; + } + + public class SSTableStreamReader implements ISSTableScanner + { + private final DataInputStream dis; + private final DataInputPlus in; + final RawInputStream dataStream; + private DecoratedKey key; + private DeletionTime partitionLevelDeletion; + private SSTableSimpleIterator iterator; + private Row staticRow; + @Nullable + private final BigInteger lastToken; + // Cassandra 6.0 decompresses through native zstd tables that a compression dictionary owns. Hold a + // reference for the life of this scanner, so a removal from SSTableCache cannot free them mid-read + @Nullable + private final Ref dictionaryRef; + private long lastTimeNanos = System.nanoTime(); + + SSTableStreamReader() throws IOException + { + lastToken = sparkRangeFilter != null ? sparkRangeFilter.tokenRange().upperEndpoint() : null; + @Nullable CompressionMetadata compressionMetadata = SSTableCache.INSTANCE.compressionMetadata(ssTable, + version.hasMaxCompressedLength(), + metadata.params.crcCheckChance); + dictionaryRef = compressionMetadata != null ? compressionMetadata.acquireDictionaryRef() : null; + DataInputStream dataInputStream = new DataInputStream(ssTable.openDataStream()); + + if (compressionMetadata != null) + { + dataStream = CompressedRawInputStream.from(ssTable, + dataInputStream, + compressionMetadata, + stats); + } + else + { + dataStream = new RawInputStream(dataInputStream, new byte[64 * 1024], stats); + } + dis = new DataInputStream(dataStream); + if (startOffset != null) + { + // Skip to start offset, if known, of first in-range partition + ByteBufferUtils.skipFully(dis, startOffset); + assert dataStream.position() == startOffset; + LOGGER.info("Using Data.db start offset to skip ahead startOffset={} sstable='{}'", + startOffset, ssTable); + stats.skippedDataDbStartOffset(startOffset); + } + in = new DataInputStreamPlus(dis); + } + + @Override + public TableMetadata metadata() + { + return metadata; + } + + public boolean overlapsSparkTokenRange(BigInteger token) + { + return sparkRangeFilter == null || sparkRangeFilter.overlaps(token); + } + + public boolean overlapsPartitionFilters(DecoratedKey key) + { + return partitionKeyFilters.isEmpty() + || partitionKeyFilters.stream().anyMatch(filter -> filter.matches(key.getKey())); + } + + public boolean overlaps(DecoratedKey key, BigInteger token) + { + return overlapsSparkTokenRange(token) && overlapsPartitionFilters(key); + } + + @Override + public boolean hasNext() + { + try + { + while (true) + { + key = metadata.partitioner.decorateKey(ByteBufferUtil.readWithShortLength(in)); + partitionLevelDeletion = DeletionTime.getSerializer(version).deserialize(in); + iterator = SSTableSimpleIterator.create(metadata, in, header, helper, partitionLevelDeletion); + staticRow = iterator.readStaticRow(); + BigInteger token = ReaderUtils.tokenToBigInteger(key.getToken()); + if (overlaps(key, token)) + { + // Partition overlaps with filters + long now = System.nanoTime(); + stats.nextPartition(now - lastTimeNanos); + lastTimeNanos = now; + return true; + } + if (lastToken != null && startOffset != null && lastToken.compareTo(token) < 0) + { + // Partition no longer overlaps SparkTokenRange so we've finished reading this SSTable + stats.skippedDataDbEndOffset(dataStream.position() - startOffset); + return false; + } + stats.skippedPartition(key.getKey(), ReaderUtils.tokenToBigInteger(key.getToken())); + // Skip partition efficiently without deserializing + UnfilteredDeserializer deserializer = UnfilteredDeserializer.create(metadata, in, header, helper); + while (deserializer.hasNext()) + { + deserializer.skipNext(); + } + } + } + catch (EOFException exception) + { + return false; + } + catch (IOException exception) + { + stats.corruptSSTable(exception, metadata.keyspace, metadata.name, ssTable); + LOGGER.warn("IOException reading sstable keyspace={} table={} dataFileName={} ssTable='{}'", + metadata.keyspace, metadata.name, ssTable.getDataFileName(), ssTable, exception); + throw new SSTableStreamException(exception); + } + catch (Throwable throwable) + { + stats.corruptSSTable(throwable, metadata.keyspace, metadata.name, ssTable); + LOGGER.error("Error reading sstable keyspace={} table={} dataFileName={} ssTable='{}'", + metadata.keyspace, metadata.name, ssTable.getDataFileName(), ssTable, throwable); + throw new RuntimeException(ThrowableUtils.rootCause(throwable)); + } + } + + @Override + public UnfilteredRowIterator next() + { + return new UnfilteredIterator(); + } + + @Override + public void close() + { + LOGGER.debug("Closing SparkSSTableReader {}", ssTable); + try + { + dis.close(); + if (openedNanos != null) + { + stats.closedSSTable(System.nanoTime() - openedNanos); + } + } + catch (IOException exception) + { + LOGGER.warn("IOException closing SSTable DataInputStream", exception); + } + finally + { + if (dictionaryRef != null) + { + dictionaryRef.close(); + } + } + } + + @Override + public long getLengthInBytes() + { + // This is mostly used to return Compaction info for Metrics or via JMX so we can ignore here + return 0; + } + + @Override + public long getCompressedLengthInBytes() + { + return 0; + } + + @Override + public long getCurrentPosition() + { + // This is mostly used to return Compaction info for Metrics or via JMX so we can ignore here + return 0; + } + + @Override + public long getBytesScanned() + { + return 0; + } + + @Override + public Set getBackingSSTables() + { + return Collections.emptySet(); + } + + /** + * Cassandra 6.0 adds isFullRange to ISSTableScanner. Only CursorCompactor.isSupported reads it, to + * reject a scanner that returns a subset of the SSTable. This scanner always returns a subset: the + * range filter, the partition key filters and the column filter each restrict it. Cassandra's own + * non-file-backed scanner, EmptySSTableScanner, answers false for the same reason. + */ + @Override + public boolean isFullRange() + { + return false; + } + + private class UnfilteredIterator implements UnfilteredRowIterator + { + @Override + public RegularAndStaticColumns columns() + { + return metadata.regularAndStaticColumns(); + } + + @Override + public TableMetadata metadata() + { + return metadata; + } + + @Override + public boolean isReverseOrder() + { + return false; + } + + @Override + public DecoratedKey partitionKey() + { + return key; + } + + @Override + public DeletionTime partitionLevelDeletion() + { + return partitionLevelDeletion; + } + + @Override + public Row staticRow() + { + return staticRow; + } + + @Override + public EncodingStats stats() + { + return header.stats(); + } + + @Override + public boolean hasNext() + { + try + { + return iterator.hasNext(); + } + catch (IOError error) + { + // SSTableSimpleIterator::computeNext wraps IOException in IOError, so we catch those, + // try to extract the IOException and re-wrap it in an SSTableStreamException, + // which we can then process in TableStreamScanner + if (error.getCause() instanceof IOException) + { + throw new SSTableStreamException((IOException) error.getCause()); + } + + // Otherwise, just throw the IOError and deal with it further up the stack + throw error; + } + } + + @Override + public Unfiltered next() + { + // NOTE: In practice we know that IOException will be thrown by hasNext(), + // because that's where the actual reading happens, so we don't bother + // catching IOError here (contrarily to what we do in hasNext) + return iterator.next(); + } + + @Override + public void close() + { + } + } + } + + private Version getSSTableVersion(SSTable sstable) + { + SSTableFormat format = DatabaseDescriptor.getSSTableFormats().get(sstable.getFormat()); + return format.getVersion(sstable.getVersion()); + } +} diff --git a/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/bridge/CassandraSchemaTests.java b/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/bridge/CassandraSchemaTests.java new file mode 100644 index 000000000..cd1c13a60 --- /dev/null +++ b/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/bridge/CassandraSchemaTests.java @@ -0,0 +1,149 @@ +/* + * 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.bridge; + +import java.util.Collections; + +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.Test; + +import org.apache.cassandra.schema.Schema; +import org.apache.cassandra.schema.SchemaProvider; +import org.apache.cassandra.spark.data.CqlTable; +import org.apache.cassandra.spark.utils.TableIdentifier; +import org.apache.cassandra.spark.utils.test.TestSchema; +import org.apache.cassandra.spark.data.partitioner.Partitioner; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CassandraSchemaTests +{ + public static final CassandraBridgeImplementation BRIDGE = new CassandraBridgeImplementation(); + + @Test + public void testUpdateCdcSchema() + { + SchemaProvider schema = Schema.instance; + CassandraSchema.updateCdcSchema(schema, Collections.emptySet(), Partitioner.Murmur3Partitioner, (keyspace, table) -> null); + + final TestSchema testSchema1 = TestSchema.builder(BRIDGE) + .withPartitionKey("a", BRIDGE.bigint()) + .withClusteringKey("b", BRIDGE.text()) + .withColumn("c", BRIDGE.timeuuid()) + .withCdc(true) + .build(); + final CqlTable cqlTable1 = testSchema1.buildTable(); + + final TestSchema testSchema2 = TestSchema.builder(BRIDGE) + .withPartitionKey("pk", BRIDGE.uuid()) + .withClusteringKey("ck", BRIDGE.aInt()) + .withColumn("val", BRIDGE.blob()) + .withCdc(true) + .build(); + final CqlTable cqlTable2 = testSchema2.buildTable(); + + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable1)).isFalse(); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable2)).isFalse(); + + CassandraSchema.updateCdcSchema(schema, ImmutableSet.of(cqlTable1, cqlTable2), Partitioner.Murmur3Partitioner, (keyspace, table) -> null); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable1)).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable2)).isTrue(); + + CassandraSchema.updateCdcSchema(schema, ImmutableSet.of(cqlTable1, cqlTable2), Partitioner.Murmur3Partitioner, (keyspace, table) -> null); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable1)).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable2)).isTrue(); + + CassandraSchema.disableCdc(schema, cqlTable2); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable1)).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable2)).isFalse(); + + CassandraSchema.disableCdc(schema, cqlTable1); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable1)).isFalse(); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable2)).isFalse(); + + CassandraSchema.enableCdc(schema, cqlTable1); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable1)).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable2)).isFalse(); + + CassandraSchema.enableCdc(schema, cqlTable2); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable1)).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable2)).isTrue(); + + CassandraSchema.updateCdcSchema(schema, ImmutableSet.of(cqlTable1), Partitioner.Murmur3Partitioner, (keyspace, table) -> null); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable1)).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable2)).isFalse(); + + CassandraSchema.updateCdcSchema(schema, ImmutableSet.of(), Partitioner.Murmur3Partitioner, (keyspace, table) -> null); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable1)).isFalse(); + assertThat(CassandraSchema.isCdcEnabled(schema, cqlTable2)).isFalse(); + } + + @Test + public void testUnregisterNonCdcTables() + { + SchemaProvider schema = Schema.instance; + + TestSchema nonCdcSchema = TestSchema.builder(BRIDGE) + .withPartitionKey("a", BRIDGE.uuid()) + .withColumn("b", BRIDGE.text()) + .build(); + CqlTable nonCdcTable = nonCdcSchema.buildTable(); + TableIdentifier nonCdcId = TableIdentifier.of(nonCdcTable.keyspace(), nonCdcTable.table()); + + TestSchema cdcSchema = TestSchema.builder(BRIDGE) + .withKeyspace(nonCdcTable.keyspace()) + .withPartitionKey("a", BRIDGE.uuid()) + .withColumn("b", BRIDGE.text()) + .withCdc(true) + .build(); + CqlTable cdcTable = cdcSchema.buildTable(); + TableIdentifier cdcId = TableIdentifier.of(cdcTable.keyspace(), cdcTable.table()); + + // register both tables (as if they'd been found to share partition-key structure) + CassandraSchema.updateCdcSchema(schema, ImmutableSet.of(nonCdcTable, cdcTable), Partitioner.Murmur3Partitioner, (keyspace, table) -> null); + assertThat(CassandraSchema.has(schema, nonCdcTable.keyspace(), nonCdcTable.table())).isTrue(); + assertThat(CassandraSchema.has(schema, cdcTable.keyspace(), cdcTable.table())).isTrue(); + + // a later refresh determines nonCdcTable is no longer at risk — unregister it + CassandraSchema.unregisterNonCdcTables(schema, ImmutableSet.of(nonCdcId)); + assertThat(CassandraSchema.has(schema, nonCdcTable.keyspace(), nonCdcTable.table())).isFalse(); + // the CDC-enabled table must be completely unaffected + assertThat(CassandraSchema.has(schema, cdcTable.keyspace(), cdcTable.table())).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cdcTable)).isTrue(); + + // idempotent: unregistering an already-unregistered table is a no-op, not an error + CassandraSchema.unregisterNonCdcTables(schema, ImmutableSet.of(nonCdcId)); + assertThat(CassandraSchema.has(schema, nonCdcTable.keyspace(), nonCdcTable.table())).isFalse(); + + // refuses to unregister a table that is currently CDC-enabled + CassandraSchema.unregisterNonCdcTables(schema, ImmutableSet.of(cdcId)); + assertThat(CassandraSchema.has(schema, cdcTable.keyspace(), cdcTable.table())).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cdcTable)).isTrue(); + + // unregistering an unknown table (never registered) is a no-op, not an error + CassandraSchema.unregisterNonCdcTables(schema, ImmutableSet.of(TableIdentifier.of("unknown_ks", "unknown_table"))); + + // the table comes back at risk: registering it again reuses the column family store that the + // metadata-only removal left with the keyspace instance + CassandraSchema.updateCdcSchema(schema, ImmutableSet.of(nonCdcTable, cdcTable), Partitioner.Murmur3Partitioner, (keyspace, table) -> null); + assertThat(CassandraSchema.has(schema, nonCdcTable.keyspace(), nonCdcTable.table())).isTrue(); + assertThat(CassandraSchema.isCdcEnabled(schema, cdcTable)).isTrue(); + } +} diff --git a/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/bridge/SSTableWriterImplementationTest.java b/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/bridge/SSTableWriterImplementationTest.java new file mode 100644 index 000000000..8dba1acfd --- /dev/null +++ b/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/bridge/SSTableWriterImplementationTest.java @@ -0,0 +1,245 @@ +/* + * 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.bridge; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import com.google.common.collect.ImmutableMap; +import com.google.common.util.concurrent.Uninterruptibles; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.dht.ByteOrderedPartitioner; +import org.apache.cassandra.dht.Murmur3Partitioner; +import org.apache.cassandra.io.sstable.CQLSSTableWriter; +import org.apache.cassandra.io.sstable.Descriptor; +import org.apache.cassandra.io.sstable.SSTableId; +import org.apache.cassandra.io.sstable.SequenceBasedSSTableId; +import org.apache.cassandra.io.sstable.format.SSTableFormat; +import org.apache.cassandra.spark.TestUtils; +import org.apache.cassandra.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for configuring {@link SSTableWriterImplementation} + */ +class SSTableWriterImplementationTest +{ + public static final String CREATE_STATEMENT = "CREATE TABLE test_keyspace.test_table (a int PRIMARY KEY, b text)"; + public static final String INSERT_STATEMENT = "INSERT INTO test_keyspace.test_table (a, b) VALUES (?, ?)"; + + @TempDir + File writeDirectory; + + static + { + CassandraTypesImplementation.setup(BridgeInitializationParameters.fromEnvironment()); + } + + @Test + void testSSTableWriterConfiguration() throws NoSuchFieldException, IllegalAccessException + { + CQLSSTableWriter.Builder builder = SSTableWriterImplementation.configureBuilder(writeDirectory.getAbsolutePath(), + CREATE_STATEMENT, + INSERT_STATEMENT, + 250, + Collections.emptySet(), + sstables -> {}, + // Cassandra 6.0 makes the partitioner constructors non-public + Murmur3Partitioner.instance); + + + assertThat(peekSorted(builder)).isTrue(); + assertThat(peekBufferSizeInMB(builder)).isEqualTo(250); + } + + @Test + void testGetProducedSSTables() throws IOException + { + Set produced = new HashSet<>(); + SSTableWriterImplementation writer = new SSTableWriterImplementation(writeDirectory.getAbsolutePath(), + // required in order to insert ordered ints + ByteOrderedPartitioner.instance, + CREATE_STATEMENT, + INSERT_STATEMENT, + Collections.emptySet(), + 1); + writer.setSSTablesProducedListener(produced::addAll); + assertThat(produced).isEmpty(); + + for (int i = 0; i < 300_000; i++) + { + writer.addRow(ImmutableMap.of("a", i, "b", "val_" + i)); + } + + assertThat(produced).hasSize(2); + // Read the format and the first generation rather than assume them: the selected format and the static + // sstable id of AbstractSSTableSimpleWriter both carry over from whatever ran earlier in this JVM + SSTableFormat format = DatabaseDescriptor.getSelectedSSTableFormat(); + String sstableFormat = format.name(); + assertThat(sstableFormat).containsAnyOf("big", "bti"); + String sstableVersion = format.getLatestVersion().version; + assertThat(sstableVersion).isEqualTo("bti".equals(sstableFormat) ? "ea" : "pa"); + int firstGeneration = lowestGeneration(produced); + assertThat(produced.stream().map(e -> e.baseFilename)).containsExactlyInAnyOrder( + toSStableFileName(sstableFormat, sstableVersion, firstGeneration), + toSStableFileName(sstableFormat, sstableVersion, firstGeneration + 1)); + // Ensure produced descriptors don't have trailing dashes + produced.forEach(desc -> assertThat(desc.baseFilename).doesNotEndWith("-")); + produced.clear(); + + for (int i = 300_000; i < 400_000; i++) + { + writer.addRow(ImmutableMap.of("a", i, "b", "val_" + i)); + } + assertThat(produced.size()).isEqualTo(1); + assertThat(produced.stream().map(e -> e.baseFilename)) + .containsExactly(toSStableFileName(sstableFormat, sstableVersion, firstGeneration + 2)); + + // when closing the writer, a new sstable is produced (by flushing the remaining data in the buffer) + produced.clear(); + writer.close(); + assertThat(produced.size()).isEqualTo(1); + assertThat(produced.stream().map(e -> e.baseFilename)) + .containsExactly(toSStableFileName(sstableFormat, sstableVersion, firstGeneration + 3)); + } + + // A base filename is version-generation-format, for example pa-2-big + private static int lowestGeneration(Set produced) + { + return produced.stream() + .mapToInt(descriptor -> Integer.parseInt(descriptor.baseFilename.split("-")[1])) + .min() + .orElseThrow(() -> new AssertionError("No sstable was produced")); + } + + @Test + void testBaseFileNameExtraction() + { + // Test basic case with big format + org.apache.cassandra.io.util.File cf = new org.apache.cassandra.io.util.File(writeDirectory); + SSTableId ssTableId = new SequenceBasedSSTableId(1); + Descriptor descriptor = new Descriptor("nb", cf, "ks", "tbl", ssTableId, TestUtils.BIG_FORMAT); + String baseFilename = CassandraBridgeImplementation.baseFilename(descriptor); + assertThat(baseFilename).isEqualTo("nb-1-big"); + + // Test with bti format (Cassandra 5.0) + Descriptor descriptorBti = new Descriptor("da", cf, "ks", "tbl", ssTableId, TestUtils.BTI_FORMAT); + String baseFilenameBti = CassandraBridgeImplementation.baseFilename(descriptorBti); + assertThat(baseFilenameBti).isEqualTo("da-1-bti"); + + // Test with different generations + SSTableId ssTableId10 = new SequenceBasedSSTableId(10); + Descriptor descriptor10 = new Descriptor("oa", cf, "ks", "tbl", ssTableId10, TestUtils.BIG_FORMAT); + assertThat(CassandraBridgeImplementation.baseFilename(descriptor10)).isEqualTo("oa-10-big"); + + SSTableId ssTableId12345 = new SequenceBasedSSTableId(12345); + Descriptor descriptor12345 = new Descriptor("oa", cf, "ks", "tbl", ssTableId12345, TestUtils.BIG_FORMAT); + assertThat(CassandraBridgeImplementation.baseFilename(descriptor12345)).isEqualTo("oa-12345-big"); + } + + @Test + void testSSTableDescriptorConsistencyWithFilePathParsing() + { + // This test ensures that SSTableDescriptors created by SSTableWriterImplementation.onSSTablesProduced() + // match those created by SSTables.getSSTableDescriptor() from file paths + org.apache.cassandra.io.util.File cf = new org.apache.cassandra.io.util.File(writeDirectory); + SSTableId ssTableId = new SequenceBasedSSTableId(1); + + // Test with big format + Descriptor descriptorBig = new Descriptor("oa", cf, "ks", "tbl", ssTableId, TestUtils.BIG_FORMAT); + String baseFilenameFromBridge = CassandraBridgeImplementation.baseFilename(descriptorBig); + SSTableDescriptor descriptorFromBridge = new SSTableDescriptor(baseFilenameFromBridge); + SSTableDescriptor descriptorFromPath = new SSTableDescriptor("oa-1-big"); + assertThat(descriptorFromBridge).isEqualTo(descriptorFromPath); + assertThat(descriptorFromBridge.baseFilename).isEqualTo("oa-1-big"); + + // Test with bti format (Cassandra 5.0) + Descriptor descriptorBti = new Descriptor("da", cf, "ks", "tbl", ssTableId, TestUtils.BTI_FORMAT); + String baseFilenameFromBridgeBti = CassandraBridgeImplementation.baseFilename(descriptorBti); + SSTableDescriptor descriptorFromBridgeBti = new SSTableDescriptor(baseFilenameFromBridgeBti); + SSTableDescriptor descriptorFromPathBti = new SSTableDescriptor("da-1-bti"); + assertThat(descriptorFromBridgeBti).isEqualTo(descriptorFromPathBti); + assertThat(descriptorFromBridgeBti.baseFilename).isEqualTo("da-1-bti"); + } + + static boolean peekSorted(CQLSSTableWriter.Builder builder) throws NoSuchFieldException, IllegalAccessException + { + Field sortedField = ReflectionUtils.getField(builder.getClass(), "sorted"); + sortedField.setAccessible(true); + return (boolean) sortedField.get(builder); + } + + static long peekBufferSizeInMB(CQLSSTableWriter.Builder builder) throws NoSuchFieldException, IllegalAccessException + { + // The name of the size field has been changed in Cassandra code base. + // We find the field using the old name to newer one. + Field sizeField = findFirstField(builder.getClass(), + "bufferSizeInMB", "bufferSizeInMiB", "maxSSTableSizeInMiB"); + sizeField.setAccessible(true); + return (long) sizeField.get(builder); + } + + static Field findFirstField(Class clazz, String... fieldNames) throws NoSuchFieldException + { + Field field = null; + for (String fieldName : fieldNames) + { + try + { + field = ReflectionUtils.getField(clazz, fieldName); + } + catch (NoSuchFieldException nsfe) + { + // ignore the exception and try with the next fieldName + } + } + + if (field == null) + { + throw new NoSuchFieldException("The class does not contain any of the supplied fieldNames: " + Arrays.asList(fieldNames)); + } + + return field; + } + + private void waitForProduced(Set produced) + { + int i = 15; // the test runs roughly within 2 seconds; 3_000 milliseconds timeout should suffice. + while (produced.isEmpty() && i-- > 0) + { + Uninterruptibles.sleepUninterruptibly(200, TimeUnit.MILLISECONDS); + } + } + + private String toSStableFileName(String format, String version, int number) + { + return String.format("%s-%d-%s", version, number, format); + } +} diff --git a/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/CompressionDictionaryTests.java b/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/CompressionDictionaryTests.java new file mode 100644 index 000000000..9bb875a96 --- /dev/null +++ b/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/CompressionDictionaryTests.java @@ -0,0 +1,320 @@ +/* + * 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.reader; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; + +import com.github.luben.zstd.ZstdDictTrainer; +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Test; + +import org.apache.cassandra.bridge.BridgeInitializationParameters; +import org.apache.cassandra.bridge.CassandraTypesImplementation; +import org.apache.cassandra.db.compression.CompressionDictionary; +import org.apache.cassandra.db.rows.Cell; +import org.apache.cassandra.db.rows.ColumnData; +import org.apache.cassandra.db.rows.Row; +import org.apache.cassandra.db.rows.Unfiltered; +import org.apache.cassandra.db.rows.UnfilteredRowIterator; +import org.apache.cassandra.dht.Murmur3Partitioner; +import org.apache.cassandra.io.compress.ZstdDictionaryCompressor; +import org.apache.cassandra.io.sstable.CQLSSTableWriter; +import org.apache.cassandra.io.sstable.ISSTableScanner; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.spark.data.FileType; +import org.apache.cassandra.spark.data.ReplicationFactor; +import org.apache.cassandra.spark.data.SSTable; +import org.apache.cassandra.spark.data.partitioner.Partitioner; +import org.apache.cassandra.spark.utils.TemporaryDirectory; +import org.apache.cassandra.spark.utils.test.TestSSTable; +import org.apache.cassandra.utils.concurrent.Ref; +import org.apache.cassandra.analytics.stats.Stats; +import org.jetbrains.annotations.Nullable; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Round-trip tests for the Cassandra 6.0 compression dictionary. + *

+ * Cassandra 6.0 appends a compression dictionary to the CompressionInfo component, which is the only reason + * for the SSTable versions big-pa and bti-ea. These tests write an SSTable with a trained zstd dictionary + * through the Cassandra 6.0 {@link CQLSSTableWriter}, then read every row back through the analytics + * {@link SSTableReader}. The negative case writes a second table with a dictionary-less compressor, so that a + * reader that ignores the dictionary section cannot pass both tests. + */ +class CompressionDictionaryTests +{ + private static final String KEYSPACE = "dictionary_keyspace"; + // CQLSSTableWriter submits the CREATE TABLE statement with ignoreIfExists, and every test in this JVM + // shares one schema, so each test needs its own table to get its own compression options. + private static final String DICTIONARY_TABLE = "dictionary_table"; + private static final String PLAIN_TABLE = "plain_table"; + private static final String LIFECYCLE_TABLE = "lifecycle_table"; + // A compressor that accepts a dictionary. CompressionParams.isDictionaryCompressionEnabled() is true only + // for this class, and CQLSSTableWriter rejects a dictionary on any other compressor. + private static final String DICTIONARY_COMPRESSION = "{'class': 'ZstdDictionaryCompressor'}"; + private static final String PLAIN_COMPRESSION = "{'class': 'ZstdCompressor'}"; + private static final int PARTITIONS = 64; + private static final int ROWS_PER_PARTITION = 8; + // One id per table. CompressionMetadata holds one dictionary instance per id, and ZstdDictionaryCompressor + // keys its compressors by id, so a shared id would make one test's references visible to another + private static final long DICTIONARY_ID = 4242L; + private static final long LIFECYCLE_DICTIONARY_ID = 4343L; + + static + { + CassandraTypesImplementation.setup(BridgeInitializationParameters.fromEnvironment()); + } + + @Test + void testReadSSTableCompressedWithDictionary() throws IOException + { + try (TemporaryDirectory directory = new TemporaryDirectory()) + { + CompressionDictionary dictionary = trainDictionary(DICTIONARY_ID); + writeSSTable(directory.path(), DICTIONARY_TABLE, DICTIONARY_COMPRESSION, dictionary); + + // The reader decodes the dictionary that the writer appended, and hands it to the compressor + try (CompressionMetadata metadata = openCompressionMetadata(directory.path())) + { + assertThat(metadata.dictionary()).isNotNull(); + assertThat(metadata.dictionary().dictId().kind).isEqualTo(CompressionDictionary.Kind.ZSTD); + assertThat(metadata.dictionary().dictId().id).isEqualTo(DICTIONARY_ID); + assertThat(metadata.dictionary().rawDictionary()).isEqualTo(dictionary.rawDictionary()); + assertThat(metadata.compressor()).isInstanceOf(ZstdDictionaryCompressor.class); + // ZstdDictionaryCompressor caches one instance per compression level for the dictionary-less case, + // and one instance per dictionary otherwise, so a compressor that carries a dictionary is never + // the instance that the table options alone produce. + assertThat(metadata.compressor()).isNotSameAs(ZstdDictionaryCompressor.create(new HashMap<>())); + } + + // Every row decompresses, which is only possible with the dictionary attached + assertThat(readRows(directory.path(), DICTIONARY_TABLE, DICTIONARY_COMPRESSION)) + .isEqualTo(PARTITIONS * ROWS_PER_PARTITION); + } + } + + @Test + void testReadSSTableCompressedWithoutDictionary() throws IOException + { + try (TemporaryDirectory directory = new TemporaryDirectory()) + { + writeSSTable(directory.path(), PLAIN_TABLE, PLAIN_COMPRESSION, null); + + // CompressionDictionary.deserialize returns null at end of file, which is both an SSTable that + // holds no dictionary and every SSTable that an earlier Cassandra version wrote + try (CompressionMetadata metadata = openCompressionMetadata(directory.path())) + { + assertThat(metadata.dictionary()).isNull(); + assertThat(metadata.compressor()).isNotInstanceOf(ZstdDictionaryCompressor.class); + } + + assertThat(readRows(directory.path(), PLAIN_TABLE, PLAIN_COMPRESSION)) + .isEqualTo(PARTITIONS * ROWS_PER_PARTITION); + } + } + + /** + * A node keeps a compression dictionary alive through {@code CompressionDictionaryManager}, which an offline + * reader has none of. {@link CompressionMetadata} takes that part: it holds one instance per dictionary id, + * owns the primary reference of each, and gives every reader a reference of its own. This test walks the + * whole sequence, because a reference released too early makes the native zstd tables invalid mid-read, and + * a reference never released makes {@code Ref} log LEAK DETECTED. + */ + @Test + void testDictionaryReferenceLifecycle() throws IOException + { + try (TemporaryDirectory directory = new TemporaryDirectory()) + { + writeSSTable(directory.path(), LIFECYCLE_TABLE, DICTIONARY_COMPRESSION, + trainDictionary(LIFECYCLE_DICTIONARY_ID)); + + CompressionMetadata first = openCompressionMetadata(directory.path()); + CompressionMetadata second = openCompressionMetadata(directory.path()); + // Two readers of one SSTable deserialize two dictionaries and share one instance + assertThat(second.dictionary()).isSameAs(first.dictionary()); + + CompressionDictionary dictionary = first.dictionary(); + Ref readerRef = first.acquireDictionaryRef(); + assertThat(readerRef).isNotNull(); + + first.close(); + // close() is idempotent, so a double release throws nothing + first.close(); + second.close(); + + // The primary reference outlives both metadata instances, so a later reader still gets a reference + Ref probe = dictionary.tryRef(); + assertThat(probe).isNotNull(); + probe.close(); + + // The reader's reference outlives the primary one, so a removal mid-read frees nothing + CompressionMetadata.evictDictionaries(); + probe = dictionary.tryRef(); + assertThat(probe).isNotNull(); + probe.close(); + + // The last reference frees the native zstd tables, and tryRef reports the dictionary as released + readerRef.close(); + assertThat(dictionary.tryRef()).isNull(); + } + } + + private static String createStatement(String table, String compression) + { + return String.format("CREATE TABLE %s.%s (a int, b int, c text, PRIMARY KEY(a, b)) WITH compression = %s", + KEYSPACE, table, compression); + } + + /** + * Train a zstd dictionary over the values that {@link #writeSSTable} writes, then wrap it in the + * Cassandra 6.0 dictionary that carries the kind, the id and the checksum through the CompressionInfo + * component. + */ + private static CompressionDictionary trainDictionary(long dictionaryId) + { + // ZstdDictTrainer needs at least 11 samples, and it caps the dictionary at the second argument + ZstdDictTrainer trainer = new ZstdDictTrainer(1024 * 1024, 16 * 1024); + for (int partition = 0; partition < PARTITIONS; partition++) + { + for (int row = 0; row < ROWS_PER_PARTITION; row++) + { + trainer.addSample(value(partition, row).getBytes(StandardCharsets.UTF_8)); + } + } + byte[] raw = trainer.trainSamples(); + assertThat(raw).isNotEmpty(); + CompressionDictionary.Kind kind = CompressionDictionary.Kind.ZSTD; + int checksum = CompressionDictionary.calculateChecksum((byte) kind.ordinal(), dictionaryId, raw); + return kind.createDictionary(new CompressionDictionary.DictId(kind, dictionaryId), raw, checksum); + } + + private static void writeSSTable(Path directory, + String table, + String compression, + @Nullable CompressionDictionary dictionary) throws IOException + { + CQLSSTableWriter.Builder builder = + CQLSSTableWriter.builder() + .inDirectory(directory.toAbsolutePath().toString()) + .forTable(createStatement(table, compression)) + .using(String.format("INSERT INTO %s.%s (a, b, c) VALUES (?, ?, ?)", KEYSPACE, table)) + .withPartitioner(Murmur3Partitioner.instance); + if (dictionary != null) + { + builder.withCompressionDictionary(dictionary); + } + + try (CQLSSTableWriter writer = builder.build()) + { + for (int partition = 0; partition < PARTITIONS; partition++) + { + for (int row = 0; row < ROWS_PER_PARTITION; row++) + { + writer.addRow(partition, row, value(partition, row)); + } + } + } + catch (Exception exception) + { + throw new IOException("Failed to write the SSTable", exception); + } + + // CQLSSTableWriter creates a keyspace directory when it builds the ColumnFamilyStore that owns the + // dictionary, and removes it again, so the SSTable is the only content of the directory + assertThat(TestSSTable.countIn(directory)).isEqualTo(1); + } + + private static CompressionMetadata openCompressionMetadata(Path directory) throws IOException + { + Path compressionInfo = TestSSTable.firstIn(directory, FileType.COMPRESSION_INFO); + try (InputStream in = new BufferedInputStream(Files.newInputStream(compressionInfo))) + { + return CompressionMetadata.fromInputStream(in, true, 1.0); + } + } + + /** + * Read every row through the analytics {@code SSTableReader} and verify both the clustering value and the + * text column, which is the value that the dictionary compresses. + */ + private static int readRows(Path directory, String table, String compression) throws IOException + { + TableMetadata metadata = new SchemaBuilder(createStatement(table, compression), + KEYSPACE, + new ReplicationFactor(ReplicationFactor.ReplicationStrategy.SimpleStrategy, + ImmutableMap.of("replication_factor", 1)), + Partitioner.Murmur3Partitioner).tableMetaData(); + SSTable ssTable = TestSSTable.firstIn(directory); + SSTableReader reader = SSTableReader.builder(metadata, ssTable) + .withReadIndexOffset(true) + .withStats(Stats.DoNothingStats.INSTANCE) + .build(); + + Set seen = new HashSet<>(); + try (ISSTableScanner scanner = reader.scanner()) + { + while (scanner.hasNext()) + { + try (UnfilteredRowIterator partition = scanner.next()) + { + int a = partition.partitionKey().getKey().getInt(0); + while (partition.hasNext()) + { + Unfiltered unfiltered = partition.next(); + assertThat(unfiltered.isRow()).isTrue(); + Row row = (Row) unfiltered; + int b = row.clustering().bufferAt(0).getInt(0); + for (ColumnData data : row) + { + Cell cell = (Cell) data; + String c = StandardCharsets.UTF_8.decode(cell.buffer().duplicate()).toString(); + assertThat(c).isEqualTo(value(a, b)); + seen.add(a + ":" + b); + } + } + } + } + } + return seen.size(); + } + + /** + * A value with enough repetition for a trained dictionary to be worth attaching, and enough variation for + * every row to be distinguishable. + */ + private static String value(int partition, int row) + { + StringBuilder builder = new StringBuilder(); + for (int repeat = 0; repeat < 16; repeat++) + { + builder.append("the quick brown fox jumps over the lazy dog "); + } + return builder.append(partition).append(':').append(row).toString(); + } +} diff --git a/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java b/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java new file mode 100644 index 000000000..f378982c7 --- /dev/null +++ b/cassandra-six-zero-bridge/src/test/java/org/apache/cassandra/spark/reader/SchemaBuilderTests.java @@ -0,0 +1,141 @@ +/* + * 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.reader; + +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Test; + +import org.apache.cassandra.bridge.CassandraBridgeImplementation; +import org.apache.cassandra.bridge.SchemaUpdater; +import org.apache.cassandra.cql3.CQLFragmentParser; +import org.apache.cassandra.cql3.CqlParser; +import org.apache.cassandra.schema.KeyspaceMetadata; +import org.apache.cassandra.schema.KeyspaceParams; +import org.apache.cassandra.schema.Keyspaces; +import org.apache.cassandra.schema.Schema; +import org.apache.cassandra.schema.SchemaTransformation; +import org.apache.cassandra.schema.SchemaTransformations; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.schema.Types; +import org.apache.cassandra.schema.UserFunctions; +import org.apache.cassandra.spark.data.ReplicationFactor; +import org.apache.cassandra.tcm.ClusterMetadata; +import org.apache.cassandra.tcm.serialization.Version; +import org.apache.cassandra.utils.FBUtilities; + +import static org.apache.cassandra.spark.reader.SchemaBuilder.rfToMap; +import static org.assertj.core.api.Assertions.assertThat; + +public class SchemaBuilderTests +{ + @Test + public void getCompactionClass() + { + FBUtilities.classForName("org.apache.cassandra.db.compaction.LeveledCompactionStrategy", "LeveledCompactionStrategy"); + } + + @Test + public void testDataTypes() + { + assertThat(FBUtilities.classForName("org.apache.cassandra.dht.Murmur3Partitioner", "Murmur3Partitioner")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.dht.RandomPartitioner", "RandomPartitioner")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.AbstractCompositeType", "AbstractCompositeType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.AbstractType", "AbstractType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.AsciiType", "AsciiType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.BooleanType", "BooleanType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.BytesType", "BytesType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.ByteType", "ByteType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.CollectionType", "CollectionType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.CompositeType", "CompositeType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.CounterColumnType", "CounterColumnType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.DateType", "DateType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.DecimalType", "DecimalType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.DoubleType", "DoubleType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.DurationType", "DurationType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.DynamicCompositeType", "DynamicCompositeType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.EmptyType", "EmptyType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.FloatType", "FloatType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.FrozenType", "FrozenType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.InetAddressType", "InetAddressType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.Int32Type", "Int32Type")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.IntegerType", "IntegerType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.LexicalUUIDType", "LexicalUUIDType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.ListType", "ListType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.LongType", "LongType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.MapType", "MapType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.NumberType", "NumberType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.PartitionerDefinedOrder", "PartitionerDefinedOrder")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.ReversedType", "ReversedType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.SetType", "SetType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.ShortType", "ShortType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.SimpleDateType", "SimpleDateType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.TemporalType", "TemporalType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.TimestampType", "TimestampType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.TimeType", "TimeType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.TimeUUIDType", "TimeUUIDType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.TupleType", "TupleType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.TypeParser", "TypeParser")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.UserType", "UserType")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.UTF8Type", "UTF8Type")).isNotNull(); + assertThat(FBUtilities.classForName("org.apache.cassandra.db.marshal.UUIDType", "UUIDType")).isNotNull(); + } + + @Test + public void testSchemaBuilderWithPartiallyInitializedMetadata() + { + CassandraBridgeImplementation.setup(); + String keyspaceName = "foo" + getClass().getSimpleName(); + ReplicationFactor replicationFactor = new ReplicationFactor(ReplicationFactor.ReplicationStrategy.SimpleStrategy, + ImmutableMap.of("replication_factor", 1)); + KeyspaceMetadata keyspaceMetadata = KeyspaceMetadata.create(keyspaceName, KeyspaceParams.create(true, rfToMap(replicationFactor))); + // Cassandra 6.0 replaces Schema.transform with submit, which an offline caller must follow by creating the + // keyspace instances; SchemaUpdater.submit does both + SchemaUpdater.submit(Schema.instance, SchemaTransformations.addKeyspace(keyspaceMetadata, false)); + + String createTableStatement = "CREATE TABLE " + keyspaceName + ".bar (a int PRIMARY KEY)"; + TableMetadata tableMetadata = CQLFragmentParser + .parseAny(CqlParser::createTableStatement, createTableStatement, "CREATE TABLE") + .keyspace(keyspaceName) + .prepare(null) + // Cassandra 6.0 adds the user functions argument to CreateTableStatement.builder + .builder(Types.none(), UserFunctions.none()) + .build(); + KeyspaceMetadata keyspace = Schema.instance.getKeyspaceMetadata(keyspaceName); + // SchemaTransformation declares two methods in Cassandra 6.0, so a lambda no longer compiles + SchemaUpdater.submit(Schema.instance, new SchemaTransformation() + { + @Override + public Keyspaces apply(ClusterMetadata metadata) + { + return metadata.schema + .getKeyspaces() + .withAddedOrUpdated(keyspace.withSwapped(keyspace.tables.with(tableMetadata))); + } + + @Override + public boolean compatibleWith(ClusterMetadata metadata) + { + return metadata.directory.commonSerializationVersion.isAtLeast(Version.V0); + } + }); + + new SchemaBuilder(createTableStatement, keyspaceName, replicationFactor); + } +} diff --git a/cassandra-six-zero-types/build.gradle b/cassandra-six-zero-types/build.gradle new file mode 100644 index 000000000..268a7b167 --- /dev/null +++ b/cassandra-six-zero-types/build.gradle @@ -0,0 +1,53 @@ +import java.nio.file.Paths + +/* + * 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. + */ + +plugins { + id('java-library') +} + +configurations { + all*.exclude(group: 'org.slf4j', module: 'slf4j-log4j12') + all*.exclude(group: 'log4j', module: 'log4j') + implCassV5 +} + +dependencies { + // base this module on the Cassandra 5.x types, whose jar already carries the 4.x classes forward + implCassV5 project(':cassandra-five-zero-types') + compileOnly project(':cassandra-five-zero-types') + compileOnly project(':cassandra-four-zero-types') + compileOnly "org.slf4j:slf4j-api:${slf4jApiVersion}" + compileOnly project(":cassandra-analytics-common") + compileOnly(project(path: ':cassandra-six-zero', configuration: 'shadow')) + compileOnly "com.esotericsoftware:kryo-shaded:${kryoVersion}" +} + +jar { + archiveFileName = "six-zero-types.jar" + dependsOn ':cassandra-five-zero-types:build' + // copy classes from the V5 implementation and exclude duplicates that we override + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from { configurations.implCassV5.collect { it.isDirectory() ? it : zipTree(it) } } +} + +test { + useJUnitPlatform() +} diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/CassandraSchema.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/CassandraSchema.java new file mode 100644 index 000000000..0e3eccd2f --- /dev/null +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/CassandraSchema.java @@ -0,0 +1,514 @@ +/* + * 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.bridge; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.google.common.base.Preconditions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.antlr.runtime.RecognitionException; +import org.apache.cassandra.cdc.api.TableIdLookup; +import org.apache.cassandra.cql3.CQLFragmentParser; +import org.apache.cassandra.cql3.CqlParser; +import org.apache.cassandra.cql3.statements.schema.CreateTypeStatement; +import org.apache.cassandra.db.Keyspace; +import org.apache.cassandra.schema.KeyspaceMetadata; +import org.apache.cassandra.schema.Schema; +import org.apache.cassandra.schema.SchemaProvider; +import org.apache.cassandra.schema.TableId; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.schema.Types; +import org.apache.cassandra.schema.UserFunctions; +import org.apache.cassandra.spark.data.CqlTable; +import org.apache.cassandra.spark.data.partitioner.Partitioner; +import org.apache.cassandra.spark.reader.SchemaBuilder; +import org.apache.cassandra.spark.utils.TableIdentifier; +import org.jetbrains.annotations.NotNull; + +import org.jetbrains.annotations.Nullable; + +public final class CassandraSchema +{ + private static final Logger LOGGER = LoggerFactory.getLogger(CassandraSchema.class); + + private CassandraSchema() + { + throw new IllegalStateException("Do not instantiate!"); + } + + /** + * Update cassandra schema with synchronization + * + * @param updater updates schema + */ + public static void update(Consumer updater) + { + synchronized (Schema.instance) + { + updater.accept(Schema.instance); + } + } + + /** + * Update cassandra schema and return a result with synchronization + * + * @param type of the returned value + * @param updater updates schema and return a result + * @return a new value depending on the updater + */ + public static T apply(Function updater) + { + synchronized (Schema.instance) + { + return updater.apply(Schema.instance); + } + } + + public static Types buildTypes(String keyspace, + Set udtStmts) + { + List typeStatements = new ArrayList<>(udtStmts.size()); + for (String udt : udtStmts) + { + try + { + typeStatements.add((CreateTypeStatement.Raw) CQLFragmentParser.parseAnyUnhandled(CqlParser::query, udt)); + } + catch (RecognitionException e) + { + LOGGER.error("Failed to parse type expression '{}'", udt); + throw new IllegalStateException(e); + } + } + Types.RawBuilder typesBuilder = Types.rawBuilder(keyspace); + for (CreateTypeStatement.Raw st : typeStatements) + { + st.addToRawBuilder(typesBuilder); + } + return typesBuilder.build(); + } + + public static TableMetadata buildTableMetadata(String keyspace, + String createStmt, + Types types, + Partitioner partitioner, + @Nullable UUID tableId, + boolean enableCdc) + { + TableMetadata.Builder builder = CQLFragmentParser.parseAny(CqlParser::createTableStatement, createStmt, "CREATE TABLE") + .keyspace(keyspace) + .prepare(null) + .builder(types, UserFunctions.none()) + .partitioner(CassandraTypesImplementation.getPartitioner(partitioner)); + + if (tableId != null) + { + builder.id(TableId.fromUUID(tableId)); + } + + TableMetadata tableMetadata = builder.build(); + if (tableMetadata.params.cdc == enableCdc) + { + return tableMetadata; + } + else + { + return tableMetadata.unbuild() + .params(tableMetadata.params.unbuild() + .cdc(enableCdc) + .build()) + .build(); + } + } + + public static boolean keyspaceExists(SchemaProvider schema, String keyspace) + { + return getKeyspace(schema, keyspace).isPresent(); + } + + public static boolean tableExists(SchemaProvider schema, String keyspace, String table) + { + return getTable(schema, keyspace, table).isPresent(); + } + + public static Optional getKeyspace(SchemaProvider schema, String keyspace) + { + return Optional.ofNullable(schema.getKeyspaceInstance(keyspace)); + } + + public static Optional getKeyspaceMetadata(SchemaProvider schema, String keyspace) + { + return getKeyspace(schema, keyspace).map(Keyspace::getMetadata); + } + + public static Optional getTable(String keyspace, String table) + { + return getTable(Schema.instance, keyspace, table); + } + + public static Optional getTable(SchemaProvider schema, String keyspace, String table) + { + return Optional.ofNullable(schema.getTableMetadata(keyspace, table)); + } + + public static boolean has(SchemaProvider schema, CqlTable cqlTable) + { + return has(schema, cqlTable.keyspace(), cqlTable.table()); + } + + public static boolean has(SchemaProvider schema, String keyspace, String table) + { + return keyspaceExists(schema, keyspace) && tableExists(schema, keyspace, table); + } + + // cdc + + public static boolean isCdcEnabled(SchemaProvider schema, CqlTable cqlTable) + { + return isCdcEnabled(schema, cqlTable.keyspace(), cqlTable.table()); + } + + public static boolean isCdcEnabled(String keyspace, String table) + { + return isCdcEnabled(Schema.instance, keyspace, table); + } + + public static boolean isCdcEnabled(SchemaProvider schema, String keyspace, String table) + { + KeyspaceMetadata ks = schema.getKeyspaceMetadata(keyspace); + if (ks == null) + { + return false; + } + TableMetadata tb = ks.getTableOrViewNullable(table); + return tb != null && tb.params.cdc; + } + + // maps keyspace -> set of table names + public static Map> cdcEnabledTables(SchemaProvider schema) + { + return new SchemaBridge(schema) + .getKeyspaces() + .stream() + .collect(Collectors.toMap(Function.identity(), + keyspace -> cdcEnabledTables(schema, keyspace))); + } + + public static Set cdcEnabledTables(SchemaProvider schema, String keyspace) + { + return Objects.requireNonNull(schema.getKeyspaceMetadata(keyspace)) + .tables.stream() + .filter(t -> t.params.cdc) + .map(f -> f.name) + .collect(Collectors.toSet()); + } + + public static void updateCdcSchema(@NotNull Set cdcTables, + @NotNull Partitioner partitioner, + @NotNull TableIdLookup tableIdLookup) + { + updateCdcSchema(Schema.instance, cdcTables, partitioner, tableIdLookup); + } + + public static void maybeUpdateSchema(SchemaProvider schema, + Partitioner partitioner, + CqlTable cqlTable, + @Nullable UUID tableId, + boolean enableCdc) + { + String keyspace = cqlTable.keyspace(); + String table = cqlTable.table(); + Optional currTable = getTable(schema, keyspace, table); + if (!currTable.isPresent()) + { + throw notExistThrowable(keyspace, table); + } + + Set udts = cqlTable.udts() + .stream() + .map(f -> f.createStatement(CassandraTypesImplementation.INSTANCE, keyspace)) + .collect(Collectors.toSet()); + TableMetadata updatedTable = buildTableMetadata(keyspace, + cqlTable.createStatement(), + buildTypes(keyspace, udts), + partitioner, + tableId != null ? tableId : currTable.get().id.asUUID(), + enableCdc); + if (updatedTable.equals(currTable.get())) + { + // no changes + return; + } + + update(s -> { + Optional ks = getKeyspaceMetadata(s, keyspace); + Optional tableOpt = getTable(s, keyspace, table); + if (!ks.isPresent() || !tableOpt.isPresent()) + { + throw notExistThrowable(keyspace, table); + } + if (updatedTable.equals(tableOpt.get())) + { + // no changes + return; + } + + LOGGER.info("Schema change detected, updating table schema keyspace={} table={} cdc={}", keyspace, cqlTable.table(), enableCdc); + SchemaUpdater.updateTable(s, ks.get(), updatedTable); + }); + } + + public static void updateCdcSchema(@NotNull SchemaProvider schema, + @NotNull Set cdcTables, + @NotNull Partitioner partitioner, + @NotNull TableIdLookup tableIdLookup) + { + if (LOGGER.isDebugEnabled()) + { + LOGGER.debug("Updating CDC schema tables='{}'", + cdcTables.stream() + .map(t -> String.format("%s.%s", t.keyspace(), t.table())) + .collect(Collectors.joining(","))); + } + + Set currentlyCdcEnabled = currentlyCdcEnabledTables(schema); + + for (CqlTable table : cdcTables) + { + table.udts().forEach(udt -> CassandraTypesImplementation.INSTANCE.updateUDTs(table.keyspace(), udt)); + + UUID tableId = tableIdLookup.lookup(table.keyspace(), table.table()); + boolean previouslyCdcEnabled = currentlyCdcEnabled.contains(TableIdentifier.of(table.keyspace(), table.table())); + if (previouslyCdcEnabled) + { + // maybeUpdateSchema logs on its own when it actually performs an update. + CassandraSchema.maybeUpdateSchema(schema, partitioner, table, tableId, table.cdc()); + } + else if (CassandraSchema.has(schema, table)) + { + // table exists but wasn't tracked as cdc-enabled (e.g. a non-CDC table the + // caller included in cdcTables anyway) — update if schema changed. + CassandraSchema.maybeUpdateSchema(schema, partitioner, table, tableId, table.cdc()); + } + else + { + // new table — register with the CDC flag from the create statement + LOGGER.info("Registering new table keyspace={} table={} cdc={}", table.keyspace(), table.table(), table.cdc()); + new SchemaBuilder(table, partitioner, tableId, table.cdc()); + if (tableId != null && table.cdc()) + { + // verify CDC-enabled tables are correctly initialized + TableId tableIdAfter = TableId.fromUUID(tableId); + Preconditions.checkNotNull(schema.getTableMetadata(tableIdAfter), "Table not initialized in the schema"); + Preconditions.checkArgument(Objects.requireNonNull(schema.getKeyspaceInstance(table.keyspace())).hasColumnFamilyStore(tableIdAfter), + "ColumnFamilyStore not initialized in the schema"); + Preconditions.checkArgument(CassandraSchema.isCdcEnabled(schema, table), + "CDC not enabled for table: " + table.keyspace() + "." + table.table()); + } + } + } + disableCdcOnStaleTables(schema, currentlyCdcEnabled, cdcTables); + } + + private static Set currentlyCdcEnabledTables(SchemaProvider schema) + { + return CassandraSchema.cdcEnabledTables(schema) + .entrySet() + .stream() + .flatMap(e -> e.getValue().stream().map(table -> TableIdentifier.of(e.getKey(), table))) + .collect(Collectors.toSet()); + } + + /** + * Disables CDC on every table in {@code currentlyCdcEnabled} that is not CDC-enabled in + * {@code cdcTables} (dropped, or CDC disabled in its CREATE TABLE). + */ + private static void disableCdcOnStaleTables(SchemaProvider schema, Set currentlyCdcEnabled, Set cdcTables) + { + Set stillCdcEnabled = cdcTables.stream() + .filter(CqlTable::cdc) + .map(t -> TableIdentifier.of(t.keyspace(), t.table())) + .collect(Collectors.toSet()); + Set stale = new HashSet<>(currentlyCdcEnabled); + stale.removeAll(stillCdcEnabled); + + stale.forEach(id -> { + LOGGER.warn("Disabling CDC on table keyspace={} table={}", id.keyspace(), id.table()); + CassandraSchema.disableCdc(schema, id.keyspace(), id.table()); + }); + } + + /** + * Removes tables from {@code Schema.instance} that were previously registered via + * {@link #updateCdcSchema} but are no longer needed — e.g. a non-CDC table that no longer + * shares partition-key structure with any CDC-enabled table in its keyspace after a schema + * change. See {@code CdcBridge#unregisterNonCdcTables} for the full rationale. + * + *

Idempotent: a table not currently registered is silently skipped. Refuses (skips, with + * a warning) to unregister any table that is currently CDC-enabled — the caller is + * responsible for only requesting removal of tables it has determined are safe, but this is + * a last-line defense against silently dropping schema CDC still needs. + * + * @param tables the tables to unregister + */ + public static void unregisterNonCdcTables(@NotNull Set tables) + { + unregisterNonCdcTables(Schema.instance, tables); + } + + public static void unregisterNonCdcTables(@NotNull SchemaProvider schema, @NotNull Set tables) + { + for (TableIdentifier id : tables) + { + String keyspace = id.keyspace(); + String table = id.table(); + try + { + unregisterNonCdcTable(schema, keyspace, table); + } + catch (RuntimeException e) + { + // Don't let one bad table abort unregistration of the rest of the batch. + LOGGER.warn("Failed to unregister table keyspace={} table={}", keyspace, table, e); + } + } + } + + private static void unregisterNonCdcTable(@NotNull SchemaProvider schema, @NotNull String keyspace, @NotNull String table) + { + Optional tableMetadata = getTable(schema, keyspace, table); + if (!tableMetadata.isPresent()) + { + // already unregistered (or never was) — nothing to do + return; + } + + if (tableMetadata.get().params.cdc) + { + LOGGER.warn("Refusing to unregister CDC-enabled table keyspace={} table={}", keyspace, table); + return; + } + + update(s -> { + Optional ks = getKeyspaceMetadata(s, keyspace); + Optional tableOpt = getTable(s, keyspace, table); + if (!ks.isPresent() || !tableOpt.isPresent()) + { + // unregistered by a concurrent call, or keyspace itself is gone + return; + } + if (tableOpt.get().params.cdc) + { + // became CDC-enabled since the check above (race with a concurrent + // updateCdcSchema) — do not remove it + return; + } + + LOGGER.info("Unregistering table no longer at risk of a batch with a CDC-enabled table keyspace={} table={}", keyspace, table); + // Removes the table's schema metadata, so deserialization throws UnknownTableException again + SchemaUpdater.removeTables(s, ks.get().withSwapped(ks.get().tables.without(table))); + }); + } + + public static void enableCdc(SchemaProvider schema, CqlTable cqlTable) + { + enableCdc(schema, cqlTable.keyspace(), cqlTable.table()); + } + + public static void enableCdc(SchemaProvider schema, + String keyspace, + String table) + { + updateCdc(schema, keyspace, table, true); + } + + public static void disableCdc(SchemaProvider schema, CqlTable cqlTable) + { + disableCdc(schema, cqlTable.keyspace(), cqlTable.table()); + } + + public static void disableCdc(SchemaProvider schema, + String keyspace, + String table) + { + updateCdc(schema, keyspace, table, false); + } + + public static void updateCdc(SchemaProvider schema, + String keyspace, + String table, + boolean enableCdc) + { + if (!has(schema, keyspace, table)) + { + throw new IllegalArgumentException("Keyspace/table not initialized: " + keyspace + "/" + table); + } + + Optional tb = getTable(schema, keyspace, table); + if (!tb.isPresent()) + { + throw notExistThrowable(keyspace, table); + } + if (tb.get().params.cdc == enableCdc) + { + // nothing to update + return; + } + + update(s -> { + Optional ks = getKeyspaceMetadata(s, keyspace); + Optional tableOpt = getTable(s, keyspace, table); + if (!ks.isPresent() || !tableOpt.isPresent()) + { + throw notExistThrowable(keyspace, table); + } + if (tableOpt.get().params.cdc == enableCdc) + { + // nothing to update + return; + } + + TableMetadata updatedTable = tableOpt.get().unbuild() + .params(tableOpt.get().params.unbuild().cdc(enableCdc).build()) + .build(); + + LOGGER.info("{} CDC for table keyspace={} table={}", + updatedTable.params.cdc ? "Enabling" : "Disabling", keyspace, table); + SchemaUpdater.updateTable(s, ks.get(), updatedTable); + }); + } + + private static IllegalStateException notExistThrowable(String keyspace, String table) + { + return new IllegalStateException("Keyspace/table doesn't exist: " + keyspace + "/" + table); + } +} diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/CassandraTypesImplementation.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/CassandraTypesImplementation.java new file mode 100644 index 000000000..6687c4e48 --- /dev/null +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/CassandraTypesImplementation.java @@ -0,0 +1,124 @@ +/* + * 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.bridge; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import com.esotericsoftware.kryo.io.Input; +import org.apache.cassandra.config.Config; +import org.apache.cassandra.config.DataStorageSpec; +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.db.Keyspace; +import org.apache.cassandra.db.commitlog.CommitLogSegmentManagerStandard; +import org.apache.cassandra.dht.Murmur3Partitioner; +import org.apache.cassandra.locator.SimpleSnitch; +import org.apache.cassandra.locator.SnitchAdapter; +import org.apache.cassandra.security.EncryptionContext; +import org.apache.cassandra.spark.data.CqlField; +import org.apache.cassandra.spark.data.complex.CqlVector; +import org.apache.cassandra.tcm.ClusterMetadataService; + +public class CassandraTypesImplementation extends AbstractCassandraTypes +{ + public static final CassandraTypesImplementation INSTANCE = new CassandraTypesImplementation(); + + public static synchronized void setup(BridgeInitializationParameters params) + { + if (!CassandraTypesImplementation.setup) + { + // We never want to enable mbean registration in the Cassandra code we use so disable it here + System.setProperty("org.apache.cassandra.disable_mbean_registration", "true"); + System.setProperty("cassandra.schema.force_load_local_keyspaces", "true"); + Config.setClientMode(true); + // When we create a TableStreamScanner, we will set the partitioner directly on the table metadata + // using the supplied IIndexStreamScanner.Partitioner. CFMetaData::compile requires a partitioner to + // be set in DatabaseDescriptor before we can do that though, so we set one here in preparation. + // Cassandra 6.0 makes setPartitionerUnsafe also build the StorageService value factory, whose + // reads an SSTable format that no configuration has supplied yet. setOnlyPartitionerUnsafe + // is the 5.0 body: the partitioner and its name, nothing else. + DatabaseDescriptor.setOnlyPartitionerUnsafe(Murmur3Partitioner.instance); + Config config = new Config(); + config.memtable_flush_writers = 8; + config.diagnostic_events_enabled = false; + config.max_mutation_size = new DataStorageSpec.IntKibibytesBound(config.commitlog_segment_size.toKibibytes() / 2); + config.concurrent_compactors = 4; + config.sstable.selected_format = params.getConfiguredSSTableFormat(); + Path tempDirectory; + try + { + tempDirectory = Files.createTempDirectory(UUID.randomUUID().toString()); + } + catch (IOException exception) + { + throw new RuntimeException(exception); + } + config.data_file_directories = new String[]{tempDirectory.toString()}; + DatabaseDescriptor.clientInitialization(true, () -> config); + // Cassandra 6.0 commits every schema change through Transactional Cluster Metadata, so an offline + // caller must start the local-only metadata service first, as 6.0's CQLSSTableWriter does + ClusterMetadataService.initializeForClients(); + setupCommitLogConfigs(tempDirectory); + // Cassandra 6.0 removes setEndpointSnitch; a snitch is now one NodeProximity implementation. The + // server-side equivalent, applySnitch(), starts StorageService, so a client must not call it. + DatabaseDescriptor.setNodeProximity(new SnitchAdapter(new SimpleSnitch())); + Keyspace.setInitialized(); + setup = true; + } + } + + protected static void setupCommitLogConfigs(Path path) + { + Path commitLogPath = path.resolve("commitlog"); + DatabaseDescriptor.getRawConfig().commitlog_directory = commitLogPath.toString(); + DatabaseDescriptor.getRawConfig().hints_directory = path.resolve("hints").toString(); + DatabaseDescriptor.getRawConfig().saved_caches_directory = path.resolve("saved_caches").toString(); + // Cassandra 6.0 adds an Accord journal directory, which createAllDirectories() rejects when null. + // CommitLog. calls createAllDirectories(), and only applySimpleConfig() supplies the default, + // which a client never calls, so name one beside the commit log. + DatabaseDescriptor.getRawConfig().accord.journal_directory = path.resolve("accord_journal").toString(); + DatabaseDescriptor.setCommitLogSync(Config.CommitLogSync.periodic); + DatabaseDescriptor.setEncryptionContext(new EncryptionContext()); + DatabaseDescriptor.setCommitLogSyncPeriod(30); + DatabaseDescriptor.setCommitLogMaxCompressionBuffersPerPool(3); + DatabaseDescriptor.setCommitLogSyncGroupWindow(30); + DatabaseDescriptor.setCommitLogSegmentSize(32); + DatabaseDescriptor.getRawConfig().commitlog_total_space = new DataStorageSpec.IntMebibytesBound(1024); + DatabaseDescriptor.setCommitLogSegmentMgrProvider(commitLog -> new CommitLogSegmentManagerStandard(commitLog, commitLogPath.toString())); + } + + @Override + public CqlField.CqlType readType(CqlField.CqlType.InternalType type, Input input) + { + if (type == CqlField.CqlType.InternalType.Vector) + { + return CqlVector.read(input, this); + } + return super.readType(type, input); + } + + @Override + public CqlField.CqlVector vector(CqlField.CqlType type, int dimensions) + { + return new CqlVector(type, dimensions); + } +} diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaBridge.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaBridge.java new file mode 100644 index 000000000..a3190e41e --- /dev/null +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaBridge.java @@ -0,0 +1,39 @@ +/* + * 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.bridge; + +import java.util.Set; + +import org.apache.cassandra.schema.SchemaProvider; + +public class SchemaBridge +{ + private final SchemaProvider schema; + + public SchemaBridge(SchemaProvider schema) + { + this.schema = schema; + } + + public Set getKeyspaces() + { + return schema.getKeyspaces(); + } +} diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaUpdater.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaUpdater.java new file mode 100644 index 000000000..5802d4de3 --- /dev/null +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/bridge/SchemaUpdater.java @@ -0,0 +1,129 @@ +/* + * 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.bridge; + +import org.apache.cassandra.schema.DistributedSchema; +import org.apache.cassandra.schema.KeyspaceMetadata; +import org.apache.cassandra.schema.Keyspaces; +import org.apache.cassandra.schema.SchemaProvider; +import org.apache.cassandra.schema.SchemaTransformation; +import org.apache.cassandra.schema.SchemaTransformations; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.schema.Types; +import org.apache.cassandra.tcm.ClusterMetadata; +import org.apache.cassandra.tcm.serialization.Version; + +/** + * Cassandra 6.0 replaces {@code Schema.transform} with {@code SchemaProvider.submit}, which commits through + * Transactional Cluster Metadata. 6.0 also adds {@code compatibleWith(ClusterMetadata)} to + * {@code SchemaTransformation}, so it is no longer a functional interface and {@link #updateTable} needs an + * anonymous class; its compatibility check copies the one {@link SchemaTransformations} uses. + */ +public class SchemaUpdater +{ + private SchemaUpdater() + { + } + + /** + * Commits a schema transformation, then creates the keyspace instances that the transformation adds. + * + *

A server builds those instances from {@code SchemaListener}, which every commit notifies. An offline + * caller runs {@code StubClusterMetadataService}, whose {@code commit} notifies no listener, so + * {@code getKeyspaceInstance} would return null for every keyspace. Cassandra 5.0 needed no such step, + * because {@code Keyspace.open} created the instance on demand through the removed + * {@code Schema.maybeAddKeyspaceInstance}. Call what the listener calls, with the listener's own arguments. + */ + public static ClusterMetadata submit(SchemaProvider schema, SchemaTransformation transformation) + { + ClusterMetadata before = ClusterMetadata.current(); + ClusterMetadata after = schema.submit(transformation); + after.schema.initializeKeyspaceInstances(before.schema, false); + return after; + } + + /** + * Creates a keyspace instance for every keyspace of the current cluster metadata. Use this only when a + * keyspace has metadata but no instance, which happens when code outside this class commits it; Cassandra + * 6.0's {@code Keyspace.openWithoutSSTables} only reads the instance and no longer creates it. + * + *

Passing an empty previous schema replaces every existing instance, dropping the column family stores + * it held, so prefer {@link #submit}, which keeps the instances of each commit. + */ + public static void openKeyspaceInstances() + { + ClusterMetadata.current().schema.initializeKeyspaceInstances(DistributedSchema.empty(), false); + } + + public static void load(SchemaProvider schema, KeyspaceMetadata keyspaceMetadata) + { + submit(schema, SchemaTransformations.addKeyspace(keyspaceMetadata, false)); + } + + public static void load(SchemaProvider schema, TableMetadata tableMetadata) + { + submit(schema, SchemaTransformations.addTable(tableMetadata, false)); + } + + public static void load(SchemaProvider schema, Types userTypes) + { + submit(schema, SchemaTransformations.addTypes(userTypes, true)); + } + + /** + * Replaces the metadata of an existing keyspace with metadata that holds fewer tables. + * + *

Cassandra 4.0's {@code Schema.load} added or reloaded, whereas {@link SchemaTransformations#addKeyspace} + * only adds and otherwise throws {@code AlreadyExistsException}, so a caller that means to replace needs a + * transformation of its own. {@link #submit} is also the wrong follow-up here: it reports the table in + * {@code Keyspaces.diff().altered}, which makes {@code DistributedSchema.initializeKeyspaceInstances} call + * {@code Keyspace.dropCf} and so initialize {@code CompactionManager}, which throws in client mode where + * concurrent_compactors is zero. Rebuild the instances from the committed metadata instead, which leaves the + * removed table without a column family store and touches no compaction machinery. + */ + public static void removeTables(SchemaProvider schema, KeyspaceMetadata keyspaceMetadata) + { + schema.submit(replace(keyspaceMetadata)); + openKeyspaceInstances(); + } + + public static void updateTable(SchemaProvider schema, KeyspaceMetadata keyspaceMetadata, TableMetadata tableMetadata) + { + submit(schema, replace(keyspaceMetadata.withSwapped(keyspaceMetadata.tables.withSwapped(tableMetadata)))); + } + + private static SchemaTransformation replace(KeyspaceMetadata keyspaceMetadata) + { + return new SchemaTransformation() + { + @Override + public Keyspaces apply(ClusterMetadata metadata) + { + return metadata.schema.getKeyspaces().withAddedOrUpdated(keyspaceMetadata); + } + + @Override + public boolean compatibleWith(ClusterMetadata metadata) + { + return metadata.directory.commonSerializationVersion.isAtLeast(Version.V0); + } + }; + } +} diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/CqlType.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/CqlType.java new file mode 100644 index 000000000..5fa0aceef --- /dev/null +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/CqlType.java @@ -0,0 +1,63 @@ +/* + * 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.data; + +import java.nio.ByteBuffer; +import java.util.concurrent.TimeUnit; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; + +import org.apache.cassandra.bridge.CassandraVersion; +import org.apache.cassandra.db.DeletionTime; +import org.apache.cassandra.db.rows.BufferCell; +import org.apache.cassandra.db.rows.CellPath; +import org.apache.cassandra.schema.ColumnMetadata; + +public abstract class CqlType extends AbstractCqlType +{ + @Override + public CassandraVersion version() + { + return CassandraVersion.SIXZERO; + } + + /** + * Tombstone the entire complex cell, i.e. non-frozen collection and UDT + */ + @VisibleForTesting + public void addComplexTombstone(org.apache.cassandra.db.rows.Row.Builder rowBuilder, + ColumnMetadata cd, + long deletionTime) + { + Preconditions.checkArgument(cd.isComplex(), "The method only works with complex columns"); + rowBuilder.addComplexDeletion(cd, DeletionTime.build(deletionTime, TimeUnit.MICROSECONDS.toSeconds(deletionTime))); + } + + public static BufferCell tombstone(ColumnMetadata column, long timestamp, long nowInSec, CellPath path) + { + return BufferCell.tombstone(column, timestamp, nowInSec, path); + } + + public static BufferCell expiring(ColumnMetadata column, long timestamp, int ttl, long nowInSec, ByteBuffer value, CellPath path) + { + return BufferCell.expiring(column, timestamp, ttl, nowInSec, value, path); + } +} diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/complex/AbstractCqlList.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/complex/AbstractCqlList.java new file mode 100644 index 000000000..ee439df19 --- /dev/null +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/complex/AbstractCqlList.java @@ -0,0 +1,130 @@ +/* + * 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.data.complex; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.apache.cassandra.bridge.CassandraVersion; +import org.apache.cassandra.cql3.functions.types.SettableByIndexData; +import org.apache.cassandra.db.marshal.AbstractType; +import org.apache.cassandra.db.marshal.ListType; +import org.apache.cassandra.db.rows.BufferCell; +import org.apache.cassandra.db.rows.CellPath; +import org.apache.cassandra.schema.ColumnMetadata; +import org.apache.cassandra.serializers.ListSerializer; +import org.apache.cassandra.serializers.TypeSerializer; +import org.apache.cassandra.spark.data.CqlField; +import org.apache.cassandra.spark.data.CqlType; +import org.apache.cassandra.spark.utils.RandomUtils; +import org.apache.cassandra.utils.TimeUUID; + +import static org.apache.cassandra.spark.data.CqlField.NO_TTL; + +/** + * A copy of the four-zero class. Cassandra 5.0 removed {@code UUIDGen.getTimeUUIDBytes()}, so the inherited + * four-zero class throws {@code NoSuchMethodError} for every list cell that carries a time to live; + * {@link TimeUUID.Generator} plus {@code TimeUUID.toBytes()} returns the same 16 bytes. + */ +public abstract class AbstractCqlList extends CqlCollection implements CqlField.CqlList +{ + public AbstractCqlList(CqlField.CqlType type) + { + super(type); + } + + @Override + public AbstractType dataType(boolean isMultiCell) + { + return ListType.getInstance(((CqlType) type()).dataType(), isMultiCell); + } + + @Override + public InternalType internalType() + { + return InternalType.List; + } + + @Override + @SuppressWarnings("unchecked") + public TypeSerializer serializer() + { + return (TypeSerializer) ListSerializer.getInstance(((CqlType) type()).serializer()); + } + + @Override + public String name() + { + return "list"; + } + + @Override + protected void setInnerValueInternal(SettableByIndexData udtValue, int position, Object value) + { + udtValue.setList(position, (List) value); + } + + @Override + public Object randomValue(int minCollectionSize) + { + return IntStream.range(0, RandomUtils.RANDOM.nextInt(16) + minCollectionSize) + .mapToObj(element -> type().randomValue(minCollectionSize)) + .collect(Collectors.toList()); + } + + @Override + public org.apache.cassandra.cql3.functions.types.DataType driverDataType(boolean isFrozen) + { + return org.apache.cassandra.cql3.functions.types.DataType.list(((CqlType) type()).driverDataType(isFrozen)); + } + + @Override + public Object convertForCqlWriter(Object value, CassandraVersion version, boolean isCollectionElement) + { + return ((List) value).stream() + .map(element -> type().convertForCqlWriter(element, version, true)) + .collect(Collectors.toList()); + } + + @Override + public void addCell(final org.apache.cassandra.db.rows.Row.Builder rowBuilder, + ColumnMetadata cd, + long timestamp, + int ttl, + long now, + Object value) + { + for (Object o : (List) value) + { + if (ttl != NO_TTL) + { + rowBuilder.addCell(CqlType.expiring(cd, timestamp, ttl, now, type().serialize(o), + CellPath.create(TimeUUID.Generator.nextTimeUUID().toBytes()))); + } + else + { + rowBuilder.addCell(BufferCell.live(cd, timestamp, type().serialize(o), randomCellPath())); + } + } + } + + protected abstract CellPath randomCellPath(); +} diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/complex/CqlTuple.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/complex/CqlTuple.java new file mode 100644 index 000000000..ecaaaa23a --- /dev/null +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/complex/CqlTuple.java @@ -0,0 +1,37 @@ +/* + * 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.data.complex; + +import org.apache.cassandra.bridge.CassandraVersion; +import org.apache.cassandra.spark.data.CqlField; + +public class CqlTuple extends AbstractCqlTuple +{ + CqlTuple(CqlField.CqlType... types) + { + super(types); + } + + @Override + protected CassandraVersion getCassandraVersion() + { + return CassandraVersion.SIXZERO; + } +} diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/complex/CqlUdt.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/complex/CqlUdt.java new file mode 100644 index 000000000..5925dcfbc --- /dev/null +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/complex/CqlUdt.java @@ -0,0 +1,438 @@ +/* + * 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.data.complex; + +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.google.common.base.Preconditions; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import org.apache.cassandra.bridge.CassandraVersion; +import org.apache.cassandra.cql3.functions.types.SettableByIndexData; +import org.apache.cassandra.cql3.functions.types.UDTValue; +import org.apache.cassandra.cql3.functions.types.UserType; +import org.apache.cassandra.cql3.functions.types.UserTypeHelper; +import org.apache.cassandra.db.marshal.AbstractType; +import org.apache.cassandra.schema.Schema; +import org.apache.cassandra.serializers.TypeSerializer; +import org.apache.cassandra.serializers.UTF8Serializer; +import org.apache.cassandra.spark.data.CassandraTypes; +import org.apache.cassandra.spark.data.CqlField; +import org.apache.cassandra.spark.data.CqlType; +import org.apache.cassandra.spark.data.TypeConverter; +import org.apache.cassandra.spark.utils.ByteBufferUtils; +import org.apache.cassandra.transport.ProtocolVersion; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A copy of the four-zero class, unchanged except for this comment. Cassandra 6.0 retypes + * {@code Schema.instance} to {@code SchemaProvider}, and a {@code getstatic} encodes the field type, so the + * inherited four-zero class throws {@code NoSuchFieldError: instance}. This copy recompiles the two reads. + */ +public class CqlUdt extends CqlType implements CqlField.CqlUdt +{ + private final String keyspace; + private final String name; + private final List fields; + private final Map fieldMap; + private final int hashCode; + + CqlUdt(String keyspace, String name, List fields) + { + this.keyspace = keyspace; + this.name = name; + this.fields = Collections.unmodifiableList(fields); + this.fieldMap = this.fields.stream().collect(Collectors.toMap(CqlField::name, Function.identity())); + this.hashCode = Objects.hash(internalType().ordinal(), this.keyspace, this.name, this.fields); + } + + @Override + public Set udts() + { + Set udts = fields.stream() + .map(CqlField::type) + .map(type -> (CqlType) type) + .map(CqlField.CqlType::udts) + .flatMap(Collection::stream) + .collect(Collectors.toSet()); + udts.add(this); + return udts; + } + + @Override + public Object randomValue(int minCollectionSize) + { + return fields().stream() + .collect(Collectors.toMap(CqlField::name, field -> Objects.requireNonNull(field.type().randomValue(minCollectionSize)))); + } + + @Override + protected void setInnerValueInternal(SettableByIndexData udtValue, int position, Object value) + { + udtValue.setUDTValue(position, (UDTValue) value); + } + + @Override + public org.apache.cassandra.cql3.functions.types.DataType driverDataType(boolean isFrozen) + { + return UserTypeHelper.newUserType( + keyspace(), + name(), + isFrozen, + fields().stream() + .map(field -> UserTypeHelper.newField(field.name(), + ((CqlType) field.type()).driverDataType(isFrozen))) + .collect(Collectors.toList()), + ProtocolVersion.V3); + } + + @Override + public Object convertForCqlWriter(@NotNull Object value, CassandraVersion version, boolean isCollectionElement) + { + if (value instanceof UDTValue) + { + return value; + } + return toUserTypeValue(version, this, value); + } + + @Override + public String toString() + { + return cqlName(); + } + + public CqlFrozen frozen() + { + return CqlFrozen.build(this); + } + + public static Builder builder(String keyspace, String name) + { + return new Builder(keyspace, name); + } + + public static class Builder implements CqlField.CqlUdtBuilder + { + private final String keyspace; + private final String name; + private final List fields = new ArrayList<>(); + + public Builder(String keyspace, String name) + { + this.keyspace = keyspace; + this.name = name; + } + + @Override + public Builder withField(String name, CqlField.CqlType type) + { + fields.add(new CqlField(false, false, false, name, type, fields.size())); + return this; + } + + @Override + public CqlUdt build() + { + return new CqlUdt(keyspace, name, fields); + } + } + + @Override + public boolean isSupported() + { + return true; + } + + @Override + public AbstractType dataType() + { + return dataType(true); + } + + @Override + public AbstractType dataType(boolean isMultiCell) + { + // Get UserTypeSerializer from Schema instance to ensure fields are deserialized in correct order + return Schema.instance.getKeyspaceMetadata(keyspace()).types + .get(UTF8Serializer.instance.serialize(name())) + .orElseThrow(() -> new RuntimeException(String.format("UDT '%s' not initialized", name()))); + } + + @Override + @SuppressWarnings("unchecked") + public TypeSerializer serializer() + { + // Get UserTypeSerializer from Schema instance to ensure fields are deserialized in correct order + return (TypeSerializer) Schema.instance.getKeyspaceMetadata(keyspace()).types + .get(UTF8Serializer.instance.serialize(name())) + .orElseThrow(() -> new RuntimeException(String.format("UDT '%s' not initialized", name()))) + .getSerializer(); + } + + @Override + public Object deserializeToType(TypeConverter converter, ByteBuffer buffer, boolean isFrozen) + { + Object value = deserializeUdt(converter, buffer, isFrozen); + return value != null ? converter.convert(this, value, isFrozen) : null; + } + + @Override + public Map deserializeUdt(TypeConverter typeConverter, ByteBuffer buffer, boolean isFrozen) + { + if (!isFrozen) + { + int fieldCount = buffer.getInt(); + Preconditions.checkArgument(fieldCount == size(), + String.format("Unexpected number of fields deserializing UDT '%s', expected %d fields but %d found", + cqlName(), size(), fieldCount)); + } + + Map result = new LinkedHashMap<>(size()); + for (CqlField field : fields()) + { + if (buffer.remaining() < 4) + { + break; + } + int length = buffer.getInt(); + result.put(field.name(), length > 0 ? field.deserializeToType(typeConverter, ByteBufferUtils.readBytes(buffer, length), isFrozen) : null); + } + + return result; + } + + @Override + @SuppressWarnings("unchecked") + public ByteBuffer serialize(Object value) + { + return serializeUdt((Map) value); + } + + @Override + public ByteBuffer serializeUdt(Map values) + { + List buffers = fields().stream() + .map(field -> field.serialize(values.get(field.name()))) + .collect(Collectors.toList()); + + ByteBuffer result = ByteBuffer.allocate(4 + buffers.stream() + .map(Buffer::remaining) + .map(remaining -> remaining + 4) + .reduce(Integer::sum) + .orElse(0)); + result.putInt(buffers.size()); // Number of fields + for (ByteBuffer buffer : buffers) + { + result.putInt(buffer.remaining()); // Length + result.put(buffer.duplicate()); // Value + } + // Cast to ByteBuffer required when compiling with Java 8 + return (ByteBuffer) result.flip(); + } + + public InternalType internalType() + { + return InternalType.Udt; + } + + @Override + public String createStatement(CassandraTypes cassandraTypes, String keyspace) + { + return String.format("CREATE TYPE %s.%s (%s);", + cassandraTypes.maybeQuoteIdentifier(keyspace), + cassandraTypes.maybeQuoteIdentifier(name), + fieldsString(cassandraTypes)); + } + + private String fieldsString(CassandraTypes cassandraTypes) + { + return fields.stream() + .map(field -> fieldString(cassandraTypes, field)) + .collect(Collectors.joining(", ")); + } + + private static String fieldString(CassandraTypes cassandraTypes, CqlField field) + { + return String.format("%s %s", cassandraTypes.maybeQuoteIdentifier(field.name()), field.type().cqlName()); + } + + public String keyspace() + { + return keyspace; + } + + public String name() + { + return name; + } + + public int size() + { + return fields.size(); + } + + public List fields() + { + return fields; + } + + public CqlField field(String name) + { + return fieldMap.get(name); + } + + public CqlField field(int position) + { + return fields.get(position); + } + + public CqlField.CqlType type(int position) + { + return field(position).type(); + } + + public String cqlName() + { + return name; + } + + public static CqlUdt read(Input input, CassandraTypes cassandraTypes) + { + Builder builder = CqlUdt.builder(input.readString(), input.readString()); + int numFields = input.readInt(); + for (int field = 0; field < numFields; field++) + { + builder.withField(input.readString(), CqlField.CqlType.read(input, cassandraTypes)); + } + return builder.build(); + } + + @Override + public void write(Output output) + { + CqlField.CqlType.write(this, output); + output.writeString(this.keyspace); + output.writeString(this.name); + output.writeInt(this.fields.size()); + for (CqlField field : this.fields) + { + output.writeString(field.name()); + field.type().write(output); + } + } + + @Override + public int hashCode() + { + return hashCode; + } + + @Override + public boolean equals(Object other) + { + if (other == null) + { + return false; + } + if (this == other) + { + return true; + } + if (this.getClass() != other.getClass()) + { + return false; + } + + CqlUdt that = (CqlUdt) other; + return this.internalType() == that.internalType() + && Objects.equals(this.keyspace, that.keyspace) + && Objects.equals(this.name, that.name) + && Objects.equals(this.fields, that.fields); + } + + public static class Serializer extends com.esotericsoftware.kryo.Serializer + { + private final CassandraTypes cassandraTypes; + + public Serializer(CassandraTypes cassandraTypes) + { + this.cassandraTypes = cassandraTypes; + } + + @Override + public CqlUdt read(Kryo kryo, Input input, Class type) + { + return CqlUdt.read(input, cassandraTypes); + } + + @Override + public void write(Kryo kryo, Output output, CqlUdt udt) + { + udt.write(output); + } + } + + @SuppressWarnings("unchecked") + public static UDTValue toUserTypeValue(CassandraVersion version, CqlUdt udt, @NotNull Object value) + { + Map values = (Map) value; + UDTValue udtValue = UserTypeHelper.newUDTValue(toUserType(udt)); + int position = 0; + for (CqlField field : udt.fields()) + { + setNullableInnerValue(version, udtValue, (CqlType) field.type(), position++, values.get(field.name())); + } + return udtValue; + } + + // Set inner value for UDTs or Tuples + public static void setNullableInnerValue(CassandraVersion version, + SettableByIndexData udtValue, + CqlType type, + int position, + @Nullable Object value) + { + type.setInnerValue(udtValue, position, value == null ? null : type.convertForCqlWriter(value, version, false)); + } + + public static UserType toUserType(CqlUdt udt) + { + List fields = udt.fields().stream() + .map(field -> UserTypeHelper.newField(field.name(), + ((CqlType) field.type()).driverDataType())) + .collect(Collectors.toList()); + return UserTypeHelper.newUserType(udt.keyspace(), udt.name(), true, fields, ProtocolVersion.V3); + } +} diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/types/AbstractTimeUUID.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/types/AbstractTimeUUID.java new file mode 100644 index 000000000..b48e5a46b --- /dev/null +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/data/types/AbstractTimeUUID.java @@ -0,0 +1,56 @@ +/* + * 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.data.types; + +import org.apache.cassandra.cql3.functions.types.DataType; +import org.apache.cassandra.db.marshal.AbstractType; +import org.apache.cassandra.db.marshal.TimeUUIDType; +import org.apache.cassandra.utils.TimeUUID; + +/** + * A copy of the four-zero class. Cassandra 5.0 removed {@code UUIDGen.getTimeUUID()}, so the inherited + * four-zero class throws {@code NoSuchMethodError}; {@link TimeUUID.Generator} replaces it. + */ +public abstract class AbstractTimeUUID extends UUID +{ + @Override + public String name() + { + return "timeuuid"; + } + + @Override + public AbstractType dataType() + { + return TimeUUIDType.instance; + } + + @Override + public Object randomValue(int minCollectionSize) + { + return TimeUUID.Generator.nextTimeAsUUID(); + } + + @Override + public DataType driverDataType(boolean isFrozen) + { + return DataType.timeuuid(); + } +} diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/reader/AbstractSchemaBuilder.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/reader/AbstractSchemaBuilder.java new file mode 100644 index 000000000..aa1c15e8d --- /dev/null +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/reader/AbstractSchemaBuilder.java @@ -0,0 +1,596 @@ +/* + * 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.reader; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.antlr.runtime.RecognitionException; +import org.apache.cassandra.bridge.CassandraSchema; +import org.apache.cassandra.bridge.CassandraTypesImplementation; +import org.apache.cassandra.bridge.SchemaUpdater; +import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.cql3.CQL3Type; +import org.apache.cassandra.cql3.CQLFragmentParser; +import org.apache.cassandra.cql3.CqlParser; +import org.apache.cassandra.cql3.statements.schema.CreateTableStatement; +import org.apache.cassandra.cql3.statements.schema.CreateTypeStatement; +import org.apache.cassandra.db.Keyspace; +import org.apache.cassandra.db.marshal.AbstractType; +import org.apache.cassandra.db.marshal.CollectionType; +import org.apache.cassandra.db.marshal.ListType; +import org.apache.cassandra.db.marshal.MapType; +import org.apache.cassandra.db.marshal.SetType; +import org.apache.cassandra.db.marshal.TupleType; +import org.apache.cassandra.db.marshal.UserType; +import org.apache.cassandra.dht.IPartitioner; +import org.apache.cassandra.schema.ColumnMetadata; +import org.apache.cassandra.schema.KeyspaceMetadata; +import org.apache.cassandra.schema.KeyspaceParams; +import org.apache.cassandra.schema.SchemaProvider; +import org.apache.cassandra.schema.TableId; +import org.apache.cassandra.schema.TableMetadata; +import org.apache.cassandra.schema.Types; +import org.apache.cassandra.schema.UserFunctions; +import org.apache.cassandra.spark.data.CassandraTypes; +import org.apache.cassandra.spark.data.CqlField; +import org.apache.cassandra.spark.data.CqlTable; +import org.apache.cassandra.spark.data.ReplicationFactor; +import org.apache.cassandra.spark.data.complex.CqlFrozen; +import org.apache.cassandra.spark.data.complex.CqlUdt; +import org.apache.cassandra.spark.data.partitioner.Partitioner; +import org.apache.cassandra.utils.Pair; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public abstract class AbstractSchemaBuilder +{ + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractSchemaBuilder.class); + + protected final TableMetadata metadata; + protected final KeyspaceMetadata keyspaceMetadata; + protected final String createStmt; + protected final String keyspace; + protected final ReplicationFactor replicationFactor; + protected final CassandraTypes cassandraTypes; + protected final int indexCount; + protected final boolean enableCdc; + + public AbstractSchemaBuilder(CqlTable table, Partitioner partitioner, boolean enableCdc) + { + this(table, partitioner, null, enableCdc); + } + + public AbstractSchemaBuilder(CqlTable table, Partitioner partitioner) + { + this(table, partitioner, null, table.cdc()); + } + + public AbstractSchemaBuilder(CqlTable table, Partitioner partitioner, UUID tableId, boolean enableCdc) + { + this(table.createStatement(), + table.keyspace(), + table.replicationFactor(), + partitioner, + table::udtCreateStmts, + tableId, + 0, + enableCdc); + } + + @VisibleForTesting + public AbstractSchemaBuilder(String createStmt, String keyspace, ReplicationFactor replicationFactor) + { + this(createStmt, keyspace, replicationFactor, Partitioner.Murmur3Partitioner, bridge -> Collections.emptySet(), null, 0, false); + } + + @VisibleForTesting + public AbstractSchemaBuilder(String createStmt, + String keyspace, + ReplicationFactor replicationFactor, + Partitioner partitioner) + { + this(createStmt, keyspace, replicationFactor, partitioner, bridge -> Collections.emptySet(), null, 0, false); + } + + public AbstractSchemaBuilder(String createStmt, + String keyspace, + ReplicationFactor replicationFactor, + Partitioner partitioner, + Function> udtStatementsProvider, + @Nullable UUID tableId, + int indexCount, + boolean enableCdc) + { + this.createStmt = createStmt; + this.keyspace = keyspace; + this.replicationFactor = replicationFactor; + this.cassandraTypes = new CassandraTypesImplementation(); + this.indexCount = indexCount; + this.enableCdc = enableCdc; + + Pair updated = CassandraSchema.apply(schema -> + updateSchema(schema, + this.keyspace, + udtStatementsProvider.apply(cassandraTypes), + this.createStmt, + partitioner, + this.replicationFactor, + tableId, enableCdc, + this::validateColumnMetaData)); + this.keyspaceMetadata = updated.left; + this.metadata = updated.right; + } + + // Update schema with the given keyspace, table and udt. + // It creates the corresponding metadata and opens instances for keyspace and table, if needed. + // At the end, it validates that the input keyspace and table both should have metadata exist and instance opened. + private static Pair updateSchema(SchemaProvider schema, + String keyspace, + Set udtStatements, + String createStatement, + Partitioner partitioner, + ReplicationFactor replicationFactor, + UUID tableId, + boolean enableCdc, + Consumer columnValidator) + { + // Set up and open keyspace if needed + IPartitioner cassPartitioner = CassandraTypesImplementation.getPartitioner(partitioner); + setupKeyspace(schema, keyspace, replicationFactor, cassPartitioner); + + // Set up and open table if needed, parse UDTs and include when parsing table schema + List typeStatements = new ArrayList<>(udtStatements.size()); + for (String udt : udtStatements) + { + try + { + typeStatements.add((CreateTypeStatement.Raw) CQLFragmentParser + .parseAnyUnhandled(CqlParser::query, udt)); + } + catch (RecognitionException exception) + { + LOGGER.error("Failed to parse type expression '{}'", udt); + throw new IllegalStateException(exception); + } + } + Types.RawBuilder typesBuilder = Types.rawBuilder(keyspace); + for (CreateTypeStatement.Raw st : typeStatements) + { + st.addToRawBuilder(typesBuilder); + } + Types types = typesBuilder.build(); + CreateTableStatement.Raw createTable = CQLFragmentParser.parseAny(CqlParser::createTableStatement, + createStatement, + "CREATE TABLE"); + // If the table already exists, the tableId should remain the same, unless a non-null tableId is supplied + TableMetadata maybeExistingTableMetadata = schema.getTableMetadata(keyspace, createTable.table()); + if (maybeExistingTableMetadata != null && tableId == null) + { + tableId = maybeExistingTableMetadata.id.asUUID(); + } + + TableMetadata.Builder builder = createTable + .keyspace(keyspace) + .prepare(null) + .builder(types, UserFunctions.none()) + .partitioner(cassPartitioner); + + if (tableId != null) + { + builder.id(TableId.fromUUID(tableId)); + } + + TableMetadata tableMetadata = builder.build(); + + if (tableMetadata.params.cdc != enableCdc) + { + tableMetadata = tableMetadata.unbuild() + .params(tableMetadata.params.unbuild() + .cdc(enableCdc) + .build()) + .build(); + } + + tableMetadata.columns().forEach(columnValidator); + setupTableAndUdt(schema, keyspace, tableMetadata, types); + + return validateKeyspaceTable(schema, keyspace, tableMetadata.name); + } + + private void validateColumnMetaData(@NotNull ColumnMetadata column) + { + validateType(column.type); + } + + protected void validateType(AbstractType type) + { + validateType(type.asCQL3Type()); + } + + protected void validateType(CQL3Type cqlType) + { + if (!(cqlType instanceof CQL3Type.Native) + && !(cqlType instanceof CQL3Type.Collection) + && !(cqlType instanceof CQL3Type.UserDefined) + && !(cqlType instanceof CQL3Type.Tuple)) + { + throw new UnsupportedOperationException("Only native, collection, tuples or UDT data types are supported, " + + "unsupported data type: " + cqlType.toString()); + } + + if (cqlType instanceof CQL3Type.Native) + { + CqlField.CqlType type = cassandraTypes.parseType(cqlType.toString()); + if (!type.isSupported()) + { + throw new UnsupportedOperationException(type.name() + " data type is not supported"); + } + } + else if (cqlType instanceof CQL3Type.Collection) + { + // Validate collection inner types + CQL3Type.Collection collection = (CQL3Type.Collection) cqlType; + CollectionType type = (CollectionType) collection.getType(); + switch (type.kind) + { + case LIST: + validateType(((ListType) type).getElementsType()); + return; + case SET: + validateType(((SetType) type).getElementsType()); + return; + case MAP: + validateType(((MapType) type).getKeysType()); + validateType(((MapType) type).getValuesType()); + return; + default: + // Do nothing + } + } + else if (cqlType instanceof CQL3Type.Tuple) + { + CQL3Type.Tuple tuple = (CQL3Type.Tuple) cqlType; + TupleType tupleType = (TupleType) tuple.getType(); + for (AbstractType subType : tupleType.allTypes()) + { + validateType(subType); + } + } + else + { + // Validate UDT inner types + UserType userType = (UserType) ((CQL3Type.UserDefined) cqlType).getType(); + for (AbstractType innerType : userType.fieldTypes()) + { + validateType(innerType); + } + } + } + + private static boolean keyspaceMetadataExists(SchemaProvider schema, String keyspaceName) + { + return schema.getKeyspaceMetadata(keyspaceName) != null; + } + + private static boolean tableMetadataExists(SchemaProvider schema, String keyspaceName, String tableName) + { + KeyspaceMetadata ksMetadata = schema.getKeyspaceMetadata(keyspaceName); + if (ksMetadata == null) + { + return false; + } + + return ksMetadata.hasTable(tableName); + } + + private static boolean keyspaceInstanceExists(SchemaProvider schema, String keyspaceName) + { + return schema.getKeyspaceInstance(keyspaceName) != null; + } + + private static boolean tableInstanceExists(SchemaProvider schema, String keyspaceName, String tableName) + { + Keyspace keyspace = schema.getKeyspaceInstance(keyspaceName); + if (keyspace == null) + { + return false; + } + + try + { + keyspace.getColumnFamilyStore(tableName); + } + catch (IllegalArgumentException exception) + { + LOGGER.info("Table instance does not exist. keyspace={} table={} existingCFS={}", + keyspace, tableName, keyspace.getColumnFamilyStores()); + return false; + } + return true; + } + + // Check whether keyspace metadata exists. Create keyspace metadata, if not. + // Check whether keyspace instance is opened. Open the keyspace, if not. + // NOTE: It is possible that external code that just creates metadata, but does not open the keyspace + private static void setupKeyspace(SchemaProvider schema, + String keyspaceName, + ReplicationFactor replicationFactor, + IPartitioner partitioner) + { + if (!keyspaceMetadataExists(schema, keyspaceName)) + { + LOGGER.info("Setting up keyspace metadata in schema keyspace={} rfStrategy={} partitioner={}", + keyspaceName, replicationFactor.getReplicationStrategy().name(), partitioner); + KeyspaceMetadata keyspaceMetadata = + KeyspaceMetadata.create(keyspaceName, KeyspaceParams.create(true, rfToMap(replicationFactor))); + SchemaUpdater.load(schema, keyspaceMetadata); + } + + if (!keyspaceInstanceExists(schema, keyspaceName)) + { + LOGGER.info("Setting up keyspace instance in schema keyspace={} rfStrategy={} partitioner={}", + keyspaceName, replicationFactor.getReplicationStrategy().name(), partitioner); + // Create keyspace instance and also initCf (cfs) for the table. Cassandra 6.0 holds the keyspace + // instances in the cluster metadata, which Keyspace.openWithoutSSTables only reads. + SchemaUpdater.openKeyspaceInstances(); + } + } + + // Check whether table metadata exists. Create table metadata, if not. + // Check whether table instance is opened. Open/init the table, if not. + // NOTE: It is possible that external code that just creates metadata, but does not open the table + private static void setupTableAndUdt(SchemaProvider schema, + String keyspaceName, + TableMetadata tableMetadata, + Types userTypes) + { + String tableName = tableMetadata.name; + KeyspaceMetadata keyspaceMetadata = schema.getKeyspaceMetadata(keyspaceName); + if (keyspaceMetadata == null) + { + LOGGER.error("Keyspace metadata does not exist. keyspace={}", keyspaceName); + throw new IllegalStateException("Keyspace metadata null for '" + keyspaceName + + "' when it should have been initialized already"); + } + + if (!tableMetadataExists(schema, keyspaceName, tableName)) + { + LOGGER.info("Setting up table metadata in schema keyspace={} table={} partitioner={}", + keyspaceName, tableName, tableMetadata.partitioner.getClass().getName()); + SchemaUpdater.load(schema, tableMetadata); + } + + if (!tableMetadata.equals(schema.getTableMetadata(keyspaceName, tableMetadata.name))) + { + // Schema of the table has changed so update it in the schema + updateTableMetaData(schema, keyspaceName, tableMetadata); + LOGGER.info("Table metadata changed schema keyspace={} table={} partitioner={}", + keyspaceName, tableName, tableMetadata.partitioner.getClass().getName()); + } + + // The metadata of the table might not be the input tableMetadata. Fetch the current to be safe. + TableMetadata currentTable = schema.getTableMetadata(keyspaceName, tableName); + if (!tableInstanceExists(schema, keyspaceName, tableName)) + { + LOGGER.info("Setting up table instance in schema keyspace={} table={} partitioner={}", + keyspaceName, tableName, tableMetadata.partitioner.getClass().getName()); + if (keyspaceInstanceExists(schema, keyspaceName)) + { + // initCf (cfs) in the opened keyspace. Cassandra 6.0 takes the TableMetadata rather than a + // TableMetadataRef, and adds addIndexes, for which DistributedSchema.createTable passes this. + schema.getKeyspaceInstance(keyspaceName) + .initCf(currentTable, false, DatabaseDescriptor.isClientOrToolInitialized()); + } + else + { + // The keyspace has not yet opened, create/open keyspace instance and also initCf (cfs) for the + // table. See SchemaUpdater.openKeyspaceInstances. + SchemaUpdater.openKeyspaceInstances(); + } + } + + if (!userTypes.equals(Types.none())) + { + LOGGER.info("Setting up user types in schema keyspace={} types={}", + keyspaceName, userTypes); + // Update Schema instance with any user-defined types built + SchemaUpdater.load(schema, userTypes); + } + } + + private static void updateTableMetaData(SchemaProvider schema, String keyspace, TableMetadata tableMetadata) + { + SchemaUpdater.updateTable(schema, schema.getKeyspaceMetadata(keyspace), tableMetadata); + } + + private static Pair validateKeyspaceTable(SchemaProvider schema, + String keyspaceName, + String tableName) + { + Preconditions.checkState(keyspaceMetadataExists(schema, keyspaceName), + "Keyspace metadata does not exist after building schema. keyspace=%s", + keyspaceName); + Preconditions.checkState(keyspaceInstanceExists(schema, keyspaceName), + "Keyspace instance is not opened after building schema. keyspace=%s", + keyspaceName); + Preconditions.checkState(tableMetadataExists(schema, keyspaceName, tableName), + "Table metadata does not exist after building schema. keyspace=%s table=%s", + keyspaceName, tableName); + Preconditions.checkState(tableInstanceExists(schema, keyspaceName, tableName), + "Table instance is not opened after building schema. keyspace=%s table=%s", + keyspaceName, tableName); + + // Validated above that keyspace and table, both exist and are opened + KeyspaceMetadata keyspaceMetadata = schema.getKeyspaceMetadata(keyspaceName); + TableMetadata tableMetadata = schema.getTableMetadata(keyspaceName, tableName); + return Pair.create(keyspaceMetadata, tableMetadata); + } + + public TableMetadata tableMetaData() + { + return metadata; + } + + public String createStatement() + { + return createStmt; + } + + public CqlTable build() + { + Map udts = buildsUdts(keyspaceMetadata); + List fields = buildFields(metadata, udts).stream().sorted().collect(Collectors.toList()); + return new CqlTable(keyspace, + metadata.name, + createStmt, + replicationFactor, + fields, + new HashSet<>(udts.values()), + indexCount, + enableCdc); + } + + private Map buildsUdts(KeyspaceMetadata keyspaceMetadata) + { + List userTypes = new ArrayList<>(); + keyspaceMetadata.types.forEach(userTypes::add); + Map udts = new HashMap<>(userTypes.size()); + while (!userTypes.isEmpty()) + { + UserType userType = userTypes.remove(0); + if (!AbstractSchemaBuilder.nestedUdts(userType).stream().allMatch(udts::containsKey)) + { + // This UDT contains a nested user-defined type that has not been parsed yet + // so re-add to the queue and parse later + userTypes.add(userType); + continue; + } + String name = userType.getNameAsString(); + CqlUdt.Builder builder = CqlUdt.builder(keyspaceMetadata.name, name); + for (int field = 0; field < userType.size(); field++) + { + builder.withField(userType.fieldName(field).toString(), + cassandraTypes.parseType(userType.fieldType(field).asCQL3Type().toString(), udts)); + } + udts.put(name, builder.build()); + } + + return udts; + } + + /** + * @param type an abstract type + * @return a set of UDTs nested within the type parameter + */ + private static Set nestedUdts(AbstractType type) + { + Set result = new HashSet<>(); + nestedUdts(type, result, false); + return result; + } + + private static void nestedUdts(AbstractType type, Set udts, boolean isNested) + { + if (type instanceof UserType) + { + if (isNested) + { + udts.add(((UserType) type).getNameAsString()); + } + for (AbstractType nestedType : ((UserType) type).fieldTypes()) + { + nestedUdts(nestedType, udts, true); + } + } + else if (type instanceof TupleType) + { + for (AbstractType nestedType : ((TupleType) type).allTypes()) + { + nestedUdts(nestedType, udts, true); + } + } + else if (type instanceof SetType) + { + nestedUdts(((SetType) type).getElementsType(), udts, true); + } + else if (type instanceof ListType) + { + nestedUdts(((ListType) type).getElementsType(), udts, true); + } + else if (type instanceof MapType) + { + nestedUdts(((MapType) type).getKeysType(), udts, true); + nestedUdts(((MapType) type).getValuesType(), udts, true); + } + } + + private List buildFields(TableMetadata metadata, Map udts) + { + Iterator it = metadata.allColumnsInSelectOrder(); + List result = new ArrayList<>(); + int position = 0; + while (it.hasNext()) + { + ColumnMetadata col = it.next(); + boolean isPartitionKey = col.isPartitionKey(); + boolean isClusteringColumn = col.isClusteringColumn(); + boolean isStatic = col.isStatic(); + String name = col.name.toString(); + CqlField.CqlType type = col.type.isUDT() ? udts.get(((UserType) col.type).getNameAsString()) + : cassandraTypes.parseType(col.type.asCQL3Type().toString(), udts); + boolean isFrozen = col.type.isFreezable() && !col.type.isMultiCell(); + result.add(new CqlField(isPartitionKey, + isClusteringColumn, + isStatic, + name, + !(type instanceof CqlFrozen) && isFrozen ? CqlFrozen.build(type) : type, + position)); + position++; + } + return result; + } + + static Map rfToMap(ReplicationFactor replicationFactor) + { + Map result = new HashMap<>(replicationFactor.getOptions().size() + 1); + result.put("class", "org.apache.cassandra.locator." + replicationFactor.getReplicationStrategy().name()); + for (Map.Entry entry : replicationFactor.getOptions().entrySet()) + { + result.put(entry.getKey(), Integer.toString(entry.getValue())); + } + return result; + } +} diff --git a/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/reader/ComplexTypeBuffer.java b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/reader/ComplexTypeBuffer.java new file mode 100644 index 000000000..62d9695a3 --- /dev/null +++ b/cassandra-six-zero-types/src/main/java/org/apache/cassandra/spark/reader/ComplexTypeBuffer.java @@ -0,0 +1,60 @@ +/* + * 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.reader; + +import java.nio.ByteBuffer; + +import org.apache.cassandra.db.marshal.ByteBufferAccessor; +import org.apache.cassandra.serializers.CollectionSerializer; + +/** + * ComplexTypeBuffer is a util class for reconstructing multi-cell data into complex types such as unfrozen lists, maps, sets, or UDTs. + * ComplexTypeBuffer buffers all the cell ByteBuffers then reconstructs as a single ByteBuffer. + */ +public abstract class ComplexTypeBuffer extends AbstractComplexTypeBuffer +{ + public ComplexTypeBuffer(int cellCount, int bufferSize) + { + super(cellCount, bufferSize); + } + + /** + * Cassandra 6.0 makes {@code CollectionSerializer.pack} an instance method that derives the element count + * from the collection. This class has no serializer instance, and {@link AbstractComplexTypeBuffer#elements()} + * already gives the count each subclass needs, so the body of {@code pack} is inlined here unchanged. + */ + public ByteBuffer pack() + { + int size = 0; + for (ByteBuffer buffer : buffers) + { + size += CollectionSerializer.sizeOfValue(buffer, ByteBufferAccessor.instance); + } + + ByteBuffer result = ByteBuffer.allocate(CollectionSerializer.sizeOfCollectionSize() + size); + result.putInt(elements()); + for (ByteBuffer buffer : buffers) + { + CollectionSerializer.writeValue(result, buffer, ByteBufferAccessor.instance); + } + // Cast to ByteBuffer required when compiling with Java 8 + return (ByteBuffer) result.flip(); + } +} diff --git a/cassandra-six-zero/build.gradle b/cassandra-six-zero/build.gradle new file mode 100644 index 000000000..7c7ddab8f --- /dev/null +++ b/cassandra-six-zero/build.gradle @@ -0,0 +1,70 @@ +import java.nio.file.Paths + +/* + * 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. + */ + +plugins { + id('java-library') + id('com.github.johnrengelman.shadow') +} + +dependencies { + implementation(group: 'org.apache.cassandra', name: 'cassandra-all', version: "$cassandra60Version") { + // Exclude JNA libraries from the cassandra-all dependency tree because Spark has its own version + // and trying to load two different versions causes issues with the native libraries + exclude(group: 'net.java.dev.jna') + exclude(group: 'org.xerial.snappy', module: 'snappy-java') + + // zstd causes dependency/classpath issues running in Spark if e.g. a user is using zstd to write compressed output file + // Spark already pulls in zstd so we can exclude here. + // NOTE: Cassandra 6.0 dictionary compression needs zstd-jni at runtime; it is supplied by Spark. + // Every zstd-jni method the read path calls exists from 1.5.0-4, which is the oldest version Spark + // ships. Cassandra itself declares 1.5.7-2, needed only by ZstdDictionaryTrainer, which trains a new + // dictionary and which no bridge calls. + exclude(group: 'com.github.luben', module: 'zstd-jni') + } +} + +jar { + enabled = false +} + +shadowJar { + archiveFileName = 'six-zero.jar' // Must match label in CassandraVersion (without extension) + zip64 = true + + exclude('META-INF/maven/org/apache/logging/**') + exclude('META-INF/maven/org.apache.logging.log4j/**') + exclude('META-INF/maven/org.slf4j/**') + exclude('com/fasterxml/jackson/**') + exclude('net/openhft/chronicle/wire/**') + exclude('org/jboss/byteman/**') + exclude('org/junit/**') + exclude('org/quicktheories/**') + exclude('org/slf4j/**') + exclude('org/apache/commons/lang3/**') + + // The four-zero and five-zero jars exclude com/google/common/collect/Range*, so that every bridge shares + // one Guava Range class with the caller. This jar keeps Range, because Cassandra 6.0 reads the + // package-private members of Range from Cut, TreeRangeSet, ImmutableRangeSet and CollectCollectors, on + // the path ClusteringElements.all -> ClusteringColumnRestrictions.slices, which the earlier versions do + // not have. Those classes exist only in a recent Guava, so the parent class loader cannot supply them, + // and a split between the two class loaders throws IllegalAccessError. No class of the six-zero bridge + // names Guava Range: the bridge API carries org.apache.cassandra.bridge.TokenRange for that reason. +} diff --git a/gradle.properties b/gradle.properties index eaa11965a..203b809b3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,6 +23,7 @@ description=Apache Cassandra Analytics analyticsJDKLevel=17 cassandra40Version=4.0.17 cassandra50Version=5.0.7 +cassandra60Version=6.0-alpha2 sidecarVersion=0.4.0 intellijVersion=9.0.4 junitVersion=5.10.2 diff --git a/scripts/build-dtest-jars.sh b/scripts/build-dtest-jars.sh index 6e2416be7..9757c6680 100755 --- a/scripts/build-dtest-jars.sh +++ b/scripts/build-dtest-jars.sh @@ -39,15 +39,16 @@ else # Due to MacOS being stuck on Bash < 4, we don't use associative arrays here. # # NOTE: The following branches need to stay in sync with the values in build.gradle: - # ext.cassandraVersionEnumMap = ["4.0": "FOURZERO", "4.1": "FOURONE", "5.0": "FIVEZERO"] - # ext.cassandraFullVersionMap = ["4.0": "4.0.17", "4.1": "4.1.4", "5.0": "5.0.7"] + # ext.cassandraVersionEnumMap = ["4.0": "FOURZERO", "4.1": "FOURONE", "5.0": "FIVEZERO", "6.0": "SIXZERO"] + # ext.cassandraFullVersionMap = ["4.0": "4.0.17", "4.1": "4.1.4", "5.0": "5.0.7", "6.0": "6.0-alpha2"] # NOTE: The following branches also need to remain in sync with CassandraVersion.java CANDIDATE_BRANCHES=( "cassandra-4.0:cassandra-4.0.17" "cassandra-4.1:99d9faeef57c9cf5240d11eac9db5b283e45a4f9" "cassandra-5.0:cassandra-5.0.7" + "cassandra-6.0:cassandra-6.0-alpha2" ) - BRANCHES=( ${BRANCHES:-cassandra-4.0 cassandra-4.1 cassandra-5.0} ) + BRANCHES=( ${BRANCHES:-cassandra-4.0 cassandra-4.1 cassandra-5.0 cassandra-6.0} ) echo ${BRANCHES[*]} REPO=${REPO:-"https://github.com/apache/cassandra.git"} SCRIPT_DIR=$( dirname -- "$( readlink -f -- "$0"; )"; ) diff --git a/settings.gradle b/settings.gradle index 86e917c95..cf1349449 100644 --- a/settings.gradle +++ b/settings.gradle @@ -31,6 +31,10 @@ include 'cassandra-five-zero' include 'cassandra-five-zero-avro-converter' include 'cassandra-five-zero-bridge' include 'cassandra-five-zero-types' +include 'cassandra-six-zero' +include 'cassandra-six-zero-avro-converter' +include 'cassandra-six-zero-bridge' +include 'cassandra-six-zero-types' include 'cassandra-analytics-core' include 'cassandra-analytics-core-example' include 'cassandra-analytics-integration-framework' @@ -39,6 +43,7 @@ include 'cassandra-analytics-sidecar-client' include 'cassandra-analytics-spark-converter' include 'cassandra-analytics-spark-four-zero-converter' include 'cassandra-analytics-spark-five-zero-converter' +include 'cassandra-analytics-spark-six-zero-converter' include 'cassandra-avro-converter' include 'cassandra-analytics-cdc-codec'