From d6d59fc49aa78e074d49c02f301ba8a1d0fb3175 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:59:46 +0300 Subject: [PATCH 1/2] fix(postgres): stop a naming convention from qualifying the check constraints twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_event_constraints declared every check constraint with a finished ck__ name. A MetaData whose ck naming convention interpolates %(constraint_name)s — sqlalchemy-foundation-kit's "%(table_name)s_%(constraint_name)s_check", or the convention SQLAlchemy's documentation recommends — qualifies that name a second time, six of the twelve run past PostgreSQL's 63 bytes, and SQLAlchemy truncates them with a hash: outbox_events_ck_outbox_events_completed_status_consist_a784. The name in the catalog then no longer matches the convention that produced it, which is what alembic-gauntlet's test_naming_conventions checks. The helper now takes the MetaData and does what SQLAlchemy is about to do with the name: when the ck rule interpolates the constraint name it hands over the bare rule (attempts_valid, completed_status_consistency, lock_consistency) and the convention builds the name; otherwise it declares ck_
_ as before. The four bases build __table_args__ in a declared_attr that passes cls.metadata, which also gives each concrete model its own Index objects — the shared tuple could only ever be bound to one DeclarativeBase per process. Closes #36 --- docs/agents.md | 8 +- docs/api_reference.md | 2 +- docs/migrations.md | 20 ++ omni_box/infra/storage/postgres/orm.py | 111 +++++++---- tests/integration/postgres/test_orm.py | 63 ++++++- tests/unit/infra/storage/postgres/test_orm.py | 178 ++++++++++++++++++ 6 files changed, 340 insertions(+), 42 deletions(-) create mode 100644 tests/unit/infra/storage/postgres/test_orm.py diff --git a/docs/agents.md b/docs/agents.md index 32aca9a..331c60e 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -366,7 +366,7 @@ handler it installs, for when you assemble the pipeline yourself. Abstract ORM bases — bind them to your own `DeclarativeBase`: `OutboxEventDBBase`, `InboxEventDBBase`, `OutboxEventPartitionedDBBase`, `InboxEventPartitionedDBBase`, the mixin `EventMixin`, and the helpers `get_event_constraints(table_name, -include_created_at_in_unique=False)` and `UnConstrainedEnum`. +include_created_at_in_unique=False, *, metadata=None)` and `UnConstrainedEnum`. `PostgresOutboxRepository(session, *, model_class, conflict_index_id=None, conflict_index_idempotency=None, batch_size=1000, error_max_length=2000, @@ -488,7 +488,11 @@ protocols, not a third implementation. `EventBatchProcessor` sets Give the sink an idempotent key. 17. **The tables are yours.** No `Base`, no migrations, no DDL. Bind the abstract bases to your `DeclarativeBase` and generate the migration yourself; the repositories depend on the - column names, so keep them. + column names, so keep them. The check constraints are named `ck_
_` unless + your `MetaData` has a `ck` naming convention with `%(constraint_name)s` in it — then the + bases hand it the bare rule (`attempts_valid`, …) and the convention names them, so nothing + runs past PostgreSQL's 63 bytes. Composing `__table_args__` yourself, pass + `metadata=Base.metadata` to `get_event_constraints` for the same. 18. **`shutdown_requested_func` stops a batch, it does not abort an event.** It is polled before the fetch — returning `True` there locks nothing at all — and again before each event. What was already processed is committed; the events left untouched come back in diff --git a/docs/api_reference.md b/docs/api_reference.md index 001b034..04bbe1d 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -197,7 +197,7 @@ All inherit from `OmniBoxError`. - ORM bases: `OutboxEventDBBase`, `InboxEventDBBase`, `OutboxEventPartitionedDBBase`, `InboxEventPartitionedDBBase`, plus the underlying `EventMixin`, `OutboxColumnsMixin`, `InboxColumnsMixin`. - Repositories: `PostgresOutboxRepository`, `PostgresInboxRepository`, `PostgresEventRepository` (shared base). Both expose `session` — the `AsyncSession` they were built on, read-only — which is how a handler passed to `InboxConsumerRunner` writes its side effects in the transaction that inserts the inbox row. -- Helpers: `UnConstrainedEnum`, `get_event_constraints(table_name, include_created_at_in_unique=False)`. +- Helpers: `UnConstrainedEnum`, `get_event_constraints(table_name, include_created_at_in_unique=False, *, metadata=None)`. ### Kafka (extra: `kafka`) diff --git a/docs/migrations.md b/docs/migrations.md index 7048961..87eadf6 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -205,6 +205,26 @@ class InboxEventDB(Base, InboxEventDBBase): You can override `__tablename__`, add service-specific columns, or change `__inbox_dedup_index_columns__` if you partition. +## Naming conventions + +The check constraints are named `ck_
_` — `ck_outbox_events_attempts_valid`, as in the DDL above — unless the `MetaData` of your `DeclarativeBase` carries a `ck` naming convention that interpolates `%(constraint_name)s`. Then the bases hand the convention the bare rule (`attempts_valid`, `completed_status_consistency`, `lock_consistency`) and it builds the name: `sqlalchemy-foundation-kit`'s `"%(table_name)s_%(constraint_name)s_check"` gives `outbox_events_attempts_valid_check`, SQLAlchemy's documented `"ck_%(table_name)s_%(constraint_name)s"` gives `ck_outbox_events_attempts_valid` again. Every name fits PostgreSQL's 63-byte limit either way; the longest, `outbox_events_partitioned_completed_status_consistency_check`, is 60. A finished name handed to such a convention would be qualified a second time, run past 63 bytes and be truncated with a hash. The indexes keep their `idx_
_…` names under any convention — SQLAlchemy leaves an explicitly named `Index` alone unless the `ix` rule interpolates `%(constraint_name)s`, which neither of the conventions above does. + +If you compose `__table_args__` yourself, pass the metadata so the helper can see the convention: + +```python +class OrdersOutbox(Base, OutboxEventDBBase): + __tablename__ = "orders_outbox" + __table_args__ = get_event_constraints("orders_outbox", metadata=Base.metadata) +``` + +Up to 0.2.1 the bases always declared the finished name, so a database created under such a convention holds the doubly qualified names, six of the twelve truncated with a hash — `outbox_events_ck_outbox_events_completed_status_consist_a784`. Alembic's autogenerate does not compare check constraints unless `alembic.ext.checkconstraint_byname` (Alembic 1.19+) is enabled, so rename them in a hand-written migration. They are the ones with `_ck_` in the middle: + +```sql +SELECT conrelid::regclass, conname FROM pg_constraint WHERE contype = 'c' AND conname LIKE '%\_ck\_%'; +ALTER TABLE outbox_events RENAME CONSTRAINT outbox_events_ck_outbox_events_completed_status_consist_a784 + TO outbox_events_completed_status_consistency_check; +``` + ## Alembic example ```python diff --git a/omni_box/infra/storage/postgres/orm.py b/omni_box/infra/storage/postgres/orm.py index 0fe1b7b..504d33b 100644 --- a/omni_box/infra/storage/postgres/orm.py +++ b/omni_box/infra/storage/postgres/orm.py @@ -4,6 +4,7 @@ import datetime from functools import partial +from typing import ClassVar from uuid import UUID from sqlalchemy import ( @@ -11,11 +12,12 @@ DateTime, Enum, Index, + MetaData, String, text, ) from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, declared_attr, mapped_column from sqlalchemy.sql import func from ....core.constants import DEFAULT_MAX_ATTEMPTS @@ -36,6 +38,10 @@ class EventMixin: __abstract__ = True + # Supplied by the DeclarativeBase a concrete model is bound to; declared so that + # __table_args__ can read its naming convention. + metadata: ClassVar[MetaData] + # Identifiers id: Mapped[UUID] = mapped_column(primary_key=True) event_type: Mapped[str] = mapped_column(String(100), nullable=False) @@ -100,25 +106,45 @@ class InboxColumnsMixin: source: Mapped[str] = mapped_column(String(255), nullable=False) -def get_event_constraints(table_name: str, include_created_at_in_unique: bool = False) -> tuple: - """Generate generic constraints and indexes for event table.""" +def get_event_constraints( + table_name: str, include_created_at_in_unique: bool = False, *, metadata: MetaData | None = None +) -> tuple: + """Generate generic constraints and indexes for event table. + + Args: + table_name: Prefix of the constraint and index names. + include_created_at_in_unique: Add ``created_at`` to the unique index on ``idempotency_key`` + (a partitioned table requires the partition key in every unique index). + metadata: The ``MetaData`` the table is registered in. When its ``ck`` naming convention + contains ``%(constraint_name)s`` the check constraints are named by their rule alone + (``attempts_valid``, ``completed_status_consistency``, ``lock_consistency``) and the + convention qualifies them; otherwise they are named ``ck__``. + """ idempotency_key_cols = ["idempotency_key"] if include_created_at_in_unique: idempotency_key_cols.append("created_at") + # SQLAlchemy applies a convention to an explicitly named constraint only when the rule + # interpolates that name (sqlalchemy/sql/naming.py); a finished name would be qualified twice. + ck_rule = metadata.naming_convention.get("ck") if metadata is not None else None + convention_qualifies = isinstance(ck_rule, str) and "constraint_name" in ck_rule + + def check_name(rule: str) -> str: + return rule if convention_qualifies else f"ck_{table_name}_{rule}" + return ( # Ensure attempts_made never exceeds max_attempts at database level - CheckConstraint("attempts_made <= max_attempts", name=f"ck_{table_name}_attempts_valid"), + CheckConstraint("attempts_made <= max_attempts", name=check_name("attempts_valid")), # Status consistency constraints CheckConstraint( f"(status = '{EventStatus.COMPLETED.value}' AND completed_at IS NOT NULL) OR " f"(status != '{EventStatus.COMPLETED.value}' AND completed_at IS NULL)", - name=f"ck_{table_name}_completed_status_consistency", + name=check_name("completed_status_consistency"), ), # Locking consistency: locked_at and locked_by must be both NULL or both NOT NULL CheckConstraint( "(locked_at IS NULL AND locked_by IS NULL) OR (locked_at IS NOT NULL AND locked_by IS NOT NULL)", - name=f"ck_{table_name}_lock_consistency", + name=check_name("lock_consistency"), ), Index( f"idx_{table_name}_pending_fetch", @@ -153,7 +179,10 @@ class OutboxEventDBBase(EventMixin, OutboxColumnsMixin): __abstract__ = True __tablename__ = "outbox_events" - __table_args__ = get_event_constraints("outbox_events") + + @declared_attr.directive + def __table_args__(self) -> tuple: + return get_event_constraints("outbox_events", metadata=self.metadata) class InboxEventDBBase(EventMixin, InboxColumnsMixin): @@ -165,16 +194,18 @@ class InboxEventDBBase(EventMixin, InboxColumnsMixin): # Columns for INSERT ... ON CONFLICT DO NOTHING (PostgresInboxRepository) __inbox_dedup_index_columns__: tuple[str, ...] = ("message_id", "consumer_group") - __table_args__ = ( - *get_event_constraints("inbox_events"), - # Inbox specific unique constraint for deduplication - Index( - "idx_inbox_deduplication", - "message_id", - "consumer_group", - unique=True, - ), - ) + @declared_attr.directive + def __table_args__(self) -> tuple: + return ( + *get_event_constraints("inbox_events", metadata=self.metadata), + # Inbox specific unique constraint for deduplication + Index( + "idx_inbox_deduplication", + "message_id", + "consumer_group", + unique=True, + ), + ) class OutboxEventPartitionedDBBase(EventMixin, OutboxColumnsMixin): @@ -193,10 +224,12 @@ class OutboxEventPartitionedDBBase(EventMixin, OutboxColumnsMixin): __outbox_conflict_index_id__ = ("id", "created_at") __outbox_conflict_index_idempotency__ = ("idempotency_key", "created_at") - __table_args__ = ( - *get_event_constraints("outbox_events_p", include_created_at_in_unique=True), - {"postgresql_partition_by": "RANGE (created_at)"}, - ) + @declared_attr.directive + def __table_args__(self) -> tuple: + return ( + *get_event_constraints("outbox_events_p", include_created_at_in_unique=True, metadata=self.metadata), + {"postgresql_partition_by": "RANGE (created_at)"}, + ) class InboxEventPartitionedDBBase(EventMixin, InboxColumnsMixin): @@ -218,23 +251,25 @@ class InboxEventPartitionedDBBase(EventMixin, InboxColumnsMixin): __inbox_dedup_index_columns__: tuple[str, ...] = ("message_id", "consumer_group", "created_at") - __table_args__ = ( - *get_event_constraints("inbox_events_p", include_created_at_in_unique=True), - Index( - "idx_inbox_events_p_deduplication", - "message_id", - "consumer_group", - "created_at", - unique=True, - ), - Index( - "idx_inbox_events_p_message_consumer", - "message_id", - "consumer_group", - unique=False, - ), - {"postgresql_partition_by": "RANGE (created_at)"}, - ) + @declared_attr.directive + def __table_args__(self) -> tuple: + return ( + *get_event_constraints("inbox_events_p", include_created_at_in_unique=True, metadata=self.metadata), + Index( + "idx_inbox_events_p_deduplication", + "message_id", + "consumer_group", + "created_at", + unique=True, + ), + Index( + "idx_inbox_events_p_message_consumer", + "message_id", + "consumer_group", + unique=False, + ), + {"postgresql_partition_by": "RANGE (created_at)"}, + ) # Type aliases diff --git a/tests/integration/postgres/test_orm.py b/tests/integration/postgres/test_orm.py index 7014970..27b4f52 100644 --- a/tests/integration/postgres/test_orm.py +++ b/tests/integration/postgres/test_orm.py @@ -1,11 +1,30 @@ import pytest -from sqlalchemy import String, Table +from sqlalchemy import Connection, MetaData, String, Table, inspect, text +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.orm import DeclarativeBase from omni_box.core.models.enums import EventStatus +from omni_box.infra.storage.postgres.orm import ( + InboxEventDBBase, + InboxEventPartitionedDBBase, + OutboxEventDBBase, + OutboxEventPartitionedDBBase, +) from tests.models import ConcreteOutboxEvent pytestmark = pytest.mark.integration +CHECK_RULES = ("attempts_valid", "completed_status_consistency", "lock_consistency") + +# sqlalchemy-foundation-kit's DB_NAMING_CONVENTION +FOUNDATION_KIT_CONVENTION = { + "ix": "%(column_0_label)s_idx", + "uq": "%(table_name)s_%(column_0_name)s_key", + "ck": "%(table_name)s_%(constraint_name)s_check", + "fk": "%(table_name)s_%(column_0_name)s_fkey", + "pk": "%(table_name)s_pkey", +} + def test__outbox_event_db_model__concrete_model__has_expected_columns_and_defaults() -> None: # Arrange @@ -92,3 +111,45 @@ def test__outbox_event_db_model__concrete_model__has_expected_indexes() -> None: assert "scheduled_at" in [c.name for c in pending_fetch_idx.columns] assert pending_fetch_idx.dialect_options["postgresql"]["where"] is not None assert EventStatus.PENDING.value in str(pending_fetch_idx.dialect_options["postgresql"]["where"]) + + +async def test__event_bases__foundation_kit_convention__postgres_stores_the_qualified_names( + db_engine: AsyncEngine, +) -> None: + # Arrange + schema = "naming_convention" + + class Base(DeclarativeBase): + metadata = MetaData(naming_convention=FOUNDATION_KIT_CONVENTION, schema=schema) + + class Outbox(Base, OutboxEventDBBase): + pass + + class Inbox(Base, InboxEventDBBase): + pass + + class OutboxPartitioned(Base, OutboxEventPartitionedDBBase): + pass + + class InboxPartitioned(Base, InboxEventPartitionedDBBase): + pass + + def reflect_check_names(conn: Connection) -> dict[str, set[str]]: + inspector = inspect(conn) + return { + table.name: {c["name"] for c in inspector.get_check_constraints(table.name, schema=schema)} + for table in Base.metadata.sorted_tables + } + + # Act + async with db_engine.begin() as conn: + await conn.execute(text(f"CREATE SCHEMA {schema}")) + try: + await conn.run_sync(Base.metadata.create_all) + stored = await conn.run_sync(reflect_check_names) + finally: + await conn.execute(text(f"DROP SCHEMA {schema} CASCADE")) + + # Assert + for table in Base.metadata.sorted_tables: + assert stored[table.name] == {f"{table.name}_{rule}_check" for rule in CHECK_RULES} diff --git a/tests/unit/infra/storage/postgres/test_orm.py b/tests/unit/infra/storage/postgres/test_orm.py new file mode 100644 index 0000000..451b191 --- /dev/null +++ b/tests/unit/infra/storage/postgres/test_orm.py @@ -0,0 +1,178 @@ +"""Unit tests for the names ``get_event_constraints`` gives the event tables. + +A check constraint is declared with the finished name ``ck_
_`` unless the +``MetaData`` carries a ``ck`` naming convention that interpolates ``%(constraint_name)s``; +then the bases hand the convention the bare rule and it builds the name. A finished name +under such a convention is qualified twice, runs past PostgreSQL's 63-byte limit and is +truncated with a hash (issue #36). These tests compile the DDL through the PostgreSQL +dialect and never touch a database; the catalog round trip lives in +``tests/integration/postgres/test_orm.py``. +""" + +from __future__ import annotations + +import re + +import pytest +from sqlalchemy import CheckConstraint, MetaData, Table +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.schema import CreateTable + +from omni_box.infra.storage.postgres.orm import ( + InboxEventDBBase, + InboxEventPartitionedDBBase, + OutboxEventDBBase, + OutboxEventPartitionedDBBase, + get_event_constraints, +) + +pytestmark = pytest.mark.unit + +PG_MAX_IDENTIFIER = 63 +CHECK_RULES = ("attempts_valid", "completed_status_consistency", "lock_consistency") + +# sqlalchemy-foundation-kit's DB_NAMING_CONVENTION +FOUNDATION_KIT_CONVENTION = { + "ix": "%(column_0_label)s_idx", + "uq": "%(table_name)s_%(column_0_name)s_key", + "ck": "%(table_name)s_%(constraint_name)s_check", + "fk": "%(table_name)s_%(column_0_name)s_fkey", + "pk": "%(table_name)s_pkey", +} +# The convention SQLAlchemy's documentation recommends +SQLALCHEMY_DOCS_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + +# (abstract base, the prefix its __table_args__ passes to get_event_constraints) +EVENT_BASES = ( + (OutboxEventDBBase, "outbox_events"), + (InboxEventDBBase, "inbox_events"), + (OutboxEventPartitionedDBBase, "outbox_events_p"), + (InboxEventPartitionedDBBase, "inbox_events_p"), +) + + +def _bind(naming_convention: dict[str, str] | None) -> dict[str, Table]: + """Bind the four abstract bases to a fresh DeclarativeBase and return its tables by name.""" + + class Base(DeclarativeBase): + metadata = MetaData(naming_convention=naming_convention) + + for base, _ in EVENT_BASES: + type(f"Concrete{base.__name__}", (Base, base), {}) + return {table.name: table for table in Base.metadata.sorted_tables} + + +def _check_names(table: Table) -> set[str]: + return {c.name for c in table.constraints if isinstance(c, CheckConstraint)} + + +def _ddl_check_names(table: Table) -> set[str]: + ddl = str(CreateTable(table).compile(dialect=postgresql.dialect())) + return set(re.findall(r"CONSTRAINT (\S+) CHECK", ddl)) + + +def _index_names(table: Table) -> set[str]: + return {index.name for index in table.indexes} + + +def test__event_bases__no_naming_convention__check_names_are_the_documented_ones() -> None: + # Act + tables = _bind(None) + + # Assert + for base, prefix in EVENT_BASES: + table = tables[base.__tablename__] + assert _check_names(table) == {f"ck_{prefix}_{rule}" for rule in CHECK_RULES} + assert _ddl_check_names(table) == _check_names(table) + + +@pytest.mark.parametrize( + ("naming_convention", "expected"), + [ + pytest.param(FOUNDATION_KIT_CONVENTION, "{table}_{rule}_check", id="foundation-kit"), + pytest.param(SQLALCHEMY_DOCS_CONVENTION, "ck_{table}_{rule}", id="sqlalchemy-docs"), + ], +) +def test__event_bases__ck_convention__check_names_are_qualified_once_and_fit_postgres( + naming_convention: dict[str, str], expected: str +) -> None: + # Act + tables = _bind(naming_convention) + + # Assert + for base, _ in EVENT_BASES: + table = tables[base.__tablename__] + names = _check_names(table) + assert names == {expected.format(table=table.name, rule=rule) for rule in CHECK_RULES} + assert all(len(name) <= PG_MAX_IDENTIFIER for name in names) + assert _ddl_check_names(table) == names + + +@pytest.mark.parametrize( + "naming_convention", + [ + pytest.param(FOUNDATION_KIT_CONVENTION, id="foundation-kit"), + pytest.param(SQLALCHEMY_DOCS_CONVENTION, id="sqlalchemy-docs"), + ], +) +def test__event_bases__ix_convention__explicit_index_names_are_kept(naming_convention: dict[str, str]) -> None: + # Arrange + without_convention = _bind(None) + + # Act + tables = _bind(naming_convention) + + # Assert + for name, table in tables.items(): + assert _index_names(table) == _index_names(without_convention[name]) + + +def test__event_bases__two_declarative_bases__each_gets_its_own_table_args() -> None: + # Act + first = _bind(None) + second = _bind(None) + + # Assert + for base, _ in EVENT_BASES: + name = base.__tablename__ + assert first[name] is not second[name] + assert _check_names(second[name]) == _check_names(first[name]) + assert _index_names(second[name]) == _index_names(first[name]) + + +@pytest.mark.parametrize( + "metadata", + [ + pytest.param(None, id="none"), + pytest.param(MetaData(), id="default"), + pytest.param(MetaData(naming_convention={"ck": "%(table_name)s_%(column_0_name)s_check"}), id="ck-by-column"), + ], +) +def test__get_event_constraints__no_ck_rule_interpolating_the_name__check_names_are_finished( + metadata: MetaData | None, +) -> None: + # Act + constraints = get_event_constraints("custom_events", metadata=metadata) + + # Assert + names = {c.name for c in constraints if isinstance(c, CheckConstraint)} + assert names == {f"ck_custom_events_{rule}" for rule in CHECK_RULES} + + +def test__get_event_constraints__ck_rule_interpolating_the_name__check_names_are_the_bare_rules() -> None: + # Arrange + metadata = MetaData(naming_convention=FOUNDATION_KIT_CONVENTION) + + # Act + constraints = get_event_constraints("custom_events", metadata=metadata) + + # Assert + names = {c.name for c in constraints if isinstance(c, CheckConstraint)} + assert names == set(CHECK_RULES) From 46c81936147242b95990a6c4a7a8b0e590e08370 Mon Sep 17 00:00:00 2001 From: Alexey Shalaev <75322386+AlexeyShalaev@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:57:34 +0300 Subject: [PATCH 2/2] fix(postgres): note the declared_attr change in the migration guide --- docs/migrations.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/migrations.md b/docs/migrations.md index 87eadf6..b62e45d 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -225,6 +225,8 @@ ALTER TABLE outbox_events RENAME CONSTRAINT outbox_events_ck_outbox_events_compl TO outbox_events_completed_status_consistency_check; ``` +The four bases also build `__table_args__` in a `declared_attr` now, so a workaround that read the tuple off the abstract class — `OutboxEventPartitionedDBBase.__table_args__`, to wrap the names in `conv()` by hand — raises `AttributeError: type object 'OutboxEventPartitionedDBBase' has no attribute 'metadata'` at import. Delete it; the bases produce the convention's names on their own. + ## Alembic example ```python