Skip to content

Commit 769b917

Browse files
quinnjclaude
andauthored
Fix retry bucket corruption on Julia 1.10 (#1356)
* fix(retry): keep depletion tracking pointer-free * ci(compat): test supported Julia 1.10 * chore(release): bump version to 2.6.7 * fix(retry): derive the compat constructor's depleted count from partition states The six-field HTTP 2.6.6 compatibility constructor trusted the legacy set's length for the depleted-partition count. A set that disagrees with the partition states (the only interesting input for a compat shim) breaks the count invariant: a too-low count strands depleted partitions (replenish early-returns at zero) and can arm the underflow tripwire in _retry_partition_set_capacity!. Derive the count from the partition states instead, matching the five-field constructor, and pin the behavior with a stale-set test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent eee3447 commit 769b917

5 files changed

Lines changed: 94 additions & 39 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
fail-fast: false
2727
matrix:
2828
version:
29+
- '1.10'
2930
- '1'
3031
- 'pre'
3132
os:

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "HTTP"
22
uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3"
3-
version = "2.6.6"
3+
version = "2.6.7"
44
authors = ["Jacob Quinn", "contributors: https://github.com/JuliaWeb/HTTP.jl/graphs/contributors"]
55

66
[deps]

src/http_retry.jl

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,11 @@ mutable struct RetryBucket
6161
capacity::Int
6262
partitions::Dict{String,_RetryPartition}
6363
lock::ReentrantLock
64-
# Copy-on-write snapshot of partition keys below full capacity. Published
65-
# snapshots are treated as immutable. Writers publish under `lock`; readers
66-
# use the snapshot to avoid locking for healthy traffic to unrelated keys.
67-
@atomic depleted_partitions::Set{String}
64+
# Number of partition keys below full capacity. Writers update it under
65+
# `lock`; readers use it to avoid locking while every partition is full.
66+
# Keep this field pointer-free: atomic references to GC-managed containers
67+
# can corrupt the referenced object on Julia 1.10 (#1355).
68+
@atomic depleted_partitions::Int
6869
end
6970

7071
"""Handle returned by `acquire` and consumed by `release` to refund retry budget."""
@@ -105,7 +106,7 @@ function RetryBucket(;
105106
Int(capacity),
106107
Dict{String,_RetryPartition}(),
107108
ReentrantLock(),
108-
Set{String}(),
109+
0,
109110
)
110111
end
111112

@@ -118,9 +119,7 @@ function RetryBucket(
118119
partitions::Dict{String,_RetryPartition},
119120
lock::ReentrantLock,
120121
)
121-
depleted_partitions = Set(
122-
key for (key, state) in partitions if state.capacity < capacity
123-
)
122+
depleted_partitions = count(state -> state.capacity < capacity, values(partitions))
124123
return RetryBucket(
125124
backoff_scale_factor_ms,
126125
max_backoff_secs,
@@ -131,6 +130,27 @@ function RetryBucket(
131130
)
132131
end
133132

133+
# Preserve the six-field constructor exposed in HTTP 2.6.6. The set argument
134+
# is accepted only for compatibility; the pointer-free depleted-partition
135+
# count is derived from `partitions` so the count invariant holds even when
136+
# the caller's set disagrees with the partition states.
137+
function RetryBucket(
138+
backoff_scale_factor_ms::Int,
139+
max_backoff_secs::Int,
140+
capacity::Int,
141+
partitions::Dict{String,_RetryPartition},
142+
lock::ReentrantLock,
143+
::Set{String},
144+
)
145+
return RetryBucket(
146+
backoff_scale_factor_ms,
147+
max_backoff_secs,
148+
capacity,
149+
partitions,
150+
lock,
151+
)
152+
end
153+
134154
function RetryBucket(
135155
backoff_scale_factor_ms,
136156
max_backoff_secs,
@@ -147,25 +167,23 @@ function RetryBucket(
147167
)
148168
end
149169

150-
# Set a partition's capacity while keeping the published depleted-key snapshot
151-
# in sync. Must be called with `bucket.lock` held.
170+
# Set a partition's capacity while keeping the depleted-partition count in sync.
171+
# Must be called with `bucket.lock` held.
152172
@inline function _retry_partition_set_capacity!(
153173
bucket::RetryBucket,
154-
partition_key::String,
155174
state::_RetryPartition,
156175
new_capacity::Int,
157176
)::Nothing
158177
was_full = state.capacity >= bucket.capacity
159178
now_full = new_capacity >= bucket.capacity
160179
state.capacity = new_capacity
161180
if was_full && !now_full
162-
depleted = copy(@atomic :acquire bucket.depleted_partitions)
163-
push!(depleted, partition_key)
164-
@atomic :release bucket.depleted_partitions = depleted
181+
depleted = @atomic :monotonic bucket.depleted_partitions
182+
@atomic :release bucket.depleted_partitions = depleted + 1
165183
elseif !was_full && now_full
166-
depleted = copy(@atomic :acquire bucket.depleted_partitions)
167-
delete!(depleted, partition_key)
168-
@atomic :release bucket.depleted_partitions = depleted
184+
depleted = @atomic :monotonic bucket.depleted_partitions
185+
depleted > 0 || error("retry bucket depleted-partition count underflow")
186+
@atomic :release bucket.depleted_partitions = depleted - 1
169187
end
170188
return nothing
171189
end
@@ -190,7 +208,7 @@ function acquire(bucket::RetryBucket, partition)
190208
if state.capacity < _RETRY_BUCKET_ACQUIRE_COST
191209
throw(RetryDeniedError(partition_key))
192210
end
193-
_retry_partition_set_capacity!(bucket, partition_key, state, state.capacity - _RETRY_BUCKET_ACQUIRE_COST)
211+
_retry_partition_set_capacity!(bucket, state, state.capacity - _RETRY_BUCKET_ACQUIRE_COST)
194212
return RetryToken(bucket, partition_key, _RETRY_BUCKET_ACQUIRE_COST, false)
195213
end
196214
end
@@ -222,7 +240,7 @@ end
222240
reserved = _retry_bucket_reserved_cost(token)
223241
consumed = min(reserved, max(0, failure_cost))
224242
refund = reserved - consumed
225-
_retry_partition_set_capacity!(bucket, token.partition, state, min(bucket.capacity, state.capacity + refund))
243+
_retry_partition_set_capacity!(bucket, state, min(bucket.capacity, state.capacity + refund))
226244
token.released = true
227245
return nothing
228246
finally
@@ -238,21 +256,20 @@ non-retried request, capped at the bucket's full capacity. This is the slow
238256
recovery path that lets a partition legitimately drained by a burst of real
239257
failures regain retry budget from healthy traffic instead of staying empty for
240258
the transport's lifetime. Partitions that have never spent capacity are left
241-
untouched. The published depleted-key snapshot also keeps this lock-free for
242-
healthy traffic to other partitions.
259+
untouched. The depleted-partition count keeps this lock-free while all
260+
partitions are healthy.
243261
"""
244262
function _retry_bucket_replenish!(bucket::RetryBucket, partition)::Nothing
245263
depleted = @atomic :acquire bucket.depleted_partitions
246-
isempty(depleted) && return nothing
264+
depleted == 0 && return nothing
247265
partition_key = _retry_bucket_partition_key(partition)
248-
partition_key in depleted || return nothing
249266
lock(bucket.lock)
250267
try
251268
state = get(() -> nothing, bucket.partitions, partition_key)
252269
state === nothing && return nothing
253270
partition_state = state::_RetryPartition
254271
partition_state.capacity >= bucket.capacity && return nothing
255-
_retry_partition_set_capacity!(bucket, partition_key, partition_state, partition_state.capacity + 1)
272+
_retry_partition_set_capacity!(bucket, partition_state, partition_state.capacity + 1)
256273
return nothing
257274
finally
258275
unlock(bucket.lock)

test/http_retry_tests.jl

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,15 @@ end
116116
partitions = Dict{String,HT._RetryPartition}("depleted.example" => HT._RetryPartition(5))
117117
positional = HT.RetryBucket(25, 20, 10, partitions, ReentrantLock())
118118
@test positional.partitions === partitions
119-
@test (@atomic :acquire positional.depleted_partitions) == Set(["depleted.example"])
119+
@test (@atomic :acquire positional.depleted_partitions) == 1
120+
121+
six_field = HT.RetryBucket(25, 20, 10, partitions, ReentrantLock(), Set(["depleted.example"]))
122+
@test (@atomic :acquire six_field.depleted_partitions) == 1
123+
124+
# The six-field constructor derives the count from the partition states;
125+
# a stale legacy set must not break the count invariant.
126+
stale_set = HT.RetryBucket(25, 20, 10, partitions, ReentrantLock(), Set{String}())
127+
@test (@atomic :acquire stale_set.depleted_partitions) == 1
120128

121129
converted = HT.RetryBucket(Int32(25), Int16(20), Int8(10), copy(partitions), ReentrantLock())
122130
@test converted.backoff_scale_factor_ms === 25
@@ -210,38 +218,64 @@ end
210218

211219
@testset "HTTP retry bucket replenishes consumed capacity (#1353)" begin
212220
bucket = HT.RetryBucket(capacity = 20)
213-
@test isempty(@atomic :acquire bucket.depleted_partitions)
221+
@test (@atomic :acquire bucket.depleted_partitions) == 0
214222

215223
# Replenish before any capacity was ever spent is a lock-free no-op and
216224
# creates no partitions.
217225
HT._retry_bucket_replenish!(bucket, "svc.example")
218226
@test isempty(bucket.partitions)
219227

220228
token = Base.acquire(bucket, "svc.example")
221-
@test (@atomic :acquire bucket.depleted_partitions) == Set(["svc.example"])
229+
@test (@atomic :acquire bucket.depleted_partitions) == 1
222230
Base.release(bucket, token, HT._RETRY_BUCKET_ACQUIRE_COST)
223231
@test bucket.partitions["svc.example"].capacity == 10
224232

225233
for _ in 1:5
226234
HT._retry_bucket_replenish!(bucket, "svc.example")
227235
end
228236
@test bucket.partitions["svc.example"].capacity == 15
229-
@test (@atomic :acquire bucket.depleted_partitions) == Set(["svc.example"])
237+
@test (@atomic :acquire bucket.depleted_partitions) == 1
230238

231239
# Case-insensitive, and capped at full capacity.
232240
for _ in 1:10
233241
HT._retry_bucket_replenish!(bucket, "SVC.example")
234242
end
235243
@test bucket.partitions["svc.example"].capacity == 20
236-
@test isempty(@atomic :acquire bucket.depleted_partitions)
244+
@test (@atomic :acquire bucket.depleted_partitions) == 0
237245

238246
# Untouched partitions are not affected by another partition's depletion.
239247
other = Base.acquire(bucket, "other.example")
240248
HT._retry_bucket_replenish!(bucket, "svc.example")
241249
@test bucket.partitions["svc.example"].capacity == 20
242-
@test (@atomic :acquire bucket.depleted_partitions) == Set(["other.example"])
250+
@test (@atomic :acquire bucket.depleted_partitions) == 1
243251
Base.release(bucket, other, 0)
244-
@test isempty(@atomic :acquire bucket.depleted_partitions)
252+
@test (@atomic :acquire bucket.depleted_partitions) == 0
253+
end
254+
255+
@testset "HTTP retry bucket uses a pointer-free concurrent fast path (#1355)" begin
256+
bucket = HT.RetryBucket(capacity = 20)
257+
@test isbitstype(fieldtype(HT.RetryBucket, :depleted_partitions))
258+
workers = max(4, 2 * Threads.nthreads())
259+
@sync begin
260+
for worker in 1:workers
261+
Threads.@spawn begin
262+
key = "svc-$worker.example"
263+
for _ in 1:2_000
264+
token = Base.acquire(bucket, key)
265+
Base.release(bucket, token, HT._RETRY_BUCKET_ACQUIRE_COST)
266+
for _ in 1:HT._RETRY_BUCKET_ACQUIRE_COST
267+
HT._retry_bucket_replenish!(bucket, key)
268+
end
269+
end
270+
end
271+
end
272+
Threads.@spawn for _ in 1:100
273+
GC.gc(false)
274+
yield()
275+
end
276+
end
277+
@test all(state.capacity == bucket.capacity for state in values(bucket.partitions))
278+
@test (@atomic :acquire bucket.depleted_partitions) == 0
245279
end
246280

247281
@testset "HTTP retry bucket heals only after successful responses" begin
@@ -734,7 +768,7 @@ end
734768
end
735769
@test err === trace_err
736770
@test bucket.partitions["127.0.0.1"].capacity == 10
737-
@test isempty(@atomic :acquire bucket.depleted_partitions)
771+
@test (@atomic :acquire bucket.depleted_partitions) == 0
738772
@test lock(transport.lock) do
739773
isempty(transport.conns_per_host)
740774
end
@@ -779,7 +813,7 @@ end
779813
end
780814
@test err === policy_err
781815
@test bucket.partitions["127.0.0.1"].capacity == 10
782-
@test isempty(@atomic :acquire bucket.depleted_partitions)
816+
@test (@atomic :acquire bucket.depleted_partitions) == 0
783817
@test lock(transport.lock) do
784818
isempty(transport.conns_per_host)
785819
end
@@ -925,7 +959,7 @@ end
925959
# The armed retry reserved 10 and recovered with a 200, so the
926960
# reservation was refunded in full instead of consumed (#1353).
927961
@test bucket.partitions["127.0.0.1"].capacity == 20
928-
@test isempty(@atomic :acquire bucket.depleted_partitions)
962+
@test (@atomic :acquire bucket.depleted_partitions) == 0
929963
finally
930964
HTTP.@try_ignore NC.close(listener)
931965
end

test/public_api_tests.jl

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,11 @@ using HTTP
4848
@test true # `public` unsupported before 1.11; nothing to assert
4949
end
5050

51-
# Internals must stay private regardless of Julia version.
52-
@test !Base.ispublic(HTTP, :_retryable_request_error)
53-
@test !Base.ispublic(HTTP, :_normalize_local_addr)
54-
@test !Base.ispublic(HTTP.WebSockets, :_ws_mask_into!)
51+
if isdefined(Base, :ispublic)
52+
@test !Base.ispublic(HTTP, :_retryable_request_error)
53+
@test !Base.ispublic(HTTP, :_normalize_local_addr)
54+
@test !Base.ispublic(HTTP.WebSockets, :_ws_mask_into!)
55+
else
56+
@test true
57+
end
5558
end

0 commit comments

Comments
 (0)