Skip to content

CASSANALYTICS-31 SAI index support in analytics - #220

Open
skoppu22 wants to merge 22 commits into
apache:trunkfrom
skoppu22:sai
Open

CASSANALYTICS-31 SAI index support in analytics#220
skoppu22 wants to merge 22 commits into
apache:trunkfrom
skoppu22:sai

Conversation

@skoppu22

@skoppu22 skoppu22 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Generate SAI index files as well while producing sstable files, so no need of async rebuilding of SAI indexes after bulk write.

Circle CI : https://app.circleci.com/pipelines/github/skoppu22/cassandra-analytics/148/workflows/5eaeec43-e43b-444c-badb-ef696a3828fc

@jyothsnakonisa jyothsnakonisa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments

Comment on lines +227 to +240

// This builder always produces an index-less table (indexes are applied later by the 5.0 bridge), so a
// rebuild never carries indexes even if the caller passed index statements. buildSchema runs repeatedly per
// table in a JVM; if an earlier call already registered indexes, copy them onto this rebuild so it matches
// the registered table.
if (maybeExistingTableMetadata != null
&& !maybeExistingTableMetadata.indexes.isEmpty()
&& tableMetadata.indexes.isEmpty())
{
tableMetadata = tableMetadata.unbuild()
.indexes(maybeExistingTableMetadata.indexes)
.build();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RecordWriter is using 5 arg buildSchema() method which is not attaching index statements, you could eliminate copying indexes here if you use the 8 arg buildSchema() method in RecordWriter and can eventually get rid of 5 arg buildSchema() method

cqlTable = writerContext.bridge()
                          .buildSchema(writerContext.schema().getTableSchema().createStatement,
                                       writerContext.job().qualifiedTableName().keyspace(),
                                       IGNORED_REPLICATION_FACTOR,
                                       writerContext.cluster().getPartitioner(),
                                       writerContext.schema().getUserDefinedTypeStatements(),
                                       null,
                                       writerContext.schema().getTableSchema().getIndexStatements(),
                                       false);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did restructure and moved SAI specific code to extended class. Now this comment not applicable I believe

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@skoppu22 This block of code is moved to FiveZeroSchemaBuilder.beforeTableRegistered but the issue still exists. You can get rid of the logic to not drop indexes in beforeTableRegistered and even the method beforeTableRegistered if you use 8 argument constructor for schemaBuilder in Recordwriter passing writerContext.schema().getTableSchema().getIndexStatements() in the constructor.

@skoppu22 skoppu22 Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jyothsnakonisa I have applied your recommended changes to pass index statements as part of cqlTable. We still need beforeTableRegistered. Because

Reason 1, Some builders only receive a CREATE TABLE string, with no CqlTable and no CREATE INDEX statements, so they can only build index‑less. These are public CassandraBridge APIs:

encodePartitionKeys(...) / toTokens(...) / encodePartitionKey(...) → new FiveZeroSchemaBuilder(createTableStmt, ks, rf, partitioner)

readPartitionKeys(...) → same createStmt‑only builder

We can't make these pass statements without changing the public bridge API, and their callers frequently don't have the CREATE INDEX statements to pass. If one of these is the first build of a table in a JVM, the table registers without its SAI index.

Reason 2, The same table is built more than once per JVM in normal flows:

Write path, multiple partitions/executor: RecordWriter is constructed per Spark partition (mapPartitions); the 2nd+ partition on an executor rebuilds the already‑registered table.

Write path, single partition: after writing, SortedSSTableWriter.validateSSTables(...) → buildLocalDataLayer → new LocalDataLayer → buildSchema rebuilds the same table in the same JVM.

Read path: getCompactionScanner, getPartitionSizeIterator, rebuildBloomFilter all build from a CqlTable, repeatedly within a JVM.

@skoppu22 skoppu22 Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like there are no callers for encodePartitionKeys and readPartitionKeys. If we can remove them, then we can switch to 8 arg constructor as you mentioned, and we can simplify beforeTableRegistered just to avoid duplicate registration

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes encodePartitionKeys & readPartitionKeys are not used anywhere in production code hence removing them should be safe. I will leave it to you whether to remove those methods or not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Marked them deprecated

@jyothsnakonisa jyothsnakonisa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Comment on lines +265 to +266
java.util.Collections.emptySet(),
java.util.Collections.emptySet());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove fully qualified class names.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +227 to +240

// This builder always produces an index-less table (indexes are applied later by the 5.0 bridge), so a
// rebuild never carries indexes even if the caller passed index statements. buildSchema runs repeatedly per
// table in a JVM; if an earlier call already registered indexes, copy them onto this rebuild so it matches
// the registered table.
if (maybeExistingTableMetadata != null
&& !maybeExistingTableMetadata.indexes.isEmpty()
&& tableMetadata.indexes.isEmpty())
{
tableMetadata = tableMetadata.unbuild()
.indexes(maybeExistingTableMetadata.indexes)
.build();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes encodePartitionKeys & readPartitionKeys are not used anywhere in production code hence removing them should be safe. I will leave it to you whether to remove those methods or not.

@yifan-c yifan-c left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

partial review; spotted a change that is needed. submitting the comments so far.

if (componentFile.getFileName().toString().endsWith("Data.db"))
// send the primary data component ("<descriptor>-Data.db") last; SAI per-index components
// such as "...+TermsData.db" are still streamed here rather than skipped
if (SSTables.isDataComponent(componentFile))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can you revert the comment?
This comment is already there in the javadoc of the method.
Repeating here and several other call-sites creates noise. The call-sites only need to know the method can determine whether a file is data component or not.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment actually confused me a bit. It wasn't clear what "here" meant; I read it as "are still streamed here" then saw the immediate "if match: continue" logic which looks like the opposite of streaming here. :)

So if you drop the comment entirely, no worries. If you decide to keep it, I'd clarify something like "SAI per-index components such as "..." are still streamed below; we're only skipping core SSTable -Data.db files here."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded as suggested by Josh

private static final Pattern COMPACTION_STRATEGY_PATTERN = Pattern.compile("compaction\\s*=\\s*\\{\\s*'class'\\s*:\\s*'([^']+)'");

private static final Pattern MULTI_WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern SAI_USING_PATTERN = Pattern.compile("USING '([^']*\\.)?STORAGEATTACHEDINDEX'");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern is not comprehensive. See the example below. cc: @maedhroz

cqlsh> DESC TABLE ks.tstsai;

CREATE TABLE ks.tstsai (
    id int PRIMARY KEY,
    val1 int,
    val2 int,
    val3 int,
    val4 int,
    val5 int
);

CREATE CUSTOM INDEX val1_index ON ks.tstsai (val1) USING 'STORAGEATTACHEDINDEX';

CREATE CUSTOM INDEX val2_index ON ks.tstsai (val2) USING 'storageattachedindex';

CREATE CUSTOM INDEX val3_index ON ks.tstsai (val3) USING 'sai';

CREATE CUSTOM INDEX val4_index ON ks.tstsai (val4) USING 'StorageAttachedIndex';

CREATE CUSTOM INDEX val5_index ON ks.tstsai (val5) USING 'org.apache.cassandra.index.sai.StorageAttachedIndex';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, also added tests for all these cases

public final class BroadcastableTableSchema implements Serializable
{
private static final long serialVersionUID = 1L;
private static final long serialVersionUID = 2L;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the rational, but is it really needed? In the past we have made changes in class fields without updating serial version ID.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serialVersionUID change is unnecessary.
The serialization is not used in any long running service and no mixed-mode is expected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted

@jmckenzie-dev jmckenzie-dev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the way through review; will try and knock out the rest of it tomorrow.

if (componentFile.getFileName().toString().endsWith("Data.db"))
// send the primary data component ("<descriptor>-Data.db") last; SAI per-index components
// such as "...+TermsData.db" are still streamed here rather than skipped
if (SSTables.isDataComponent(componentFile))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment actually confused me a bit. It wasn't clear what "here" meant; I read it as "are still streamed here" then saw the immediate "if match: continue" logic which looks like the opposite of streaming here. :)

So if you drop the comment entirely, no worries. If you decide to keep it, I'd clarify something like "SAI per-index components such as "..." are still streamed below; we're only skipping core SSTable -Data.db files here."

skoppu22 added 2 commits July 28, 2026 21:41
# Conflicts:
#	CHANGES.txt
#	cassandra-analytics-common/src/main/java/org/apache/cassandra/spark/data/CqlTable.java
#	cassandra-bridge/src/testFixtures/java/org/apache/cassandra/spark/utils/test/TestSchema.java
#	cassandra-four-zero-types/src/main/java/org/apache/cassandra/spark/reader/SchemaBuilder.java

@jmckenzie-dev jmckenzie-dev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a lot of places where we call the old CassandraBridge#buildSchema where we can simplify to the signature assuming null UUID and false on enableCdc which would tidy up the patch a bit.

public Set<String> indexStatements()
{
return indexCount;
return indexStatements;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If our goal is to keep external consumers from mutating the contents of this set we should do something like:

public Set<String> indexStatements() {
    return Collections.unmodifiableSet(indexStatements);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indexStatements is already created using Collections.unmodifiableSet above in constructor

* @param table the table name
* @return set of CREATE INDEX statements for the table
*/
public static Set<String> extractIndexStatements(@NotNull String schemaStr,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caught my eye. I think there's a couple regex specific issues here; had a couple models take a couple passes at it and this is where we landed:

  1. Stray ? after the keyspace makes its last character optional. The format string ""?%s?"?" expands to "??"?, so the trailing ? binds the final character of the keyspace — asking for keyspace orders also matches order. Note the table side (correctly) has no ?; that asymmetry is the tell. This is copy-pasted from extractCleanedTableSchema, so it's pre-existing, but it's still wrong.
  1. Identifiers are interpolated raw into the pattern. A keyspace/table name containing a regex metacharacter (legal in quoted CQL identifiers) misbehaves or throws PatternSyntaxException. Wrap both in Pattern.quote(...).
  1. ⚠ These two are separate fixes. Pattern.quote alone does not fix bug CASSANDRA-18545: Provide a SecretsProvider interface to abstract the secret provisioning #1 — the ? lives in the format string outside the %s, so it survives quoting. We need to delete the ? and quote the value.
  1. Cosmetic: .{1} — the {1} is dead; it's just ..

Whenever I see big blocks of regex like this it always makes my "spidey senses tingle". Regexes are super powerful but are effectively embedded functions and edge-cases and "sanitization" of input become quite challenging when there's this much density expressed in one place.

return quoteIdentifiers;
}

public Set<String> getIndexStatements()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here; returning a collection an external user could mutate under us. 😬

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

+ " weight float,\n"
+ " height int\n"
+ ");"));
+ ");"), null, Collections.emptySet(), false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can update various call sites to simplify them now w/out the vestigial UUID and boolean now:

    public CqlTable buildSchema(String createStatement,
                                String keyspace,
                                ReplicationFactor replicationFactor,
                                Partitioner partitioner,
                                Set<String> udts,
                                Set<String> indexStatements)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

.withPartitioner(cassPartitioner)
.using(insertStatement)
// The data frame to write is always sorted,
// see org.apache.cassandra.spark.bulkwriter.CassandraBulkSourceRelation.insert

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a contractual promise from the other class or could this drift? There's no javadoc on CassandraBulkSourceRelation#insert and no javadoc on the parent class indicating that this is a durable "external API" commitment here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added javadoc for why we need sorted

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants