Управление партициями 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-запроса. Это проверяет интеграционный тест, считающий запросы.
План описывает предполагаемые изменения. Обслуживание выполняет 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.