Skip to content

Commit 356af94

Browse files
authored
perf: send the publish NOTIFY in the INSERT's RETURNING (#235)
* perf: send the publish NOTIFY in the INSERT's RETURNING * test: accept the uncast pg_notify bind that SQLAlchemy 2.0.0 renders * ci: measure coverage with sys.monitoring
1 parent 88e505b commit 356af94

6 files changed

Lines changed: 130 additions & 47 deletions

File tree

‎docs/concepts/performance.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,18 @@ why).
4545
| Need more parallel handler capacity | `max_workers` / `fetch_batch_size` | More concurrency, but mind the [connection budget](../usage/subscriber.md#connection-budget). |
4646

4747
Single-publish-per-transaction workloads see no change from NOTIFY dedup (one row
48-
still emits one NOTIFY), and the batched-flush win is largest at low `max_workers`
48+
still emits one NOTIFY), but that NOTIFY rides in the INSERT's `RETURNING`, so it
49+
adds no round trip of its own. The batched-flush win is largest at low `max_workers`
4950
(where per-row deletes serialise) and narrows as worker parallelism rises.
5051

5152
## The three levers
5253

5354
### Producer: NOTIFY dedup (automatic)
5455

5556
`broker.publish` / `publish_batch` emit one `pg_notify` per (transaction,
56-
queue), so N publishes to the same queue in one transaction cost one NOTIFY
57-
round-trip. Postgres already coalesces identical notifications per transaction
57+
queue), so N publishes to the same queue in one transaction cost at most one
58+
NOTIFY. A single `publish` puts it in the INSERT's `RETURNING`, so it costs no
59+
round trip; `publish_batch` sends it as one extra statement. Postgres already coalesces identical notifications per transaction
5860
at delivery, so extra NOTIFYs would be pure waste. It is default-on, has no knob, and is
5961
behavior-preserving: the subscriber still gets its wake, fired inline at the first
6062
publish. See

‎docs/introduction/how-it-works.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,13 @@ async with session_factory() as session, session.begin():
4747
`publish_batch(*bodies, queue, session, ...)` does the same with a single
4848
round-trip for many rows.
4949

50-
The producer also emits `SELECT pg_notify('outbox_<table>', queue)` on the
51-
caller's session right after the INSERT, except when the row is
52-
genuinely future-dated (a future `activate_in` / `activate_at`; a *past*
53-
`activate_at`, e.g. a recovered idempotency token, still notifies) or a
54-
`timer_id` conflict made the insert a no-op. NOTIFY is transactional, so listeners only see it
50+
A single `publish` also sends `pg_notify('outbox_<table>', queue)` in the
51+
INSERT's `RETURNING` clause, so the wakeup costs no extra round trip.
52+
`publish_batch` sends a separate `SELECT pg_notify(...)` after its INSERT.
53+
Neither notifies when the row is genuinely future-dated (a future
54+
`activate_in` / `activate_at`; a *past* `activate_at`, e.g. a recovered
55+
idempotency token, still notifies), and a `timer_id` conflict returns no
56+
row, so it sends no NOTIFY either. NOTIFY is transactional, so listeners only see it
5557
after the user's transaction commits, so atomicity with the row insert is
5658
automatic.
5759

‎faststream_outbox/publisher/producer.py‎

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from faststream._internal.endpoint.utils import ParserComposition
1515
from faststream._internal.parser import DefaultCodec
16-
from sqlalchemy import Float, Table, bindparam, func, insert, text
16+
from sqlalchemy import Float, Table, bindparam, func, insert, literal, text
1717
from sqlalchemy.dialects.postgresql import insert as pg_insert
1818

1919
from faststream_outbox._scheduling import is_future_dated, resolve_next_attempt_client_side
@@ -33,8 +33,12 @@
3333
from sqlalchemy import Insert
3434

3535

36-
def _build_insert_stmts(table: Table) -> "dict[tuple[bool, bool], Insert]":
37-
"""Build the single-row INSERT once per ``(timer_id set, activate_in set)``; values arrive as parameters."""
36+
def _build_insert_stmts(table: Table, channel: str) -> "dict[tuple[bool, bool, bool], Insert]":
37+
"""Build the single-row INSERT once per ``(timer_id set, activate_in set, notify)``; values arrive as parameters.
38+
39+
The notify variants call ``pg_notify`` in ``RETURNING``, so the NOTIFY costs no extra round trip and
40+
fires only when a row actually landed (an ``ON CONFLICT DO NOTHING`` skip returns no row).
41+
"""
3842
# Server-side compute keeps timing immune to worker/DB clock skew (mirrors
3943
# client.mark_pending_with_lease).
4044
activate_in = func.now() + func.make_interval(0, 0, 0, 0, 0, 0, bindparam("activate_in_seconds", type_=Float))
@@ -43,12 +47,13 @@ def _build_insert_stmts(table: Table) -> "dict[tuple[bool, bool], Insert]":
4347
index_elements=[table.c.queue, table.c.timer_id],
4448
index_where=table.c.timer_id.is_not(None),
4549
)
46-
return {
47-
(False, False): plain.returning(table.c.id),
48-
(False, True): plain.values(next_attempt_at=activate_in).returning(table.c.id),
49-
(True, False): timer.returning(table.c.id),
50-
(True, True): timer.values(next_attempt_at=activate_in).returning(table.c.id),
51-
}
50+
notify = func.pg_notify(literal(channel), table.c.queue)
51+
stmts: dict[tuple[bool, bool, bool], Insert] = {}
52+
for has_timer, base in ((False, plain), (True, timer)):
53+
for has_activate_in, stmt in ((False, base), (True, base.values(next_attempt_at=activate_in))):
54+
stmts[has_timer, has_activate_in, False] = stmt.returning(table.c.id)
55+
stmts[has_timer, has_activate_in, True] = stmt.returning(table.c.id, notify)
56+
return stmts
5257

5358

