@@ -43,6 +43,19 @@ class LeaseGrant:
4343 expires_at : float
4444
4545
46+ @dataclass
47+ class _Flight :
48+ """An in-flight wire call under single-flight.
49+
50+ ``requested_additional`` is the additional amount an extend's wire call
51+ asked for, the figure a joiner compares its own shortfall against. Acquire
52+ flights share the type and leave it unset.
53+ """
54+
55+ task : "asyncio.Future[Optional[LeaseState]]"
56+ requested_additional : Optional [float ] = None
57+
58+
4659class LeaseWireClient (Protocol ):
4760 """The three lease calls the manager makes.
4861
@@ -151,8 +164,8 @@ def __init__(
151164 self ._clock = clock
152165 # Kept separate so an in-flight extend can never satisfy an acquire,
153166 # or the other way round.
154- self ._inflight_acquire : Dict [str , "asyncio.Future[Optional[LeaseState]]" ] = {}
155- self ._inflight_extend : Dict [str , "asyncio.Future[Optional[LeaseState]]" ] = {}
167+ self ._inflight_acquire : Dict [str , _Flight ] = {}
168+ self ._inflight_extend : Dict [str , _Flight ] = {}
156169 # Every task shutdown has to wait out, whatever it resolves to: the
157170 # fire-and-forget work from `_spawn` and the single-flight acquires and
158171 # extends, which resolve to a LeaseState.
@@ -193,7 +206,7 @@ async def acquire_if_needed(
193206 key = lease_key (company_id , credit_type_id )
194207 inflight = self ._inflight_acquire .get (key )
195208 if inflight is not None :
196- return await asyncio .shield (inflight )
209+ return await asyncio .shield (inflight . task )
197210 return await self ._single_flight (
198211 self ._inflight_acquire , key , self ._acquire (company_id , credit_type_id , timeout )
199212 )
@@ -254,7 +267,24 @@ async def maybe_extend(
254267 Triggered by either the low-water-mark ratio (steady-state refresh) or
255268 a ``required_credits`` hint above the local remaining (a check just
256269 failed a reserve of that size).
270+
271+ A caller arriving while an extend is in flight joins it. If its own
272+ shortfall is larger than what that extend asked for, it waits the
273+ flight out and then issues exactly one follow-up extend for the
274+ remaining difference: otherwise it would inherit a tranche-sized ask
275+ and fail its post-extend retry with credits still sitting on the
276+ server.
257277 """
278+ return await self ._maybe_extend (company_id , credit_type_id , required_credits , timeout , True )
279+
280+ async def _maybe_extend (
281+ self ,
282+ company_id : str ,
283+ credit_type_id : str ,
284+ required_credits : Optional [float ],
285+ timeout : Optional [float ],
286+ allow_follow_up : bool ,
287+ ) -> Optional [LeaseState ]:
258288 try :
259289 entry = await self ._lease_store .get (company_id , credit_type_id )
260290 except Exception as err :
@@ -274,30 +304,54 @@ async def maybe_extend(
274304 if not below_watermark and not below_required :
275305 return entry
276306
307+ # Size the extend to cover the request that triggered it: a single
308+ # check needing more than remaining plus one tranche would otherwise
309+ # fail its post-extend retry forever, however much balance the server
310+ # has. The steady-state path keeps asking for the configured tranche.
311+ # Sized here, one level above the wire call, so the flight registered
312+ # below and the request body provably carry the same number for a
313+ # joiner to compare against.
314+ shortfall = (required_credits - entry .local_remaining_credits ) if required_credits is not None else 0.0
315+ additional_amount = max (resolved .lease_size , shortfall )
316+
277317 key = lease_key (company_id , credit_type_id )
278318 inflight = self ._inflight_extend .get (key )
279319 if inflight is not None :
280- return await asyncio .shield (inflight )
320+ joined = await asyncio .shield (inflight .task )
321+ # The flight already asked for at least what we need: every
322+ # watermark-driven joiner, and any check the tranche covers. One
323+ # wire call serves all of them, which is the point of single-flight.
324+ if additional_amount <= (inflight .requested_additional or 0.0 ) or not allow_follow_up :
325+ return joined
326+ # The flight we waited out has settled. Its own cleanup usually
327+ # runs first, but leaving it registered would have the follow-up
328+ # join a finished flight and issue nothing.
329+ if self ._inflight_extend .get (key ) is inflight :
330+ del self ._inflight_extend [key ]
331+ # Our shortfall outran the flight's ask. We waited it out rather
332+ # than racing a second extend onto the same lease; now top up the
333+ # difference with exactly one more, re-reading the slot the flight
334+ # just moved. No follow-up on the follow-up: when the server cannot
335+ # cover the request, a chain would spin.
336+ return await self ._maybe_extend (company_id , credit_type_id , required_credits , timeout , False )
281337 return await self ._single_flight (
282- self ._inflight_extend , key , self ._extend (entry , resolved , required_credits , timeout )
338+ self ._inflight_extend ,
339+ key ,
340+ self ._extend (entry , resolved , additional_amount , timeout ),
341+ additional_amount ,
283342 )
284343
285344 async def _extend (
286345 self ,
287346 entry : LeaseState ,
288347 resolved : ResolvedLeaseConfig ,
289- required_credits : Optional [ float ] ,
348+ additional_amount : float ,
290349 timeout : Optional [float ] = None ,
291350 ) -> Optional [LeaseState ]:
292- # Size the extend to cover the request that triggered it: a single
293- # check needing more than remaining plus one tranche would otherwise
294- # fail its post-extend retry forever, however much balance the server
295- # has. The steady-state path keeps asking for the configured tranche.
296- shortfall = (required_credits - entry .local_remaining_credits ) if required_credits is not None else 0.0
297351 try :
298352 grant = await self ._wire .extend (
299353 entry .lease_id ,
300- max ( resolved . lease_size , shortfall ) ,
354+ additional_amount ,
301355 self ._clock () + resolved .lease_duration ,
302356 timeout ,
303357 )
@@ -395,12 +449,14 @@ def stop(self) -> None:
395449
396450 async def _single_flight (
397451 self ,
398- registry : Dict [str , "asyncio.Future[Optional[LeaseState]]" ],
452+ registry : Dict [str , _Flight ],
399453 key : str ,
400454 coro : Awaitable [Optional [LeaseState ]],
455+ requested_additional : Optional [float ] = None ,
401456 ) -> Optional [LeaseState ]:
402457 task = asyncio .ensure_future (coro )
403- registry [key ] = task
458+ flight = _Flight (task = task , requested_additional = requested_additional )
459+ registry [key ] = flight
404460 # The registry dedupes concurrent callers and the drain set waits the
405461 # wire call out; they have different lifetimes. Cancelling a caller
406462 # cancels its `shield`, not the task, and drops the registry entry the
@@ -411,7 +467,10 @@ async def _single_flight(
411467 try :
412468 return await asyncio .shield (task )
413469 finally :
414- if registry .get (key ) is task :
470+ # Identity-guarded rather than an unconditional delete: a joiner
471+ # whose shortfall outran this flight registers a follow-up under
472+ # the same key, and this cleanup must not evict it.
473+ if registry .get (key ) is flight :
415474 del registry [key ]
416475
417476 async def _release (self , lease_id : str ) -> None :
0 commit comments