Skip to content

Snake: CoDel-based load shedding gate for vttablet conn pools - #860

Draft
bgwines wants to merge 76 commits into
slack-22.0from
bwines/snake-22
Draft

Snake: CoDel-based load shedding gate for vttablet conn pools#860
bgwines wants to merge 76 commits into
slack-22.0from
bwines/snake-22

Conversation

@bgwines

@bgwines bgwines commented May 20, 2026

Copy link
Copy Markdown

@github-actions github-actions Bot added this to the v22.0.4 milestone May 20, 2026
@bgwines
bgwines force-pushed the bwines/snake-22 branch from fe4d453 to 8ac9b6d Compare May 21, 2026 17:41
@codecov-commenter

codecov-commenter commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.21519% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.57%. Comparing base (dd873af) to head (8c8dc48).

Files with missing lines Patch % Lines
go/vt/vttablet/tabletserver/loadshed/codelq.go 96.52% 5 Missing ⚠️
go/vt/vttablet/tabletserver/loadshed/snake.go 97.81% 3 Missing ⚠️
...abletserver/loadshed/selfcontentionaware_codelq.go 98.05% 2 Missing ⚠️
go/vt/vttablet/tabletserver/loadshed/request.go 90.90% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff               @@
##           slack-22.0     #860      +/-   ##
==============================================
+ Coverage       69.52%   69.57%   +0.05%     
==============================================
  Files            1606     1610       +4     
  Lines          214357   214752     +395     
==============================================
+ Hits           149021   149413     +392     
- Misses          65336    65339       +3     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@bgwines
bgwines force-pushed the bwines/snake-22 branch 2 times, most recently from 0de8af7 to 8ac9b6d Compare May 21, 2026 21:14
bgwines and others added 3 commits June 10, 2026 18:13
### Background / Why?

VTTablets under high load need a mechanism to shed excess requests rather than letting queue depth grow unbounded. The Python [loadshed-lock](https://github.com/quip/loadshed-lock) system implements this using the CoDel (Controlled Delay) algorithm — the same algorithm used in Linux's `fq_codel` queueing discipline — to detect persistent queue buildup and drop low-priority requests before they accumulate latency that compounds downstream.

This PR ports that system to Go as "Snake" (Project Snakeskin). The core challenge is that Python's asyncio provides implicit single-threaded synchronization, while Go requires explicit synchronization under true parallelism. The design uses a single shared `sync.Mutex` across all layers to mirror Python's single-threaded guarantee, with `chan error` replacing `asyncio.Future` for per-request signaling.

## Architecture

```
Snake (public API — owns sync.Mutex)
│
├── Acquire(ctx, valveID) → *SafeUnlock
│   ├── uncontended: grant immediately, start max-age timer
│   └── contended: enqueue → select { req.result | ctx.Done() }
│
├── SafeUnlock.Release(errs...)
│   ├── verify nonce (sync.Once for idempotency)
│   ├── run release callbacks (mutex NOT held)
│   └── dequeue current holder, signal next waiter
│
├── SelfContentionAwareCoDelQueue (valve layer)
│   │
│   │  N valves (one per valve ID, typically one per HTTP request
│   │  on the app server). Each valve permits at most 1 request
│   │  into the CoDel queue; the rest wait in a per-ID FIFO slice.
│   │
│   │  Owns drop orchestration: lockedRunScheduledDrop encapsulates
│   │  finding the lowest-priority droppable request and triggering
│   │  valve promotion.
│   │
│   │         valve "A"           valve "B"         valve "C"
│   │        ┌─────────┐        ┌─────────┐       ┌─────────┐
│   │        │ A₂  A₃  │        │ B₂      │       │ (empty) │
│   │        └────┬────┘        └────┬────┘       └────┬────┘
│   │             │ promote          │ promote         │
│   │             ▼                  ▼                 ▼
│   └───► CoDelQueue ◄───────────────────────────────────────
│         (single shared instance)
│
├── CoDelQueue (CoDel algorithm)
│   ├── *list.List: [A₁] [B₁] [C₁]   (O(1) head pop + O(1) removal)
│   ├── healthy ↔ dropping state machine
│   ├── control law: interval / count^exponent
│   └── schedules drops via injected callback — no timer ownership
│
└── Timers (owned by Snake, scheduled via callback injection)
    ├── dropTimer: time.AfterFunc → sq.lockedRunScheduledDrop
    └── maxAgeTimer: time.AfterFunc → force-release stale holder
```

## Request lifecycle events

| Event | Mechanism | Result channel touched? | Request leaves CoDel queue? |
|---|---|---|---|
| Grant | `lockedMarkNotDroppable` + `signal(nil)` | Yes — nil | No — holder stays until release |
| Release | `lockedDequeue` | No (already signaled) | Yes — removed on release |
| Drop | `lockedDropActive` → `signal(err)` | Yes — error | Yes — removed immediately |
| Cancel | `lockedCancel` / `lockedRemove` | Yes — error | Yes — removed immediately |

## Differences from the Python implementation

1. **Synchronization**: Python relies on asyncio's single-threaded event loop (no locking). Go uses a single `sync.Mutex` on Snake guarding all state
2. **Request signaling**: Python uses `asyncio.Future`. Go uses buffered `chan error` (cap 1) paired with `signal()` that writes both an inspectable `outcome` field and the channel
3. **Timer ownership**: Both Python and Go inject scheduling into the queue. Python uses `loop.call_later()`, Go uses an injected `func(delayNs int64)` callback. Snake owns the actual `time.AfterFunc` and provides idempotency
4. **Cancel-vs-grant race**: Python has no equivalent — asyncio's cooperative scheduling prevents it. Go's Acquire uses a double-select with holder-check fallback
5. **Context cancellation**: Python cancels via `asyncio.CancelledError`. Go takes `context.Context` in Acquire with explicit `lockedCancel`/`lockedRemove` methods
6. **Release idempotency**: Python uses `__del__` fallback. Go wraps Release in `sync.Once`
7. **Max-age timeout**: Python cancels the holder's asyncio.Task. Go uses `time.AfterFunc` with staleness guard via `maxAgeHolder` pointer comparison
8. **activePerValve map**: Added for O(1) lookup of which request is in CoDel per valve ID (Python scans callbacks)
9. **Release callbacks**: Python awaits async coroutines. Go runs `func(error)` callbacks without mutex held, with `recover()` for panic safety

## Other changes

- **`go/hack/ensure_swiss_map.go` deleted**: This file uses a build tag (`!goexperiment.swissmap`) that fails to compile on Go 1.26+ where swiss maps are always enabled and the experiment tag no longer exists. The base branch (`bwines/snake-22`) still targets Go 1.24 where this isn't an issue, but this branch builds on 1.26. The upstream fix (vitessio#19088) was already backported to upstream release-22.0 but hasn't reached our fork yet — it'll come along when snake-22 merges with slack-22.0.

## Testing

~110 tests across 7 test files:
- **codelq_test.go** (39 tests) — CoDel algorithm unit tests: FIFO ordering, drop priority selection, state machine transitions, control law math, scheduled drop behavior via test recorder
- **selfcontentionaware_codelq_test.go** (24 tests) — Valve logic: direct entry, valving, promotion on dequeue/drop/cancel, outstanding count lifecycle, FIFO within contention, mass cancel under overload, peek cleanup
- **snake_test.go** (28 tests) — Snake behavior: mutual exclusion, FIFO ordering, context cancellation, cancel-vs-grant race, self-contention serialization, CoDel drops, max-age timeout, SafeUnlock idempotency, release callbacks with panic recovery
- **snake_stress_test.go** (16 tests) — High-concurrency stress tests with -race: 200 goroutines, rapid acquire/release, cancel-vs-grant races, drop timer + cancel races, max-age under load, goroutine leak detection, starvation prevention, self-contention stress
- **snake_race_test.go** (1 test) — 50k-iteration targeted reproduction of the cancel-vs-grant race
- **snake_overload_test.go** (12 tests) — Overload recovery, memory steady-state, CoDel state transitions, mass cancel, drop-only-droppable, max-age vs cancel race, no-panic invariant
- **snake_bench_test.go** — Comprehensive benchmarks (see `BENCHMARK_RESULTS.md`)

All tests pass with `-race -count=5`.

---

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)
- elem → codelqElem (disambiguates from other list memberships)
- *float64 → float64 (nil never occurs in practice)
- lockedMarkNotDroppable → lockedOnGrant (event-based naming)
- lockedDropActive → lockedDrop (remove redundant qualifier)
- releaseInternal → releaseOnCancel (clarifies the call site)

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>
### Background / Why?

Snake currently operates as a single-holder mutex (capacity=1). This works as a proof-of-concept but doesn't match the real deployment targets: vttablet's connection pool has dozens of connections, and the SCHED_IDLE dispatch gate needs a concurrency band (`[min, max)`) — not a binary lock. Generalizing to an N-holder semaphore with dynamic capacity is the prerequisite for integrating Snake into any real vttablet admission path.

The CoDel queue and self-contention valve are preserved unchanged. The only structural shift is that up to `Capacity()` concurrent grants are allowed instead of exactly one, and each release triggers exactly one new grant (no thundering herd).

```
                         ┌─────────────────────────────────────────────────────────┐
                         │                         Snake                           │
                         │                                                         │
  Acquire(ctx, valveID)  │  ┌───────────────────────────────────────────────────┐  │
 ─────────────────────►  │  │  SelfContentionAwareCoDelQueue                    │  │
                         │  │                                                   │  │
                         │  │  valve["id-A"] ─► [r5] [r6]    (pending FIFO)     │  │
                         │  │  valve["id-B"] ─► [r7]                            │  │
                         │  │                                                   │  │
                         │  │  ┌─────────────────────────────────────────────┐  │  │
                         │  │  │  CoDelQueue (dropping / healthy)            │  │  │
                         │  │  │                                             │  │  │
                         │  │  │ undroppable undroppable droppable droppable │  │  |
                         │  │  │  ┌────┐      ┌────┐      ┌────┐      ┌────┐ │  │  │
                         │  │  │  │ r1 │      │ r2 │      │ r3 │ ◄fw  │ r4 │ │  │  │
                         │  │  │  └────┘      └────┘      └────┘      └────┘ │  │  │
                         │  │  │  id-A GRANT  id-B GRANT  id-A        id-B   │  │  │
                         │  │  │                                             │  │  │
                         │  │  └─────────────────────────────────────────────┘  │  │
                         │  └───────────────────────────────────────────────────┘  │
                         │                                                         │
                         │  holders: { r1, r2 }      capacity: N (dynamic)         │
                         │                                                         │
                         │  On Release(r1):                                        │
                         │    1. delete(holders, r1)                               │
                         │    2. lockedComplete(r1)  ← sojourn health check        │
                         │    3. lockedTryGrantOne() ← grant r3 (firstWaiting)     │
                         │                                                         │
                         └─────────────────────────────────────────────────────────┘

  fw = firstWaiting pointer (O(1) next-ungrant lookup)
  Capacity() is dynamic — driven by SCHED_IDLE sidecar in production
```

Key design decisions:
- `holders` map replaces single `holder` pointer — O(1) membership check for the ctx.Done() race resolution
- `lockedTryGrantOne` grants exactly one waiter per release event (no thundering herd)
- Fast-path grant only for requests already in the CoDel queue (valve-queued requests wait for promotion)
- `CoDelQueue` gains `firstWaiting` (O(1) pointer) and `lockedComplete` (release-time removal with sojourn-based health transition)
- `SafeUnlock.Release` is idempotent (returns nil on double-release from max-age timer race)
- `nonce`-based auth removed — `holders` map is the authority

### Benchmarks

