Skip to content

Commit c64c10b

Browse files
fix(datasource): dedupe the pylon saturation warning (EXT-13)
The warning fired once per request let through past max_wait, and the condition it reports is a saturation that lasts, so it put a line on every request it described: 1080 lines for a stream 5x over a 120/min budget for two minutes, burying the first one, which is the only one the operator needed. It now says its piece once per endpoint per window, and says so in the line itself, so one line does not read as one request. A stream 25x over budget for five minutes goes from 15000 lines to 5. RateLimiter keeps its bookings ordered by inserting into place rather than appending and sorting. The list is sorted, so the expired bookings are its leading run and the new one has a single position: pruning is a prefix drop and the insert is a binary search, where the sort was O(n log n) per request held under the mutex every endpoint shares. With 20000 bookings in the window an acquire costs 1.9us rather than 620us. Verified behaviour-preserving against the previous scheduler over 50 randomised trials of limit, window and max_wait: identical sleeps, and the bookings sorted at every step. DEFAULT_MAX_WAIT said the region it smooths is "a stream a few percent over budget", which is optimistic. The waits accumulate rather than settling, so the bound is crossed sooner the further over budget the stream sits, and a bypass books inside the window and brings the next one closer. Measured against a 120/min endpoint: 1% over throttles its first thousand requests then lets one in twelve through, 5% over gives up after two hundred and lets half through, and past 10% over the bound is crossed as the window fills and almost nothing is throttled. The comment now says that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f00f9fc commit c64c10b

2 files changed

Lines changed: 84 additions & 18 deletions

File tree

packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/rate_limiter.rb

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,18 @@ class RateLimiter
2626
# once per attempt, on top of the backoff `retry` waits itself. None of it
2727
# runs under the Faraday timeout, which only covers the adapter.
2828
#
29-
# The bound leaves a narrow operating region, and it is worth being plain
30-
# about it: a wait fits under it only while the window has been full for less
31-
# than this long, so what gets smoothed is a stream a few percent over
32-
# budget. A burst arriving at once books its next slot a whole window out and
33-
# goes straight through. Under real saturation the 429 retry is the defence,
34-
# not this.
29+
# The region this smooths is narrow, and it is worth being plain about it.
30+
# On a stream over budget the waits accumulate rather than settling, so the
31+
# bound is crossed sooner the further over it sits: measured against a
32+
# 120/min endpoint, a stream 1% over budget throttles its first thousand
33+
# requests and then lets roughly one in twelve through unthrottled, one 5%
34+
# over gives up after two hundred and lets half through, and past ~10% over
35+
# the bound is crossed as soon as the window fills — request 121 — after
36+
# which almost nothing is throttled at all. A burst arriving at once books
37+
# its next slot a whole window out and goes straight through from the first
38+
# request past the budget. Under real saturation the 429 retry is the
39+
# defence, not this; what this buys is the region just over budget, and a
40+
# budget the code knows rather than one it discovers as a 429.
3541
DEFAULT_MAX_WAIT = 5.0
3642

