Skip to content

Keep a pacing stretch open across freed slots - #78

Merged
taurheim merged 1 commit into
masterfrom
fix/pacing-hysteresis
Aug 9, 2026
Merged

Keep a pacing stretch open across freed slots#78
taurheim merged 1 commit into
masterfrom
fix/pacing-hysteresis

Conversation

@taurheim

@taurheim taurheim commented Aug 9, 2026

Copy link
Copy Markdown
Owner

What

Preventive pacing was supposed to report once per throttled stretch. PR #76 shipped that, but the stretch ended on the first track that needed no wait — and a saturated rolling window frees exactly one slot at a time, which the loop consumes immediately. So msUntilBurstSafe() alternates between a fraction of a second and zero from one track to the next, and the stretch ended roughly every other track.

Net effect: instead of ~1 event per stretch, we got a begin/end pair per track. A 34% reduction where ~100x was intended.

Evidence from production (post-#76 deploy)

day stretches paced tracks events per paced track single-track stretches
08-09 447 1,354 0.66 57%
08-08 439 1,232 0.71 52%

Median paced_tracks is 1. A single user's timeline shows the shape plainly — waits of 4ms and 6ms, burst_count sitting exactly on burst_limit:

13:20:07.469  scrobble_paused        wait_ms=4
13:20:07.983  scrobble_pacing_ended  paced_tracks=1
13:20:20.740  scrobble_paused        wait_ms=6
13:20:21.171  scrobble_pacing_ended  paced_tracks=1

Fix

PACING_EXIT_CLEAR_TRACKS = 3 — require a streak of genuinely unimpeded tracks before leaving the paced state. Saturation cannot produce three zero-wait tracks in a row, so the stretch stays open for as long as the throttling lasts. A window that really has cleared, or an adaptive burst-limit raise that frees a block of slots at once, still exits within three tracks.

RateLimitTracker is deliberately untouched: one-slot-at-a-time is the right answer for throughput, it was only the wrong thing to read as "the throttling is over".

Test

a throttled stretch is continuous, it does not restart on every freed slot seeds the burst window so slots free in pairs, making the alternation structural rather than a race — the first track of each pair always waits, the second always finds a slot free. It counts how often the pacing notice comes back after disappearing (counting reappearances rather than disappearances ignores the last one, which is the stretch legitimately ending as the queue drains).

Verified against git stash push -- src/components/ScrobbleStep.vue: 11 reappearances without the fix, 0 with.

An earlier attempt varied the mocked reply latency around the steady-state spacing instead. That does not work: the send rate is capped by the window no matter how fast Last.fm answers, so the loop drifts permanently ahead, pacing ends once and never restarts, and both the fixed and the broken code pass.

Also

Documents a local Playwright footgun that hid this through three "verified" runs. playwright.config.ts sets reuseExistingServer: true on a fixed port, so a vue-cli-service serve already running from another checkout or worktree gets tested instead of yours, silently. CI is unaffected — it starts from nothing.

Data note

paced_tracks / scrobble_pacing_ended between 2026-08-04 and this deploy are flap-inflated and not comparable with later data. Recorded in AGENTS.md alongside the existing note about the 07-27 to 07-29 window.

Preventive pacing was meant to report once per throttled stretch, but the
stretch ended the moment `msUntilBurstSafe()` returned zero. A saturated
rolling window frees exactly one slot at a time and the loop consumes it
immediately, so the wait alternates between a fraction of a second and zero
from one track to the next. Ending on the first zero ended the stretch once
per track, which turned "one event per track" into "one begin/end pair per
track": telemetry after the deploy showed 439 stretches in a day, a median
`paced_tracks` of 1, waits of 4-40ms, and 0.66 events per paced track where
roughly a hundredth of one was intended.

Require a streak of genuinely unimpeded tracks before leaving the paced state.
Saturation cannot produce three zero-wait tracks in a row, so the stretch now
stays open for as long as the throttling lasts, while a window that really has
cleared - or an adaptive burst limit raise, which frees a block of slots at
once - still exits within three tracks.

The regression test seeds the burst window so slots free in *pairs*, which
makes the alternation structural rather than a race: the first track of each
pair always waits and the second always finds a slot free. An earlier version
tried to reproduce it by varying the mocked reply latency around the
steady-state spacing, but the send rate is capped by the window regardless of
how fast Last.fm answers, so the loop simply drifted permanently ahead and both
the fixed and the broken code passed.

Also document the local Playwright footgun that hid this for three runs:
`reuseExistingServer: true` on a fixed port will silently test whichever
checkout already has a dev server up, so a fix verified against another
worktree's build looks verified but is not.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f5423b1e-b316-412a-bade-c28bf90eeb0f
@taurheim
taurheim merged commit 7e06cfc into master Aug 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant