Skip to content

Add transport outgoing-datagram queue engine - #3982

Closed
jesup wants to merge 1 commit into
mainfrom
users/jesup/datagram_engine
Closed

jesup wants to merge 1 commit into
mainfrom
users/jesup/datagram_engine

Conversation

@jesup

@jesup jesup commented Sep 14, 2026

Copy link
Copy Markdown
Member

No description provided.

Copilot AI balanced review requested due to automatic review settings September 14, 2026 02:20

Copilot AI left a comment

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.

🟡 Changes recommended

The queue is not production-integrated and has correctness issues in fairness, backpressure recovery, and expiry boundaries.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds a transport-level engine for scheduling outgoing datagrams with prioritization, fairness, expiry, backpressure, and byte limits.

Changes:

  • Introduces the datagram queue engine and comprehensive unit tests.
  • Adds ordering support to send-group identifiers.
  • Exposes queue-related outcomes, capacity, IDs, and default expiry calculation.
File summaries
File Description
neqo-transport/src/datagram_queue.rs Implements scheduling, expiry, eviction, and backpressure.
neqo-transport/src/streams.rs Makes send-group IDs orderable.
neqo-transport/src/lib.rs Re-exports queue-related public types.
Review details

Suppressed comments (1)

neqo-transport/src/datagram_queue.rs:514

  • The comment says overflow applies backpressure, but this branch never sets blocked. If the first pressure event is Overflowed (for example, the byte budget is reached before the count watermark), a caller that stops enqueueing will never get true from resume_if_unblocked() after space is freed. Mark the queue blocked for this outcome as well and cover the overflow/resume transition with a test.
        } else {
            DatagramQueueOutcome::Overflowed { dropped }
        };
  • Files reviewed: 3/3 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs
@codecov

codecov Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.09%. Comparing base (f40c8d6) to head (1a3c09a).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3982      +/-   ##
==========================================
+ Coverage   96.98%   97.09%   +0.10%     
==========================================
  Files         113      114       +1     
  Lines       39657    40743    +1086     
  Branches    39657    40743    +1086     
