Здесь представлены файлы bash для автоматизации управления жизненным циклом партиций (архивация и восстановление) слоя сырых данных RAW минимального жизнеспособного (MVP) гибридного хранилища данных (Data Lakehouse) на основе PostgreSQL.
Скрипт файла archive_raw_partition.sh для архивации партиций слоя RAW¶
Файл archive_raw_partition.sh — предназначен для автоматизации архивации партиций слоя RAW минимального жизнеспособного гибридного хранилища данных с полным удалением. В файле реализована следующая логика:
- Отключение партиции от родительской таблицы (
DETACH PARTITION). - Удаление самой партиции (
DROP TABLE), чтобы она перестала существовать как объект. - Удаление табличного пространства (
DROP TABLESPACE). - Отмонтирование тома.
- Создание сжатого архива содержимого тома (если том ещё существует).
- Удаление логического тома (если передан флаг
--remove-lv). - Удаление записи из
/etc/fstab - Удаление точки монтирования (каталога).
Это гарантирует, что не остаётся ссылок на несуществующие файлы, и запрос 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 из архива. В файле реализована следующая логика:
- Создание LVM-тома (если отсутствует) указанного размера (например, 800 ГБ).
- Форматирование в XFS.
- Монтирование тома в каталог /mnt/raw_<квартал>.
- Распаковка архива в этот каталог.
- Установка прав для пользователя postgres.
- Создание табличного пространства.
- Создание партиции как части 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 "$@"
