Query icon
OutputValues icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Loop icon
Sleep icon
Fail icon
Schedule icon

Cassandra Unbounded Row Cache Guard

Flag Cassandra tables caching unbounded rows per partition, alert Slack, cap the caching option with ALTER TABLE and verify the schema.

Categories
DataInfrastructure

CREATE TABLE ... WITH caching = {'keys': 'ALL', 'rows_per_partition': 'ALL'} is one line in a schema file that nobody reads again. It tells Cassandra that when the row cache holds anything for this table, it may hold every row of every partition it touches. On a wide partition table that is not a cache size, it is an instruction with no bound. Turn the row cache on later and that table can fill it on its own, so entries for every other table get evicted, the cache churns instead of serving reads and the heap pays for all of it.

This blueprint reads system_schema.tables for the keyspaces you name, flags every table whose caching['rows_per_partition'] is ALL or a number above a ceiling you set and alerts Slack. When you allow it, it rewrites the caching option with ALTER TABLE ... WITH caching, one table at a time, behind a human approval, then reads the schema again and fails the run if any flagged table is still unbounded. Each statement carries that table's existing keys value, so the key cache setting is preserved.

This blueprint was created by zkasuran.

Findings

Be clear about the size of this problem, because the honest version is still worth fixing. Cassandra ships with row_cache_size_in_mb: 0 in cassandra.yaml, which means the row cache is off. On a cluster at that default, rows_per_partition: 'ALL' costs nothing today. It is a landmine, not a fire: the day somebody raises row_cache_size_in_mb to make one hot lookup faster, every table carrying ALL starts competing for that memory with no bound. The operator who turns the cache on is rarely the person who wrote the schema. Finding the setting is cheap now. Finding it from a GC log during an incident is not.

So this flow does not claim to measure heap pressure or to prove an OOM. What it does is exact, reads back from the schema and is checkable with cqlsh: it tells you which tables carry an unbounded row cache instruction, then caps them.

How it works

  1. scan_tables (io.kestra.plugin.cassandra.standard.Query, read only) runs SELECT keyspace_name, table_name, caching FROM system_schema.tables WHERE keyspace_name IN (...) for the target_keyspaces you pass. Any name starting with system is dropped from the list first, so the built in keyspaces are never in scope. The restriction is on the keyspace_name partition key, so the read needs no ALLOW FILTERING.
  2. flagged (io.kestra.plugin.core.output.OutputValues) keeps the rows whose rows_per_partition is ALL or a number above rows_per_partition_ceiling, then builds one ALTER TABLE per table plus a readable name list. summary logs the counts.
  3. route (io.kestra.plugin.core.flow.If) branches on whether any table is above the ceiling:
    • clean: clean logs that every table in the target keyspaces is bounded.
    • flagged: alert posts the table list to Slack.
      • remediate_gate runs only when auto_remediate is true with dry_run false.
      • hold (io.kestra.plugin.core.flow.Pause) waits for a person to approve the list.
      • remediate (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) runs each ALTER TABLE ... WITH caching through io.kestra.plugin.cassandra.standard.Query in turn.
      • settle waits a few seconds for schema agreement, then reverify re-reads system_schema.tables. verify_result fails the run through io.kestra.plugin.core.execution.Fail if any table still reads above the ceiling.
  4. The errors block alerts Slack when the flow itself fails, for example an unreachable cluster or a role without ALTER rights.
  5. The daily Schedule trigger ships disabled: true and always forces safe mode.

What you get

  • A standing answer to which tables carry an unbounded row cache instruction, read from Cassandra's own schema rather than from the schema file somebody committed two years ago.
  • A cap applied only behind two input gates plus a human approval, one table at a time, with a re-read of the schema as proof.
  • A Slack page listing the tables, plus a separate failure channel when the guard cannot run.
  • keys left exactly as it was on every table, because the key cache is on by default and is not the problem here.

Who it's for

  • Platform and database reliability teams who want the row cache to be a tuning option rather than a surprise.
  • Teams about to raise row_cache_size_in_mb who need to know what will compete for it before they do.
  • Anyone reviewing inherited keyspaces where CREATE TABLE statements were copied between services.

Why orchestrate this with Kestra

A DESCRIBE TABLE in cqlsh answers this for one table at one moment and changes nothing. Kestra reads the whole set on a schedule, turns it into a Slack page and an execution history, applies the cap only behind auto_remediate with dry_run plus a human approval, one statement at a time, then verifies by reading the schema back. A table created with ALL next month is caught by the next scheduled run instead of by an incident.

