Chapter 18 · Secondary Indexing with Storage-Attached Indexing (SAI)

SAI Architecture: Index Components Attached to SSTables and Distributed Query Execution

Open the SAI storage lifecycle: synchronous Memtable indexing, per-SSTable and per-column components, build state, distributed range execution, and streaming.

Intermediate → Advanced110–150 minutesSAI storage/build/stream labApache Cassandra 5.0.9 · SAI · cqlsh/nodetool · Java Driver 4.19.3 optional · RF=3 · UCSLast reviewed: September 2026

Learning outcomes

AtlasMart's support query works, but operators now need to answer harder questions: where does the index live, when is a write considered indexed, what does compaction do to index files, how does an index build differ from normal writes, and what happens during streaming? SAI is easiest to operate when its lifecycle is understood as part of Cassandra storage rather than as a sidecar search cluster.

01

Trace SAI from Memtable indexing through SSTable index components and read-time union/intersection.

02

Distinguish per-SSTable shared index data from per-column index data and inspect both through virtual tables.

03

Explain why acknowledged writes are already represented in SAI on the replica that acknowledged them.

04

Observe initial index build/rebuild state without depending on a long-running build.

05

Explain distributed query rounds and zero-copy streaming of SAI components during eligible SSTable streaming.

Chapter 18 lab baseline

The mandatory labs continue the disposable AtlasMart course cluster: Apache Cassandra 5.0.9 in the pinned cassandra:5.0.9 image, Java 17 inside the image, cluster atlasmart-course, Docker network atlasmart-cassandra, nodes atlasmart-cass-1..3, datacenter dc1, racks rack1..rack3, 16 virtual nodes per node, NetworkTopologyStrategy with replication factor (RF) 3, and consistency level (CL) LOCAL_QUORUM unless an experiment explicitly changes it. New tables use UnifiedCompactionStrategy (UCS), gc_grace_seconds = 864000, and no default Time To Live (TTL). Authentication, client Transport Layer Security (TLS), internode TLS, and remote Java Management Extensions (JMX) are disabled only inside this isolated local learning network. Apache Cassandra Java Driver 4.19.3 is optional for latency/routing exercises; all mandatory SAI evidence is available with cqlsh, virtual tables, tracing, nodetool, and read-only filesystem inventory. Storage-Attached Indexing (SAI) is a Cassandra 5.0 feature; do not assume the same syntax or operational behavior on Cassandra 4.x, commercial distributions, or Cassandra-compatible services.

Execution and safety note

Run commands only against the disposable Apache Cassandra course lab or another explicitly approved non-production environment. Confirm node, keyspace, table, container, volume, path, and datacenter targets before destructive, failure-injection, cleanup, repair, restore, security, or topology operations. Capture current state and expected rollback/recovery evidence first; output and timings can differ by host, operating system, Java runtime, Docker/runtime, driver, and Cassandra configuration.

Core terms for this chapter

Apache Cassandra is a peer-to-peer distributed database. CQL is the Cassandra Query Language. A partition key determines a partition and is hashed into a token; token ownership and the keyspace replication strategy determine the replicas storing that partition. A request's coordinator is the Cassandra node that accepts that client request and coordinates work with replicas. A primary-key access path identifies partitions directly from the primary key and is Cassandra's most efficient lookup path. A secondary index provides an additional way to find rows by non-primary-key values. Storage-Attached Indexing (SAI) is Cassandra 5.0's storage-integrated secondary-index capability: it indexes Memtables in memory and attaches on-disk index components to SSTables. An SSTable is an immutable on-disk Cassandra storage file set; a Memtable is the mutable in-memory write buffer before flush. Selectivity describes how narrowly a predicate reduces candidate rows; a predicate matching nearly every row is low-selectivity. Fanout is the amount of token-range/replica work a distributed query must touch. Build state records whether an index is still being constructed and whether it is queryable. Streaming moves SSTable components between nodes during operations such as bootstrap, repair, rebuild, or decommission. SAI is a filtering engine, not a general relational join engine or full-text relevance/search platform.

1. SAI is attached to the Cassandra write and storage lifecycle

