Coverage for gco / services / spot_price_gate.py: 100.00%

98 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""Spot price gating for the central DynamoDB job queue. 

2 

3A job submitted to the central queue may carry a spot price cap: 

4``spot_max_price`` (USD/hour) for ``spot_instance_type``. The regional queue 

5worker consults this gate before claiming such a job — while the instance 

6type's current spot price in the worker's region sits above the cap, the job 

7stays queued and is re-evaluated on every worker pass. The moment pricing 

8drops to or below the cap, dispatch proceeds normally. 

9 

10Price lookups use ``ec2:DescribeSpotPriceHistory`` and take the *minimum* 

11current price across the region's Availability Zones — a capacity-flexible 

12job can land in whichever zone currently clears its cap. Results are cached 

13briefly so a busy queue never hammers the EC2 API, and lookup failures fail 

14open at the per-pass level by deferring only the affected job (never by 

15dispatching above the cap). 

16""" 

17 

18from __future__ import annotations 

19 

20import logging 

21import math 

22import re 

23import time 

24from dataclasses import dataclass 

25from datetime import UTC, datetime, timedelta 

26from typing import Any 

27 

28# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

29# Generated at (UTC): 2026-09-01T14:42:56Z 

30# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc 

31# Flowchart(s) generated from this file: 

32# * ``SpotPriceGate.evaluate`` -> ``diagrams/code_diagrams/gco/services/spot_price_gate.SpotPriceGate_evaluate.html`` 

33# (PNG: ``diagrams/code_diagrams/gco/services/spot_price_gate.SpotPriceGate_evaluate.png``) 

34# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

35# <pyflowchart-code-diagram> END 

36 

37 

38logger = logging.getLogger(__name__) 

39 

40#: EC2 instance type shape (``g5.xlarge``, ``p6-b200.48xlarge``, ``trn2.3xlarge``). 

41INSTANCE_TYPE_PATTERN = re.compile(r"^[a-z][a-z0-9-]{0,29}\.[a-z0-9]{1,20}$") 

42 

43#: Spot price caps accepted at submission time (USD/hour). 

44MIN_SPOT_PRICE = 0.0001 

45MAX_SPOT_PRICE = 1_000.0 

46 

47_PRICE_CACHE_TTL_SECONDS = 60.0 

48_PRICE_LOOKBACK_HOURS = 4 

49 

50#: Minimum seconds between persisted gate observations per job. In-memory 

51#: evaluation still happens every pass; only the DynamoDB write is throttled. 

52OBSERVATION_WRITE_INTERVAL_SECONDS = 60.0 

53 

54 

55def validate_spot_gate_fields(max_price: float | None, instance_type: str | None) -> str | None: 

56 """Validate the submission-time gate pair; return an error or ``None``. 

57 

58 The two fields are all-or-nothing: a cap without an instance type is 

59 unenforceable, and an instance type without a cap is meaningless. 

60 """ 

61 if max_price is None and instance_type is None: 

62 return None 

63 if max_price is None or instance_type is None: 

64 return "max_spot_price and spot_instance_type must be provided together" 

65 if not (MIN_SPOT_PRICE <= max_price <= MAX_SPOT_PRICE): 

66 return f"max_spot_price must be between {MIN_SPOT_PRICE} and {MAX_SPOT_PRICE} USD/hour" 

67 if not INSTANCE_TYPE_PATTERN.fullmatch(instance_type): 

68 return "spot_instance_type is not a valid EC2 instance type" 

69 return None 

70 

71 

72@dataclass(frozen=True) 

73class SpotGateDecision: 

74 """Outcome of evaluating one job's spot price gate.""" 

75 

76 gated: bool 

77 instance_type: str 

78 max_price: float 

79 observed_price: float | None 

80 reason: str 

81 

82 

83class SpotPriceGate: 

84 """TTL-cached regional spot price lookups plus per-job gate evaluation.""" 

85 

86 def __init__( 

87 self, 

88 region: str, 

89 *, 

90 ec2_client: Any | None = None, 

91 cache_ttl_seconds: float = _PRICE_CACHE_TTL_SECONDS, 

92 ) -> None: 

93 self.region = region 

94 self.cache_ttl_seconds = float(cache_ttl_seconds) 

95 self._ec2 = ec2_client 

96 self._cache: dict[str, tuple[float, float | None]] = {} 

97 

98 def _client(self) -> Any: 

99 if self._ec2 is None: 

100 import boto3 

101 from botocore.config import Config 

102 

103 self._ec2 = boto3.client( 

104 "ec2", 

105 region_name=self.region, 

106 config=Config( 

107 connect_timeout=3, 

108 read_timeout=10, 

109 retries={"max_attempts": 2, "mode": "standard"}, 

110 ), 

111 ) 

112 return self._ec2 

113 

114 def current_min_spot_price(self, instance_type: str) -> float | None: 