==========================================
+ Hits        38461    39558    +1097     
+ Misses       1182     1169      -13     
- Partials       14       16       +2     
Flag Coverage Δ
linux 97.12% <100.00%> (+0.07%) ⬆️
macos 95.28% <99.26%> (+0.13%) ⬆️
windows 95.36% <99.26%> (+0.11%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
neqo-common 99.31% <ø> (ø)
neqo-http3 95.33% <ø> (ø)
neqo-qpack 96.97% <ø> (ø)
neqo-transport 97.93% <100.00%> (+0.15%) ⬆️
neqo-udp 95.37% <ø> (ø)
mtu 89.13% <ø> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed

codspeed Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will regress 2 benchmarks

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 13 improved benchmarks
❌ 2 regressed benchmarks
✅ 84 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ Simulation write_frames 5-streams 1-active 14.9 µs 15.3 µs -2.54%
❌ Simulation write_frames 20-streams 1-active 17.9 µs 18.3 µs -2.11%
⚡ Simulation coalesce_acked_from_zero 1000 ranges 655.3 µs 600.5 µs +9.14%
⚡ Simulation coalesce_acked_from_zero 10 ranges 10.3 µs 9.7 µs +6.8%
⚡ Simulation inbound_frame in-order 1.3 ms 1.2 ms +6.31%
⚡ Simulation inbound_frame 5%-dup 1.3 ms 1.2 ms +6.29%
⚡ Simulation simulated/pacing-false/same-seed 77.2 ms 72.7 ms +6.25%
⚡ Simulation coalesce_acked_from_zero 3 ranges 3.8 µs 3.6 µs +6.09%
⚡ Simulation mark_sent retransmit 34.9 µs 33.4 µs +4.51%
⚡ Simulation mark_acked fragmented 221.5 µs 212.4 µs +4.32%
⚡ Simulation coalesce_acked_from_zero 1 ranges 2.8 µs 2.7 µs +4.08%
⚡ WallTime neqo-neqo-cubic 20.9 ms 20.2 ms +3.37%
⚡ Simulation simulated/pacing-true/same-seed 75.1 ms 72.8 ms +3.17%
⚡ Simulation inbound_frame 2%-loss 2.4 ms 2.4 ms +2.84%
⚡ Simulation simulated/1000-streams/each-1000-bytes 174.7 ms 171.3 ms +2.02%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing users/jesup/datagram_engine (1a3c09a) with main (28cacc8)

Open in CodSpeed

@github-actions github-actions Bot left a comment

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.

Reviewed the full diff (datagram_queue.rs, the lib.rs re-exports, and the SendGroupId Ord derive). No prior review comments existed on this PR. No unsafe, no FFI, and no untrusted-input parsing here — the risk surface is the scheduling/accounting logic and the backpressure contract. Byte accounting (total_bytes/total_count) is symmetric across every add/remove path and capacity() saturates, so I found no underflow paths.

A few things that apply to the PR as a whole rather than any one line:

Duplicated scheduler skeleton. send_stream.rs already implements per-send-group round-robin with within-group SendOrder priority: per_group: IndexMap<SendGroupId, PerGroupQueues> plus a cursor, where PerGroupQueues is itself a BTreeMap<SendOrder, _> served highest-first. This module reimplements the same shape with BTreeMap<SendGroupId, GroupQueue> plus rr_next. Two semantic divergences look unintentional rather than chosen: stream groups round-robin in insertion order, datagram groups in ascending group-ID order; and the stream side keeps a separate unordered bucket. Since a SendGroupId can legitimately be shared between a WebTransportSendStream and a datagram writable, it would be worth either factoring out the shared skeleton or at least documenting why the two cursors order groups differently. Relatedly, SendStreams::set_sendgroup rejects SendGroupId::new(0) from callers, while enqueue accepts it as an ordinary key standing in for the null sendGroup — the asymmetry deserves a line in the enqueue docs so callers know they must do the mapping themselves.

Test surface points at the wrong API. Almost every test drives drain, which is #[cfg(test)]-only; peek_next_len/take_next, the pair production will use, has two tests. The interesting interactions (budget boundary, expiry interleaved with partial takes, round-robin cursor across takes) are only covered through the test-only wrapper. Moving the shared-core cases onto peek/take would test the real contract at no extra cost, given drain is just a loop over take_next.

Configurability and benchmarking. max_queued_bytes is hard-coded to DEFAULT_MAX_QUEUED_BYTES with no setter outside tests, whereas the existing queue depth is tunable via ConnectionParameters::outgoing_datagram_queue — a matching byte-budget parameter would avoid a second, differently-shaped knob later. On cost: lowest_priority_key is a full scan of groups and runs once per evicted datagram, so a burst that evicts k entries is O(k·groups) inside enqueue. That is fine at today's group counts, but since this is destined for the packet-build path, a neqo-transport/benches case covering enqueue-under-budget-pressure would be worth landing with it.

Landing shape. As noted inline, the module compiles into the lib target with no consumer, which I expect trips dead_code under -D warnings, and several doc comments describe QuicDatagrams integration that isn't in this PR. If the intent is to land the engine separately from the wiring, both are easy to resolve (export the type, soften the docs); if the wiring is imminent, folding it in would make the design reviewable against a real caller — in particular whether DatagramQueueOutcome/resume_if_unblocked is the right split of responsibility versus letting the queue own the ConnectionEvents handle the way QuicDatagrams does today.

Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/lib.rs
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs Outdated
@jesup
jesup force-pushed the users/jesup/datagram_engine branch from 5cf2dbe to 662dce4 Compare September 14, 2026 05:31

@github-actions github-actions Bot left a comment

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.

Self-contained, well-documented scheduling engine with genuinely good test density. The rationale comments on the constants (DEFAULT_MAX_QUEUED_BYTES, the 1.25x/20 ms max-age derivation, the deliberate absence of a ceiling) are the kind of thing that saves the next reader an hour.

Previously raised, now resolved: inclusive expiry boundary (>= max_age); per-iteration re-check of the eviction victim's priority; blocked armed on Overflowed/Rejected; resume_if_unblocked documented for expiry paths and covered by expiry_alone_releases_a_blocked_queue; BTreeMap::retain in expire_old; filter_map in lowest_priority_key replacing the expect/unreachable!; next_expiry_is_none_when_the_deadline_overflows renamed; DatagramQueue now re-exported, so the dead_code concern is gone. Still open from the earlier round: the module-level docs (and drain's "production code (QuicDatagrams::write_frames) pulls …") still describe integration that quic_datagrams.rs does not have in this PR.

