RFC 9957

The DOCSIS Queue Protection Algorithm to Preserve Low Latency

๐Ÿ“… published May 2026, from ietf

The DOCSIS Queue Protection Algorithm to Preserve Low Latency ๐Ÿ’–

Okay bestie, here's the tea ๐Ÿต. This is RFC 9957, and it's an Independent Submission (not from an IETF stream) in the Informational category ๐Ÿฆ‹. The ISSN is 2070-1721, and it's dated May 2026. The authors are B. Briscoe, Ed. (Independent) and G. White (CableLabs) ๐Ÿซถ. The header doesn't list anything it obsoletes or updates, so it's not replacing or changing any other RFC.

Abstract โœจ

Okay so this doc explains the queue protection algorithm that got added to DOCSIS at version 3.1 ๐ŸŽ€. A shared low-latency queue only works if every flow using it behaves in a non-queue-building way, but some flows might not, whether by accident or on purpose ๐Ÿ’€. When a queue is about to go over a threshold of delay, the Queue Protection algorithm can quickly spot the flows most likely to be causing it. Then it protects the other traffic in that low-latency queue by ejecting some packets (or all of them) from those flows ๐Ÿ”ฅ.

This document is written for four audiences:

  • a) congestion control designers who want to know how to stay on the "good" side of the algorithm ๐Ÿ’…
  • b) implementers who want to understand it in more depth ๐Ÿงš
  • c) designers of algorithms with similar goals, maybe for non-DOCSIS scenarios ๐ŸŒˆ
  • d) researchers who want to evaluate the algorithm ๐Ÿ“

Status of This Memo ๐ŸŒธ

This document is not an Internet Standards Track specification. It's published for informational purposes only ๐Ÿฆ‹.

It's a contribution to the RFC Series, independent of any other RFC stream. The RFC Editor chose to publish it at their discretion and makes no statement about its value for implementation or deployment. Documents the RFC Editor approves for publication are not candidates for any level of Internet Standard, see Section 2 of RFC 7841 โœจ.

For info about the current status, any errata, and how to give feedback, check https://www.rfc-editor.org/info/rfc9957 ๐Ÿ’œ.

Copyright (c) 2026 IETF Trust and the persons identified as the document authors. All rights reserved ๐Ÿ’–.

This document is subject to BCP 78 and the IETF Trust's Legal Provisions Relating to IETF Documents (https://trustee.ietf.org/license-info) as in effect on the date of publication. Please read these carefully, because they describe your rights and restrictions with respect to this document ๐Ÿ™ˆ.

Table of Contents ๐ŸŒˆ

1.  Introduction
  1.1.  Document Roadmap
  1.2.  Terminology
  1.3.  Source Material
2.  Approach (In Brief)
  2.1.  Mechanism
  2.2.  Policy
    2.2.1.  Policy Conditions
    2.2.2.  Policy Action
3.  Necessary Flow Behaviour
4.  Pseudocode Walk-Through
  4.1.  Input Parameters, Constants, and Variables
  4.2.  QProt Data Path
    4.2.1.  The qprotect() Function
    4.2.2.  The pick_bucket() Function
    4.2.3.  The fill_bucket() Function
    4.2.4.  The calcProbNative() Function
5.  Rationale
  5.1.  Rationale: Blame for Queuing, Not for Rate in Itself
  5.2.  Rationale: Constant Aging of the Queuing Score
  5.3.  Rationale: Transformed Queuing Score
  5.4.  Rationale: Policy Conditions
  5.5.  Rationale: Reclassification as the Policy Action
6.  Limitations
7.  IANA Considerations
8.  Security Considerations
  8.1.  Resource Exhaustion Attacks
    8.1.1.  Exhausting Flow State Storage
    8.1.2.  Exhausting Processing Resources
9.  Comments Solicited
10. References
  10.1.  Normative References
  10.2.  Informative References
Acknowledgements
Authors' Addresses

1. Introduction ๐Ÿฆ‹

Okay so this Informational RFC explains the queue protection (QProt) algorithm that got introduced into DOCSIS technology at version 3.1 [DOCSIS] ๐Ÿ’….

The algorithm is defined in Annex P of [DOCSIS], but it leans on cross references to other parts of the set of specifications. This document pulls all those strands together into one self-contained document ๐Ÿงถ. The core is a pseudocode walk-through that's similar to the one in the DOCSIS specification, but it also includes extra stuff:

  • i. a brief overview โœจ
  • ii. a definition of how a data sender needs to behave to avoid triggering queue protection ๐Ÿซถ
  • iii. a section giving the rationale for the design choices ๐Ÿง 

Low queuing delay depends on hosts sending their data smoothly. They can either send at a low rate or respond to Explicit Congestion Notification (ECN), see [RFC8311] and [RFC9331] ๐Ÿ’–. So low-latency queuing is something hosts create for themselves, not something the network hands them. This mostly means self-interest alone doesn't push flows to mismark their packets for the low-latency queue ๐Ÿ™Œ. But traffic from an application that doesn't behave in a non-queue-building way might get classified into a low-latency queue by mistake, or on purpose ๐Ÿ’€. QProt protects the other traffic in that low-latency queue from the harm of excess queuing that such anomalous behaviour would otherwise cause ๐Ÿ›ก๏ธ.

In normal scenarios without misclassified traffic, QProt isn't expected to step in at all with the classification or forwarding of packets ๐ŸŒธ.

An overview of how low-latency support was added to DOCSIS is given in [LLD] ๐Ÿ“š. In each direction of a DOCSIS link (upstream and downstream), there are two queues: one for Low-Latency (LL) traffic and one for Classic traffic. This is similar to the IETF's Dual-Queue Coupled Active Queue Management (AQM) [RFC9332] โœจ. The two queues make it possible to move from "Classic" to "Scalable" congestion control, so low latency can become the norm for any application. That includes apps seeking both full throughput and low latency, not just the low-rate apps that have traditionally been linked with a low-latency service ๐Ÿš€. The Classic queue is only needed for traffic like traditional TCP (Reno [RFC5681] / Cubic [RFC9438]) that needs about a round trip of buffering to fully use the link. So this traffic has no incentive to mismark itself as low latency ๐Ÿ™…โ€โ™€๏ธ.

The QProt function sits at the ingress to the Low-Latency queue. So in the upstream, QProt is on the cable modem (CM), and in the downstream, it's on the CM Termination System (CMTS) ๐Ÿ“. If an arriving packet triggers queue protection, the QProt algorithm ejects that packet from the Low-Latency queue and reclassifies it into the Classic queue ๐Ÿฆ‹.

If QProt is used in settings other than DOCSIS links, it'd be simple to detect queue-building flows by using slightly different conditions, and/or to trigger a different action as a result, depending on the scenario. For example, it could drop packets instead of reclassifying them, or perhaps accumulate a second per-flow score to decide whether to redirect a whole flow rather than just certain packets ๐ŸŒˆ. That's future study and out of scope for this document ๐Ÿ”ญ.

The QProt algorithm is based on a rigorous approach to quantifying how much each flow contributes to congestion. Economists use this kind of idea to assign responsibility for the cost of one party's behaviour on others, which is called the economic externality ๐Ÿงฎ. Another important feature is that the metric used for the queuing score is based on the same variable that decides the level of ECN signalling the sender sees (see [RFC8311] and [RFC9331]). This makes the internal queuing score visible from the outside, as ECN markings ๐Ÿ‘€. That transparency is what lets us objectively state (in Section 3) how a flow can keep on the "good" side of the algorithm ๐ŸŽ€.

1.1. Document Roadmap ๐Ÿ—บ๏ธ

