Skip to content

feat(quic)!: hierarchical send groups, one per subscription - #5711

Merged
kixelated merged 36 commits into
mainfrom
quest/m1/quic/scheduler
Oct 17, 2026
Merged

kixelated merged 36 commits into
mainfrom
quest/m1/quic/scheduler

Conversation

@kixelated

@kixelated kixelated commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #699. Completes quest/m1/quic/scheduler.md, which this PR deletes.

What

One QUIC connection now carries the MoQ scheduling hierarchy instead of packing it into a scalar:

  • moq-quic replaces quinn's single pending-stream heap with send groups (streams/schedule.rs).
    • Streams outside every group go first, in quinn's order. That covers control and HTTP/3 streams.
    • Groups sit in a scheduling domain; there is one default domain. Within a domain, the highest backlogged priority is served strictly.
    • Groups at equal priority share bytes by deficit round robin. They are charged in wire bytes, with a 1200 byte quantum.
    • Domains share bytes the same way, so opening more groups earns a domain nothing.
    • A stream's priority widens to i64: the priority within the stream's send group, or session-wide when ungrouped.
    • Debt carries over when a group goes idle; unused credit does not. A blocked stream does not hold a turn.
    • Retransmits re-queue through the same schedule.
  • moq-tokio / moq-uring drivers wrap a group in a shared SendGroup handle that closes on drop. Their streams gain set_group.
  • moq-net transport traits gain send groups. The lite and IETF publishers open one group per subscription at the subscriber priority, and one per standalone FETCH; a joining FETCH shares its subscription's group.
    • Group streams join it ordered by sequence in the subscription's group order, and a group's first stream goes ahead of the rest of that group.
    • SUBSCRIBE_UPDATE moves the group once rather than reranking every stream.
    • Transports without groups (browsers, iroh, qmux, ffi and wasm adapters) keep the session-wide PriorityQueue exactly as before.

Results

cargo bench -p moq-net --bench schedule compares the groups against the PriorityQueue and two scalar packings on a congested link. The shares, lag and queue work are deterministic. The timings were taken at load 60 to 150 on a shared host, so read them only as a relative comparison.

