Add user docs for Accord focused on how to use, what CQL is and isnt valid, and use cases

patch by David Capwell; reviewed by Caleb Rackliffe for CASSANDRA-21127
This commit is contained in:
David Capwell 2026-01-20 15:24:33 -08:00
parent c9d468bf3c
commit 998f42984f
6 changed files with 1385 additions and 0 deletions

View File

@ -43,6 +43,9 @@
*** xref:cassandra:developing/cql/types.adoc[Data types] *** xref:cassandra:developing/cql/types.adoc[Data types]
*** xref:cassandra:developing/cql/ddl.adoc[Data definition (DDL)] *** xref:cassandra:developing/cql/ddl.adoc[Data definition (DDL)]
*** xref:cassandra:developing/cql/dml.adoc[Data manipulation (DML)] *** xref:cassandra:developing/cql/dml.adoc[Data manipulation (DML)]
*** xref:cassandra:developing/cql/transactions.adoc[Transactions]
*** xref:cassandra:developing/cql/transactions-examples.adoc[Transaction patterns]
*** xref:cassandra:developing/cql/transactions-migration.adoc[Transaction migration]
*** xref:cassandra:developing/cql/dynamic-data-masking.adoc[] *** xref:cassandra:developing/cql/dynamic-data-masking.adoc[]
*** xref:cassandra:developing/cql/operators.adoc[Operators] *** xref:cassandra:developing/cql/operators.adoc[Operators]
*** xref:cassandra:developing/cql/indexing/indexing-concepts.adoc[] *** xref:cassandra:developing/cql/indexing/indexing-concepts.adoc[]

View File