```
BenchmarkSnake_NHolder_Throughput/Cap1    439 ns/op    416 B/op    7 allocs/op
BenchmarkSnake_NHolder_Throughput/Cap2    831 ns/op    416 B/op    7 allocs/op
BenchmarkSnake_NHolder_Throughput/Cap4    936 ns/op    416 B/op    7 allocs/op
BenchmarkSnake_NHolder_Throughput/Cap8    607 ns/op    416 B/op    7 allocs/op
BenchmarkSnake_NHolder_Throughput/Cap16   666 ns/op    416 B/op    7 allocs/op

BenchmarkSnake_NHolder_Uncontended/Cap1   227 ns/op   416 B/op    7 allocs/op
BenchmarkSnake_NHolder_Uncontended/Cap4   231 ns/op   416 B/op    7 allocs/op
BenchmarkSnake_NHolder_Uncontended/Cap16  230 ns/op   416 B/op    7 allocs/op

BenchmarkSnake_NHolder_Saturated/Cap1     445 ns/op   416 B/op    7 allocs/op
BenchmarkSnake_NHolder_Saturated/Cap4     899 ns/op   416 B/op    7 allocs/op
BenchmarkSnake_NHolder_Saturated/Cap8     978 ns/op   416 B/op    7 allocs/op

BenchmarkSnake_NHolder_WithValve/Cap1     579 ns/op   431 B/op    7 allocs/op
BenchmarkSnake_NHolder_WithValve/Cap4    1069 ns/op   431 B/op    7 allocs/op
BenchmarkSnake_NHolder_WithValve/Cap8    1076 ns/op   428 B/op    7 allocs/op

BenchmarkSnake_NHolder_Allocs/Cap1        235 ns/op   416 B/op    7 allocs/op
BenchmarkSnake_NHolder_Allocs/Cap4        233 ns/op   416 B/op    7 allocs/op
BenchmarkSnake_NHolder_Allocs/Cap16       231 ns/op   416 B/op    7 allocs/op
```

Uncontended fast path is ~230ns regardless of capacity (no extra overhead from generalization). Allocation count is constant at 7 per acquire/release cycle across all capacities.

### Testing

- New `snake_nholder_test.go`: concurrent grants, capacity blocking, parallel throughput invariant (max concurrent never exceeds capacity), dynamic capacity increase/decrease, valve serialization with multiple slots, NGranted accuracy under concurrency, context cancel slot freeing, max-age per-slot, memory cleanup
- All existing tests updated to use `lockedFirstWaiting` + `lockedOnGrant` + `lockedComplete` lifecycle (replaces `lockedDequeue`)
- `go test -race -count=3 ./go/vt/vttablet/tabletserver/loadshed/...` passes clean

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)
bgwines and others added 2 commits June 11, 2026 09:25
### Background / Why?

The valve's gate condition in `lockedEnqueue` used `outstandingCounts[valveID] > 1` to decide whether to valve a new arrival. This is a proxy for "there's already a droppable representative in the CoDel queue", but it's wrong after a grant. When a request is granted, `lockedOnGrant` deletes `droppablePerValve[valveID]` (the droppable became undroppable), but `outstandingCounts` remains elevated because the granted request is still outstanding. Subsequent arrivals for the same valve ID see count > 1 and get valved, but there's no droppable representative to ever trigger promotion. As a result, the invariant ("each nonempty valve always has exactly one droppable entry in the CoDel queue") is broken and requests are stranded indefinitely.

This was correct in the Python implementation because:
1. it was a lock, not a semaphore
2. the invariant was slightly different (each nonempty valve has one representative in the codelq, not one DROPPABLE representative). As we noted at the time of coming up with the N-holder invariant, that invariant is actually superior even for the 1-holder (lock) case, it just only mattered in an extreme edge case we likely never saw.

The practical impact: with capacity N and M concurrent requests from the same valve ID where M ≤ N, only the first request is ever granted. The rest are serialized one-at-a-time through release→promote→grant chains, leaving N-1 capacity slots unused. This case probably wouldn't ever happen in production since it requires all requests to be for only one valve ID.

The fix replaces the `outstandingCounts > 1` check with a direct `droppablePerValve[valveID]` existence check, which directly encodes the invariant.

### Testing

new unit tests

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)
* Gate OLTP read pool with Snake load shedder

Wire the CoDel-based Snake gate into QueryExecutor.getConn() so that
OLTP read requests are admitted or shed before consuming a MySQL
connection. Controlled by --snake-enabled flag (off by default).

ContentionID is extracted from the unique_id SQL margin comment that
webapp injects, with a random 16-char hex fallback for requests
without one.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

* Use proto UniqueId as Snake contentionID, bypass valve when unset

Replace the extractUniqueID regex (which targeted a comment format
webapp never sends) with the proto-based approach: read
qre.options.GetUniqueId() and only enter the Snake gate when it's
non-empty. Requests without a unique_id bypass the gate entirely
rather than sharing a single valve slot or using random IDs.

Add `string unique_id = 22` to ExecuteOptions in proto/query.proto.
This is an optional field — callers that don't set it get zero-cost
bypass. The sqlparser/vtgate propagation to populate it from SQL
directives is deferred to a follow-up PR.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

