Skip to content

perf: send the publish NOTIFY in the INSERT's RETURNING - #235

Merged
lesnik512 merged 3 commits into
mainfrom
perf/notify-in-returning
Oct 17, 2026
Merged

lesnik512 merged 3 commits into
mainfrom
perf/notify-in-returning

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

For 0.15.1, no issue: a single publish now sends its NOTIFY in the INSERT's RETURNING, saving one round trip per publishing transaction.

Why

A transaction's first publish to a queue ran the INSERT and then a separate SELECT pg_notify(...). That second statement is one more round trip to Postgres for every publishing transaction, and it's the common case: one publish per business transaction.

The change

  • Four more prebuilt INSERT variants: the producer now builds eight, adding a "notify" key to the existing (timer_id set, activate_in set). The notify variants return pg_notify('outbox_<table>', outbox.queue) next to the id. The channel is a bound constant and the payload is the inserted row's own queue column, so no new parameter is sent per publish.
  • Conflicts: when ON CONFLICT DO NOTHING skips a duplicate timer_id, no row comes back, so no NOTIFY fires, the same as before.
  • Dedup memo: the per-transaction memo is now checked before the INSERT and updated after it, and only when a row landed. So a conflicting timer publish doesn't stop a later publish in the same transaction from notifying. Under autobegin the transaction only exists once the INSERT has run, so the memo is read again before it's updated.
  • publish_batch keeps its separate SELECT pg_notify: it runs an executemany, which has no single RETURNING to carry the NOTIFY. It shares the same memo, in both directions.
  • Future-dated rows still skip the NOTIFY.

Measured

Median time of the publish call inside an open transaction, against real Postgres in docker, 3000 transactions per case, three runs each:

case before after
one publish 442–506 µs 285–334 µs
one publish with timer_id 453–621 µs 310–368 µs
two publishes, same queue 692–752 µs 429–554 µs

That's about a third off each publishing transaction on a local connection. The saving is one network round trip, so it grows with the latency to Postgres. The benchmark gate passes: the producer's insert_calls and tuple counters are unchanged, and its separate select_calls go away.

Tests

  • test_publish_notifies_in_the_insert_round_trip checks that two publishes in one transaction run exactly two statements, with pg_notify only in the first. It failed before the change, which ran three.
  • test_notify_sent_after_a_timer_conflict_in_the_same_transaction uses a real LISTEN connection. It covers the memo edge case this change could have introduced: a skipped duplicate timer followed by a normal publish still delivers one notification.
  • test_notify_dedup_spans_publish_and_publish_batch covers a batch after a single publish in one transaction.
  • The existing dedup, savepoint, autobegin and future-dated tests pass unchanged. Four unit tests that asserted the separate SELECT pg_notify now assert the single statement.

Docs

how-it-works.md and performance.md described the separate statement. They now say a single publish carries the NOTIFY in RETURNING and publish_batch sends one extra statement.

Also in this PR: coverage uses sys.monitoring

The first CI run failed on two things.

  • SQLAlchemy 2.0.0 (the floors jobs): it compiles the pg_notify bind without a ::VARCHAR cast, and one new unit-test assertion expected the cast. The assertion now accepts both forms.
  • Coverage: one pytest job fell under 100% because test_batched_flush_dlq_row_stays_inline sometimes reported two lines as never run. Those are the loop that publishes the successful rows.

The test proves those lines run: with an extra assertion that all four rows arrived, it passed every time while the lines were still reported missed. It's coverage.py's default C tracer losing lines in this async test, and it isn't new. Locally, main showed it in 3 of 15 runs of that test. With this branch's timing it showed in 7–8 of 15, and with coverage's sys.monitoring backend in 0 of 15.

So [tool.coverage.run] now sets core = "sysmon":

  • Version: the setting exists from coverage 7.9, and CI installs 7.16.2 in every job, floors included.
  • Fallbacks: Python 3.11 has no sys.monitoring, and just test-branch asks for branch coverage, which sysmon can't measure before 3.14. In both cases coverage falls back to the C tracer with a no-sysmon warning, which is disabled.
  • Speed: the suite runs no slower, 29.6 s against about 31 s before.

Verification

  • 707 tests pass in docker at 100% coverage.
  • ruff and ty are clean, the docs build, and just bench-check passes.

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Benchmark gate

✅ gate passed

scenario msg/s delete/msg WALrec/msg WALB/msg fpi upd del dead_tup
consumer/w1/b10 1396 1.000 6.70 914 242 5000 5000 10000
consumer/w1/b100 1467 1.000 7.09 1143 243 5000 5000 10000
consumer/w2/b10 1659 1.000 6.70 915 242 5000 5000 10000
consumer/w2/b100 1907 1.000 7.09 1143 243 5000 5000 10000
consumer/w4/b10 1626 1.000 6.74 943 242 5000 5000 10000
consumer/w4/b100 1988 1.000 7.09 1143 243 5000 5000 10000
consumer/w1/b100/tfbs100 6387 0.010 6.06 1117 243 5000 5000 10000
consumer/w1/b100/interval1-10 1495 1.000 7.15 1186 265 5000 5000 10000
producer/w1/b100 3039 0.000 3.04 597 1 0 0 0
producer/w1/b100/ppt1 996 0.000 4.04 637 1 0 0 0

Gated (fails the build): delete_calls + tuple counters (upd/del/ins) + the producer's insert_calls, exact; select_calls and other_calls within +2; wal_records within a 10% band; fetch_calls within 2x. msg/s, WAL bytes and total calls are informational (timing/FPI noise).

@lesnik512
lesnik512 merged commit 356af94 into main Oct 17, 2026
13 checks passed
@lesnik512
lesnik512 deleted the perf/notify-in-returning branch October 17, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant