Skip to content

Commit 2afc0ac

Browse files
authored
perf(publisher): dedup pg_notify to one statement per (transaction, queue) (#135)
* docs(planning): design for NOTIFY-per-transaction dedup Dedup the producer's pg_notify to one statement per (transaction, queue) via a WeakKeyDictionary memo keyed on the innermost active transaction (savepoint- safe via GC, no listener). Behavior-preserving (matches Postgres' per-txn delivery coalescing), default-on, no knob. Benchmark producer select_calls 5000 -> 1, exact-gated. * feat: dedup pg_notify to one statement per transaction and queue Postgres already coalesces identical NOTIFYs per transaction at delivery, so N publishes of the same queue in one caller transaction only need one pg_notify -- the rest are wasted round-trips. Memoize emitted queues in a WeakKeyDictionary keyed on the innermost active transaction so entries GC when the transaction ends, meaning a rolled-back savepoint can never suppress a later real NOTIFY in the outer transaction. * fix(publisher): key NOTIFY dedup memo on sync transaction, not async proxy session.get_nested_transaction()/get_transaction() return the async AsyncSessionTransaction proxy, which SQLAlchemy regenerates per call and only weak-references. Under autobegin (no explicit session.begin()) the proxy is GC'd between publishes, so the WeakKeyDictionary memo never survives to the next publish and the dedup silently no-ops. Key on session.sync_session's SessionTransaction instead -- the Session holds it strongly for the transaction's lifetime, so the memo works for both session.begin() and autobegin usage. Add a regression test that reproduces the gap (5 same-queue publishes in one autobegin transaction emitted 5 pg_notify before the fix) and fix two test-session mocks that lacked a sync_session attribute. * bench: gate the producer NOTIFY dedup (select_calls 5000 -> 1)
1 parent 86e1676 commit 2afc0ac

7 files changed

Lines changed: 366 additions & 74 deletions

File tree

‎architecture/producer.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ Session-type / queue / activate-args-mutex / tz validation lives in one shared `
1616

1717
`from_cmd` raises (relay chaining is unsupported here).
1818

19+
## NOTIFY dedup per transaction
20+
21+
`_notify` emits at most one `pg_notify` per `(transaction, queue)`, deduped via a `WeakKeyDictionary` memo keyed on the innermost active *sync* transaction (`session.sync_session.get_nested_transaction() or session.sync_session.get_transaction()`). This is behavior-preserving and default-on — no config knob: Postgres already coalesces identical NOTIFYs per transaction at delivery, so the dedup only removes redundant round-trips, and the subscriber already tolerates coalesced/duplicate wakes. The memo entry for a transaction GCs when that transaction object is collected, so a rolled-back savepoint's entry can never suppress a later real NOTIFY for the same queue once control returns to the outer transaction. The guarantee holds for the caller's transaction regardless of whether they use an explicit `session.begin()` or rely on autobegin: keying on the sync `SessionTransaction` (which the `Session` holds strongly for the transaction's lifetime) rather than the async `AsyncSessionTransaction` proxy (regenerated per call, only weak-referenced) means the memo entry survives between autobegin publishes instead of GC'ing and silently losing the dedup.
22+
1923
## Publisher wrapper
2024

2125
`broker.publisher(queue, *, headers=None, title=None, description=None, schema=None, include_in_schema=True)` returns an `OutboxPublisher` — a typed wrapper around `broker.publish` with the same transactional contract. Static decorator headers merge with per-call headers (per-call wins).

‎benchmarks/baseline.json‎

Lines changed: 68 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,50 @@
11
{
22
"runs": {
33
"consumer/w1/b10": {
4-
"calls": 14880,
4+
"calls": 14712,
55
"dead_tup": 10000,
66
"delete_calls": 5000,
77
"delete_calls_per_msg": 1.0,
88
"heap_bytes": 1875968,
99
"index_bytes": 344064,
1010
"insert_calls": 0,
1111
"messages": 5000,
12-
"msgs_per_second": 441.21057661925147,
13-
"round_trips_per_msg": 2.976,
12+
"msgs_per_second": 409.5546765342868,
13+
"round_trips_per_msg": 2.9424,
1414
"select_calls": 0,
1515
"tup_del": 5000,
1616
"tup_hot_upd": 0,
1717
"tup_ins": 0,
1818
"tup_upd": 5000,
19-
"wal_bytes": 4490440,
20-
"wal_bytes_per_msg": 898.088,
19+
"wal_bytes": 4497344,
20+
"wal_bytes_per_msg": 899.4688,
2121
"wal_fpi": 242,
22-
"wal_records": 36649,
23-
"wal_records_per_msg": 7.3298,
24-
"wall_seconds": 11.332457255019108
22+
"wal_records": 36554,
23+
"wal_records_per_msg": 7.3108,
24+
"wall_seconds": 12.208382144017378
2525
},
2626
"consumer/w1/b100": {
27-
"calls": 14658,
27+
"calls": 14616,
2828
"dead_tup": 10000,
2929
"delete_calls": 5000,
3030
"delete_calls_per_msg": 1.0,
3131
"heap_bytes": 2048000,
3232
"index_bytes": 344064,
3333
"insert_calls": 0,
3434
"messages": 5000,
35-
"msgs_per_second": 450.1358749815796,
36-
"round_trips_per_msg": 2.9316,
35+
"msgs_per_second": 466.9523359149118,
36+
"round_trips_per_msg": 2.9232,
3737
"select_calls": 0,
3838
"tup_del": 5000,
3939
"tup_hot_upd": 0,
4040
"tup_ins": 0,
4141
"tup_upd": 5000,
42-
"wal_bytes": 4643216,
43-
"wal_bytes_per_msg": 928.6432,
42+
"wal_bytes": 4638792,
43+
"wal_bytes_per_msg": 927.7584,
4444
"wal_fpi": 245,
45-
"wal_records": 37462,
46-
"wal_records_per_msg": 7.4924,
47-
"wall_seconds": 11.10775718599325
45+
"wal_records": 37372,
46+
"wal_records_per_msg": 7.4744,
47+
"wall_seconds": 10.707730994006852
4848
},
4949
"consumer/w1/b100/tfbs100": {
5050
"calls": 204,
@@ -55,134 +55,134 @@
5555
"index_bytes": 352256,
5656
"insert_calls": 0,
5757
"messages": 5000,
58-
"msgs_per_second": 9673.101687744125,
58+
"msgs_per_second": 10183.536244050012,
5959
"round_trips_per_msg": 0.0408,
6060
"select_calls": 0,
6161
"tup_del": 5000,
6262
"tup_hot_upd": 0,
6363
"tup_ins": 0,
6464
"tup_upd": 5000,
65-
"wal_bytes": 5580984,
66-
"wal_bytes_per_msg": 1116.1968,
65+
"wal_bytes": 5580992,
66+
"wal_bytes_per_msg": 1116.1984,
6767
"wal_fpi": 243,
6868
"wal_records": 30330,
6969
"wal_records_per_msg": 6.066,
70-
"wall_seconds": 0.5168972850078717
70+
"wall_seconds": 0.49098858001525514
7171
},
7272
"consumer/w2/b10": {
73-
"calls": 8586,
73+
"calls": 9303,
7474
"dead_tup": 10000,
7575
"delete_calls": 5000,
7676
"delete_calls_per_msg": 1.0,
77-
"heap_bytes": 2121728,
78-
"index_bytes": 278528,
77+
"heap_bytes": 2080768,
78+
"index_bytes": 294912,
7979
"insert_calls": 0,
8080
"messages": 5000,
81-
"msgs_per_second": 1189.9545047874471,
82-
"round_trips_per_msg": 1.7172,
81+
"msgs_per_second": 975.4114938640164,
82+
"round_trips_per_msg": 1.8606,
8383
"select_calls": 0,
8484
"tup_del": 5000,
8585
"tup_hot_upd": 0,
8686
"tup_ins": 0,
8787
"tup_upd": 5000,
88-
"wal_bytes": 4721320,
89-
"wal_bytes_per_msg": 944.264,
88+
"wal_bytes": 4704512,
89+
"wal_bytes_per_msg": 940.9024,
9090
"wal_fpi": 249,
91-
"wal_records": 34016,
92-
"wal_records_per_msg": 6.8032,
93-
"wall_seconds": 4.201841314003104
91+
"wal_records": 34340,
92+
"wal_records_per_msg": 6.868,
93+
"wall_seconds": 5.126041707990225
9494
},
9595
"consumer/w2/b100": {
96-
"calls": 8943,
96+
"calls": 9126,
9797
"dead_tup": 10000,
9898
"delete_calls": 5000,
9999
"delete_calls_per_msg": 1.0,
100-
"heap_bytes": 2424832,
100+
"heap_bytes": 2400256,
101101
"index_bytes": 294912,
102102
"insert_calls": 0,
103103
"messages": 5000,
104-
"msgs_per_second": 853.0250182265884,
105-
"round_trips_per_msg": 1.7886,
104+
"msgs_per_second": 1022.17433469905,
105+
"round_trips_per_msg": 1.8252,
106106
"select_calls": 0,
107107
"tup_del": 5000,
108108
"tup_hot_upd": 0,
109109
"tup_ins": 0,
110110
"tup_upd": 5000,
111-
"wal_bytes": 4668904,
112-
"wal_bytes_per_msg": 933.7808,
111+
"wal_bytes": 4653304,
112+
"wal_bytes_per_msg": 930.6608,
113113
"wal_fpi": 243,
114-
"wal_records": 34237,
115-
"wal_records_per_msg": 6.8474,
116-
"wall_seconds": 5.8614927970047574
114+
"wal_records": 34296,
115+
"wal_records_per_msg": 6.8592,
116+
"wall_seconds": 4.891533499001525
117117
},
118118
"consumer/w4/b10": {
119-
"calls": 8007,
119+
"calls": 8010,
120120
"dead_tup": 10000,
121121
"delete_calls": 5000,
122122
"delete_calls_per_msg": 1.0,
123-
"heap_bytes": 2146304,
123+
"heap_bytes": 2088960,
124124
"index_bytes": 278528,
125125
"insert_calls": 0,
126126
"messages": 5000,
127-
"msgs_per_second": 1161.7567625270935,
128-
"round_trips_per_msg": 1.6014,
127+
"msgs_per_second": 1160.5565577478487,
128+
"round_trips_per_msg": 1.602,
129129
"select_calls": 0,
130130
"tup_del": 5000,
131131
"tup_hot_upd": 0,
132132
"tup_ins": 0,
133133
"tup_upd": 5000,
134-
"wal_bytes": 4675072,
135-
"wal_bytes_per_msg": 935.0144,
136-
"wal_fpi": 242,
137-
"wal_records": 33711,
138-
"wal_records_per_msg": 6.7422,
139-
"wall_seconds": 4.30382689498947
134+
"wal_bytes": 4924936,
135+
"wal_bytes_per_msg": 984.9872,
136+
"wal_fpi": 267,
137+
"wal_records": 34166,
138+
"wal_records_per_msg": 6.8332,
139+
"wall_seconds": 4.308277753996663
140140
},
141141
"consumer/w4/b100": {
142-
"calls": 6729,
142+
"calls": 7029,
143143
"dead_tup": 10000,
144144
"delete_calls": 5000,
145145
"delete_calls_per_msg": 1.0,
146-
"heap_bytes": 2768896,
146+
"heap_bytes": 2662400,
147147
"index_bytes": 270336,
148148
"insert_calls": 0,
149149
"messages": 5000,
150-
"msgs_per_second": 1589.6276667449354,
151-
"round_trips_per_msg": 1.3458,
150+
"msgs_per_second": 1729.9536695879196,
151+
"round_trips_per_msg": 1.4058,
152152
"select_calls": 0,
153153
"tup_del": 5000,
154154
"tup_hot_upd": 0,
155155
"tup_ins": 0,
156156
"tup_upd": 5000,
157-
"wal_bytes": 4923224,
158-
"wal_bytes_per_msg": 984.6448,
157+
"wal_bytes": 4836824,
158+
"wal_bytes_per_msg": 967.3648,
159159
"wal_fpi": 243,
160-
"wal_records": 33866,
161-
"wal_records_per_msg": 6.7732,
162-
"wall_seconds": 3.1453906500246376
160+
"wal_records": 33755,
161+
"wal_records_per_msg": 6.751,
162+
"wall_seconds": 2.890250813012244
163163
},
164164
"producer/w1/b100": {
165-
"calls": 10002,
165+
"calls": 5003,
166166
"dead_tup": 0,
167167
"delete_calls": 0,
168168
"delete_calls_per_msg": 0.0,
169169
"heap_bytes": 2162688,
170170
"index_bytes": 196608,
171171
"insert_calls": 5000,
172172
"messages": 5000,
173-
"msgs_per_second": 4169.062244595134,
174-
"round_trips_per_msg": 2.0004,
175-
"select_calls": 5000,
173+
"msgs_per_second": 7409.4357487481775,
174+
"round_trips_per_msg": 1.0006,
175+
"select_calls": 1,
176176
"tup_del": 0,
177177
"tup_hot_upd": 0,
178178
"tup_ins": 5000,
179179
"tup_upd": 0,
180-
"wal_bytes": 2925984,
181-
"wal_bytes_per_msg": 585.1968,
182-
"wal_fpi": 1,
183-
"wal_records": 15220,
184-
"wal_records_per_msg": 3.044,
185-
"wall_seconds": 1.1993104699940886
180+
"wal_bytes": 2918536,
181+
"wal_bytes_per_msg": 583.7072,
182+
"wal_fpi": 0,
183+
"wal_records": 15219,
184+
"wal_records_per_msg": 3.0438,
185+
"wall_seconds": 0.674815217993455
186186
}
187187
}
188188
}

‎faststream_outbox/publisher/producer.py‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import time
1111
import typing
12+
from weakref import WeakKeyDictionary
1213

1314
from faststream._internal.endpoint.utils import ParserComposition
1415
from faststream._internal.parser import DefaultCodec
@@ -47,6 +48,10 @@ def __init__(
4748
) -> None:
4849
self._table = table
4950
self._channel = f"outbox_{table.name}"
51+
# Per-transaction NOTIFY dedup memo (see _notify). Keyed on the innermost active
52+
# transaction object, weak-referenced so entries GC when the transaction ends -- no
53+
# explicit reset, and a rolled-back savepoint cannot suppress a later real NOTIFY.
54+
self._notified: WeakKeyDictionary[typing.Any, set[str]] = WeakKeyDictionary()
5055
self.serializer: SerializerProto | None = None
5156
# ProducerProto[0.7] requires a `codec` attribute. The outbox owns its
5257
# own encoding pipeline (_encode_payload) and never reads this attribute
@@ -210,10 +215,30 @@ async def request(self, cmd: OutboxPublishCommand) -> typing.NoReturn:
210215
raise NotImplementedError(_REQUEST_UNSUPPORTED_MSG)
211216

212217
async def _notify(self, session: typing.Any, queue: str) -> None:
213-
# ``pg_notify(:channel, :payload)`` — parameterized so channel and payload
214-
# bind cleanly (raw NOTIFY accepts only literals — injection-prone). Runs
215-
# on the caller's session so NOTIFY commits with the row insert; rollback
216-
# silently discards it. Non-Postgres dialects ignore it.
218+
# ``pg_notify(:channel, :payload)`` -- parameterized so channel and payload
219+
# bind cleanly (raw NOTIFY accepts only literals -- injection-prone). Runs on
220+
# the caller's session so NOTIFY commits with the row insert; rollback silently
221+
# discards it. Non-Postgres dialects ignore it.
222+
#
223+
# Dedup: Postgres already coalesces identical (channel, payload) NOTIFYs per
224+
# transaction at delivery, so a second pg_notify for the same queue in the same
225+
# transaction only wastes a round-trip. Memoize emitted queues per innermost
226+
# active transaction (get_nested_transaction() during a savepoint, else the outer
227+
# transaction) -- each a distinct, weak-referenceable object that GCs when the
228+
# transaction ends, so a rolled-back savepoint cannot suppress a later real NOTIFY.
229+
# Key on the *sync* SessionTransaction, not the async AsyncSessionTransaction proxy:
230+
# SQLAlchemy regenerates that proxy on every call and only weak-references it, so
231+
# under autobegin (no explicit `session.begin()`) it would GC between publishes and
232+
# defeat the memo. The Session holds the sync transaction strongly for its lifetime,
233+
# so the key survives across publishes whether the caller uses `session.begin()` or
234+
# autobegin. No active transaction -> emit unconditionally.
235+
sync_session = session.sync_session
236+
txn = sync_session.get_nested_transaction() or sync_session.get_transaction()
237+
if txn is not None:
238+
emitted = self._notified.setdefault(txn, set())
239+
if queue in emitted:
240+
return
241+
emitted.add(queue)
217242
await session.execute(
218243
text("SELECT pg_notify(:channel, :payload)"),
219244
{"channel": self._channel, "payload": queue},

0 commit comments

Comments
 (0)