Coverage for app/services/build_lock.py: 98.61%
72 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-25 16:05 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-25 16:05 +0000
1"""Redis-backed distributed lock around the Packer image build.
3Why this exists: the build phase in `tasks.py` does a check-then-act pair
4(`check_image_exists` → `packer.build`) on the shared OpenStack Glance store.
5Two parallel workers triggering a build of the same `(project_id, image_name)`
6both observe "not found" and both kick off a build, leaving a duplicate image
7behind and burning ~10 minutes of compute. This lock serializes the build for
8a given image name within a given OpenStack project so only one worker
9actually builds; the other re-checks Glance after waiting and skips straight
10to Terraform if the image now exists.
12Backend: Redis (already present as Celery's result backend, no new dep).
14Lock semantics:
16* ``SET NX PX`` with a unique token holds the lock with a short TTL
17 (default 5 min). A daemon thread inside the holder periodically
18 refreshes the TTL via ``PEXPIRE`` so a long-running build can't
19 outlive its lease.
20* Release uses a Lua-CAS so a worker can never release a lock that
21 another worker grabbed after the first one's TTL expired.
22* If the holding worker process is killed before ``release()`` runs
23 (SIGKILL, OOM, container restart), the heartbeat thread dies with
24 it and the lock self-heals after ``ttl_ms`` — bounded staleness.
25"""
27from __future__ import annotations
29import threading
30import time
31import uuid
33import redis
35from ..config import settings
36from ..utils.logger import get_logger
38logger = get_logger(__name__)
41_LUA_RELEASE = """
42if redis.call("get", KEYS[1]) == ARGV[1] then
43 return redis.call("del", KEYS[1])
44end
45return 0
46"""
48_LUA_RENEW = """
49if redis.call("get", KEYS[1]) == ARGV[1] then
50 return redis.call("pexpire", KEYS[1], ARGV[2])
51end
52return 0
53"""
55# Lock lease. Short enough that a crashed worker doesn't block the next
56# deploy for long; the heartbeat below extends it on every tick so an
57# in-flight build that takes longer than the lease still keeps the lock.
58_DEFAULT_TTL_MS = 5 * 60 * 1000
59# Heartbeat interval. Must be < TTL with margin so we don't miss a
60# refresh window after a momentary GC pause / Redis hiccup.
61_DEFAULT_HEARTBEAT_S = 60
62_DEFAULT_POLL_S = 5
63_DEFAULT_TOTAL_WAIT_S = 25 * 60
66def _redis_client() -> redis.Redis:
67 return redis.Redis.from_url(settings.CELERY_RESULT_BACKEND)
70class PackerBuildLock:
71 """Acquire a Redis lock keyed on (project, image_name).
73 The class is a context-manager-shaped polling loop. Pattern:
75 lock = PackerBuildLock(project_id, image_name)
76 try:
77 while True:
78 held = lock.acquire_or_wait()
79 if held:
80 if image_already_exists(): break
81 packer.build(...)
82 break
83 if image_already_exists(): break
84 finally:
85 lock.release()
86 """
88 def __init__(
89 self,
90 project_id: str,
91 image_name: str,
92 *,
93 ttl_ms: int = _DEFAULT_TTL_MS,
94 heartbeat_interval_s: int = _DEFAULT_HEARTBEAT_S,
95 poll_interval_s: int = _DEFAULT_POLL_S,
96 total_wait_s: int = _DEFAULT_TOTAL_WAIT_S,
97 ):
98 self.key = f"lock:packer:{project_id or 'unknown'}:{image_name}"
99 self.token = uuid.uuid4().hex
100 self.ttl_ms = ttl_ms
101 self.heartbeat_interval_s = heartbeat_interval_s
102 self.poll_interval_s = poll_interval_s
103 self.deadline = time.monotonic() + total_wait_s
104 self._client = _redis_client()
105 self._held = False
106 # Heartbeat coordination. A daemon thread runs while the lock is
107 # held and renews the TTL on every interval; ``_stop`` flips it
108 # off during ``release()``. Daemon=True so the thread doesn't
109 # keep the worker alive on shutdown.
110 self._heartbeat: threading.Thread | None = None
111 self._stop = threading.Event()
113 # ----- acquisition ---------------------------------------------------
115 def acquire_or_wait(self) -> bool:
116 """Try to acquire. Returns True if held, False if we slept and the
117 caller should re-check Glance and call again. Raises TimeoutError
118 if the total wait budget is exhausted."""
119 if self._client.set(self.key, self.token, nx=True, px=self.ttl_ms):
120 self._held = True
121 self._start_heartbeat()
122 logger.info("Acquired Packer build lock", lock_key=self.key, ttl_ms=self.ttl_ms)
123 return True
124 if time.monotonic() > self.deadline:
125 raise TimeoutError(
126 f"Timed out waiting for Packer build lock {self.key} " f"(another worker is still building this image)"
127 )
128 # Look up the lock's remaining TTL so the operator/log reader has
129 # a hint at how long the wait will be. ``pttl`` returns -2 if the
130 # key has just expired (race with another waiter), -1 if no TTL
131 # is set (shouldn't happen — we always set PX), or the remaining
132 # ms otherwise.
133 ttl_left_ms = self._client.pttl(self.key)
134 logger.info(
135 "Waiting for in-progress Packer build",
136 lock_key=self.key,
137 poll_interval_s=self.poll_interval_s,
138 other_holder_ttl_ms=ttl_left_ms if ttl_left_ms and ttl_left_ms > 0 else None,
139 )
140 time.sleep(self.poll_interval_s)
141 return False
143 # ----- heartbeat -----------------------------------------------------
145 def _start_heartbeat(self) -> None:
146 """Spawn the renewal thread.
148 The thread loops on ``_stop.wait(interval)`` so it sleeps
149 cancellably and exits promptly when ``release()`` runs. CAS via
150 the Lua script ensures we only renew our own lock — if another
151 worker has somehow taken over after our TTL expired (e.g. our
152 heartbeat fell behind a Redis outage), we don't accidentally
153 steal their lease back.
154 """
155 self._stop.clear()
156 thread = threading.Thread(
157 target=self._heartbeat_loop,
158 name=f"packer-lock-heartbeat-{self.key[-12:]}",
159 daemon=True,
160 )
161 thread.start()
162 self._heartbeat = thread
164 def _heartbeat_loop(self) -> None:
165 while not self._stop.wait(self.heartbeat_interval_s):
166 try:
167 renewed = self._client.eval(_LUA_RENEW, 1, self.key, self.token, self.ttl_ms)
168 except Exception as e:
169 # Redis blip — try again next tick. We don't break the
170 # loop on a single failure; if Redis stays down past
171 # our TTL the lock will expire and another worker will
172 # eventually pick up.
173 logger.warning(f"Packer lock renewal raised: {e}")
174 continue
175 if not renewed:
176 # Lock is gone (TTL expired faster than we could renew,
177 # or someone manually deleted it). Stop heartbeating —
178 # ``release()`` is a no-op in this case.
179 logger.warning(
180 "Packer lock vanished mid-build; stopping heartbeat",
181 lock_key=self.key,
182 )
183 self._held = False
184 return
186 # ----- release -------------------------------------------------------
188 def release(self) -> None:
189 if not self._held:
190 return
191 # Stop the heartbeat first so it doesn't race with the delete.
192 self._stop.set()
193 if self._heartbeat is not None:
194 self._heartbeat.join(timeout=2)
195 self._heartbeat = None
196 try:
197 self._client.eval(_LUA_RELEASE, 1, self.key, self.token)
198 except Exception as e:
199 # Best-effort: even if release fails, the TTL will eventually
200 # expire and the lock self-heals.
201 logger.warning(f"Failed to release Packer lock {self.key}: {e}")
202 finally:
203 self._held = False
205 # ----- context manager ergonomics -----------------------------------
207 def __enter__(self) -> PackerBuildLock:
208 return self
210 def __exit__(self, *exc_info: object) -> None:
211 self.release()