Repair stalled Telegraf partitions from soup instead of every highstate

Truncating default partitions is destructive and premaking them is pg_cron's
job, so neither belongs in a state that runs on every checkin. Replace the two
telegraf_users states with so-telegraf-partition-repair, a standalone tool that
reports partition health and clears the backlog, and call it once from soup.

The script depends only on pg_partman, so it also runs against a grid that has
not yet picked up the new postgres state. It no-ops when nothing is stranded,
refuses to discard rows non-interactively without --yes, and reports when the
pg_cron job has never fired, which is the underlying cause rather than a
symptom the truncate addresses.

Hourly self-healing stays with so_admin.telegraf_maintenance() via pg_cron, so
a grid that never soups still recovers, just gradually and without discarding
in-retention metrics.
This commit is contained in:
Mike Reeves
2026-08-06 12:28:40 -04:00
parent 5d36d00dec
commit a2a4d9314d
4 changed files with 176 additions and 66 deletions
+15
View File
@@ -1013,9 +1013,24 @@ up_to_3.3.0() {
INSTALLEDVERSION=3.3.0
}
telegraf_partition_repair() {
# Grids upgraded onto an existing /nsm/postgres never ran init-db.sh, so
# so_telegraf did not exist when PostgreSQL started and pg_cron's launcher
# died without retrying. partman maintenance never ran and every metric piled
# up in <parent>_default, which then blocks partition creation outright.
# Fresh installs are unaffected. The script no-ops when nothing is stranded.
[[ -x /usr/sbin/so-telegraf-partition-repair ]] || return 0
docker ps --format '{{.Names}}' | grep -qx so-postgres || return 0
echo "Checking Telegraf metric partitions."
/usr/sbin/so-telegraf-partition-repair --yes \
|| echo " warning: so-telegraf-partition-repair failed; run it manually" >&2
}
post_to_3.3.0() {
# Recollate again since some internal DBs were excluded during 3.2.0 soup
recollate_postgres
telegraf_partition_repair
}
### 3.3.0 End ###
-19
View File
@@ -70,25 +70,6 @@ postgres_telegraf_retention_reconcile:
- cmd: postgres_telegraf_group_role
- file: postgres_sbin
# One-time recovery for grids whose pg_cron job never ran. Truncating the
# defaults is destructive, so the watermark keeps it to one pass per host.
postgres_telegraf_partman_default_repair:
cmd.run:
- name: mkdir -p /opt/so/state && /usr/sbin/so-telegraf-postgres repair && touch /opt/so/state/telegraf_partman_default_repair
- creates: /opt/so/state/telegraf_partman_default_repair
- require:
- cmd: postgres_telegraf_retention_reconcile
- file: postgres_sbin
# Also run from the state, not just hourly from pg_cron, so a grid whose
# pg_cron worker is dead still recovers on its own.
postgres_telegraf_partman_maintenance:
cmd.run:
- name: /usr/sbin/so-telegraf-postgres maintenance
- require:
- cmd: postgres_telegraf_partman_default_repair
- file: postgres_sbin
{% endif %}
{% else %}
@@ -0,0 +1,153 @@
#!/bin/bash
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at
# https://securityonion.net/license; you may not use this file except in compliance with the
# Elastic License 2.0.
# Repair Telegraf metric partitions on a grid where pg_partman maintenance
# stalled and metrics piled up in <parent>_default.
#
# Once a default partition holds rows for a day with no child partition,
# Postgres cannot create that child at all -- attaching it would violate the
# default's constraint -- so maintenance stays broken until the default is
# emptied. This discards the stranded rows rather than repartitioning them:
# most are already past retention, and moving tens of GB just to drop them is
# not worth the WAL.
#
# Self-contained on purpose: it depends only on pg_partman, so it runs against
# a grid that has not yet picked up the current postgres state.
#
# Usage: so-telegraf-partition-repair [--dry-run] [--yes]
# --dry-run Report only; change nothing.
# --yes Skip the confirmation prompt (for soup and other automation).
set -e
DRY_RUN=false
ASSUME_YES=false
usage() {
sed -n '8,24p' "$0" | sed 's/^# \?//'
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true ;;
--yes|-y) ASSUME_YES=true ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
esac
shift
done
fail() { echo "ERROR: $*" >&2; exit 1; }
psql_tg() { docker exec -i so-postgres psql -U postgres -d so_telegraf "$@"; }
# Row counts have to come from dynamic SQL; query_to_xml keeps that in a plain
# query so this needs no helper functions installed in the database.
REPORT="
WITH parents AS (
SELECT pc.parent_table,
split_part(pc.parent_table, '.', 1) AS sch,
split_part(pc.parent_table, '.', 2) AS tbl
FROM partman.part_config pc
WHERE pc.parent_table LIKE 'telegraf.%'
), children AS (
SELECT p.parent_table,
max(to_date(substring(c.relname FROM '_p(\d{8})\$'), 'YYYYMMDD')) AS newest_child
FROM parents p
JOIN pg_class pt ON pt.oid = p.parent_table::regclass
JOIN pg_inherits i ON i.inhparent = pt.oid
JOIN pg_class c ON c.oid = i.inhrelid
WHERE pg_get_expr(c.relpartbound, c.oid) <> 'DEFAULT'
GROUP BY p.parent_table
), defaults AS (
SELECT p.parent_table,
format('%I.%I', p.sch, p.tbl || '_default') AS default_table,
to_regclass(format('%I.%I', p.sch, p.tbl || '_default')) AS default_oid
FROM parents p
)
SELECT d.parent_table,
c.newest_child,
(c.newest_child - current_date) AS days_ahead,
CASE WHEN d.default_oid IS NULL THEN NULL ELSE
(xpath('/row/cnt/text()',
query_to_xml(format('SELECT count(*) AS cnt FROM %s', d.default_table),
false, true, '')))[1]::text::bigint
END AS default_rows,
CASE WHEN d.default_oid IS NULL THEN NULL
ELSE pg_size_pretty(pg_total_relation_size(d.default_oid)) END AS default_size
FROM defaults d
LEFT JOIN children c ON c.parent_table = d.parent_table
ORDER BY 1
"
docker ps --format '{{.Names}}' | grep -qx so-postgres \
|| fail "so-postgres is not running."
docker exec so-postgres psql -U postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='so_telegraf'" | grep -q 1 \
|| fail "The so_telegraf database does not exist; Telegraf is not writing to Postgres."
psql_tg -tAc "SELECT 1 FROM pg_extension WHERE extname='pg_partman'" | grep -q 1 \
|| fail "pg_partman is not installed in so_telegraf."
echo "Telegraf partition status:"
psql_tg -c "$REPORT"
stranded=$(psql_tg -tAc "SELECT coalesce(sum(default_rows), 0) FROM ( $REPORT ) t")
echo "Rows stranded in default partitions: $stranded"
# Report the scheduler too. A grid that needed this script almost always has a
# pg_cron job that has never fired, and repairing partitions without fixing that
# just delays the next occurrence.
cron_db=$(docker exec so-postgres psql -U postgres -tAc \
"SELECT current_setting('cron.database_name', true)" | tr -d '[:space:]')
if [[ -n "$cron_db" ]]; then
runs=$(docker exec so-postgres psql -U postgres -d "$cron_db" -tAc \
"SELECT count(*) FROM cron.job_run_details d JOIN cron.job j USING (jobid)
WHERE j.jobname = 'telegraf-partman-maintenance'" 2>/dev/null | tr -d '[:space:]' || true)
if [[ "$runs" == "0" ]]; then
echo
echo "WARNING: the telegraf-partman-maintenance job has never run. Apply the"
echo " postgres state so pg_cron is pointed at a database that exists"
echo " at server start, or this will recur."
fi
fi
if [[ "$stranded" == "0" ]]; then
echo "Nothing stranded. Running maintenance to premake the current window."
$DRY_RUN && { echo "(dry run: skipping maintenance)"; exit 0; }
psql_tg -v ON_ERROR_STOP=1 -c "CALL partman.run_maintenance_proc()"
exit 0
fi
if $DRY_RUN; then
echo "(dry run: would TRUNCATE the default partitions listed above)"
exit 0
fi
if ! $ASSUME_YES; then
echo
echo "This will permanently discard the $stranded stranded row(s) above."
[[ -t 0 ]] || fail "Not a terminal; re-run with --yes to confirm."
read -r -p "Continue? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 1; }
fi
psql_tg -v ON_ERROR_STOP=1 <<'EOSQL'
SELECT format('TRUNCATE TABLE %I.%I', n.nspname, c.relname)
FROM partman.part_config pc
JOIN pg_class p ON p.oid = pc.parent_table::regclass
JOIN pg_inherits i ON i.inhparent = p.oid
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE pc.parent_table LIKE 'telegraf.%'
AND pg_get_expr(c.relpartbound, c.oid) = 'DEFAULT'
\gexec
CALL partman.run_maintenance_proc();
EOSQL
echo
echo "Telegraf partition status after repair:"
psql_tg -c "$REPORT"
+8 -47
View File
@@ -12,28 +12,16 @@ set -e
# retention Reconcile partman retention and premake on telegraf parents.
# Env: RETENTION_DAYS.
# maintenance Drain default partitions and run partman maintenance.
# repair One-time recovery: TRUNCATE non-empty defaults, then maintain.
# check Report partition health. Non-zero if any parent is unhealthy.
#
# Once a default partition holds rows for a day with no child, Postgres cannot
# create that child at all -- attaching it would violate the default's
# constraint -- so maintenance drains defaults before calling partman.
# To recover a grid that is already in that state, use
# so-telegraf-partition-repair, which discards the backlog instead of moving it.
cmd="${1:?subcommand required}"
# A function rather than re-invoking $0, which assumes the script is executable.
run_maintenance() {
docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d so_telegraf <<'EOSQL'
SELECT CASE WHEN to_regproc('so_admin.telegraf_maintenance') IS NOT NULL
THEN 'true' ELSE 'false' END AS has_proc \gset
\if :has_proc
CALL so_admin.telegraf_maintenance();
\else
\echo 'so_admin.telegraf_maintenance() is missing; run so-telegraf-postgres group_role first.'
\endif
EOSQL
}
case "$cmd" in
create_db)
if ! docker exec so-postgres psql -U postgres -tAc \
@@ -258,42 +246,15 @@ EOSQL
;;
maintenance)
run_maintenance
;;
repair)
# Stranded rows are discarded, not repartitioned: most are already past
# retention, and moving tens of GB just to drop them isn't worth the WAL.
docker exec -i so-postgres psql -v ON_ERROR_STOP=1 -U postgres -d so_telegraf <<'EOSQL'
SELECT CASE WHEN EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_partman')
THEN 'true' ELSE 'false' END AS has_partman \gset
\if :has_partman
\echo 'Default partitions holding stranded Telegraf metrics:'
SELECT format('%I.%I', n.nspname, c.relname) AS default_partition,
pg_size_pretty(pg_total_relation_size(c.oid)) AS size
FROM partman.part_config pc
JOIN pg_class p ON p.oid = pc.parent_table::regclass
JOIN pg_inherits i ON i.inhparent = p.oid
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE pc.parent_table LIKE 'telegraf.%'
AND pg_get_expr(c.relpartbound, c.oid) = 'DEFAULT'
AND pg_total_relation_size(c.oid) > 0
ORDER BY pg_total_relation_size(c.oid) DESC;
SELECT format('TRUNCATE TABLE %I.%I', n.nspname, c.relname)
FROM partman.part_config pc
JOIN pg_class p ON p.oid = pc.parent_table::regclass
JOIN pg_inherits i ON i.inhparent = p.oid
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE pc.parent_table LIKE 'telegraf.%'
AND pg_get_expr(c.relpartbound, c.oid) = 'DEFAULT'
\gexec
SELECT CASE WHEN to_regproc('so_admin.telegraf_maintenance') IS NOT NULL
THEN 'true' ELSE 'false' END AS has_proc \gset
\if :has_proc
CALL so_admin.telegraf_maintenance();
\else
\echo 'so_admin.telegraf_maintenance() is missing; run so-telegraf-postgres group_role first.'
\endif
EOSQL
# Premake the window so the next flush has somewhere to land.
run_maintenance
;;
check)