* Gate DML with Snake load shedder (#871)

Acquire a Snake slot in TxPool.Begin (for fresh connections only,
not reserved conns) and release it in txComplete. This gates both
autocommit and explicit transaction DML through the same CoDel-based
load-shedding mechanism already used for the OLTP read pool.

Requests without a unique_id bypass the gate entirely, matching the
OLTP-read behavior.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Signed-off-by: Brett Wines <bwines@slack-corp.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

---------

Signed-off-by: Brett Wines <bwines@slack-corp.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
@bgwines bgwines changed the title Snake CoDel count ease-out with configurable EasingDivisor Jun 17, 2026
@bgwines
bgwines force-pushed the bwines/snake-22 branch 2 times, most recently from fbb9a86 to 95a1721 Compare June 18, 2026 01:07
@bgwines bgwines changed the title CoDel count ease-out with configurable EasingDivisor Snake: CoDel-based load shedding gate for vttablet conn pools Jun 18, 2026
bgwines and others added 14 commits June 18, 2026 10:57
Expose a point-in-time snapshot of CoDel internal state (queue length,
droppable count, holder count, dropping state, drop count, current
interval). Useful for bench harnesses and future metrics export.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Signed-off-by: Brett Wines <bwines@slack-corp.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The original CoDel paper uses exponent 1.0 (interval / sqrt(count)).
This matches the standard algorithm behavior where drop rate scales
linearly with throughput change.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add Snake bench suite and plotting scripts

Standalone bench harness (go run bench_suite.go) that exercises Snake
under a linear ramp load profile, polling Stats() every 10ms. Outputs
TSV data to ~/snake-load-test-charts/tsv/<date>/<timestamp>/.

Two Python plotting scripts consume the TSVs:
- plot_linear_ramp.py: 7-row chart (grant rate, issued/shed Hz,
  queue depth, CoDel state, interval, unfilled slots, latency)
- plot_easing_comparison.py: 6-column × 7-row comparison across
  easing divisor values

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

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

* Add CLI flags for CoDel params and README

Avoids config drift between bench suite and production by making
exponent, target, and interval configurable via flags (defaulting to
production values). Adds README explaining how to run benchmarks and
plot results.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

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

* Include HH-MM-SS in plot output filenames

Prevents overwrites when running benchmarks multiple times per day.
Also fixes stale EXPONENT=0.5 default in plot_linear_ramp.py to match
the production value of 1.0.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

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

* Make plot_linear_ramp.py config params CLI flags

Replaces hardcoded CAPACITY, WORK_MS, TARGET_MS, INTERVAL_MS, EXPONENT,
PEAK_MULT with argparse flags so the chart title stays accurate without
manual code edits.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The cap prevented the drop timer from catching up after a burst or GC
pause — if more than 100 drops were overdue, the remainder were deferred
to the next timer fire. Under sustained overload this meant the queue
couldn't drain fast enough, keeping the system in a continuous dropping
state rather than shedding decisively and recovering.

Without the cap the loop drains all overdue drops in one pass, producing
a healthy oscillation: shed burst → droppableLen hits 0 → exit dropping
→ new arrivals refill → re-enter dropping. Benchmarks confirm the
sawtooth is driven by this change alone (control law floor is irrelevant
at this overload level).

The original concern (CPU burn from a late timer fire) is mitigated by
the fact that each iteration does O(n) work in findLowestPriorityDroppable
which bounds the realistic iteration count — and the queue length itself
is bounded by arrival rate × interval.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The previous floor of 100ns was arbitrarily chosen. Since the interval
is int64 nanoseconds, there's no floating point precision concern — a
floor of 1ns is sufficient to ensure lockedControlLaw always makes
forward progress.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>
AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Signed-off-by: Brett Wines <bwines@slack-corp.com>
* Wire ExecuteOptions priority into Snake load shedding

Snake's Acquire now takes an explicit priority in its own convention
(lower priority shed first, higher shed last). The two tabletserver
entrypoints translate the Vitess ExecuteOptions proto priority
(0 = most important) into that convention via MaxPriorityValue -
protoPriority, so loadshed stays free of any sqlparser dependency and
holds no proto knowledge.

The proto-priority parse-and-default logic is extracted from
getPriorityFromOptions into a package-level priorityFromOptions function
so TxPool, which cannot reach a TabletServer, can share it.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

* Tighten priority docstrings and rename a test

Shorten the Acquire, Snake.priority, and priorityFromOptions docstrings,
and rename TestSnake_Priority_HonorsCallerKey to use the package's
"priority" terminology rather than "key".

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

---------

Signed-off-by: Brett Wines <bwines@slack-corp.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…t` (#887)

Align the load shedder's flag surface with the Query Admission Control
RFC, which specifies four flags: --loadshed-enabled, --loadshed-target,
--loadshed-interval, and --loadshed-exponent. Renames the three existing
snake-* flags and their TabletConfig fields, and promotes the CoDel
control law exponent from a hardcoded 1.0 to the configurable
--loadshed-exponent. Defaults are set to the RFC's recommended values
(target 20ms, interval 400ms, exponent 1.0).

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Signed-off-by: Brett Wines <bwines@slack-corp.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* CoDel count ease-out with configurable easing divisor

Replace the hard count=1 reset on dropping→healthy transition with a
gradual ease-out: the timer continues to fire at progressively longer
intervals, halving (or dividing by EasingDivisor) the count each time
until it reaches 1. This eliminates oscillation where the system rapidly
bounces between dropping and healthy states.

Key changes:
- Fix lockedCurrentInterval() to compress intervals whenever count > 1
  (not just when dropping), so the ease-out timer fires at correct rates
- Add EasingDivisor config param (defaults to 2.0 if nil)
- Three CoDel states: idle (count=1, no timer), easing (!dropping,
  count>1, timer armed), dropping (dropping=true, timer armed)
- lockedComplete() no longer stops/restarts the timer — it just sets
  dropping=false and lets the already-armed timer continue into easing
- Add Stats() method and snakeserver bench suite with chart generation

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

* Add snakeserver bench suite and chart generation

Standalone load test harness (//go:build ignore) that exercises Snake
under various traffic profiles (sine, constant, linear ramp) and emits
TSV data + a Python chart generator for visualizing CoDel internals.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

* Fix easing re-entry bug in lockedEnterDroppingState

When CoDel re-enters the dropping state from easing (count > 1), the
delta heuristic (count - lastCount) produces a negative number because
count was halved below lastCount during easing. This caused count to
unconditionally reset to 1, losing all dropping intensity and producing
wild oscillation in the control loop.

Fix: when count > 1, preserve it directly and skip the delta heuristic,
which is only meaningful for the idle→dropping transition (count == 1).

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

* bench_suite: add -easing flag and per-request latency tracking

Avoids source edits when running comparison sweeps across easing divisor
values. Also tracks end-to-end request latency (acquire→release) so
charts can show p50/p95/p99 alongside throughput metrics.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

* Easing implementation + benchmark tooling improvements

- Rename BENCHMARK_RESULTS.md → benchmark/MICROBENCHMARK_RESULTS.md
- Update plot_easing_comparison.py: fractional divisors (2^(1/2), 2^(1/3),
  2^(1/4)), base branch column, --run flag, stable dir naming, stale-data
  fix (always read from tsv/ subdir), remove shared y-axis normalization,
  "no decay" label for divisor=1
- Add EasingDivisor to bench_suite.go config
- Update benchmark README with new workflow
- Add *.~undo-tree~ to .gitignore

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

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

* Rename snakeserver/ to benchmark/

Consolidates all bench tooling under the benchmark/ directory. The
snakeserver bench_suite.go (full-featured: parallel execution, -easing
flag, -filter flag, sine/constant/linear profiles) replaces the simpler
version that was in benchmark/. Also moves the HTTP server (main.go),
go module, and plot_suite.py.

Updates plot_easing_comparison.py --bench-go default path to reference
the local bench_suite.go now that it lives in the same directory.

Removes the accidentally-tracked snakeserver binary.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

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

* Remove LastDropsPerRun, default easing to 1.2, clean up comments

- Remove LastDropsPerRun field and all tracking state (unnecessary complexity)
- Change default EasingDivisor in tests from 2.0 to 1.2
- Condense stream-of-consciousness comment in codelq_test.go to 2 lines
- Replace "halves" with "decays" in comments (divisor is configurable, not always 2)
- Restore CurrentInterval comment to just "// ns"
- Add bench_suite binary to .gitignore
- Remove drops-per-run panel from plot_suite.py (4→3 stat panels)

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

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

* Update benchmark README to match current flags and paths

The README had stale references to --exponent/--target-ms/--interval-ms
flags that no longer exist, and was missing -j/-filter/-easing. Also
fixed the --bench-go default path (was ../snakeserver/, now local).

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

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

* codelq: add subtract easing mode and seed dropNextNs on dropping entry

Add a configurable easing strategy to CoDelConfig: EasingMode selects
"subtract" (decrement count by EasingSubtractor each easing fire) or the
default "divide" (divide count by EasingDivisor). Both floor count at 1.

Manually rewrite a bunch of claude's ugly and overly complex code. In
doing so, also make a subtle but important fix. Previously, the easing
was being done by adding the interval to q.nowNs() instead of applying
a catchup loop based on the previous value of q.dropNextNs. As a result,
it would only ever ease out once per iteration, whose min time was
bounded by timer accuracy, making it more closely simulate a slower
easing under aggressive load. "subtract" easing with a value of 1 or 2
shows much closer performance to what we saw prior.

Dropped the count memory because it probably wasn't doing much and
we've had some very good looking results with easing.

* benchmark: configurable workloads, easing strategies, and comparison axes

Add a JSON config (--config) to plot_easing_comparison.py describing the
easings and workloads to run. Each easing entry is a divisor or a
subtract spec; "compare" selects whether figure columns vary by easing
(one figure per workload) or by workload (one figure per profile, e.g.
an interval:target sweep at a fixed easing).

bench_suite.go gains a custom single-workload mode (-profile and friends)
that replaces the preset matrix when set, plus -easing-mode/-easing-sub
to drive the subtract strategy, and a linear_ramp_down profile mirroring
linear_ramp.

Use a repo-root-relative replace path in go.mod so the bench builds from
any checkout. Add example and working configs; document the new flags.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>

* touch up documentation a tiny bit

Signed-off-by: Brett Wines <bwines@slack-corp.com>

* codelq: nil-guard EasingDivisor in lockedEaseCount

lockedEaseCount nil-guarded EasingMode and EasingSubtractor but called
q.cfg.EasingDivisor() unguarded. A CoDelConfig that omits EasingDivisor
(e.g. TestTxPoolSnake_CapacityExhaustedShedsLoad) thus segfaults when the
drop timer fires during the easing phase, crashing the whole tabletserver
test binary. Default to 2.0 when unset, matching the prior behavior.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

* codelq: make logbase the only easing strategy

Collapse the configurable easing strategies (divide / subtract / sqrt-ln
log / logbase) down to a single logbase decay:
count -= floor(log_base(count) / base), floored at 1. CoDelConfig now
exposes only EasingLogBase (defaulting to 3 when unset or <= 1); the
EasingMode, EasingDivisor, and EasingSubtractor fields are removed.

Benchmark sweeps across sine, ramp, and brown-noise workloads showed the
logbase strategy gives a consistent, large median-latency reduction for a
negligible throughput cost, with the base acting as a clean
aggressiveness knob, so the other strategies are no longer needed.

Update the easing tests to the logbase semantics and rename the
EasingDivisor config fields to EasingLogBase.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>

* benchmark: logbase-only easing plus new workload profiles and knobs

Easing simplification (follows the codelq change): drop the
-easing / -easing-mode / -easing-sub flags, keep -easing-log-base
(now defaulting to 3), and remove the divide/subtract/log handling in
plot_easing_comparison.py. Easing config entries are now a bare base
number or {"base": N}, and --config is required.

Workload additions accumulated while tuning the easing:
- brown_noise profile: repeatable seeded random-walk load
  (-brown-seed / -brown-step / -brown-sample-ms)
- sine trough floor (-sine-floor) so a sine can swing between two
  non-zero load levels
- linear_ramp_down profile mirroring linear_ramp
- plot_easing_comparison.py: --jobs/-j to cap concurrent bench
  processes (use 1 for low-noise serial runs), a "compare" axis to vary
  columns by easing or by workload, and a log-scale latency panel

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>

* Drop stale EasingDivisor from CoDel callers

The "make logbase the only easing strategy" commit renamed the
CoDelConfig.EasingDivisor field to EasingLogBase inside the loadshed
package, but missed the two callers a directory up in query_engine.go
and tx_engine.go, so the tabletserver package no longer compiled. The
old literal (1.2) was a divide-mode divisor anyway; under the logbase
strategy the documented default base of 3 (applied when EasingLogBase is
unset) is the right behavior, so just drop the stale field.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

* Pass priority arg to Acquire in benchmark module

The benchmark module has its own go.mod, so its callers weren't caught
when ExecuteOptions priority wiring (#884) added a third priority arg to
Snake.Acquire. Both call sites (bench_suite.go, main.go) still used the
2-arg signature and failed to build. Pass 0 (uniform priority), matching
the in-tree loadshed benchmarks.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

---------

Signed-off-by: Brett Wines <bwines@slack-corp.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ross Cohen <rcohen@slack-corp.com>
Co-authored-by: Claude <svc-devxp-claude@slack-corp.com>
* Characterize global-lock throughput under high contention

Add BenchmarkSnake_HighContention to sweep Snake's single global mutex
from 8 up to ~524288 concurrent contenders across the fast-path
(immediate-grant), valve, and default capacity-1 serialized paths,
measuring whether ns/op stays flat or collapses super-linearly.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

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

* Fold high-contention benchmark into snake_bench_test.go

Move BenchmarkSnake_HighContention and its runPooledContention/
contentionLevels helpers out of the standalone snake_contention_bench_test.go
and into the canonical snake_bench_test.go, consolidating the loadshed
package's benchmarks in one file. Pure relocation; benchmark logic is
unchanged.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>

---------

Signed-off-by: Brett Wines <bwines@slack-corp.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Brett Wines <bwines@slack-corp.com>
The commit that flipped --loadshed-enabled to default true updated the
flag golden files but left defaultConfig.LoadshedEnabled implicitly false,
so NewDefaultConfig() disagreed with the flag-populated currentConfig and
TestFlags failed (LoadshedEnabled: false vs true). Set it explicitly in
defaultConfig so the two agree.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Signed-off-by: Brett Wines <bwines@slack-corp.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
rcohen2000 and others added 18 commits July 13, 2026 11:53
lockedAdvance incremented count and advanced dropNextNs unconditionally in
the drop branch, even when dropFn() failed to shed. When dropFn keeps
failing (keep-last refusing the final droppable request, or a
droppableLen/index desync) AND the head is old enough that the
end-of-interval re-arm check (head.enqueuedAt < dropNextNs) stays true,
the loop re-armed dropping every iteration and ran count++ without
bound. count ramps until the control-law interval floors at 1ns, at which
point the loop advances dropNextNs slower than the real clock moves and
never terminates — spinning under s.mu. In production this wedged a tablet
(CPU pegged, metrics frozen, ~125k goroutines piled up on s.mu in the
Acquire/TxPool.Begin paths behind the release goroutine stuck in
lockedCompleteAndShed -> lockedRunTimer -> lockedAdvance).

Advance count and dropNextNs only on a successful drop; a refused drop
falls through to the easing branch. count can no longer ramp without real
drops, so it stays bounded, eases down, and dropNextNs marches past now —
the call terminates. This is a dequeue-path bug (not the timer), and is
independent of request rate: at steady overload count tops out near the
queue depth; the runaway needs a refused drop on a stale head.

Adds a regression test that reproduces the spin against a real advancing
clock (keep-last + old head + a dropNextNs gap): it spins to count>500M on
the old code and terminates with bounded count on the fix.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
The CoDel timer/dequeue unification (621dac9) drives shedding off the
control-law interval, so with the default 1s test interval the queued valve
entries are not shed until roughly an interval elapses. TestPublishStats_
ValveDepthHistogramRecords drains those entries at teardown with a 2s timeout,
which the 1s-interval drop no longer beats, so teardown timed out even though
the depth-observation assertions (the actual subject) all pass.

Give the test a fast CoDel config (1us interval / 1ns target), matching sibling
tests like TestPublishStats_DroppableAndIntervalHistogramsRecordUnderLoad, so
the queued entries shed promptly at teardown. No assertion changes.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
convertAndLogError built a full SQL + bind-variables log string
(queryAsString -> fmt.Sprintf -> BindVariable.String -> prototext.Marshal)
on every RESOURCE_EXHAUSTED error, including load-shed rejections. That
path is high-volume under overload and its log is rate-limited
(logPoolFull, once/minute), so the expensive per-rejection string was
built only to be discarded — a CPU profile of an overloaded tablet showed
~18% of CPU in this formatting plus much of the GC it drove. The load
shedder sheds to save work under load, then spent more work logging each
shed than the shed saved.

Skip building the query+bindvars string for RESOURCE_EXHAUSTED: log just
the error. The returned client error is unchanged (that branch never
included the query), ErrorCounters still increments, and the throttled log
still fires — only the discarded per-shed SQL detail is dropped.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
A CPU profile of an overloaded tablet (after the query-stringification fix)
still showed ~7% of CPU in vterrors.Errorf -> vterrors.callers ->
runtime.Callers, plus the stack-unwind frames it drives, on the load-shed
path. Each shed created a vterrors error at the shed site AND had it
re-wrapped in convertAndLogError — two runtime.Callers stack captures per
rejection, for an expected high-volume outcome.

- Pre-build the shed errors as package vars (errLoadShed, errDMLLoadShed),
  matching the existing errTxThrottled pattern: the stack is captured once
  at init instead of per rejection, and there is no per-shed allocation.
  The wrapped snake error was the constant *loadshed.DroppedRequestError,
  so no per-request detail is lost.
- Skip the convertAndLogError re-wrap for RESOURCE_EXHAUSTED with no
  callerID: return the (pre-built) error as-is instead of a second
  vterrors.Errorf, avoiding the second stack capture.

RESOURCE_EXHAUSTED code, ErrorCounters, and the throttled log are
preserved. With the prior stringification fix this takes the shed path
from a CPU sink under overload to negligible.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
An execution trace of the loaded primary (with per-CPU intake both on and off)
showed ~89% of all scheduler delay in lockedDrop -> Request.signal ->
runtime.chansend1: the CoDel drop loop wakes each rejected client goroutine
(goready via a channel send) while holding s.mu. With the control-law count
ramped high, one drop-timer fire sheds a large batch, so s.mu is held across a
long run of goreadys — blocking Acquire (arrivals) and release (grants) for the
whole batch. Requests then age in the queue, producing the multi-second
shed-latency tail and the semaphore underfill.

Split Request.signal into markSignaled (sets signaledValue, under the lock, so
every under-lock reader still sees the terminal state synchronously) and
sendSignal (the channel send / goready). The batch drop path now marks each
dropped request and collects it on q.pendingSignals; the two lockedRunTimer
callers (runDropTimer and the release path via lockedFinishRelease) take that
slice before unlocking and sendSignal each afterward. Grants and cancels stay
single-shot inline (negligible in the trace).

This keeps the goready storm out of the critical section so grants and arrivals
interleave with reject wakeups instead of serializing behind them. Total wakeups
are unchanged; the win is removing the serialization. Per-loop drop cap deferred
as a measured follow-up.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
…omplete

The DML load-shed (Snake) slot acquired in TxPool.Begin was released only via
txComplete (the commit/rollback path). Connections torn down through
StatefulConnection.Release() directly — the transaction killer, shutdown, taint,
and renew-failure paths — bypass txComplete and so leaked their Snake holder.
Enough leaks fill the gate's capacity, after which every write waits forever for
a grant that never comes: the client times out (~10s) and gets RESOURCE_EXHAUSTED
with zero sheds and no lock contention. A goroutine dump from a stalled primary
showed exactly this — hundreds of writers parked in Snake.Acquire, the gate
otherwise idle, MySQL healthy.

Invoke SnakeRelease from ReleaseString, the universal teardown funnel every
release path passes through. txComplete still calls it; SafeUnlock.Release is
idempotent (once.Do), so the double-call is safe and any conn released without
txComplete still frees its slot instead of leaking.

Adds a regression test: a conn acquired via Begin and torn down with a direct
Release (not Commit/Rollback) must still free the slot — it reproduced the leak
before this fix.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
CoDel's drop path always sheds the lowest-priority droppable request, but if the
droppable backlog drains to a shallow set it is forced to drop whatever remains
— including higher-priority requests. Refuse to shed while the droppable backlog
is at or below a fixed floor of 4, so a reserve of droppable requests stays on
hand and the lowest-priority ones remain available to shed first. The guard only
engages in the queue's near-empty troughs; under a real backlog (droppableLen >
4) shedding is unchanged.

The floor changes small-scale shedding behavior (the gate holds up to 4 rather
than shedding the last few), so several existing tests that induced shedding at
capacity 1 with a handful of contenders are updated: capacity/contender counts
raised past the floor so drops still occur and below-floor survivors get granted
as holders release, and the grant-stall/valve teardown paths use a cancelable
context since floor survivors are never shed while capacity is pinned.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
The vttablet_queries latency histogram lumps successful queries together with
fast-failing load-shed rejections (RESOURCE_EXHAUSTED), so there is no exact way
to measure successful-request latency percentiles in isolation — sheds land in
the low buckets and blur the distribution.

Add QueryTimingsByErrorCode, a Timings histogram labeled by the vterrors code of
the result (OK for successful queries, RESOURCE_EXHAUSTED for shed, etc.). It is
recorded in Execute's existing defer from the already-computed errCode and
duration, so it is nearly free. histogram_quantile over {ErrorCode="OK"} now
gives exact granted-only latency, the clean signal for judging whether load-shed
scheduling changes (e.g. yield-on-drop) move the successful-request tail.

Kept as a separate metric rather than folding an error-code label into
vttablet_queries: that is a single-label Timings shared across ~18 call sites
(BEGIN/COMMIT/RESERVE/vreplication phases/...) with no meaningful error code,
and converting it to MultiTimings would pollute those timings and inflate the
hottest histogram's cardinality. Existing dashboards sum by(le) so they are
unaffected either way.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Add ShedByPriority and AcquireByPriority multi-label counters to Snake, labeled
by the caller's original query priority ("0" most important .. "100" least,
"overflow"). ShedByPriority breaks the scalar ShedCount down so operators can
see whether the gate sheds low-priority traffic first rather than eating
high-priority requests; AcquireByPriority is the offered-load denominator so
per-priority shed rate is ShedByPriority/AcquireByPriority — computed exactly
rather than from assumed traffic weights.

The label reports the caller value: Acquire inverts to the internal Snake
priority (snake = maxPriorityBucket - caller, so lower Snake value sheds first),
and shedPriorityLabel inverts back so the metric matches what the caller passed.

(Hand-ported to this branch, combining the original label commit and the
caller-priority/denominator follow-up; the metric interface gains
NewCountersWithMultiLabels and the test fake implements it.)

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
histogram_quantile interpolates linearly within a bucket, so a bucket [a,b]
bounds the estimate's relative error by b/a. The original shared cutoffs
alternated x2 and x5 steps (0.5,1,5,10,50,100,500ms,...), so the x5 buckets —
e.g. 100ms->500ms — gave up to 5x error exactly where tablet p99s sit, making
the estimate leap by hundreds of ms as samples crossed a boundary (it read as
latency noise but was interpolation artifact).

Replace with a geometric set: constant multiplier ~10^(1/5) ~= 1.58 (Renard R5
preferred numbers, 1.0/1.6/2.5/4.0/6.3 per decade) across 0.5ms..10s. Every
bucket now bounds the quantile to ~1.6x (~±23%) uniformly, so resolution no
longer depends on where latency happens to fall. 10 -> 22 cutoffs; a modest
cardinality increase over the ~66 shared timing metrics, worth it for p99 you
can trust. Regenerated the timings_test expected strings.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Extends the Snake load-test harness so it can exercise regimes the sleep-based
model cannot:

- -work-mode=cpu: holders busy-spin a calibrated amount of CPU (yielding
  periodically so they don't monopolize their P) instead of time.Sleep, so they
  genuinely occupy cores. Under core contention the wall-clock to finish fixed
  work stretches — the slot-refill delay and latency inflation the sleep model
  can't produce. -gomaxprocs lets a run saturate a chosen core count.
- Per-request priority is threaded into the event log (priority column) so
  per-priority shed rates can be measured — the axis on which the keep-droppable
  floor's high-priority protection shows up.

All additive: -work-mode defaults to sleep, so existing presets are unchanged.

(Hand-ported subset of the rcohen harness commit; the drop_overshoot_ns stats
column is omitted because Snake DropOvershootNs is not on this branch.)

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Shedding was driven only by the release path and the backstop timer, so under
sparse releases or a late-firing timer the CoDel control law could lag the
actual load. Run the control-law advance (lockedRunTimer) on every non-granted
enqueue so arrivals drive the drop cadence too. Only the non-granted path
advances — an inline grant means there is capacity and nothing to shed. Like
the timer path, lockedRunTimer only marks drops, so the pending rejections are
drained and sent after s.mu is released to keep the goready storm off the lock.

Always on — no config, flag, or /debug/env knob.

Also raise MinDropDelay from 1ms to 100ms in both the OLTP-read and DML snakes,
flooring how often the drop timer re-arms.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Holder count is bounded by the pool size (testing points to 64 or lower), but
it shared the powers-of-two lengthBucketCutoffs (1,2,4,8,16,32,64,...) which
collapse that whole operating range into ~6 buckets — no resolution where it
matters. Give it a dedicated holderBucketCutoffs with unit steps through the
single digits (so underfill to 2 vs 4 vs 6 is distinguishable), tightening
steps across the 32-64 range under test, and a little headroom above 64 to
catch misconfiguration. Other length histograms (queueLen, droppableLen,
dropCount, valveDepth, dropsPerFire) keep the powers-of-two set since they
legitimately reach the hundreds.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
…erval-ratio)

Replace the absolute --loadshed-interval duration with --loadshed-interval-ratio
(float64, default 20): the CoDel observation interval is now derived as
target * ratio in both the OLTP-read and DML snakes. This keeps interval tied to
target (the recommended 10-20x relationship) instead of requiring the two to be
tuned in lockstep. Default 20 * 5ms target = 100ms, matching the prior interval.

Updated the tabletenv config field/flag/default, both engine wirings, the
/debug/env setter+getVar (now float64), the loadshed-trigger doc that referenced
loadshed-interval, the endtoend flag docs, and the tests.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
gofmt-only whitespace fix (align the const block's = signs); no behavior change.
Clears a latent gofmt/format-check failure.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
The control-law exponent was tunable via --loadshed-exponent / LoadshedExponent
(/debug/env), but 1.0 is the only value we run. Hardcode Exponent to 1 in both
the OLTP-read and DML snake configs and remove the flag, config field, default,
/debug/env setter+getVar, endtoend docs, and test references.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
… set

convertAndLogError re-wraps the returned error with vterrors.Errorf, which
captures a stack trace (runtime.Callers) and allocates on every call. The
existing RESOURCE_EXHAUSTED fast path that returns the pre-built shed error
as-is was gated on callerID == "", but production requests carry an immediate
callerID, so shed rejections fell through to the vterrors.Errorf re-wrap and
paid a stack capture per drop. A CPU profile of a saturated, heavily-shedding
tablet showed vterrors.Errorf -> vterrors.callers at ~5% of CPU, the single
largest stack-capture site — precisely when the tablet is already overloaded.

Drop the callerID == "" condition: for RESOURCE_EXHAUSTED, always return the
pre-built error as-is and fold any callerID into the rate-limited log line only,
never into a stack-capturing re-wrap. The returned client error no longer
carries the "(CallerID: x)" suffix for shed rejections (an expected, low-value
detail the caller already knows); the callerID remains in the server log.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
… floor

Both call sites of scheduleDropTimer already floor the arm delay by
MinDropDelayNs (100ms in prod) before it reaches lockedScheduleDropTimer, so
the additional 5ms backstopFloorNs check could never fire: max(delay, 100ms) is
always >= 5ms. The 5ms floor predated MinDropDelay; the 100ms floor added later
subsumed it. Remove backstopFloorNs and let MinDropDelayNs be the single,
config-visible floor on the drop timer.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
bgwines and others added 10 commits August 21, 2026 11:28
* loadshed: judge CoDel re-arm staleness by oldest waiter, not raw list head

lockedAdvance's head-check compared the raw list front's enqueue time
against the next drop deadline to decide whether to keep dropping. A
granted request stays resident in the list as UNDROPPABLE until Release,
so a long-held straggler could be the raw list front — its age drove the
staleness decision even though it can never itself be a drop candidate.

During a multi-interval catch-up (e.g. a backstop timer that fired late),
this let a single stuck holder ramp count and shed an otherwise-healthy
backlog. Judge staleness by the oldest WAITING request instead.

AI disclosure: Claude Code assisted with development. Every line of code was either written by or carefully reviewed by me :)

Signed-off-by: Brett Wines <bwines@slack-corp.com>

* remove slop comments

Signed-off-by: Brett Wines <bwines@slack-corp.com>

* loadshed: trim remaining verbose assert messages

Signed-off-by: Brett Wines <bwines@slack-corp.com>

---------

Signed-off-by: Brett Wines <bwines@slack-corp.com>
### Background / Why?

CoDel measures sojourn when a request is granted, so an executing request contributes no new queue-health information after grant. This PR is a refactor; it has no behavioral change.

It removes granted requests from the CoDel list at grant time while leaving Snake's semaphore and holder lifecycle unchanged; release still performs the holder and valve accounting that belongs to the current Snake API. Admission decisions therefore remain unchanged.

This is a step in preparation for replacing `wl.list` from `go/pools/smartconnpool/waitlist.go` with the snake. In that world, the semaphore is the conn pool itself.

### Testing

Existing unit tests updated.

AI assisted with development. Every line of code was either written by or carefully reviewed by me :)
Keep Snake load shedding slow-start only
AI assisted with development. Every line of code was either written by or carefully reviewed by me :)

Signed-off-by: Brett Wines <bwines@slack-corp.com>
AI assisted with development. Every line of code was either written by or carefully reviewed by me :)

Signed-off-by: Brett Wines <bwines@slack-corp.com>
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.

3 participants