Whole-PR observations:

  • Duplicated round-robin. SendStreams already does per-SendGroupId round-robin with a persisted cursor (send_stream.rs#L2237). This adds a second, independent implementation of the same cursor-over-BTreeMap-keys pattern. Worth extracting a small shared helper, especially since the doc comment explicitly points at the stream scheduler as the analogue — and since a shared helper is the natural seam if the cross-type fairness gap called out at line 307 is ever closed.
  • expire and next_expiry are O(groups × order-buckets) per call, and both are expected to run every process_output. All datagrams in a queue share one max_age and timestamps are non-decreasing by insertion, so a single insertion-ordered side index (VecDeque<(Instant, SendGroupId, SendOrder)>) would make next_expiry O(1) and expire O(expired). Probably not worth it at ~213 queued datagrams, but it's the obvious next step if profiles say otherwise.
  • No benchmark. enqueue under byte pressure is the interesting path: eviction calls lowest_priority_key() (O(groups)) once per evicted datagram, so a large datagram arriving into a many-group full queue is O(groups × evictions). A criterion case alongside the existing transport benches would pin the constant down before this is on the packet-build hot path.
  • max_queued_bytes is not configurable from outside the crate. Tests poke the private field; there is no setter and DEFAULT_MAX_QUEUED_BYTES isn't exported, yet DatagramQueueCapacity reports it. Either add set_max_queued_bytes (symmetric with set_high_water_mark) or drop it from the capacity snapshot.
  • DatagramOutcome is exported but nothing constructs it in this PR — fine as staging, just noting the crate-root public surface currently includes a type with no producer.
  • Re: Copilot's byte-fairness comment — I think the turn-based tradeoff is reasonably argued in the doc comment, and DRR isn't worth the per-group state today. Worth noting that peek_next_len already hands the caller the byte count a deficit scheme would need, so this stays cheap to revisit without an API change.

Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/lib.rs
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs Outdated
@larseggert

Copy link
Copy Markdown
Collaborator

@jesup naive question: why is this still 1.6K LoC? I thought the queue moved down into the QUIC layer now, should the adaptions you need me much lighter-weight?

@jesup

jesup commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

This patch is >50% tests. Of the remainder, almost half are comments; there's ~400LoC of actual non-test code here, and this is the basis for all the other patches. I could pull a few bits out of the initial patch, but it wouldn't dramatically reduce the size, and it would add yet more patches to the stack.

I will ask claude to re-evaluate all the patches and push on DRY to see if they can be made smaller.

@jesup

jesup commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

Also: the code saved was mostly just some api-layer-crossing code, which was a small part of this patch (it eliminated adding some API changes to transport, but this (in other patches) has a different set of API changes to receive data on max-age, send back expiry reasons, etc). I moved it down here at Max's request to avoid two levels of queues and to centralize the handling. Because I needed to move stuff down and redo interfaces, I took the opportunity to clean the stack to remove internal commits and provide a single clean commit, and also to minimize later PRs that modified the initial patch's design

@jesup

jesup commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

For reference, asking claude to apply (more) DRY to all the patches (including the ones I haven't uploaded yet) produced some small reductions, generally one function pulled out each in ~3 or 4 of the ~14 commits (in 10 or 11 PRs) that are currently uploaded -- one in this patch, but it didn't actually change the line count, or at most by 1 line. So no major reductions. Looking at several of the DRY patches, while they centralized code (good thing, reduces risk of changes not hitting every usage for example), none of them actually reduced LoC by more than a line or two, at most.

@github-actions github-actions Bot left a comment

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.

Reviewed the current head (fd69c54). This is self-contained new code with no unsafe, no untrusted-input parsing, and no wire-format surface — the only public API change outside the new module is adding PartialOrd/Ord to SendGroupId, which is additive. The risk concentrates in the eviction/backpressure state machine, and that is now genuinely well covered: the multi-eviction priority re-check, the over-budget admit, the Rejected charge-exact resume, the zero-budget escape and the FIFO-within-bucket victim all have dedicated tests, and I traced each of the byte arithmetic examples in them by hand.

A number of earlier findings are resolved at this revision: the inclusive expiry boundary now agrees with next_expiry; expire_old uses partition_point + one bulk drain and writes ids into the caller's Vec; blocked is an Option<usize> combined with max on the Rejected path; high_water_mark is NonZeroUsize; the charge bills Vec::capacity(); the types are re-exported so nothing is dead in a non-test build; and the module doc now says plainly that QuicDatagrams is not yet wired up (#3983).

Architectural notes, none blocking:

  • default_max_age as a parameter is an unenforced invariant. It is threaded through four methods (set_max_age, expire, next_expiry, drain), and the inclusive-boundary contract — "wake at next_expiry and you are guaranteed to shed something" — only holds if the caller passes the same value to both. Nothing in the type system says so, and the value legitimately moves as min_rtt is sampled. Holding it as a field (set_default_max_age(min_rtt), refreshed where the RTT estimate is updated) would make that structural and shrink four signatures; the cost is one more piece of state to keep fresh. Worth deciding now rather than in #3983, since the call sites land there.
  • Backpressure signal is momentary after Overflowed. When eviction leaves any headroom, blocked = Some(1) is satisfied by the very next resume_if_unblocked() with nothing having drained. That may be exactly what you want (there is room again), but it makes arming blocked on that path close to a no-op, and the doc — "drains back below the high water mark and has freed the charge it is waiting on" — reads as if a drain were required. Either is fine; they should agree.
  • Benchmark. neqo-transport/benches/send_streams.rs benchmarks the analogous stream scheduler, and this queue sits on the same per-datagram path with lowest_priority_key() (O(groups)) inside the enqueue loop and expire/next_expiry walking every bucket once per process_output. A sibling bench covering enqueue-under-eviction and drain-with-many-groups would make those costs visible before #3983 puts them on the hot path.
  • Re @mxinden's thread on dropped: Vec<DatagramId> and your confirmation that nothing in Gecko reads the outcome today: agreed, and it goes further than the one field — dropping DatagramId entirely would collapse expire/set_max_age's Vec<Option<DatagramId>> returns to plain counts and remove DatagramOutcome, which is currently constructed only in a test. Easier to add back when a consumer exists than to carry unexercised.
  • Minor: the test module discards enqueue's #[must_use] outcome with drop(...) ~60 times. A two-line fn push(q: &mut DatagramQueue, ...) helper alongside the existing drain_ids would read better and keep the assertions where they matter.

Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs
@jesup
jesup force-pushed the users/jesup/datagram_engine branch from fd69c54 to 6138309 Compare September 17, 2026 05:01

@github-actions github-actions Bot left a comment

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.

Re-reviewed at 6138309. The scheduling/eviction/expiry logic reads correct now, and the rewrite since the last pass resolved the issues I raised then: the expiry boundary is inclusive and pinned by expiry_is_inclusive_at_the_deadline; the eviction loop re-derives the victim once per bucket instead of once per datagram; the Rejected-vs-Overflowed split and the per-charge resume signal are well specified and tested. Three new items inline.

A few PR-wide observations:

  • enqueue charges data.capacity() unshrunk. An earlier thread concluded that enqueue would shrink_to_fit the incoming buffer before charging it; the current code does not (datagram_queue.rs:538), and enqueue_charges_the_full_allocation_not_just_the_payload now asserts the opposite. Intentional revert? It matters because DatagramQueueCapacity::remaining_bytes is meant to drive a content-process credit grant, and as written the grant a writer consumes depends on that writer's allocation slack, which it cannot observe. If the realloc is too expensive for the hot path, charging data.len() and letting PER_DATAGRAM_OVERHEAD absorb the slack is the deterministic alternative.

  • Hot-path cost before wiring up. next_expiry() → GroupQueue::oldest_timestamp() is O(total buckets) and will run on every process_output, per session; with distinct send_order values per datagram that is one bucket per datagram (~207 at the default budget). lowest_priority_key() is O(groups) per eviction round. Both are fine at today's scale but neither is bounded by anything except the byte budget. Worth a neqo-transport/benches/ case for enqueue-under-eviction and next_expiry at a realistic bucket count before #3983 puts this on the send path — the crate already has the bench harness.

  • Public API surface. DatagramQueue, QueuedDatagram (all fields pub), DatagramQueueCapacity, DatagramQueueOutcome, DatagramOutcome, DatagramId and default_datagram_max_age are all exported from neqo-transport while nothing in-tree constructs them. Given the module doc already describes the QuicDatagrams integration in the present tense and then disclaims it, landing this together with #3983 would both shrink the exported surface to what is actually needed and make the docs true on arrival.

  • Responding to @martinthomson's still-open thread on global send_order as an eviction key: I agree with the author's reasoning that the application's own ordering beats group identity as a shedding signal, and byte_budget_admits_equal_priority_newcomer_across_groups covers the starvation case that motivated the concern. The residual asymmetry — victim selection breaks ties by group ID while admission does not — is documented at lowest_priority_key, so SendGroupId::new(0) (null sendGroup) is systematically the first victim at equal order. That is probably the right default, but it deserves a line in the eventual WebTransport-facing docs rather than only in a private helper's comment.

Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs
@jesup
jesup force-pushed the users/jesup/datagram_engine branch from 6138309 to af19e15 Compare September 17, 2026 13:33

@github-actions github-actions Bot left a comment

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.

Re-reviewed at af19e15. The scheduling/eviction/expiry logic reads correct now, and the eviction loop's four outcomes (fits, outranked-with-nothing-evicted, outranked-after-evicting, queue-emptied-still-over-budget) are each pinned by a test. Four minor items inline; one of them is a real coverage hole with a livelock failure mode.

Resolved since my last pass (thanks): Duration * u32 overflow panic in the now-pub default_max_age; Some(0) high water mark wedge (NonZeroUsize); the two-field blocked/blocked_charge split; charge() billing len() instead of capacity(); expire_old's double Vec<QueuedDatagram> allocation; missing #[must_use] on set_max_age and its untested expire tail; the once-per-evicted-datagram victim re-derivation; set_max_queued_bytes having zero coverage; untested take_all; blocked being assigned rather than combined. Also Overflowed's Vec<DatagramId> → dropped: usize per @mxinden, and EXPLICIT_MAX_AGE_FLOOR = GRANULARITY plus the partition_point bulk drain per @martinthomson.

Still open from earlier rounds, not re-raised inline: next_expiry/expire are O(groups × send_order buckets) at a once-per-process_output call site; the WebTransport must-not-starve rule for a null [[SendOrder]] is unmodelled (SendOrder is a bare i64, unlike SendStreams' Option<SendOrder>); clippy::module_name_repetitions is warn workspace-wide with -D warnings and nothing in the file carries an #[expect] — CI hasn't reported on this SHA yet, so worth confirming locally.

Additional opinion on @martinthomson's Option<DatagramId> comment: I'd push on this one rather than keep the Option. By your own audit, WebTransport sends everything untracked today, so the common case allocates a Vec<Option<u64>> full of Nones purely so the caller can call .len() on it. (usize, impl Iterator<Item = DatagramId>) — or a &mut Vec<DatagramId> out-param plus a returned count — gives both callers exactly what they use with no allocation in the untracked case.

Architecture. Two things follow from landing the API ahead of its consumer (#3983), beyond the doc-tense discussion already had:

  • DatagramOutcome is constructed nowhere in-tree; its sole test asserts id() round-trips. So the Expired/Dropped split — the part of the API a content-process consumer actually observes — is pinned only by its own definition. If the Gecko patch queue is close, wiring even one of the two producers (expire → Expired) in this PR would validate the shape.
  • No benchmark. neqo-transport/benches/ exists, and enqueue under sustained eviction pressure plus next_expiry are both per-packet-build costs once #3983 lands them in process_output. A criterion bench added here would make the PER_DATAGRAM_OVERHEAD/with_capacity(1)/BTreeMap-scan trade-offs measurable rather than argued.

No security concerns: no unsafe, no wire-format parsing, and all arithmetic on the eviction path is bounded by a caller-supplied budget with saturating_sub at the two places that can go negative.

Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs Outdated
@jesup
jesup force-pushed the users/jesup/datagram_engine branch from af19e15 to b31aa87 Compare September 17, 2026 15:03

@github-actions github-actions Bot left a comment

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.

Re-reviewed the whole file at b31aa87. The backpressure state machine now looks internally consistent: blocked: Option<usize> is combined rather than clobbered on Rejected, the resume predicate (below_watermark() && max_queued_bytes - total_bytes >= charge) is the exact inverse of the rejection condition, and the empty-queue escape hatch covers max_queued_bytes == 0. The eviction loop terminates unconditionally (lowest_priority_key only ever names a non-empty bucket, so evict_at always makes progress), and the accounting cannot underflow — every -= is derived from datagrams actually removed. No unsafe, no untrusted-input parsing.

Previously-raised items I can confirm resolved: the Duration * u32 overflow panic in default_max_age (now checked_mul), the one-age-comparison-per-datagram expiry (now partition_point + bulk drain, per @martinthomson), the triple allocation in expire, the priority check inside the multi-eviction loop, charge() billing len() instead of capacity(), the unexported types breaking -D warnings, high_water_mark: Option<NonZeroUsize>, the evict_lowest_priority dangling intra-doc link, #[must_use] on set_max_age, take_all coverage, and the // === test headings.

Architectural note that ties my inline comments together: this module reimplements three things neqo-transport already has — DatagramTracking (as Option<DatagramId>), OutgoingDatagramOutcome (as DatagramOutcome), and the QuicDatagrams::blocked / ConnectionEvent::OutgoingDatagramSpaceAvailable resume handshake (as blocked / resume_if_unblocked). Each is defensible in isolation, but the net effect is that QuicDatagrams will end up owning two of everything until #3983 collapses them, and in the meantime the crate root grows four public types with no in-tree caller. Reusing the existing types now costs little and removes that translation layer from #3983.

Two open points I raised earlier and won't re-litigate, but which I'd still weigh differently than the replies did:

  • The module doc's present-tense claim that QuicDatagrams holds one DatagramQueue per session and round-robins between them is contradicted by the "Not yet wired up" paragraph three lines later. Documenting the end state of a stack is reasonable, but a reader landing here from the crate root can't tell which half is true today; phrasing the first paragraph in the future tense would cost nothing.
  • PER_DATAGRAM_OVERHEAD = 64 still undercounts when send_order values are distinct: each new order allocates a VecDeque that reserves 4 slots on first push, so a per-frame-unique-order sender (a plausible WebTransport pattern) charges ~64 B for ~200 B of real allocation. The byte budget is a memory backstop, so a systematic 3× undercount on that shape is worth either a comment or a per-bucket charge.

Responding to @martinthomson's point that the Option in the expiry list is unnecessary: agreed that a None isn't individually actionable, and if QueuedDatagram carried DatagramTracking instead, expire could return Vec<DatagramTracking> and the caller would pass each straight to ConnectionEvents::datagram_outcome, which already discards the untracked ones — keeping the count without the awkward Option.

Nothing blocking from me.

Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs

@mxinden mxinden left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No more comments from my end. Code looks clean, especially the enqueue function. Well done.

Preference to merge here after #4006.

@github-actions github-actions Bot left a comment

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.

Reviewed at a547999: the new datagram_queue.rs, the lib.rs re-exports, and the NULL_GROUP_ID move plus Ord derive on SendGroupId. No unsafe, no FFI, no untrusted-input parsing — the surface is scheduling policy and byte accounting, and the queue is outgoing-only, so a peer cannot drive it.

Correctness re-check. Byte/count accounting is symmetric across all four removal paths (evict_at, take_next, expire_old, take_all) and capacity() saturates, so I found no underflow. The eviction loop terminates (every evict_at frees ≥ PER_DATAGRAM_OVERHEAD) and total_bytes stays bounded by budget + one datagram even on the deliberate admit-over-budget path. blocked's combine-don't-clobber rule holds at all three write sites. peek_next_len and take_next resolve to the same entry. expire_old's partition_point precondition is now guarded by the debug_assert! in push.

Previously raised, resolved at this head (not re-raised): the evict_lowest_priority dangling intra-doc link (now Self::evict_at); blocked assignment clobbering a larger outstanding charge; the missing debug_assert! on per-bucket timestamp monotonicity; evicted_any duplicating evicted_count; dropped_ids removed from Overflowed; the triple-Vec expiry path collapsed into one output vec; partition_point bulk removal per @martinthomson; EXPLICIT_MAX_AGE_FLOOR = GRANULARITY; Default instead of new().

Architectural note. This lands 2,160 lines that nothing in the crate constructs — QuicDatagrams is untouched and still holds one unbounded VecDeque per connection (#3983). That is a defensible split, but it means the only thing exercising the scheduler is its own unit tests, and several public-API decisions (DatagramId tracking, QueuedDatagram's pub fields, take_all's reset semantics) are being frozen into neqo-transport's public surface before a single caller has validated them. Worth considering whether DatagramQueue and friends should stay pub(crate) until #3983 lands and only then be exported — that would also let the doc comments describe what exists rather than the stack's end state, which is the root of several threads above.

Docs. Agreeing with @larseggert's "still VERY wordy": at ~1.4 lines of prose per line of logic this reads as a design document embedded in a source file. The rationale comments (the 1.25×/20 ms derivation, the admission-vs-victim-selection asymmetry, why group ID breaks victim ties only) are genuinely valuable and worth keeping; the ones restating what the next line does, and the per-test paragraphs explaining what the assertion already says, are the ones to cut.

Benchmarks. None added for what is intended to run per process_output. See the inline note on GroupQueue::pop_front — benches/send_streams.rs is a ready template for the analogous scheduler.

Note: GitHub's API returned only 100 of the 124 existing review comments here and no pagination was available in this environment, so if any of the above duplicates something raised in the most recent round, please disregard it.

Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs Outdated
Comment thread neqo-transport/src/datagram_queue.rs
Comment thread neqo-transport/src/datagram_queue.rs Outdated
@jesup
jesup force-pushed the users/jesup/datagram_engine branch from a547999 to 7feab9b Compare September 23, 2026 06:27

@github-actions github-actions Bot left a comment

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.

I read the full review history (57 reviews, 131 comments — counts matched the index), traced the scheduling, eviction, expiry, accounting and backpressure state machines in neqo-transport/src/datagram_queue.rs, and checked the NULL_GROUP_ID move plus the Ord derive on SendGroupId. Everything I found that was substantive is already raised in a prior thread (multi-eviction priority re-check, blocked charge combining, charge(capacity()), partition_point monotonicity, the over-budget admit, doc tense vs. #3983, benchmarks, DatagramOutcome/DatagramId surface without a consumer).

No new defects survived filtering; posting no review comments.

@github-actions

Copy link
Copy Markdown
Contributor

Performance profiles for profiler.firefox.com

Benchmarks (14)
  • neqo-bin-main: pr · base
  • neqo-common-decoder: pr · base
  • neqo-http3-streams_simulated: pr · base
  • neqo-http3-streams_walltime: pr · base
  • neqo-transport-frame_decode: pr · base
  • neqo-transport-min_bandwidth: pr · base
  • neqo-transport-pacer: pr · base
  • neqo-transport-packet_codec: pr · base
  • neqo-transport-range_tracker: pr · base
  • neqo-transport-rx_stream_orderer: pr · base
  • neqo-transport-send_streams: pr · base
  • neqo-transport-sent_packets: pr · base
  • neqo-transport-transfer_simulated: pr · base
  • neqo-transport-transfer_walltime: pr · base
Comparisons (5)

@jesup
jesup force-pushed the users/jesup/datagram_engine branch from 7feab9b to 6b42740 Compare September 23, 2026 23:54

@github-actions github-actions Bot left a comment

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.

I found nothing new worth commenting on at 6b42740; the correctness pass's only finding, unreported IDs of evicted datagrams, was deliberately removed after @mxinden asked for it (thread r4023850251).

DatagramQueue holds one session's outgoing datagrams: round-robin between
send groups (equal turns, not equal bytes), highest send_order first within
a group, max-age expiry inclusive at timestamp + max_age, and a byte budget
that evicts the lowest-priority datagram rather than the newest. enqueue
reports Ok/AboveWatermark/Rejected/Overflowed, and anything but Ok arms the
resume signal resume_if_unblocked reports once the queue drains back below
the high water mark - by a send or by expiry.

DatagramQueue is exported only so this commit's lib target has a live user;
the next one, which gives QuicDatagrams a queue per session, takes the
export back out.
@jesup
jesup force-pushed the users/jesup/datagram_engine branch from 6b42740 to 1a3c09a Compare September 25, 2026 15:05

@github-actions github-actions Bot left a comment

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.

I found no new defects at 1a3c09a; everything substantive has already been raised and settled in earlier review threads.

@github-actions

Copy link
Copy Markdown
Contributor

Failed Interop Tests

QUIC Interop Runner, client vs. server, differences relative to main at 28cacc8.

neqo-pr as clientneqo-pr as server
neqo-pr vs. go-x-net: BP BA
neqo-pr vs. haproxy: 🚀M BP BA
neqo-pr vs. kwik: Z
neqo-pr vs. linuxquic: ⚠️L1
neqo-pr vs. lsquic: L1 C1
neqo-pr vs. msquic: Z A L1 C1 🚀C2
neqo-pr vs. mvfst: A BA
neqo-pr vs. neqo: Z A
neqo-pr vs. nginx: BP BA
neqo-pr vs. ngtcp2: Z L1 ⚠️C1 CM
neqo-pr vs. picoquic: 🚀Z A
neqo-pr vs. quic-go: A
neqo-pr vs. quiche: BP BA
neqo-pr vs. s2n-quic: CM
neqo-pr vs. tquic: S BP BA
neqo-pr vs. xquic: S R Z A L1 C1 ⚠️BA
aioquic vs. neqo-pr: CM
go-x-net vs. neqo-pr: CM
kwik vs. neqo-pr: BP BA CM
msquic vs. neqo-pr: CM
mvfst vs. neqo-pr: Z L1 C1 CM
neqo vs. neqo-pr: Z A
openssl vs. neqo-pr: LR M A CM
quic-go vs. neqo-pr: 🚀H DC LR C20 M S R Z 3 B U E A L1 L2 C1 C2 6 V2 BP BA CM
quiche vs. neqo-pr: CM
quinn vs. neqo-pr: V2 CM
s2n-quic vs. neqo-pr: CM
tquic vs. neqo-pr: CM
xquic vs. neqo-pr: M CM
All results

Succeeded Interop Tests

QUIC Interop Runner, client vs. server

neqo-pr as client

neqo-pr as server

Unsupported Interop Tests

QUIC Interop Runner, client vs. server

neqo-pr as client

neqo-pr as server

@github-actions

Copy link
Copy Markdown
Contributor

Client/server transfer results

Performance differences relative to 28cacc8.

Transfer of 33554432 bytes over loopback, min. 100 runs. All unit-less numbers are in milliseconds.

Client vs. server Mean±σ Min–Max Median±MAD MiB/s±σ ΔMedian
google-neqo-cubic 70.0 ± 0.3 69.2 – 70.8 70.0 ± 0.4 457.2 ± 2.1 💔 +0.9 (+1.3%)

Table above only shows statistically significant changes. See all results below.

All results

Transfer of 33554432 bytes over loopback, min. 100 runs. All unit-less numbers are in milliseconds.

Client vs. server Mean±σ Min–Max Median±MAD MiB/s±σ ΔMedian
google-google 134.8 ± 0.6 133.4 – 136.1 134.8 ± 0.6 237.4 ± 1.0
google-neqo-cubic 70.0 ± 0.3 69.2 – 70.8 70.0 ± 0.4 457.2 ± 2.1 💔 +0.9 (+1.3%)
neqo-google-cubic ⚠️ 234.8 ± 48.3 177.5 – 537.8 227.6 ± 26.0 136.3 ± 28.0 +7.2 (+3.3%)
neqo-neqo-cubic 19.4 ± 0.1 19.1 – 19.7 19.4 ± 0.1 1650.8 ± 10.1 -0.0 (-0.0%)
neqo-neqo-cubic-nopacing 19.1 ± 0.1 18.8 – 19.5 19.1 ± 0.1 1674.0 ± 12.8 +0.0 (+0.0%)
neqo-neqo-newreno 19.2 ± 0.1 18.9 – 19.5 19.2 ± 0.1 1666.7 ± 10.4 +0.1 (+0.3%)
neqo-neqo-newreno-nopacing 19.3 ± 0.1 19.0 – 19.6 19.3 ± 0.1 1658.9 ± 10.9 +0.1 (+0.3%)
neqo-quiche-cubic 33.1 ± 0.3 32.4 – 33.8 33.1 ± 0.3 967.9 ± 8.7 +0.1 (+0.4%)
neqo-s2n-cubic 38.2 ± 0.2 37.8 – 38.9 38.2 ± 0.2 838.2 ± 4.4 +0.0 (+0.1%)
quiche-neqo-cubic 38.0 ± 0.5 37.1 – 40.1 37.9 ± 0.3 841.3 ± 12.1 +0.1 (+0.2%)
quiche-quiche 39.2 ± 0.2 38.7 – 39.7 39.1 ± 0.2 817.4 ± 4.6
s2n-neqo-cubic 111.7 ± 0.3 111.1 – 113.6 111.7 ± 0.2 286.5 ± 0.8 +0.0 (+0.0%)
s2n-s2n ⚠️ 165.5 ± 25.6 134.4 – 257.4 158.8 ± 0.8 193.3 ± 29.9

Download data for profiler.firefox.com or download performance comparison data.

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark results

No significant performance differences relative to 28cacc8.

All results
transfer/1-conn/1-100mb-req (aka. Upload)/mtu-1500
       time:   [48.869 ms 48.948 ms 49.059 ms]
       thrpt:  [1.9906 GiB/s 1.9951 GiB/s 1.9983 GiB/s]
Found 3 outliers among 100 measurements (3.00%)
3 (3.00%) high severe
transfer/1-conn/1-100mb-resp (aka. Download)/mtu-1500
       time:   [49.204 ms 49.243 ms 49.283 ms]
       thrpt:  [1.9815 GiB/s 1.9831 GiB/s 1.9847 GiB/s]
transfer/1-conn/1-1b-resp (aka. HPS)/mtu-1500
       time:   [3.0042 ms 3.0053 ms 3.0063 ms]
       thrpt:  [332.63   B/s 332.75   B/s 332.86   B/s]
Found 6 outliers among 100 measurements (6.00%)
5 (5.00%) high mild
1 (1.00%) high severe
transfer/1-conn/10_000-parallel-1b-resp (aka. RPS)/mtu-1500
       time:   [65.841 ms 66.171 ms 66.505 ms]
       thrpt:  [150.36 Kelem/s 151.12 Kelem/s 151.88 Kelem/s]
streams-flow-controlled/walltime/1-streams/each-4194304-bytes
       time:   [10.384 ms 10.387 ms 10.390 ms]
       thrpt:  [384.97 MiB/s 385.08 MiB/s 385.19 MiB/s]
streams-flow-controlled/walltime/10-streams/each-1048576-bytes
       time:   [27.418 ms 27.431 ms 27.443 ms]
       thrpt:  [364.39 MiB/s 364.55 MiB/s 364.72 MiB/s]
streams/walltime/1-streams/each-1000-bytes
       time:   [131.00 µs 131.55 µs 132.26 µs]
       thrpt:  [7.2109 MiB/s 7.2494 MiB/s 7.2801 MiB/s]
Found 2 outliers among 100 measurements (2.00%)
2 (2.00%) high mild
streams/walltime/1000-streams/each-1-bytes
       time:   [3.7477 ms 3.7583 ms 3.7683 ms]
       thrpt:  [259.15 KiB/s 259.84 KiB/s 260.58 KiB/s]
Found 6 outliers among 100 measurements (6.00%)
6 (6.00%) low mild
streams/walltime/1000-streams/each-1000-bytes
       time:   [10.830 ms 10.837 ms 10.844 ms]
       thrpt:  [87.945 MiB/s 88.003 MiB/s 88.059 MiB/s]
Found 1 outliers among 100 measurements (1.00%)
1 (1.00%) high mild
transfer/walltime/pacing-false/same-seed
       time:   [2.0352 ms 2.0365 ms 2.0381 ms]
       thrpt:  [1.9166 GiB/s 1.9181 GiB/s 1.9194 GiB/s]
transfer/walltime/pacing-false/varying-seeds
       time:   [2.0286 ms 2.0296 ms 2.0308 ms]
       thrpt:  [1.9235 GiB/s 1.9247 GiB/s 1.9256 GiB/s]
transfer/walltime/pacing-true/same-seed
       time:   [2.0424 ms 2.0438 ms 2.0457 ms]
       thrpt:  [1.9095 GiB/s 1.9112 GiB/s 1.9126 GiB/s]
transfer/walltime/pacing-true/varying-seeds
       time:   [2.1166 ms 2.1183 ms 2.1203 ms]
       thrpt:  [1.8423 GiB/s 1.8441 GiB/s 1.8455 GiB/s]

Instructions per cycle

Criterion reported no significant timing changes.

All benchmarks
Benchmark IPC before IPC after ΔIPC
streams-flow-controlled/walltime/10-streams/each-1048576-bytes 3.11 3.18 +2.2%
transfer/1-conn/1-100mb-resp (aka. Download)/mtu-1500 3.18 3.21 +1.0%
streams-flow-controlled/walltime/1-streams/each-4194304-bytes 2.98 3.00 +0.7%
transfer/1-conn/1-100mb-req (aka. Upload)/mtu-1500 3.15 3.14 -0.4%
transfer/walltime/pacing-false/same-seed 2.88 2.89 +0.4%
transfer/walltime/pacing-false/varying-seeds 2.88 2.89 +0.3%
transfer/1-conn/10_000-parallel-1b-resp (aka. RPS)/mtu-1500 3.21 3.20 -0.3%
streams/walltime/1-streams/each-1000-bytes 2.54 2.55 +0.3%
transfer/walltime/pacing-true/same-seed 2.89 2.88 -0.3%
transfer/1-conn/1-1b-resp (aka. HPS)/mtu-1500 2.24 2.24 +0.1%
streams/walltime/1000-streams/each-1-bytes 3.27 3.27 -0.1%
streams/walltime/1000-streams/each-1000-bytes 3.07 3.07 +0.1%
transfer/walltime/pacing-true/varying-seeds 2.89 2.89 -0.1%
Profiles for profiler.firefox.com (62)

Download data for profiler.firefox.com or download performance comparison data.

@jesup

jesup commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

Landed as part of #4003 (squash-merged in 9d7e35b).

@jesup jesup closed this Sep 29, 2026
@jesup
jesup deleted the users/jesup/datagram_engine branch September 30, 2026 03:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants