diff --git a/README.md b/README.md index 0bab51f..c844bce 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,12 @@ engine = create_async_engine("postgresql+asyncpg://localhost/app") broker = OutboxBroker(engine, outbox_table=outbox_table) app = FastStream(broker) + @broker.subscriber("orders", max_workers=4) async def handle(order_id: int) -> None: print(f"order {order_id}") + # Producer side — share the caller's open transaction: session_factory = async_sessionmaker(engine, expire_on_commit=False) async with session_factory() as session, session.begin(): diff --git a/docs/introduction/how-it-works.md b/docs/introduction/how-it-works.md index 4cfaa10..f92a8c2 100644 --- a/docs/introduction/how-it-works.md +++ b/docs/introduction/how-it-works.md @@ -36,7 +36,7 @@ transaction — the row must commit with the caller's domain writes: ```python async with session_factory() as session, session.begin(): - session.add(order) # domain write + session.add(order) # domain write await broker.publish(order.id, queue="orders", session=session) # session.begin() commits both atomically on exit ``` @@ -127,8 +127,7 @@ immune to worker / DB clock skew. When the invariant fires, the broker emits a WARNING with structured fields: ```python -extra={"event": "lease_lost", "phase": "terminal" | "retry", - "row_id": ..., "queue": ..., "deliveries_count": ...} +extra = {"event": "lease_lost", "phase": "terminal" | "retry", "row_id": ..., "queue": ..., "deliveries_count": ...} ``` Recurring `event=lease_lost` records mean `lease_ttl_seconds < handler P99` diff --git a/docs/operations/alembic.md b/docs/operations/alembic.md index 06bd9ff..1f77234 100644 --- a/docs/operations/alembic.md +++ b/docs/operations/alembic.md @@ -16,29 +16,41 @@ against an empty schema): ```python # ### commands auto generated by Alembic - please adjust! ### -op.create_table('outbox', - sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column('queue', sa.String(length=255), nullable=False), - sa.Column('payload', sa.LargeBinary(), nullable=False), - sa.Column('headers', postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column('attempts_count', sa.BigInteger(), server_default='0', nullable=False), - sa.Column('deliveries_count', sa.BigInteger(), server_default='0', nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('next_attempt_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('first_attempt_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('acquired_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('acquired_token', sa.Uuid(), nullable=True), - sa.Column('timer_id', sa.String(length=255), nullable=True), - sa.CheckConstraint('(acquired_token IS NULL) = (acquired_at IS NULL)', name='outbox_lease_ck'), - sa.PrimaryKeyConstraint('id') +op.create_table( + "outbox", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("queue", sa.String(length=255), nullable=False), + sa.Column("payload", sa.LargeBinary(), nullable=False), + sa.Column("headers", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("attempts_count", sa.BigInteger(), server_default="0", nullable=False), + sa.Column("deliveries_count", sa.BigInteger(), server_default="0", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("next_attempt_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("first_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("acquired_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("acquired_token", sa.Uuid(), nullable=True), + sa.Column("timer_id", sa.String(length=255), nullable=True), + sa.CheckConstraint("(acquired_token IS NULL) = (acquired_at IS NULL)", name="outbox_lease_ck"), + sa.PrimaryKeyConstraint("id"), +) +op.create_index( + "outbox_lease_idx", + "outbox", + ["queue", "acquired_at"], + unique=False, + postgresql_where=sa.text("acquired_token IS NOT NULL"), +) +op.create_index( + "outbox_pending_idx", + "outbox", + ["queue", "next_attempt_at"], + unique=False, + postgresql_where=sa.text("acquired_token IS NULL"), +) +op.create_index( + "outbox_timer_id_uq", "outbox", ["queue", "timer_id"], unique=True, postgresql_where=sa.text("timer_id IS NOT NULL") ) -op.create_index('outbox_lease_idx', 'outbox', ['queue', 'acquired_at'], unique=False, - postgresql_where=sa.text('acquired_token IS NOT NULL')) -op.create_index('outbox_pending_idx', 'outbox', ['queue', 'next_attempt_at'], unique=False, - postgresql_where=sa.text('acquired_token IS NULL')) -op.create_index('outbox_timer_id_uq', 'outbox', ['queue', 'timer_id'], unique=True, - postgresql_where=sa.text('timer_id IS NOT NULL')) # ### end Alembic commands ### ``` @@ -89,21 +101,22 @@ The captured `upgrade()` body: ```python # ### commands auto generated by Alembic - please adjust! ### -op.create_table('outbox_dlq', - sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column('original_id', sa.BigInteger(), nullable=False), - sa.Column('queue', sa.String(length=255), nullable=False), - sa.Column('payload', sa.LargeBinary(), nullable=False), - sa.Column('headers', postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column('deliveries_count', sa.BigInteger(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('failed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('failure_reason', sa.String(length=64), nullable=False), - sa.Column('last_exception', sa.String(), nullable=True), - sa.Column('timer_id', sa.String(length=255), nullable=True), - sa.PrimaryKeyConstraint('id') +op.create_table( + "outbox_dlq", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("original_id", sa.BigInteger(), nullable=False), + sa.Column("queue", sa.String(length=255), nullable=False), + sa.Column("payload", sa.LargeBinary(), nullable=False), + sa.Column("headers", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("deliveries_count", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("failed_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("failure_reason", sa.String(length=64), nullable=False), + sa.Column("last_exception", sa.String(), nullable=True), + sa.Column("timer_id", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint("id"), ) -op.create_index('outbox_dlq_queue_failed_idx', 'outbox_dlq', ['queue', 'failed_at'], unique=False) +op.create_index("outbox_dlq_queue_failed_idx", "outbox_dlq", ["queue", "failed_at"], unique=False) # ### end Alembic commands ### ``` @@ -268,11 +281,11 @@ pointer to this section. Re-running autogenerate produces an empty ```python # Drop first ONLY if the constraint exists with a wrong predicate; skip the # drop if it is absent entirely. -op.drop_constraint('outbox_lease_ck', 'outbox', type_='check') +op.drop_constraint("outbox_lease_ck", "outbox", type_="check") op.create_check_constraint( - 'outbox_lease_ck', - 'outbox', - '(acquired_token IS NULL) = (acquired_at IS NULL)', + "outbox_lease_ck", + "outbox", + "(acquired_token IS NULL) = (acquired_at IS NULL)", ) ``` @@ -288,13 +301,13 @@ three indexes and their expected shape: | `outbox_timer_id_uq` | `queue, timer_id` | yes | `timer_id IS NOT NULL` | ```python -op.drop_index('outbox_timer_id_uq', table_name='outbox') +op.drop_index("outbox_timer_id_uq", table_name="outbox") op.create_index( - 'outbox_timer_id_uq', - 'outbox', - ['queue', 'timer_id'], + "outbox_timer_id_uq", + "outbox", + ["queue", "timer_id"], unique=True, - postgresql_where=sa.text('timer_id IS NOT NULL'), + postgresql_where=sa.text("timer_id IS NOT NULL"), ) ``` @@ -339,7 +352,7 @@ faster, no vacuum debt. # ### Convert outbox_dlq to range-partitioned by failed_at ### # Rename the existing table out of the way. -op.rename_table('outbox_dlq', 'outbox_dlq_old') +op.rename_table("outbox_dlq", "outbox_dlq_old") # Create the partitioned parent. The PRIMARY KEY must include the # partition key, so we extend it to (id, failed_at). @@ -381,7 +394,7 @@ op.execute(""" WHERE failed_at >= '2026-05-01'; """) -op.drop_table('outbox_dlq_old') +op.drop_table("outbox_dlq_old") ``` `validate_schema()` continues to work against the partitioned table — diff --git a/docs/usage/dlq.md b/docs/usage/dlq.md index cf10309..fc1d060 100644 --- a/docs/usage/dlq.md +++ b/docs/usage/dlq.md @@ -129,12 +129,10 @@ transform (or drop) the stored text: ```python # Store only the exception class — no message, no payload. -broker = OutboxBroker(engine, outbox_table=t, dlq_table=dlq, - last_exception_renderer=lambda exc: type(exc).__name__) +broker = OutboxBroker(engine, outbox_table=t, dlq_table=dlq, last_exception_renderer=lambda exc: type(exc).__name__) # Or drop the detail entirely: -broker = OutboxBroker(engine, outbox_table=t, dlq_table=dlq, - last_exception_renderer=lambda exc: None) +broker = OutboxBroker(engine, outbox_table=t, dlq_table=dlq, last_exception_renderer=lambda exc: None) ``` The renderer runs per terminal failure; its output is still length-capped diff --git a/docs/usage/fastapi.md b/docs/usage/fastapi.md index 229e976..14498b9 100644 --- a/docs/usage/fastapi.md +++ b/docs/usage/fastapi.md @@ -63,8 +63,7 @@ router = OutboxRouter(engine, outbox_table=outbox_table) async def handle( body: dict, session: AsyncSession = Depends(get_session), -) -> None: - ... # domain writes on `session` commit with any chained outbox publishes +) -> None: ... # domain writes on `session` commit with any chained outbox publishes @router.post("/orders") @@ -116,8 +115,7 @@ async def handle( msg: OutboxMessage, broker: OutboxBroker, session: AsyncSession = Depends(get_session), -) -> None: - ... +) -> None: ... ``` They resolve via FastStream's `Context()` paths but go through FastAPI's diff --git a/docs/usage/messaging-service.md b/docs/usage/messaging-service.md index 9c391ae..77b2b89 100644 --- a/docs/usage/messaging-service.md +++ b/docs/usage/messaging-service.md @@ -101,7 +101,9 @@ class OutboxEventProducer: async def send_chat_event(self, event: ChatEvent) -> None: await self.outbox_broker.publish( - event, queue="chat-events", session=self.session, + event, + queue="chat-events", + session=self.session, ) ``` @@ -250,7 +252,11 @@ from faststream_outbox import TestOutboxBroker async def test_create_message_relays_event_to_kafka( - outbox_broker, kafka_broker, create_message_use_case, command, kafka_publisher, + outbox_broker, + kafka_broker, + create_message_use_case, + command, + kafka_publisher, ) -> None: async with TestOutboxBroker(outbox_broker), TestKafkaBroker(kafka_broker): await create_message_use_case(command) diff --git a/docs/usage/publisher.md b/docs/usage/publisher.md index 97db26f..b56db6b 100644 --- a/docs/usage/publisher.md +++ b/docs/usage/publisher.md @@ -22,7 +22,7 @@ raises `NotImplementedError`. ```python async with session_factory() as session, session.begin(): - session.add(order) # domain write + session.add(order) # domain write await broker.publish( {"order_id": order.id}, queue="orders", @@ -97,9 +97,9 @@ orders_pub = broker.publisher("orders", headers={"source": "checkout"}) async def checkout(order: Order, session: AsyncSession) -> None: - session.add(order) # domain write + session.add(order) # domain write await orders_pub.publish({"order_id": order.id}, session=session) - await session.commit() # row + domain commit together + await session.commit() # row + domain commit together ``` Per-call `headers` are merged with the publisher's static headers diff --git a/docs/usage/relay.md b/docs/usage/relay.md index d1ef3f6..567eaa6 100644 --- a/docs/usage/relay.md +++ b/docs/usage/relay.md @@ -131,6 +131,7 @@ are **not** forwarded to the foreign publish. Two ways to override: from faststream.response import Response from faststream_outbox.annotations import OutboxMessage + @publisher_kafka @broker_outbox.subscriber("outbox_queue") async def relay(body: dict, msg: OutboxMessage) -> Response: diff --git a/docs/usage/subscriber.md b/docs/usage/subscriber.md index e1e06b7..24942e4 100644 --- a/docs/usage/subscriber.md +++ b/docs/usage/subscriber.md @@ -124,7 +124,7 @@ reclaim of *actually* stuck rows everywhere. Instead, segregate slow work onto its own subscriber with a longer TTL: ```python -@broker.subscriber("slow_q", lease_ttl_seconds=600) # 10 minutes +@broker.subscriber("slow_q", lease_ttl_seconds=600) # 10 minutes async def heavy_job(msg): ... @@ -235,7 +235,7 @@ async def handle(msg: OutboxMessage, body: dict) -> None: await write_audit(body) await msg.ack() except TransientError: - await msg.nack() # retry + await msg.nack() # retry except PermanentError: await msg.reject() # terminal delete ``` diff --git a/docs/usage/testing.md b/docs/usage/testing.md index 350a585..0ba15ba 100644 --- a/docs/usage/testing.md +++ b/docs/usage/testing.md @@ -124,7 +124,7 @@ tb = TestOutboxBroker(broker, run_loops=True) async with tb: tb.feed("orders", json.dumps({"order_id": 1}).encode()) # the real fetch + worker loops pick the row up asynchronously - async with asyncio.timeout(1.0): # fail fast instead of hanging forever + async with asyncio.timeout(1.0): # fail fast instead of hanging forever while not received: await asyncio.sleep(0.01) diff --git a/docs/usage/timers.md b/docs/usage/timers.md index ee5f424..20cd4f8 100644 --- a/docs/usage/timers.md +++ b/docs/usage/timers.md @@ -23,7 +23,9 @@ await broker.publish( # Fire at a specific UTC instant: await broker.publish( - {"x": 1}, queue="orders", session=session, + {"x": 1}, + queue="orders", + session=session, activate_at=dt.datetime(2027, 6, 1, 9, tzinfo=dt.UTC), ) ``` @@ -63,7 +65,9 @@ id is a silent no-op (returns `None`): ```python first = await broker.publish( - {"order_id": 1}, queue="orders", session=session, + {"order_id": 1}, + queue="orders", + session=session, activate_in=dt.timedelta(seconds=30), timer_id="order-confirm-1", ) @@ -71,7 +75,9 @@ assert first is not None # Re-publish — no row inserted, no NOTIFY emitted, returns None. second = await broker.publish( - {"order_id": 1}, queue="orders", session=session, + {"order_id": 1}, + queue="orders", + session=session, activate_in=dt.timedelta(seconds=30), timer_id="order-confirm-1", ) diff --git a/faststream_outbox/metrics/__init__.py b/faststream_outbox/metrics/__init__.py index f994549..c7a79bb 100644 --- a/faststream_outbox/metrics/__init__.py +++ b/faststream_outbox/metrics/__init__.py @@ -79,7 +79,7 @@ def _safe_emit(recorder: MetricsRecorder, event: str, tags: Mapping[str, typing. """ try: recorder(event, tags) - except Exception: # noqa: BLE001 + except Exception: _logger.log(logging.DEBUG, "metrics recorder raised", exc_info=True) diff --git a/planning/changes/2026-06-03.02-faststream-0.7-migration.md b/planning/changes/2026-06-03.02-faststream-0.7-migration.md index b4c056a..13d5d7d 100644 --- a/planning/changes/2026-06-03.02-faststream-0.7-migration.md +++ b/planning/changes/2026-06-03.02-faststream-0.7-migration.md @@ -161,6 +161,7 @@ _parser: AsyncCallable _decoder: AsyncCallable codec: CodecProto # NEW in 0.7 + # faststream/_internal/endpoint/subscriber/usecase.py — add_call signature def add_call( self, @@ -171,6 +172,7 @@ def add_call( codec_: Optional[CodecProto] = None, # NEW; outbox passes nothing ) -> Self: ... + # faststream/_internal/testing/broker.py — create_publisher_fake_subscriber @abstractmethod def create_publisher_fake_subscriber( diff --git a/planning/changes/2026-06-04.02-foreign-broker-relay.md b/planning/changes/2026-06-04.02-foreign-broker-relay.md index 6a74ad0..deb726c 100644 --- a/planning/changes/2026-06-04.02-foreign-broker-relay.md +++ b/planning/changes/2026-06-04.02-foreign-broker-relay.md @@ -13,6 +13,7 @@ Rabbit, NATS, Redis, Confluent — any FastStream broker) is the destination: ```python publisher_kafka = broker_kafka.publisher("kafka_topic") + @publisher_kafka @broker_outbox.subscriber("outbox_queue", max_workers=10, retry_strategy=...) async def relay(body: dict) -> dict: @@ -47,7 +48,7 @@ relevant body is: ```python for p in chain( self.__get_response_publisher(message), # → (OutboxFakePublisher,) - h.handler._publishers, # → (KafkaPublisher, …) from @pub + h.handler._publishers, # → (KafkaPublisher, …) from @pub ): await p._publish( result_msg.as_publish_command(), @@ -97,12 +98,14 @@ kafka_router = KafkaRouter() publisher_kafka = kafka_router.publisher("kafka_topic") + @publisher_kafka @broker_outbox.subscriber("outbox_queue") async def relay(body: dict) -> dict: return body -broker_kafka.include_router(kafka_router) # wires producer into router publisher + +broker_kafka.include_router(kafka_router) # wires producer into router publisher app = FastStream(broker_outbox, on_startup=[broker_kafka.connect]) ``` @@ -208,6 +211,7 @@ relay subscriber forwards to Kafka. The relay block: ```python publisher_kafka = broker_kafka.publisher("kafka_topic") + @publisher_kafka @broker_outbox.subscriber("outbox_queue") async def relay(body: dict) -> dict: diff --git a/planning/changes/2026-06-11.01-operator-pages.md b/planning/changes/2026-06-11.01-operator-pages.md index 843686b..df33602 100644 --- a/planning/changes/2026-06-11.01-operator-pages.md +++ b/planning/changes/2026-06-11.01-operator-pages.md @@ -298,28 +298,40 @@ Postgres 17, against an empty schema with a single ```python # ### commands auto generated by Alembic - please adjust! ### -op.create_table('outbox', - sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column('queue', sa.String(length=255), nullable=False), - sa.Column('payload', sa.LargeBinary(), nullable=False), - sa.Column('headers', postgresql.JSONB(astext_type=Text()), nullable=True), - sa.Column('attempts_count', sa.BigInteger(), server_default='0', nullable=False), - sa.Column('deliveries_count', sa.BigInteger(), server_default='0', nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('next_attempt_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('first_attempt_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('acquired_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('acquired_token', sa.Uuid(), nullable=True), - sa.Column('timer_id', sa.String(length=255), nullable=True), - sa.PrimaryKeyConstraint('id') +op.create_table( + "outbox", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("queue", sa.String(length=255), nullable=False), + sa.Column("payload", sa.LargeBinary(), nullable=False), + sa.Column("headers", postgresql.JSONB(astext_type=Text()), nullable=True), + sa.Column("attempts_count", sa.BigInteger(), server_default="0", nullable=False), + sa.Column("deliveries_count", sa.BigInteger(), server_default="0", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("next_attempt_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("first_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("acquired_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("acquired_token", sa.Uuid(), nullable=True), + sa.Column("timer_id", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint("id"), +) +op.create_index( + "outbox_lease_idx", + "outbox", + ["queue", "acquired_at"], + unique=False, + postgresql_where=sa.text("acquired_token IS NOT NULL"), +) +op.create_index( + "outbox_pending_idx", + "outbox", + ["queue", "next_attempt_at"], + unique=False, + postgresql_where=sa.text("acquired_token IS NULL"), +) +op.create_index( + "outbox_timer_id_uq", "outbox", ["queue", "timer_id"], unique=True, postgresql_where=sa.text("timer_id IS NOT NULL") ) -op.create_index('outbox_lease_idx', 'outbox', ['queue', 'acquired_at'], unique=False, - postgresql_where=sa.text('acquired_token IS NOT NULL')) -op.create_index('outbox_pending_idx', 'outbox', ['queue', 'next_attempt_at'], unique=False, - postgresql_where=sa.text('acquired_token IS NULL')) -op.create_index('outbox_timer_id_uq', 'outbox', ['queue', 'timer_id'], unique=True, - postgresql_where=sa.text('timer_id IS NOT NULL')) # ### end Alembic commands ### ``` @@ -353,20 +365,21 @@ with `make_dlq_table(metadata, table_name="outbox_dlq")` added to ```python # ### commands auto generated by Alembic - please adjust! ### -op.create_table('outbox_dlq', - sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column('original_id', sa.BigInteger(), nullable=False), - sa.Column('queue', sa.String(length=255), nullable=False), - sa.Column('payload', sa.LargeBinary(), nullable=False), - sa.Column('headers', postgresql.JSONB(astext_type=Text()), nullable=True), - sa.Column('deliveries_count', sa.BigInteger(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('failed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('failure_reason', sa.String(length=64), nullable=False), - sa.Column('last_exception', sa.String(), nullable=True), - sa.PrimaryKeyConstraint('id') +op.create_table( + "outbox_dlq", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("original_id", sa.BigInteger(), nullable=False), + sa.Column("queue", sa.String(length=255), nullable=False), + sa.Column("payload", sa.LargeBinary(), nullable=False), + sa.Column("headers", postgresql.JSONB(astext_type=Text()), nullable=True), + sa.Column("deliveries_count", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("failed_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("failure_reason", sa.String(length=64), nullable=False), + sa.Column("last_exception", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), ) -op.create_index('outbox_dlq_queue_failed_idx', 'outbox_dlq', ['queue', 'failed_at'], unique=False) +op.create_index("outbox_dlq_queue_failed_idx", "outbox_dlq", ["queue", "failed_at"], unique=False) # ### end Alembic commands ### ``` @@ -387,10 +400,12 @@ walkthrough converts the DLQ to partitioned. import asyncio from faststream_outbox import OutboxBroker, make_outbox_table + async def main() -> None: broker = OutboxBroker(engine, outbox_table=outbox_table) await broker.validate_schema() + asyncio.run(main()) ``` diff --git a/planning/changes/2026-06-16.01-actionable-schema-drift-error.md b/planning/changes/2026-06-16.01-actionable-schema-drift-error.md index 9f64a4e..468da0e 100644 --- a/planning/changes/2026-06-16.01-actionable-schema-drift-error.md +++ b/planning/changes/2026-06-16.01-actionable-schema-drift-error.md @@ -108,10 +108,11 @@ then gives exact hand-written `op.*` recipes: ```python # only if it exists with a wrong predicate: - op.drop_constraint('outbox_lease_ck', 'outbox', type_='check') + op.drop_constraint("outbox_lease_ck", "outbox", type_="check") op.create_check_constraint( - 'outbox_lease_ck', 'outbox', - '(acquired_token IS NULL) = (acquired_at IS NULL)', + "outbox_lease_ck", + "outbox", + "(acquired_token IS NULL) = (acquired_at IS NULL)", ) ``` @@ -120,10 +121,13 @@ then gives exact hand-written `op.*` recipes: index): ```python - op.drop_index('outbox_timer_id_uq', table_name='outbox') + op.drop_index("outbox_timer_id_uq", table_name="outbox") op.create_index( - 'outbox_timer_id_uq', 'outbox', ['queue', 'timer_id'], - unique=True, postgresql_where=sa.text('timer_id IS NOT NULL'), + "outbox_timer_id_uq", + "outbox", + ["queue", "timer_id"], + unique=True, + postgresql_where=sa.text("timer_id IS NOT NULL"), ) # outbox_pending_idx: postgresql_where=sa.text('acquired_token IS NULL') # outbox_lease_idx: postgresql_where=sa.text('acquired_token IS NOT NULL') diff --git a/planning/changes/2026-07-15.02-autovacuum-tuning.md b/planning/changes/2026-07-15.02-autovacuum-tuning.md index d5ab871..164525f 100644 --- a/planning/changes/2026-07-15.02-autovacuum-tuning.md +++ b/planning/changes/2026-07-15.02-autovacuum-tuning.md @@ -90,6 +90,7 @@ returns the `ALTER TABLE "" SET (…)` statement (identifier quoted). Used ```python from faststream_outbox import outbox_autovacuum_ddl + def upgrade() -> None: op.execute(outbox_autovacuum_ddl("outbox")) ``` diff --git a/planning/changes/2026-07-16.02-notify-dedup.md b/planning/changes/2026-07-16.02-notify-dedup.md index 5b46cfa..e7b27d8 100644 --- a/planning/changes/2026-07-16.02-notify-dedup.md +++ b/planning/changes/2026-07-16.02-notify-dedup.md @@ -49,10 +49,9 @@ async def _notify(self, session, queue): if txn is not None: emitted = self._notified.setdefault(txn, set()) if queue in emitted: - return # already NOTIFYed this queue in this txn + return # already NOTIFYed this queue in this txn emitted.add(queue) - await session.execute(text("SELECT pg_notify(:channel, :payload)"), - {"channel": self._channel, "payload": queue}) + await session.execute(text("SELECT pg_notify(:channel, :payload)"), {"channel": self._channel, "payload": queue}) ``` The `WeakKeyDictionary` needs **no explicit reset**: each transaction is a distinct diff --git a/pyproject.toml b/pyproject.toml index 0ef69cc..95ecf7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,11 @@ dependencies = [ [project.optional-dependencies] asyncpg = ["asyncpg>=0.29"] validate = ["alembic>=1.13"] -fastapi = ["fastapi>=0.95"] +# Upper cap: fastapi 0.140 made Dependant a slotted dataclass, which breaks +# faststream's FastAPI integration (ag2ai/faststream#2959). faststream declares +# no fastapi dependency of its own, so this extra is the only place the pairing +# can be constrained. Lift the cap and raise the faststream floor once fixed. +fastapi = ["fastapi>=0.95,<0.140"] prometheus = ["prometheus-client>=0.19"] opentelemetry = ["opentelemetry-api>=1.20", "opentelemetry-sdk>=1.20"] all = ["faststream-outbox[asyncpg,validate,fastapi,prometheus,opentelemetry]"] @@ -41,7 +45,7 @@ dev = [ "pytest-cov", "asyncpg>=0.29", "alembic>=1.13", - "fastapi>=0.95", + "fastapi>=0.95,<0.140", # see the cap note on the fastapi extra "faststream[kafka]>=0.7.1,<0.8", "httpx2>=2.2", "prometheus-client>=0.19", @@ -81,6 +85,7 @@ ignore = [ "ISC001", # flake8-implicit-str-concat "G004", # Logging statement uses f-string "ANN", + "CPY001", # no per-file copyright header ] isort.lines-after-imports = 2 isort.no-lines-before = ["standard-library", "local-folder"] diff --git a/tests/test_unit.py b/tests/test_unit.py index d24c193..1add9b4 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -1432,8 +1432,10 @@ def test_validate_check_constraints_missing_describes_predicate() -> None: connection = _mock_check_constraint_connection([]) # constraint absent from the live DB errors = _validate_check_constraints_sync(connection, table) assert errors == [ - f"missing CHECK constraint enforcing '{_LEASE_CK_PREDICATE}' " - f"(the lease invariant; name it e.g. outbox_faststream_lease_ck)", + ( + f"missing CHECK constraint enforcing '{_LEASE_CK_PREDICATE}' " + f"(the lease invariant; name it e.g. outbox_faststream_lease_ck)" + ), ] @@ -1446,8 +1448,10 @@ def test_validate_check_constraints_drifted_predicate_reported_as_missing() -> N ) errors = _validate_check_constraints_sync(connection, table) assert errors == [ - f"missing CHECK constraint enforcing '{_LEASE_CK_PREDICATE}' " - f"(the lease invariant; name it e.g. outbox_faststream_lease_ck)", + ( + f"missing CHECK constraint enforcing '{_LEASE_CK_PREDICATE}' " + f"(the lease invariant; name it e.g. outbox_faststream_lease_ck)" + ), ]