Stacksaga Cassandra Reactive Support
Overview
stacksaga-cassandra-reactive-support is the Cassandra reactive (non-blocking) implementation of the Stacksaga Event Store.
It provides all the necessary facilities for accessing the Cassandra database for the Stacksaga engine, and exposes endpoints for accessing the event store to display tracing details in the StackSaga Trace-Window.
It is used in the orchestrator service alongside any StackSaga orchestrator starter. See Saga Orchestrator Engines for the full list of available implementations and their respective starters.
|
Retry Ordering Guarantee (No Strict FIFO in Cassandra): Consequently, Cassandra does not guarantee strict chronological retry ordering within the same schedule window. If your business domain strictly requires exact FIFO retry ordering, Cassandra is not recommended as the event-store. Architectural Recommendation for Strict FIFO Requirements: |
Adding cassandra-support to your orchestrator application
Adding cassandra-support to your orchestrator application consists of 3 simple steps.
The following sections will explain each step in detail.
Adding stacksaga-cassandra-reactive-support as a dependency
Here is the way that you can add the library into your existing orchestrator application as a dependency.
stacksaga-cassandra-reactive-support as a dependency<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.stacksaga</groupId>
<artifactId>stacksaga-bom</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependency>
<groupId>org.stacksaga</groupId>
<artifactId>stacksaga-cassandra-reactive-support</artifactId>
</dependency>
| It is recommended to use StackSaga Initializer to get the dependency snippets for your project for StackSaga related dependencies, as it will ensure you have the correct versions and configurations for your project setup. |
Generate Schema
Because StackSaga creates some dynamic tables based on the service name, region, and cluster for saving transaction data, a schema creation tool is provided for your convenience.
Understanding Region and Virtual Cluster
Before generating the schema, it is important to understand what region and cluster mean in the StackSaga context — because these two inputs directly determine the names of the generated tables.
What is "Region"?
A region maps to your physical deployment location (e.g. us-central, asia-south).
Every transaction is stamped with the region of the orchestrator that created it.
This ensures that recovery and retry operations in one region never interfere with those in another — even when both share the same Cassandra cluster.
What is a "Virtual Cluster"?
By default, a region contains exactly one cluster (a one-to-one relationship — for example, region us-central maps to a single cluster also named us-central).
However, for large-scale deployments you can create multiple virtual clusters within the same physical region (e.g. us-central-c1, us-central-c2, us-central-c3).
Each virtual cluster gets its own independent set of generated tables (es_rt_* and es_irt_*) and its own dedicated Retry Ring Coordinator deployment.
This lets you split the retry subsystem into isolated streams — if one virtual cluster’s coordinator fails, the others continue operating normally.
See Virtual Clusters for the full conceptual explanation and Ring-Coordinator Deployment Topologies to understand how the Ring-Coordinator and Retry-Nodes are deployed in practice across single and multiple virtual clusters.
How does this affect table names?
The generated tables are named using a hash suffix derived from the combination of service name, region, and cluster.
This hash-based approach keeps table names short and avoids issues with special characters in region or cluster identifiers.
You never need to construct these names manually — the schema generator tool produces the correct names for you based on your inputs.
Schema Generator
Enter your service name, region, and cluster name to generate and download the .cql schema script for your Cassandra database.
| If you deploy the application in multiple regions or clusters, you must run the generator separately for each combination of service name, region, and cluster. Each run produces a distinct set of Generated Tables (with a different hash suffix), while the Constant Tables are the same across all runs. |
How Transaction Data Is Stored in Cassandra
The primary responsibility of the Cassandra database support implementation is providing the facilities for saving transaction data in the event store. The approach to saving transaction data is quite different from SQL support implementations due to Cassandra’s data modeling characteristics — Denormalized Schema, clustering, partitioning, and the absence of centralized indexing.
Table Overview
All tables used by StackSaga fall into two categories:
Constant Tables — table names never change regardless of service, region, or cluster. These are created once as part of the schema script and are shared across all deployments:
| Table | Purpose |
|---|---|
|
Main transaction metadata store |
|
Transaction execution step (tryout) records |
|
Write protection and duplicate detection for Kafka event streaming |
|
Compensation failed transactions requiring manual intervention |
Generated Tables — table names include a hash suffix derived from your service name, region, and cluster. A separate set is created for each combination of service, region, and cluster:
| Table Pattern | Purpose |
|---|---|
|
Transaction recovery retention (crash / missing transaction tracking) |
|
Immediate retry retention (paused transaction tracking) |
The {hash} suffix is computed by the schema generator tool from your service name, region, and cluster name.
|
es_transaction table
es_transaction is the main table where transaction metadata is saved in the event store, using the transaction ID as the Partition Key.
All transactions are saved in this table as Single-Row Partition.
The Single-Row Partition approach enables handling millions of transactions without creating database hotspots across Cassandra nodes, because transactions are distributed across all available nodes efficiently as shown below.
Even though Single-Row Partition helps overcome the database hotspot problem, data cannot be fetched without knowing the exact transaction key.
This makes it impossible to bulk-scan for transactions needing recovery or retry.
This is addressed by the es_rt_* recovery tables and es_irt_* retry tables described below.
|
es_transaction_tryout table
es_transaction_tryout is the table where transaction tryout data is saved, using the transaction ID as the Partition Key.
Transaction tryouts are saved under the transaction ID with a Multi-Row Partition approach.
Because tryout data is keyed by the same transaction ID, it is stored on the same Cassandra node as the parent transaction in es_transaction.
This colocation optimizes network latency — all related data flows to and from the same node.
As shown in the diagram above, tryout data is saved on the same node as the parent transaction.
If the tx-8dKz7LpJ2Q transaction metadata is saved on Node-1, the tx-8dKz7LpJ2Q tryout data is also saved on Node-1.
es_execution_markers table
es_execution_markers is the table used by StackSaga’s Cassandra reactive event store to track individual executed records/spans and enforce write protection.
For complete details on how this table prevents duplicate execution in Kafka event streaming and guards against concurrent race conditions, see Write Protection & Duplicate Prevention.
es_frozen_transaction table
es_frozen_transaction is a constant table that stores Compensation Failed Transactions — transactions where a compensation span itself failed with a permanent, non-retryable error, leaving the transaction stuck in a partially compensated state.
These transactions cannot be retried automatically and require manual developer intervention: the team diagnoses the root cause, deploys a fix, and manually restores the transaction via the restore endpoint.
See Compensation Failed Transactions for the full explanation of this failure category.
Two Tracking Problems: Recovery vs. Retry
Cassandra’s partition-based distribution prevents bulk filtering of transactions directly from the main transaction table (see Why Bulk Filtering is Not Possible in Cassandra for details). To track transactions that need attention, StackSaga uses two separate sets of generated tables — each solving a fundamentally different problem:
| Problem | Table Set | When a record is added / deleted |
|---|---|---|
Transaction paused due to transient error |
|
Added only when the transaction stops due to a retryable error. |
Transaction crashed / went missing |
|
Added when the transaction is initialized. |
Both table sets use the same three-table rotation mechanism, but with very different time windows — typically hours for recovery and minutes for retry.
Why Bulk Filtering is Not Possible in Cassandra
-
Partition-Based Distribution
Cassandra shards data across multiple nodes using the transaction ID as the partition key. Because rows are distributed across the entire cluster, querying transactions based on a field (such as status or timestamp) without knowing the exact partition key requires contacting multiple nodes, making global status queries inefficient and impractical at scale. -
No Centralized Indexing for Status-Based Queries
In a relational database, finding pending transactions is as simple as runningWHERE status = 'PENDING'. In Cassandra, secondary indexes on low-cardinality columns (like status) cause severe performance degradation across cluster nodes. Consequently, status-based bulk filtering directly on the main transaction table is not feasible.
The Alternative: Dedicated Rotating Tables as Status Queues
Because Cassandra cannot filter transactions by status in-place, StackSaga adopts an architectural solution where table membership itself represents the transaction’s state:
-
Presence in
es_irt_* = The transaction paused due to a transient error and needs retry. -
Presence in
es_rt_* = The transaction was initialized and has not yet completed. If it remains in the table past the retention window, it is considered crashed or missing and needs recovery. -
State Transition via Deletion: Once a transaction is picked up and referred for execution by a Retry-Node, its row is immediately deleted from the table. Deletion acts as the status update.
Why Are There Three Tables per Category?
Both recovery (es_rt_*) and retry (es_irt_*) use a rotating set of three tables (_1, _2, and _3):
-
Each table is assigned to a specific scheduled time slot in a round-robin rotation.
-
Dividing the retention period across three tables allows the system to drain and process batches on a predictable schedule without requiring expensive time-range scans across Cassandra partitions.
Why Tables Are Generated per Service, Region, and Virtual Cluster
Generated tables (es_rt_* and es_irt_*) carry a hash suffix derived from the service name, region, and virtual cluster:
-
Regional and Cluster Isolation: Retry-Nodes only access tables matching their own service, region, and virtual cluster. A Retry-Node in
us-east-1never touches data originating fromus-west-1, even in a globally shared Cassandra cluster. -
Independent Scaling: Virtual clusters can scale up or down independently and deploy dedicated Retry Ring Coordinators without cross-cluster interference.
-
Clean Partition Scopes: Schema scripts generate distinct table sets for each cluster, keeping individual tables compact and manageable.
How Retry-Nodes Process These Tables (Token Range-Based Batch Fetching)
|
Why the table itself acts as the status indicator |
The actual fetching and re-invocation is done by Retry-Nodes — orchestrator instances that have the Ring-Coordinator Connector added and enabled. The Retry Ring Coordinator (Master + Slave) is not responsible for touching the database. Its only role is to assign each Retry-Node a token range — a unique, non-overlapping slice of the Cassandra token ring — so that no two Retry-Nodes ever fetch the same rows simultaneously.
| The way Ring-Coordinator instances and Retry-Nodes are deployed together depends on the scale and virtual cluster configuration of your system. See Ring-Coordinator Deployment Topologies for the available deployment options — from an embedded single-instance setup to fully clustered multi-virtual-cluster deployments. |
Here is how a Retry-Node processes a table (the same flow applies to both es_rt_* and es_irt_*):
-
Receive a token range from the coordinator
The Retry Ring Coordinator pushes the Retry-Node its assigned token range (e.g., token1to1000) for the current time window. The Ring Coordinator itself never connects to the Cassandra database; it only manages and assigns token ranges across active Retry-Nodes. -
Determine the target table
Based on the configured schedule, the Retry-Node identifies which table slot is currently active (e.g.,es_irt_1_{hash}at 00:00,es_irt_2_{hash}at 00:02, etc.). -
Fetch a batch by token range (without specifying partition keys)
Because we need to fetch a batch and do not know in advance which transaction IDs are in the table, we query using Cassandra’stoken()function across our assigned range rather than filtering on specific partition keys or statuses:SELECT * FROM es_irt_1_{hash} WHERE token(transaction_id) >= 1 AND token(transaction_id) <= 1000 LIMIT 100; -- controlled by stacksaga.cassandra.transaction.stream.batch-sizeTargeted Node Retrieval (No Full Cluster Scan): Cassandra’s broker knows exactly which physical nodes hold partitions within token range
[1, 1000]. If the data within that range spans across 10 nodes in the cluster, the broker routes requests strictly to those 10 nodes and aggregates the results — it never queries all nodes in the cluster. The Retry-Node receives up tobatch-size(e.g., 100) records. -
Re-invoke the transactions
Each fetched transaction is immediately referred for execution, starting from the last successfully recorded span. -
Delete the rows immediately after referral
Once a transaction is handed over for re-invocation, its row is deleted from the table:DELETE FROM es_irt_1_{hash} WHERE transaction_id = :transaction_id;Why immediate deletion is mandatory: In Cassandra, tables do not support in-place status mutation without incurring heavy secondary indexing penalties. Instead, table membership itself represents status. Deleting the row immediately upon batch retrieval marks it as processed and prevents that same transaction from being exposed again in the subsequent batch fetch.
-
Continuous long-polling with configured delay
Even if a batch query returns empty (no records found), the Retry-Node does not stop. It maintains a continuous long-polling loop with a small configured delay throughout the active time window, ensuring that any new transactions arriving in the table partition are picked up promptly. Once the time window expires, the Retry-Node advances to the next scheduled table slot.
|
Why is this safe with multiple Retry-Nodes running in parallel? |
Retry & Recovery Ordering Guarantee: Token Order vs. Strict FIFO
When designing a distributed transaction retry strategy, understanding how the database driver orders retrieved batches is critical.
How SQL Implementations Achieve Strict FIFO Ordering
In SQL implementations, identifying transactions to retry or recover is executed via indexed queries with explicit sorting:
SELECT * FROM saga_retry_queue
WHERE status = 'PAUSED'
ORDER BY paused_time ASC
LIMIT 100;
Because the SQL database sorts by timestamp, transactions are retried in strict First-In, First-Out (FIFO) order: a transaction that paused at 00:02:10 is guaranteed to be retried before a transaction that paused at 00:02:40.
How Cassandra Processes Batches: Murmur3 Token Order
In Cassandra, rows are partitioned across cluster nodes using a hashing function (by default, Murmur3Partitioner).
As explained in the batch retrieval steps above, Retry-Nodes do not query by timestamp; they query by the Murmur3 token hash of the transaction ID:
SELECT * FROM es_irt_1_{hash}
WHERE token(transaction_id) >= :start_token AND token(transaction_id) <= :end_token
LIMIT 100;
Cassandra returns matching rows ordered by the numerical value of token(transaction_id), not by the timestamp when the transaction was paused or created.
Consider two transactions that encountered transient errors within the same schedule window (both saved into es_irt_1_{hash}):
-
tx-1: Paused at00:02:10→token(tx-1) = 850 -
tx-2: Paused at00:02:40→token(tx-2) = 150
When the Retry-Node queries token range [1, 1000]:
-
Because
token(tx-2)(150) is smaller thantoken(tx-1)(850), Cassandra returnstx-2beforetx-1. -
As a result,
tx-2is fetched and retried beforetx-1, even thoughtx-1failed 30 seconds earlier.
|
This reordering occurs only within the same scheduled time window (i.e. transactions co-located in the same table slot).
Transactions assigned to different table slots (for example, |
Architectural Guidance: When to Pair Cassandra with an SQL Event Store
In the vast majority of microservice architectures, transient errors (such as network blips or temporary resource limits) resolve within seconds or minutes, making minor non-FIFO execution variations within a 1-to-2 minute window completely acceptable.
However, if your business domain contains strict sequential requirements (for example, financial order books or sequential inventory allocation where tx-1 must strictly precede tx-2 under all circumstances):
-
Default Rule: StackSaga strongly recommends keeping the event-store technology aligned with the system’s primary database to avoid the operational overhead of managing multiple database engines.
-
The FIFO Exception: If your application uses Cassandra as its primary database but strictly requires exact FIFO retry ordering, deploy StackSaga with one of the SQL database support modules for the event-store. This hybrid pattern provides the best of both worlds: Cassandra delivers massive write throughput for your primary domain operations, while the SQL event-store guarantees strict chronological FIFO ordering for saga retries and recovery.
es_irt_* tables (Immediate Retry Retention)
In StackSaga, asynchronous transaction retrying is an essential feature that ensures transactions are retried when they fail due to transient errors (such as resource unavailability or temporary network timeouts).
When a transaction fails with a retryable error, it is stored in one of the active es_irt_* tables.
At the next scheduled time window, a Retry-Node fetches it by token range, re-invokes it, and deletes the row immediately using the token range batch processing flow described above.
The key difference between immediate retry retention (es_irt_*) and transaction recovery retention (es_rt_*) is the time window: retry retention uses a much shorter delay (minutes) compared to recovery retention (hours), since paused transactions are expected to be retried quickly once the downstream resource becomes available again.
|
es_irt_* table selection formula
Suppose you configure the Transaction retry retention time to be 2 minutes. The actual time a transaction waits before being retried will naturally oscillate between 1 minute and 3 minutes.
Why does this oscillation happen?
When the retry delay is set to 2 minutes, 3 scheduled rotation slots run in round-robin fashion throughout the day:
-
00:00 →
es_irt_1_{hash} -
00:02 →
es_irt_2_{hash} -
00:04 →
es_irt_3_{hash} -
00:06 →
es_irt_1_{hash} -
00:08 →
es_irt_2_{hash} -
and so on…
The Challenge: Preventing Premature Retries
Imagine a transaction encounters a transient error and pauses at 00:03:58.
The next scheduled batch runs at 00:04:00 — just 2 seconds later.
If we placed that transaction into es_irt_3_{hash} (the 00:04:00 table), it would be retried after only 2 seconds!
Retrying immediately would defeat the purpose of the 2-minute cooldown and risk overwhelming an already struggling downstream service.
Every transaction must be guaranteed a fair minimum cooldown period (at least half of the configured delay).
The Solution: The Midpoint Boundary Rule
To solve this, StackSaga divides each 2-minute scheduling interval in half using the midpoint as a boundary point:
-
For the active interval between
00:02:00and00:04:00, the configured duration is 2 minutes, making00:03:00the midpoint boundary.
Transaction Placement Rule:
-
First Half (Before Midpoint —
00:02:00to00:03:00):
The transaction paused early enough in the interval. By the time the next schedule (00:04:00) runs, it will have waited at least 1 minute (the minimum threshold). Therefore, it is placed in the table for the immediate next schedule (es_irt_3_{hash}at 00:04:00). -
Second Half (After Midpoint —
00:03:00to00:04:00):
The transaction paused too close to the00:04:00schedule (less than 1 minute remaining). To protect it from premature retry, it skips00:04:00and is placed into the table for the schedule after next (es_irt_1_{hash}at 00:06:00).
| Transaction | Stopped Time | Time Remaining until 00:04:00 | Position in Interval | Assigned Table & Schedule | Total Actual Wait Time |
|---|---|---|---|---|---|
T4 |
00:02:50 |
1 min 10 sec (>= 1 min) |
First half (Before |
|
1 min 10 sec |
T5 |
00:03:08 |
52 sec (< 1 min) |
Second half (After |
|
2 min 52 sec |
T6 |
00:03:58 |
2 sec (< 1 min) |
Second half (After |
|
2 min 02 sec |
Why Wait Time Fluctuates Between 1 and 3 Minutes:
-
Minimum wait (1 minute): A transaction stopping exactly at the midpoint (
00:03:00) waits 1 minute until execution at00:04:00. -
Maximum wait (nearly 3 minutes): A transaction stopping just after the midpoint (
00:03:01) waits 59 seconds until00:04:00, plus the full 2-minute cycle until00:06:00(total 2 minutes 59 seconds).
es_rt_* tables (Transaction Recovery Retention)
While es_irt_* tables track transactions paused due to transient errors, the es_rt_* tables track crashed or missing transactions — scenarios where an orchestrator node died unexpectedly mid-execution (e.g., node crash, hardware failure, or unhandled network partition).
When a transaction is initialized, its data is stored in one of the active es_rt_* tables.
If the transaction completes its full saga journey successfully, its row is deleted from the table.
However, if a transaction fails to complete due to an unexpected crash, its row persists in the es_rt_* table, making it a candidate for recovery.
Identifying Missing Transactions
Consider a scenario where 1,000 transactions are executed within a specified time period across multiple nodes.
Ideally, all transactions that complete their journey are deleted from the es_rt_* table.
However, if even one transaction fails to complete — due to a node crash, power failure, or unexpected interruption — its row persists in the table.
When the scheduled time window arrives, a Retry-Node fetches that row (along with any others in its token range) from the respective es_rt_* table, re-invokes the transaction from the last successfully completed span, and then deletes the row so it is not picked up again.
es_rt_* table selection formula
Suppose you configure the Transaction recovery retention time to be 8 hours (480 minutes). The actual time a transaction is allowed to run before being considered missing will naturally oscillate between 4 hours and 12 hours.
Why does this oscillation happen?
When recovery retention is configured as 8 hours, 3 schedulers run in round-robin fashion throughout the day:
-
1st schedule at 00:00 →
es_rt_1_{hash} -
2nd schedule at 08:00 →
es_rt_2_{hash} -
3rd schedule at 16:00 →
es_rt_3_{hash}
The Challenge: Preventing False Positives for Healthy In-Flight Transactions
Imagine a transaction is initialized at 15:59:00.
The next recovery scheduler triggers at 16:00:00 — just 1 minute later.
If we placed that transaction into es_rt_3_{hash} (the 16:00:00 table), the recovery scheduler would find it still in the table at 16:00, assume it crashed, and trigger a duplicate recovery attempt — even though the transaction is perfectly healthy and only started 1 minute ago!
Every transaction must be granted at least half of the configured retention window (4 hours) as a minimum completion buffer before it can be flagged as missing.
The Solution: The Midpoint Boundary Rule
To prevent false alarms, StackSaga divides each 8-hour scheduling window in half using the midpoint as a boundary point:
-
For the active interval between
08:00:00and16:00:00, the configured duration is 8 hours, making12:00:00the midpoint boundary.
Transaction Placement Rule:
-
First Half (Before Midpoint —
08:00:00to12:00:00):
The transaction was initialized early in the window. By the time the next recovery schedule (16:00:00) arrives, the transaction will have been running for at least 4 hours (the minimum threshold). Therefore, it is placed into the table for the immediate next schedule (es_rt_3_{hash}at 16:00:00). -
Second Half (After Midpoint —
12:00:00to16:00:00):
The transaction was initialized after the midpoint (less than 4 hours before the 16:00 schedule). To give it adequate time to complete naturally, it skips the 16:00 schedule and is placed into the table for the schedule after next (es_rt_1_{hash}at 00:00:00).
| Transaction | Initialization Time | Time Remaining until 16:00:00 | Position in Interval | Assigned Table & Schedule | Total Grace Period Before Recovery |
|---|---|---|---|---|---|
T4 |
11:30 |
4 hours 30 min (>= 4h) |
First half (Before |
|
4 hours 30 min |
T5 |
12:30 |
3 hours 30 min (< 4h) |
Second half (After |
|
11 hours 30 min |
T6 |
15:59 |
1 minute (< 4h) |
Second half (After |
|
8 hours 01 min |
Why Grace Period Fluctuates Between 4 and 12 Hours:
-
Minimum grace period (4 hours): A transaction initialized exactly at the midpoint (
12:00:00) is checked at16:00:00(4 hours elapsed). -
Maximum grace period (nearly 12 hours): A transaction initialized just after the midpoint (
12:00:01) waits 3 hours 59 minutes until16:00, plus the full 8-hour cycle until00:00(total 11 hours 59 minutes).
Handling False Positives in Transaction Recovery & the Role of Idempotency
While transactions that remain in the es_rt_* table are generally considered missing, this is not always the case.
- Scenario: Transactions Delayed but Not Missing
-
Consider a situation where a transaction is still in the queue, waiting for execution because the respective orchestrator service is too busy. In this case:
-
The system mistakenly assumes the transaction is missing since it has not been removed from the
es_rt_*table within the expected time frame. -
As a result, the system triggers a recovery process, re-invoking the transaction.
-
This can lead to the transaction (or certain atomic executions) being executed multiple times, causing unintended duplicate operations.
-
| This is one of the possible ways transactions can be executed multiple times. To prevent these kinds of unintended duplicate executions, idempotency should be implemented at the atomic execution level of the transaction. |
⏱ Time Window Manager Visualizer
Write Protection & Duplicate Prevention (Kafka Event Sourcing)
When using Cassandra reactive support with event-driven streaming (such as Apache Kafka), guarding against duplicate execution and concurrent race conditions is essential.
Why Execution Markers are Needed
StackSaga’s Kafka implementation uses at-least-once delivery semantics.
Because of this, duplicate event deliveries are naturally expected in distributed environments,
for example:
-
Consumer group rebalances when pods scale up or down.
-
Consumer node restarts or crashes before offset commits are acknowledged.
-
Network timeouts and automated message retries.
To ensure idempotency, StackSaga saves an execution marker in the es_execution_markers table in Cassandra.
Before or during processing, the framework inspects this table so that already-processed events are detected and not executed again.
Database Safety vs. Event Listener Side Effects (The Real Issue with Races)
Technically, a concurrent race condition does not corrupt the Cassandra database itself because Cassandra write queries are idempotent. Writing the same event-store record twice will not break the database state.
However, the real issue is what happens after the database write:
|
StackSaga publishes state-changed events to registered event listeners after successfully updating the event store:
|
If a concurrent race condition causes the same record to be inserted multiple times, these state-changed listeners will also be invoked that same number of times.
If the listener triggers external side effects — such as sending a confirmation email or SMS notification to a customer, calling a third-party payment gateway, or dispatching an external API call — the customer would receive duplicate notifications unless the developer explicitly built custom idempotency into their listener code.
Choosing Between Consistency Modes & Overhead Trade-offs
StackSaga provides two write-protection modes to balance strictness and throughput:
1. STRICT Mode
-
When to use: Recommended when you must prevent duplicate listener calls (such as customer-facing notifications or non-idempotent third-party APIs) and you do not want to build custom idempotency logic into every listener.
-
How it works:
-
LWT Lease Acquisition: Before running the execution, a worker acquires an exclusive temporary lock in Cassandra using Lightweight Transactions (LWT:
INSERT INTO es_execution_markers … IF NOT EXISTS USING TTL <leaseDuration>). -
Active Lease Wait: If another worker is already processing the same record (marker exists with
TTL > 0), the second worker waits for the remaining TTL duration and retries. -
Permanent Marker Commitment: When the first worker successfully commits its batch, it writes a permanent marker (
TTL = 0). When concurrent workers retry, they seeTTL = 0and terminate cleanly withOptimisticLockConflictException. -
Automatic Crash Recovery: If the first worker crashes mid-flight, the temporary lease expires automatically when its TTL finishes, permitting another worker to retry and safely complete the saga step without deadlocks.
-
-
Claim the Lease (
INSERT … IF NOT EXISTS USING TTL): Before executing business logic, the worker attempts an atomic conditional insert with a temporary lease TTL (default-lease-durationor domain override). If another node crashes mid-flight, this lease automatically expires after the TTL window, preventing permanent distributed deadlocks without manual intervention. -
Evaluate Acquisition (
[applied] == true):-
Lease Won (
applied == true): The worker exclusively owns the step. It executes the saga step and persists the event store batch along with a permanent execution marker (TTL = 0). Once persisted, registered transaction event listeners are safely dispatched. -
Collision Detected (
applied == false): A marker already exists. The worker queries the record to check whether processing is still active or already completed.
-
-
Collision Inspection & Safe Deduplication:
-
Lease Active (
TTL > 0): Another worker is currently executing the step, or a crashed worker’s lease has not expired yet. The waiting worker backs off for the remaining lease duration and retries. -
Permanently Finalized (
TTL == 0): The step was already completed by another worker. The current message is a duplicate Kafka delivery and is safely discarded without re-executing logic or triggering duplicate listener events.
-
2. RELAXED_WITH_DEDUP Mode
-
When to use: Recommended when duplicate listener calls are harmless (e.g. idempotent downstream consumers, cache invalidations, simple loggers), OR when you already handle idempotency inside your listener logic.
-
How it works: Before running, the framework checks if a completed execution marker exists (
checkExists). If found, it skips execution. This avoids Cassandra LWT locks, maximizing throughput, but does not prevent the rare race condition where two workers execute at the exact same millisecond.
-
Fast Duplicate Check (
checkExists): Before executing the step, the worker performs a lightweight read againstes_execution_markersin Cassandra using standard read consistency (LOCAL_QUORUM). This query does not trigger Cassandra Paxos consensus (LWT), completely avoiding coordination round-trips. -
Evaluate Existence:
-
Marker Exists (
YES): A previous execution has already processed this record. The framework recognizes this as a duplicate Kafka event redelivery, aborts further processing withSpanAlreadyExecutedException, and safely terminates without re-running business logic or triggering duplicate listener notifications. -
Marker Does Not Exist (
NO): The worker proceeds to execute the saga step’s business logic.
-
-
Atomic Batch Persistence & Listener Dispatch: Upon business logic completion, the worker writes the saga event store state changes and the permanent
ExecutionMarker(TTL = 0) together in an atomic logged batch. After the batch write successfully completes, registered transaction event listeners are dispatched.
|
Overhead Comparison: Managing idempotency manually in application code (such as using Redis distributed locks or custom database tables) incurs roughly the same overhead as having the framework do it, and the framework’s Cassandra LWT lease mechanism is built-in and highly optimized. However, if duplicate listener invocations do not matter for your business use case, choosing |
Domain-Level Configuration
Write protection can be configured globally and fine-tuned per domain entity. Because different saga workflows have distinct consistency and performance requirements, you can maintain both the consistency mode and the lease TTL duration differently for each saga based on its domain.
For example:
-
Critical Sagas (such as
order-sagaorpayment-saga) that trigger external side effects like payments or customer notifications can be configured withSTRICTmode and an appropriate lease duration (e.g.,10sor5s) to completely prevent duplicate executions. -
High-Throughput Sagas (such as
notification-sagaor internal auditing) where operations are naturally idempotent can useRELAXED_WITH_DEDUPto bypass LWT locking overhead and maximize write throughput.
The domain key configured under domains corresponds to the domain entity name defined by @SagaDomainEntity#name() (matched case-insensitively).
Any domain that is not explicitly overridden will automatically inherit the global default values (default-mode and default-lease-duration).
application.ymlstacksaga:
cassandra:
write-protection:
default-mode: RELAXED_WITH_DEDUP # Default High-throughput mode
default-lease-duration: 5s # Default LWT lease TTL (only for mode=STRICT mode)
domains:
order-saga:
mode: STRICT # Strict mutual exclusion for order saga
lease-duration: 10s # Custom lease TTL override
payment-saga:
mode: STRICT # Strict mode using default 5s lease
notification-saga:
mode: RELAXED_WITH_DEDUP # High-throughput mode for notifications
Configuring Properties
Property Name |
Default Value |
Type |
Property Description |
|
|
|
Consistency level for read operations. Default is |
|
|
|
Consistency level for write operations. Default is |
|
|
|
Location of the configuration file to use. See configuration options for more details about how to provide the .conf file and the available configuration options in the .conf file. |
Write Protection Properties: The following properties configure duplicate protection, LWT lease TTL durations, and per-saga consistency modes under |
|||
|
|
|
Default write-protection mode applied to sagas that do not specify a per-domain override. |
|
|
|
Default temporary marker lease duration for |
|
|
|
Optional per-domain consistency mode override, keyed by |
|
|
|
Optional per-domain lease duration override for |
Transaction Properties: The following properties configure transaction lifetime, retry/restore scheduling, and the in-memory streaming buffer used by the recovery pipeline. See Transaction Recovery and Transaction Retry for the conceptual explanation. |
|||
|
|
|
How long a transaction is kept as live. After reaching the lifetime the transaction will not be exposed for retrying even if the transaction is still in processing mode. |
|
|
|
How long the transaction should be kept waiting before the next retry exposure. This ensures that transactions are not retried too frequently within a short period. The transaction is frozen for some period of time even if it fails again after a recent retry. The value must evenly divide 1440 (24*60). The delay time can fluctuate within |
|
|
|
How long before a transaction is considered crashed and exposed for recovery. In rare cases, transactions may crash without acknowledging the failure (e.g. application crash). This value determines after how long the transaction is considered crashed and exposed for restoring and retrying. The value must evenly divide 24. The delay time can fluctuate within |
|
|
|
Controls how many transactions are fetched from the database at once into the in-memory buffer. This maps to the highTide parameter of Reactor’s limitRate(). A higher value reduces the number of database round trips but increases memory usage. A lower value keeps memory footprint small but increases database pressure. |
|
|
|
Controls when the framework triggers the next database fetch to refill the buffer. This maps to the lowTide parameter of Reactor’s limitRate(). When the number of buffered transactions drops to this value, a new fetch is triggered in the background — so processing never stalls waiting for data. Should always be less than batch-size. A common rule of thumb is 75% of batch-size. |
|
|
|
Controls how many transactions are processed simultaneously in the recovery pipeline. This maps to the concurrency parameter of Reactor’s |
|
|
|
Controls how many transactions are pre-fetched and buffered in memory before processing begins. This maps to the prefetch parameter of Reactor’s |