5459
class OutboxProducer:
@@ -80,7 +85,7 @@ def __init__(
8085
self._parser = ParserComposition(parser, default.parse_message)
8186
self._decoder = ParserComposition(decoder, default.decode_message)
8287
self._metrics_recorder = metrics_recorder
83-
self._insert_stmts = _build_insert_stmts(table)
88+
self._insert_stmts = _build_insert_stmts(table, self._channel)
8489

8590
def _emit_metric(self, event: str, tags: "Mapping[str, typing.Any]") -> None:
8691
# Delegate to the shared swallow-and-DEBUG-log helper so the recorder-isolation
@@ -143,16 +148,20 @@ async def _do_publish(
143148
params["timer_id"] = cmd.timer_id
144149
# Skip NOTIFY only when the row is genuinely future-dated. A past activate_at
145150
# (e.g. a recovered idempotency token) is immediately eligible — fire NOTIFY.
146-
now = utcnow()
147-
is_future = is_future_dated(cmd.activate_in, cmd.activate_at, now)
148-
149-
stmt = self._insert_stmts[cmd.timer_id is not None, cmd.activate_in is not None]
151+
# Future-dated rows skip NOTIFY: listeners can't act before the gate opens, and
152+
# polling fires them at the next tick.
153+
emitted = self._notified_queues(cmd.session)
154+
notify = not is_future_dated(cmd.activate_in, cmd.activate_at, utcnow()) and (
155+
emitted is None or cmd.queue not in emitted
156+
)
157+
stmt = self._insert_stmts[cmd.timer_id is not None, cmd.activate_in is not None, notify]
150158
result = await cmd.session.execute(stmt, params)
151159
row_id: int | None = result.scalar()
152-
# Skip NOTIFY for future-dated rows (listeners can't act before the gate
153-
# opens — polling fires them at next tick) and on conflict (no row landed).
154-
if row_id is not None and not is_future:
155-
await self._notify(cmd.session, cmd.queue)
160+
if row_id is not None and notify:
161+
# Re-read: under autobegin the transaction only exists once the INSERT has run.
162+
emitted = self._notified_queues(cmd.session)
163+
if emitted is not None:
164+
emitted.add(cmd.queue)
156165
return row_id
157166

158167
async def publish_batch(self, cmd: OutboxPublishCommand) -> None:
@@ -206,6 +215,14 @@ async def publish_batch(self, cmd: OutboxPublishCommand) -> None:
206215
async def request(self, cmd: OutboxPublishCommand) -> typing.NoReturn:
207216
raise NotImplementedError(_REQUEST_UNSUPPORTED_MSG)
208217

218+
def _notified_queues(self, session: typing.Any) -> set[str] | None:
219+
"""Return the queues already notified in *session*'s innermost transaction, or None outside one."""
220+
sync_session = session.sync_session
221+
txn = sync_session.get_nested_transaction() or sync_session.get_transaction()
222+
if txn is None:
223+
return None
224+
return self._notified.setdefault(txn, set())
225+
209226
async def _notify(self, session: typing.Any, queue: str) -> None:
210227
# ``pg_notify(:channel, :payload)`` -- parameterized so channel and payload
211228
# bind cleanly (raw NOTIFY accepts only literals -- injection-prone). Runs on
@@ -224,10 +241,8 @@ async def _notify(self, session: typing.Any, queue: str) -> None:
224241
# defeat the memo. The Session holds the sync transaction strongly for its lifetime,
225242
# so the key survives across publishes whether the caller uses `session.begin()` or
226243
# autobegin. No active transaction -> emit unconditionally.
227-
sync_session = session.sync_session
228-
txn = sync_session.get_nested_transaction() or sync_session.get_transaction()
229-
if txn is not None:
230-
emitted = self._notified.setdefault(txn, set())
244+
emitted = self._notified_queues(session)
245+
if emitted is not None:
231246
if queue in emitted:
232247
return
233248
emitted.add(queue)

‎pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ Changelog = "https://github.com/modern-python/faststream-outbox/releases"
149149

150150
[tool.coverage.run]
151151
omit = ["benchmarks/*"]
152+
# The C tracer intermittently drops executed lines in async tests; 3.11 falls back to it.
153+
core = "sysmon"
154+
disable_warnings = ["no-sysmon"]
152155

153156
[tool.coverage.report]
154157
fail_under = 100

‎tests/test_integration.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import Any
1010
from unittest import mock
1111

12+
import asyncpg
1213
import pytest
1314
from faststream import BaseMiddleware, Context, Depends
1415
from faststream.kafka import KafkaBroker, TestKafkaBroker
@@ -2529,6 +2530,16 @@ async def test_notify_dedup_spans_publish_batch_and_publish(pg_engine, outbox_ta
25292530
assert count[0] == 1
25302531

25312532

2533+
async def test_notify_dedup_spans_publish_and_publish_batch(pg_engine: AsyncEngine, outbox_table: Table) -> None:
2534+
broker = OutboxBroker(pg_engine, outbox_table=outbox_table)
2535+
session_factory = async_sessionmaker(pg_engine, expire_on_commit=False)
2536+
with _count_pg_notify(pg_engine) as count:
2537+
async with session_factory() as session, session.begin():
2538+
await broker.publish({"n": 1}, queue="orders", session=session) # pg_notify in RETURNING
2539+
await broker.publish_batch({"n": 2}, {"n": 3}, queue="orders", session=session) # deduped
2540+
assert count[0] == 1
2541+
2542+
25322543
async def test_notify_still_skipped_for_future_dated(pg_engine, outbox_table) -> None:
25332544
broker = OutboxBroker(pg_engine, outbox_table=outbox_table)
25342545
session_factory = async_sessionmaker(pg_engine, expire_on_commit=False)
@@ -2538,6 +2549,57 @@ async def test_notify_still_skipped_for_future_dated(pg_engine, outbox_table) ->
25382549
assert count[0] == 0 # future-dated -> no NOTIFY (unchanged), dedup did not break the skip
25392550

25402551

2552+
async def test_publish_notifies_in_the_insert_round_trip(pg_engine: AsyncEngine, outbox_table: Table) -> None:
2553+
broker = OutboxBroker(pg_engine, outbox_table=outbox_table)
2554+
session_factory = async_sessionmaker(pg_engine, expire_on_commit=False)
2555+
statements: list[str] = []
2556+
2557+
def _listener(conn, cursor, statement, parameters, context, executemany) -> None: # noqa: ARG001
2558+
statements.append(statement)
2559+
2560+
async with session_factory() as session, session.begin():
2561+
await session.connection()
2562+
event.listen(pg_engine.sync_engine, "before_cursor_execute", _listener)
2563+
try:
2564+
await broker.publish({"n": 1}, queue="orders", session=session)
2565+
await broker.publish({"n": 2}, queue="orders", session=session)
2566+
finally:
2567+
event.remove(pg_engine.sync_engine, "before_cursor_execute", _listener)
2568+
2569+
assert len(statements) == 2
2570+
assert "pg_notify" in statements[0].lower()
2571+
assert "pg_notify" not in statements[1].lower()
2572+
2573+
2574+
async def test_notify_sent_after_a_timer_conflict_in_the_same_transaction(
2575+
pg_engine: AsyncEngine, outbox_table: Table
2576+
) -> None:
2577+
broker = OutboxBroker(pg_engine, outbox_table=outbox_table)
2578+
session_factory = async_sessionmaker(pg_engine, expire_on_commit=False)
2579+
async with session_factory() as session, session.begin():
2580+
await broker.publish({"n": 0}, queue="orders", session=session, timer_id="t-1")
2581+
2582+
payloads: list[str] = []
2583+
received = asyncio.Event()
2584+
dsn = pg_engine.url.set(drivername="postgresql").render_as_string(hide_password=False)
2585+
listener = await asyncpg.connect(dsn)
2586+
try:
2587+
2588+
def _cb(_conn, _pid, _channel, payload) -> None:
2589+
payloads.append(payload)
2590+
received.set()
2591+
2592+
await listener.add_listener(f"outbox_{outbox_table.name}", _cb)
2593+
async with session_factory() as session, session.begin():
2594+
assert await broker.publish({"n": 1}, queue="orders", session=session, timer_id="t-1") is None
2595+
assert await broker.publish({"n": 2}, queue="orders", session=session) is not None
2596+
await asyncio.wait_for(received.wait(), timeout=2.0)
2597+
finally:
2598+
await listener.close()
2599+
2600+
assert payloads == ["orders"]
2601+
2602+
25412603
async def test_notify_deduped_in_autobegin_transaction(pg_engine, outbox_table) -> None:
25422604
# Autobegin (no explicit session.begin()): the dedup must still collapse N publishes
25432605
# of one queue to one pg_notify. Guards against keying the memo on the GC-fragile async

‎tests/test_unit.py‎

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -612,21 +612,19 @@ async def test_broker_publish_rejects_non_async_session() -> None:
612612
await broker.publish(b"x", queue="orders", session=object()) # ty: ignore[invalid-argument-type]
613613

614614

615-
async def test_broker_publish_executes_insert_then_pg_notify_on_session() -> None:
615+
async def test_broker_publish_executes_one_insert_that_notifies() -> None:
616616
broker = _make_broker()
617617
session = _make_session_mock()
618618
await broker.publish({"order_id": 1}, queue="orders", session=session)
619-
# Two execute calls: the INSERT, then SELECT pg_notify(...).
620-
assert session.execute.await_count == 2
619+
assert session.execute.await_count == 1
621620
insert_stmt, params = session.execute.await_args_list[0].args
622-
assert "INSERT INTO" in str(insert_stmt)
621+
compiled = insert_stmt.compile(dialect=postgresql.dialect())
622+
assert "INSERT INTO" in str(compiled)
623+
assert re.search(r"RETURNING outbox\.id, pg_notify\(%\(param_1\)s(::VARCHAR)?, outbox\.queue\)", str(compiled))
624+
assert compiled.params["param_1"] == "outbox_outbox"
623625
assert params["queue"] == "orders"
624626
assert json.loads(params["payload"]) == {"order_id": 1}
625627
assert params["headers"]["content-type"] == "application/json"
626-
notify_stmt, notify_params = session.execute.await_args_list[1].args
627-
assert "pg_notify" in str(notify_stmt)
628-
assert notify_params["channel"] == "outbox_outbox"
629-
assert notify_params["payload"] == "orders"
630628

631629

632630
async def test_broker_publish_encodes_pydantic_model() -> None:
@@ -989,6 +987,7 @@ async def test_broker_publish_with_activate_in_skips_notify() -> None:
989987
insert_stmt = session.execute.await_args_list[0].args[0]
990988
assert "INSERT INTO" in str(insert_stmt)
991989
assert "next_attempt_at" in str(insert_stmt)
990+
assert "pg_notify" not in str(insert_stmt)
992991

993992

994993
async def test_broker_publish_with_activate_at_skips_notify() -> None:
@@ -997,8 +996,9 @@ async def test_broker_publish_with_activate_at_skips_notify() -> None:
997996
fire = dt.datetime.now(tz=dt.UTC) + dt.timedelta(minutes=5)
998997
await broker.publish(b"x", queue="orders", session=session, activate_at=fire)
999998
assert session.execute.await_count == 1
1000-
_, params = session.execute.await_args_list[0].args
999+
insert_stmt, params = session.execute.await_args_list[0].args
10011000
assert params["next_attempt_at"] == fire
1001+
assert "pg_notify" not in str(insert_stmt)
10021002

10031003

10041004
async def test_broker_publish_emits_notify_when_activate_at_is_past() -> None:
@@ -1008,19 +1008,18 @@ async def test_broker_publish_emits_notify_when_activate_at_is_past() -> None:
10081008
session = _make_session_mock()
10091009
past = dt.datetime.now(tz=dt.UTC) - dt.timedelta(seconds=5)
10101010
await broker.publish(b"x", queue="orders", session=session, activate_at=past)
1011-
assert session.execute.await_count == 2
1012-
notify_stmt, notify_params = session.execute.await_args_list[1].args
1013-
assert "pg_notify" in str(notify_stmt)
1014-
assert notify_params["payload"] == "orders"
1011+
assert session.execute.await_count == 1
1012+
insert_stmt, _params = session.execute.await_args_list[0].args
1013+
assert "pg_notify" in str(insert_stmt)
10151014

10161015

10171016
async def test_broker_publish_emits_notify_when_activate_in_is_zero() -> None:
10181017
broker = _make_broker()
10191018
session = _make_session_mock()
10201019
await broker.publish(b"x", queue="orders", session=session, activate_in=dt.timedelta(0))
1021-
assert session.execute.await_count == 2
1022-
notify_stmt, _params = session.execute.await_args_list[1].args
1023-
assert "pg_notify" in str(notify_stmt)
1020+
assert session.execute.await_count == 1
1021+
insert_stmt, _params = session.execute.await_args_list[0].args
1022+
assert "pg_notify" in str(insert_stmt)
10241023

10251024

10261025
async def test_broker_publish_rejects_naive_activate_at() -> None:
@@ -3669,7 +3668,7 @@ def _make_producer(recorder: typing.Any) -> OutboxProducer:
36693668
async def test_producer_publish_emits_error_event_and_reraises_on_sql_failure() -> None:
36703669
events, recorder = _events_recorder()
36713670
producer = _make_producer(recorder)
3672-
session = AsyncMock(spec=AsyncSession)
3671+
session = _make_session_mock()
36733672
session.execute.side_effect = RuntimeError("forced INSERT failure")
36743673
cmd = OutboxPublishCommand({"x": 1}, queue="orders", session=session)
36753674

0 commit comments

Comments
 (0)