Tested end to end

On Kestra 2.0.5 against cassandra:5 (5.0.9) on the same Docker network, with a local HTTP sink standing in for Slack. Seeded keyspace app: sessions altered to rows_per_partition: 'ALL', events created with {'keys':'NONE','rows_per_partition':'5000'}, profiles at '100', lookup_small at the default 'NONE'. Keyspace app_clean holds one table at the default.

Run Inputs Result
1 target_keyspaces: [app_clean], ceiling 1000 Clean. 1 table scanned, 0 flagged. No alert, no change
2 target_keyspaces: [app] 4 scanned, 2 flagged (app.sessions at ALL, app.events at 5000). Alert sent. profiles at 100 not flagged. Schema unchanged
3 run 2 plus auto_remediate: true, dry_run: true Same report. cqlsh confirms all 4 unchanged. No ALTER ran, the Pause was never reached
4 auto_remediate: true, dry_run: false, resumed with no answer Pause reached, declined branch taken, schema untouched
5 same, approved 2 ALTER TABLE ran one at a time, reverify found 0 above the ceiling. cqlsh: sessions ALL to NONE with keys: ALL kept, events 5000 to NONE with keys: NONE kept, profiles still 100, lookup_small still NONE
6 same, with app.hotpath created at ALL while the run waited for approval The 2 known tables were fixed, reverify found app.hotpath, the run FAILED and the errors block posted
7 target_keyspaces: [system, system_schema, app_clean] 1 table scanned. The system names are dropped before the query runs
8 target_keyspaces: [system, system_auth] 0 tables scanned, clean, no failure
9 cassandra_host pointed at an unreachable name scan_tables failed, the errors block posted to the sink, execution FAILED

Across the whole cluster, cqlsh reported 0 tables carrying rows_per_partition: 'ALL' after the last run, including every system* keyspace, which ships none. Before and after values from cqlsh are in the PR description.

Prerequisites

  • A reachable Cassandra cluster with its native CQL port open to the Kestra Worker. For the audit, a role that can read system_schema.tables. For remediation, a role with ALTER on the target tables.
  • The bundled Cassandra plugin (io.kestra.plugin.cassandra.standard.Query), which ships with Kestra.
  • Local testing: docker run -d --name cassandra --network <kestra-network> -e MAX_HEAP_SIZE=1G -e HEAP_NEWSIZE=256M cassandra:5, then create a keyspace and a table WITH caching = {'keys':'ALL','rows_per_partition':'ALL'}. Allow a minute or two before the node accepts CQL.

Secrets

  • SLACK_WEBHOOK_URL: Slack incoming webhook used by alert and the errors block.
  • This flow connects without a username or password, which matches a local single node Cassandra. For an authenticated cluster, add a username plus a password: {{ secret('CASSANDRA_PASSWORD') }} to each task's session rather than hardcoding them. The driver needs both or neither.
  • In Kestra OSS, provide a secret as a base64-encoded environment variable prefixed SECRET_, for example SECRET_SLACK_WEBHOOK_URL.

Inputs

Input Default Purpose
cassandra_host cassandra Contact point hostname or IP
cassandra_port 9042 Native CQL port
cassandra_datacenter datacenter1 Local datacenter for the driver
target_keyspaces [app] Keyspaces to audit. Names starting with system are dropped
rows_per_partition_ceiling 1000 Highest numeric value allowed. ALL is always flagged
remediation_rows_per_partition NONE Value written by the fix. Keep it at or below the ceiling
auto_remediate false Allow ALTER TABLE
dry_run true Must be false before the approval step is reached

Quick start

  1. Add the SLACK_WEBHOOK_URL secret. The Cassandra plugin ships with Kestra, so there is nothing extra to install.
  2. Import the flow and set target_keyspaces to your application keyspaces.
  3. Run it once with the defaults and read which tables carry an unbounded row cache instruction.
  4. Agree the list with the table owners, then run with auto_remediate: true and dry_run: false and approve the Pause.
  5. Enable the daily trigger, which always runs in safe mode.

How to extend

  • Set a different ceiling per keyspace from a map, for example a higher allowance for a small reference keyspace.
  • Keep an allowlist of tables that are meant to carry ALL, for example a tiny static lookup table, then skip them in flagged.
  • Flag the reverse case too: a table at keys: 'NONE' in a keyspace where the key cache is expected to help.
  • Pair with a flow that reads row_cache_size_in_mb per node and refuses to raise it while any table still carries ALL.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.