Microsegment.ru
  • Главная страница
  • О проекте
  • Портфолио
  • Блог
Системы

Автоматизация управления жизненным циклом партиций слоя RAW гибридного хранилища данных

Автоматизация управления жизненным циклом партиций слоя RAW гибридного хранилища данных
Системы

Здесь представлены файлы bash для автоматизации управления жизненным циклом партиций (архивация и восстановление) слоя сырых данных RAW минимального жизнеспособного (MVP) гибридного хранилища данных (Data Lakehouse) на основе PostgreSQL.

Содержание:

  1. Скрипт файла archive_raw_partition.sh для архивации партиций слоя RAW
  2. Скрипт файла restore_raw_partition.sh для восстановления партиции слоя RAW из архива
  3. Скрипт файла create_new_partition.sh для создания новых партиций слоя RAW

Скрипт файла archive_raw_partition.sh для архивации партиций слоя RAW¶

Файл archive_raw_partition.sh — предназначен для автоматизации архивации партиций слоя RAW минимального жизнеспособного гибридного хранилища данных с полным удалением. В файле реализована следующая логика:

  1. Отключение партиции от родительской таблицы (DETACH PARTITION).
  2. Удаление самой партиции (DROP TABLE), чтобы она перестала существовать как объект.
  3. Удаление табличного пространства (DROP TABLESPACE).
  4. Отмонтирование тома.
  5. Создание сжатого архива содержимого тома (если том ещё существует).
  6. Удаление логического тома (если передан флаг --remove-lv).
  7. Удаление записи из /etc/fstab
  8. Удаление точки монтирования (каталога).

Это гарантирует, что не остаётся ссылок на несуществующие файлы, и запрос SELECT * FROM raw.table_1 не вызовет ошибок.Обязательно сделать этот файл исполняемым с помощью команды: sudo chmod +x /opt/dlh/scripts/archive_raw_partition.sh. После этого его можно будет использовать с помощью команды sudo ./archive_raw_partition.sh q2_2026 --remove-lv.

archive_raw_partition.sh

#!/usr/bin/env bash
# ============================================================================
# archive_raw_partition.sh — архивация и удаление одной квартальной партиции
#
# Принципы (те же, что в create_raw.sh / drop_raw.sh):
#   1. Идемпотентность: повторный запуск безопасен.
#   2. Безопасный порядок: DETACH → CHECKPOINT → архив → верификация → DROP.
#   3. Разрушение — только после успешной верификации архива.
#   4. Наблюдаемость: предполётный отчёт + логирование каждого шага.
#   5. Готовность к CI/CD: --dry-run, вынесенная конфигурация, flock.
#
# Использование:
#   sudo ./archive_raw_partition.sh q2_2026
#   sudo ./archive_raw_partition.sh q2_2026 --remove-lv \
#        --yes-i-really-mean-it
#   sudo ./archive_raw_partition.sh q2_2026 --dry-run
# ============================================================================

set -Eeuo pipefail

trap 'on_error $? $LINENO "${BASH_COMMAND}"' ERR
trap 'cleanup' EXIT

on_error() {
    local exit_code=$1 line_no=$2 failed_cmd=$3
    log "КРИТИЧЕСКАЯ ОШИБКА: код=${exit_code}, строка=${line_no}, команда='${failed_cmd}'"
    exit "${exit_code}"
}

cleanup() {
    # Гарантированно размонтируем временную точку, если она осталась
    if [[ -n "${TEMP_MNT:-}" && -d "${TEMP_MNT:-}" ]]; then
        if mountpoint -q "$TEMP_MNT" 2>/dev/null; then
            umount "$TEMP_MNT" 2>/dev/null || true
        fi
        rmdir "$TEMP_MNT" 2>/dev/null || true
    fi
    log "Завершение archive_raw_partition.sh."
}

# ---------------------------------------------------------------------------
# 1. Конфигурация
# ---------------------------------------------------------------------------
CONFIG_FILE="${CONFIG_FILE:-/etc/dlh/raw.env}"
if [[ -f "$CONFIG_FILE" ]]; then
    # shellcheck source=/dev/null
    source "$CONFIG_FILE"
fi

PG_VERSION="${PG_VERSION:-17}"
PG_SERVICE="${PG_SERVICE:-postgresql-${PG_VERSION}}"
PG_DB="${PG_DB:-dlh_db}"
PG_USER="${PG_USER:-postgres}"

RAW_SCHEMA="${RAW_SCHEMA:-raw}"
TABLE_NAME="${TABLE_NAME:-table_1}"
VG_NAME="${VG_NAME:-vg_data}"
LV_PREFIX="${LV_PREFIX:-lv_raw}"
MOUNT_BASE="${MOUNT_BASE:-/mnt}"
TS_PREFIX="${TS_PREFIX:-ts_raw}"
ARCHIVE_DIR="${ARCHIVE_DIR:-/mnt/backup/raw_archives}"
LOADER_ROLE="${LOADER_ROLE:-loader_role}"

LOG_DIR="${LOG_DIR:-/var/log/dlh/scripts}"
LOG_FILE="${LOG_FILE:-${LOG_DIR}/archive_raw_$(date +%Y%m%d_%H%M%S).log}"

# Параметры блокировок (настраиваются)
LOCK_TIMEOUT="${LOCK_TIMEOUT:-5s}"          # на каждую DDL-операцию
STATEMENT_TIMEOUT="${STATEMENT_TIMEOUT:-30s}"  # на DETACH CONCURRENTLY

# ---------------------------------------------------------------------------
# 2. Аргументы
# ---------------------------------------------------------------------------
QUARTER=""
REMOVE_LV=false
DRY_RUN=false
CONFIRM=false

usage() {
    cat <<EOF
Использование: $0 <qN_YYYY> [опции]

Аргументы:
  qN_YYYY                   Квартал, например q2_2026 (N=1..4, YYYY — год).

Опции:
  --remove-lv               Удалить LV, запись в fstab и каталог монтирования.
                            ТРЕБУЕТ --yes-i-really-mean-it.
  --yes-i-really-mean-it    Подтверждение деструктивных операций.
  --dry-run                 Показать план, ничего не выполнять.
  -h, --help                Эта справка.
EOF
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        --remove-lv)             REMOVE_LV=true ;;
        --yes-i-really-mean-it)  CONFIRM=true ;;
        --dry-run)               DRY_RUN=true ;;
        -h|--help)               usage; exit 0 ;;
        -*)                      echo "Неизвестная опция: $1" >&2; usage; exit 2 ;;
        *)                       [[ -z "$QUARTER" ]] && QUARTER="$1" || { echo "Лишний аргумент: $1" >&2; exit 2; } ;;
    esac
    shift
done

[[ -n "$QUARTER" ]] || { echo "ОШИБКА: укажите квартал (например, q2_2026)." >&2; usage; exit 2; }

# Валидация формата qN_YYYY
if [[ ! "$QUARTER" =~ ^q([1-4])_([0-9]{4})$ ]]; then
    echo "ОШИБКА: неверный формат '${QUARTER}'. Ожидается q1..q4_YYYY." >&2
    exit 2
fi
Q_NUM="${BASH_REMATCH[1]}"
Q_YEAR="${BASH_REMATCH[2]}"

if $REMOVE_LV && ! $CONFIRM; then
    echo "ОШИБКА: --remove-lv уничтожает данные. Добавьте --yes-i-really-mean-it." >&2
    exit 2
fi

# ---------------------------------------------------------------------------
# 3. Производные переменные
# ---------------------------------------------------------------------------
PARTITION_NAME="${RAW_SCHEMA}.${TABLE_NAME}_${QUARTER}"
TABLESPACE_NAME="${TS_PREFIX}_${QUARTER}"
MOUNT_POINT="${MOUNT_BASE}/raw_${QUARTER}"
LV_NAME="${LV_PREFIX}_${QUARTER}"
LV_PATH="/dev/${VG_NAME}/${LV_NAME}"
TEMP_MNT="${MOUNT_BASE}/temp_${QUARTER}"
BACKUP_FILE="${ARCHIVE_DIR}/raw_${QUARTER}_$(date +%Y%m%d_%H%M%S).tar.gz"

LOCK_FILE="/var/lock/archive_raw_partition.${QUARTER}.lock"

# ---------------------------------------------------------------------------
# 4. Базовые функции
# ---------------------------------------------------------------------------
mkdir -p "$LOG_DIR" "$ARCHIVE_DIR"

log() {
    local msg
    msg="$(date '+%Y-%m-%d %H:%M:%S') - $1"
    echo "$msg" | tee -a "$LOG_FILE" >&2
}

die() {
    log "ФАТАЛЬНАЯ ОШИБКА: $1"
    exit 1
}

require_root() {
    [[ $EUID -eq 0 ]] || die "Скрипт должен быть запущен от root (sudo)."
}

acquire_lock() {
    exec 9>"$LOCK_FILE"
    if ! flock -n 9; then
        die "Другой экземпляр archive_raw_partition.sh для ${QUARTER} уже выполняется."
    fi
}

# ---------------------------------------------------------------------------
# 5. Унифицированный запуск команд и SQL
# ---------------------------------------------------------------------------
run_cmd() {
    local desc="$1"; shift
    if $DRY_RUN; then
        log "[DRY-RUN] ${desc}"
        return 0
    fi
    log "${desc}"
    "$@"
}

