Coverage for gco / services / leader_lease.py: 100.00%
64 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Single-writer election on a pre-created Kubernetes Lease.
3The platform services run with two or more replicas for availability, but
4several of their side effects must happen exactly once per cluster — the
5ALB-hostname write to SSM, and every webhook delivery. Both elect a single
6writer through a ``coordination.k8s.io`` Lease with the same rules:
8* The Lease is **pre-created by the manifests** (``02-rbac.yaml``), so RBAC
9 grants ``get``/``update`` on one named object instead of ``create`` on every
10 Lease in the namespace. A missing Lease disables the side effect rather than
11 letting two replicas perform it.
12* Acquisition is a read followed by ``replace`` carrying the read's
13 ``resourceVersion``; Kubernetes rejects a racing writer with HTTP 409, and
14 the loser simply does not act this cycle.
15* A holder whose ``renewTime`` is older than ``leaseDurationSeconds`` — or that
16 never recorded one — is treated as gone and may be replaced.
17* Every API or RBAC failure returns ``False``. Losing the side effect for one
18 cycle is always safer than performing it twice.
20Callers re-run :func:`try_acquire_lease` on every cycle; a ``True`` result is
21a renewal for the current holder and an acquisition for a new one.
22"""
24from __future__ import annotations
26import logging
27from dataclasses import dataclass
28from datetime import UTC, datetime
29from typing import Any
31from kubernetes.client.rest import ApiException
33logger = logging.getLogger(__name__)
35#: Shortest lease any caller may configure. Below this a slow API call or a
36#: paused process could let a second replica win the Lease while the first
37#: still believes it holds it.
38LEASE_MIN_DURATION_SECONDS = 60
40#: (connect, read) timeouts for the two Lease calls, kept short so an
41#: unreachable API server costs one cycle, not the whole loop.
42LEASE_REQUEST_TIMEOUT: tuple[int, int] = (3, 10)
45@dataclass(frozen=True)
46class LeaseIdentity:
47 """Which Lease to hold, as whom, and for how long."""
49 name: str
50 namespace: str
51 holder: str
52 duration_seconds: int
55def lease_duration_from_env(value: str | None, *, label: str) -> int:
56 """Parse a configured lease duration, enforcing the shared floor."""
57 configured = int(value) if value is not None else LEASE_MIN_DURATION_SECONDS
58 if configured < LEASE_MIN_DURATION_SECONDS:
59 logger.warning(
60 "%s=%s is too short; enforcing %s seconds",
61 label,
62 configured,
63 LEASE_MIN_DURATION_SECONDS,
64 )
65 return LEASE_MIN_DURATION_SECONDS
66 return configured
69def try_acquire_lease(
70 coordination_v1: Any,
71 identity: LeaseIdentity,
72 *,
73 label: str,
74 request_timeout: tuple[int, int] = LEASE_REQUEST_TIMEOUT,
75) -> bool:
76 """Acquire or renew ``identity`` for its holder; ``False`` means do not act.
78 ``label`` names the elected duty in log lines (for example ``"ALB-sync"``
79 or ``"webhook"``) so two elections in one process stay distinguishable.
80 """
81 observed_at = datetime.now(UTC)
83 try:
84 lease = coordination_v1.read_namespaced_lease(
85 identity.name,
86 identity.namespace,
87 _request_timeout=request_timeout,
88 )
89 spec = lease.spec
90 current_holder = spec.holder_identity
91 renew_time = spec.renew_time
92 lease_duration = spec.lease_duration_seconds or identity.duration_seconds
94 expired = False
95 if current_holder:
96 if renew_time is None:
97 # A holder without a renewal timestamp cannot prove it still
98 # owns the lease. Treat it as expired so the named Lease
99 # cannot remain wedged indefinitely.
100 expired = True
101 else:
102 if renew_time.tzinfo is None:
103 renew_time = renew_time.replace(tzinfo=UTC)
104 expired = (observed_at - renew_time).total_seconds() >= lease_duration
106 if current_holder not in (None, "", identity.holder) and not expired:
107 return False
109 acquiring = current_holder != identity.holder
110 renewed_at = datetime.now(UTC)
111 if acquiring:
112 spec.holder_identity = identity.holder
113 spec.acquire_time = renewed_at
114 spec.lease_transitions = (spec.lease_transitions or 0) + 1
115 spec.lease_duration_seconds = identity.duration_seconds
116 spec.renew_time = renewed_at
118 try:
119 coordination_v1.replace_namespaced_lease(
120 identity.name,
121 identity.namespace,
122 lease,
123 _request_timeout=request_timeout,
124 )
125 except ApiException as exc:
126 if exc.status == 409:
127 logger.debug("Lost %s Lease race to another replica", label)
128 return False
129 raise
131 if acquiring:
132 logger.info("Acquired %s leader Lease as %s", label, identity.holder)
133 return True
135 except ApiException as exc:
136 if exc.status == 404:
137 logger.warning(
138 "%s Lease %s/%s is missing; the elected duty is disabled until it is restored",
139 label,
140 identity.namespace,
141 identity.name,
142 )
143 else:
144 logger.warning("%s Lease check failed (non-fatal): %s", label, exc)
145 return False
146 except Exception as exc:
147 logger.warning("%s Lease check failed (non-fatal): %s", label, exc)
148 return False