Skip to content

Move RTMP publish/play authorization off the poll thread - #229

Draft
claude[bot] wants to merge 8 commits into
mainfrom
claude/project-thread-3w9rjy
Draft

claude[bot] wants to merge 8 commits into
mainfrom
claude/project-thread-3w9rjy

Conversation

@claude

@claude claude Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

<!-- ccr-projects-attribution: {"github_login":"AlexanderWagnerDev"} -->
Requested by Alex · project thread

Depends on OpenRTMP/librtmp2#318, which is not yet merged. This PR's Cargo.toml temporarily pins librtmp2 to that PR's branch so CI can build against the new API; it needs to be switched back to a crates.io version pin once #318 merges and is released. Marked draft for that reason, independent of review status here.

Where synchronous blocking happened

authorize_publish/authorize_play on DbRtmpBridge ran directly inside librtmp2's publish/play command callbacks (on_publish_cb/on_play_cb), which fire synchronously on the single RTMP poll thread. Both do blocking SQLite work (viewer_find_by_play_key, stream_get, player_try_acquire/publisher_update, all serialized behind Db's single-connection mutex), and publish additionally acquires Raft stream ownership when clustering is enabled. One slow authorization — lock contention, a busy disk, a Raft round trip to another node — stalled every other connection's handshake, publish, and play on the same server until it returned.

What moved off that thread, and how it comes back

This PR does not change what authorize_publish/authorize_play check — same DB lookups, same connection-limit enforcement, same cluster ownership/fencing rules, same players/publishers persistence. It changes where that work runs.

src/auth_worker.rs is a new dedicated OS thread that owns exactly one job: pull a publish/play request off a bounded channel (capacity 512), call the existing authorize_publish/authorize_play unchanged, and push the boolean result onto a completion channel. One thread keeps SQLite access serialized exactly as it already was under Db's connection mutex — a pool of workers would just queue on that same mutex — while guaranteeing the work never blocks the RTMP thread. A full queue fails the request closed (Deny) immediately rather than growing without bound or blocking the caller.

The RTMP-side publish/play callbacks (rtmp_publish_auth_cb/rtmp_play_auth_cb in server.rs) now submit to that worker and return librtmp2's new AuthorizationResult::Pending immediately (see #318) instead of blocking on the DB call inline. drain_auth_completions, called once per poll tick from process_server_connections (shared by the production server and the test harness), applies every completion the worker has finished via Server::complete_publish_authorization/complete_play_authorization — this resumes librtmp2's own publish/play state machine and sends the client's onStatus reply. The now-unused synchronous rtmp_publish_cb/rtmp_play_cb are removed rather than left dead.

A pending connection sends and receives no media in the meantime — librtmp2's Pending state holds off the state-machine transition that would enable relay, so this falls out of the existing protocol layer rather than needing new gating here.

Verified

cargo test --features test-support -- --test-threads=1 (142 lib tests) and cargo test --test rtmp_http_e2e --features test-support -- --test-threads=1 (5 tests, including a real RTMP publish+play round trip through the new async path) both pass unchanged, as does cargo test --lib --features cluster,test-support -- --test-threads=1 (175 tests, covering cluster ownership through the worker). cargo clippy --all-targets --features test-support -- -D warnings and cargo fmt --check are clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RLht6yc8cp3bfPYYXo58k6


Generated by Claude Code

authorize_publish/authorize_play do blocking SQLite work (and, for
publish, Raft ownership acquisition when clustering is enabled). They
used to run directly inside librtmp2's publish/play callbacks on the
single RTMP poll thread, so one slow authorization -- lock contention,
a busy disk, a Raft round trip -- stalled every other connection's
handshake, publish, and play until it returned.

auth_worker.rs adds a dedicated OS thread that owns this work instead.
The publish/play callbacks (rtmp_publish_auth_cb/rtmp_play_auth_cb)
submit a request and return librtmp2's new
AuthorizationResult::Pending immediately; the worker calls the
existing authorize_publish/authorize_play unchanged and reports the
result on a completion channel, which the RTMP poll loop drains once
per tick (drain_auth_completions, called from
process_server_connections so both the production server and the test
harness pick it up) via Server::complete_publish_authorization/
complete_play_authorization. A single worker thread keeps SQLite
access serialized exactly as it already was under Db's connection
mutex; the request queue is bounded (512) so a stuck DB fails new
requests closed instead of queuing without limit.

Existing DB/cluster authorization semantics, connection limits, and
ownership checks are unchanged -- only where that work runs moved.
The now-unused synchronous rtmp_publish_cb/rtmp_play_cb are removed.

librtmp2's Cargo dependency is temporarily pinned to the branch adding
AuthorizationResult/Pending (OpenRTMP/librtmp2#318) until that PR
merges and a release containing it is published.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLht6yc8cp3bfPYYXo58k6
@coderabbitai

coderabbitai Bot commented Sep 22, 2026

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: b73d585b-ec70-4189-a2b1-0a879716c3f3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Replace the sequential viewer_find_by_play_key() + stream_get() lookups
in the play-authorization hot path with viewer_and_stream_by_play_key(),
a single JOIN query. Both lookups were already indexed (stream_viewers.
play_key is UNIQUE, streams.id is the primary key), so this removes a
round trip and a lock acquisition rather than adding an index.
Authorization semantics are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLht6yc8cp3bfPYYXo58k6
Replace the three SELECT COUNT(*) FROM players WHERE viewer_id=? AND
active=1 queries (in player_try_acquire, player_update's reactivation
check, and player_active_count_for_viewer) with an in-memory counter
(Db::active_player_counts) mutated in the same conn-lock critical
section as the matching SQLite write, so it can never observably
disagree with the players table. This removes a DB round trip from
every viewer join and from the per-join cluster heartbeat check,
without changing the per-viewer connection-cap semantics.

SQLite remains the durable record: players rows are still inserted and
updated exactly as before, and the counter starts empty on every
Db::open, matching the existing startup reset of stale active rows (no
session survives a process restart either way).

players_deactivate_for_viewer now also zeroes the counter, which
already covers the DeleteViewer cluster command. The Raft snapshot
install path can drop an active player row outright (skipped rather
than reinserted, when its stream or viewer didn't survive the
snapshot) without ever calling player_update — install_app_snapshot
now tracks those and resyncs the counter for their viewers after
commit, so a concurrent viewer deletion during a snapshot install
can't leave a stale +1 in the counter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLht6yc8cp3bfPYYXo58k6
process_server_connections now reports whether it just applied a
publish/play authorization completion from the auth worker
(drain_auth_completions returns bool instead of ()). The poll loop
uses that, alongside the existing "still negotiating" check, to pick
the fast poll interval for the next tick -- so a client that sends its
next protocol step (first media frame, a play command) right after
receiving the onStatus reply doesn't wait out a full idle-interval
sleep before the server reads it.

Also drop POLL_INTERVAL_FAST_MS from 5ms to 1ms: it's already scoped
to brief negotiation/join bursts (never steady-state traffic), so
tightening it lowers per-step latency during those bursts without
adding steady-state CPU cost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLht6yc8cp3bfPYYXo58k6
Comment thread Cargo.toml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dependencies -> librtmp2 Version set on Release

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's intentional for now, not an oversight — this PR needs AuthorizationResult, on_publish_auth_cb/on_play_auth_cb, and complete_publish_authorization/complete_play_authorization from librtmp2#318, which hasn't shipped a crates.io release yet. I pinned to that PR's branch as a stopgap (see the comment above the dependency line) so this PR can build and be reviewed in the meantime.

Once #318 merges and a release containing it is published, I'll revert this to a normal version pin — I've noted that as a required follow-up. If you'd rather I hold off merging this PR until that release exists, or if you want to cut the librtmp2 release yourself once #318 lands, let me know; otherwise I'll handle the revert as soon as it's available.


Generated by Claude Code

claude and others added 4 commits September 22, 2026 23:24
Documents the async publish/play authorization work already on this
branch: the dedicated auth worker thread, the single-JOIN play auth
query, the in-memory viewer connection counter, and the immediate
fast poll tick after an auth completion resolves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLht6yc8cp3bfPYYXo58k6
Re-ran scripts/run_rtmp_benchmarks.sh (librtmp2-server vs nginx-rtmp vs
MediaMTX) against the async-auth-worker-thread architecture from 0.4.0:
handshake latency improved (avg 61.3ms -> 49.9ms), and the 100-viewer
join-latency gap to nginx-rtmp narrowed from ~12.6ms to ~4ms, consistent
with authorization moving off the RTMP poll thread and the per-viewer
connection cap reading an in-memory counter instead of a per-join
SELECT COUNT(*). Also documents a benign setup-phase rate-limit quirk
found while re-running the script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLht6yc8cp3bfPYYXo58k6
@sonarqubecloud

Copy link
Copy Markdown

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.

2 participants