run_sql() {
    local sql="$1"
    local -a psql_vars=("${@:2}")

    if $DRY_RUN; then
        log "[DRY-RUN] SQL: $(printf '%s' "$sql" | tr '\n' ' ' | head -c 400)"
        return 0
    fi

    log "Выполняется SQL (${#sql} байт)."
    if printf '%s\n' "$sql" |
        sudo -u "$PG_USER" -- psql \
            -X -q -d "$PG_DB" \
            -v ON_ERROR_STOP=1 -1 \
            "${psql_vars[@]}" \
            -f - >/dev/null; then
        return 0
    else
        local rc=$?
        log "Ошибка выполнения SQL (rc=${rc})."
        return "${rc}"
    fi
}

psql_at() {
    sudo -u "$PG_USER" -- psql -X -At -d "$PG_DB" -v ON_ERROR_STOP=1 -c "$1" 2>/dev/null || true
}

# ---------------------------------------------------------------------------
# 6. Предполётный отчёт
# ---------------------------------------------------------------------------
preflight_report() {
    log "=== Предполётный отчёт для ${QUARTER} ==="
    log "Режим: $($DRY_RUN && echo 'DRY-RUN' || echo 'РЕАЛЬНЫЕ ДЕЙСТВИЯ')"
    log "Партиция: ${PARTITION_NAME}"
    log "Tablespace: ${TABLESPACE_NAME}"
    log "LV: ${LV_PATH} → ${MOUNT_POINT}"
    log "Архив: ${BACKUP_FILE}"
    log "Удалять LV: ${REMOVE_LV}"

    # Проверка существования партиции и её границ
    local info
    info="$(psql_at "
        SELECT format('%I.%I', n.nspname, c.relname) || ' | bound: ' ||
               COALESCE(pg_get_expr(c.relpartbound, c.oid), 'N/A') ||
               ' | size: ' || COALESCE(pg_size_pretty(pg_total_relation_size(c.oid)), 'N/A')
        FROM pg_class c
        JOIN pg_namespace n ON n.oid = c.relnamespace
        WHERE n.nspname = '${RAW_SCHEMA}'
          AND c.relname = '${TABLE_NAME}_${QUARTER}';
    ")"
    if [[ -n "$info" ]]; then
        log "Найдена партиция: ${info}"
    else
        log "Партиция ${PARTITION_NAME} не найдена — скрипт будет идемпотентен."
    fi

    # Проверка tablespace
    local ts_exists
    ts_exists="$(psql_at "SELECT 1 FROM pg_tablespace WHERE spcname = '${TABLESPACE_NAME}';")"
    if [[ "$ts_exists" == "1" ]]; then
        local refs
        refs="$(psql_at "SELECT count(*) FROM pg_class c JOIN pg_tablespace t ON t.oid = c.reltablespace WHERE t.spcname = '${TABLESPACE_NAME}';")"
        log "Tablespace ${TABLESPACE_NAME} существует (объектов внутри: ${refs})."
    else
        log "Tablespace ${TABLESPACE_NAME} отсутствует."
    fi

    # Проверка LV
    if lvs "$LV_PATH" &>/dev/null; then
        log "LV ${LV_PATH} существует."
    else
        log "LV ${LV_PATH} отсутствует."
    fi
}

# ---------------------------------------------------------------------------
# 7. Остановка записи в партицию (без обрыва транзакций)
# ---------------------------------------------------------------------------
stop_writes_phase() {
    log "--- Фаза 1: остановка записи в ${PARTITION_NAME} ---"

    local part_exists
    part_exists="$(psql_at "SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '${RAW_SCHEMA}' AND c.relname = '${TABLE_NAME}_${QUARTER}';")"
    if [[ "$part_exists" != "1" ]]; then
        log "Партиция ${PARTITION_NAME} отсутствует — пропускаем REVOKE."
        return 0
    fi

    # REVOKE с явным ON TABLE (в исходнике REVOKE был без ON TABLE — синтаксическая ошибка)
    run_sql "
        SET LOCAL lock_timeout = '${LOCK_TIMEOUT}';
        REVOKE INSERT, UPDATE, DELETE ON TABLE ${PARTITION_NAME} FROM ${LOADER_ROLE};
    " || die "REVOKE на ${PARTITION_NAME} не удался."

    # Диагностика блокирующих сессий (без принудительного завершения)
    local blockers
    blockers="$(psql_at "
        SELECT count(*)
        FROM pg_stat_activity
        WHERE usename = '${LOADER_ROLE}' AND state = 'active';
    ")"
    if [[ "${blockers:-0}" != "0" ]]; then
        log "ВНИМАНИЕ: активных сессий ${LOADER_ROLE}: ${blockers}. DETACH может ждать."
        log "Рекомендация: дождаться завершения или использовать pg_cancel_backend вручную."
    else
        log "Активных сессий ${LOADER_ROLE} нет."
    fi

    log "Запись в ${PARTITION_NAME} остановлена (REVOKE)."
}

# ---------------------------------------------------------------------------
# 8. Архивация: DETACH → CHECKPOINT → mount ro → tar → верификация
# ---------------------------------------------------------------------------
archive_phase() {
    log "--- Фаза 2: архивация ${PARTITION_NAME} ---"

    local part_exists
    part_exists="$(psql_at "SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '${RAW_SCHEMA}' AND c.relname = '${TABLE_NAME}_${QUARTER}';")"
    if [[ "$part_exists" != "1" ]]; then
        log "Партиция ${PARTITION_NAME} отсутствует — архивация не требуется."
        return 0
    fi

    # 8.1. DETACH CONCURRENTLY (если поддерживается; вне транзакции!)
    #     DETACH ... CONCURRENTLY нельзя в transaction block → отдельный psql без -1.
    if ! $DRY_RUN; then
        log "Отсоединение партиции ${PARTITION_NAME} (CONCURRENTLY)."
        if ! sudo -u "$PG_USER" -- psql \
                -X -q -d "$PG_DB" \
                -v ON_ERROR_STOP=1 \
                -c "SET lock_timeout = '${LOCK_TIMEOUT}';
                    SET statement_timeout = '${STATEMENT_TIMEOUT}';
                    ALTER TABLE ${RAW_SCHEMA}.${TABLE_NAME}
                        DETACH PARTITION ${PARTITION_NAME} CONCURRENTLY;" >/dev/null 2>&1; then
            log "DETACH CONCURRENTLY не удался (возможно, версия <14). Пробуем обычный DETACH."
            run_sql "
                SET LOCAL lock_timeout = '${LOCK_TIMEOUT}';
                ALTER TABLE ${RAW_SCHEMA}.${TABLE_NAME} DETACH PARTITION ${PARTITION_NAME};
            " || die "DETACH ${PARTITION_NAME} не удался."
        else
            log "DETACH CONCURRENTLY завершён."
        fi
    else
        log "[DRY-RUN] ALTER TABLE ${RAW_SCHEMA}.${TABLE_NAME} DETACH PARTITION ${PARTITION_NAME} CONCURRENTLY"
    fi

    # 8.2. CHECKPOINT — после DETACH, чтобы грязные страницы сбросились на диск
    run_cmd "CHECKPOINT перед архивацией" \
        sudo -u "$PG_USER" -- psql -X -q -d "$PG_DB" -c "CHECKPOINT;" >/dev/null \
        || log "Предупреждение: CHECKPOINT завершился с ошибкой."

    # 8.3. Монтирование LV только для чтения
    if lvs "$LV_PATH" &>/dev/null; then
        log "Монтирование ${LV_PATH} → ${TEMP_MNT} (ro)."
        mkdir -p "$TEMP_MNT"
        if ! $DRY_RUN; then
            mount -o ro "$LV_PATH" "$TEMP_MNT" || die "mount -o ro ${LV_PATH} ${TEMP_MNT} не удался."
        else
            log "[DRY-RUN] mount -o ro ${LV_PATH} ${TEMP_MNT}"
        fi

        # 8.4. Создание архива
        run_cmd "Создание архива ${BACKUP_FILE}" \
            tar -czf "$BACKUP_FILE" -C "$TEMP_MNT" . \
            || die "tar ${BACKUP_FILE} не удался."

        # 8.5. Верификация архива
        if ! $DRY_RUN; then
            log "Верификация архива ${BACKUP_FILE}."
            tar -tzf "$BACKUP_FILE" >/dev/null 2>&1 \
                || die "Архив ${BACKUP_FILE} повреждён. DROP отменён."
            log "Архив верифицирован."
        else
            log "[DRY-RUN] tar -tzf ${BACKUP_FILE}"
        fi

        # 8.6. Размонтирование временной точки
        run_cmd "Размонтирование ${TEMP_MNT}" umount "$TEMP_MNT" \
            || die "umount ${TEMP_MNT} не удался."
        rmdir "$TEMP_MNT" 2>/dev/null || true
    else
        log "LV ${LV_PATH} отсутствует — архивация на уровне тома пропущена."
        log "Рекомендация: выполнить pg_dump для standalone-таблицы ${PARTITION_NAME}."
    fi

    log "Архивация ${PARTITION_NAME} завершена."
}

# ---------------------------------------------------------------------------
# 9. Удаление партиции и tablespace (только после верификации архива)
# ---------------------------------------------------------------------------
drop_phase() {
    log "--- Фаза 3: удаление партиции и tablespace ---"

    # 9.1. DROP TABLE ... RESTRICT
    local part_exists
    part_exists="$(psql_at "SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '${RAW_SCHEMA}' AND c.relname = '${TABLE_NAME}_${QUARTER}';")"
    if [[ "$part_exists" == "1" ]]; then
        run_sql "
            SET LOCAL lock_timeout = '${LOCK_TIMEOUT}';
            DROP TABLE ${PARTITION_NAME} RESTRICT;
        " || die "DROP TABLE ${PARTITION_NAME} RESTRICT не удался (возможно, есть зависимости)."
        log "Партиция ${PARTITION_NAME} удалена."
    else
        log "Партиция ${PARTITION_NAME} уже отсутствует."
    fi

    # 9.2. DROP TABLESPACE — только если пусто
    local ts_exists
    ts_exists="$(psql_at "SELECT 1 FROM pg_tablespace WHERE spcname = '${TABLESPACE_NAME}';")"
    if [[ "$ts_exists" == "1" ]]; then
        local refs
        refs="$(psql_at "SELECT count(*) FROM pg_class c JOIN pg_tablespace t ON t.oid = c.reltablespace WHERE t.spcname = '${TABLESPACE_NAME}';")"
        if [[ "${refs:-0}" != "0" ]]; then
            die "Tablespace ${TABLESPACE_NAME} не пуст (объектов: ${refs}). DROP невозможен."
        fi
        run_sql "DROP TABLESPACE IF EXISTS \"${TABLESPACE_NAME}\";" \
            || die "DROP TABLESPACE ${TABLESPACE_NAME} не удался."
        log "Tablespace ${TABLESPACE_NAME} удалён."
    else
        log "Tablespace ${TABLESPACE_NAME} уже отсутствует."
    fi
}

# ---------------------------------------------------------------------------
# 10. LVM-фаза (строго по --remove-lv + --yes-i-really-mean-it)
# ---------------------------------------------------------------------------
lvm_cleanup_phase() {
    log "--- Фаза 4: удаление LV и каталога монтирования ---"

    # 10.1. umount (если смонтирован)
    if mountpoint -q "$MOUNT_POINT"; then
        run_cmd "Размонтирование ${MOUNT_POINT}" umount "$MOUNT_POINT" \
            || die "umount ${MOUNT_POINT} не удался."
    else
        log "${MOUNT_POINT} не смонтирован."
    fi

    # 10.2. fstab: удаление записи с бэкапом
    if grep -qsF " ${MOUNT_POINT} " /etc/fstab; then
        if $DRY_RUN; then
            log "[DRY-RUN] Удаление записи ${MOUNT_POINT} из /etc/fstab"
        else
            cp -a /etc/fstab /etc/fstab.dlh.bak
            awk -v mp="$MOUNT_POINT" '$2 != mp' /etc/fstab.dlh.bak > /etc/fstab.new
            mv /etc/fstab.new /etc/fstab
            log "Запись ${MOUNT_POINT} удалена из /etc/fstab (бэкап: /etc/fstab.dlh.bak)."
        fi
    else
        log "Запись для ${MOUNT_POINT} в /etc/fstab отсутствует."
    fi

    # 10.3. daemon-reload после изменения fstab
    run_cmd "systemctl daemon-reload" systemctl daemon-reload \
        || log "Предупреждение: daemon-reload завершился с ошибкой."

    # 10.4. rmdir — только пустой каталог (никаких rm -rf)
    if [[ -d "$MOUNT_POINT" ]]; then
        local content
        content="$(ls -A "$MOUNT_POINT" 2>/dev/null || true)"
        if [[ -n "$content" ]]; then
            log "ВНИМАНИЕ: каталог ${MOUNT_POINT} не пуст после umount:"
            log "$(printf '%s\n' "$content" | head -10)"
            die "Отказ удалять непустой каталог ${MOUNT_POINT}."
        fi
        run_cmd "rmdir ${MOUNT_POINT}" rmdir "$MOUNT_POINT" \
            || die "rmdir ${MOUNT_POINT} не удался."
    else
        log "Каталог ${MOUNT_POINT} отсутствует."
    fi

    # 10.5. lvremove
    if lvs "$LV_PATH" &>/dev/null; then
        run_cmd "lvremove -y ${LV_PATH}" lvremove -y "$LV_PATH" \
            || die "lvremove ${LV_PATH} не удался."
    else
        log "LV ${LV_PATH} отсутствует."
    fi

    log "LVM-фаза завершена."
}

# ---------------------------------------------------------------------------
# 11. Пост-проверки
# ---------------------------------------------------------------------------
post_checks() {
    log "=== Пост-проверки ==="
    log "Архив: ${BACKUP_FILE} (размер: $(du -h "$BACKUP_FILE" 2>/dev/null | cut -f1 || echo 'N/A'))"

    if [[ -d "${PG_DATA:-/var/lib/pgsql/${PG_VERSION}/data}/pg_tblspc" ]]; then
        local orphans
        orphans="$(find "${PG_DATA}/pg_tblspc" -maxdepth 1 -type l -xtype l 2>/dev/null || true)"
        if [[ -n "$orphans" ]]; then
            log "ВНИМАНИЕ: битые симлинки в pg_tblspc:"
            log "$orphans"
        fi
    fi
}

# ---------------------------------------------------------------------------
# 12. Main
# ---------------------------------------------------------------------------
main() {
    require_root
    acquire_lock
    log "=== Начало archive_raw_partition.sh для ${QUARTER} ==="

    preflight_report

    stop_writes_phase
    archive_phase
    drop_phase

    if $REMOVE_LV; then
        lvm_cleanup_phase
    else
        log "LVM-фаза пропущена (--remove-lv не передан)."
    fi

    post_checks

    if $DRY_RUN; then
        log "=== DRY-RUN завершён. Реальных изменений не производилось. ==="
    else
        log "=== Архивация ${QUARTER} успешно завершена. ==="
    fi
}

main "$@"

Скрипт файла restore_raw_partition.sh для восстановления партиции слоя RAW из архива¶

Файл restore_raw_partition.sh — предназначен для автоматизации восстановления партиции слоя RAW из архива. В файле реализована следующая логика:

  1. Создание LVM-тома (если отсутствует) указанного размера (например, 800 ГБ).
  2. Форматирование в XFS.
  3. Монтирование тома в каталог /mnt/raw_<квартал>.
  4. Распаковка архива в этот каталог.
  5. Установка прав для пользователя postgres.
  6. Создание табличного пространства.
  7. Создание партиции как части raw.table_1 с нужным диапазоном дат.

Обязательно сделать этот файл исполняемым с пмощью команды: sudo chmod +x /opt/dlh/scripts/restore_raw_partition.sh. После этого его можно будет использовать с помощью команды sudo ./restore_raw_partition.sh.

#!/usr/bin/env bash
# ============================================================================
# restore_raw_partition.sh — восстановление одной квартальной партиции
#
# Принципы (те же, что в create_raw.sh / drop_raw.sh / archive_raw_partition.sh):
#   1. Идемпотентность.
#   2. Безопасный порядок: LV → FS → mount → tablespace → restore → attach.
#   3. Разрушение — только после верификации источника.
#   4. Наблюдаемость: предполётный отчёт + логирование каждого шага.
#   5. Готовность к CI/CD: --dry-run, вынесенная конфигурация, flock.
#
# Форматы:
#   pg_dump  — РЕКОМЕНДУЕТСЯ. Логический custom-dump standalone-таблицы.
#   tar      — LEGACY. Физический tar тома. Требует --force-tar-attach
#              (rename relfilenode R1→R2 + REINDEX) — опасно, только для DR.
#
# Использование:
#   sudo ./restore_raw_partition.sh q2_2026 /backup/raw_q2_2026.dump
#   sudo ./restore_raw_partition.sh q2_2026 /backup/raw_q2_2026.tar.gz \
#        --format=tar --dry-run
# ============================================================================

set -Eeuo pipefail
trap 'on_error $? $LINENO "${BASH_COMMAND}"' ERR
trap 'cleanup' EXIT

on_error() {
    log "КРИТИЧЕСКАЯ ОШИБКА: код=$1, строка=$2, команда='$3'"
    exit "$1"
}

cleanup() {
    if [[ -n "${TEMP_MNT:-}" && -d "${TEMP_MNT:-}" ]]; then
        mountpoint -q "$TEMP_MNT" && umount "$TEMP_MNT" 2>/dev/null || true
        rmdir "$TEMP_MNT" 2>/dev/null || true
    fi
    log "Завершение restore_raw_partition.sh."
}

# ---------------------------------------------------------------------------
# 1. Конфигурация
# ---------------------------------------------------------------------------
CONFIG_FILE="${CONFIG_FILE:-/etc/dlh/raw.env}"
[[ -f "$CONFIG_FILE" ]] && source "$CONFIG_FILE"

PG_VERSION="${PG_VERSION:-17}"
PG_SERVICE="${PG_SERVICE:-postgresql-${PG_VERSION}}"
PG_DB="${PG_DB:-dlh_db}"
PG_USER="${PG_USER:-postgres}"
PG_DATA="${PG_DATA:-/var/lib/pgsql/${PG_VERSION}/data}"

RAW_SCHEMA="${RAW_SCHEMA:-raw}"
TABLE_NAME="${TABLE_NAME:-table_1}"
VG_NAME="${VG_NAME:-vg_data}"
LV_PREFIX="${LV_PREFIX:-lv_raw}"
MOUNT_BASE="${MOUNT_BASE:-/mnt}"
TS_PREFIX="${TS_PREFIX:-ts_raw}"

LV_SIZE="${LV_SIZE:-800G}"
LOG_DIR="${LOG_DIR:-/var/log/dlh/scripts}"
LOG_FILE="${LOG_FILE:-${LOG_DIR}/restore_raw_$(date +%Y%m%d_%H%M%S).log}"

LOCK_TIMEOUT="${LOCK_TIMEOUT:-5s}"
APP_USER="${APP_USER:-dlh_user}"

# ---------------------------------------------------------------------------
# 2. Аргументы
# ---------------------------------------------------------------------------
QUARTER=""
ARCHIVE_FILE=""
FORMAT="auto"
DRY_RUN=false
CONFIRM=false
SKIP_LV=false
FORCE_TAR_ATTACH=false

usage() {
    cat <<EOF
Использование: $0 <qN_YYYY> <путь_к_архиву> [опции]

Опции:
  --format=auto|pg_dump|tar   Формат архива (по умолчанию auto-detect).
  --skip-lv                   Не создавать LV/tablespace (default tablespace).
  --force-tar-attach          Для tar: переименовать relfilenode R1→R2 + REINDEX.
                              ОПАСНО. ТРЕБУЕТ --yes-i-really-mean-it.
  --yes-i-really-mean-it      Подтверждение деструктивных операций.
  --dry-run                   Показать план, ничего не выполнять.
  -h, --help                  Эта справка.
EOF
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        --format=*)              FORMAT="${1#*=}" ;;
        --skip-lv)               SKIP_LV=true ;;
        --force-tar-attach)      FORCE_TAR_ATTACH=true ;;
        --yes-i-really-mean-it)  CONFIRM=true ;;
        --dry-run)               DRY_RUN=true ;;
        -h|--help)               usage; exit 0 ;;
        -*)                      echo "Неизвестная опция: $1" >&2; usage; exit 2 ;;
        *)                       if [[ -z "$QUARTER" ]]; then QUARTER="$1"
                                 elif [[ -z "$ARCHIVE_FILE" ]]; then ARCHIVE_FILE="$1"
                                 else echo "Лишний аргумент: $1" >&2; exit 2; fi ;;
    esac
    shift
done

[[ -n "$QUARTER" && -n "$ARCHIVE_FILE" ]] || { usage; exit 2; }
[[ "$QUARTER" =~ ^q([1-4])_([0-9]{4})$ ]] || { echo "Неверный формат квартала: $QUARTER" >&2; exit 2; }
Q_NUM="${BASH_REMATCH[1]}"; Q_YEAR="${BASH_REMATCH[2]}"
[[ -f "$ARCHIVE_FILE" ]] || { echo "Архив не найден: $ARCHIVE_FILE" >&2; exit 2; }
[[ "$FORMAT" =~ ^(auto|pg_dump|tar)$ ]] || { echo "Неверный --format: $FORMAT" >&2; exit 2; }

if $FORCE_TAR_ATTACH && ! $CONFIRM; then
    echo "ОШИБКА: --force-tar-attach переименовывает файлы на диске. Добавьте --yes-i-really-mean-it." >&2
    exit 2
fi

# ---------------------------------------------------------------------------
# 3. Производные переменные и границы квартала
# ---------------------------------------------------------------------------
case "$Q_NUM" in
    1) START_DATE="${Q_YEAR}-01-01"; END_DATE="${Q_YEAR}-04-01" ;;
    2) START_DATE="${Q_YEAR}-04-01"; END_DATE="${Q_YEAR}-07-01" ;;
    3) START_DATE="${Q_YEAR}-07-01"; END_DATE="${Q_YEAR}-10-01" ;;
    4) START_DATE="${Q_YEAR}-10-01"; END_DATE="$((Q_YEAR+1))-01-01" ;;
esac

PARTITION_NAME="${RAW_SCHEMA}.${TABLE_NAME}_${QUARTER}"
PARTITION_SHORT="${TABLE_NAME}_${QUARTER}"
TABLESPACE_NAME="${TS_PREFIX}_${QUARTER}"
MOUNT_POINT="${MOUNT_BASE}/raw_${QUARTER}"
LV_NAME="${LV_PREFIX}_${QUARTER}"
LV_PATH="/dev/${VG_NAME}/${LV_NAME}"
TEMP_MNT="${MOUNT_BASE}/temp_restore_${QUARTER}"

LOCK_FILE="/var/lock/restore_raw_partition.${QUARTER}.lock"

# ---------------------------------------------------------------------------
# 4. Базовые функции
# ---------------------------------------------------------------------------
mkdir -p "$LOG_DIR"

log() {
    local msg; msg="$(date '+%Y-%m-%d %H:%M:%S') - $1"
    echo "$msg" | tee -a "$LOG_FILE" >&2
}

die() { log "ФАТАЛЬНАЯ ОШИБКА: $1"; exit 1; }

require_root() { [[ $EUID -eq 0 ]] || die "Требуется root (sudo)."; }

acquire_lock() {
    exec 9>"$LOCK_FILE"
    flock -n 9 || die "Другой экземпляр restore для ${QUARTER} уже выполняется."
}

run_cmd() {
    local desc="$1"; shift
    if $DRY_RUN; then log "[DRY-RUN] ${desc}"; return 0; fi
    log "${desc}"; "$@"
}

run_sql() {
    local sql="$1"; shift
    if $DRY_RUN; then
        log "[DRY-RUN] SQL: $(printf '%s' "$sql" | tr '\n' ' ' | head -c 400)"
        return 0
    fi
    log "SQL (${#sql} байт)."
    printf '%s\n' "$sql" |
        sudo -u "$PG_USER" -- psql -X -q -d "$PG_DB" \
            -v ON_ERROR_STOP=1 -1 "$@" -f - >/dev/null \
        || { log "Ошибка SQL."; return 1; }
}

psql_at() {
    sudo -u "$PG_USER" -- psql -X -At -d "$PG_DB" -v ON_ERROR_STOP=1 -c "$1" 2>/dev/null || true
}

# ---------------------------------------------------------------------------
# 5. Определение формата
# ---------------------------------------------------------------------------
detect_format() {
    if [[ "$FORMAT" != "auto" ]]; then echo "$FORMAT"; return; fi
    if pg_restore --list "$ARCHIVE_FILE" >/dev/null 2>&1; then echo "pg_dump"; return; fi
    if tar -tzf "$ARCHIVE_FILE" >/dev/null 2>&1; then echo "tar"; return; fi
    echo "unknown"
}

# ---------------------------------------------------------------------------
# 6. Предполётный отчёт
# ---------------------------------------------------------------------------
preflight_report() {
    log "=== Предполётный отчёт ==="
    log "Режим: $($DRY_RUN && echo DRY-RUN || echo REAL)"
    log "Квартал: ${QUARTER} | границы: ${START_DATE} → ${END_DATE}"
    log "Партиция: ${PARTITION_NAME}"
    log "Tablespace: ${TABLESPACE_NAME}${SKIP_LV:+ (SKIP_LV=$SKIP_LV)}"
    log "Архив: ${ARCHIVE_FILE} (формат: ${DETECTED_FORMAT})"

    # Родительская таблица существует?
    local parent
    parent="$(psql_at "SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='${RAW_SCHEMA}' AND c.relname='${TABLE_NAME}' AND c.relkind IN ('p','r');")"
    [[ "$parent" == "1" ]] || die "Родительская таблица ${RAW_SCHEMA}.${TABLE_NAME} не найдена."

    # Целевая партиция уже существует?
    local target
    target="$(psql_at "SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='${RAW_SCHEMA}' AND c.relname='${PARTITION_SHORT}';")"
    if [[ "$target" == "1" ]]; then
        local rows
        rows="$(psql_at "SELECT count(*) FROM ${PARTITION_NAME};")"
        die "Партиция ${PARTITION_NAME} уже существует (строк: ${rows}). Удалите вручную или используйте другой квартал."
    fi

    # Пересечение с существующими партициями?
    local overlaps
    overlaps="$(psql_at "
        SELECT string_agg(c.relname, ', ')
        FROM pg_inherits i
        JOIN pg_class c ON c.oid = i.inhrelid
        JOIN pg_class p ON p.oid = i.inhparent
        JOIN pg_namespace n ON n.oid = c.relnamespace
        WHERE p.relname = '${TABLE_NAME}' AND n.nspname = '${RAW_SCHEMA}'
          AND pg_get_expr(c.relpartbound, c.oid) LIKE '%${Q_YEAR}%';")"
    if [[ -n "$overlaps" ]]; then
        log "ВНИМАНИЕ: соседние партиции за ${Q_YEAR} год: ${overlaps}"
        log "Проверьте, что границы ${START_DATE}→${END_DATE} не пересекаются."
    fi
}

# ---------------------------------------------------------------------------
# 7. LV + FS + mount (идемпотентно, только если нужно)
# ---------------------------------------------------------------------------
ensure_lv_fs_mount() {
    log "--- Фаза LV+FS+mount ---"

    if lvs "$LV_PATH" &>/dev/null; then
        log "LV ${LV_PATH} уже существует."
    else
        # Проверка свободного места
        local free_mb
        free_mb="$(vgs --noheadings -o vg_free --units m -S "vg_name=${VG_NAME}" 2>/dev/null | tr -d ' m' | cut -d. -f1)"
        local need_mb
        need_mb="$(numfmt --from=iec "${LV_SIZE}" --to=iec 2>/dev/null || echo 0)"
        log "Свободно в VG ${VG_NAME}: ${free_mb:-?} MB (запрошено: ${LV_SIZE})."
        run_cmd "Создание LV ${LV_NAME} (${LV_SIZE})" \
            lvcreate -L "$LV_SIZE" -n "$LV_NAME" "$VG_NAME" \
            || die "lvcreate ${LV_NAME} не удался."
    fi

    # ФС: только если нет
    local fstype
    fstype="$(blkid -o value -s TYPE "$LV_PATH" 2>/dev/null || true)"
    if [[ "$fstype" == "xfs" ]]; then
        log "ФС XFS на ${LV_PATH} уже есть."
    else
        run_cmd "Форматирование ${LV_PATH} в XFS" mkfs.xfs -f "$LV_PATH" \
            || die "mkfs.xfs ${LV_PATH} не удался."
    fi

    # mount
    mkdir -p "$MOUNT_POINT"
    if mountpoint -q "$MOUNT_POINT"; then
        local src; src="$(findmnt -no SOURCE -T "$MOUNT_POINT" | tr -d '[:space:]')"
        [[ "$src" == "$LV_PATH" ]] || die "${MOUNT_POINT} смонтирован с ${src}, ожидался ${LV_PATH}."
        log "${MOUNT_POINT} уже смонтирован корректно."
    else
        run_cmd "Монтирование ${LV_PATH} → ${MOUNT_POINT}" mount "$LV_PATH" "$MOUNT_POINT" \
            || die "mount ${LV_PATH} ${MOUNT_POINT} не удался."
    fi

    # fstab (awk вместо sed, с бэкапом)
    if awk -v mp="$MOUNT_POINT" '$2 == mp {found=1} END {exit !found}' /etc/fstab; then
        log "Запись для ${MOUNT_POINT} в fstab уже есть."
    else
        if $DRY_RUN; then
            log "[DRY-RUN] Добавление ${MOUNT_POINT} в /etc/fstab"
        else
            cp -a /etc/fstab /etc/fstab.dlh.bak
            printf '%s %s xfs defaults,nofail 0 0\n' "$LV_PATH" "$MOUNT_POINT" >> /etc/fstab
            log "Запись для ${MOUNT_POINT} добавлена в fstab (бэкап: /etc/fstab.dlh.bak)."
            systemctl daemon-reload || log "Предупреждение: daemon-reload с ошибкой."
        fi
    fi

    # Права
    run_cmd "chown postgres:postgres ${MOUNT_POINT}" chown postgres:postgres "$MOUNT_POINT" \
        || die "chown ${MOUNT_POINT} не удался."
    run_cmd "chmod 700 ${MOUNT_POINT}" chmod 700 "$MOUNT_POINT" \
        || die "chmod ${MOUNT_POINT} не удался."
}

# ---------------------------------------------------------------------------
# 8. pg_dump: восстановление через pg_restore + ATTACH
# ---------------------------------------------------------------------------
restore_pg_dump() {
    log "--- Фаза pg_dump: pg_restore + ATTACH ---"

    local restore_args=( -X -q -d "$PG_DB" --no-owner --no-privileges --exit-on-error )
    if $SKIP_LV; then
        restore_args+=( --no-tablespaces )
    fi

    if $DRY_RUN; then
        log "[DRY-RUN] pg_restore ${restore_args[*]} ${ARCHIVE_FILE}"
    else
        log "pg_restore ${ARCHIVE_FILE} → ${PG_DB}."
        sudo -u "$PG_USER" -- pg_restore "${restore_args[@]}" "$ARCHIVE_FILE" \
            || die "pg_restore завершился с ошибкой."
    fi

    # ATTACH standalone-таблицы к родителю
    run_sql "
        SET LOCAL lock_timeout = '${LOCK_TIMEOUT}';
        ALTER TABLE ${RAW_SCHEMA}.${TABLE_NAME}
            ATTACH PARTITION ${PARTITION_NAME}
            FOR VALUES FROM ('${START_DATE}') TO ('${END_DATE}');
    " || die "ATTACH PARTITION ${PARTITION_NAME} не удался."
    log "Партиция ${PARTITION_NAME} присоединена к ${RAW_SCHEMA}.${TABLE_NAME}."
}

# ---------------------------------------------------------------------------
# 9. tar: extract + (опционально) relfilenode-rename + REINDEX
# ---------------------------------------------------------------------------
restore_tar() {
    log "--- Фаза tar: extract в ${MOUNT_POINT} ---"

    # 9.1. Проверка архива ДО extract
    if ! tar -tzf "$ARCHIVE_FILE" >/dev/null 2>&1; then
        die "Архив ${ARCHIVE_FILE} повреждён или не tar.gz."
    fi
    local structure
    structure="$(tar -tzf "$ARCHIVE_FILE" | grep -E '^\./PG_[0-9]+' | head -1 || true)"
    [[ -n "$structure" ]] || die "Архив не содержит ожидаемой структуры ./PG_*/."

    # 9.2. Проверка пустоты MOUNT_POINT
    local content
    content="$(ls -A "$MOUNT_POINT" 2>/dev/null | grep -v '^lost+found$' || true)"
    if [[ -n "$content" ]]; then
        die "${MOUNT_POINT} не пуст. Восстановление поверх существующих данных запрещено. Содержимое: $(echo "$content" | head -5 | tr '\n' ' ')"
    fi

    # 9.3. Extract
    run_cmd "tar -xzf ${ARCHIVE_FILE} → ${MOUNT_POINT}" \
        tar -xzf "$ARCHIVE_FILE" -C "$MOUNT_POINT" \
        || die "tar extract не удался."

    # 9.4. Права
    run_cmd "chown -R postgres:postgres ${MOUNT_POINT}" \
        chown -R postgres:postgres "$MOUNT_POINT" \
        || die "chown -R не удался."

    # 9.5. Найти relfilenode на диске
    local pg_dir db_dir old_relfilenode
    pg_dir="$(find "$MOUNT_POINT" -maxdepth 1 -type d -name 'PG_*' | head -1)"
    [[ -n "$pg_dir" ]] || die "В ${MOUNT_POINT} нет каталога PG_*/."
    db_dir="$(find "$pg_dir" -maxdepth 1 -type d -name '[0-9]*' | head -1)"
    [[ -n "$db_dir" ]] || die "В ${pg_dir} нет подкаталога с OID базы."

    old_relfilenode="$(find "$db_dir" -maxdepth 1 -type f -name '[0-9]*' ! -name '*_*' -printf '%f\n' | sort | head -1)"
    [[ -n "$old_relfilenode" ]] || die "Не найден relfilenode в ${db_dir}."

    log "Обнаружен relfilenode в архиве: ${old_relfilenode}"
    log "Файлы: $(find "$db_dir" -maxdepth 1 -type f -printf '%f ' | head -c 300)"

    # 9.6. Ветка: с --force-tar-attach — автоматический rename
    if ! $FORCE_TAR_ATTACH; then
        log "ВНИМАНИЕ: tar-формат восстановлен на уровне ФС, но PostgreSQL не видит данные."
        log "Ручные шаги:"
        log "  1) CREATE TABLESPACE ${TABLESPACE_NAME} LOCATION '${MOUNT_POINT}'"
        log "  2) CREATE TABLE ${PARTITION_NAME} PARTITION OF ${RAW_SCHEMA}.${TABLE_NAME}"
        log "       FOR VALUES FROM ('${START_DATE}') TO ('${END_DATE}')"
        log "       TABLESPACE ${TABLESPACE_NAME};  -- получит новый relfilenode R2"
        log "  3) Остановить PostgreSQL, переименовать ${db_dir}/${old_relfilenode}* → */R2*, запустить."
        log "  4) REINDEX TABLE ${PARTITION_NAME};"
        log "Автоматически — с --force-tar-attach --yes-i-really-mean-it."
        die "tar-восстановление НЕ завершено автоматически (безопасный отказ)."
    fi

    # 9.7. Автоматический rename (--force-tar-attach)
    local db_oid
    db_oid="$(psql_at "SELECT oid FROM pg_database WHERE datname = '${PG_DB}';")"
    [[ -n "$db_oid" ]] || die "Не удалось получить OID БД ${PG_DB}."

    # CREATE TABLESPACE (идемпотентно; PG_* уже есть из tar)
    local ts_exists
    ts_exists="$(psql_at "SELECT 1 FROM pg_tablespace WHERE spcname = '${TABLESPACE_NAME}';")"
    if [[ "$ts_exists" != "1" ]]; then
        run_sql "CREATE TABLESPACE \"${TABLESPACE_NAME}\" OWNER postgres LOCATION '${MOUNT_POINT}';" \
            || die "CREATE TABLESPACE ${TABLESPACE_NAME} не удался."
    fi

    # Пустая партиция → новый relfilenode R2
    run_sql "
        SET LOCAL lock_timeout = '${LOCK_TIMEOUT}';
        CREATE TABLE ${PARTITION_NAME}
            PARTITION OF ${RAW_SCHEMA}.${TABLE_NAME}
            FOR VALUES FROM ('${START_DATE}') TO ('${END_DATE}')
            TABLESPACE ${TABLESPACE_NAME};
    " || die "CREATE TABLE ${PARTITION_NAME} не удался."

    local new_relfilenode
    new_relfilenode="$(psql_at "SELECT relfilenode FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='${RAW_SCHEMA}' AND c.relname='${PARTITION_SHORT}';")"
    [[ -n "$new_relfilenode" ]] || die "Не удалось получить новый relfilenode."

    log "Переименование файлов: R1=${old_relfilenode} → R2=${new_relfilenode} в ${db_dir}."
    log "ВНИМАНИЕ: остановка PostgreSQL для атомарной подмены файлов."
    run_cmd "systemctl stop ${PG_SERVICE}" systemctl stop "$PG_SERVICE" \
        || die "Не удалось остановить ${PG_SERVICE}."

    local f renamed=0
    for f in "$db_dir/${old_relfilenode}" "$db_dir/${old_relfilenode}"_*; do
        [[ -e "$f" ]] || continue
        local suffix="${f#${db_dir}/${old_relfilenode}}"
        local dst="${db_dir}/${new_relfilenode}${suffix}"
        if $DRY_RUN; then
            log "[DRY-RUN] mv ${f} ${dst}"
        else
            mv "$f" "$dst" || die "mv ${f} → ${dst} не удался."
            chown postgres:postgres "$dst"
            chmod 0600 "$dst"
            renamed=$((renamed+1))
        fi
    done
    [[ $renamed -gt 0 ]] || die "Ни один файл не переименован — состояние неопределено."
    log "Переименовано файлов: ${renamed}."

    run_cmd "systemctl start ${PG_SERVICE}" systemctl start "$PG_SERVICE" \
        || die "Не удалось запустить ${PG_SERVICE}."
    for _ in {1..30}; do
        sudo -u "$PG_USER" -- pg_isready -q && break
        sleep 1
    done

    # REINDEX обязателен: индексы в tar имели старые relfilenode и не подхвачены
    run_sql "REINDEX TABLE ${PARTITION_NAME};" \
        || die "REINDEX ${PARTITION_NAME} не удался."
    log "Партиция ${PARTITION_NAME} восстановлена из tar (R1=${old_relfilenode} → R2=${new_relfilenode})."
}