The core of the document is the walk-through of the DOCSIS QProt algorithm's pseudocode in Section 4 ๐Ÿ’–.

Before that, Section 2 summarises the approach the algorithm uses. Then, Section 3 looks at QProt from the end-system's point of view, by defining the behaviour a flow needs to follow to avoid having the QProt algorithm eject its packets from the low-latency queue ๐Ÿงš.

Section 5 gives deeper insight into the principles and rationale behind the algorithm ๐Ÿง . Then Section 6 explains the limitations of the approach. The usual closing sections follow ๐ŸŒ™.

1.2. Terminology ๐Ÿ“–

The normative language for the DOCSIS QProt algorithm lives in the DOCSIS specifications [DOCSIS], [DOCSIS-CM-OSS], and [DOCSIS-CCAP-OSS], not in this Informational RFC. If there's any inconsistency, the DOCSIS specifications win ๐Ÿ‘‘.

The following terms and abbreviations are used:

  • CM: Cable Modem ๐Ÿ“ก
  • CMTS: CM Termination System ๐Ÿข
  • Congestion-rate: The transmission rate, counting only the bits or bytes inside packets of a flow that have the Congestion Experienced (CE) codepoint set in the IP-ECN field [RFC3168] (including IP headers unless specified otherwise). Congestion-bit-rate and congestion-volume were introduced in [RFC7713] and [RFC6789] ๐Ÿ“Š.
  • DOCSIS: Data-Over-Cable Service Interface Specification. "DOCSIS" is a registered trademark of Cable Television Laboratories, Inc. ("CableLabs") โ„ข๏ธ.
  • QProt: The DOCSIS Queue Protection function ๐Ÿ›ก๏ธ.
  • non-queue-building: A flow that tends not to build a queue ๐ŸŒฟ.
Note the lowercase "non-queue-building", which describes the behaviour of a flow. That's different from the uppercase Non-Queue-Building (NQB), which refers to a Diffserv marking [RFC9956] ๐Ÿ™ˆ. A flow that identifies itself as uppercase NQB might not actually behave in a non-queue-building way, and catching that is exactly what the QProt algorithm is meant to do ๐Ÿ•ต๏ธโ€โ™€๏ธ.
  • queue-building: A flow that builds a queue. If it's classified into the Low-Latency queue, it's therefore a candidate for the QProt algorithm to detect and sanction ๐Ÿšจ.
  • ECN: Explicit Congestion Notification [RFC3168] ๐Ÿšฆ.
  • ECN marking or ECN signalling: Setting the IP-ECN field of an increasing proportion of packets to the Congestion Experienced (CE) codepoint [RFC3168] as queuing gets worse ๐Ÿšฉ.

1.3. Source Material ๐Ÿ™

Parts of this document are reproduced from [DOCSIS] with kind permission of Cable Television Laboratories, Inc. ("CableLabs") ๐Ÿ’œ.

2. Approach (In Brief) ๐Ÿงฉ

The QProt algorithm is split into mechanism and policy. There's only a tiny amount of policy code, but policy might need changing in the future. So where hardware implementation is being considered, it'd be advisable to put the policy aspects in firmware or software ๐Ÿ’ป.

  • The mechanism aspects identify flows, maintain flow state, and accumulate per-flow queuing scores โš™๏ธ.
  • The policy aspects can be divided into conditions and actions ๐ŸŽฏ:
    • The conditions are the logic that decides when action should be taken to avert the risk of queuing delay becoming excessive โฐ.
    • The actions decide how that risk gets averted, e.g., by redirecting packets from a flow into another queue, or by reclassifying a whole flow that seems to be misclassified ๐Ÿ”.

2.1. Mechanism โœจ

Okay so the algorithm keeps per-flow state, and "flow" usually means an end-to-end (Layer 4) 5-tuple ๐Ÿฆ‹. The flow state is basically a queuing score that decays over time. The fun twist is that the score gets transformed into time units, so it literally is the flow state's own expiry time (more on that in Section 5.3). A higher queuing score pushes that expiry further out ๐Ÿ’….

Non-queue-building flows tend to release their flow state super fast. It usually expires pretty early in the gap between packets of a normal flow ๐ŸŒธ. Then that memory can get recycled for packets from other flows that show up in between. So only queue-building flows hold onto flow state for real ๐Ÿ‘‘.

The whole thing is simple and effective because of how the queuing score is defined ๐Ÿ”ฅ. The score represents the share of blame for queuing that each flow bears. It uses the same internal variable, probNative, that the AQM for the low-latency queue uses to ECN-mark packets. (The other two forms of marking, Classic and coupled, are driven by Classic traffic, so they're not relevant to protecting the LL queue. ๐Ÿ™ˆ)

So the score adds up the size of each arriving packet, but scaled by the value of probNative (somewhere in the range 0 to 1) at the exact instant that packet arrives. That means a flow's score builds up faster:

  • the higher the degree of queuing, and
  • the faster the flow's packets arrive when there is queuing.

Section 5.1 explains further why this score represents blame for queuing ๐Ÿงš.

Here's the vibe: the algorithm builds up a queuing score that would rise at the so-called congestion-rate of the flow (see Section 1.2). That's the rate at which the flow is contributing to congestion, or the rate at which the AQM is forwarding ECN-marked bytes of that flow. But the score doesn't just grow forever. It also gets reduced (or "aged") at a constant rate ๐Ÿฆ‹. This is because capacity-seeking flows can't avoid inducing a continuous low level of congestion while they track available capacity. Section 5.2 explains why this allowance can be set to the same constant for any scalable flow, whatever its bit rate. So the queuing score rises or falls depending on the difference between the flow's congestion-rate and the allowed aging rate ๐Ÿ“ˆ.

For implementation efficiency, the queuing score gets transformed into time units, and then it represents the expiry time of the flow state (as already covered above). After that, it doesn't need to be explicitly aged, because the natural passage of time implicitly "ages" an expiry time โณ. The transformation is just dividing the queuing score of each packet by the constant aging rate (explained further in Section 5.3). Math, but make it cute ๐ŸŽ€.

2.2. Policy

2.2.1. Policy Conditions

The algorithm only uses the queuing score to decide whether to eject each packet, and it does that only at the moment the packet first arrives ๐Ÿ“. That limits the policies available. For example, when queueing delay goes over a threshold, it's not feasible to eject a packet from the flow with the highest queuing score, because that would mean searching the queue for such a packet (if one were even still in there ๐Ÿ’€). Still, it's possible to build a policy that protects the low latency of the queue by making the queuing score threshold stricter the greater the excess of queuing delay relative to the threshold. This is explained in Section 5.4 ๐Ÿ’œ.

2.2.2. Policy Action

At the time of writing, the DOCSIS QProt specification says that when the policy conditions are met, the action taken to protect the low-latency queue is to reclassify a packet into the Classic queue. This is justified in Section 5.5 โœจ.

3. Necessary Flow Behaviour

The QProt algorithm described here works for responsive and/or unresponsive flows, so it's pretty flexible ๐ŸŒˆ.

  • It's possible to objectively describe the least responsive way that a flow needs to respond to congestion signals in order to avoid triggering queue protection, no matter the link capacity and no matter how much other traffic there is. Iconic ๐Ÿ‘‘.
  • It's not possible to describe how fast or smooth an unresponsive flow should be to avoid queue protection, because that depends on how much other traffic there is and the capacity of the link, which an application can't know ๐Ÿ˜ญ. However, the more smoothly an unresponsive flow paces its packets and the lower its rate is relative to typical broadband link capacities, the less likely it is to cause enough queueing to trigger queue protection ๐Ÿซถ.

Responsive low-latency flows can use a Low Latency, Low Loss, and Scalable throughput (L4S) ECN codepoint [RFC9331] to get classified into the low-latency queue ๐Ÿฆ‹.

A sender can also arrange for flows that are smooth but don't respond to ECN marking to get classified into the low-latency queue by using the Non-Queue-Building (NQB) Diffserv codepoint [RFC9956]. The DOCSIS specifications support this. Or an operator can use various other local classifiers ๐Ÿ’….

As already explained in Section 2.1, the QProt algorithm is driven by the same variable that drives the ECN-marking probability in the Low-Latency or "LL" queue. (The "Native" AQM of the LL queue is defined in the Immediate Active Queue Management Annex of [DOCSIS].) The algorithm that calculates this internal variable runs on the arrival of every LL packet, whether or not it's ECN-capable, so that the QProt algorithm can use it. But the variable only gets used to ECN-mark packets that are ECN-capable ๐Ÿงš.

This dual use is honestly so smart ๐Ÿ”ฅ. It improves processing efficiency, and it also makes the basis of the QProt algorithm visible and transparent, at least for responsive ECN-capable flows. That means it's possible to state objectively that a flow can avoid triggering queue protection by keeping the bit rate of ECN-marked packets (the congestion-rate) below AGING. That's a configured constant of the algorithm, with a default of 2^19 B/s โ‰ˆ 4 Mb/s ๐ŸŒธ. Also, it's in a congestion controller's own interest to keep its average congestion-rate well below this level (e.g., ~1 Mb/s) so it doesn't trigger queue protection during transient dynamics. Protect your vibes, protect your queue ๐Ÿซถ.

If the QProt algorithm gets used in other settings, it would still need to be based on the visible level of congestion signalling, in a similar way to the DOCSIS approach. Without transparency about the basis of the algorithm's decisions, end-systems wouldn't be able to avoid triggering queue protection on an objective basis. Not me having to guess ๐Ÿ™ˆ.

4. Pseudocode Walk-Through โœจ

4.1. Input Parameters, Constants, and Variables ๐Ÿ’–

okay so the operator input parameters that set the values in the first two pseudocode blocks below are defined for cable modems (CMs) in [DOCSIS-CM-OSS] and for CMTSs in [DOCSIS-CCAP-OSS] ๐ŸŽ€ then more constants are either derived from those inputs or just hard-coded, so they're not up to anyone ๐Ÿงš

Defaults and units are in square brackets. bestie, defaults (or honestly any part of the QProt algorithm) can change, so the latest DOCSIS specs are the source of truth ๐Ÿ“œ and any operator might set certain parameters to non-default values, so don't assume you'll see the defaults ๐Ÿ’…

// Input Parameters
MAX_RATE;          // Configured maximum sustained rate [b/s]
QPROTECT_ON;       // Queue Protection is enabled [Default: TRUE]
CRITICALqL_us;     // LL queue threshold delay [ยตs] Default: MAXTH_us
CRITICALqLSCORE_us;// The threshold queuing score [Default: 4000 ยตs]
LG_AGING;          // The aging rate of the q'ing score [Default: 19]
                   //  as log base 2 of the congestion-rate [lg(B/s)]

// Input Parameters for the calcProbNative() algorithm:
MAXTH_us;          // Max LL AQM marking threshold [Default: 1000 ยตs]
LG_RANGE;          // Log base 2 of the range of ramp [lg(ns)]
                   //  Default: 2^19 = 524288 ns (roughly 525 ยตs)
// Constants, either derived from input parameters or hard-coded
T_RES;                                    // Resolution of t_exp [ns]
                                          // Convert units (approx)
AGING = pow(2, (LG_AGING-30) ) * T_RES;   // lg([B/s]) to [B/T_RES]
CRITICALqL = CRITICALqL_us * 1000;        // [ยตs] to [ns]
CRITICALqLSCORE = CRITICALqLSCORE_us * 1000/T_RES; // [ยตs] to [T_RES]
// Threshold for the q'ing score condition
CRITICALqLPRODUCT = CRITICALqL * CRITICALqLSCORE;
qLSCORE_MAX = 5E9 / T_RES;           // Max queuing score = 5 s

ATTEMPTS = 2; // Max attempts to pick a bucket (vendor-specific)
BI_SIZE = 5;  // Bit-width of index number for non-default buckets
NBUCKETS = pow(2, BI_SIZE);  // No. of non-default buckets
MASK = NBUCKETS-1;     // convenient constant, with BI_SIZE LSBs set

                       // Queue Protection exit states
EXIT_SUCCESS  = 0;     // Forward the packet
EXIT_SANCTION = 1;     // Redirect the packet

MAX_PROB      = 1; // For integer arithmetic, would use a large int
                   //  e.g., 2^31, to allow space for overflow
MAXTH = MAXTH_us * 1000;   // Max marking threshold [ns]
MAX_FRAME_SIZE = 2000;  // DOCSIS-wide constant [B]
// Minimum marking threshold of 2 MTU for slow links [ns]
FLOOR =  2 * 8 * MAX_FRAME_SIZE * 10^9 / MAX_RATE;
RANGE = (1 << LG_RANGE);      // Range of ramp [ns]
MINTH = max ( MAXTH - RANGE, FLOOR);
MAXTH = MINTH + RANGE;           // Max marking threshold [ns]

Almost everything in the pseudocode is an integer ๐Ÿ”ข The only exceptions are the floating-point variables that hold probabilities (MAX_PROB and probNative), plus the AGING parameter. The real DOCSIS QProt algorithm uses integer arithmetic, but the floating-point arithmetic used in this document means 0 <= probNative <= 1. Also, the pseudocode skips overflow checking, and it would need to be made robust for non-default input parameters โš 

T_RES needs to be picked carefully โฐ so that expiry times for buckets can represent times that are a fraction (e.g., 1/10) of the expected packet interarrival time for the system.

The following definitions explain what the important variables and functions are for โœจ

// Public variables:
qdelay;        // The current queuing delay of the LL queue [ns]
probNative;    // Native marking probability of LL queue within [0,1]

// External variables
packet;            // The structure holding packet header fields
packet.size;       // The size of the current packet [B]
packet.uflow;      // The flow identifier of the current packet
                   //  (e.g., 5-tuple or 4-tuple if IPsec)

// Irrelevant details of DOCSIS function to return qdelay are removed
qdelayL(...)      // Returns current delay of the low-latency Q [ns]

The pseudocode for how the algorithm sorts packets into flows (to fill in packet.uflow) isn't given in detail here ๐Ÿ•ต๏ธโ€โ™€๏ธ The application's flow ID is usually a common 5-tuple (or 4-tuple) of:

  • source and destination IP addresses of the innermost IP header found;
  • the protocol (IPv4) or next header (IPv6) field in this IP header
  • either of:
    • source and destination port numbers, for TCP, UDP, UDP-Lite, Stream Control Transmission Protocol (SCTP), Datagram Congestion Control Protocol (DCCP), etc.
    • Security Parameter Index (SPI) for IPsec Encapsulating Security Payload (ESP) [RFC4303].

The Microflow Classification section of the Queue Protection Annex of the DOCSIS specification [DOCSIS] has the different strategies for finding these headers by skipping extension headers or encapsulations ๐Ÿงฉ If they can't be found, the spec has various less-specific 3-tuples to fall back on. The DOCSIS specification is where you should look for all of these strategies, since they won't be repeated here.

The array of bucket structures defined below is used by all the QProt functions ๐Ÿชฃ

struct bucket { // The leaky bucket structure to hold per-flow state
   id;          // identifier (e.g., 5-tuple) of flow using bucket
   t_exp;       // expiry time in units of T_RES
                // (t_exp - now) = flow's transformed q'ing score
};
struct bucket buckets[NBUCKETS+1];

4.2. QProt Data Path ๐Ÿ›ค๏ธ

All the functions of QProt work on the data path, and they're all driven by packet arrivals ๐Ÿš€

The following functions, which maintain per-flow queuing scores and manage per-flow state, are considered primarily to be mechanism:

    pick_bucket(uflow_id);              // Returns bucket identifier
    fill_bucket(bucket_id, pkt_size, probNative); /* Returns queuing
                                                   * score */
    calcProbNative(qdelay); /* Returns ECN-marking probability of the
                             * Native LL AQM */

The following function is primarily concerned with policy ๐Ÿ“‹

    qprotect(packet, ...); /* Returns exit status to either forward
                            * or redirect the packet */

('...' suppresses distracting detail.)

Policy changes are more likely to happen in the future than mechanism changes ๐Ÿ‘€ which is why policy parts would be less suitable candidates for any hardware acceleration ๐Ÿ”ง

The entry point to these functions is qprotect(). It gets called after packet classification and AQM (including ECN marking), but before each packet is enqueued into the right queue, queue_id, like this ๐ŸŽฏ

classifier(packet) {
   // Determine which queue using ECN, DSCP, and any local-use fields
   queue_id = classify(packet);
   //  LQ & CQ are macros for valid queue IDs returned by classify()
   if (queue_id == LQ) {
      // if packet classified to Low-Latency Service Flow
      aqm_mark(packet); // apply ECN marking, as needed based on AQM
      if (QPROTECT_ON) {
         if (qprotect(packet, ...) == EXIT_SANCTION) {
            // redirect packet to Classic Service Flow
            queue_id = CQ;
         }
      }
   return queue_id;
}

4.2.1. The qprotect() Function ๐Ÿ”

On each packet arrival at the Low-Latency (LL) queue, qprotect() measures the current delay of the LL queue and works out the Native LL marking probability from it ๐Ÿ“ Then it uses pick_bucket to find the bucket already holding the flow's state, or to grab a new bucket if the flow is new or its state has expired (the most likely case) ๐Ÿชฃ Next, fill_bucket() updates the queuing score. And that wraps up the mechanism side of things ๐Ÿ’œ

The comments on the policy conditions and actions that follow should be pretty self-explanatory at a surface level ๐Ÿ™ˆ The deeper reasoning behind these conditions is in Section 5.4.

// Per-packet Queue Protection
qprotect(packet, ...) {

   bckt_id;   // bucket index
   qLscore;   // queuing score of pkt's flow in units of T_RES

   qdelay = qL.qdelay(...);
   probNative = calcProbNative(qdelay);

   bckt_id = pick_bucket(packet.uflow);
   qLscore = fill_bucket(buckets[bckt_id], packet.size, probNative);

   // Determine whether to sanction packet
   if ( ( ( qdelay > CRITICALqL ) // Test if qdelay over threshold...
      // ...and if flow's q'ing score scaled by qdelay/CRITICALqL
      // ...exceeds CRITICALqLSCORE
      && ( qdelay * qLscore > CRITICALqLPRODUCT ) )
      // or qLSCORE_MAX reached
      || ( qLscore >= qLSCORE_MAX ) )

      return EXIT_SANCTION;

   else
      return EXIT_SUCCESS;
}

4.2.2. The pick_bucket() Function โœจ

okay bestie, pick_bucket() is built for flow state that will usually have expired by the time the next packet of the same flow shows up ๐Ÿฆ‹. It's just one way of finding the bucket tied to each packet's flow ID, so someone could totally come up with something more efficient ๐Ÿ’….

The algorithm is set up so that the bucket holding any live (non-expired) flow state for a packet always gets found before a new bucket is allocated ๐Ÿ‘‘. The constant ATTEMPTS, defined earlier, decides how many hashes get used to find a bucket for each flow. (Actually, only ONE hash gets generated! Then, by default, 5 bits of it at a time are used as the hash value, because by default there are 2^5 = 32 buckets ๐Ÿงš.)

The algorithm stores the flow's own ID in its flow state ๐Ÿ’œ. So when a packet of a flow arrives, it tries up to ATTEMPTS times to hash to a bucket, looking for the flow's own ID. If it finds it, it uses that bucket and first resets the expiry time to "now" if it's expired โœจ.

If it doesn't find the flow's ID and the expiry time is still current, the algorithm knows another flow is using that bucket, so it keeps looking ๐Ÿ‘€. Even if it finds another flow's bucket whose expiry time has passed, it doesn't grab it right away ๐Ÿ™ˆ. It just remembers it as the potential bucket to use. But first it runs through all the ATTEMPTS hashes to look for a bucket assigned to the flow ID. Then, if no live bucket is already associated with the packet's flow, the algorithm should have already set aside an existing bucket with a score that has aged out. Since that bucket doesn't need to hold state for its previous flow anymore, it can be recycled for the present packet's flow ๐ŸŒธ.

If all else fails, there's one extra bucket (called the dregs) that can be used ๐Ÿ“. If the dregs is still in live use by another flow, later flows that can't find a bucket of their own all share it, adding their score to the one in the dregs. A flow might get away with using the dregs on its own, but when there are lots of mismarked flows, multiple flows are more likely to collide in the dregs, including innocent flows ๐Ÿ˜ญ. The number of buckets and the number of hash attempts decide how likely this not-so-fun scenario is to happen ๐Ÿ’€.

<CODE BEGINS>

// Pick the bucket associated with flow uflw
pick_bucket(uflw) {

   now;                      // current time
   j;                        // loop counter
   h32;                      // holds hash of the packet's flow IDs
   h;                        // bucket index being checked
   hsav;                     // interim chosen bucket index

   h32   = hash32(uflw);     // 32-bit hash of flow ID
   hsav  = NBUCKETS;         // Default bucket
   now   = get_time_now();   // in units of T_RES

   // The for loop checks ATTEMPTS buckets for ownership by flow ID
   // It also records the 1st bucket, if any, that could be recycled
   // because it's expired.
   // Must not recycle a bucket until all ownership checks completed
   for (j=0; j<ATTEMPTS; j++) {
      // Use least signif. BI_SIZE bits of hash for each attempt
      h = h32 & MASK;
      if (buckets[h].id == uflw) {    // Once uflw's bucket found...
         if (buckets[h].t_exp <= now) // ...if bucket has expired...
            buckets[h].t_exp = now;   // ...reset it
         return h;                    // Either way, use it
      }
      else if ( (hsav == NBUCKETS)  // If not seen expired bucket yet
                                    //  and this bucket has expired
           && (buckets[h].t_exp <= now) ) {
         hsav = h;                  // set it as the interim bucket
      }
      h32 >>= BI_SIZE;          // Bit-shift hash for next attempt
   }
   // If reached here, no tested bucket was owned by the flow ID
   if (hsav != NBUCKETS) {
      // If here, found an expired bucket within the above for loop
      buckets[hsav].t_exp = now;              // Reset expired bucket
   } else {
      // If here, we're having to use the default bucket (the dregs)
      if (buckets[hsav].t_exp <= now) {   // If dregs has expired...
         buckets[hsav].t_exp = now;       // ...reset it
      }
   }
   buckets[hsav].id = uflw; // In either case, claim for recycling
   return hsav;
}

<CODE ENDS>


4.2.3. The fill_bucket() Function ๐ŸŽ€

The fill_bucket() function both accumulates and ages the queuing score over time, as outlined in Section 2.1 โฐ. To make aging the score efficient, the increment of the queuing score gets transformed into units of time by dividing by AGING, so the result represents the new expiry time of the flow.

Given that probNative is already used to select which packets to ECN-mark, you might think the queuing score could just be incremented by the full size of each selected packet, instead of by the product of every packet's size (pkt_sz) and probNative. However, the unpublished experience of one of the authors with other congestion policers has found that the score then increments far too jumpily, particularly when probNative is low ๐ŸŽข.

A deeper explanation of the queuing score is given in Section 5 ๐Ÿ”.

<CODE BEGINS>

fill_bucket(bckt_id, pkt_sz, probNative) {
   now;                                       // current time
   now = get_time_now();                      // in units of T_RES
   // Add packet's queuing score
   // For integer arithmetic, a bit-shift can replace the division
   qLscore = min(buckets[bckt_id].t_exp - now
                 + probNative * pkt_sz / AGING, qLSCORE_MAX);
   buckets[bckt_id].t_exp = now + qLscore;
   return qLscore;
}

<CODE ENDS>


4.2.4. The calcProbNative() Function ๐ŸŒˆ

To derive this queuing score, QProt uses the linear ramp function calcProbNative() to normalize the instantaneous queuing delay of the LL queue into a probability in the range [0,1], which it assigns to probNative ๐Ÿ“ˆ.

<CODE BEGINS>

calcProbNative(qdelay){
      if ( qdelay >= MAXTH ) {
         probNative = MAX_PROB;
      } else if ( qdelay > MINTH ) {
         probNative = MAX_PROB * (qdelay - MINTH)/RANGE;
         // In practice, the * and the / would use a bit-shift
      } else {
         probNative = 0;
      }
      return probNative;
}

<CODE ENDS>

5. Rationale โœจ

5.1. Rationale: Blame for Queuing, Not for Rate in Itself ๐ŸŽ€

Okay bestie, Figure 1 shows the bit rates of two flows as stacked areas ๐Ÿ“Š, and it asks a juicy question: who is more to blame for queuing delay? Is it the unresponsive constant bit rate flow (c) eating about 80% of the capacity, or the flow sending regular short unresponsive bursts (b)? ๐Ÿค” Flow c is super smooth, which seems like it should help with avoiding queuing, but that high rate is still the problem. ngl, though, if flow c weren't there, or ran slightly more slowly, b wouldn't cause any queuing at all ๐Ÿ’€

   ^ bit rate (stacked areas)
   |  ,-.          ,-.          ,-.          ,-.          ,-.
   |--|b|----------|b|----------|b|----------|b|----------|b|---Capacity
   |__|_|__________|_|__________|_|__________|_|__________|_|_____
   |
   |                       c
   |
   |
   |
   +---------------------------------------------------------------->
                                                                 time

Figure 1: Which is more to blame for queuing delay?

To explain queuing scores, we'll start by pretending that QProt is accumulating queuing scores but not doing anything with them yet ๐Ÿงš

To measure how much responsibility each flow has for queuing delay, QProt adds up the product of each flow's rate and the level of congestion, both measured at the instant each packet arrives ๐Ÿ’œ. A flow's instantaneous rate is represented, at each discrete event when a packet arrives, by that packet's size, so the score builds faster the more packets arrive in each unit of time. The congestion level gets squished into a dimensionless number between 0 and 1 (probNative) โœจ. Why not just use queuing delay directly? Two reasons, bestie:

  • to be able to ignore very low levels of queuing that contribute insignificantly to delay
  • to be able to erect a steep barrier against excessive queuing delay

The unit of the resulting queue score is "congested-bytes" per second, which is how we tell it apart from plain bytes per second ๐Ÿ“

Then, during the gaps between bursts (b), neither flow builds up any queuing score, because c's high rate is benign in those moments ๐Ÿซถ. But during each burst, say c and b are 80% and 45% of capacity, which causes 25% overload. They each bear (80/125)% and (45/125)% of the responsibility for the queuing delay, which works out to 64% and 36%. The algorithm doesn't explicitly calculate those percentages! They just fall out of how many packets arrive from each flow during the burst ๐Ÿ‘‘

To summarize, the queuing score never sanctions rate just because it's rate. It only sanctions rate inasmuch as it causes queuing ๐Ÿ’…

   ^ bit rate (stacked areas)                               ,
   |               ,-.                       |\           ,-
   |------Capacity-|b|----------,-.----------|b|----------|b\-----
   |             __|_|_______   |b|        /``\| _...-._-': | ,.--
   |  ,-.     __/            \__|_|_     _/    |/          \|/
   |  |b| ___/                      \___/   __       r
   |  |_|/                v             \__/  \_______    _/\____/
   | _/                                               \__/
   |
   +---------------------------------------------------------------->
                                                                 time

Figure 2: Responsibility for Queuing: A More Complex Scenario

Figure 2 gives a more complex illustration of the way the queuing score assigns responsibility for queuing (limited to the precision that ASCII art can illustrate) ๐ŸŽจ. It shows the bit rates of three flows as stacked areas labelled b, v, and r. The unresponsive bursts (b) are the same as in the previous example, but a variable-rate video (v) replaces flow c. Its rate varies as the complexity of the video scene varies. Also, on a slower timescale, in response to the level of congestion, the video adapts its quality. However, on a short timescale it appears to be unresponsive to small amounts of queuing. Partway through, a low-latency responsive flow (r) joins in, aiming to fill the balance of capacity left by the other two ๐ŸŒˆ

The combination of the first burst and the low application-limited rate of the video causes neither flow to accumulate queuing score. In contrast, the second burst causes similar excessive overload (125%) to the example in Figure 1 ๐Ÿ˜ญ. Then the video happens to reduce its rate (probably due to a less-complex scene), so the third burst causes only a little congestion. Let us assume the resulting queue causes probNative to rise to just 1%. Then the queuing score will only accumulate 1% of the size of each packet of flows v and b during this burst ๐Ÿ™ˆ

The fourth burst happens to arrive just as the new responsive flow (r) has filled the available capacity, so it leads to very rapid growth of the queue ๐Ÿ”ฅ. After a round trip, the responsive flow rapidly backs off, and the adaptive video also backs off more rapidly than it would normally because of the very high congestion level. The rapid response to congestion of flow r reduces the queuing score that all three flows accumulate, but they each still bear the cost in proportion to the product of the rates at which their packets arrive at the queue and the value of probNative when they do so. Thus, during the fifth burst, they all accumulate a lower score than during the fourth, because the queuing delay is not as excessive ๐Ÿฆ‹

5.2. Rationale: Constant Aging of the Queuing Score ๐Ÿงผ

Even well-behaved flows won't always be able to respond fast enough to dynamic events ๐Ÿ™ƒ. Also, well-behaved flows, e.g., Data Center TCP (DCTCP) [RFC8257], TCP Prague [PRAGUE-CC], Bottleneck Bandwidth and Round-trip propagation time version 3 (BBRv3) [BBRv3], or the L4S variant of SCReAM [SCReAM] for real-time media [RFC8298], can maintain a very shallow queue ๐ŸŒŠ. They do it by continually probing carefully for more while also continually subtracting a little from their rate (or congestion window) in response to low levels of ECN signalling. Therefore, the QProt algorithm needs to continually offer a degree of forgiveness to age out the queuing score as it accumulates ๐Ÿซง

Scalable congestion controllers, such as those above, maintain their congestion window in inverse proportion to the congestion level, probNative. That leads to the important property that, on average, a scalable flow holds the product of its congestion window and the congestion level constant, no matter the capacity of the link or how many other flows it competes with ๐Ÿง . For instance, if the link capacity doubles, a scalable flow induces half the congestion probability. Or, if three scalable flows compete for the capacity, each flow will reduce to one third of the capacity it would use on its own and increase the congestion level by 3x. Therefore, in steady state, a scalable flow will induce the same constant amount of "congested-bytes" per round trip, whatever the link capacity and no matter how many flows are sharing the capacity ๐Ÿ’–

This suggests that the QProt algorithm will not sanction a well-behaved scalable flow if it ages out the queuing score at a sufficient constant rate. The constant will need to be somewhat above the average of a well-behaved scalable flow, to allow for normal dynamics ๐ŸŽฏ

Relating QProt's aging constant to a scalable flow does not mean that a flow has to behave like a scalable flow: it can be less aggressive, but not more aggressive. For instance, a longer RTT flow can run at a lower congestion-rate than the aging rate, but it can also increase its aggressiveness to equal the rate of short RTT scalable flows [ScalingCC] ๐Ÿƒโ€โ™€๏ธ. The constant aging of QProt also means that a long-running unresponsive flow will be prone to trigger QProt if it runs faster than a competing responsive scalable flow would. And, of course, if a flow causes excessive queuing in the short term, its queuing score will still rise faster than the constant aging process will decrease it. Then QProt will still eject the flow's packets before they harm the low latency of the shared queue ๐Ÿšซ

5.3. Rationale: Transformed Queuing Score ๐Ÿชฃ

The QProt algorithm holds a flow's queuing score state in a structure called a "bucket". This is because of its similarity to a regular leaky bucket (except the contents of the bucket do not represent bytes) ๐Ÿผ

   probNative * pkt_sz   probNative * pkt_sz / AGING
             |                        |
          |  V  |                  |  V  |
          |  :  |        ___       |  :  |
          |_____|        ___       |_____|
          |     |        ___       |     |
          |__ __|                  |__ __|
             |                        |
             V                        V
        AGING * Dt                    Dt

Figure 3: Transformation of Queuing Score

The accumulation and aging of the queuing score is shown on the left of Figure 3, in token bucket form. Dt is the difference between the times when the scores of the current and previous packets were processed โฑ๏ธ. A transformed equivalent of this token bucket is shown on the right of Figure 3, dividing both the input and output by the constant rate, AGING. The result is a bucket-depth that represents time, and it drains at the rate that time passes ๐ŸŒธ

As a further optimization, the time the bucket was last updated is not stored with the flow state. Instead, when the bucket is initialized, the queuing score is added to the system time "now" and the resulting expiry time is written into the bucket. Subsequently, if the bucket has not expired, the incremental queuing score is added to the time already held in the bucket. Then the queuing score always represents the expiry time of the flow state itself. This means that the queuing score does not need to be aged explicitly, because it ages itself implicitly โœจ๐Ÿงš

5.4. Rationale: Policy Conditions โœจ

okay bestie, the pseudocode for the QProt policy conditions lives in Section 4.1, inside the second half of the qprotect() function. ๐Ÿ’… Every time a packet shows up, the algorithm finds its flow state and bumps that flow's queuing score. Then it checks whether the shared queue delay is over the constant threshold CRITICALqL (e.g., 2 ms). Here's the snippet again so you can see it ๐Ÿ‘€

   if (  ( qdelay > CRITICALqL )  // Test if qdelay over threshold...
      // ...and if flow's q'ing score scaled by qdelay/CRITICALqL
      // ...exceeds CRITICALqLSCORE
      && ( qdelay * qLscore > CRITICALqLPRODUCT ) )
      // Recall that CRITICALqLPRODUCT = CRITICALqL * CRITICALqLSCORE

If the queue delay is over the threshold, the flow's queuing score gets temporarily scaled up by the ratio of the current queue delay to CRITICALqL. (I'll explain why in a sec ๐Ÿ™ˆ) If that scaled-up score is still higher than another constant threshold, CRITICALqLSCORE, the packet gets ejected. The actual last line of code multiplies both sides of the second condition by CRITICALqL, which dodges an expensive division. Smart, right? ๐Ÿง โœจ

This approach lets every packet get assessed once, right when it arrives. Once the queue delay goes over the threshold, two things can happen:

  • The current packet might get ejected, even though there are already packets sitting in the queue from flows with higher queuing scores. But any flow that keeps adding to the queue has to keep sending packets, so there's a chance to eject those too as they come in. ๐Ÿฆ‹
  • The next packets might not get ejected, because they could belong to flows with low queuing scores. In that case the queue delay could keep climbing with no chance to eject anything. This is exactly why the queuing score gets scaled up by the current queue delay. The longer the queue grows without an ejection, the more the algorithm "raises the bar" for the packets after it. Main character energy for the bar ๐Ÿ’…

This approach is preferred over the extra per-packet processing cost of searching the buckets for the flow with the highest queuing score, and then searching the queue for one of that flow's packets to eject (if one is still in the queue). Nobody wants that much extra work per packet, periodt ๐Ÿ™Œ

Figure 4 explains all of this visually. The horizontal axis shows actual harm, meaning the queuing delay in the shared queue. The vertical axis shows the behaviour record of the flow that the arriving packet belongs to, which the algorithm represents with that flow's queuing score. The shaded region is the combo of actual harm and behaviour record that leads to the packet being ejected. ๐ŸŽ€

      |  Note that, by default, CRITICALqL_us is set to the maximum
      |  threshold of the ramp marking algorithm MAXTH_us.  However,
      |  there is some debate as to whether setting it to the minimum
      |  threshold instead would improve QProt performance.  This would
      |  roughly double the ratio of qdelay to CRITICALqL, which is
      |  compared against the CRITICALqLSCORE threshold.  Therefore, the
      |  threshold would have to be roughly doubled accordingly.

   Behaviour Record:
   Queueing Score of
   Arriving Packet's Flow
   ^
   |   +          |/ / / / / / / / / / / / / / / / / / /
   |    +   N     | / / / / / / / / / / / / / / / / / / /
   |     +        |/ / / / /                   / / / / /
   |      +       | / / / /  E (Eject packet)   / / / / /
   |       +      |/ / / / /                   / / / / /
   |         +    | / / / / / / / / / / / / / / / / / / /
   |           +  |/ / / / / / / / / / / / / / / / / / /
   |             +| / / / / / / / / / / / / / / / / / / /
   |              |+ / / / / / / / / / / / / / / / / / /
   |    N         |   + / / / / / / / / / / / / / / / / /
   | (No actual   |       +/ / / / / / / / / / / / / / /
   |   harm)      |            +  / / / / / / / / / / / /
   |              | P (Pass over)   +   ,/ / / / / / / /
   |              |                           ^ + /./ /_/
   +--------------+------------------------------------------>
             CRITICALqL        Actual Harm: Shared Queue Delay

          Figure 4: Graphical Explanation of the Policy Conditions

The regions labelled "N" are the no-actual-harm cases. The queue delay is still under the critical threshold CRITICALqL, so the first condition isn't met. ๐Ÿ˜Œ

The region labelled "E" is where there's actual harm (queue delay is over CRITICALqL) and the arriving packet's queuing score is high enough to eject it with certainty. Bye bye, packet ๐Ÿ‘‹

The region labelled "P" is where there's actual harm too, but the arriving packet's queuing score isn't high enough to eject it, so it has to be passed over. Yes, that adds to the queuing delay, but the alternative is risking sanctioning an innocent flow, and nobody wants that drama ๐Ÿ˜ญ Also notice that as actual harm goes up, the judgement of innocence gets stricter. The next packet's flow doesn't need to look as bad to get ejected. ๐Ÿ“

Conditioning ejection on actual harm helps stop VPN packets from being ejected for no reason. ngl this is a whole thing ๐Ÿ” VPNs made of multiple flows can pile up queuing score faster than it gets aged out, because the aging rate is designed for a single flow. But whether or not some traffic is in a VPN, the queue delay threshold (CRITICALqL) is no more likely to be exceeded. So conditioning ejection on actual harm helps reduce the chance that VPN traffic gets ejected by the QProt function. Protecting the VPN girlies ๐Ÿซถ

5.5. Rationale: Reclassification as the Policy Action ๐Ÿฆ‹

When the DOCSIS QProt algorithm decides it needs to eject a packet to protect the Low-Latency queue, it redirects the packet to the Classic queue. In the Low-Latency DOCSIS architecture (same as Dual-Queue Coupled AQMs in general), the Classic queue is expected to often have a bigger backlog. That's because classic congestion controllers interact with a Classic AQM (which has a latency target of 10 ms), plus there's other bursty traffic hanging out in there too. ๐ŸŒŠ

So, typically, an ejected packet is going to see higher queuing delay than it otherwise would. It could also get re-ordered within its flow (assuming QProt doesn't eject all the packets of an anomalous flow). That mild harm to the ejected packet's flow is on purpose! It gives senders a slight incentive to identify their packets correctly. Accountability era ๐Ÿ’…

If there were no such harm, nothing would stop every flow from claiming it's suitable for the low-latency queue and just letting QProt sort the whole pile into queue-building and non-queue-building flows. That might sound like a useful alternative to making senders identify their flows correctly. But handling misclassified flows isn't free. The more packets that have to be reclassified, the more often the low-latency queue's delay would go over the threshold. Also, more memory would be needed to hold the extra flow state. Not it ๐Ÿ™…โ€โ™€๏ธ

When a packet gets redirected into the Classic queue, an operator might want to change the identifier(s) that originally got it classified into the Low-Latency queue, so it won't get classified into another low-latency queue further downstream. Here's the catch though: redirecting the occasional packet can be caused by unusually high transient load right at that specific bottleneck. It's not necessarily happening at any other bottleneck, and it's not necessarily because of bad flow behaviour. So Section 5.4.1.2 of [RFC9331] precludes a network node from altering the end-to-end ECN field to exclude traffic from L4S treatment. Instead, a local-use identifier ought to be used (e.g., Diffserv codepoint or VLAN tag), so each operator can apply its own policy without prejudging what other operators ought to do. Everyone gets their own rules, no judging ๐Ÿซถ

Although the DOCSIS specifications don't support this, QProt could be extended to notice that large numbers of redirected packets belong to the same flow. This might be detected when the bucket expiry time t_exp goes over a threshold. Depending on policy and implementation capabilities, QProt could then install a classifier to redirect the whole flow into the Classic queue, with an idle timeout to remove stale classifiers. In these "persistent offender" cases, QProt might also overwrite each redirected packet's DSCP, to protect other potential low-latency queues downstream. The DOCSIS specifications don't discuss sanctioning whole flows, and further discussion is beyond the scope of this document. Plot twist saved for later ๐ŸŽฌ

6. Limitations ๐Ÿ”

The QProt algorithm groups packets with common Layer 4 flow identifiers. It then uses that grouping to accumulate queuing scores and to sanction packets.

Okay, real talk: this choice of identifier for grouping is pragmatic with no scientific basis. ๐Ÿ™ˆ All the packets of a flow definitely pass between the same two endpoints. But some applications might start multiple flows between the same endpoints, e.g., for media, control, data, etc. Others might use common flow identifiers for all of those streams. A user might also group multiple application flows inside the same encrypted VPN between the same Layer 4 tunnel endpoints. And even if there were a one-to-one mapping between flows and applications, there's no reason to believe that the rate at which congestion can be caused should be allocated on a per-application-flow basis. Messy, bestie ๐Ÿ˜ญ

The good news is that using a queuing score which leaves out the parts of flow rate that don't contribute to queuing (Section 5.1) helps soften this limitation. The algorithm doesn't mainly judge who's responsible for queuing delay based on the combined rate of a set of flows grouped under one flow ID. โœจ

7. IANA Considerations ๐ŸŒธ

This document has no IANA actions. Nothing to see here, no registries to touch ๐Ÿ’…

8. Security Considerations ๐Ÿ”

The whole of this document is about traffic security. ๐Ÿ’€ It looks at the security question of how to identify and eject traffic that doesn't follow the non-queue-building behaviour needed to use a shared low-latency queue, whether that happens by accident or on purpose.

Section 8.2 of [RFC9330] of the L4S architecture [RFC9330] introduces the problem of keeping latency low through either self-restraint or enforcement. It also places DOCSIS Queue Protection (QProt) in context within a wider set of approaches to that problem. ๐Ÿง 

โš ๏ธ Seriously, this part matters. Traffic that doesn't behave can break the low-latency experience for everyone sharing the queue, so the enforcement side has to be handled carefully.

8.1. Resource Exhaustion Attacks ๐Ÿงจ

The QProt algorithm has been designed to fail gracefully when faced with traffic crafted to overrun the resources it uses for its own processing and flow state. This means that non-queue-building flows will always be less likely to be sanctioned than queue-building flows. But an attack could be contrived to deplete resources in such a way that the proportion of innocent (non-queue-building) flows that get incorrectly sanctioned could increase. Yikes ๐Ÿ˜ฐ

Incorrect sanctioning is meant to be non-catastrophic. It results in more packets from well-behaved flows being redirected into the Classic queue, which introduces more reordering into innocent flows. Annoying, but not the end of the world ๐Ÿซถ

8.1.1. Exhausting Flow State Storage โœจ

okay so the sneakiest move here is trying to run out of buckets ๐Ÿชฃ. The most efficient attack is sending enough long-running attack flows that a brand-new flow shows up, finds no free bucket, and has to share the "dregs" bucket ๐Ÿ˜ญ. Like, if ATTEMPTS=2 and NBUCKETS=32, the attacker needs about 94 attack flows, each using different port numbers, to hit a 99% chance that an arriving flow has to share the dregs ๐Ÿ’€. And in the dregs it gets a high degree of redirection into the Classic queue, along with all the other attack flows. Not it ๐Ÿ™ˆ

For an attacker to keep buckets busy, it's way more efficient to hold onto them by cycling through a set of port numbers (94 in the above example) than to keep occupying and releasing buckets with single packet flows across a much bigger pool of ports ๐Ÿงš

During an attack like this, the coupled marking probability will have saturated at 100%. So, to hold a bucket, the rate of an attack flow needs to be no less than the aging rate of each bucket: 4 Mb/s by default ๐Ÿ’… However, for an attack flow to be sure it holds on to its bucket, it would need to send somewhat faster. Thus, an attack with 100 flows would need a total force of more than 100 * 4 Mb/s = 400 Mb/s ๐Ÿ˜ญ

This attack can be mitigated (but not prevented) by increasing the number of buckets ๐ŸŒธ The required attack force scales linearly with the number of buckets, NBUCKETS. So if NBUCKETS were doubled to 64, twice as many 4 Mb/s flows would be needed to keep the same impact on innocent flows.

Probably the most effective mitigation would be to implement redirection of whole-flows once enough of the individual packets of a certain offending flow had been redirected ๐Ÿ‘‘ This would free up the buckets used to maintain the per-packet queuing score of persistent offenders. Whole-flow redirection is outside the scope of the current version of the QProt algorithm specified here, but it's briefly discussed at the end of Section 5.5.

It might be considered that all the packets of persistently offending flows ought to be discarded rather than redirected. However, this is not recommended, because attack flows might be able to pervert whole-flow discard, turning it onto at least some innocent flows. That would amplify an attack that causes reordering into total deletion of some innocent flows ๐Ÿ’€ Hard pass.

Whole-flow discard is a trap, bestie. Attackers can bend it onto innocent flows, so it's not the move ๐Ÿšซ

8.1.2. Exhausting Processing Resources ๐ŸŽ€

The processing time needed to apply the QProt algorithm to each Low-Latency (LL) packet is small, and it's intended not to take all the time available between each of a run of fairly small packets โœจ However, an attack could use minimum sized packets launched from multiple input interfaces into a lower capacity output interface. Whether the QProt algorithm is vulnerable to processor exhaustion will depend on the specific implementation ๐Ÿค”

Adding a capability to redirect persistently offending flows from LL to Classic would be the most effective way to reduce the per-packet processing cost of the QProt algorithm when under attack ๐Ÿ”ฅ As already mentioned in Section 8.1.1, this would also be an effective way to mitigate flow-state exhaustion attacks. Further discussion of whole-flow redirection is outside the scope of the present document, but it's briefly discussed at the end of Section 5.5 ๐Ÿฆ‹

9. Comments Solicited ๐Ÿ’œ

Evaluation and assessment of the algorithm by researchers is solicited ๐Ÿง Comments and questions are also encouraged and welcome. They can be addressed to the authors ๐Ÿซถ

10. References ๐ŸŒˆ

10.1. Normative References

[DOCSIS] CableLabs, "MAC and Upper Layer Protocols Interface (MULPI) Specification, CM-SP-MULPIv3.1", Data-Over-Cable Service Interface Specifications DOCSIS(r) 3.1 Version I17 or later, 21 January 2019, <https://www.cablelabs.com/specifications/CM-SP-MULPIv3.1>.

[DOCSIS-CCAP-OSS] CableLabs, "CCAP Operations Support System Interface Specification", Data-Over-Cable Service Interface Specifications DOCSIS(r) 3.1 Version I14 or later, 7 February 2019, <https://www.cablelabs.com/specifications/CM-SP-CCAP-OSSIv3.1>.

[DOCSIS-CM-OSS] CableLabs, "Cable Modem Operations Support System Interface Specification", Data-Over-Cable Service Interface Specifications DOCSIS(r) 3.1 Version I14 or later, 21 January 2019, <https://www.cablelabs.com/specifications/CM-SP-CM-OSSIv3.1>.

[RFC3168] Ramakrishnan, K., Floyd, S., and D. Black, "The Addition of Explicit Congestion Notification (ECN) to IP", RFC 3168, DOI 10.17487/RFC3168, September 2001, <https://www.rfc-editor.org/info/rfc3168>.

[RFC8311] Black, D., "Relaxing Restrictions on Explicit Congestion Notification (ECN) Experimentation", RFC 8311, DOI 10.17487/RFC8311, January 2018, <https://www.rfc-editor.org/info/rfc8311>.

[RFC9331] De Schepper, K. and B. Briscoe, Ed., "The Explicit Congestion Notification (ECN) Protocol for Low Latency, Low Loss, and Scalable Throughput (L4S)", RFC 9331, DOI 10.17487/RFC9331, January 2023, <https://www.rfc-editor.org/info/rfc9331>.

[RFC9956] White, G., Fossati, T., and R. Geib, "A Non-Queue-Building Per-Hop Behavior (NQB PHB) for Differentiated Services", RFC 9956, DOI 10.17487/RFC9956, May 2026, <https://www.rfc-editor.org/info/rfc9956>.

10.2. Informative References

[BBRv3] "TCP BBR v3 Release", commit 90210de, 18 March 2025, <https://github.com/google/bbr/blob/v3/README.md>.

[LLD] White, G., Sundaresan, K., and B. Briscoe, "Low Latency DOCSIS: Technology Overview", CableLabs White Paper, February 2019, <https://cablela.bs/low-latency-docsis-technology-overview-february-2019>.

[PRAGUE-CC] De Schepper, K., Tilmans, O., Briscoe, B., and V. Goel, "Prague Congestion Control", Work in Progress, Internet-Draft, draft-briscoe-iccrg-prague-congestion-control-04, 24 July 2024, <https://datatracker.ietf.org/doc/html/draft-briscoe-iccrg-prague-congestion-control-04>.

[RFC4303] Kent, S., "IP Encapsulating Security Payload (ESP)", RFC 4303, DOI 10.17487/RFC4303, December 2005, <https://www.rfc-editor.org/info/rfc4303>.

[RFC5681] Allman, M., Paxson, V., and E. Blanton, "TCP Congestion Control", RFC 5681, DOI 10.17487/RFC5681, September 2009, <https://www.rfc-editor.org/info/rfc5681>.

[RFC6789] Briscoe, B., Ed., Woundy, R., Ed., and A. Cooper, Ed., "Congestion Exposure (ConEx) Concepts and Use Cases", RFC 6789, DOI 10.17487/RFC6789, December 2012, <https://www.rfc-editor.org/info/rfc6789>.

[RFC7713] Mathis, M. and B. Briscoe, "Congestion Exposure (ConEx) Concepts, Abstract Mechanism, and Requirements", RFC 7713, DOI 10.17487/RFC7713, December 2015, <https://www.rfc-editor.org/info/rfc7713>.

[RFC8257] Bensley, S., Thaler, D., Balasubramanian, P., Eggert, L., and G. Judd, "Data Center TCP (DCTCP): TCP Congestion Control for Data Centers", RFC 8257, DOI 10.17487/RFC8257, October 2017, <https://www.rfc-editor.org/info/rfc8257>.

[RFC8298] Johansson, I. and Z. Sarker, "Self-Clocked Rate Adaptation for Multimedia", RFC 8298, DOI 10.17487/RFC8298, December 2017, <https://www.rfc-editor.org/info/rfc8298>.

[RFC9330] Briscoe, B., Ed., De Schepper, K., Bagnulo, M., and G. White, "Low Latency, Low Loss, and Scalable Throughput (L4S) Internet Service: Architecture", RFC 9330, DOI 10.17487/RFC9330, January 2023, <https://www.rfc-editor.org/info/rfc9330>.

[RFC9332] De Schepper, K., Briscoe, B., Ed., and G. White, "Dual-Queue Coupled Active Queue Management (AQM) for Low Latency, Low Loss, and Scalable Throughput (L4S)", RFC 9332, DOI 10.17487/RFC9332, January 2023, <https://www.rfc-editor.org/info/rfc9332>.

[RFC9438] Xu, L., Ha, S., Rhee, I., Goel, V., and L. Eggert, Ed., "CUBIC for Fast and Long-Distance Networks", RFC 9438, DOI 10.17487/RFC9438, August 2023, <https://www.rfc-editor.org/info/rfc9438>.

[ScalingCC] Briscoe, B. and K. De Schepper, "Resolving Tensions between Congestion Control Scaling Requirements", arXiv:1904.07605, Simula Technical Report TR-CS-2016-001, DOI 10.48550/arXiv.1904.07605, July 2017, <https://arxiv.org/abs/1904.07605>.

[SCReAM] "SCReAM", commit 0208f59, 10 November 2025, <https://github.com/EricssonResearch/scream/blob/master/README.md>.

Acknowledgements ๐Ÿซถ

Thanks to Tom Henderson, Magnus Westerlund, David Black, Adrian Farrel, and Gorry Fairhurst for their reviews of this document ๐Ÿ’– The design of the QProt algorithm and the settings of the parameters benefited from discussion and critique from the participants of the cable industry working group on Low-Latency DOCSIS. CableLabs funded Bob Briscoe's initial work on this document ๐ŸŽ€

Authors' Addresses ๐Ÿฆ‹

Bob Briscoe (editor)
Independent
United Kingdom
Email: ietf@bobbriscoe.net
URI:   `https://bobbriscoe.net/`


Greg White
CableLabs
United States of America
Email: G.White@CableLabs.com
๐Ÿ“ฆ where this page comes from