Skip to content

fix: heartbeat extends on alternate beats to stop expiry drift (v0.3.1) - #75

Open
amavashev wants to merge 5 commits into
mainfrom
fix/heartbeat-drift
Open

fix: heartbeat extends on alternate beats to stop expiry drift (v0.3.1)#75
amavashev wants to merge 5 commits into
mainfrom
fix/heartbeat-drift

Conversation

@amavashev

Copy link
Copy Markdown
Contributor

Summary

P1 liveness, fleet-wide (same bug in all four SDKs). The protocol's extend_by_ms extends relative to the reservation's current expires_at_ms (cycles-protocol-v0.yaml: "relative to its current expires_at_ms (i.e., not relative to request time)"), but src/heartbeat.rs sent ExtendRequest::new(ttl_ms) on every ttl/2 beat — drifting the expiry outward by ttl/2 per beat.

Consequences of the drift:

  • Kill the process after a long run and the reserved budget stays locked until the drifted expiry — a zombie window that scales with runtime, capped only by the server's max_extensions (default 10 → up to ~6×ttl).
  • Extensions burn twice as fast as needed, so runs longer than ~max_extensions × ttl/2 exhaust the allowance and lose heartbeat protection mid-flight.

Fix — alternate-beat extension

Same sleep interval ((ttl/2).max(1s)); the first beat extends (only ttl/2 of lifetime remains at that point); after each successful ACTIVE extend, exactly one beat is skipped; after a failed extend (transport/API error, or an HTTP-success response whose status is not ACTIVE — forward-compat Unknown), the next beat retries immediately. Extend amount stays ttl_ms. The client clock is deliberately 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.

The guard does not track expiry from extend responses (it stores the reserve-time expires_at_ms once), 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 for extend_by_ms == ttl_ms):

  • Alternate beats: beat 1 extends, beat 2 skipped, beat 3 extends (pins against the old every-beat behavior).
  • Failure retry: beat 1's extend 500s → beat 2 retries immediately → beat 3 skipped after the successful retry.
  • Non-ACTIVE status: an HTTP-200 extend with unrecognized status earns no skip → next beat retries; skip resumes after ACTIVE.

Verification

  • cargo test — full suite green (incl. 8 doc-tests)
  • cargo clippy --all-targets --all-features -- -D warnings — clean
  • cargo fmt --check — clean
  • cargo tarpaulin95.81% (617/644 lines; heartbeat.rs +35.45%)

Housekeeping

  • Version 0.3.0 → 0.3.1 (Cargo.toml + Cargo.lock)
  • CHANGELOG [0.3.1] - 2026-07-27; AUDIT.md dated entry

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.
@amavashev

amavashev commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

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

  1. Single-failure lapse at zero margin — after a failed extend, exactly one retry beat remained before expiry; a second hiccup lapsed the reservation.
  2. Guaranteed lapse for spec-legal ttl_ms in (1000, 2000) — the 1-second interval floor put the first beat at 1000 ms for ttl 1200, then a skip cycle past expiry. (The old tests pinned TTL_MS = 1000 — inside the broken regime.)
  3. Beat slip by one RTT per cyclesleep after each awaited extend pushed every subsequent beat later by the request round trip.
  4. Non-ACTIVE 2xx treated as failure — a 2xx means the server DID apply the extension (expires_at_ms is authoritative proof), so this branch would re-extend every beat against any server sending a newer status string: exactly the drift the fix was for.
  5. Permanent failures retried foreverMAX_EXTENSIONS_EXCEEDED / RESERVATION_EXPIRED etc. produced doomed traffic and log noise indefinitely.
  6. Fresh idempotency key per retry — an applied-but-lost extension followed by a fresh-key retry could double-extend.

