Skip to content

Commit 407bc0c

Browse files
committed
give a lease-extend joiner its own shortfall
1 parent e3ea339 commit 407bc0c

5 files changed

Lines changed: 302 additions & 16 deletions

File tree

‎conformance/SPEC.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,13 @@ Rules:
270270
Sizing to the shortfall matters: a single check needing more than `remaining + lease_size`
271271
would otherwise fail its post-extend retry forever regardless of server balance.
272272
`expires_at = now + lease_duration_ms`.
273+
- A caller that joins an extend already in flight must be sized too. If its own
274+
`additional_amount` exceeds the one the in-flight extend asked for, it waits that flight out
275+
and then issues **exactly one** further extend, re-sized against the slot the flight just
276+
moved; if the flight's ask already covers it, it issues nothing. A joiner that silently
277+
inherits a tranche-sized ask fails its post-extend retry with credits sitting on the server.
278+
The follow-up never chains — a company whose balance cannot reach the request would otherwise
279+
spin.
273280
- On response, reconcile via the store's `extend` with the server's **total** and new expiry,
274281
**pinned** to the extended lease's id.
275282
- Failures resolve to "no lease" without throwing (often fire-and-forget).
@@ -431,7 +438,11 @@ to demonstrate; ports must uphold them and should test them natively.
431438
stores balances as strings to avoid integer truncation.
432439
4. **Single-flight.** Per-process, per-slot single-flight for acquire and for extend, tracked
433440
separately. Best-effort only: duplicate wire calls are safe (idempotent server + keep-first
434-
`replace` + reconcile-to-total `extend`).
441+
`replace` + reconcile-to-total `extend`). An extend flight carries the `additional_amount` it
442+
asked for: a joiner whose required shortfall exceeds that figure waits the flight out and
443+
then issues exactly one further extend for the remaining shortfall, while a joiner the flight
444+
already covers — every watermark-driven one, the common case — issues nothing and shares the
445+
single wire call.
435446
5. **Concurrent cross-pod extends converge.** Two pods extending from the same stale read must
436447
not double-count — guaranteed by reconcile-to-total computed inside the store (the sequential
437448
out-of-order-totals vector pins the arithmetic; the concurrent schedule needs a race).

‎src/schematic/leases/lease_manager.py‎

Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
4659
class 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:

‎tests/lease_support.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import asyncio
1112
import datetime as dt
1213
from typing import Any, Awaitable, Dict, List, Optional, cast
1314

@@ -148,6 +149,26 @@ def __init__(self) -> None:
148149
# Runs while an acquire is in flight, for emulating a sibling pod
149150
# winning the race.
150151
self.during_acquire: Optional[Any] = None
152+
# The same seam on extend, for holding one open while another caller
153+
# joins it.
154+
self.during_extend: Optional[Any] = None
155+
156+
def hold_extend(self) -> "tuple[asyncio.Event, asyncio.Event]":
157+
"""Hold the next extend wire call open.
158+
159+
The first event fires once that call has landed, the second releases
160+
it, so a test can place a joining caller against a flight it knows is
161+
in flight rather than against a sleep.
162+
"""
163+
arrived = asyncio.Event()
164+
release = asyncio.Event()
165+
166+
async def hold() -> None:
167+
arrived.set()
168+
await release.wait()
169+
170+
self.during_extend = hold
171+
return arrived, release
151172

152173
async def acquire(
153174
self,
@@ -195,6 +216,10 @@ async def extend(
195216
"timeout": timeout,
196217
}
197218
)
219+
during = self.during_extend
220+
if during is not None:
221+
self.during_extend = None
222+
await during()
198223
scripted = self.extend_responses.pop(0) if self.extend_responses else None
199224
lease = _scripted_lease(scripted, "unscripted extend wire call")
200225
return LeaseGrant(

‎tests/leases/test_check_and_track.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,57 @@ async def test_extends_once_and_retries_when_the_lease_is_short(self, clock: Vir
427427
assert len(flow.wire.extend_calls) == 1
428428
assert await flow.remaining() == 600
429429

430+
async def test_allows_a_check_needing_more_than_the_extend_in_flight_asked_for(
431+
self, clock: VirtualClock
432+
) -> None:
433+
# A sub-water-mark check fires a background extend for one tranche, and
434+
# a check needing 1500 arrives while it is in flight. Inheriting the
435+
# tranche would leave that check at 1200 local and denied for
436+
# insufficient balance with the credits sitting on the server.
437+
flow = make_flow(clock)
438+
arrived, release = flow.wire.hold_extend()
439+
for granted_total in (2000, 3000, 4000):
440+
flow.wire.extend_responses.append(
441+
{"lease": {"granted_total": granted_total, "expires_at": clock() + LEASE_DURATION}}
442+
)
443+
444+
# 80 at a rate of 10 draws 800 of the 1000-credit lease, leaving 200:
445+
# below the water mark, so this check's background extend goes out and
446+
# is held open.
447+
first = await check_with_lease(
448+
flow.deps, FLAG_KEY, COMPANY, None, CheckOptions(usage=80, event_subtype=EVENT_SUBTYPE), flow.fallback
449+
)
450+
assert first.allowed is True
451+
await arrived.wait()
452+
assert flow.wire.extend_calls[0]["additional_amount"] == LEASE_SIZE
453+
454+
# 150 at a rate of 10 is 1500 against 200 local: the reserve fails and
455+
# the check asks for an extend, joining the tranche-sized flight.
456+
second_task = asyncio.ensure_future(
457+
check_with_lease(
458+
flow.deps,
459+
FLAG_KEY,
460+
COMPANY,
461+
None,
462+
CheckOptions(usage=150, event_subtype=EVENT_SUBTYPE),
463+
flow.fallback,
464+
)
465+
)
466+
for _ in range(10):
467+
await asyncio.sleep(0)
468+
assert len(flow.wire.extend_calls) == 1
469+
470+
release.set()
471+
second = await second_task
472+
473+
assert second.allowed is True
474+
assert second.reservation is not None
475+
assert second.reservation.credits_reserved == 1500
476+
# The follow-up, sized against the slot the first flight moved to 1200.
477+
assert len(flow.wire.extend_calls) == 2
478+
assert flow.wire.extend_calls[1]["additional_amount"] == LEASE_SIZE
479+
await flow.manager._drain_background()
480+
430481
async def test_denies_when_the_retry_after_a_failed_extend_is_still_short(self, clock: VirtualClock) -> None:
431482
flow = make_flow(clock)
432483
await flow.check()

0 commit comments

Comments
 (0)