workload scheduler equal-priority Jain queue work notes
alice-bob (#699) groups 1.000 0 audio p5 preempts; both p4 subscriptions get 7.5 KB/tick
queue 0.500 584k reranks the deeper backlog takes the whole p4 share
scalar [p][seq] 0.500 0 sequences compete across subscriptions
scalar [p] 0.644 0 shares by stream count, loses newest first (lag 698)
wrap (seq crosses 2^24) groups 1.000 0
queue 0.800 349k
scalar [p][seq] 1.000 0 wrap inverts order: lag 1401 groups
sparse (ms-clock seqs) groups 1.000 0
scalar [p][seq] 0.814 0 lag 1451
crowd (64 subs, mixed cadence) groups 1.000 0
queue 0.341 244k
scalar [p][seq] 0.202 0

The groups beat the queue on every workload: alice-bob took 8.5 ms against 186 ms, wrap 24 against 181 ms, sparse 61 against 376 ms, and crowd 12.7 ms against 76 ms. The crowd figures come from a re-run at load 23; the first run's crowd timing was taken at the load peak and was noise. The fanout sweep times one frame and one join, push and leave across subscriptions in {1, 16, 256, 4096} and in-flight streams per subscription in {1, 16, 256}. Each case stays between 20 and 700 ns with no slope against the table size. The benchmark is picked up by bench/run.sh in nightly.

Public API

Breaking, in every case:

  • moq-net transport::poll:
    • Session::send_group(&mut self, i64) -> Option<…::Group> is required, not defaulted, like protocol.
    • SendStream::Group and SendStream::set_group(&Self::Group) are added.
    • SendStream::set_priority takes i64 instead of i32. It is the only stream ordering setter: the priority within the stream's send group, or session-wide when ungrouped.
    • New SendGroup trait (Clone, set_priority(i64)) and Ungrouped, the uninhabited group for flat transports.
    • Group and stream priorities are both i64, so neither side converts.
    • Boxable also requires Group: MaybeSend + MaybeSync.
  • moq-quic:
    • Connection::send_groups() -> SendGroups<'_> with open, close, set_priority, priority, open_domain and close_domain. Group priorities are i64.
    • SendGroup and SendDomain are owned, non-Clone tokens tagged with their connection, so another connection panics on them.
    • SendStream::set_group(Option<&SendGroup>) is added, and SendStream::{set_priority, priority} use i64.
  • moq-tokio:
    • quic::driver::SendGroup (set_priority/priority in i64), Connection::send_group(i64), and quic::web::Session::send_group(i64) are added.
    • Driver and web SendStream::set_group are added, and their set_priority and priority use i64.
    • transport::Session<S> now accepts only this crate's backends: native QUIC/WebTransport (real groups), iroh and qmux (flat). Its bound is a sealed trait pair, Grouped and GroupedSend, which is not public API (see decision 3), so a custom web_transport_trait::Session no longer fits. No crate in the repo wraps one. Custom transports implement moq-net's transport::poll traits directly, which already carry send groups, and Drop web-transport-trait deletes these adapters.
  • moq-uring: mirrors moq-tokio. It adds quic::SendGroup, Connection::send_group, web::Session::send_group, and stream set_group and set_priority(i64). Its transport::Session<S> adapter uses the same sealed pair, so it accepts only the crate's native QUIC session.

Wire: none. Priorities stay local scheduling state; no message, field or draft changes. That is why just test interop --all was not run.

Decisions

  1. ✅ Streams outside every group go strictly first. Control, HTTP/3 and WebTransport header streams can never queue behind media; everything ungrouped is ours.
  2. ✅ Domains stay in moq-quic only until Scope track priority wires the broadcast domains. The drivers and moq-net open every group in the default domain.
  3. ✅ One ordering setter, no public extension traits (changed in review).
    • set_priority(i64) is the single name everywhere: the moq-net trait, both drivers and the adapters. It means the priority within the stream's send group, or session-wide when ungrouped. set_order is gone.
    • Grouped and GroupedSend are not public API. pub(crate) was tried and is refused with E0446: the public adapters' poll::SendStream impl says type Group = S::Group, which projects through GroupedSend, and an associated type may not leak a crate-private trait. So each pair is pub inside a private module (moq_tokio::transport::grouped, moq_uring::transport::adapter) and never re-exported. Other crates can neither name nor implement them, which is stronger than #[doc(hidden)], and nothing outside either crate needs them.
  4. ✅ In-group order is 2 * seq (negated for ascending), plus 1 for a stream that starts at object 0. This keeps the joining FETCH prefix ahead of the live rest of its group.
  5. ✅ PriorityQueue stays as the flat fallback for browsers, iroh, qmux, ffi and wasm.
  6. ✅ One PR. moq-quic plus the drivers has no consumer without the moq-net half.

Follow-ups

  • Schedule datagrams by subscription priority: add a datagram queue to each send group, charged against the group's credit.
  • Scope track priority: open a SendDomain per broadcast scope.
  • qmux on moq-quic: run streams/saturated.rs through its record writer once it adopts the scheduler.
  • Drop web-transport-trait, added in this PR: every backend implements moq-net's transport::poll traits, and the generic web_transport_trait adapters with the sealed Grouped/GroupedSend pair are deleted.
  • Re-run the schedule bench on an idle host for absolute timings, since the shared host never got below load 23.

Checks

  • just check origin/main on the merged head is green: clippy, docs, the moq-net no-default-features build, and 7369 workspace tests. cargo clippy --all-targets --all-features -D warnings covers the schedule bench.
  • New tests:
    • moq-quic schedule and saturated: byte fairness, preemption, per-group order, priority updates, wrap and sparse sequences, blocked streams, retransmits not starved, domains, reset cleanup, leaving a group.
    • lite and IETF grouped_streams_rank_within_their_subscription.
    • moq-tokio send_defers_a_group_across_an_inflight_write.
    • moq-quic send_group_from_another_connection and send_domain_from_another_connection.
    • moq-quic reliable_reset_sends_its_prefix_then_frees_its_group and stop_sending_drops_a_reliable_prefix_from_the_schedule: a RESET_STREAM_AT stream (feat(quic)!: reliable reset from a send-stream checkpoint #5690) stays in its group only for its Reliable Size prefix.

🤖 Generated with Claude Code

(Written by Opus 5.5)

kixelated and others added 21 commits October 15, 2026 19:35
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…batim

Unmodified copies, not yet compiled, so a reviewer can diff the adaptation
that follows against upstream:

- quinn-rs/quinn 7616e6b2782722f3ce4a1b181ef08817d7f545e4 quinn/src ->
  rs/moq-tokio/src/quic/driver (lib.rs -> mod.rs), the commit moq-quic
  forked from.
- moq-dev/noq e27c0ce00741d81c8620b98e345d4f2c88e0768c web-transport-moq/src ->
  rs/moq-tokio/src/quic/web (lib.rs -> mod.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop every moq-noq dependency. moq-tokio's QUIC backend now drives moq-quic
through quinn's async layer (`quic::driver`) with web-transport-moq folded in
as `quic::web`; moq-uring's sans-IO driver moves from moq-noq-proto to
moq-quic. Upstream noq stays only behind moq-tokio's `iroh` feature.

- The `noq` cargo feature is `quic` everywhere it was forwarded.
- `moq_tokio::noq::Error` is `moq_tokio::quic::Error`; `Error::Noq` is
  `Error::Quic`.
- moq-quic takes `moq_clock::Instant` and raises a once-per-connection
  Draining endpoint event, which backs the driver's `wait_all_draining`.
- qlog writes one trace per endpoint (per dial on moq-uring's connect side),
  since moq-quic holds one sink per transport config.
- Session stats report the congestion controller's bandwidth estimate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The capsule reader maps a lost stream to UnexpectedEnd, so every ordinary
session close warned "failed to read capsule". A read error while the
connection is closed is now debug; one on a live connection still warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Relay peak RSS on the fanout workloads matches moq-noq on origin/main, which
was the last open part of the Goal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Carry #5638's steering changes onto the renamed moq-quic drivers: the
shard CID comes from moq_sock::shard::cid, and moq-uring stops greasing
the QUIC bit on sharded endpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Endpoint::close now refuses attempts still queued for accept, and
wait_all_draining waits for accepts partway through Incoming::accept, so
a server shutdown cannot release the socket before those peers hear back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace quinn's single pending-stream heap with a hierarchical schedule:

- Streams outside every group go first, in quinn's order (priority, then
  round robin), so control and HTTP/3 streams are never queued behind media.
- Groups belong to a scheduling domain (one default). Inside a domain the
  highest backlogged priority is served strictly, and equal priorities
  share bytes by deficit round robin, charged in wire bytes. Domains share
  bytes the same way, so opening more groups buys a domain nothing.
- A stream's priority widens to i64 and orders it only within its group.
  Debt carries across a group going idle; unused credit does not, and a
  blocked stream holds no turn.

Connection::send_groups opens, moves and closes groups and domains as
owned tokens. moq-tokio's and moq-uring's drivers wrap a group in a shared
SendGroup handle that closes on drop, and their streams gain set_group.

Retransmits re-queue through the same schedule, so a lost range keeps its
group's share. Tests drive the real send path with differently sized
streams: byte fairness within a bounded window, preemption, per-group
order, priority updates, sparse sequences, blocked streams, retransmits,
domains, resets and leaving a group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The poll transport gains send groups:

- Session::send_group(priority) opens one, or returns None on a transport
  without them.
- SendStream::Group names the transport's group (Ungrouped, uninhabited,
  for flat transports), SendStream::set_group joins one, and
  SendStream::set_priority widens to i64.
- SendGroup::set_priority moves a whole group.

The lite and IETF publishers open one group per subscription (and per
standalone FETCH; a joining FETCH shares its subscription's) at the
subscriber priority. Group streams join it ranked by sequence in the
subscription's group order, with a group's first stream ahead of the
rest of it. SUBSCRIBE_UPDATE moves the group once instead of reranking
every stream, and an order flip re-stamps the open streams. On a
transport without groups the session-wide PriorityQueue still ranks
every stream, as before.

moq-tokio's and moq-uring's adapters reach backend groups through new
Grouped and GroupedSend extension traits: native QUIC and WebTransport
open real groups; iroh, qmux, ffi and wasm stay flat. The moq-tokio
adapter defers a join made while a write or FIN watch holds the stream,
like a priority change.

A benchmark compares the groups with the PriorityQueue and two scalar
packings on congested audio and video workloads, and sweeps
subscriptions against in-flight streams.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hierarchical send groups landed in moq-quic and every subscription uses one, so drop the quest and the links that waited on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server socket retried every failed recv, so a persistent error spun
the endpoint task while tokio kept readiness asserted. Only WouldBlock
retries now, matching the driver's tokio runtime.

Also cap moq_quic at info like noq was, fix the driver doc example, and
point the moq-uring README at the just recipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the async publishers name it, so a model-only build flagged it as dead code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Status: draft, ready for maintainer review once #5664 lands.

  • Landed:
    • moq-quic send groups (strict by priority, byte-fair DRR at equal priority, and a domain tier).
    • Shared SendGroup handles in the moq-tokio and moq-uring drivers.
    • moq-net transport send groups, with one group per subscription and standalone FETCH in the lite and IETF publishers.
    • The PriorityQueue kept as the fallback on flat transports.
  • Checks: just check against the merged base is green (7282 workspace tests plus per-crate suites, clippy, docs, model-only build). Interop was not run, because nothing changes on the wire.
  • Bench: on equal-priority subscriptions the groups score Jain 1.000, against 0.34 to 0.80 for the queue and 0.20 to 0.81 for the scalar packings. The queue does 244k to 584k reranks per run; the groups do none.
  • Decisions: six are in the description. My recommendations: keep ungrouped streams first, keep domains private until track-priority-scope, keep the Grouped/GroupedSend names, accept the one-bit start tie-break, keep the queue fallback, and land as one PR.

(Written by Opus 5.5)

Base automatically changed from quest/m1/quic/fork/switch to main October 16, 2026 16:04
kixelated and others added 2 commits October 16, 2026 09:19
Drop GroupedSend::set_order: the adapters use a single set_priority(i64),
documented everywhere as the priority within the stream's send group, or
session-wide when ungrouped.

Grouped and GroupedSend leave the public API. pub(crate) is refused
(E0446) because the public adapters' `type Group` projects through
GroupedSend, so both are `pub` in a private module and never re-exported:
other crates can neither name nor implement them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	rs/moq-quic/README.md
#	rs/moq-tokio/src/quic/driver/connection.rs
#	rs/moq-tokio/src/quic/driver/mod.rs
#	rs/moq-tokio/src/quic/driver/send_stream.rs
#	rs/moq-tokio/src/quic/web/send.rs
#	rs/moq-tokio/src/quic/web/session.rs
#	rs/moq-uring/src/quic/driver/stream.rs
#	rs/moq-uring/src/quic/mod.rs
@kixelated

Copy link
Copy Markdown
Collaborator Author

Iterated on review:

  • Decisions 1, 2, 4, 5 and 6 are recorded as settled in the description.
  • Decision 3: set_order is gone. set_priority(i64) is the one ordering setter everywhere, documented as the priority within the stream's send group, or session-wide when ungrouped.
  • Grouped and GroupedSend are out of the public API. pub(crate) is refused with E0446, because the public adapters' type Group = S::Group projects through GroupedSend. Each pair is now pub inside a private module and never re-exported, so other crates can neither name nor implement them.
  • Merged main after feat(quic)!: run moq-tokio and moq-uring on moq-quic #5664 squashed. git diff origin/main...HEAD shows only this PR's changes, and just check origin/main is green.

(Written by Opus 5.5)

# Conflicts:
#	quest/m1/quic/scheduler.md
#	quest/m2/quic-deadline.md
@kixelated

Copy link
Copy Markdown
Collaborator Author

TS validation failure on 674746b, checked against main:

head runs failures
this branch 19 1
main (eb73457, then d29e6ee) 18 0

The last 10 of each ran interleaved, branch then main, so both saw the same load. The single local failure has the #5565 signature exactly: current group evicted ... track="0.avc3" group=1 error=Hang(Moq(Stream(Old))), 6 ms after the max_delay: 0 video subscribe. That reset comes from the publisher's delivery budget once the next group exists, not from send order, so the in-group ordering here cannot cause it. One failure in 19 is within the flake's rate and not distinguishable from main's 0 in 18. #5713 fixes it at the exporter.

(Written by Opus 5.5)

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 76296c742a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

kixelated and others added 2 commits October 16, 2026 14:40
Custom transports implement moq-net's transport::poll traits, which
already carry send groups. The generic web_transport_trait adapters,
whose bound #5711 sealed to the crates' own backends, are deleted along
with the sealed Grouped/GroupedSend pair. quic-qmux's port now targets
moq-net's traits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in reliable reset (#5690) and per-core sharding (#5688).

A reset stream with a Reliable Size now stays in its send group's
schedule for just the data below that size, and leaves the schedule as
soon as nothing is left to send: on reset, on STOP_SENDING abandoning the
prefix, and on removal once the reset is acknowledged. The schedule still
holds only streams with something to send, so the write loop and
can_send_stream_data need no reset special case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

(Written by Opus 5.5)

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 1031168d2c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

(Written by Opus 5.5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c16b117fc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread doc/concept/moq-lite.md Outdated
Browser WebTransport from Wasm has no send groups, and Lite datagrams
bypass the stream scheduler, so neither gets the byte-fair split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

(Written by Opus 5.5)

Datagram priority will move datagrams into the send-group hierarchy;
its quest now owns updating the Priority row when that lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

(Written by Opus 5.5)

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: ddeb5232ff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kixelated

Copy link
Copy Markdown
Collaborator Author

Heads-up: #5781 (moq-quic characterization tests) adds exact-order scheduling goldens in rs/moq-quic/src/tests/characterize/schedule.rs that pin today's order. When this PR rebases onto it, update each golden in this diff so the ordering changes are reviewable, and consider adding a golden for a priority change partway through a stream.

(Written by Claude Opus 5.5)

@kixelated

Copy link
Copy Markdown
Collaborator Author

Ready to land at ddeb5232ff.

  • moq-quic gets a hierarchical send scheduler. Ungrouped streams go first, then send groups strict by priority, with byte DRR at equal priority, inside DRR-shared domains. Group and stream priorities are both i64.
  • moq-tokio and moq-uring hand each subscription its own send group through moq-net's transport::poll traits. The web_transport_trait adapters are sealed to this crate's backends, and Drop web-transport-trait removes that layer.
  • Composes with reliable reset (feat(quic)!: reliable reset from a send-stream checkpoint #5690): a RESET_STREAM_AT stream stays in its group only for its Reliable Size prefix, and tests cover both the reset and STOP_SENDING paths.
  • doc/concept/moq-lite.md scopes the equal-priority split to native stream data. The datagram-priority quest owns extending it to datagrams.

Checks:

  • CI is green, and Codex found no issues on the head.
  • The platform dispatch (run 38983031021) failed only on two known flakes: macOS moq-tokio quic::driver::tests::zero_rtt, and Windows moq-relay::drills relay_killed_mid_group_aborts_then_resumes.
  • Fail-fast stopped Windows after hang, moq-auth and moq-relay. The merge queue reruns the full macOS and Windows suites.

(Written by Opus 5.5)

@kixelated

Copy link
Copy Markdown
Collaborator Author

moq-quic now has Criterion benchmarks (#5812, landing shortly): stream send/priority, receive/reset/stop, endpoint routing, and wire codec cases, each swept over two axes. Once it merges, please rebase and run just bench <the #5812 merge commit> on an idle machine pinned to one core type, and post the moq-quic deltas here. On a shared or mixed-core host, identical builds swing about 5% on small cases and 20-130% on large-working-set cases (see #5812's noise table), so only changes well outside that band count.

(Written by Claude Opus 5.5)

Merged via the queue into main with commit 0d7f812 Oct 17, 2026
14 of 16 checks passed
@kixelated
kixelated deleted the quest/m1/quic/scheduler branch October 17, 2026 01:01
kixelated added a commit that referenced this pull request Oct 17, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated added a commit that referenced this pull request Oct 17, 2026
main carries #5711 squashed, so the merge takes main's changes relative to
the scheduler tip this branch already merged (ddeb523).
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.

Better prioritize ties

1 participant