Skip to content

Commit 504c48b

Browse files
committed
feat: adopt remaining_ttl_ms (spec PR #148) for normative heartbeat scheduling
Spec review round 5 (user-approved) proved regime detection from (grant, elapsed) samples undecidable in general: any real per-extend grant in the sticky window [0.75*min(ttl/2, 30s), 0.9*ttl) tracks the held cadence closely enough to stay classified lead-clamp while the lease erodes to a lapse (ttl 24s, +10s grants -> held 12s -> ratio 10/12 inside the [0.75, 1.25] band forever, -2s per cycle). The protocol gained a server-authoritative field; this adopts it. Models: optional `remaining_ttl_ms` on ReservationCreateResponse and ExtendResponse (int64 >= 0, remaining lifetime at response evaluation, same clock snapshot as expires_at_ms; absent on dry-run/DENY and on older servers). Heartbeat (src/heartbeat.rs), NORMATIVE when present on a response: - lead_floor = max(0, remaining_ttl_ms - rtt), rtt = monotonic send->receive elapsed (unknown -> 0); max observed rtt tracked. - retry_reserve = min(lead_floor/2, max(1s, 2*max_observed_rtt)) - next beat = lead_floor - retry_reserve after response receipt, recomputed from EVERY field-carrying response; expiry-difference accumulation is never used for scheduling in this mode. - First beat: a field-carrying create derives the first delay from the same formula instead of the immediate prime (no wasted primed extension under max-lead clamping; a capped 1s lease first-beats at ~500ms, inside the lease). - The lead_min skip check is BYPASSED while the latest successful response carried the field (exact schedule; a heuristic skip could overshoot the real lease); the grant ledger keeps running so the heuristic resumes seamlessly if the field disappears. - Transient failure: same-key retry after clamp(lead_estimate/4, 1s, 30s), lead_estimate = last lead_floor - elapsed since that response. - Extend-by-requested-ttl, permanent stops, 2xx-as-applied unchanged. Fallback (no field): v2.3 grant-ledger behavior unchanged in code; docs amended in place to mark the [0.75, 1.25] band as best-effort for per-extend-delta clamping legacy servers only, with remaining_ttl_ms as the normative path. Tests: five new wiremock scenarios (responders optionally emitting remaining_ttl_ms, constant or first-n-only) - normative steady state with skip bypass and no immediate prime; capped-create first beat inside the lease; field-carrying max-lead clamp at ~cap - reserve with no collapse; field disappearing mid-flight with the heuristic resuming (and skipping) on the maintained ledger; normative same-key transient retry. Pure-function pins: 60s remaining -> 59s delay, rtt widening, lead-floor saturation, retry clamp bounds. Wire-format serde tests for the optional field on both responses. All 11 legacy fallback tests unchanged and green. CHANGELOG and AUDIT amended in place (same 0.3.1 release, PR #75).
1 parent 5f1f7ba commit 504c48b

8 files changed

Lines changed: 708 additions & 72 deletions

File tree

AUDIT.md

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
---
1010

11-
## 2026-07-27 — heartbeat extend-drift fix, grant-ledger v2.3 (v0.3.1)
11+
## 2026-07-27/28 — heartbeat extend-drift fix: `remaining_ttl_ms` normative scheduling + grant-ledger fallback (v0.3.1)
1212

1313
P1 liveness, fleet-wide (same bug in all four SDKs). The spec's `extend_by_ms`
1414
is relative to the reservation's *current* `expires_at_ms`, not request time,
@@ -39,7 +39,29 @@ found that under a server-side **maximum-LEAD clamp** (extend re-stamps
3939
`expires_at ≈ now + L`) successive `expires_at_ms` differences measure
4040
*elapsed time, not lease* — grant-derived cadence is self-referential there
4141
and collapses to the 500 ms floor, burning `max_extensions` in seconds.
42-
Final **grant-ledger (v2.3)** design: correctness rests on
42+
Spec review round 5 (user-approved) then proved regime detection from
43+
`(grant, elapsed)` samples **undecidable in general** — real per-extend
44+
grants in the sticky window `[0.75·min(ttl/2, 30 s), 0.9·ttl)` track the
45+
held cadence closely enough to stay classified lead-clamp while the lease
46+
erodes to a lapse (ttl 24 s, +10 s grants → held 12 s → ratio 10/12 inside
47+
the band forever, −2 s per cycle) — so the protocol gained a
48+
server-authoritative field (spec PR #148): **`remaining_ttl_ms`** on both
49+
`ReservationCreateResponse` and `ExtendResponse` (int64 ≥ 0, remaining
50+
lifetime at response evaluation, same clock snapshot as `expires_at_ms`,
51+
optional in the client models for back-compat). When present, scheduling is
52+
**normative**: `lead_floor = max(0, remaining − rtt)` (rtt = monotonic
53+
send→receive elapsed, unknown → 0; max observed rtt tracked per heartbeat),
54+
`retry_reserve = min(lead_floor/2, max(1 s, 2·max_rtt))`, next beat at
55+
`lead_floor − retry_reserve` after receipt — recomputed from every
56+
field-carrying response, never from accumulated expiry differences; the
57+
`lead_min` skip check is bypassed (exact schedule; a heuristic skip could
58+
overshoot the real lease) while the ledger keeps running for seamless
59+
fallback if the field disappears; transient failures retry same-key after
60+
`clamp(lead_estimate/4, 1 s, 30 s)`; a field-carrying create derives the
61+
first beat from the same formula instead of the immediate prime (no wasted
62+
extension under max-lead clamping). The grant-ledger heuristic below is
63+
retained verbatim as the fallback for servers without the field.
64+
**Grant-ledger fallback (v2.3)** design: correctness rests on
4365
`lead_min = grants_sum − elapsed` (signed, starts 0), a rigorous lower bound
4466
built only from same-frame arithmetic — each grant is the difference of
4567
successive server-frame `expires_at_ms` values (reserve's threaded from the
@@ -59,17 +81,25 @@ equivalent, hand-rolled for variable delays). Any 2xx counts as applied
5981
(`expires_at_ms` authoritative, warn on odd status); permanent codes
6082
(`RESERVATION_EXPIRED`/`RESERVATION_FINALIZED`/`MAX_EXTENSIONS_EXCEEDED`/
6183
`TENANT_CLOSED`/`NOT_FOUND` or HTTP 410/404) stop the heartbeat.
62-
Cancellation unchanged. Eleven wiremock tests with dynamic expiry responders
63-
(immediate first beat + extend@0/1000/2000, skip@3000, extend@4000; capped
64-
grant keeping `extend_by_ms` at the request while the immediate beat
65-
discovers the cap; 503 on the immediate beat → single held-cadence retry
66-
with the same key; permanent stops; small-ttl liveness; per-extend grant
67-
clamp still tightening to grant/2; lead-clamp echo responder holding
68-
cadence instead of collapsing; zero-grant immediate prime holding cadence;
69-
unknown-status 200 as applied) + extracted pure cadence/regime functions
70-
unit-tested (incl. the 30 s held-cadence cap and the lead-clamp band
71-
boundaries) in `src/heartbeat.rs`; `Date`-parsing unit tests in
72-
`src/response.rs`. Coverage 95.12%; tests, clippy `-D warnings`, fmt green.
84+
Cancellation unchanged. Sixteen wiremock tests with dynamic expiry
85+
responders — fallback: immediate first beat + extend@0/1000/2000,
86+
skip@3000, extend@4000; capped grant keeping `extend_by_ms` at the request
87+
while the immediate beat discovers the cap; 503 on the immediate beat →
88+
single held-cadence retry with the same key; permanent stops; small-ttl
89+
liveness; per-extend grant clamp still tightening to grant/2; lead-clamp
90+
echo responder holding cadence instead of collapsing; zero-grant immediate
91+
prime holding cadence; unknown-status 200 as applied — normative: no prime
92+
+ `lead_floor − retry_reserve` first beat; skip bypass under accumulating
93+
grants; capped 1 s lease first-beating at ~500 ms inside the lease;
94+
field-carrying max-lead clamp at ~cap − reserve with no collapse; field
95+
disappearing mid-flight → heuristic resuming (with its skip) on the
96+
ledger maintained through the normative phase; same-key normative retry at
97+
`clamp(lead/4, 1 s, 30 s)`. Extracted pure scheduling functions
98+
unit-tested (30 s held-cadence cap, lead-clamp band boundaries, 60 s → 59 s
99+
normative delay pin, rtt widening/saturation, retry-clamp bounds) in
100+
`src/heartbeat.rs`; `remaining_ttl_ms` wire-format serde tests in
101+
`tests/models_test.rs`; `Date`-parsing unit tests in `src/response.rs`.
102+
Coverage 95.26%; tests, clippy `-D warnings`, fmt green.
73103

74104
## 2026-07-27 — v0.3.0 self-review hardening
75105

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
66

77
## [0.3.1] - 2026-07-27
88

9-
Heartbeat extend-drift fix (P1 liveness, fleet-wide — same bug in all four SDKs), refined under four rounds of adversarial + spec review: alternate-beat → lead-estimate → grant-ledger → **grant-ledger with immediate first beat and lead-clamp regime (v2.3)**.
9+
Heartbeat extend-drift fix (P1 liveness, fleet-wide — same bug in all four SDKs), refined under five rounds of adversarial + spec review: alternate-beat → lead-estimate → grant-ledger → grant-ledger with immediate first beat and lead-clamp regime (v2.3) → **server-authoritative `remaining_ttl_ms` scheduling with the v2.3 heuristic as fallback (round 5)**.
10+
11+
### Added
12+
13+
- **`remaining_ttl_ms` support (spec PR #148) — normative heartbeat scheduling.** Round 5 of the spec review proved regime detection from `(grant, elapsed)` samples **undecidable in general**: any real per-extend grant in the sticky window `[0.75·min(ttl/2, 30 s), 0.9·ttl)` tracks the held cadence closely enough to stay classified lead-clamp while the lease erodes to a lapse (e.g. ttl 24 s with real +10 s grants → held cadence 12 s → post-skip ratio 10/12 sits inside the `[0.75, 1.25]` band forever, losing 2 s per cycle). The protocol therefore gained a server-authoritative field: `remaining_ttl_ms` (int64 ≥ 0) on **both** `ReservationCreateResponse` and `ExtendResponse` — the remaining reservation lifetime in ms at response evaluation, same clock snapshot as `expires_at_ms`, present on successful live-reservation responses (absent on dry-run/DENY and on older servers; the field is optional in the client models for back-compat).
14+
15+
When a successful response carries the field, scheduling is **normative**: `lead_floor = max(0, remaining_ttl_ms − rtt)` (rtt = monotonic elapsed between sending the call and receiving the response; unknown → 0), `retry_reserve = min(lead_floor/2, max(1 s, 2·max_observed_rtt))`, and the next beat lands `lead_floor − retry_reserve` after response receipt — recomputed from **every** field-carrying response, never from accumulated expiry differences. The `lead_min` skip heuristic is **bypassed** in this mode (the schedule is exact; a heuristic skip could overshoot the real lease), but the grant ledger keeps running in the background so the heuristic resumes seamlessly if a later response omits the field (mixed fleets, rollbacks). A transient failure retries with the **same idempotency key** after `clamp(lead_estimate/4, 1 s, 30 s)`, where `lead_estimate` is the last `lead_floor` minus the monotonic time since that response (saturating at 0). When the **create** response carries the field, the first beat is derived from the same formula instead of the immediate prime — a 60 s remaining lease first beats at 59 s, a tenant-capped 1 s lease at 500 ms (inside the lease), and no primed extension is spent even under a maximum-lead clamp. Extend amount (requested ttl), permanent stops, and 2xx-as-applied are unchanged.
1016

1117
### Fixed
1218

@@ -24,12 +30,14 @@ Heartbeat extend-drift fix (P1 liveness, fleet-wide — same bug in all four SDK
2430

2531
Spec-review follow-ups (rounds 2–4, same release): **tenant policy `max_reservation_ttl_ms` silently caps the granted TTL** at reserve (governance default 1 hour), and the create response has no effective-TTL field — so seeding the heartbeat from the *requested* TTL alone schedules the first beat far too late (a 24 h request capped to 1 h → first beat at 12 h, 11 h after expiry). Round 2 recovered an "effective TTL" as `clamp(expires_at_ms − Date, 1000 ms, requested)` and let it drive the whole scheduler; **round 3 rejected the HTTP `Date` header as a correctness input**: RFC 9110's `Date` is a whole-second, *best-effort origination* timestamp that intermediaries may replace, and in cycles-server `expires_at_ms` is stamped from Redis `TIME` while `Date` comes from the HTTP layer — not the same clock, so the difference is not a lease measurement, and the 1000 ms upward clamp could *fabricate* lease the server never granted. **Round 4 removed lease estimation from scheduling entirely**, on two confirmed findings:
2632
- **Any bounded first-beat delay can outlive a small capped lease** (a 30 s cap is still 28 s too late for a 2 s grant), so v2.3's **first extend fires immediately**. It costs one extension, but it is the only schedule that provably beats an arbitrarily small lease — and its response primes the grant ledger with a *real* grant sample that paces every later beat. The `Date`-derived hint is gone from the heartbeat path; `ApiResponse::date_ms` and the `httpdate` parsing (a direct dependency — already in the tree via hyper) remain as general response utilities the SDK derives no behavior from.
27-
- **Grant-derived cadence is only valid for real per-extend grants.** Under a server-side **maximum-LEAD clamp** (every extend re-stamps `expires_at ≈ now + L` instead of adding lease), successive `expires_at_ms` differences measure *elapsed time*, not lease — so pacing by them is self-referential: the observed "grant" shrinks to whatever the cadence is, the cadence halves in response, and within a few beats it collapses to the 500 ms floor, burning `max_extensions` in seconds. v2.3 classifies each success (`is_lead_clamp_grant`): a grant that is non-positive, or that is both `< 0.9·requested` and within `[0.75, 1.25]×` the elapsed time since the last success (the signature of a clock reading, not a lease), enters the **lead-clamp regime** — cadence held at `min(requested/2, 30 s)`, never tightened, with a `tracing::warn` once per heartbeat (the allowance is still depleting, just at the held pace). The lower `0.75×` band arm lets a *real* but small per-extend grant recover: after a skip doubles the inter-success gap a fixed grant falls below the band and the cadence tightens again, whereas a lead-clamped "grant" tracks the gap and stays inside the band.
33+
- **Grant-derived cadence is only valid for real per-extend grants.** Under a server-side **maximum-LEAD clamp** (every extend re-stamps `expires_at ≈ now + L` instead of adding lease), successive `expires_at_ms` differences measure *elapsed time*, not lease — so pacing by them is self-referential: the observed "grant" shrinks to whatever the cadence is, the cadence halves in response, and within a few beats it collapses to the 500 ms floor, burning `max_extensions` in seconds. v2.3 classifies each success (`is_lead_clamp_grant`): a grant that is non-positive, or that is both `< 0.9·requested` and within `[0.75, 1.25]×` the elapsed time since the last success (the signature of a clock reading, not a lease), enters the **lead-clamp regime** — cadence held at `min(requested/2, 30 s)`, never tightened, with a `tracing::warn` once per heartbeat (the allowance is still depleting, just at the held pace). The lower `0.75×` band arm lets a *real* but small per-extend grant recover: after a skip doubles the inter-success gap a fixed grant falls below the band and the cadence tightens again, whereas a lead-clamped "grant" tracks the gap and stays inside the band. **Round 5 proved this band undecidable in general** (see the `remaining_ttl_ms` entry above): it survives only as a best-effort fallback for legacy servers that clamp per-extend deltas; `remaining_ttl_ms` is the normative path.
2834

2935
Correctness lives entirely in the grant ledger above: from the first (immediate) extend response onward the cadence follows the server's *observed* grants, which — unlike any `Date` arithmetic — are same-frame by construction. The permanent stop set also gained `TENANT_CLOSED` (tenant closure is irreversible without administrative action) and `NOT_FOUND` / raw HTTP 404 (a 404'd reservation never returns).
3036

3137
Cancellation semantics are unchanged (`CancellationToken`; the guard cancels on commit/release/drop). Regression tests (`tests/heartbeat_test.rs`, wiremock with dynamic `expires_at_ms` responders) pin: the immediate first beat (an extend arrives well before ttl/2) and the v2.3 full-grant cadence extend@0/1000/2000 ms / skip@3000 (bound exactly `1.5·grant`, inclusive) / extend@4000; the capped scenario (requested 8000 / granted 2000 → the immediate beat discovers the cap that a requested/2 schedule would have found 2 s after expiry, cadence tracking the *observed* grant at 1000 ms, wire `extend_by_ms` staying the requested 8000); a 503 on the immediate first beat retrying at the held cadence — exactly one attempt inside the first margin, never a zero-delay hot loop — with the **same** idempotency key, then a fresh key after success (asserted from received request bodies); permanent-failure stop for 409 `MAX_EXTENSIONS_EXCEEDED`, 409 `TENANT_CLOSED`, and 404 `NOT_FOUND` (no further requests); ttl 1200 staying alive across 600 ms beats with the skip landing exactly at the threshold beat; a per-extend grant clamp (+ttl/4) still *tightening* the cadence to grant/2 = 500 ms; a **lead-clamp responder** (echoing `reserve_expiry + elapsed-at-receipt`) holding the cadence at requested/2 instead of collapsing to the 500 ms floor; a zero-grant immediate prime holding the cadence likewise; and a 200 with an unknown status counting as applied (fresh key next beat + the steady-state skip still occurring — pinning both the resolution and the ledger update). The pure cadence/regime computations are extracted as functions and unit-tested in `src/heartbeat.rs` — including the 30 s held-cadence cap for huge TTLs, which would be impractical to wait out in wall-clock tests — alongside skip-threshold, grant-cadence, lead-clamp-band boundary, and permanent-classification tests; `Date`-parsing unit tests remain in `src/response.rs` (the parsing is now a general utility, exercised end-to-end in `tests/response_test.rs`).
3238

39+
**Normative-mode tests** (round 5): wiremock responders now optionally emit `remaining_ttl_ms` (constant, or only on the first *n* responses) and pin — no immediate prime and a `lead_floor − retry_reserve` first beat when the create carries the field; ~1 s steady normative cadence with the `lead_min` skip **bypassed** even when accumulated grants would trip it; a tenant-capped 1 s lease first-beating at ~500 ms (inside the lease); a max-lead-clamping field-carrying server scheduling at ~cap − reserve with no cadence collapse and no primed extension; the field disappearing mid-flight with the heuristic resuming at the observed cadence **and** skipping on the ledger maintained through the normative phase; and a transient failure in normative mode retrying with the same idempotency key after `clamp(lead/4, 1 s, 30 s)`. Pure-function pins in `src/heartbeat.rs` cover the 59 s delay for a 60 s remaining lease, rtt/max-rtt widening, the lead-floor saturation, and the retry clamp bounds; wire-format serde tests in `tests/models_test.rs` cover the optional field on both responses.
40+
3341
## [0.3.0] - 2026-07-27
3442

3543
Commit durability: expired-commit event fallback, plus `Retry-After`-aware retry.

src/client.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,12 @@ impl CyclesClient {
169169
validation::validate_grace_period_ms(req.grace_period_ms)?;
170170
validation::validate_non_negative(req.estimate.amount, "estimate.amount")?;
171171

172+
// Round-trip time of the reserve call: when the response carries
173+
// remaining_ttl_ms (spec PR #148), the heartbeat subtracts this from
174+
// it to get a floor on the lease actually left at receipt.
175+
let sent_at = std::time::Instant::now();
172176
let resp = self.create_reservation(&req).await?;
177+
let create_rtt_ms = u64::try_from(sent_at.elapsed().as_millis()).unwrap_or(u64::MAX);
173178

174179
if resp.decision.is_denied() {
175180
return Err(Error::BudgetExceeded {
@@ -229,6 +234,8 @@ impl CyclesClient {
229234
resp.expires_at_ms,
230235
resp.affected_scopes.clone(),
231236
req.ttl_ms,
237+
resp.remaining_ttl_ms,
238+
create_rtt_ms,
232239
req.subject.clone(),
233240
req.action.clone(),
234241
))

src/guard.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,20 +83,27 @@ impl ReservationGuard {
8383
expires_at_ms: Option<u64>,
8484
affected_scopes: Vec<String>,
8585
requested_ttl_ms: u64,
86+
remaining_ttl_ms: Option<u64>,
87+
create_rtt_ms: u64,
8688
subject: Subject,
8789
action: Action,
8890
) -> Self {
8991
let cancel = CancellationToken::new();
9092
// The reserve response's expires_at_ms (server frame) is the base of
91-
// the heartbeat's grant ledger. The first extend fires immediately —
92-
// a tenant policy may have silently capped the lease far below the
93-
// requested TTL, and no bounded delay provably beats an arbitrarily
94-
// small grant; see src/heartbeat.rs module docs.
93+
// the heartbeat's grant ledger. When the response carries
94+
// remaining_ttl_ms (spec PR #148) the heartbeat schedules from it
95+
// normatively, with create_rtt_ms bounding the sample's staleness;
96+
// otherwise the first extend fires immediately — a tenant policy may
97+
// have silently capped the lease far below the requested TTL, and no
98+
// bounded delay provably beats an arbitrarily small grant; see
99+
// src/heartbeat.rs module docs.
95100
let heartbeat = start_heartbeat(
96101
client.clone(),
97102
id.clone(),
98103
requested_ttl_ms,
99104
expires_at_ms,
105+
remaining_ttl_ms,
106+
create_rtt_ms,
100107
cancel.clone(),
101108
);
102109

0 commit comments

Comments
 (0)