On a replica, SAI indexes the in-memory Memtable representation as writes arrive. When the Memtable flushes, SAI writes index components associated with the resulting SSTable. Current Cassandra documentation states that the SAI write path is synchronous: when a replica acknowledges a write, that replica's data is already represented in the SAI indexing path. This is very different from an asynchronously refreshed external search service. It does not, however, mean every replica has acknowledged—regular Cassandra CL still defines the request guarantee.

On disk, SAI shares some per-SSTable index structures across all SAI columns on the table and stores additional per-column components. For text, current SAI uses trie/postings structures; for numeric and other non-literal types it uses k-dimensional-tree-style structures. Application code should treat these as implementation/storage evidence, not stable file APIs.

Layer State Operational evidence
Memtable index new/unflushed values SAI query sees current writes; trace may mention Memtable indexes
Per-SSTable shared components row-ID/token linkage shared across indexed columns system_views.indexes per_table_disk_size
Per-column SSTable components column-specific terms/ranges/postings per_column_disk_size and sstable_indexes
Build/rebuild task backfills index state for existing SSTables is_building / is_queryable, build metrics
Streaming eligible SSTable components move between nodes nodetool netstats plus destination index status

2. Force an SSTable and inspect SAI metadata safely

bash · verify or recreate the disposable three-node lab
# Verify the existing course cluster first.docker exec atlasmart-cass-1 nodetool versiondocker exec atlasmart-cass-1 nodetool statusdocker exec atlasmart-cass-1 java -version# If the shared course cluster does not exist, recreate the same local topology.docker network inspect atlasmart-cassandra >/dev/null 2>&1 || docker network create atlasmart-cassandradocker volume create atlasmart-cass-1-datadocker volume create atlasmart-cass-2-datadocker volume create atlasmart-cass-3-datadocker inspect atlasmart-cass-1 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-1 --hostname atlasmart-cass-1 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack1 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -v atlasmart-cass-1-data:/var/lib/cassandra cassandra:5.0.9# Wait until node 1 is UN before starting peers.docker exec atlasmart-cass-1 nodetool statusdocker inspect atlasmart-cass-2 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-2 --hostname atlasmart-cass-2 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack2 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -e CASSANDRA_SEEDS=atlasmart-cass-1 -v atlasmart-cass-2-data:/var/lib/cassandra cassandra:5.0.9docker inspect atlasmart-cass-3 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-3 --hostname atlasmart-cass-3 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack3 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -e CASSANDRA_SEEDS=atlasmart-cass-1 -v atlasmart-cass-3-data:/var/lib/cassandra cassandra:5.0.9# Continue only when all three nodes are UN in dc1.docker exec atlasmart-cass-1 nodetool statusdocker exec -it atlasmart-cass-1 cqlsh
CQL · create the Chapter 18 base and query-table fixtures
CREATE KEYSPACE IF NOT EXISTS atlasmart_saiWITH replication = {'class':'NetworkTopologyStrategy','dc1':3};CREATE TABLE IF NOT EXISTS atlasmart_sai.product_catalog (    product_id uuid PRIMARY KEY,    category text,    brand text,    status text,    price_cents int,    rating int,    warehouse_region text,    updated_at timestamp) WITH compaction = {'class':'UnifiedCompactionStrategy'};CREATE TABLE IF NOT EXISTS atlasmart_sai.products_by_category_bucket (    category text,    bucket tinyint,    product_id uuid,    brand text,    status text,    price_cents int,    rating int,    PRIMARY KEY ((category,bucket),product_id)) WITH compaction = {'class':'UnifiedCompactionStrategy'};CONSISTENCY LOCAL_QUORUM;
CQL · load a small deterministic AtlasMart catalog
INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001801,'laptop','Northstar','ACTIVE',129900,5,'eu-west',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001802,'laptop','Northstar','ACTIVE',89900,4,'eu-west',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001803,'laptop','Contoso','ACTIVE',149900,5,'us-east',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001804,'monitor','Northstar','ACTIVE',39900,4,'eu-west',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001805,'monitor','Fabrikam','DISCONTINUED',29900,3,'us-east',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001806,'keyboard','Fabrikam','ACTIVE',9900,4,'eu-west',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001807,'keyboard','Northstar','ACTIVE',15900,5,'us-east',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001808,'mouse','Contoso','ACTIVE',4900,4,'eu-west',toTimestamp(now()));INSERT INTO atlasmart_sai.products_by_category_bucket (category,bucket,product_id,brand,status,price_cents,rating) VALUES ('laptop',0,00000000-0000-0000-0000-000000001801,'Northstar','ACTIVE',129900,5);INSERT INTO atlasmart_sai.products_by_category_bucket (category,bucket,product_id,brand,status,price_cents,rating) VALUES ('laptop',0,00000000-0000-0000-0000-000000001802,'Northstar','ACTIVE',89900,4);INSERT INTO atlasmart_sai.products_by_category_bucket (category,bucket,product_id,brand,status,price_cents,rating) VALUES ('laptop',0,00000000-0000-0000-0000-000000001803,'Contoso','ACTIVE',149900,5);
CQL · add text and numeric indexes
CREATE INDEX IF NOT EXISTS product_brand_saiON atlasmart_sai.product_catalog (brand) USING 'sai';CREATE INDEX IF NOT EXISTS product_price_saiON atlasmart_sai.product_catalog (price_cents) USING 'sai';SELECT keyspace_name,index_name,column_name,indexed_sstable_count,       is_building,is_queryable,cell_count,per_column_disk_size,per_table_disk_sizeFROM system_views.indexesWHERE keyspace_name='atlasmart_sai';
bash · flush only the disposable Chapter 18 table and inspect metadata
docker exec atlasmart-cass-1 nodetool flush atlasmart_sai product_catalog# Virtual tables are local to each node; inspect more than one.for n in 1 2 3; do  echo "=== node $n ==="  docker exec atlasmart-cass-$n cqlsh -e "SELECT keyspace_name,index_name,indexed_sstable_count,is_building,is_queryable,per_column_disk_size,per_table_disk_size FROM system_views.indexes WHERE keyspace_name='atlasmart_sai';"done# Read-only inventory: filenames are implementation detail, so do not script production logic around them.docker exec atlasmart-cass-1 sh -lc "find /var/lib/cassandra/data/atlasmart_sai -maxdepth 4 -type f | sort | head -120"

