Chapter 06 · Query-First Data Modeling and Denormalization
Duplicate Data Across Tables and Keep It Consistent with Application / Streaming Workflows
Make cross-table projection consistency an explicit workflow with versioning, retries, drift metrics and replayable repair.
Learning outcomes
Denormalized query tables are useful only if AtlasMart can explain who keeps them aligned. Cassandra does not enforce referential integrity across tables, so a product name or order status duplicated into three projections can drift after timeout, process crash or partial deployment. The consistency mechanism belongs to the application/data pipeline and must be observable and replayable.
Separate replica consistency inside one table from projection consistency across denormalized tables.
Use authoritative event/version fields to detect stale projections deterministically.
Compare synchronous dual-write, outbox/event and streaming-materialization approaches without pretending any is universally correct.
Inject a partial projection failure safely, diagnose it, repair it and verify convergence.
Define metrics for drift count, drift age, retry backlog and reconciliation success.
-
Server: Apache Cassandra 5.0.9, pinned
container image
cassandra:5.0.9; Java 17 inside the image. -
Topology: three disposable nodes
atlasmart-cass-1..3, clusteratlasmart-course, one DCdc1, racksrack1..rack3, 16 vnodes per node, Docker networkatlasmart-cassandra. -
Replication / consistency: chapter keyspace
atlasmart_model,NetworkTopologyStrategy, RF=3 indc1; examples useQUORUMunless a different level is stated next to the operation. -
Storage: chapter tables explicitly use
UnifiedCompactionStrategy(UCS); no chapter-specificgc_grace_secondsor default TTL override. TTL is introduced only when a query has explicit retention semantics. - Security: the disposable Docker network is isolated for learning; authentication, client TLS, internode TLS and hardened JMX are not enabled. Do not expose CQL/JMX ports broadly or reuse this posture for production.
- Resources: the full three-node lab is intended for a development machine with enough headroom for three Cassandra JVMs (plan roughly 6 GiB+ RAM for the containers plus host/Docker overhead and several GiB of disposable disk). If that is impractical, use one pinned node and RF=1 only for schema/query-shape exercises and label the topology difference.
-
Client: mandatory work uses the bundled
cqlsh/nodetool. No application driver is required in Chapter 06, so driver retry/idempotency behavior is discussed as a design obligation but not fabricated as lab evidence. - Evidence: capture your own row counts, trace events, timings and node state. The generated lesson never claims unexecuted throughput, p95/p99 latency, fan-out cost or convergence measurements.
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.
1. RF/CL consistency is not cross-table projection consistency
With RF=3 and QUORUM, Cassandra can successfully replicate an
update to products_by_id while
products_by_category never receives the
corresponding logical projection update. RF and consistency
level govern replicas of a partitioned table write; they do not
create a multi-table business invariant.
AtlasMart therefore attaches an application-owned
source_version to every product projection. A
business event such as product-version=42 is
applied idempotently to each read shape. A reconciler can detect
a projection at version 41, rebuild the expected row and safely
replay version 42.
2. Three common ownership patterns
| Pattern | Strength | Main failure mode | Required control |
|---|---|---|---|
| synchronous application fan-out | simple request path | partial success before timeout/crash | idempotency, retry classification, reconciliation |
| authoritative write + outbox/event | separates commit intent from projections | outbox backlog / duplicate delivery | durable event identity, consumer offsets, replay |
| streaming/CDC materialization | scales projection ownership separately | lag, reordering, poison events | ordering/version rules, dead-letter policy, drift audit |
The chapter does not require Kafka, a proprietary managed stream or Cassandra CDC. The mandatory lab simulates the same state machine locally with explicit CQL so the invariant is visible. Production streaming choices must be evaluated separately.
3. Reproducible partial-failure and reconciliation lab
# Reuse the course cluster if it exists; otherwise create only these disposable resources.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-data# Start node 1 only if it does not already exist.docker 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 answers 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# Wait until all three nodes report UN before creating RF=3 data.docker exec atlasmart-cass-1 nodetool statusdocker exec atlasmart-cass-1 cqlsh -e "SELECT release_version,cluster_name,data_center,rack FROM system.local;"docker exec atlasmart-cass-1 cqlsh -e "CREATE KEYSPACE IF NOT EXISTS atlasmart_model WITH replication = {'class':'NetworkTopologyStrategy','dc1':3};"docker exec atlasmart-cass-1 cqlsh -e "DESCRIBE KEYSPACE atlasmart_model"
On Windows, run these Docker commands from WSL/Git Bash or adapt
the existence checks to PowerShell. If three Cassandra JVMs are
too heavy for the machine, a single
cassandra:5.0.9 node with RF=1 is sufficient for
the chapter's query-shape exercises, but it is
not equivalent evidence for RF=3/QUORUM
behavior.
CREATE TABLE IF NOT EXISTS atlasmart_model.product_authority ( product_id text PRIMARY KEY, name text, category text, price_cents int, active boolean, source_version bigint) WITH compaction={'class':'UnifiedCompactionStrategy'};CREATE TABLE IF NOT EXISTS atlasmart_model.products_by_id ( product_id text PRIMARY KEY, name text, category text, price_cents int, active boolean, source_version bigint) WITH compaction={'class':'UnifiedCompactionStrategy'};CREATE TABLE IF NOT EXISTS atlasmart_model.products_by_category ( category text, price_cents int, product_id text, name text, active boolean, source_version bigint, PRIMARY KEY ((category),price_cents,product_id)) WITH CLUSTERING ORDER BY (price_cents ASC,product_id ASC) AND compaction={'class':'UnifiedCompactionStrategy'};CONSISTENCY QUORUM;INSERT INTO atlasmart_model.product_authority (product_id,name,category,price_cents,active,source_version) VALUES ('p-701','AtlasCam Mini','cameras',39900,true,12);INSERT INTO atlasmart_model.products_by_id (product_id,name,category,price_cents,active,source_version) VALUES ('p-701','AtlasCam Mini','cameras',39900,true,12);INSERT INTO atlasmart_model.products_by_category (category,price_cents,product_id,name,active,source_version) VALUES ('cameras',39900,'p-701','AtlasCam Mini',true,12);
Now simulate event version 13 reaching the authority and ID projection but not the category projection.
UPDATE atlasmart_model.product_authority SET name='AtlasCam Mini 2',source_version=13 WHERE product_id='p-701';UPDATE atlasmart_model.products_by_id SET name='AtlasCam Mini 2',source_version=13 WHERE product_id='p-701';SELECT product_id,name,source_version FROM atlasmart_model.product_authority WHERE product_id='p-701';SELECT product_id,name,source_version FROM atlasmart_model.products_by_id WHERE product_id='p-701';SELECT product_id,name,source_version FROM atlasmart_model.products_by_category WHERE category='cameras' AND price_cents=39900 AND product_id='p-701';
Your deterministic drift predicate is
projection.source_version < authority.source_version
(plus content validation where versions match). Repair by
replaying version 13 to the stale projection.
UPDATE atlasmart_model.products_by_category SET name='AtlasCam Mini 2',source_version=13 WHERE category='cameras' AND price_cents=39900 AND product_id='p-701';SELECT product_id,name,source_version FROM atlasmart_model.products_by_category WHERE category='cameras' AND price_cents=39900 AND product_id='p-701';
4. Reconciliation must be a product feature, not a one-off script
A real reconciler needs a bounded work queue or scan strategy, rate limiting, checkpoints, metrics and an escalation path. At minimum record drift count, oldest drift age, projection retry backlog, replay success/failure and version regression attempts. A source version also protects against out-of-order delivery: a projection consumer should not blindly overwrite version 13 with stale version 12.
Cassandra’s cell timestamps participate in last-write-wins reconciliation at the replica/data-cell layer. They do not tell your business workflow whether every denormalized table received the same domain event. Use an explicit domain version/event identity for projection ownership.
5. Safety and rollback
Projection schema changes need the same staged thinking as application changes. Add a new query table, dual-write or backfill it, reconcile against the authority, shadow-read it, switch traffic, retain the old path through a rollback window, then retire old writes/data deliberately. A migration that drops the old table immediately after a backfill has no safe rollback story.
Check your understanding
- Why can QUORUM succeed while a denormalized projection is stale?
- Why use source_version in addition to Cassandra writetime?
- What is a safe response to duplicate event delivery?
- Which drift metric is especially important for user impact?
- Why retain an old read path during migration?
Review the answers
1. QUORUM applies to replicas of the CQL write that was issued; if the application never issued the second table write, Cassandra cannot infer it.
2. source_version represents domain-event ordering across projections; cell timestamps are storage reconciliation metadata and do not prove cross-table workflow completion.
3. Make projection handlers idempotent by event/version identity so replaying the same event converges to the same row state.
4. Oldest drift age shows how long the worst stale projection has remained inconsistent, not just how many mismatches exist.
5. It provides a rollback route while the new projection is being backfilled, reconciled and shadow-validated.
Production judgment
A Cassandra table is an operational commitment, not just a schema object. Before approving the read shape, record expected partition cardinality and byte growth, retention/TTL behavior, read and write rates, tail-latency objectives, RF/CL, failure domains, write fan-out, retry/idempotency rules, reconciliation ownership, compaction and tombstone consequences, repair/backup requirements, observability signals, and the migration/rollback path. The same denormalization that removes a read-time join can multiply writes, storage, repair traffic and opportunities for projection drift.
Do not derive universal size or latency thresholds from this local lab. Production decisions require measurements with the real key distribution, payloads, concurrency, disk/network/JVM behavior and failure modes. SAI/vector features are intentionally not used to rescue a poor primary access model here; those mechanisms have their own later chapters and costs.
Lesson 5 consolidates the chapter by taking a normalized relational AtlasMart model and systematically deriving Cassandra query tables, update fan-out and migration controls from the workload.
Summary and next bridge
Denormalized data does not keep itself consistent. Projection ownership requires event identity/versioning, idempotent writes, observable retries and a reconciler that can detect and repair drift. The final lesson applies those controls to a complete normalized-to-Cassandra redesign and documents the costs that moved from reads to writes and operations.
Authoritative references
- Apache Cassandra downloads — release baseline; 5.0.9 is the current GA 5.0 patch at chapter generation time.
- Data modeling introduction — official query-driven modeling, partition-minimization and denormalization guidance.
- RDBMS design versus Cassandra design — official join, referential-integrity and denormalization contrasts.
- CQL data definition — primary-key, partition-key and clustering-order syntax used by the chapter tables.
- CQL data manipulation — SELECT restrictions and single-table query semantics.
- Unified Compaction Strategy — current Cassandra guidance for the explicit lab compaction baseline.