fix: heartbeat extends on alternate beats to stop expiry drift (v0.3.1) - #75
fix: heartbeat extends on alternate beats to stop expiry drift (v0.3.1)#75amavashev wants to merge 5 commits into
Conversation
P1 liveness, fleet-wide (same bug in all four SDKs). The protocol's extend_by_ms is relative to the reservation's CURRENT expires_at_ms (cycles-protocol-v0.yaml), not to request time, but the heartbeat sent ExtendRequest::new(ttl_ms) on every ttl/2 beat — drifting the expiry outward by ttl/2 per beat. A killed process left the reserved budget locked until the drifted expiry (up to ~6xttl at the server's default max_extensions = 10), and extensions burned twice as fast as needed, so runs longer than ~max_extensions x ttl/2 exhausted the allowance and lost heartbeat protection mid-flight. The heartbeat now extends on alternate beats: the first beat extends (only ttl/2 of lifetime remains at that point), each successful ACTIVE extend skips exactly one beat, and a failed extend — transport/API error or an HTTP-success response whose status is not ACTIVE (forward-compat Unknown) — is retried on the very next beat. The extend amount stays ttl_ms, and the client clock is never compared against the server's expires_at_ms (clock skew makes that unsafe). Net: no drift, expiry lead oscillates within [ttl/2, 1.5*ttl], extension consumption halved. Three wiremock cadence regression tests (tests/heartbeat_test.rs): real short-TTL heartbeat asserting extend counts per beat — extend/skip/ extend, failure-then-immediate-retry, and non-ACTIVE-status-as-failure. Coverage 95.81% (tarpaulin); clippy -D warnings and fmt clean. Version 0.3.0 -> 0.3.1; CHANGELOG and AUDIT updated.
…0.3.1) Adversarial self-review of the alternate-beat cadence found confirmed liveness regressions; redesigned per the v2 lead-estimate design: - Interval is exactly ttl/2 — 1 s floor removed (spec-legal ttl in (1000, 2000) was guaranteed to lapse under the floor). - Per beat, estimate the expiry lead as (known_expiry - initial_expiry) + ttl - elapsed: server-frame minus server-frame, monotonic minus monotonic — the client wall clock is never compared against the server's (skew-free). The reserve response's expires_at_ms is threaded from the guard into the heartbeat. Skip iff lead >= 1.5*ttl, else extend by ttl_ms. Any lead shortfall (failure, clamped grant, small ttl) extends on the very next beat — no fixed cadence to lapse through. - Beats come from interval_at + MissedTickBehavior::Skip, so a slow extend RTT no longer slips every subsequent beat. - Any 2xx counts as applied — its expires_at_ms is authoritative proof — so known_expiry updates and an unrecognized status only warns. Reverses the previous non-ACTIVE-as-failure branch, which would have re-extended every beat (drift) against any newer server. - Transient failures keep and reuse their idempotency key on the next beat, so an applied-but-lost extension cannot double-extend on retry. - Permanent failures (RESERVATION_EXPIRED, RESERVATION_FINALIZED, MAX_EXTENSIONS_EXCEEDED, or any HTTP 410) stop the heartbeat instead of retrying forever. - Cancellation semantics unchanged (CancellationToken). Tests: six wiremock integration tests with dynamic expires_at_ms responders (v2 cadence extend/extend/skip/extend, same-key retry then fresh key, permanent-stop, ttl=1200 stays alive with 600 ms beats, clamped +ttl/4 grants extend every beat, unknown-status 2xx counts as applied) plus lead-math and permanent-classification unit tests. Coverage 95.70% (tarpaulin); cargo test, clippy -D warnings, fmt green. CHANGELOG [0.3.1] and AUDIT.md rewritten for the redesign.
Redesign: alternate-beat -> lead-estimate heartbeat (ba8be61)Adversarial self-review of the alternate-beat scheme shipped in 2aad4f8 found confirmed liveness regressions, so this PR now replaces it with a lead-estimate scheduler. What was wrong with alternate-beat
The v2 design
Tests
Plus unit tests for the lead math (inclusive 1.5·ttl threshold, negative lead on stall, clamped-grant never-skip) and permanent-failure classification in Verification
Follow-up: effective-TTL seeding + expanded permanent set (2aaf337)Spec review confirmed two more issues, folded into the same branch: 1. Tenant policy silently caps the granted TTL
2. Two more permanent stop codes
Tests added
Verification (follow-up)
|
Spec-review follow-up to the lead-estimate redesign: 1. Effective TTL. 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 — seeding the heartbeat from the requested TTL schedules the first beat far too late (24 h request capped to 1 h -> first beat at 12 h, 11 h after expiry). The client now captures the HTTP Date response header on reserve (new ApiResponse::date_ms, parsed with httpdate — promoted from transitive to direct dependency) and computes effective_ttl = clamp(expires_at_ms - date_ms, 1000, requested), falling back to the requested TTL when either sample is missing or the Date is unparseable. Both terms are server-frame, so the derivation stays clock-skew-free; the Date header's 1-second resolution is negligible against the spec's 1000 ms TTL minimum. The effective TTL drives everything: beat interval (effective/2), the lead formula's ttl term, the 1.5x skip threshold, and the per-beat extend amount. 2. Permanent stop set. TENANT_CLOSED (tenant closure is irreversible without administrative action) and NOT_FOUND / raw HTTP 404 (a 404'd reservation never returns) now terminate the heartbeat like RESERVATION_EXPIRED / RESERVATION_FINALIZED / MAX_EXTENSIONS_EXCEEDED / HTTP 410. Tests: capped scenario (requested 8000 / granted 2000 -> 1000 ms beats extending by 2000, full cadence derived from the effective TTL); garbage Date header falls back to the requested TTL; 409 TENANT_CLOSED and 404 NOT_FOUND stop the loop (shared scaffold with the MAX_EXTENSIONS_EXCEEDED test); effective-TTL unit tests (capped, never-exceeds-requested, clamps-to-minimum, fallbacks) and Date-parsing unit tests (IMF-fixdate, garbage/empty/invalid). The shared reserve mocks (tests/common/mod.rs) now stamp a Date header consistent with their fixed expires_at_ms so heartbeats in unrelated suites keep their intended 60 s effective TTL. Coverage 95.77% (tarpaulin); cargo test, clippy -D warnings, fmt green. CHANGELOG [0.3.1] and AUDIT.md extended.
…er lead lower bound Spec review round 3: the HTTP Date header is NOT a same-clock anchor for expires_at_ms (RFC 9110 whole-second best-effort origination timestamp, replaceable by intermediaries; in cycles-server expires_at_ms comes from Redis TIME while Date comes from the HTTP layer), and clamping the Date-derived estimate upward fabricated lease the server never granted. - Correctness now rests on lead_min = grants_sum - elapsed (signed, starts 0): a rigorous lower bound from same-frame arithmetic only (grants = differences of successive server-frame expires_at_ms). - Skip iff a grant sample exists and lead_min >= 1.5 * last_grant; otherwise extend by the REQUESTED ttl_ms. - Date-derived TTL is a raw, unclamped first-beat cadence hint: first delay = min(requested/2, 30s cap, hint/2 when > 0). - Post-success delay = clamp(last_grant/2, 500ms, requested/2); the cadence tracks observed grants. Transient failures retry at the current cadence with the same idempotency key. - Per-beat computed sleeps replace interval_at: scheduled from intended instants, realigned to now after stalls (Skip-equivalent, no burst). - Permanent stop set unchanged (6 codes + HTTP 410/404). Tests: wiremock suite retraced for v2.2 (extend@1..4/skip@5/extend@6; capped grant requested 8000 / +2000 grants -> 1000ms beats, extend_by stays 8000; garbage Date -> requested/2; same-key retry; permanent stops; small-ttl liveness; clamped grants speed cadence to 500ms floor; unknown-status 200 as applied). Pure delay/lead computations extracted and unit-tested (incl. 30s first-beat cap, raw unclamped hint). Coverage 95.73% (tarpaulin); clippy -D warnings and fmt clean. Same release 0.3.1; CHANGELOG/AUDIT amended in place.
|
v2.2 (spec review round 3): the HTTP Correctness now rests on a grant ledger: Wiremock suite retraced (cadence extend@1..4 / skip@5 / extend@6; capped grant: requested 8000 with +2000 grants → 1000 ms beats, |
Spec review round 4 (two confirmed findings, both fixed): 1. Any bounded first-beat delay can outlive a small tenant-capped lease (a 30 s cap is still 28 s too late for a 2 s grant). The first extend now fires IMMEDIATELY: it costs one extension but provably beats an arbitrarily small lease, and its response primes the grant ledger with a real grant sample. All Date-derived effective-TTL/hint scheduling is removed from the heartbeat path (start_heartbeat drops the hint parameter; reserve() no longer threads date_ms). ApiResponse::date_ms + httpdate parsing remain as general response utilities with unit and e2e tests. 2. Under a server-side maximum-LEAD clamp (extend re-stamps expires_at ~ now + L), successive expires_at_ms differences measure elapsed time, not lease — grant-derived cadence is self-referential and collapses to the 500 ms floor, burning max_extensions in seconds. Each success is now classified (is_lead_clamp_grant): a grant that is non-positive, or < 0.9*requested while within [0.75, 1.25]x elapsed-since-last-success (a clock reading, not a lease), holds the cadence at min(requested/2, 30 s), never tightened, with a tracing::warn once per heartbeat. The lower band arm lets a real-but-small per-extend grant re-tighten after a skip doubles the gap. The same held cadence paces retries before any grant sample exists, so a failed immediate first beat can never hot-loop at zero delay. Everything else unchanged: skip rule (lead_min >= 1.5*last_grant), requested-amount wire extends, same-key transient retry, permanent stop set, 2xx-as-applied, intended-instant scheduling, cancellation. Also folds in the uncommitted v2.2 grant-ledger baseline this refines (rounds 2-3: Date rejected as a correctness input, grant ledger with same-frame arithmetic, per-beat delays, TENANT_CLOSED/NOT_FOUND stops). Tests: wiremock suite reworked — immediate-first-beat cadence (extend@0/1000/2000, skip@3000, extend@4000), capped-grant discovery via the immediate beat, 503-on-first-beat single held-cadence retry with same idempotency key, lead-clamp echo responder holding cadence (no floor collapse), zero-grant immediate prime holding cadence, per-extend grant clamp (+ttl/4) still tightening to 500 ms, small-ttl liveness, permanent stops, unknown-status 2xx as applied. Pure cadence/regime functions unit-tested (held-cadence pins incl. 30 s cap, lead-clamp band boundaries, skip threshold, trace). Obsolete effective-TTL/date- hint tests removed; Date parsing tests kept; date_ms asserted e2e in response_test. CHANGELOG and AUDIT amended in place (same 0.3.1 release, PR #75).
|
v2.3 (spec review round 4) — pushed Two confirmed findings, both fixed in
Everything else unchanged (skip rule, requested-amount wire extends, key reuse, permanent stops, 2xx-as-applied, cancellation). Verification: full suite green (13 test targets incl. doc-tests), |
Summary
P1 liveness, fleet-wide (same bug in all four SDKs). The protocol's
extend_by_msextends relative to the reservation's currentexpires_at_ms(cycles-protocol-v0.yaml: "relative to its current expires_at_ms (i.e., not relative to request time)"), butsrc/heartbeat.rssentExtendRequest::new(ttl_ms)on everyttl/2beat — drifting the expiry outward byttl/2per beat.Consequences of the drift:
max_extensions(default 10 → up to ~6×ttl).max_extensions × ttl/2exhaust the allowance and lose heartbeat protection mid-flight.Fix — alternate-beat extension
Same sleep interval (
(ttl/2).max(1s)); the first beat extends (onlyttl/2of lifetime remains at that point); after each successfulACTIVEextend, exactly one beat is skipped; after a failed extend (transport/API error, or an HTTP-success response whose status is notACTIVE— forward-compatUnknown), the next beat retries immediately. Extend amount staysttl_ms. The client clock is deliberately never compared against the server'sexpires_at_ms— clock skew makes that unsafe.Net: no drift; expiry lead oscillates within
[ttl/2, 1.5·ttl]; extension consumption halved.The guard does not track expiry from extend responses (it stores the reserve-time
expires_at_msonce), so no lifecycle behavior changed there.Tests
New
tests/heartbeat_test.rs— three wiremock tests running a real short-TTL (1 s) heartbeat and asserting extend-call counts per beat (each request body checked forextend_by_ms == ttl_ms):ACTIVE.Verification
cargo test— full suite green (incl. 8 doc-tests)cargo clippy --all-targets --all-features -- -D warnings— cleancargo fmt --check— cleancargo tarpaulin— 95.81% (617/644 lines;heartbeat.rs+35.45%)Housekeeping
Cargo.toml+Cargo.lock)[0.3.1] - 2026-07-27; AUDIT.md dated entry