#!/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"