New to Kestra?
Use blueprints to kickstart your first workflows.
Find PostgreSQL roles spilling sorts to disk with no work_mem override, size a value from the measured spill, set it with ALTER ROLE and verify.
work_mem is the memory one sort or one hash node may use before it spills to disk. The default is 4MB. It is charged per node rather than per query, so a reporting role running wide sorts and hash joins spills on almost every statement. PostgreSQL writes temp files under pgsql_tmp, the query slows by an order of magnitude, nothing errors, nothing is logged by default. The only trace is temp_blks_written in pg_stat_statements plus temp_files and temp_bytes in pg_stat_database. The analyst just reports that the dashboard is slow.
This blueprint reads temp_blks_written per role and per database from pg_stat_statements, keeps the roles that have no work_mem override of their own in pg_db_role_setting, computes a value from the worst single call they measured, caps it against a host memory budget, then behind a dry run and a Pause applies it with ALTER ROLE ... SET work_mem and re-reads pg_db_role_setting to prove the override is stored.
This blueprint was created by zkasuran.
| Finding | Severity | Meaning |
|---|---|---|
TEMP_SPILL_NO_WORK_MEM_OVERRIDE |
High or Medium | The role spills past the reporting threshold with no work_mem setting of its own. This is the only finding that gets a statement |
SPILL_WITH_OVERRIDE_PRESENT |
Info | The role spills but already carries its own work_mem. Somebody tuned it, so the flow leaves it alone |
SPILL_ALREADY_SIZED |
Info | The role spills, but the value it already gets covers the measured spill. A budget cap sitting below that value has the same effect, so a change would not help |
SPILL_BY_SUPERUSER_SKIPPED |
Info | A superuser spilled. Superusers are never altered |
SPILL_ROLE_EXCLUDED |
Info | The role is in excluded_roles |
The effective value is resolved the way PostgreSQL resolves it at login: ALTER ROLE ... IN DATABASE, then ALTER ROLE, then ALTER DATABASE, then the server setting.
work_mem is charged per sort or hash node and per connection, so a value times many concurrent sessions is what exhausts host memory. That is the real risk here, which is why the write sits behind a Pause and why the proposal is bounded twice.
headroom_multiplier covers. The proposal is therefore an estimate, so a role that still spills after the change wants a higher multiplier rather than a hand picked number.max_work_mem_mb is a hard ceiling.shared_buffers is conventionally a quarter of host memory, so four times shared_buffers is used as a memory proxy, ram_fraction_for_work_mem of it is the whole allowance for sorting. max_connections then divides it. A proposal above that comes back clamped with clamped_by_budget set, so a tiny instance is never handed a large value.pg_* roles, template databases and excluded_roles are never touched.What the verification proves, exactly. The second read of pg_db_role_setting proves the override is stored at the scope that was asked for, with the value that was proposed. It does not prove the spilling stopped. That depends on the queries that run next, on how many nodes each plan has, on how skewed the data is. Settings are read at connect time, so sessions the role already holds keep the old value until they reconnect. To see the payoff, open a new session as the role, check SHOW work_mem, then run the same query and compare Sort Method in EXPLAIN (ANALYZE, BUFFERS) and temp_blks_written before against after.
precheck (io.kestra.plugin.jdbc.postgresql.Query, read-only) confirms pg_stat_statements is installed. precheck_gate fails with a clear message if it is not, since the spill counters live nowhere else.audit (read-only) sums temp_blks_written and calls per role and database from pg_stat_statements, takes the worst single call, resolves the effective work_mem and its source from pg_db_role_setting, computes need_kb from the worst call times headroom_multiplier, clamps it to the ceiling and the host budget, then builds one ALTER ROLE ... SET work_mem per role with format('%I') plus %L.actionable keeps only the roles with no override of their own. summary logs every row with its numbers.route (io.kestra.plugin.core.flow.If): nothing actionable logs it, otherwise alert posts the roles, their spill and the proposed value to Slack.remediate_gate runs only when auto_remediate is true and dry_run is false. In any other combination would_change logs the exact statements and stops.approval_gate (io.kestra.plugin.core.flow.Pause) holds the run. On approval, remediate_overrides (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) runs one statement at a time, because concurrent ALTER ROLE ... SET on the same role collide in pg_db_role_setting.reverify reads pg_db_role_setting again and compares the stored value with the proposed one. verify_result fails the run through io.kestra.plugin.core.execution.Fail on any MISSING or MISMATCH.errors alerts Slack when the run fails.pg_db_role_setting that fails the run when the statement was rejected, which is what happens when the connecting role lacks the rights or the value is outside the range PostgreSQL accepts.pgsql_tmp and want the per role fix done, bounded and proven.The measurement lives in pg_stat_statements and accumulates between resets, so it wants reading on a schedule rather than during an incident. Kestra reads it, sizes the change against the server's own memory numbers, posts the proposal to Slack, applies it only behind auto_remediate, dry_run and a human approval, one statement at a time, re-reads the catalog to prove it landed. The record of who approved what stays in Kestra.
On Kestra 2.0.5 OSS against PostgreSQL 16.15 with pg_stat_statements preloaded, shared_buffers 2GB, max_connections 20, server work_mem 4MB. A local HTTP sink stood in for Slack. Seed: a 50MB table of 400000 rows, then the same unindexed ORDER BY run three times as each of four roles, so every role wrote 86.3MB of temp files over 3 calls with a worst single call of 28.8MB. reporting had no override, analytics_tuned had work_mem=8MB, migration_bot was in excluded_roles, admin_super is a superuser. The budget worked out at 104858kB per connection, so the proposal for reporting was 58MB (28.8MB times the 2.0 default) with no clamp.
| Run | Inputs | Result |
|---|---|---|
| 1 | defaults | 4 roles over the threshold, exactly 1 actionable: reporting, proposal 58MB. The other three came back SPILL_WITH_OVERRIDE_PRESENT, SPILL_ROLE_EXCLUDED and SPILL_BY_SUPERUSER_SKIPPED. pg_db_role_setting unchanged |
| 2 | auto_remediate: true, dry_run: true |
Same report, no statement run, still no row for reporting in pg_db_role_setting |
| 3 | auto_remediate: true, dry_run: false, resumed with approved=false |
Paused, then nothing changed |
| 4 | auto_remediate: true, dry_run: false, approved |
ALTER ROLE reporting SET work_mem = '58MB' applied. pg_db_role_setting went from no row to {work_mem=58MB}. A new session as reporting returned 58MB from SHOW work_mem, the same query's Sort Method moved from external merge Disk: 29368kB to quicksort Memory: 49379kB, so the statement's temp_blks_written stayed at 11046 across a fourth call |
| 5 | defaults, after run 4 | 0 actionable. reporting is now reported as SPILL_WITH_OVERRIDE_PRESENT |
| 6 | approved, inputs pushed until the proposal leaves the range PostgreSQL accepts | The statement was rejected with invalid value for parameter "work_mem", reverify reported MISSING, the run FAILED, errors posted to Slack |
| 7 | approved, the role renamed during the Pause |
The statement hit a role that no longer existed, reverify reported MISSING, the run FAILED |
| 8 | a database with no pg_stat_statements |
precheck_gate failed with the install instruction instead of reading a view that is not there |
| 9 | unreachable host | The errors block posted to Slack |
The in-memory sort for that workload took 49379kB while the external merge had written 29368kB to tape, a ratio of 1.7, which is where the 2.0 default for headroom_multiplier comes from. At 1.5 the proposal was 44MB, the setting landed, the sort still spilled. That is the honest shape of this: the flow proves the override is stored, the operator proves the spill is gone.
pg_stat_statements in shared_preload_libraries plus CREATE EXTENSION pg_stat_statements in the database the flow connects to. Without it there is no per role spill counter.pg_stat_statements, pg_db_role_setting and pg_settings. For the change, a superuser or a role holding ADMIN OPTION on the target role.docker run -d --name pg -p 5432:5432 -e POSTGRES_PASSWORD=pg postgres:16 -c shared_preload_libraries=pg_stat_statements;CREATE EXTENSION pg_stat_statements, create a login role, give it a big table with an unindexed column, then run ORDER BY on that column as the role until temp_blks_written climbs.POSTGRES_USERNAME / POSTGRES_PASSWORD: credentials used by every query.SLACK_WEBHOOK_URL: used by alert and the errors block.SECRET_.| Input | Default | Purpose |
|---|---|---|
jdbc_url |
jdbc:postgresql://localhost:5432/app |
Database to read the counters from |
excluded_roles |
[migration_bot] |
Roles whose spilling is accepted |
override_scope |
ROLE |
ROLE or ROLE_IN_DATABASE |
min_total_temp_mb |
64 |
Temp megabytes a role must have written to be reported |
high_severity_temp_mb |
1024 |
Temp megabytes that make a finding HIGH |
headroom_multiplier |
1.5 |
Scale over the worst measured call |
max_work_mem_mb |
256 |
Hard ceiling for the proposal |
ram_fraction_for_work_mem |
0.25 |
Share of the memory proxy all sorting may claim |
auto_remediate |
false |
Allow ALTER ROLE |
dry_run |
true |
Must be false for a statement to run |
POSTGRES_USERNAME, POSTGRES_PASSWORD and SLACK_WEBHOOK_URL secrets.jdbc_url, then list in excluded_roles anything you are happy to leave on disk.auto_remediate: true and dry_run: false and answer the Pause.SHOW work_mem, re-run the slow query and compare Sort Method in EXPLAIN (ANALYZE, BUFFERS).daily trigger, which always runs in safe mode.pg_stat_statements_reset() on a schedule so the counters describe a window rather than all time. Keep the window bounds in a KV pair.temp_files and temp_bytes from pg_stat_database as a second signal, which also catches spilling from statements that have aged out of pg_stat_statements.hash_mem_multiplier alongside work_mem for hash heavy plans.maintenance_work_mem the same way for roles that build indexes.work_mem: https://www.postgresql.org/docs/current/runtime-config-resource.html