|
40 | 40 | from runcycles.retry import ( |
41 | 41 | AsyncCommitRetryEngine, |
42 | 42 | CommitRetryEngine, |
| 43 | + _extract_error_code, |
43 | 44 | _is_recognized_rejection, |
44 | 45 | ) |
45 | 46 |
|
@@ -198,6 +199,49 @@ def _build_release_body(reason: str) -> dict[str, Any]: |
198 | 199 | return {"idempotency_key": str(uuid.uuid4()), "reason": reason} |
199 | 200 |
|
200 | 201 |
|
| 202 | +def _now_mono_ms() -> float: |
| 203 | + """Monotonic milliseconds — the heartbeat's only clock (test seam).""" |
| 204 | + return time.monotonic() * 1000.0 |
| 205 | + |
| 206 | + |
| 207 | +# Extend failures that can never succeed again — the heartbeat stops on them. |
| 208 | +_PERMANENT_EXTEND_CODES = frozenset( |
| 209 | + { |
| 210 | + "RESERVATION_EXPIRED", |
| 211 | + "RESERVATION_FINALIZED", |
| 212 | + "MAX_EXTENSIONS_EXCEEDED", |
| 213 | + "TENANT_CLOSED", # closure is irreversible per cascade semantics |
| 214 | + "NOT_FOUND", # a purged reservation never comes back |
| 215 | + } |
| 216 | +) |
| 217 | + |
| 218 | + |
| 219 | +def _effective_ttl_ms( |
| 220 | + requested_ttl_ms: int, |
| 221 | + expires_at_ms: int | None, |
| 222 | + server_date_ms: int | None, |
| 223 | +) -> int: |
| 224 | + """The TTL the server actually granted, best-effort. |
| 225 | +
|
| 226 | + Tenant policy ``max_reservation_ttl_ms`` (default 1h) silently caps the |
| 227 | + granted TTL, and the create response has no effective-TTL field — |
| 228 | + scheduling the heartbeat from the REQUESTED ttl can put the first beat |
| 229 | + long after expiry. Derive the grant from two server-frame values — |
| 230 | + ``expires_at_ms`` minus the HTTP ``Date`` header — which stays |
| 231 | + clock-skew-free (the header's ~1s resolution is negligible against |
| 232 | + multi-second TTLs). Falls back to the requested ttl when either value |
| 233 | + is unavailable. |
| 234 | + """ |
| 235 | + if expires_at_ms is None or server_date_ms is None: |
| 236 | + return requested_ttl_ms |
| 237 | + derived = expires_at_ms - server_date_ms |
| 238 | + return max(1000, min(derived, requested_ttl_ms)) |
| 239 | +# Lead threshold: extend when the estimated remaining lifetime drops below |
| 240 | +# this multiple of ttl. Attempts then happen with ~ttl of margin, tolerating |
| 241 | +# failed beats; the success-path lead stays within ~[ttl, 2*ttl]. |
| 242 | +_LEAD_TARGET_FACTOR = 1.5 |
| 243 | + |
| 244 | + |
201 | 245 | def _build_extend_body(ttl_ms: int) -> dict[str, Any]: |
202 | 246 | validate_extend_by_ms(ttl_ms) |
203 | 247 | return {"idempotency_key": str(uuid.uuid4()), "extend_by_ms": ttl_ms} |
@@ -354,7 +398,8 @@ def execute( |
354 | 398 |
|
355 | 399 | # Start heartbeat |
356 | 400 | heartbeat_stop = threading.Event() |
357 | | - heartbeat_thread = self._start_heartbeat(reservation_id, cfg.ttl_ms, ctx, heartbeat_stop) |
| 401 | + hb_ttl = _effective_ttl_ms(cfg.ttl_ms, res_result.expires_at_ms, res_response.server_date_ms) |
| 402 | + heartbeat_thread = self._start_heartbeat(reservation_id, hb_ttl, ctx, heartbeat_stop) |
358 | 403 |
|
359 | 404 | try: |
360 | 405 | result = fn(*args, **kwargs) |
@@ -474,31 +519,61 @@ def _start_heartbeat( |
474 | 519 | ) -> threading.Thread | None: |
475 | 520 | if ttl_ms <= 0: |
476 | 521 | return None |
477 | | - interval_s = max(ttl_ms / 2, 1000) / 1000.0 |
| 522 | + # No 1s floor: for spec-legal ttl < 2000 a floored interval cannot |
| 523 | + # keep the reservation alive (each extend adds only ttl of lifetime). |
| 524 | + interval_s = (ttl_ms / 2) / 1000.0 |
478 | 525 |
|
479 | 526 | def heartbeat_loop() -> None: |
480 | | - # Alternate-beat extension: extend_by_ms is relative to the |
481 | | - # CURRENT expiry (spec), so extending by ttl on every ttl/2 beat |
482 | | - # drifts expiry outward +ttl/2 per beat — a zombie-reservation |
483 | | - # window — and burns max_extensions twice as fast as needed. |
484 | | - # Extend on the first beat (only ttl/2 of lifetime remains) and |
485 | | - # every second beat after a success; retry right away after a |
486 | | - # failure. Expiry lead stays within [ttl/2, 1.5*ttl]. |
487 | | - beats_since_extend = 1 |
| 527 | + # Lead-estimate heartbeat: extend_by_ms is relative to the |
| 528 | + # CURRENT expiry (spec), so blind cadence-based extension either |
| 529 | + # drifts expiry outward or leaves zero margin after a failed |
| 530 | + # beat. Instead, estimate the remaining lead from the |
| 531 | + # AUTHORITATIVE expires_at_ms the server returns, compared |
| 532 | + # skew-free: server-frame differences plus client-monotonic |
| 533 | + # elapsed only (never client wall clock vs server wall clock). |
| 534 | + # Extend when lead < 1.5*ttl; skip otherwise. Failed extends are |
| 535 | + # retried with the SAME body (same idempotency key) so a lost |
| 536 | + # response cannot double-extend; permanent rejections stop the |
| 537 | + # heartbeat for good. |
| 538 | + initial_expiry = ctx.expires_at_ms |
| 539 | + known_expiry = initial_expiry |
| 540 | + anchor_ms = _now_mono_ms() |
| 541 | + pending_body: dict[str, Any] | None = None |
488 | 542 | while not stop_event.wait(timeout=interval_s): |
489 | | - beats_since_extend += 1 |
490 | | - if beats_since_extend < 2: |
| 543 | + elapsed = _now_mono_ms() - anchor_ms |
| 544 | + if initial_expiry is not None and known_expiry is not None: |
| 545 | + lead = (known_expiry - initial_expiry) + ttl_ms - elapsed |
| 546 | + else: |
| 547 | + lead = ttl_ms - elapsed |
| 548 | + if lead >= _LEAD_TARGET_FACTOR * ttl_ms: |
491 | 549 | continue |
492 | 550 | try: |
493 | | - body = _build_extend_body(ttl_ms) |
| 551 | + body = pending_body if pending_body is not None else _build_extend_body(ttl_ms) |
| 552 | + pending_body = body |
494 | 553 | response = self._client.extend_reservation(reservation_id, body) |
495 | 554 | if response.is_success: |
496 | | - beats_since_extend = 0 |
| 555 | + pending_body = None |
497 | 556 | new_expires = response.get_body_attribute("expires_at_ms") |
498 | 557 | if new_expires is not None: |
499 | 558 | ctx.update_expires_at_ms(int(new_expires)) |
| 559 | + if initial_expiry is None: |
| 560 | + # Late anchor: treat this response as the frame origin. |
| 561 | + initial_expiry = int(new_expires) |
| 562 | + known_expiry = initial_expiry |
| 563 | + anchor_ms = _now_mono_ms() |
| 564 | + else: |
| 565 | + known_expiry = int(new_expires) |
| 566 | + elif known_expiry is not None: |
| 567 | + known_expiry += ttl_ms |
500 | 568 | logger.debug("Heartbeat extend ok: id=%s", reservation_id) |
501 | 569 | else: |
| 570 | + code = _extract_error_code(response) |
| 571 | + if response.status == 410 or code in _PERMANENT_EXTEND_CODES: |
| 572 | + logger.warning( |
| 573 | + "Heartbeat stopping permanently (%s, status=%d): id=%s", |
| 574 | + code, response.status, reservation_id, |
| 575 | + ) |
| 576 | + return |
502 | 577 | logger.warning("Heartbeat extend failed: id=%s, status=%d", reservation_id, response.status) |
503 | 578 | except Exception: |
504 | 579 | logger.warning("Heartbeat extend error: id=%s", reservation_id, exc_info=True) |
@@ -577,7 +652,8 @@ async def execute( |
577 | 652 | ) |
578 | 653 | _set_context(ctx) |
579 | 654 |
|
580 | | - heartbeat_task = self._start_heartbeat(reservation_id, cfg.ttl_ms, ctx) |
| 655 | + hb_ttl = _effective_ttl_ms(cfg.ttl_ms, res_result.expires_at_ms, res_response.server_date_ms) |
| 656 | + heartbeat_task = self._start_heartbeat(reservation_id, hb_ttl, ctx) |
581 | 657 |
|
582 | 658 | try: |
583 | 659 | result = await fn(*args, **kwargs) |
@@ -691,26 +767,50 @@ async def _handle_release(self, reservation_id: str, reason: str) -> None: |
691 | 767 | def _start_heartbeat(self, reservation_id: str, ttl_ms: int, ctx: CyclesContext) -> asyncio.Task[None] | None: |
692 | 768 | if ttl_ms <= 0: |
693 | 769 | return None |
694 | | - interval_s = max(ttl_ms / 2, 1000) / 1000.0 |
| 770 | + # No 1s floor — see the sync heartbeat for rationale. |
| 771 | + interval_s = (ttl_ms / 2) / 1000.0 |
695 | 772 |
|
696 | 773 | async def heartbeat_loop() -> None: |
697 | | - # Alternate-beat extension — see the sync heartbeat for rationale. |
698 | | - beats_since_extend = 1 |
| 774 | + # Lead-estimate heartbeat — see the sync heartbeat for rationale. |
| 775 | + initial_expiry = ctx.expires_at_ms |
| 776 | + known_expiry = initial_expiry |
| 777 | + anchor_ms = _now_mono_ms() |
| 778 | + pending_body: dict[str, Any] | None = None |
699 | 779 | try: |
700 | 780 | while True: |
701 | 781 | await asyncio.sleep(interval_s) |
702 | | - beats_since_extend += 1 |
703 | | - if beats_since_extend < 2: |
| 782 | + elapsed = _now_mono_ms() - anchor_ms |
| 783 | + if initial_expiry is not None and known_expiry is not None: |
| 784 | + lead = (known_expiry - initial_expiry) + ttl_ms - elapsed |
| 785 | + else: |
| 786 | + lead = ttl_ms - elapsed |
| 787 | + if lead >= _LEAD_TARGET_FACTOR * ttl_ms: |
704 | 788 | continue |
705 | 789 | try: |
706 | | - body = _build_extend_body(ttl_ms) |
| 790 | + body = pending_body if pending_body is not None else _build_extend_body(ttl_ms) |
| 791 | + pending_body = body |
707 | 792 | response = await self._client.extend_reservation(reservation_id, body) |
708 | 793 | if response.is_success: |
709 | | - beats_since_extend = 0 |
| 794 | + pending_body = None |
710 | 795 | new_expires = response.get_body_attribute("expires_at_ms") |
711 | 796 | if new_expires is not None: |
712 | 797 | ctx.update_expires_at_ms(int(new_expires)) |
| 798 | + if initial_expiry is None: |
| 799 | + initial_expiry = int(new_expires) |
| 800 | + known_expiry = initial_expiry |
| 801 | + anchor_ms = _now_mono_ms() |
| 802 | + else: |
| 803 | + known_expiry = int(new_expires) |
| 804 | + elif known_expiry is not None: |
| 805 | + known_expiry += ttl_ms |
713 | 806 | else: |
| 807 | + code = _extract_error_code(response) |
| 808 | + if response.status == 410 or code in _PERMANENT_EXTEND_CODES: |
| 809 | + logger.warning( |
| 810 | + "Heartbeat stopping permanently (%s, status=%d): id=%s", |
| 811 | + code, response.status, reservation_id, |
| 812 | + ) |
| 813 | + return |
714 | 814 | logger.warning("Heartbeat extend failed: id=%s", reservation_id) |
715 | 815 | except Exception: |
716 | 816 | logger.warning("Heartbeat extend error: id=%s", reservation_id, exc_info=True) |
|
0 commit comments