Перейти к содержанию

Управление партициями PostgreSQL: от одного сбоя к другому

Партиционированная таблица может поднять вас с постели двумя способами. Первый — INSERT в 03:00, который PostgreSQL отклоняет, потому что никто не создал партицию на следующий месяц. Второй тише и хуже: задача очистки удалила таблицу, которую сама не создавала. Создать партиции легко. Годами поддерживать их дерево в правильном состоянии каждую ночь и ни разу не тронуть чужую таблицу — вот на что никто не закладывает время. Эту часть я переписывал из сервиса в сервис, пока она не превратилась в pg-partsmith. Устройство библиотеки выросло из сбоев; по ним же построена статья: непонятный результат обслуживания, удаление чужих таблиц, одновременный запуск двух реплик, команда без Python, архиватор, который должен отработать до удаления, и ассистент, придумывающий несуществующий API.

Непонятно, что сделает обслуживание

Прежде чем проектировать ядро, я хотел разобраться, какие сложные случаи встречались только мне, а какие — всем. Поэтому изучил исходный код управления партициями в десяти рабочих системах: GlitchTip, GitLab, Centrifugo, PGMQ, Hatchet, pg-trx-outbox, Hookdeck Outpost, ColdFront, pg_partman и pg_clickhouse. Итоговый отчёт начинается с исходного вопроса: если десять промышленных систем написали собственные менеджеры партиций PostgreSQL, что понадобилось им всем и какую часть стоит вынести в библиотеку?

Совпадений оказалось больше, чем я ожидал; список есть в исследовании открытых проектов. Никто не доверяет имени партиции, если доступны другие сведения; в остальных случаях читают границы из каталога. Все серьёзные реализации разделяют «что должно существовать» и «что делать сейчас». Отсоединение и удаление — разные события. Надёжно определить принадлежность таблицы не удаётся никому. Операторы хотят увидеть план до запуска. Об этих выводах — следующие разделы.

pg-partsmith работает как цикл приведения к желаемому состоянию: вы описываете нужное дерево, библиотека читает фактическое дерево из каталога, превращает разницу в план и применяет его. Промежуточный результат — отдельное значение, с которым можно работать: plan_maintenance — чистая Python-функция от конфигурации, прочитанного дерева и времени, а service.plan() получает план без блокировок и DDL.

from sqlalchemy.ext.asyncio import create_async_engine

from pg_partsmith import PartitionGranularity, TablePartitionConfig
from pg_partsmith.aio import PartitionToolkit

config = TablePartitionConfig(
    schema="public",
    table_name="events",
    partition_column="created_at",
    granularity=PartitionGranularity.MONTH,
    create_ahead_count=3,
    retention_count=12,
)

engine = create_async_engine("postgresql+asyncpg://app@localhost/app")
kit = PartitionToolkit.from_engine(engine)
plan = await kit.service.plan(config)                # no lock, no DDL
print(plan.describe())

Если построить план 28 августа, получится:

plan for public.events at 2026-08-28T00:00:00+00:00
  CREATE public.events__2026_08 (create_ahead)
  CREATE public.events__2026_09 (create_ahead)
  CREATE public.events__2026_10 (create_ahead)

У каждой операции есть причина. У строк detach и drop появляются size= и rows~, если они понадобились правилу вроде SizeAbove или RowsAbove; обычную помесячную таблицу никто не измеряет. Объекты, которые планировщик увидел и оставил в покое, попадают в диагностические сообщения с уровнем важности: предупреждение требует внимания человека, информационная запись — нет.

Краткая форма выше подходит для первой партиционированной таблицы. У второй первичный ключ UUIDv7 и нет подходящей временной колонки; третья хранит данные арендаторов и требует хеш-разбиения внутри каждого месяца; на четвёртую ссылается внешний ключ другой таблицы, поэтому PostgreSQL запрещает отсоединять партицию, пока на её строки есть ссылки; пятой три года управлял pg_partman. Для таких случаев есть составная форма:

from datetime import timedelta

from pg_partsmith import (
    CreateAhead, DropAfter, HashPartitioning, KeepNewest, LifecyclePolicy,
    PartitionGranularity, RangePartitioning, TablePartitionConfig,
    TimeBoundaries, UUIDv7BoundaryCodec,
)

config = TablePartitionConfig(
    table_name="issue_events",
    scheme=RangePartitioning(
        key="id",                                                   # a UUIDv7 column
        boundaries=TimeBoundaries(granularity=PartitionGranularity.WEEK, codec=UUIDv7BoundaryCodec()),
        child=HashPartitioning(key="organization_id", modulus=4),   # each week split by tenant
    ),
    lifecycle=LifecyclePolicy(
        creation=CreateAhead(count=3),
        retention=KeepNewest(count=12),          # twelve weeks, not twelve leaves
        drop=DropAfter(grace=timedelta(days=7)), # detach now, drop a week later
    ),
)

Комментарий у правила хранения выражает основную идею. Уровень RANGE — это последовательность: неограниченный ряд окон, по которому движется жизненный цикл. Уровень HASH — это набор: фиксированный, полный и не истекающий. Партиция непосредственно под уровнем последовательности — единица жизненного цикла: она целиком создаётся, учитывается, передаётся обработчикам и выводится из обращения вместе с поддеревом. Поэтому KeepNewest(12) сохраняет двенадцать недель независимо от числа корзин внутри, а обработчик before_drop вызывается раз на неделю, а не четыре раза. Unreferenced() вместе с ограничением количества в ExpireIf(AllOf((KeepNewest(12), Unreferenced()))) решает случай четвёртой таблицы: партиция, на которую ещё ссылаются, пока не считается просроченной, вместо того чтобы каждый запуск добавлял отклонённый DETACH в result.issues. Обратите внимание на значения по умолчанию: CreateAhead() задаёт шесть окон, KeepNewest() — двенадцать, оба учитывают текущее; у DropAfter() нет отсрочки, поэтому стандартная политика отсоединяет и удаляет за один запуск. Отсрочку или DropNever() нужно выбрать явно.

План — ещё и документ: JSON с признаком is_destructive и самой строгой блокировкой каждой операции, а также config_fingerprint конфигурации таблицы. Если сохранить план во вторник и применить в четверг, когда кто-то уже изменил число сохраняемых партиций, выполнение будет отклонено с PlanConfigMismatchError. Повторный запуск для дерева, уже приведённого к нужному состоянию, не выполняет ни одного DDL-запроса. Это проверяет интеграционный тест, считающий запросы.

ИДЕЯ В СХЕМЕСначала изучить состояние, затем выполнить план
---
config:
  theme: default
  look: classic
  flowchart:
    useMaxWidth: false
    wrappingWidth: 150
    padding: 12
    nodeSpacing: 24
    rankSpacing: 32
---
flowchart TD
    accTitle: Сначала изучить состояние, затем выполнить план
    accDescr: План описывает предполагаемые изменения. Обслуживание выполняет DDL отдельными шагами; общей атомарной транзакции для всех партиций нет.
 C[("Каталог PostgreSQL")] --> I["Изучить партиции"]
 P["Политика создания и хранения"] --> B["Построить план"]
 I --> B
 B --> M["Выполнить шаги DDL"] --> O["Сообщить результаты"]

План описывает предполагаемые изменения. Обслуживание выполняет DDL отдельными шагами; общей атомарной транзакции для всех партиций нет.

Удаление того, что вы не создавали

Первый вопрос администратора БД к любому инструменту с DROP: «Это точно моё?» Ответ берётся из каталога, без отдельной таблицы метаданных, которая может с ним рассинхронизироваться. Если библиотека не может установить принадлежность, ответ — нет.

Для присоединённой партиции проверяются границы, прочитанные через pg_get_expr(relpartbound), а не имя. Если её окно совпадает с ячейкой настроенной сетки или помещается внутри неё, партиция участвует в жизненном цикле. Более крупные окна и окна, пересекающие две ячейки, отмечаются как unmanaged_partition и остаются нетронутыми. Так же происходит переход с pg_partman: партиции принимаются под управление по границам, переименований нет; таблицы, которые старый менеджер уже отсоединил, нужно передать в repo.adopt_partition, прежде чем библиотека сможет их удалить.

Для отсоединённой таблицы свидетельством служит COMMENT, записанный до DETACH: прерванный запуск оставит помеченную таблицу, а не невидимую. В первой строке — pg-partsmith:orphan-parent=public.events, во второй — pg-partsmith:detached-at=, от которого отсчитывается отсрочка удаления.

Между планом и запросом есть ещё одна проверка: отсоединение или удаление выполняется только при совпадении OID отношения с OID, увиденным планировщиком. Иначе операция отклоняется с PlanStaleError без разрушительных действий. DROP никогда не использует CASCADE, не применяется к присоединённой партиции и не удаляет таблицу без маркера, если только вы явно не указали drop_allow_unmanaged=True в репозитории. Отказы для меня не менее важны, чем сами операции.

Общей транзакции на весь запуск нет: DETACH CONCURRENTLY не может выполняться внутри неё. Каждый запрос фиксируется отдельно, поэтому библиотеке нужен engine, а не session. Если у родителя есть DEFAULT-партиция, PostgreSQL запрещает конкурентную форму; планировщик знает об этом и выбирает блокирующее отсоединение, поэтому plan --locks показывает реальную блокировку ACCESS EXCLUSIVE.

Две реплики запускаются одновременно

Встроенного планировщика задач нет. Ваш существующий планировщик вызывает PartitionMaintainer.run_maintenance_safe, который не выбрасывает исключений: ошибка возвращается в result.error. Блокировки неблокирующие и отдельные для каждой таблицы: по умолчанию advisory lock, либо Redis. При одновременном запуске двух реплик проигравшая получает LockAcquisitionError в result.error; руководство по расписанию рекомендует считать такой запуск пропущенным, а не неудачным.

Библиотека предоставляет одинаковые имена в двух вариантах: pg_partsmith.aio поверх SQLAlchemy AsyncEngine и pg_partsmith.sync поверх Engine. В синхронном варианте остаются три отличия: таймаут DDL задаётся серверным statement_timeout, блокировка Redis продлевается из потока и при потере аренды может только выдать предупреждение, а Python-обработчик выполняется непосредственно в текущем потоке.

PartitionToolkit.from_engine существует потому, что три настройки — marker_prefix, ddl_timezone, boundary_codec — используются двумя объектами и должны совпадать. Если передать marker_prefix только репозиторию, провайдер метаданных никогда не найдёт помеченные им отсоединённые таблицы, а значит, они никогда не будут удалены.

У обработчиков восемь фаз: до и после create, attach, detach и drop. Дополнительно есть on_event, который первым видит каждое событие, поэтому даже отказ оставляет след в журнале аудита. Исключение из обработчика before_* отменяет соответствующую операцию; следующий запуск запланирует её снова.

Команда, в которой нет Python

Всё это бесполезно команде, чьи сервисы написаны не на Python. С дополнением cli pg-partsmith становится командой, принимающей конфигурационный документ YAML или JSON и DSN. Контейнерный образ упаковывает её так, что самому приложению Python вообще не нужен.

inspect, plan и validate не выполняют DDL, не берут блокировок и не вызывают обработчики. apply применяет изменения, но пропускает все отсоединения и удаления без --allow-destructive: только создаёт и повторно присоединяет, что как раз подходит init-контейнеру. backfill — это библиотечный partition_data в командной строке: он освобождает DEFAULT-партицию по окнам, пакетами по 10 000 строк по умолчанию. Каждый пакет — отдельный атомарный DELETE … RETURNING / INSERT, поэтому строка не оказывается в двух местах и не исчезает из обоих. Однако перенос не прозрачен для чтения: строки окна не видны через родителя с момента переноса соответствующего пакета до присоединения партиции. --max-batches 50 останавливается после пятидесяти запросов на таблицу и возвращает код 2, если остались строки. Поэтому until pg-partsmith backfill -c partitions.yaml --max-batches 50; do sleep 60; done превращается в возобновляемый Job.

CronJob и шаг CI ориентируются на код завершения, поэтому значения специально различаются:

  • 0 — незавершённой работы нет.
  • 2 — расхождение состояния при plan --check либо оставшиеся строки при backfill.
  • 3 — диагностика, требующая человека, например отказ обработчика.
  • 4 — ошибка конфигурации, включая изменившийся отпечаток.
  • 5 — не удалось подключиться к БД.
  • 6 — блокировку держит другой процесс обслуживания; это не сбой, а --ok-if-locked преобразует код в 0.
  • 64 — неправильное использование команды; специально не стандартный код парсера 2, чтобы опечатка не вызывала тревогу о расхождении состояния.
  • 130 / 143 — остановка по SIGINT / SIGTERM после освобождения блокировки.
  • 1 — непредвиденная ошибка.
pg-partsmith plan  -c partitions.yaml --check                  # exit 2 while anything is pending; a scheduled probe
pg-partsmith plan  -c partitions.yaml --save plan.json         # zero DDL; the artifact a reviewer reads
pg-partsmith apply -c partitions.yaml --plan plan.json --allow-destructive

Флага --sql намеренно нет. Я отказался от него, потому что DDL строится во время выполнения на основе решений, принимаемых только тогда. Распечатку без этих решений за настоящий результат примет именно тот, кто проверяет отсутствие сюрпризов. --output metrics --write FILE записывает метрики Prometheus типа gauge, переименовывая временный файл в целевой: textfile-коллектор node_exporter никогда не прочитает половину файла. Все флаги перечислены в руководстве CLI.

Образ ghcr.io/bedrock-python/pg-partsmith построен на distroless Debian 12 с сокращённым Python 3.14, UID 65532 и самой командой в качестве entrypoint. Я не хотел включать ничего лишнего: нет оболочки, пакетного менеджера и pip; --write и --ok-if-locked выполняют две задачи прежнего скрипта-обёртки. Каждый релиз собирается нативно для amd64 и arm64, сканируется и публикуется по digest вместе с SBOM и сведениями о происхождении SLSA ещё до публикации в PyPI. Затем он подписывается cosign без постоянного ключа, повторно скачивается на обеих архитектурах, проверяется и проходит сквозные тесты. Ночной запуск из руководства по эксплуатации, который CI проверяет через kubeconform:

apiVersion: batch/v1
kind: CronJob
metadata: { name: partition-maintenance }
spec:
  schedule: "15 2 * * *"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 3600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 0
      activeDeadlineSeconds: 3600
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: pg-partsmith
              image: ghcr.io/bedrock-python/pg-partsmith:latest
              args: ["apply", "-c", "/etc/partitions.yaml", "--allow-destructive"]
              env:
                - { name: PG_PARTSMITH_DSN, valueFrom: { secretKeyRef: { name: partsmith-dsn, key: dsn } } }
              volumeMounts:
                - { name: config, mountPath: /etc/partitions.yaml, subPath: partitions.yaml, readOnly: true }
          volumes:
            - { name: config, configMap: { name: partition-config } }

В примерах используется latest, чтобы они не устаревали. В реальном расписании я закрепляю тег минорной версии: не хочется, чтобы CronJob ночью самостоятельно перешёл через мажорную версию с --allow-destructive в аргументах. Контейнер выполняет DDL, поэтому выделите ему отдельную роль: USAGE и CREATE для схемы и владение родительской таблицей. PostgreSQL не разрешит остальным CREATE TABLE … PARTITION OF, ATTACH и DETACH, поэтому роль должна владеть обслуживаемыми таблицами либо входить в роль-владельца. Командам чтения нужны USAGE для схемы, SELECT для каталога и, при использовании SqlPredicate, SELECT для партиции.

Архиватор должен отработать до удаления

Политика хранения редко сводится к удалению: обычно сначала нужен экспорт. В Python это класс обработчика, а в документе — секция hooks:

tables:
  - table_name: events
    partition_column: created_at
    granularity: month
    create_ahead_count: 3
    retention_count: 12

hooks:
  timeout_seconds: 900
  before_drop: ["/opt/hooks/archive-partition.sh"]
  after_create: ["/opt/hooks/notify.sh"]
  before_detach:
    python_file: hooks/export_partition.py
  after_drop:
    python: |
      log.info("dropped %s (%s)", event.partition.name, event.operation.reason)

Команда задаётся массивом аргументов, а не строкой оболочки. Она получает весь PartitionEvent как JSON через stdin, а фазу, таблицу, партицию и границы окна — в переменных PG_PARTSMITH_*; ненулевой код завершения означает отказ. Python-блок выполняется с доступными event и log; исключение тоже означает отказ. Все блоки компилируются при чтении документа, поэтому SyntaxError становится ошибкой валидации с номером строки, а не открытием в 03:00. Обработчики исполняют произвольный код в процессе с правами на DDL, поэтому apply и backfill отклоняют документ с обработчиками без --allow-hooks: код 4 ещё до подключения. Команды чтения никогда их не запускают. Изоляция произвольного кода не заявляется.

Документ проверяется строго на всех уровнях: befor_drop отклоняется прямо в месте объявления, опечатка в defaults указывается по имени, пустой список tables и повторное описание одного отношения запрещены. Ошибочное поле в файле, который никто не открывает между развёртываниями, ведёт себя не как опечатка, а как политика.

Подключение библиотеки с помощью ассистента

У ассистента по программированию характерный сбой: модель угадывает правдоподобный метод — retention_days=90 или Session там, где требуется Engine. Это можно учесть в проектировании, поэтому в документации есть страница для модели, доступная также в исходном Markdown по адресу /agents.md.

Она содержит модель работы, схему подключения, четырнадцать правил, от которых зависит корректность кода: передавайте Engine, а не Session; встроенного планировщика нет; количества включают текущий период; стандартный жизненный цикл удаляет данные. Там же — четыре пары WRONG/RIGHT с типичными ошибками моделей и карта остальной документации. Примерно 3400 слов: много для промпта, мало для библиотеки. Все страницы, кроме справочника API, доступны таким же образом: .md вместо завершающего слеша. Кнопка Copy page позволяет скопировать Markdown, показать его или открыть с ним ChatGPT, Claude либо Perplexity.

Где читать дальше

Краткая конфигурация из пяти полей в начале — не режим для новичков: это сокращение для RangePartitioning поверх TimeBoundaries с CreateAhead и KeepNewest. config.scheme и config.lifecycle в любом случае предоставляют составную форму.

Итак, три способа начать. Если сервис на Python, выполните pip install pg-partsmith, выберите pg_partsmith.aio или pg_partsmith.sync и начните с plan(). Если Python нет или вы хотите держать права на DDL вне приложения, дополнение cli либо образ запускают тот же цикл по конфигурационному документу и сообщают результат кодами завершения и метриками. Если подключением занимается ассистент, передайте ему страницу agents. Остальное есть в документации, исходники и тесты на PostgreSQL 15–18 — на GitHub.