115 """Return the lowest current spot price across AZs, or ``None``. 

116 

117 ``None`` means the price could not be determined (no offerings in the 

118 region, API error, malformed response). Callers treat ``None`` as 

119 "defer the job" — an unknown price must never dispatch a price-capped 

120 job. 

121 """ 

122 cached = self._cache.get(instance_type) 

123 now = time.monotonic() 

124 if cached is not None and now - cached[0] < self.cache_ttl_seconds: 

125 return cached[1] 

126 

127 price = self._fetch_min_spot_price(instance_type) 

128 self._cache[instance_type] = (now, price) 

129 return price 

130 

131 def _fetch_min_spot_price(self, instance_type: str) -> float | None: 

132 end = datetime.now(UTC) 

133 try: 

134 response = self._client().describe_spot_price_history( 

135 InstanceTypes=[instance_type], 

136 ProductDescriptions=["Linux/UNIX"], 

137 StartTime=end - timedelta(hours=_PRICE_LOOKBACK_HOURS), 

138 EndTime=end, 

139 ) 

140 except Exception as exc: # noqa: BLE001 - lookup failures defer, never dispatch 

141 logger.warning( 

142 "Spot price lookup failed for %s in %s: %s", 

143 instance_type, 

144 self.region, 

145 exc, 

146 ) 

147 return None 

148 

149 # DescribeSpotPriceHistory returns newest-first per AZ; keep each 

150 # AZ's most recent price and take the minimum across AZs. 

151 latest_by_az: dict[str, float] = {} 

152 for entry in response.get("SpotPriceHistory", []): 

153 az = str(entry.get("AvailabilityZone") or "") 

154 if not az or az in latest_by_az: 

155 continue 

156 try: 

157 latest_by_az[az] = float(entry["SpotPrice"]) 

158 except KeyError, TypeError, ValueError: 

159 continue 

160 if not latest_by_az: 

161 return None 

162 return min(latest_by_az.values()) 

163 

164 def evaluate(self, job: dict[str, Any]) -> SpotGateDecision | None: 

165 """Evaluate one queue record's gate; ``None`` means the job is ungated. 

166 

167 Malformed gate fields on a stored record gate the job closed (with a 

168 descriptive reason) rather than dispatching a job whose cap cannot be 

169 honored. 

170 """ 

171 raw_price = job.get("spot_max_price") 

172 instance_type = job.get("spot_instance_type") 

173 if raw_price is None and instance_type is None: 

174 return None 

175 try: 

176 max_price = float(str(raw_price)) 

177 except TypeError, ValueError: 

178 max_price = float("nan") 

179 # A cap must be a finite number: NaN means the stored field is 

180 # unparseable, and an infinite cap would wave every price through — 

181 # both gate closed rather than dispatching a job whose cap cannot be 

182 # honored. 

183 if not isinstance(instance_type, str) or not instance_type or not math.isfinite(max_price): 

184 return SpotGateDecision( 

185 gated=True, 

186 instance_type=str(instance_type or ""), 

187 max_price=0.0, 

188 observed_price=None, 

189 reason="spot gate fields are malformed; refusing to dispatch", 

190 ) 

191 

192 observed = self.current_min_spot_price(instance_type) 

193 if observed is None: 

194 return SpotGateDecision( 

195 gated=True, 

196 instance_type=instance_type, 

197 max_price=max_price, 

198 observed_price=None, 

199 reason=( 

200 f"current spot price for {instance_type} in {self.region} " 

201 "is unavailable; deferring" 

202 ), 

203 ) 

204 if observed > max_price: 

205 return SpotGateDecision( 

206 gated=True, 

207 instance_type=instance_type, 

208 max_price=max_price, 

209 observed_price=observed, 

210 reason=( 

211 f"spot price {observed:.4f} USD/h for {instance_type} in " 

212 f"{self.region} is above the {max_price:.4f} USD/h cap" 

213 ), 

214 ) 

215 return SpotGateDecision( 

216 gated=False, 

217 instance_type=instance_type, 

218 max_price=max_price, 

219 observed_price=observed, 

220 reason=( 

221 f"spot price {observed:.4f} USD/h for {instance_type} in " 

222 f"{self.region} clears the {max_price:.4f} USD/h cap" 

223 ), 

224 ) 

225 

226 

227def should_persist_observation(job: dict[str, Any], now: datetime | None = None) -> bool: 

228 """Throttle DynamoDB gate-observation writes per job. 

229 

230 Evaluation happens on every worker pass; persisting every observation 

231 would add one write per gated job per pass for no operator benefit. Only 

232 write when the record has no observation yet or the last one is older 

233 than :data:`OBSERVATION_WRITE_INTERVAL_SECONDS`. 

234 """ 

235 checked_at = job.get("spot_gate_checked_at") 

236 if not checked_at: 

237 return True 

238 try: 

239 last = datetime.fromisoformat(str(checked_at)) 

240 except ValueError: 

241 return True 

242 moment = now or datetime.now(UTC) 

243 return (moment - last).total_seconds() >= OBSERVATION_WRITE_INTERVAL_SECONDS