Carbon-Aware Batch Job Scheduler with Deadline-Safe Deferral
Shift deferrable batch work into a lower-carbon regional grid window without missing its hard completion deadline.
Flag Cassandra tables caching unbounded rows per partition, alert Slack, cap the caching option with ALTER TABLE and verify the schema.
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.
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.
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.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.route (io.kestra.plugin.core.flow.If) branches on whether any table is above the ceiling:clean logs that every table in the target keyspaces is bounded.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.errors block alerts Slack when the flow itself fails, for example an unreachable cluster or a role without ALTER rights.daily Schedule trigger ships disabled: true and always forces safe mode.keys left exactly as it was on every table, because the key cache is on by default and is not the problem here.row_cache_size_in_mb who need to know what will compete for it before they do.CREATE TABLE statements were copied between services.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.
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.
system_schema.tables. For remediation, a role with ALTER on the target tables.io.kestra.plugin.cassandra.standard.Query), which ships with Kestra.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.SLACK_WEBHOOK_URL: Slack incoming webhook used by alert and the errors block.username plus a password: {{ secret('CASSANDRA_PASSWORD') }} to each task's session rather than hardcoding them. The driver needs both or neither.SECRET_, for example SECRET_SLACK_WEBHOOK_URL.| 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 |
SLACK_WEBHOOK_URL secret. The Cassandra plugin ships with Kestra, so there is nothing extra to install.target_keyspaces to your application keyspaces.auto_remediate: true and dry_run: false and approve the Pause.daily trigger, which always runs in safe mode.ALL, for example a tiny static lookup table, then skip them in flagged.keys: 'NONE' in a keyspace where the key cache is expected to help.row_cache_size_in_mb per node and refuses to raise it while any table still carries ALL.