@ -474,3 +474,97 @@ partly applied.
Use the `COUNTER` option for batched counter updates. Unlike other Use the `COUNTER` option for batched counter updates. Unlike other
updates in Cassandra, counter updates are not idempotent. updates in Cassandra, counter updates are not idempotent.
=== BATCH vs Transactions
BATCH statements have limitations compared to Accord transactions. Understanding the differences helps you choose the right approach for your use case.
==== BATCH Statement Capabilities
BATCH statements offer:
* **Single-partition atomicity**: All operations within a single partition succeed or fail together atomically
* **Multi-partition batching**: LOGGED batches can span multiple partitions, but are not atomic. The batch log ensures all operations eventually complete, but there can be a window where only some operations are visible.
* **Single partition isolation**: Operations on the same partition are isolated
* **Network efficiency**: Multiple operations in one round-trip
* **Automatic timestamps**: All operations use the same timestamp by default
* **Conditional updates**: Batches support `IF` clauses for lightweight transactions (LWT/CAS) on a single partition. If any condition fails, the entire batch is rejected.
* **Basic business rule enforcement**: `IF` clauses can validate conditions before applying changes, though limited to what CAS syntax supports
==== BATCH Statement Limitations
However, BATCH statements cannot provide:
* **Cross-partition atomicity**: Multi-partition LOGGED batches are not truly atomic (see above)
* **Read-before-write patterns**: Cannot read data within the batch to make decisions based on current values
* **Cross-partition conditional updates**: Conditional batches (using `IF` clauses) must operate on a single partition and table
* **Complex conditional logic**: `IF` clauses are limited to simple equality/inequality checks on existing column values
==== When to Use Accord Transactions Instead
Consider using xref:developing/cql/transactions.adoc[Accord transactions] when you need:
**Read-Modify-Write Patterns:**
[source,cql]
----
-- BATCH cannot check balance before transfer
BEGIN BATCH
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;
APPLY BATCH;
-- Transaction can validate before transfer
BEGIN TRANSACTION
LET sender = (SELECT balance FROM accounts WHERE user_id = 1);
IF sender.balance >= 100 THEN
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;
END IF
COMMIT TRANSACTION
----
**Complex Business Logic:**
NOTE: Row reference arithmetic in SET clauses and comparing two row references are not currently supported. Pass values as parameters instead.
[source,cql]
----
-- Application code:
-- double productPrice = 50.00; // Retrieved from products table
-- Transaction can enforce multi-step business rules
BEGIN TRANSACTION
LET user_account = (SELECT balance, status FROM accounts WHERE user_id = ?);
LET product_info = (SELECT quantity FROM products WHERE id = ?);
IF user_account.status = 'active'
AND user_account.balance >= ? -- Pass product_price as parameter
AND product_info.quantity > 0 THEN
UPDATE accounts SET balance = balance - ? WHERE user_id = ?; -- Pass product_price
UPDATE products SET quantity = quantity - 1 WHERE id = ?;
INSERT INTO orders (id, user_id, product_id, amount) VALUES (?, ?, ?, ?); -- Pass product_price
END IF
COMMIT TRANSACTION
----
**Error Prevention:**
Transactions prevent invalid operations like:
* Overdrawing accounts
* Selling out-of-stock items
* Creating duplicate records
* Violating business constraints
==== When to Continue Using BATCH
BATCH statements remain appropriate for:
* **Simple multi-table updates** without conditional logic
* **Same-partition operations** where isolation is sufficient
* **High-throughput scenarios** where transaction overhead isn't justified
* **Counter updates** (transactions don't support counters)
* **Backward compatibility** with existing applications
For detailed transaction syntax and examples, see the xref:developing/cql/transactions.adoc[Transactions] and xref:developing/cql/transactions-examples.adoc[Transaction Examples] documentation.

View File

@ -13,6 +13,9 @@ For that reason, when used in this document, these terms (tables, rows and colum
* xref:developing/cql/types.adoc[Data types] * xref:developing/cql/types.adoc[Data types]
* xref:developing/cql/ddl.adoc[Data definition language] * xref:developing/cql/ddl.adoc[Data definition language]
* xref:developing/cql/dml.adoc[Data manipulation language] * xref:developing/cql/dml.adoc[Data manipulation language]
* xref:developing/cql/transactions.adoc[Transactions]
* xref:developing/cql/transactions-examples.adoc[Transaction patterns]
* xref:developing/cql/transactions-migration.adoc[Transaction migration]
* xref:developing/cql/dynamic-data-masking.adoc[Dynamic data masking] * xref:developing/cql/dynamic-data-masking.adoc[Dynamic data masking]
* xref:developing/cql/operators.adoc[Operators] * xref:developing/cql/operators.adoc[Operators]
* xref:developing/cql/indexing/indexing-concepts.adoc[Indexing] * xref:developing/cql/indexing/indexing-concepts.adoc[Indexing]

View File

@ -0,0 +1,496 @@
= Accord Transaction Design Patterns
:page-nav-title: Transaction Patterns
This page provides common patterns and advanced design patterns for Accord transactions. These patterns solve common distributed system challenges that were difficult or impossible to address with eventual consistency.
For basic syntax and getting started, see xref:developing/cql/transactions.adoc[Accord Transactions]. For migration from LWT/BATCH, see xref:developing/cql/transactions-migration.adoc[Transaction Migration].
== Common Patterns
=== Read-Modify-Write
The fundamental transaction pattern: read current state, check a condition, then write. This covers conditional updates, conditional inserts, and any operation that must validate preconditions.
**Conditional update (e.g., sufficient balance):**
[source,cql]
----
BEGIN TRANSACTION
LET sender_account = (SELECT balance FROM accounts WHERE id = ?);
IF sender_account.balance >= ? THEN
UPDATE accounts SET balance = balance - ? WHERE id = ?;
UPDATE accounts SET balance = balance + ? WHERE id = ?;
INSERT INTO transactions (id, from_account, to_account, amount, timestamp)
VALUES (?, ?, ?, ?, toTimestamp(now()));
END IF
COMMIT TRANSACTION
----
**Conditional insert (e.g., prevent duplicates):**
[source,cql]
----
BEGIN TRANSACTION
LET existing_user = (SELECT user_id FROM email_index WHERE email = ? LIMIT 1);
IF existing_user IS NULL THEN
INSERT INTO users (id, email, created_at, status)
VALUES (?, ?, toTimestamp(now()), 'active');
INSERT INTO email_index (email, user_id) VALUES (?, ?);
INSERT INTO user_profiles (user_id, display_name)
VALUES (?, ?);
END IF
COMMIT TRANSACTION
----
=== Multi-Table Updates
Maintain referential integrity across tables:
[source,cql]
----
BEGIN TRANSACTION
UPDATE users SET status = 'suspended', suspended_at = toTimestamp(now())
WHERE id = ?;
UPDATE orders SET status = 'cancelled'
WHERE order_id = ?;
INSERT INTO audit_log (id, user_id, action, timestamp)
VALUES (?, ?, 'user_suspended', toTimestamp(now()));
COMMIT TRANSACTION
----
=== Cross-Partition Transactions
Coordinate updates across different partitions (see xref:developing/cql/transactions.adoc#row-reference-limitations[row reference limitations] for parameter passing):
[source,cql]
----
BEGIN TRANSACTION
LET user_data = (SELECT balance, status FROM users WHERE id = ?);
IF user_data.status = 'active' AND user_data.balance >= ? THEN
UPDATE users SET balance = balance - ? WHERE id = ?;
INSERT INTO orders (id, user_id, total, status, created_at)
VALUES (?, ?, ?, 'confirmed', toTimestamp(now()));
UPDATE inventory SET quantity = quantity - ? WHERE product_id = ?;
END IF
COMMIT TRANSACTION
----
== Pattern: Synchronous Unique Constraints
Cassandra's primary key enforces uniqueness, but what if you need uniqueness on a non-primary-key column like `email` or `username`? This pattern uses user-maintained lookup tables to enforce multiple unique constraints atomically.
=== The Challenge
You have a `users` table keyed by `user_id`, but you also need:
* Unique `email` addresses
* Unique `username` values
* The ability to **change** email or username while maintaining uniqueness
=== Schema
[source,cql]
----
CREATE TABLE users (
user_id uuid PRIMARY KEY,
username text,
email text,
display_name text,
created_at timestamp
) WITH transactional_mode = 'full';
-- Lookup tables for uniqueness enforcement
CREATE TABLE username_index (
username text PRIMARY KEY,
user_id uuid
) WITH transactional_mode = 'full';
CREATE TABLE email_index (
email text PRIMARY KEY,
user_id uuid
) WITH transactional_mode = 'full';
----
=== Creating a User with Unique Constraints
[source,cql]
----
BEGIN TRANSACTION
LET existing_username = (SELECT user_id FROM username_index WHERE username = ? LIMIT 1);
LET existing_email = (SELECT user_id FROM email_index WHERE email = ? LIMIT 1);
IF existing_username IS NULL AND existing_email IS NULL THEN
INSERT INTO users (user_id, username, email, display_name, created_at)
VALUES (?, ?, ?, ?, toTimestamp(now()));
INSERT INTO username_index (username, user_id) VALUES (?, ?);
INSERT INTO email_index (email, user_id) VALUES (?, ?);
END IF
COMMIT TRANSACTION
----
=== Changing a Username
Renaming requires atomically: (1) verifying the new name is available, (2) deleting the old index entry, and (3) inserting the new one. Without transactions, a crash between steps could leave orphaned index entries or allow duplicates.
[source,cql]
----
-- Application provides: user_id, old_username, new_username
BEGIN TRANSACTION
LET current_user = (SELECT username FROM users WHERE user_id = ?);
LET new_name_owner = (SELECT user_id FROM username_index WHERE username = ? LIMIT 1);
-- Verify: user exists, old username matches, new username is available
IF current_user IS NOT NULL
AND current_user.username = ? -- old_username parameter
AND new_name_owner IS NULL THEN
-- Update the user record
UPDATE users SET username = ? WHERE user_id = ?; -- new_username
-- Atomically swap index entries
DELETE FROM username_index WHERE username = ?; -- old_username
INSERT INTO username_index (username, user_id) VALUES (?, ?); -- new_username, user_id
END IF
COMMIT TRANSACTION
----
This pattern ensures the index tables are always consistent with the `users` table, even under concurrent modifications or partial failures.
== Pattern: Distributed State Machine
Many business objects follow a lifecycle with strict state transitions. An order might be `PENDING` -> `PAID` -> `SHIPPED` -> `DELIVERED`. Without transactions, concurrent operations (e.g., "cancel" and "ship") could both succeed, leaving the system in an invalid state.
=== The Challenge
* Ensure state transitions follow valid paths
* Prevent race conditions between competing operations
* Maintain audit trail of transitions
NOTE: The `IN` and `OR` operators are not currently supported in transaction `IF` conditions. To check multiple valid states, use **numeric status codes** with range comparisons (e.g., `status_code < 30` to mean "any state before SHIPPED").
=== Schema
[source,cql]
----
CREATE TABLE orders (
order_id uuid PRIMARY KEY,
customer_id uuid,
status_code int, -- 10=PENDING, 20=PAID, 30=SHIPPED, 40=DELIVERED, 99=CANCELLED
status_name text,
total_amount decimal,
updated_at timestamp
) WITH transactional_mode = 'full';
CREATE TABLE order_status_history (
order_id uuid,
transition_time timestamp,
from_status int,
to_status int,
actor_id uuid,
PRIMARY KEY (order_id, transition_time)
) WITH transactional_mode = 'full';
----
=== Valid Transition: PAID -> SHIPPED
[source,cql]
----
-- Application provides: order_id, actor_id
BEGIN TRANSACTION
LET current_order = (SELECT status_code FROM orders WHERE order_id = ?);
-- Only allow transition from PAID (20) state
IF current_order IS NOT NULL AND current_order.status_code = 20 THEN
UPDATE orders
SET status_code = 30, status_name = 'SHIPPED', updated_at = toTimestamp(now())
WHERE order_id = ?;
INSERT INTO order_status_history (order_id, transition_time, from_status, to_status, actor_id)
VALUES (?, toTimestamp(now()), 20, 30, ?);
END IF
COMMIT TRANSACTION
----
=== Handling Cancellation (Competing Transition)
Cancellation is only valid from certain states. If a "ship" and "cancel" operation race, exactly one will succeed.
[source,cql]
----
-- Application provides: order_id, actor_id
BEGIN TRANSACTION
LET current_order = (SELECT status_code, customer_id, total_amount FROM orders WHERE order_id = ?);
-- Cancellation allowed only from PENDING (10) or PAID (20) states.
-- Use range comparison (status_code <= 20) since IN/OR are not supported.
IF current_order IS NOT NULL
AND current_order.status_code <= 20 THEN
UPDATE orders
SET status_code = 99, status_name = 'CANCELLED', updated_at = toTimestamp(now())
WHERE order_id = ?;
INSERT INTO order_status_history (order_id, transition_time, from_status, to_status, actor_id)
VALUES (?, toTimestamp(now()), current_order.status_code, 99, ?);
END IF
COMMIT TRANSACTION
----
If a concurrent "ship" operation already moved the order to `SHIPPED` (30), the `status_code <= 20` condition fails and the cancellation is rejected. The application can check the result and inform the user.
== Pattern: Synchronous Denormalization
Cassandra best practices often involve denormalizing data for read performance. Keeping summary tables in sync with detail tables has traditionally been eventually consistent. Accord enables **synchronous denormalization** where aggregates are always accurate.
=== The Challenge
You have a `posts` table and want to maintain accurate counts per author without using counters (which aren't supported in transactions) and without eventual consistency lag.
=== Schema
[source,cql]
----
CREATE TABLE posts (
post_id uuid PRIMARY KEY,
author_id uuid,
title text,
content text,
status text, -- draft, published, archived
created_at timestamp
) WITH transactional_mode = 'full';
CREATE TABLE author_stats (
author_id uuid PRIMARY KEY,
draft_count bigint,
published_count bigint,
archived_count bigint,
last_post_at timestamp
) WITH transactional_mode = 'full';
----
=== Publishing a New Post (Increment Count)
This example demonstrates **idempotent creation**: if the application retries a failed publish request, the post won't be duplicated and the counter won't be incremented twice.
[source,cql]
----
-- Application provides: post_id, author_id, title, content
BEGIN TRANSACTION
-- Check if this post already exists (idempotency guard)
LET existing_post = (SELECT status FROM posts WHERE post_id = ?);
IF existing_post IS NULL THEN
-- Create the post
INSERT INTO posts (post_id, author_id, title, content, status, created_at)
VALUES (?, ?, ?, ?, 'published', toTimestamp(now()));
-- Synchronously update the count exactly once
UPDATE author_stats
SET published_count = published_count + 1,
last_post_at = toTimestamp(now())
WHERE author_id = ?;
END IF
COMMIT TRANSACTION
----
=== Changing Post Status (Transfer Between Counts)
When a post moves from `published` to `archived`, both counts must update atomically.
[source,cql]
----
-- Application provides: post_id, author_id
BEGIN TRANSACTION
LET current_post = (SELECT status, author_id FROM posts WHERE post_id = ?);
IF current_post IS NOT NULL AND current_post.status = 'published' THEN
-- Update post status
UPDATE posts SET status = 'archived' WHERE post_id = ?;
-- Atomically transfer between counts
UPDATE author_stats
SET published_count = published_count - 1,
archived_count = archived_count + 1
WHERE author_id = ?; -- author_id passed as parameter
END IF
COMMIT TRANSACTION
----
=== Deleting a Post (Decrement Count)
[source,cql]
----
-- Application provides: post_id, author_id, current_status
-- Application must query the post first to get status and author_id
BEGIN TRANSACTION
LET current_post = (SELECT status FROM posts WHERE post_id = ?);
IF current_post IS NOT NULL AND current_post.status = ? THEN -- current_status parameter
-- Delete the post
DELETE FROM posts WHERE post_id = ?;
-- Decrement the appropriate counter based on status
-- Application passes which counter to decrement based on current_status
UPDATE author_stats
SET published_count = published_count - ? -- pass 1 if published, 0 otherwise
WHERE author_id = ?;
END IF
COMMIT TRANSACTION
----
== Pattern: Multi-Entity Referential Integrity
Relational databases use foreign keys to prevent orphaned records. In Cassandra, you can achieve similar guarantees with transactions.
=== The Challenge
* A `Task` must belong to an existing `Project`
* When a `Project` is deleted, handle its `Tasks` appropriately
* Prevent creating tasks for non-existent projects
=== Schema
[source,cql]
----
CREATE TABLE projects (
project_id uuid PRIMARY KEY,
name text,
owner_id uuid,
status text, -- active, completed, deleted
task_count bigint,
created_at timestamp
) WITH transactional_mode = 'full';
CREATE TABLE tasks (
task_id uuid PRIMARY KEY,
project_id uuid,
title text,
status text, -- open, in_progress, done
assignee_id uuid,
created_at timestamp
) WITH transactional_mode = 'full';
-- Index for finding tasks by project (for cleanup operations)
CREATE TABLE tasks_by_project (
project_id uuid,
task_id uuid,
title text,
status text,
PRIMARY KEY (project_id, task_id)
) WITH transactional_mode = 'full';
----
=== Creating a Task (Enforce Parent Exists)
[source,cql]
----
-- Application provides: task_id, project_id, title, assignee_id
BEGIN TRANSACTION
LET project = (SELECT status FROM projects WHERE project_id = ?);
-- Only create task if project exists and is active
IF project IS NOT NULL AND project.status = 'active' THEN
INSERT INTO tasks (task_id, project_id, title, status, assignee_id, created_at)
VALUES (?, ?, ?, 'open', ?, toTimestamp(now()));
INSERT INTO tasks_by_project (project_id, task_id, title, status)
VALUES (?, ?, ?, 'open');
UPDATE projects SET task_count = task_count + 1 WHERE project_id = ?;
END IF
COMMIT TRANSACTION
----
=== Soft-Deleting a Project
Rather than cascading deletes (which would require iterating over all tasks), mark the project as deleted. Tasks can be cleaned up asynchronously or remain for audit purposes.
[source,cql]
----
-- Application provides: project_id
BEGIN TRANSACTION
LET project = (SELECT status FROM projects WHERE project_id = ?);
IF project IS NOT NULL AND project.status = 'active' THEN
UPDATE projects
SET status = 'deleted'
WHERE project_id = ?;
END IF
COMMIT TRANSACTION
----
Future task operations will fail the `project.status = 'active'` check, preventing modifications to a deleted project's tasks.
=== Moving a Task Between Projects
This pattern ensures both projects exist and are active, and maintains accurate task counts.
[source,cql]
----
-- Application provides: task_id, old_project_id, new_project_id, task_title, task_status
BEGIN TRANSACTION
LET task = (SELECT project_id, title, status FROM tasks WHERE task_id = ?);
LET old_project = (SELECT status FROM projects WHERE project_id = ?); -- old_project_id
LET new_project = (SELECT status FROM projects WHERE project_id = ?); -- new_project_id
IF task IS NOT NULL
AND task.project_id = ? -- verify task belongs to old_project_id
AND old_project.status = 'active'
AND new_project.status = 'active' THEN
-- Update task's project reference
UPDATE tasks SET project_id = ? WHERE task_id = ?; -- new_project_id
-- Update denormalized index: remove from old, add to new
DELETE FROM tasks_by_project WHERE project_id = ? AND task_id = ?; -- old_project_id
INSERT INTO tasks_by_project (project_id, task_id, title, status)
VALUES (?, ?, ?, ?); -- new_project_id, task_id, task_title, task_status
-- Update counts on both projects
UPDATE projects SET task_count = task_count - 1 WHERE project_id = ?; -- old_project_id
UPDATE projects SET task_count = task_count + 1 WHERE project_id = ?; -- new_project_id
END IF
COMMIT TRANSACTION
----
== Summary
These patterns demonstrate how Accord transactions solve problems that were previously difficult in Cassandra:
|===
| Pattern | Problem Solved | Key Technique
| Synchronous Unique Constraints
| Non-primary-key uniqueness
| Lookup index tables with atomic swap
| Distributed State Machine
| Race conditions in status changes
| IF condition guards valid transitions
| Synchronous Denormalization
| Stale aggregate counts, duplicate increments on retry
| Idempotent creation with atomic detail + summary updates
| Multi-Entity Referential Integrity
| Orphaned child records
| Parent existence check before child operations
|===
All patterns share common principles:
* **Read what you need**: Use LET to capture current state
* **Guard with IF**: Validate preconditions before modifications
* **Atomic updates**: All changes succeed or fail together
* **Pass computed values as parameters**: Row-reference arithmetic in SET/VALUES is not supported

View File

@ -0,0 +1,289 @@
= Migrating to Accord Transactions
:page-nav-title: Transaction Migration
This page covers migrating existing CQL patterns to Accord transactions, including LWT (Paxos), BATCH statements, and multi-statement workflows.
For transaction syntax and usage, see xref:developing/cql/transactions.adoc[Accord Transactions]. For design patterns, see xref:developing/cql/transactions-examples.adoc[Transaction Patterns].
== From Light Weight Transactions (LWT)
=== IF EXISTS
**Before (LWT):**
[source,cql]
----
UPDATE accounts SET balance = balance - 100
WHERE user_id = 12345
IF EXISTS;
----
**After (Accord):**
[source,cql]
----
BEGIN TRANSACTION
LET account_data = (SELECT balance FROM accounts WHERE user_id = 12345);
IF account_data IS NOT NULL THEN
UPDATE accounts SET balance = balance - 100 WHERE user_id = 12345;
END IF
COMMIT TRANSACTION
----
=== IF NOT EXISTS
**Before (LWT):**
[source,cql]
----
INSERT INTO users (id, email, status)
VALUES (?, ?, 'active')
IF NOT EXISTS;
----
**After (Accord):**
[source,cql]
----
BEGIN TRANSACTION
LET existing_user = (SELECT id FROM users WHERE id = ? LIMIT 1);
IF existing_user IS NULL THEN
INSERT INTO users (id, email, status) VALUES (?, ?, 'active');
END IF
COMMIT TRANSACTION
----
=== IF column = null (NULL Comparison)
[IMPORTANT]
====
LWT and Accord handle `IF column = null` differently. See <<Null Handling>> for complete details.
====
LWT treats `column = null` as "column is null," but Accord follows SQL semantics where `column = null` is always FALSE.
**Before (LWT):**
[source,cql]
----
-- LWT: Matches when email is null/missing
UPDATE users SET email = 'default@example.com'
WHERE id = ?
IF email = null;
----
**After (Accord):**
[source,cql]
----
BEGIN TRANSACTION
LET user_data = (SELECT email FROM users WHERE id = ?);
-- Must use IS NULL, not = null
IF user_data IS NOT NULL AND user_data.email IS NULL THEN
UPDATE users SET email = 'default@example.com' WHERE id = ?;
END IF
COMMIT TRANSACTION
----
=== Complex CAS with Multiple Operations
**Before (Multiple LWT operations with race condition risk):**
[source,cql]
----
UPDATE accounts SET balance = balance - 50
WHERE user_id = 12345 AND balance >= 50
IF balance >= 50;
-- Separate operation (not atomic with above)
INSERT INTO transaction_log (id, user_id, amount, timestamp)
VALUES (?, 12345, -50, toTimestamp(now()));
----
**After (Single atomic transaction):**
[source,cql]
----
BEGIN TRANSACTION
LET account = (SELECT balance FROM accounts WHERE user_id = 12345);
IF account.balance >= 50 THEN
UPDATE accounts SET balance = balance - 50 WHERE user_id = 12345;
INSERT INTO transaction_log (id, user_id, amount, timestamp)
VALUES (?, 12345, -50, toTimestamp(now()));
END IF
COMMIT TRANSACTION
----
== From BATCH Statements
Single-partition BATCH provides atomicity (all mutations apply or none do). Multi-partition (LOGGED) BATCH does not guarantee atomicity — it uses a batchlog for best-effort delivery. CAS can be used with BATCH for conditional checks, but only on a single partition, and if the condition fails the entire batch is rejected.
Accord transactions provide atomicity across multiple partitions with richer conditional logic:
**Before (Multi-partition BATCH - no atomicity guarantee, no condition checking):**
[source,cql]
----
BEGIN BATCH
UPDATE users SET balance = 400 WHERE id = ?; -- Application pre-computes new balance
INSERT INTO orders (id, user_id, amount) VALUES (?, ?, 100);
APPLY BATCH;
-- Problem: No atomicity across partitions, can't check if balance is sufficient!
-- Application had to read balance separately, race condition possible.
----
**After (Transaction with condition and cross-partition atomicity):**
[source,cql]
----
BEGIN TRANSACTION
LET user_data = (SELECT balance FROM users WHERE id = ?);
IF user_data.balance >= 100 THEN
UPDATE users SET balance = balance - 100 WHERE id = ?;
INSERT INTO orders (id, user_id, amount) VALUES (?, ?, 100);
END IF
COMMIT TRANSACTION
----
For single-partition operations that already use CAS BATCH, transactions provide equivalent guarantees with more flexible conditions:
**Before (CAS BATCH - single partition, single table only; condition fails = nothing applies):**
[source,cql]
----
BEGIN BATCH
UPDATE users SET balance = balance - 100 WHERE id = ?
IF balance >= 100;
UPDATE users SET last_action = 'debit' WHERE id = ?;
APPLY BATCH;
----
**After (Transaction - same guarantees, works across partitions and tables):**
[source,cql]
----
BEGIN TRANSACTION
LET user_data = (SELECT balance FROM users WHERE id = ?);
IF user_data.balance >= 100 THEN
UPDATE users SET balance = balance - 100 WHERE id = ?;
INSERT INTO user_history (user_id, action) VALUES (?, 'debit');
END IF
COMMIT TRANSACTION
----
== Null Handling
[IMPORTANT]
====
Accord transactions follow SQL standard null semantics, which differ from LWT (Paxos) behavior. This is a critical difference when migrating from LWT to Accord.
====
=== LWT vs Accord: NULL Comparison Behavior
|===
| Condition | Column Value | LWT (Paxos) Result | Accord Result
| `IF column = null`
| Column is NULL/missing
| **TRUE** (matches)
| **FALSE** (never matches)
| `IF column = null`
| Column has a value
| FALSE
| FALSE
| `IF column IS NULL`
| Column is NULL/missing
| TRUE
| TRUE
| `IF column != null`
| Column is NULL/missing
| FALSE
| **FALSE** (null comparisons always false)
| `IF column != null`
| Column has a value
| TRUE
| TRUE
|===
=== Why the Difference?
**LWT (Paxos) behavior:** LWT treats `column = null` as a special case meaning "column is null or missing." This is a Cassandra-specific convenience that deviates from SQL standards.
**Accord behavior:** Accord follows SQL standard semantics where `NULL` represents an unknown value. Comparing anything to `NULL` using `=`, `!=`, `<`, `>`, etc. always returns `FALSE` (or more precisely, `UNKNOWN`, which is treated as `FALSE` in conditionals). This is because you cannot know if an unknown value equals another value.
=== Migration from LWT
If your LWT code uses `IF column = null`, you must update it when migrating to Accord transaction syntax.
NOTE: This does not affect LWT statements executed on Accord-enabled tables. Those statements retain their original LWT null semantics.
**Before (LWT):**
[source,cql]
----
-- LWT: This returns TRUE when balance is null/missing
UPDATE users SET balance = 100 WHERE id = ? IF balance = null;
----
**After (Accord):**
[source,cql]
----
BEGIN TRANSACTION
LET user_data = (SELECT balance FROM users WHERE id = ?);
-- Use IS NULL for null checks
IF user_data IS NOT NULL AND user_data.balance IS NULL THEN
UPDATE users SET balance = 100 WHERE id = ?;
END IF
COMMIT TRANSACTION
----
TIP: Null checks are recursive. If `user_data` is null (row doesn't exist), then `user_data.balance` is also null. So `IF user_data.balance IS NULL` will be true both when the row doesn't exist AND when the row exists but the column is null. Use `user_data IS NOT NULL` first only if you need to distinguish between these cases.
=== Common Null Patterns
**Check if a column is null (row missing OR column unset):**
[source,cql]
----
IF user_data.balance IS NULL THEN
-- Either row doesn't exist, OR row exists but balance is null
END IF
----
**Check if a row exists (regardless of column value):**
[source,cql]
----
IF user_data IS NOT NULL THEN
-- Row exists (balance could be null or have a value)
END IF
----
**Check if a column is null but row exists:**
[source,cql]
----
IF user_data IS NOT NULL AND user_data.balance IS NULL THEN
-- Row exists AND balance is null (distinguishes from missing row)
END IF
----
**Check if a column has a specific value:**
[source,cql]
----
IF user_data IS NOT NULL AND user_data.balance = 100 THEN
-- Row exists AND balance equals 100
END IF
----
**Check if a row does not exist:**
[source,cql]
----
IF user_data IS NULL THEN
-- Row does not exist
END IF
----
=== Null Propagation Rules
* Any comparison with null using `=`, `!=`, `<`, `>`, `<=`, `>=` returns **FALSE**
* Only `IS NULL` and `IS NOT NULL` can meaningfully test for null
* Arithmetic operations with null return null
* Null columns in row references return null (not errors)
* Accessing a field on a null row reference (e.g., `missing_row.column`) returns null

View File

@ -0,0 +1,500 @@
= Accord Transactions
:page-nav-title: Transactions
Accord provides strong consistency and ACID guarantees for Cassandra operations.
When enabled on a table, **all CQL operations automatically execute through Accord** - no code changes required.
For complex multi-step operations, explicit transaction syntax (`BEGIN TRANSACTION ... COMMIT TRANSACTION`) allows you to read, apply conditions, and write atomically across multiple partitions and tables.
== Overview
=== Key Benefits
* **Automatic Strong Consistency**: Normal CQL reads and writes become linearizable when `transactional_mode='full'`
* **ACID Guarantees**: Atomicity, Consistency, Isolation, and Durability across multiple operations
* **Multi-Partition Consistency**: Coordinate updates across different partition keys
* **Multi-Table Support**: Update multiple tables atomically within a single transaction
* **Complex Business Logic**: Support for conditional operations with multiple steps
=== When to Use Explicit Transactions
While normal CQL operations are automatically transactional with `transactional_mode='full'`, use explicit `BEGIN TRANSACTION ... COMMIT TRANSACTION` syntax when you need:
* **Read-Modify-Write Patterns**: Check a condition before making changes
* **Complex Business Logic**: Multi-step operations that must be atomic
* **Cross-Partition Operations**: Updates that span multiple partition keys
* **Multi-Table Atomicity**: Ensure related changes across tables succeed or fail together
=== Safety & Consistency
Accord ensures data integrity through:
* **Strict Serializability**: Transactions execute as if in a single, total order that respects real-time ordering
* **Conflict Detection**: Automatic handling of concurrent access to the same data
* **Atomic Commitment**: All changes commit together or none at all
* **Durable Writes**: Committed transactions survive node failures
== Getting Started
=== Prerequisites
Before using transactions:
. **Enable Accord globally** in `cassandra.yaml`:
+
[source,yaml]
----
accord:
enabled: true
----
. **Enable transactional mode on tables**:
+
[source,cql]
----
CREATE TABLE users (
id UUID PRIMARY KEY,
email text,
balance decimal
) WITH transactional_mode = 'full';
----
See <<transactional-modes>> for detailed mode explanations.
=== Normal CQL Operations Are Transactional
When a table has `transactional_mode='full'`, your existing CQL statements are automatically executed through Accord. **You do not need to rewrite your application code.**
[source,cql]
----
-- These normal CQL operations are automatically transactional:
-- Reads are executed through Accord
SELECT id, email, balance FROM users WHERE id = 123e4567-e89b-12d3-a456-426614174000;
-- Writes are executed through Accord
INSERT INTO users (id, email, balance) VALUES (123e4567-e89b-12d3-a456-426614174000, 'user@example.com', 100.00);
UPDATE users SET balance = 50.00 WHERE id = 123e4567-e89b-12d3-a456-426614174000;
DELETE FROM users WHERE id = 123e4567-e89b-12d3-a456-426614174000;
----
Each statement executes as an individual Accord transaction, providing linearizability, consistency, and durability. Migrating to Accord can be as simple as enabling `transactional_mode='full'` on your tables. See <<transactional-modes>> for all available modes.
[[transactional-modes]]
== Transactional Modes
Tables must be configured with one of these transactional modes:
=== transactional_mode='off' (Default)
* No Accord transaction support
* Uses traditional Cassandra behavior
* Lightweight transactions use Paxos protocol
* Cannot participate in Accord transactions
**When to Use:**
* Tables not ready for transaction migration
* High-throughput tables where transaction overhead isn't justified
* Tables with existing Paxos-based logic that works well
[source,cql]
----
CREATE TABLE tbl (...)
WITH transactional_mode = 'off';
----
[[transactional_mode_mixed_reads]]
=== transactional_mode='mixed_reads'
NOTE: Most users should migrate directly from `off` to `full`. This mode is only needed for specific scenarios where you must mix transactional and non-transactional access on the same table during a transition period.
* Non-SERIAL writes are routed through Accord but committed at the supplied consistency level
* Allows non-SERIAL reads to see transactionally written data
* Blocking read repair is routed through Accord to avoid exposing uncommitted data
**When to Use:**
* Applications that **require** mixed transactional and non-transactional access on the same table simultaneously
**Trade-offs:**
* **Slower transactions**: Accord cannot perform single-replica read optimization because it must ensure data is readable at the non-SERIAL consistency level
* **Read repair overhead**: Accord must repair stale replicas during reads
* **Not recommended for most users**: Direct migration from `off` to `full` is simpler and provides better performance
[source,cql]
----
ALTER TABLE tbl WITH transactional_mode = 'mixed_reads';
----
=== transactional_mode='full' (Recommended)
* All reads and writes must occur through Accord transactions
* Enables single-replica reads since non-transactional readers don't exist
* Best transaction performance
**When to Use:**
* New tables that will use transactions
* Tables migrating from `off` mode (recommended for most users)
* Maximum transaction performance required
**Why this mode is faster:**
* **Single-replica reads**: Accord can read from a single replica and still provide correct results
* **No read repair overhead**: Accord doesn't need to repair data for non-transactional readers
[source,cql]
----
CREATE TABLE tbl (...)
WITH transactional_mode = 'full';
----
== Transaction Syntax
=== Basic Structure
All transactions follow this pattern:
[source,cql]
----
BEGIN TRANSACTION
[LET assignments]
[SELECT statements]
[IF conditions THEN]
[modification statements]
[END IF]
COMMIT TRANSACTION
----
=== LET Assignments
LET statements read data and bind it to variables for use later in the transaction:
[source,cql]
----
BEGIN TRANSACTION
LET user_data = (SELECT id, balance FROM users WHERE id = ?);
LET account_data = (SELECT account_type FROM accounts WHERE user_id = ?);
IF user_data.balance > 100 AND account_data.account_type = 'premium' THEN
UPDATE users SET balance = balance - 50 WHERE id = ?;
END IF
COMMIT TRANSACTION
----
**LET Requirements:**
* Each LET must specify a unique variable name
* SELECT must return exactly one row (use `LIMIT 1` if needed)
* All partition key columns must be specified with equality operators
* Cannot use `ORDER BY`, `GROUP BY`, or aggregation functions
* Cannot use range queries or multi-partition operations
**Valid LET Examples:**
[source,cql]
----
LET user_data = (SELECT balance, status FROM users WHERE id = ?);
LET order_info = (SELECT total, shipping_fee FROM orders WHERE id = ? LIMIT 1);
LET static_config = (SELECT max_attempts FROM config WHERE setting_type = 'retry');
----
**Invalid LET Examples:**
[source,cql]
----
-- Missing LIMIT 1 with potential multiple results
LET users = (SELECT * FROM users WHERE status = 'active');
-- Range query not allowed
LET recent = (SELECT * FROM events WHERE id > ? AND id < ?);
-- Aggregation not supported
LET total = (SELECT COUNT(*) FROM orders WHERE user_id = ?);
----
=== Row References
Access fields from LET variables using dot notation:
[source,cql]
----
BEGIN TRANSACTION
LET current_user = (SELECT balance, status FROM users WHERE id = ?);
-- Access fields with dot notation
SELECT current_user.balance, current_user.status;
COMMIT TRANSACTION
----
[[row-reference-limitations]]
**Row Reference Limitations:**
Row reference arithmetic in SET clauses and row references in VALUES are not currently supported. Pass values as parameters instead.
[source,cql]
----
-- Application code computes values, passes as parameters
BEGIN TRANSACTION
LET user_data = (SELECT balance FROM users WHERE id = ?);
IF user_data.balance >= ? THEN -- Pass order_total as parameter
UPDATE users SET balance = balance - ? WHERE id = ?; -- Pass order_total
END IF
COMMIT TRANSACTION
----
=== Returning Results
Return data from transactions using SELECT statements that appear before any modifications. You can use either row reference values or a normal single-partition SELECT.
NOTE: If no SELECT statement is present, the transaction returns an empty result set regardless of whether the IF condition was satisfied. Use a SELECT before modifications to observe transaction state.
==== Using Row References
[source,cql]
----
BEGIN TRANSACTION
LET user_data = (SELECT balance, status FROM users WHERE id = ?);
-- Return row reference values (must come before UPDATE)
SELECT user_data.balance, user_data.status;
UPDATE users SET balance = balance - 50 WHERE id = ?;
COMMIT TRANSACTION
----
==== Using a Normal SELECT
[source,cql]
----
BEGIN TRANSACTION
LET user_data = (SELECT balance FROM users WHERE id = ?);
-- Return data directly from table (must come before UPDATE)
SELECT balance, status, email FROM users WHERE id = ?;
IF user_data.balance >= 50 THEN
UPDATE users SET balance = balance - 50 WHERE id = ?;
END IF
COMMIT TRANSACTION
----
**Important:** SELECT statements must appear before any UPDATE, INSERT, or DELETE statements. To retrieve updated values, query outside the transaction after it commits.
=== Conditional Logic
Add conditional logic to transactions with IF blocks:
[source,cql]
----
BEGIN TRANSACTION
LET sender = (SELECT balance FROM accounts WHERE user_id = ?);
IF sender.balance >= 100 THEN
UPDATE accounts SET balance = balance - 100 WHERE user_id = ?;
UPDATE accounts SET balance = balance + 100 WHERE user_id = ?;
END IF
COMMIT TRANSACTION
----
**Supported Operators:**
* Comparison: `=`, `<`, `<=`, `>`, `>=`, `!=`
* Null checks: `IS NULL`, `IS NOT NULL`
* Logical: `AND` only
**Complex Condition Examples:**
[source,cql]
----
-- Multiple conditions with AND
IF user_data.balance >= 100 AND user_data.status = 'active'
AND user_data.credit_limit > 150 THEN
-- statements
END IF
-- Null checking
IF account_info IS NOT NULL AND account_info.balance > 0 THEN
-- statements
END IF
----
**Important Notes:**
* Null handling is strict (any null comparison returns false)
* All modification statements must be inside the IF block when using conditions
== Restrictions
The following features cannot be used within `BEGIN TRANSACTION ... COMMIT TRANSACTION` blocks:
* **Counter tables** and counter operations
* **Aggregation functions** (COUNT, SUM, AVG, etc.)
* **ORDER BY** and **GROUP BY** clauses
* **Custom TTL** and **timestamp** specifications (transaction manages timestamps)
* **Range DELETE** operations (must specify complete primary key)
* **Non-equality partition key** restrictions in LET/SELECT
* **Individual statement conditions** (use transaction-level IF blocks instead)
=== Schema Requirements
* Accord must be enabled in `cassandra.yaml`
* Tables must have a `transactional_mode` other than `'off'`
* Tables being dropped cannot participate in transactions
== Syntax Reference
=== Complete Grammar
[source,cql]
----
BEGIN TRANSACTION
[LET letStatements]
[SELECT selectStatement | SELECT rowDataReferences]
[IF conditionalExpression THEN]
[modificationStatements]
[END IF]
COMMIT TRANSACTION
----
=== Modification Statements
**INSERT:**
[source,cql]
----
INSERT INTO table (columns) VALUES (values);
----
**UPDATE:**
[source,cql]
----
UPDATE table SET assignments WHERE conditions;
----
**DELETE:**
[source,cql]
----
DELETE [columns] FROM table WHERE conditions;
----
== Multi-Partition Coordination
Cross-partition transactions add coordination overhead:
[source,cql]
----
BEGIN TRANSACTION
LET user1 = (SELECT balance FROM accounts WHERE user_id = ?);
LET user2 = (SELECT balance FROM accounts WHERE user_id = ?);
IF user1.balance >= 100 THEN
UPDATE accounts SET balance = balance - 100 WHERE user_id = ?;
UPDATE accounts SET balance = balance + 100 WHERE user_id = ?;
END IF
COMMIT TRANSACTION
----
**Performance Impact:**
* Same-partition transactions: Low overhead
* Cross-partition transactions: Moderate overhead
* More partitions = higher coordination cost
**Optimization:** Minimize cross-partition operations; consider data model changes to reduce cross-partition transactions.
== Automatic Read Generation
When UPDATE statements reference current column values, Accord automatically reads the current state:
[source,cql]
----
BEGIN TRANSACTION
-- This UPDATE automatically reads current balance
UPDATE users SET balance = balance + ? WHERE id = ?;
COMMIT TRANSACTION
----
**Triggers:**
* SET clauses that reference current column values
== Troubleshooting
=== Configuration Errors
**Accord transactions are disabled**
Enable in cassandra.yaml:
[source,yaml]
----
accord:
enabled: true
----
**Accord transactions are disabled on table**
Enable on table:
[source,cql]
----
ALTER TABLE tbl WITH transactional_mode = 'full';
----
=== Syntax Errors
**Duplicate variable names**
Use unique variable names:
[source,cql]
----
-- Bad
LET user_data = (SELECT balance FROM accounts WHERE user_id = 1);
LET user_data = (SELECT balance FROM accounts WHERE user_id = 2);
-- Good
LET sender_data = (SELECT balance FROM accounts WHERE user_id = 1);
LET receiver_data = (SELECT balance FROM accounts WHERE user_id = 2);
----
=== Performance Issues
**High latency investigation:**
. Check transaction complexity (LET count, partition count)footnote:[Keep LET statements under 5 and partitions under 3 as a starting point. These are suggested guidelines, not empirically derived limits.]
. Monitor cross-datacenter operations
. Check for high contention on specific keys
**Large result sets:**
Select only needed columns:
[source,cql]
----
-- Bad: Reading entire row
LET user_history = (SELECT * FROM large_history_table WHERE user_id = ? LIMIT 1);
-- Good: Only read necessary data
LET user_status = (SELECT status, last_login FROM users WHERE id = ?);
----
=== Counter Table Alternative
Counters cannot be used in transactions. Use regular columns:
[source,cql]
----
-- Bad: Counter in transaction
CREATE TABLE page_views (
page_id uuid PRIMARY KEY,
views counter
) WITH transactional_mode = 'full';
-- Good: Regular column
CREATE TABLE page_views (
page_id uuid PRIMARY KEY,
views bigint
) WITH transactional_mode = 'full';
BEGIN TRANSACTION
UPDATE page_views SET views = views + 1 WHERE page_id = ?;
COMMIT TRANSACTION
----