The v2 design

  • Interval is exactly ttl/2floor removed (spec min ttl 1000 -> 500 ms beats).
  • Each beat computes lead = (known_expiry - initial_expiry) + ttl - elapsed (signed): server-frame minus server-frame, monotonic minus monotonic — the client wall clock is never compared against the server's, so skew cannot corrupt it, and the estimate is conservative (extends early, never late). The reserve response's expires_at_ms is threaded from the guard into start_heartbeat.
  • lead >= 1.5*ttl -> skip; else extend by ttl_ms. Any shortfall (failure, clamped grant, small ttl) extends on the very next beat — no fixed cadence to lapse through; steady state still halves extension consumption vs the original every-beat drift.
  • Beats are anchored with tokio::time::interval_at + MissedTickBehavior::Skip (no RTT slip; realigns after stalls).
  • Any 2xx counts as applied: known_expiry updates from the response expires_at_ms; an unrecognized status only warns (reverses item 4).
  • Transient failures keep and reuse the idempotency key next beat (server replay dedupes a lost response); fresh key only after a resolved outcome (fixes 6).
  • Permanent codes stop the loop: RESERVATION_EXPIRED, RESERVATION_FINALIZED, MAX_EXTENSIONS_EXCEEDED, or any HTTP 410 — logged once, heartbeat self-terminates (fixes 5).
  • Cancellation semantics unchanged (CancellationToken; guard cancels on commit/release/drop).

Tests

tests/heartbeat_test.rs rewritten (wiremock, dynamic expires_at_ms responders that grant prev + step per call):

  • v2 cadence under full grants: extend@1, extend@2, skip@3 (lead exactly 1.5·ttl), extend@4;
  • 500 -> retry reuses the same idempotency key (asserted from received request bodies), fresh key after success;
  • 409 MAX_EXTENSIONS_EXCEEDED stops the heartbeat — no further requests across subsequent beats;
  • ttl = 1200 stays alive across four 600 ms beats (pins the floor removal — regime 2 above);
  • clamped grants (+ttl/4) extend every beat (signed lead math, no lapse);
  • 200 with unknown status (SUSPENDED) counts as applied — beat 3 skips, pinning the reversal of item 4.

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 src/heartbeat.rs.

Verification

  • cargo test — all green (6 wiremock heartbeat tests + 5 new unit tests; full suite passes)
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo fmt --check — clean
  • tarpaulin coverage 95.70% (gate: >= 95%)
  • CHANGELOG [0.3.1] and AUDIT.md rewritten for the redesign

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

max_reservation_ttl_ms (governance default 1 hour) caps the grant at reserve, and the create response has no effective-TTL field — so 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). Fix:

  • reserve() now captures the HTTP Date response header (new ApiResponse::date_ms, parsed with httpdate — already in the tree via hyper, promoted to a direct dependency);
  • effective_ttl = clamp(expires_at_ms - date_ms, 1000 ms, requested) when both samples are available, else the requested TTL. Both terms are server-frame -> still 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: the beat interval (effective/2), the lead formula's ttl term, the 1.5x skip threshold, and the per-beat extend amount.

2. Two more permanent stop codes

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, joining RESERVATION_EXPIRED / RESERVATION_FINALIZED / MAX_EXTENSIONS_EXCEEDED / HTTP 410.

Tests added

  • capped scenario: requested 8000 / granted 2000 (reserve Date + expires_at pair) -> first beat at 1000 ms (not 4000 ms), extends by 2000 (not 8000), full extend/extend/skip cadence derived from the effective TTL;
  • garbage Date header -> fallback to the requested TTL (no beat at 500 ms, first beat at requested/2);
  • 409 TENANT_CLOSED and 404 NOT_FOUND stop the loop — no further requests (shared scaffold with the MAX_EXTENSIONS_EXCEEDED test);
  • unit tests: effective-TTL (capped grant recovery, never exceeds requested, clamps to the 1000 ms minimum, fallbacks) and Date parsing (IMF-fixdate exact ms, garbage/empty/invalid -> None);
  • shared reserve mocks (tests/common/mod.rs) now stamp a Date consistent with their fixed expires_at_ms, keeping heartbeats quiescent in unrelated suites.

Verification (follow-up)

  • cargo test — all green (10 wiremock heartbeat tests, 64 lib unit tests)
  • cargo clippy --all-targets -- -D warnings — clean; cargo fmt --check — clean
  • tarpaulin coverage 95.77%
  • CHANGELOG [0.3.1] and AUDIT.md extended

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.
@amavashev