After a flush, indexed_sstable_count and disk-size fields should become nonzero once relevant data exists. The exact byte counts, component names, SSTable format, and compaction timing depend on runtime state. The key proof is that SAI's disk accounting is attached to SSTable/index metadata on each node rather than a separate remote service.

3. Read path: union, intersection, then row materialization

For one indexed predicate, SAI merges matching token-ordered results from relevant Memtable and SSTable indexes. With multiple indexed predicates joined by AND, SAI intersects indexed result streams. The resulting partition keys still cause Cassandra partition reads; rows can then be post-filtered for row granularity, tombstones, or expressions not satisfied directly by index structures. This is why “an index exists” is not equivalent to “only matching rows are touched.”

CQL · trace the index read path
TRACING ON;SELECT product_id,category,brand,price_centsFROM atlasmart_sai.product_catalogWHERE brand='Northstar' AND price_cents >= 5000 AND price_cents <= 130000LIMIT 20;TRACING OFF;

Current SAI tracing can report how many Memtable indexes, SSTable indexes, segments, partitions, and post-filtered rows were involved. Capture those numbers. They are far more useful than a screenshot of the final result set because they explain why two predicates with the same result count can have different cost.

4. Build/rebuild and streaming are operational states

Creating an index on an already-populated table triggers index construction for existing data. On a tiny lab this may finish almost instantly. To make the lifecycle observable without inventing a slow build, inspect system_views.indexes before/after creation and optionally use nodetool rebuild_index on one disposable node. During a rebuild, queries that require an index are only safe once the relevant local index is queryable. In a cluster, nodes gossip local index status so coordinators can avoid replicas whose required index is not queryable.

bash · controlled one-node SAI rebuild observation
# Optional lab: rebuild one index on one disposable node.docker exec atlasmart-cass-1 nodetool rebuild_index atlasmart_sai product_catalog product_brand_sai# Observe local state after/during the rebuild. On a tiny dataset it may already be complete.docker exec atlasmart-cass-1 cqlsh -e "SELECT keyspace_name,index_name,is_building,is_queryable,indexed_sstable_count FROM system_views.indexes WHERE keyspace_name='atlasmart_sai' AND index_name='product_brand_sai';"docker exec atlasmart-cass-1 nodetool netstats

SAI is compatible with zero-copy streaming: when Cassandra can stream entire SSTables, their SAI components travel with them rather than requiring a separate external reindex on the destination. Whether a specific operation uses zero-copy depends on SSTable eligibility, encryption, compaction/storage state, and Cassandra configuration. Do not infer zero-copy from “streaming happened”; inspect the operation and current configuration.

Wrong approach: “SAI is a separate eventually indexed search service.”

That mental model leads to incorrect write-consistency and recovery assumptions. On each replica, SAI indexing is integrated with the write path; build state is mainly relevant when adding/rebuilding an index for existing SSTables. Distributed visibility is still governed by Cassandra CL, replica availability, repair, and local index queryability.

5. Verification

  • Both brand and price indexes are queryable on inspected nodes.
  • After flush, you recorded indexed SSTable count and disk bytes.
  • You captured a trace for an AND query and identified index/segment/post-filter evidence if present.
  • You did not modify any SAI component files directly.
  • You can distinguish index build from normal synchronous indexing of new writes.

Check your understanding

  1. When is a newly acknowledged write indexed by SAI on the acknowledging replica?
  2. Why are there per-table and per-column SAI disk sizes?
  3. What does an AND query do with multiple indexed expressions?
  4. Does streaming always imply index rebuilding on the receiver?
  5. Why inspect index state on multiple nodes?
Review the answers

1. The SAI write path is synchronous with that replica write; the data is represented in the Memtable/on-disk index lifecycle when the write is acknowledged.

2. SAI shares some SSTable-level structures across indexes while keeping column-specific structures for indexed values.

3. SAI intersects indexed result streams, then Cassandra materializes partitions/rows and may post-filter where necessary.

4. No. SAI supports streaming index components with SSTables, including zero-copy when eligible.

5. system_views virtual tables are local-node operational views, and build/queryable state can differ during failures/rebuilds.

Production judgment

Adopt SAI only after the primary-key model and business query have been written down. Record expected cardinality/selectivity, result limit, partitions/token ranges touched, replica/DC scope, RF/CL, p50/p95/p99 latency, index build state, SSTables/indexes touched per query, SAI disk bytes, base-table disk bytes, write amplification, compaction strategy/backlog, tombstones/TTL, streaming and repair behavior, node/index failure behavior, driver timeout/retry/idempotency policy, and whether authorization is enforced before or as part of retrieval. SAI adds synchronous write work and on-disk components; it does not make a low-selectivity cluster-wide predicate free. More SAI indexes also mean more components and more write/disk cost, so index count is a workload decision rather than a schema decoration.

For security, remember that index components may contain user-derived values and must be included in the data-at-rest threat model. For managed Cassandra services, verify exact SAI version, allowed index options, query guardrails, observability surfaces, backup/restore behavior, encryption, and billing model instead of assuming open-source defaults. Do not claim SAI provides cross-row relational constraints, global linearizability, arbitrary joins, relevance scoring, fuzzy full-text semantics, or authorization isolation. Lesson 3 now turns the architecture into concrete text/numeric indexes and deliberately probes supported and unsupported predicate boundaries.

Summary and next step

This lesson’s concepts, evidence path, failure boundaries, and production judgment should now be explicit enough to verify rather than assume. Re-run the check-your-understanding prompts and preserve any lab evidence you need before changing or cleaning up the environment.

Next, continue to Create SAI Indexes for Textual/Numeric Columns and Combine Multiple Predicates.

Authoritative references

These are the version-sensitive source of truth for this lesson. Re-check them when regenerating the course because SAI capabilities, guardrails, virtual tables, and query semantics can evolve.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.