# ---------------------------------------------------------------------------
# 10. Гранты и комментарии
# ---------------------------------------------------------------------------
apply_grants() {
    run_sql "
        GRANT SELECT, INSERT ON ${PARTITION_NAME} TO ${APP_USER};
    " || log "ВНИМАНИЕ: GRANT для ${APP_USER} не удался."
    log "Гранты для ${APP_USER} применены к ${PARTITION_NAME}."
}

# ---------------------------------------------------------------------------
# 11. Пост-проверки
# ---------------------------------------------------------------------------
verify_and_report() {
    log "=== Пост-проверки ==="

    local rows size ts_actual
    rows="$(psql_at "SELECT count(*) FROM ${PARTITION_NAME};")"
    size="$(psql_at "SELECT pg_size_pretty(pg_total_relation_size('${PARTITION_NAME}'));")"
    ts_actual="$(psql_at "
        SELECT t.spcname
        FROM pg_class c
        JOIN pg_tablespace t ON t.oid = c.reltablespace
        JOIN pg_namespace n ON n.oid = c.relnamespace
        WHERE n.nspname='${RAW_SCHEMA}' AND c.relname='${PARTITION_SHORT}';")"

    log "Строк в ${PARTITION_NAME}: ${rows:-?}"
    log "Размер: ${size:-?}"
    log "Tablespace: ${ts_actual:-pg_default}"

    if [[ "${rows:-0}" == "0" ]]; then
        log "ВНИМАНИЕ: партиция пуста. Проверьте, что архив содержал данные."
    fi

    # Осиротевшие pg_tblspc
    if [[ -d "${PG_DATA}/pg_tblspc" ]]; then
        local orphans
        orphans="$(find "${PG_DATA}/pg_tblspc" -maxdepth 1 -type l -xtype l 2>/dev/null || true)"
        [[ -n "$orphans" ]] && log "ВНИМАНИЕ: битые симлинки pg_tblspc: $orphans"
    fi
}

# ---------------------------------------------------------------------------
# 12. Main
# ---------------------------------------------------------------------------
main() {
    require_root
    acquire_lock
    log "=== Начало restore_raw_partition.sh для ${QUARTER} ==="

    DETECTED_FORMAT="$(detect_format)"
    [[ "$DETECTED_FORMAT" == "unknown" ]] && die "Не удалось определить формат архива ${ARCHIVE_FILE}."

    preflight_report

    if ! $SKIP_LV; then
        ensure_lv_fs_mount
    else
        log "--- Фаза LV пропущена (--skip-lv) ---"
    fi

    # PostgreSQL должен быть запущен до restore
    if ! systemctl is-active --quiet "$PG_SERVICE"; then
        run_cmd "Запуск ${PG_SERVICE}" systemctl start "$PG_SERVICE" \
            || die "Не удалось запустить ${PG_SERVICE}."
    fi

    case "$DETECTED_FORMAT" in
        pg_dump) restore_pg_dump ;;
        tar)     restore_tar ;;
    esac

    apply_grants
    verify_and_report

    if $DRY_RUN; then
        log "=== DRY-RUN завершён. Реальных изменений не производилось. ==="
    else
        log "=== Восстановление ${QUARTER} завершено. ==="
    fi
}

main "$@"

Скрипт файла create_new_partition.sh для создания новых партиций слоя RAW¶

Файл create_new_partition.sh — предназначен для автоматизации создания новых партиций для хранения квартальных данных слоя RAW который будет использоваться на этапе промышленного внедрения. В рамках MVP он не будет запускаться автоматически, но должен быть готов. Обязательно сделать этот файл исполняемым с пмощью команды: sudo chmod +x /opt/dlh/scripts/create_new_partition.sh. После этого его можно будет использовать с помощью команды sudo ./create_new_partition.sh 2027 1.

#!/usr/bin/env bash
# ============================================================================
# create_new_partition.sh — идемпотентное создание одной квартальной партиции
#
# Назначение: "горячий путь" — вызывается регулярно (cron/pg_partman/CI-CD),
# поэтому:
#   * PostgreSQL НЕ останавливается — все операции online;
#   * DDL выполняется в одной транзакции с lock_timeout;
#   * повторный запуск безопасен (идемпотентность);
#   * есть --dry-run для отладки в пайплайне.
#
# Принципы (те же, что в create_raw.sh / drop_raw.sh / archive / restore):
#   1. Идемпотентность.        2. Атомарность DDL с lock_timeout.
#   3. Non-disruptive.         4. Наблюдаемость + --dry-run.
#   5. Готовность к pg_partman / Ansible / CI-CD.
#
# Если в окружении установлен pg_partman — используйте его для premake
# партиций; этот скрипт остаётся как ручной premake / bootstrap / DR-tool.
#
# Использование:
#   sudo ./create_new_partition.sh 2027 1
#   sudo ./create_new_partition.sh 2027 1 --dry-run
#   sudo ./create_new_partition.sh 2027 1 --skip-lv   # pg_default tablespace
# ============================================================================

set -Eeuo pipefail
trap 'on_error $? $LINENO "${BASH_COMMAND}"' ERR
trap 'cleanup' EXIT

on_error() {
    log "КРИТИЧЕСКАЯ ОШИБКА: код=$1, строка=$2, команда='$3'"
    exit "$1"
}

cleanup() {
    log "Завершение create_new_partition.sh."
}

# ---------------------------------------------------------------------------
# 1. Конфигурация
# ---------------------------------------------------------------------------
CONFIG_FILE="${CONFIG_FILE:-/etc/dlh/raw.env}"
[[ -f "$CONFIG_FILE" ]] && source "$CONFIG_FILE"

PG_VERSION="${PG_VERSION:-17}"
PG_SERVICE="${PG_SERVICE:-postgresql-${PG_VERSION}}"
PG_DB="${PG_DB:-dlh_db}"
PG_USER="${PG_USER:-postgres}"

RAW_SCHEMA="${RAW_SCHEMA:-raw}"
TABLE_NAME="${TABLE_NAME:-table_1}"
VG_NAME="${VG_NAME:-vg_data}"
LV_PREFIX="${LV_PREFIX:-lv_raw}"
MOUNT_BASE="${MOUNT_BASE:-/mnt}"
TS_PREFIX="${TS_PREFIX:-ts_raw}"

LV_SIZE="${LV_SIZE:-800G}"
LOG_DIR="${LOG_DIR:-/var/log/dlh/scripts}"
LOG_FILE="${LOG_FILE:-${LOG_DIR}/create_partition_$(date +%Y%m%d_%H%M%S).log}"

LOCK_TIMEOUT="${LOCK_TIMEOUT:-5s}"
STATEMENT_TIMEOUT="${STATEMENT_TIMEOUT:-10s}"

APP_USER="${APP_USER:-dlh_user}"

# ---------------------------------------------------------------------------
# 2. Аргументы
# ---------------------------------------------------------------------------
YEAR=""
QUARTER=""
DRY_RUN=false
SKIP_LV=false

usage() {
    cat <<EOF
Использование: $0 <year> <quarter> [опции]

  <year>     Год (2000..2100), например 2027
  <quarter>  Номер квартала (1..4)

Опции:
  --skip-lv     Не создавать LV/tablespace. Партиция пойдёт в pg_default.
  --dry-run     Показать план, ничего не выполнять.
  -h, --help    Эта справка.
EOF
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        --skip-lv)   SKIP_LV=true ;;
        --dry-run)   DRY_RUN=true ;;
        -h|--help)   usage; exit 0 ;;
        -*)          echo "Неизвестная опция: $1" >&2; usage; exit 2 ;;
        *)           if [[ -z "$YEAR" ]]; then YEAR="$1"
                     elif [[ -z "$QUARTER" ]]; then QUARTER="$1"
                     else echo "Лишний аргумент: $1" >&2; exit 2; fi ;;
    esac
    shift
done

[[ -n "$YEAR" && -n "$QUARTER" ]] || { usage; exit 2; }
[[ "$YEAR" =~ ^[0-9]{4}$ ]] || { echo "ОШИБКА: год должен быть 4 цифры: ${YEAR}" >&2; exit 2; }
[[ "$YEAR" -ge 2000 && "$YEAR" -le 2100 ]] || { echo "ОШИБКА: год вне диапазона 2000..2100: ${YEAR}" >&2; exit 2; }
[[ "$QUARTER" =~ ^[1-4]$ ]] || { echo "ОШИБКА: квартал должен быть 1..4: ${QUARTER}" >&2; exit 2; }

# ---------------------------------------------------------------------------
# 3. Производные переменные и границы квартала
# ---------------------------------------------------------------------------
QUARTER_NAME="q${QUARTER}_${YEAR}"
PARTITION_SHORT="${TABLE_NAME}_${QUARTER_NAME}"
PARTITION_NAME="${RAW_SCHEMA}.${PARTITION_SHORT}"
TABLESPACE_NAME="${TS_PREFIX}_${QUARTER_NAME}"
MOUNT_POINT="${MOUNT_BASE}/raw_${QUARTER_NAME}"
LV_NAME="${LV_PREFIX}_${QUARTER_NAME}"
LV_PATH="/dev/${VG_NAME}/${LV_NAME}"

case "$QUARTER" in
    1) START_DATE="${YEAR}-01-01"; END_DATE="${YEAR}-04-01" ;;
    2) START_DATE="${YEAR}-04-01"; END_DATE="${YEAR}-07-01" ;;
    3) START_DATE="${YEAR}-07-01"; END_DATE="${YEAR}-10-01" ;;
    4) START_DATE="${YEAR}-10-01"; END_DATE="$((YEAR+1))-01-01" ;;
esac

LOCK_FILE="/var/lock/create_partition.${QUARTER_NAME}.lock"

# ---------------------------------------------------------------------------
# 4. Базовые функции
# ---------------------------------------------------------------------------
mkdir -p "$LOG_DIR"

log() {
    local msg; msg="$(date '+%Y-%m-%d %H:%M:%S') - $1"
    echo "$msg" | tee -a "$LOG_FILE" >&2
}

die() { log "ФАТАЛЬНАЯ ОШИБКА: $1"; exit 1; }

require_root() { [[ $EUID -eq 0 ]] || die "Требуется root (sudo)."; }

acquire_lock() {
    exec 9>"$LOCK_FILE"
    flock -n 9 || die "Другой экземпляр для ${QUARTER_NAME} уже выполняется."
}

run_cmd() {
    local desc="$1"; shift
    if $DRY_RUN; then log "[DRY-RUN] ${desc}"; return 0; fi
    log "${desc}"; "$@"
}

run_sql() {
    local sql="$1"; shift
    if $DRY_RUN; then
        log "[DRY-RUN] SQL: $(printf '%s' "$sql" | tr '\n' ' ' | head -c 400)"
        return 0
    fi
    log "SQL (${#sql} байт)."
    printf '%s\n' "$sql" |
        sudo -u "$PG_USER" -- psql -X -q -d "$PG_DB" \
            -v ON_ERROR_STOP=1 -1 "$@" -f - >/dev/null \
        || { log "Ошибка SQL."; return 1; }
}

psql_at() {
    sudo -u "$PG_USER" -- psql -X -At -d "$PG_DB" -v ON_ERROR_STOP=1 -c "$1" 2>/dev/null || true
}

# ---------------------------------------------------------------------------
# 5. Готовность PostgreSQL (без остановки)
# ---------------------------------------------------------------------------
ensure_pg_running() {
    if systemctl is-active --quiet "$PG_SERVICE"; then
        log "Служба ${PG_SERVICE} уже запущена."
    else
        run_cmd "Запуск ${PG_SERVICE}" systemctl start "$PG_SERVICE" \
            || die "Не удалось запустить ${PG_SERVICE}."
    fi
    for _ in {1..30}; do
        sudo -u "$PG_USER" -- pg_isready -q && { log "PostgreSQL готов."; return 0; }
        sleep 1
    done
    die "PostgreSQL не стал готов за 30 секунд."
}

# ---------------------------------------------------------------------------
# 6. Предполётный отчёт
# ---------------------------------------------------------------------------
preflight_report() {
    log "=== Предполётный отчёт ==="
    log "Режим: $($DRY_RUN && echo DRY-RUN || echo REAL)"
    log "Партиция: ${PARTITION_NAME}"
    log "Границы: ${START_DATE} → ${END_DATE}"
    log "Tablespace: ${SKIP_LV:+pg_default}${SKIP_LV:-${TABLESPACE_NAME}}"
    log "LV: ${LV_PATH}${SKIP_LV:+ (SKIP_LV=true)}"
    log "MOUNT_POINT: ${MOUNT_POINT}${SKIP_LV:+ (SKIP_LV=true)}"

    # Родительская таблица должна существовать и быть партиционированной
    local parent_info
    parent_info="$(psql_at "
        SELECT c.relkind FROM pg_class c
        JOIN pg_namespace n ON n.oid = c.relnamespace
        WHERE n.nspname = '${RAW_SCHEMA}' AND c.relname = '${TABLE_NAME}';")"
    [[ "$parent_info" == "p" ]] || die "Родитель ${RAW_SCHEMA}.${TABLE_NAME} не найден или не является партиционированной таблицей (relkind=${parent_info:-none})."

    # Целевая партиция уже существует?
    local target_exists
    target_exists="$(psql_at "
        SELECT 1 FROM pg_class c
        JOIN pg_namespace n ON n.oid = c.relnamespace
        WHERE n.nspname = '${RAW_SCHEMA}' AND c.relname = '${PARTITION_SHORT}';")"
    if [[ "$target_exists" == "1" ]]; then
        local bound rows
        bound="$(psql_at "SELECT pg_get_expr(c.relpartbound, c.oid) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='${RAW_SCHEMA}' AND c.relname='${PARTITION_SHORT}';")"
        rows="$(psql_at "SELECT count(*) FROM ${PARTITION_NAME};")"
        log "Партиция ${PARTITION_NAME} уже существует."
        log "  bounds: ${bound}"
        log "  rows:   ${rows}"
        if [[ "$bound" == *"${START_DATE}"* && "$bound" == *"${END_DATE}"* ]]; then
            log "Границы совпадают — идемпотентный skip."
            IDEMPOTENT_SKIP=true
        else
            die "Партиция ${PARTITION_NAME} существует с другими границами (${bound}). Требуется ручное вмешательство."
        fi
    else
        IDEMPOTENT_SKIP=false
    fi

    # Соседние партиции — для предупреждения о пересечении
    local siblings
    siblings="$(psql_at "
        SELECT string_agg(c.relname || ' ' || pg_get_expr(c.relpartbound, c.oid), E'\n')
        FROM pg_inherits i
        JOIN pg_class c ON c.oid = i.inhrelid
        JOIN pg_class p ON p.oid = i.inhparent
        JOIN pg_namespace n ON n.oid = p.relnamespace
        WHERE p.relname = '${TABLE_NAME}' AND n.nspname = '${RAW_SCHEMA}'
          AND pg_get_expr(c.relpartbound, c.oid) LIKE '%${YEAR}%';")"
    if [[ -n "$siblings" ]]; then
        log "Партиции за ${YEAR} год (проверьте пересечение границ):"
        while IFS= read -r line; do log "  - ${line}"; done <<< "$siblings"
    fi
}

# ---------------------------------------------------------------------------
# 7. LVM + ФС + mount (идемпотентно, PG не останавливается)
# ---------------------------------------------------------------------------
ensure_lv_fs_mount() {
    log "--- Фаза LV+FS+mount ---"

    # 7.1. LV
    if lvs "$LV_PATH" &>/dev/null; then
        log "LV ${LV_PATH} уже существует."
    else
        local free_g vg_free
        vg_free="$(vgs --noheadings --units g --nosuffix -o vg_free -S "vg_name=${VG_NAME}" 2>/dev/null | tr -d ' ' || true)"
        [[ -n "$vg_free" ]] || die "VG ${VG_NAME} не найдена."
        free_g="${vg_free%.*}"
        log "Свободно в VG ${VG_NAME}: ${free_g}G, запрошено: ${LV_SIZE}."
        run_cmd "lvcreate -L ${LV_SIZE} -n ${LV_NAME} ${VG_NAME}" \
            lvcreate -L "$LV_SIZE" -n "$LV_NAME" "$VG_NAME" \
            || die "lvcreate ${LV_NAME} не удался (проверьте свободное место)."
    fi

    # 7.2. ФС — только если её нет
    local fstype
    fstype="$(blkid -o value -s TYPE "$LV_PATH" 2>/dev/null || true)"
    if [[ "$fstype" == "xfs" ]]; then
        log "ФС XFS на ${LV_PATH} уже существует — форматирование пропущено."
    else
        run_cmd "mkfs.xfs ${LV_PATH}" mkfs.xfs -f "$LV_PATH" \
            || die "mkfs.xfs ${LV_PATH} не удался."
    fi

    # 7.3. Точка монтирования
    mkdir -p "$MOUNT_POINT"
    if mountpoint -q "$MOUNT_POINT"; then
        local src
        src="$(findmnt -no SOURCE -T "$MOUNT_POINT" | tr -d '[:space:]')"
        [[ "$src" == "$LV_PATH" ]] || die "${MOUNT_POINT} смонтирован с ${src}, ожидался ${LV_PATH}."
        log "${MOUNT_POINT} уже смонтирован корректно."
    else
        run_cmd "mount ${LV_PATH} → ${MOUNT_POINT}" mount "$LV_PATH" "$MOUNT_POINT" \
            || die "mount ${LV_PATH} ${MOUNT_POINT} не удался."
    fi

    # 7.4. fstab (awk, бэкап, nofail)
    if awk -v mp="$MOUNT_POINT" '$2 == mp {found=1} END {exit !found}' /etc/fstab; then
        log "Запись для ${MOUNT_POINT} в fstab уже есть."
    else
        if $DRY_RUN; then
            log "[DRY-RUN] Добавление ${LV_PATH} ${MOUNT_POINT} в /etc/fstab"
        else
            cp -a /etc/fstab /etc/fstab.dlh.bak
            printf '%s %s xfs defaults,nofail 0 0\n' "$LV_PATH" "$MOUNT_POINT" >> /etc/fstab
            log "Запись для ${MOUNT_POINT} добавлена в fstab (бэкап: /etc/fstab.dlh.bak)."
            systemctl daemon-reload || log "Предупреждение: daemon-reload с ошибкой."
        fi
    fi

    # 7.5. Права
    run_cmd "chown postgres:postgres ${MOUNT_POINT}" chown postgres:postgres "$MOUNT_POINT" \
        || die "chown ${MOUNT_POINT} не удался."
    run_cmd "chmod 700 ${MOUNT_POINT}" chmod 700 "$MOUNT_POINT" \
        || die "chmod ${MOUNT_POINT} не удался."
}

# ---------------------------------------------------------------------------
# 8. Создание tablespace (идемпотентно, online)
# ---------------------------------------------------------------------------
ensure_tablespace() {
    log "--- Фаза CREATE TABLESPACE ---"

    local exists loc
    exists="$(psql_at "SELECT 1 FROM pg_tablespace WHERE spcname = '${TABLESPACE_NAME}';")"
    if [[ "$exists" == "1" ]]; then
        loc="$(psql_at "SELECT pg_tablespace_location(oid) FROM pg_tablespace WHERE spcname = '${TABLESPACE_NAME}';")"
        if [[ "$loc" != "$MOUNT_POINT" ]]; then
            die "Tablespace ${TABLESPACE_NAME} существует с LOCATION=${loc}, ожидалось ${MOUNT_POINT}."
        fi
        log "Tablespace ${TABLESPACE_NAME} уже существует (LOCATION=${loc})."
        return 0
    fi

    run_sql "
        SET LOCAL lock_timeout = '${LOCK_TIMEOUT}';
        CREATE TABLESPACE \"${TABLESPACE_NAME}\" OWNER postgres LOCATION '${MOUNT_POINT}';
    " || die "CREATE TABLESPACE ${TABLESPACE_NAME} не удался (проверьте: каталог существует, пуст, владелец postgres)."
    log "Tablespace ${TABLESPACE_NAME} создан."
}

# ---------------------------------------------------------------------------
# 9. Создание партиции (online, атомарно, с lock_timeout)
# ---------------------------------------------------------------------------
create_partition() {
    log "--- Фаза CREATE PARTITION ---"

    if $IDEMPOTENT_SKIP; then
        log "Партиция ${PARTITION_NAME} уже существует с корректными границами — пропуск."
        return 0
    fi

    local ts_clause="TABLESPACE \"${TABLESPACE_NAME}\""
    $SKIP_LV && ts_clause=""

    # CREATE TABLE ... PARTITION OF берёт ACCESS EXCLUSIVE на родителя.
    # SET LOCAL lock_timeout ограничивает ожидание; statement_timeout — общий предел.
    run_sql "
        SET LOCAL lock_timeout = '${LOCK_TIMEOUT}';
        SET LOCAL statement_timeout = '${STATEMENT_TIMEOUT}';
        CREATE TABLE ${PARTITION_NAME}
            PARTITION OF ${RAW_SCHEMA}.${TABLE_NAME}
            FOR VALUES FROM ('${START_DATE}') TO ('${END_DATE}')
            ${ts_clause};
    " || die "CREATE TABLE ${PARTITION_NAME} не удался (возможно, пересечение границ или lock_timeout)."
    log "Партиция ${PARTITION_NAME} создана."
}

# ---------------------------------------------------------------------------
# 10. Гранты для прикладной роли
# ---------------------------------------------------------------------------
apply_grants() {
    local exists
    exists="$(psql_at "SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='${RAW_SCHEMA}' AND c.relname='${PARTITION_SHORT}';")"
    [[ "$exists" == "1" ]] || return 0

    run_sql "
        GRANT SELECT, INSERT ON ${PARTITION_NAME} TO ${APP_USER};
    " || log "ВНИМАНИЕ: GRANT для ${APP_USER} не удался."
    log "Гранты для ${APP_USER} применены."
}

# ---------------------------------------------------------------------------
# 11. Верификация
# ---------------------------------------------------------------------------
verify_and_report() {
    log "=== Пост-проверки ==="

    local info
    info="$(psql_at "
        SELECT
            pg_get_expr(c.relpartbound, c.oid) || ' | ts=' ||
            COALESCE(t.spcname, 'pg_default') || ' | size=' ||
            pg_size_pretty(pg_total_relation_size(c.oid))
        FROM pg_class c
        JOIN pg_namespace n ON n.oid = c.relnamespace
        LEFT JOIN pg_tablespace t ON t.oid = c.reltablespace
        WHERE n.nspname = '${RAW_SCHEMA}' AND c.relname = '${PARTITION_SHORT}';")"

    if [[ -n "$info" ]]; then
        log "Партиция: ${info}"
    else
        log "ВНИМАНИЕ: партиция не найдена по завершении — проверьте лог SQL."
    fi

    # Всего партиций в родителе
    local total
    total="$(psql_at "
        SELECT count(*) FROM pg_inherits i
        JOIN pg_class p ON p.oid = i.inhparent
        JOIN pg_namespace n ON n.oid = p.relnamespace
        WHERE p.relname = '${TABLE_NAME}' AND n.nspname = '${RAW_SCHEMA}';")"
    log "Всего партиций у ${RAW_SCHEMA}.${TABLE_NAME}: ${total:-?}"
}

# ---------------------------------------------------------------------------
# 12. Main
# ---------------------------------------------------------------------------
main() {
    require_root
    acquire_lock
    log "=== Начало create_new_partition.sh: ${QUARTER_NAME} ==="

    ensure_pg_running
    preflight_report

    if ! $IDEMPOTENT_SKIP; then
        if ! $SKIP_LV; then
            ensure_lv_fs_mount
            ensure_tablespace
        else
            log "--- LV и TABLESPACE пропущены (--skip-lv) ---"
        fi
        create_partition
    fi

    apply_grants
    verify_and_report

    if $DRY_RUN; then
        log "=== DRY-RUN завершён. Реальных изменений не производилось. ==="
    else
        log "=== Партиция ${PARTITION_NAME} готова (${START_DATE} → ${END_DATE}). ==="
    fi
}

main "$@"

AI-Ready платформа Bash Data Lakehouse PostgreSQL гибридное хранилище данных корпоративная информационная система практика

Предыдущая статьяПрименение n8n Self-hosted AI Starter Kit на локальном ПКСледующая статья Автоматизация наполнения данными гибридного хранилища данных

Рубрики

Метки

abc abcd AI-Ready платформа Bash Data Lakehouse ELT excel ms sql n8n PostgreSQL PowerShell Python RAG RAW sql STG tessa VBA xyz ИИ-агент анализ виртуальный помощник гибридное хранилище данных данные знания информационная система информация искусственный интеллект кластерный анализ компетенции корпоративная информационная система математика модель предоставления прав мудрость о проекте оптимизация практика программное обеспечение ролевая модель сеть; теория теория вероятностей тесса юмор языки программирования

Политика конфиденциальности

Продолжая использовать данный сайт вы подтверждаете свое согласие с условиями его политики конфиденциальности. Подробнее…




Администрация и владельцы данного информационного ресурса не несут ответственности за возможные последствия, связанные с использованием информации, размещенной на нем.


Все права защищены. При копировании материалов сайта обязательно указывать ссылку на © Microsegment.ru (2020-2026)