Copy link
Copy Markdown
Contributor Author

v2.2 (spec review round 3): the HTTP Date header is demoted from correctness input to first-beat cadence hint. RFC 9110 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, so expires_at_ms − Date is not a lease measurement, and the old 1000 ms upward clamp could fabricate lease the server never granted.

Correctness now rests on a grant ledger: lead_min = grants_sum − elapsed (signed, starts 0) is a rigorous lower bound built only from same-frame arithmetic — each grant is the difference of successive server-frame expires_at_ms values. A beat skips only when lead_min ≥ 1.5·last_grant; otherwise it extends by the requested ttl_ms. First beat at min(requested/2, 30 s, date_hint/2) (hint raw and unclamped, dropped when underivable); after each success the delay is clamp(last_grant/2, 500 ms, requested/2), so the cadence tracks the server's observed grants (clamped grants speed it up). Per-beat computed sleeps replace interval_at, scheduled from intended instants with stall realignment (Skip-equivalent). Same-key transient retry and the permanent stop set (6 codes + HTTP 410/404) are unchanged.

Wiremock suite retraced (cadence extend@1..4 / skip@5 / extend@6; capped grant: requested 8000 with +2000 grants → 1000 ms beats, extend_by_ms stays 8000; unknown-status 200 still applied); the pure delay/lead computations are extracted and unit-tested, including the 30 s first-beat cap. Coverage 95.73%; tests, clippy -D warnings, fmt green. Same version 0.3.1 — CHANGELOG/AUDIT amended in place. Commit 2346011.

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).
@amavashev

Copy link
Copy Markdown
Contributor Author

v2.3 (spec review round 4) — pushed 5f1f7ba, same 0.3.1 release, CHANGELOG/AUDIT amended in place.

Two confirmed findings, both fixed in src/heartbeat.rs:

  1. First beat is now immediate. Any bounded first-beat delay can outlive a small tenant-capped lease (the 30 s cap is still 28 s late for a 2 s grant), so the first extend fires at delay 0 — it costs one extension but provably beats an arbitrarily small lease, and it primes the grant ledger with a real sample. All Date-derived effective-TTL/hint scheduling is removed from the heartbeat path; ApiResponse::date_ms + httpdate parsing remain as general response utilities (unit + e2e tested, unused by the heartbeat). A transient failure on the immediate beat retries at the held cadence min(requested/2, 30 s) with the same idempotency key — never a zero-delay hot loop.

  2. Lead-clamp regime. Under a maximum-LEAD clamp (extend re-stamps expires_at ≈ now + L), successive expires_at_ms differences measure elapsed time, not lease — grant-derived cadence self-collapses to the 500 ms floor and burns max_extensions in seconds. Each success is classified: grant ≤ 0, or < 0.9·requested and within [0.75, 1.25]× elapsed-since-last-success, holds the cadence at min(requested/2, 30 s) (never tightened) and warns once per heartbeat. Note the lower 0.75× band arm is a v2.3 refinement of the review's rule: without it, a real per-extend grant observed across a skip-doubled gap (grant = 2·cadence·… ⇒ grant ≈ elapsed exactly) is misclassified as lead-clamped, loosens to the held cadence, and decays the lease to a lapse — the band lets it re-tighten, keeping the required 'grant-clamp (+ttl/4) still tightens' behavior sustainable.

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), clippy -D warnings clean, cargo fmt --check clean, tarpaulin 95.12% (682/717). Wiremock suite reworked: immediate first beat, recomputed extend/skip pattern (E@0/1000/2000, skip@3000, E@4000), lead-clamp echo responder (cadence holds at ttl/2, no 500 ms collapse), zero-grant immediate prime → held cadence, grant-clamp +ttl/4 still tightens, 503-on-first-beat held-cadence retry, permanent stops, unknown-status 2xx — plus unit tests on the extracted pure cadence/regime functions (30 s cap, band boundaries).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant