Files
utilities/personal/tools/airsonic_pg_diagnostics.sh
T
2026-04-23 08:26:38 -04:00

277 lines
8.9 KiB
Bash

#!/bin/bash
set -euo pipefail
# Airsonic Advanced + PostgreSQL diagnostics
# Focus: slow media scans, index efficiency, write pressure, and cache tuning signals.
PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5432}"
PGUSER="${PGUSER:-postgres}"
PGDATABASE="${PGDATABASE:-airsonic}"
OUT_FILE="${1:-airsonic_pg_diagnostics_$(date +%Y%m%d_%H%M%S).txt}"
PSQL=(psql -X -v ON_ERROR_STOP=1 -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE")
run_section() {
local title="$1"
local sql="$2"
{
echo
echo "================================================================"
echo "$title"
echo "================================================================"
} >> "$OUT_FILE"
if ! "${PSQL[@]}" -c "$sql" >> "$OUT_FILE" 2>&1; then
echo "Query failed for section: $title" >> "$OUT_FILE"
fi
}
query_single_value() {
local sql="$1"
"${PSQL[@]}" -Atc "$sql" 2>/dev/null || true
}
bytes_to_human() {
local bytes="$1"
if [[ -z "$bytes" || ! "$bytes" =~ ^[0-9]+$ ]]; then
echo "unknown"
return
fi
local kib=$((1024))
local mib=$((1024 * 1024))
local gib=$((1024 * 1024 * 1024))
if (( bytes >= gib )); then
awk -v b="$bytes" -v g="$gib" 'BEGIN { printf "%.2f GiB", b/g }'
elif (( bytes >= mib )); then
awk -v b="$bytes" -v m="$mib" 'BEGIN { printf "%.2f MiB", b/m }'
elif (( bytes >= kib )); then
awk -v b="$bytes" -v k="$kib" 'BEGIN { printf "%.2f KiB", b/k }'
else
echo "${bytes} B"
fi
}
{
echo "Airsonic PostgreSQL Diagnostics"
echo "Generated at: $(date -Iseconds)"
echo "Target: host=$PGHOST port=$PGPORT db=$PGDATABASE user=$PGUSER"
} > "$OUT_FILE"
run_section "1) Server identity and uptime" "
SELECT version();
SELECT now() AS current_time, pg_postmaster_start_time() AS postmaster_start_time;
"
run_section "2) Key PostgreSQL tuning parameters" "
SELECT name, setting, unit, short_desc
FROM pg_settings
WHERE name IN (
'shared_buffers',
'work_mem',
'maintenance_work_mem',
'effective_cache_size',
'max_connections',
'effective_io_concurrency',
'random_page_cost',
'seq_page_cost',
'checkpoint_timeout',
'checkpoint_completion_target',
'autovacuum',
'autovacuum_max_workers',
'autovacuum_naptime'
)
ORDER BY name;
"
run_section "3) Database cache hit ratio and temp usage" "
SELECT
datname,
blks_read,
blks_hit,
ROUND(100.0 * blks_hit / NULLIF(blks_hit + blks_read, 0), 2) AS cache_hit_pct,
temp_files,
pg_size_pretty(temp_bytes) AS temp_bytes,
deadlocks,
stats_reset
FROM pg_stat_database
WHERE datname = current_database();
"
run_section "4) Most read tables and their index-vs-seq scan profile" "
SELECT
relname,
seq_scan,
idx_scan,
n_live_tup,
n_dead_tup,
ROUND((100.0 * idx_scan / NULLIF(idx_scan + seq_scan, 0))::numeric, 2) AS index_scan_pct,
ROUND((100.0 * heap_blks_hit / NULLIF(heap_blks_hit + heap_blks_read, 0))::numeric, 2) AS table_cache_hit_pct
FROM pg_stat_user_tables t
JOIN pg_statio_user_tables io USING (relid)
ORDER BY (seq_scan + idx_scan) DESC NULLS LAST
LIMIT 25;
"
run_section "5) Potentially unused indexes (can slow writes during scans/indexing)" "
SELECT
s.schemaname,
s.relname AS table_name,
s.indexrelname AS index_name,
s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size,
t.n_tup_ins + t.n_tup_upd + t.n_tup_del AS table_writes_since_reset
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
JOIN pg_stat_user_tables t ON t.relid = s.relid
WHERE s.idx_scan = 0
AND NOT i.indisunique
AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC
LIMIT 50;
"
run_section "6) Write-heavy tables (hot spot during media metadata indexing)" "
SELECT
relname,
n_tup_ins,
n_tup_upd,
n_tup_del,
n_tup_hot_upd,
n_dead_tup,
vacuum_count,
autovacuum_count,
analyze_count,
autoanalyze_count,
COALESCE(last_autovacuum::text, 'never') AS last_autovacuum,
COALESCE(last_autoanalyze::text, 'never') AS last_autoanalyze
FROM pg_stat_user_tables
ORDER BY (n_tup_ins + n_tup_upd + n_tup_del) DESC
LIMIT 25;
"
run_section "7) Largest relations (tables and indexes)" "
SELECT
n.nspname AS schema_name,
c.relname,
c.relkind,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND c.relkind IN ('r', 'i')
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 30;
"
if [[ "$(query_single_value "SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements';")" == "1" ]]; then
run_section "8) Slowest normalized SQL from pg_stat_statements" "
SELECT
calls,
ROUND(total_exec_time::numeric, 2) AS total_exec_ms,
ROUND((total_exec_time / NULLIF(calls, 0))::numeric, 2) AS mean_exec_ms,
ROUND(rows::numeric / NULLIF(calls, 0), 2) AS avg_rows,
shared_blks_read,
shared_blks_hit,
temp_blks_read,
temp_blks_written,
LEFT(query, 300) AS query_sample
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
ORDER BY mean_exec_ms DESC
LIMIT 20;
"
else
{
echo
echo "================================================================"
echo "8) Slowest normalized SQL from pg_stat_statements"
echo "================================================================"
echo "Extension pg_stat_statements is not installed."
echo "Install with: CREATE EXTENSION pg_stat_statements;"
} >> "$OUT_FILE"
fi
# Heuristic recommendation summary
CACHE_HIT_PCT="$(query_single_value "
SELECT ROUND(100.0 * blks_hit / NULLIF(blks_hit + blks_read, 0), 2)
FROM pg_stat_database
WHERE datname = current_database();")"
UNUSED_INDEX_COUNT="$(query_single_value "
SELECT COUNT(*)
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
AND NOT i.indisunique
AND NOT i.indisprimary;")"
SHARED_BUFFERS_BYTES="$(query_single_value "
SELECT pg_size_bytes(setting || unit)
FROM pg_settings
WHERE name = 'shared_buffers';")"
TOTAL_RAM_KB=""
if [[ -r /proc/meminfo ]]; then
TOTAL_RAM_KB="$(awk '/MemTotal:/ {print $2}' /proc/meminfo)"
fi
{
echo
echo "================================================================"
echo "9) Recommendations summary"
echo "================================================================"
if [[ -n "$CACHE_HIT_PCT" ]]; then
echo "Cache hit ratio: ${CACHE_HIT_PCT}%"
awk -v hit="$CACHE_HIT_PCT" 'BEGIN {
if (hit < 98.0) {
print "- Action: cache hit ratio is low for media workloads. Increase shared_buffers/effective_cache_size and review slow seq scans."
} else if (hit < 99.0) {
print "- Action: cache hit ratio is acceptable but can likely improve for large libraries."
} else {
print "- Action: cache hit ratio looks healthy."
}
}'
else
echo "Cache hit ratio: unavailable"
fi
if [[ -n "$UNUSED_INDEX_COUNT" ]]; then
echo "Potentially unused non-unique indexes: $UNUSED_INDEX_COUNT"
if [[ "$UNUSED_INDEX_COUNT" =~ ^[0-9]+$ ]] && (( UNUSED_INDEX_COUNT > 0 )); then
echo "- Action: validate and drop truly unused indexes to reduce write overhead during scans/indexing."
else
echo "- Action: no obvious unused non-unique indexes from current stats."
fi
fi
if [[ -n "$SHARED_BUFFERS_BYTES" ]]; then
echo "shared_buffers: $(bytes_to_human "$SHARED_BUFFERS_BYTES")"
if [[ -n "$TOTAL_RAM_KB" && "$TOTAL_RAM_KB" =~ ^[0-9]+$ ]]; then
TOTAL_RAM_BYTES=$((TOTAL_RAM_KB * 1024))
SHARED_BUFFER_PCT=$(awk -v s="$SHARED_BUFFERS_BYTES" -v t="$TOTAL_RAM_BYTES" 'BEGIN { printf "%.2f", (s/t)*100 }')
echo "Estimated shared_buffers/system RAM: ${SHARED_BUFFER_PCT}%"
awk -v p="$SHARED_BUFFER_PCT" 'BEGIN {
if (p < 10.0) {
print "- Action: shared_buffers appears low; typical starting point is ~15-25% of system RAM."
} else if (p > 40.0) {
print "- Action: shared_buffers appears high; ensure OS cache remains adequate."
} else {
print "- Action: shared_buffers proportion looks reasonable."
}
}'
else
echo "- Action: compare shared_buffers to system RAM manually (target often 15-25% for dedicated DB hosts)."
fi
fi
echo "- Action: for media scans, verify high-read tables use selective indexes and that autovacuum keeps dead tuples controlled."
echo "- Action: rerun this script before and during a scan to compare write pressure and cache behavior over time."
} >> "$OUT_FILE"
echo "Diagnostics complete. Report written to: $OUT_FILE"