3743
attr_reader :max_wait, :window
@@ -44,16 +50,17 @@ def initialize(limits: RateLimits, window: WINDOW, max_wait: DEFAULT_MAX_WAIT, c
4450
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
4551
@mutex = Mutex.new
4652
@slots = {}
53+
@warned = {}
4754
end
4855

4956
# Blocks until the endpoint has room, then returns. Called once per attempt,
5057
# retries included: a replayed request spends the budget a first one did.
5158
def acquire(method, path)
5259
rule = @limits.for(method, path)
53-
wait = @mutex.synchronize { reserve(rule) }
54-
return if wait <= 0
60+
wait, warn = @mutex.synchronize { reserve(rule) }
5561

56-
return warn_saturated(rule, wait) if wait > @max_wait
62+
warn_saturated(rule, wait) if warn
63+
return if wait <= 0 || wait > @max_wait
5764

5865
@sleeper.call(wait)
5966
end
@@ -67,31 +74,48 @@ def acquire(method, path)
6774
# What is recorded is the moment the request will be made, not the moment it
6875
# was asked for, so concurrent callers each take a distinct slot and spread
6976
# out instead of all waking onto the same one.
77+
#
78+
# Returns the wait the caller owes and whether this is the bypass worth a log
79+
# line — both settled here, the second being shared state like the first.
7080
def reserve(rule)
7181
taken = (@slots[rule.name] ||= [])
7282
now = @clock.call
73-
taken.reject! { |at| at <= now - @window }
83+
# The list is kept ordered, so the expired bookings are its leading run
84+
# and the index below reads as the limit-th most recent one.
85+
taken.shift(taken.bsearch_index { |at| at > now - @window } || taken.size)
7486

7587
slot = taken.size < rule.limit ? now : taken[taken.size - rule.limit] + @window
7688
wait = slot - now
7789
# Past the bound the request goes out now, so the slot it books is now:
7890
# recording the one it declined to wait for would meter a request nobody
7991
# ever made and push the whole window further out.
80-
taken << (wait > @max_wait ? now : slot)
81-
# A booking of `now` lands before slots already reserved further out, and
82-
# the index above only reads as the limit-th most recent booking on an
83-
# ordered list: unsorted, a later caller reads the wrong one and lets a
84-
# request through against a window that had a slot for it.
85-
taken.sort!
92+
insert(taken, wait > @max_wait ? now : slot)
93+
94+
[wait, wait > @max_wait && first_warning?(rule, now)]
95+
end
96+
97+
def insert(taken, booking)
98+
taken.insert(taken.bsearch_index { |at| at >= booking } || taken.size, booking)
99+
end
100+
101+
# One line per endpoint per window. What the warning reports is a saturation
102+
# that lasts, so a line per request puts one on every request it describes —
103+
# a thousand of them for a couple of minutes over budget, burying the first,
104+
# which is the only one the operator needed.
105+
def first_warning?(rule, now)
106+
last = @warned[rule.name]
107+
return false if last && now - last < @window
86108

87-
wait
109+
@warned[rule.name] = now
110+
true
88111
end
89112

90113
def warn_saturated(rule, wait)
91114
ForestAdminDatasourcePylon.logger.warn(
92115
"[forest_admin_datasource_pylon] #{rule.name} is at its budget of #{rule.limit} requests per " \
93116
"#{@window.round}s; the next slot is #{wait.round(1)}s out, past the #{@max_wait.round(1)}s this waits. " \
94-
'Letting the request through — Pylon may answer 429, which the client retries.'
117+
'Letting the request through — Pylon may answer 429, which the client retries. Further requests over ' \
118+
"this budget are let through too, and this says so once per #{@window.round}s."
95119
)
96120
end
97121
end

packages/forest_admin_datasource_pylon/spec/forest_admin_datasource_pylon/rate_limiter_spec.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def at(time)
6969

7070
describe 'when the wait would exceed max_wait' do
7171
it 'lets the request through rather than queueing behind the window' do
72+
allow(ForestAdminDatasourcePylon.logger).to receive(:warn)
7273
subject = limiter(max_wait: 1.0)
7374
limit.times { subject.acquire(:get, '/things') }
7475

@@ -88,6 +89,36 @@ def at(time)
8889
.to have_received(:warn).with(%r{get /things is at its budget of 2 requests per 6s})
8990
end
9091

92+
# What the warning reports is a saturation that lasts, so a line per request
93+
# puts one on every request it describes: a stream 10% over a 120/min
94+
# budget crosses the bound as the window fills and then bypasses almost
95+
# everything, which is a thousand identical lines for a couple of minutes.
96+
it 'warns once per window rather than once per request' do
97+
allow(ForestAdminDatasourcePylon.logger).to receive(:warn)
98+
subject = limiter(max_wait: 1.0)
99+
limit.times { subject.acquire(:get, '/things') }
100+
101+
5.times { subject.acquire(:get, '/things') }
102+
103+
expect(ForestAdminDatasourcePylon.logger).to have_received(:warn).once
104+
end
105+
106+
# Deduplicated, not silenced: a saturation still going a window later is
107+
# news again, and the operator reading one line has to be able to tell the
108+
# burst that passed from the stream that did not.
109+
it 'warns again once a window has gone by' do
110+
allow(ForestAdminDatasourcePylon.logger).to receive(:warn)
111+
subject = limiter(max_wait: 1.0)
112+
limit.times { subject.acquire(:get, '/things') }
113+
subject.acquire(:get, '/things')
114+
115+
at(7.0)
116+
limit.times { subject.acquire(:get, '/things') }
117+
subject.acquire(:get, '/things')
118+
119+
expect(ForestAdminDatasourcePylon.logger).to have_received(:warn).twice
120+
end
121+
91122
# The slot it declined to wait for must not be booked: recording it would
92123
# meter a request nobody made and push every later slot further out, so one
93124
# burst past the bound would keep the window saturated indefinitely.
@@ -143,6 +174,17 @@ def at(time)
143174

144175
expect(slept).to be_empty
145176
end
177+
178+
# Each endpoint reports its own saturation too: one silenced by another's
179+
# line would have the operator narrow the wrong budget.
180+
it 'warns for each endpoint that saturates' do
181+
allow(ForestAdminDatasourcePylon.logger).to receive(:warn)
182+
subject = limiter(max_wait: 1.0)
183+
184+
['/things', '/others'].each { |path| (limit + 1).times { subject.acquire(:get, path) } }
185+
186+
expect(ForestAdminDatasourcePylon.logger).to have_received(:warn).twice
187+
end
146188
end
147189
end
148190

0 commit comments

Comments
 (0)