Coverage for gco/services/inference_store.py: 93.63%

117 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +0000

1""" 

2DynamoDB-backed store for inference endpoint state. 

3 

4Provides CRUD operations for inference endpoints. The inference_monitor 

5in each regional cluster polls this table to reconcile desired state 

6with actual Kubernetes resources. 

7""" 

8 

9from __future__ import annotations 

10 

11import logging 

12import os 

13from datetime import UTC, datetime 

14from typing import Any 

15 

16import boto3 

17from botocore.exceptions import ClientError 

18 

19logger = logging.getLogger(__name__) 

20 

21DEFAULT_TABLE_NAME = "gco-inference-endpoints" 

22 

23 

24def _utc_now_iso() -> str: 

25 return datetime.now(UTC).isoformat() 

26 

27 

28def _validate_endpoint_spec(spec: dict[str, Any]) -> None: 

29 """Reject endpoint shapes the reconciler cannot safely materialize.""" 

30 if not isinstance(spec, dict): 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true

31 raise ValueError("Endpoint spec must be a mapping") 

32 if "mooncake" in spec and "canary" in spec: 

33 raise ValueError("Endpoint spec cannot combine 'mooncake' and 'canary' blocks") 

34 

35 

36class InferenceEndpointStore: 

37 """DynamoDB store for inference endpoint desired state.""" 

38 

39 def __init__(self, table_name: str | None = None, region: str | None = None): 

40 self.table_name = table_name or os.getenv( 

41 "INFERENCE_ENDPOINTS_TABLE_NAME", DEFAULT_TABLE_NAME 

42 ) 

43 self._region = region or os.getenv("DYNAMODB_REGION") or os.getenv("REGION", "us-east-1") 

44 self._dynamodb = boto3.resource("dynamodb", region_name=self._region) 

45 self._table = self._dynamodb.Table(self.table_name) 

46 

47 def create_endpoint( 

48 self, 

49 endpoint_name: str, 

50 spec: dict[str, Any], 

51 target_regions: list[str], 

52 namespace: str = "gco-inference", 

53 labels: dict[str, str] | None = None, 

54 created_by: str | None = None, 

55 ) -> dict[str, Any]: 

56 """Create a new inference endpoint entry.""" 

57 _validate_endpoint_spec(spec) 

58 now = _utc_now_iso() 

59 ingress_path = f"/inference/{endpoint_name}" 

60 

61 item: dict[str, Any] = { 

62 "endpoint_name": endpoint_name, 

63 "desired_state": "deploying", 

64 "target_regions": target_regions, 

65 "namespace": namespace, 

66 "spec": _serialize_for_dynamo(spec), 

67 "ingress_path": ingress_path, 

68 "created_at": now, 

69 "updated_at": now, 

70 "region_status": {}, 

71 } 

72 if labels: 

73 item["labels"] = labels 

74 if created_by: 

75 item["created_by"] = created_by 

76 

77 try: 

78 self._table.put_item( 

79 Item=item, 

80 ConditionExpression="attribute_not_exists(endpoint_name)", 

81 ) 

82 except ClientError as e: 

83 if e.response["Error"]["Code"] == "ConditionalCheckFailedException": 

84 raise ValueError(f"Endpoint '{endpoint_name}' already exists") from e 

85 raise 

86 

87 return item 

88 

89 def get_endpoint(self, endpoint_name: str) -> dict[str, Any] | None: 

90 """Get an endpoint by name.""" 

91 response = self._table.get_item(Key={"endpoint_name": endpoint_name}) 

92 item = response.get("Item") 

93 if item: 

94 return _deserialize_from_dynamo(item) 

95 return None 

96 

97 def list_endpoints( 

98 self, 

99 desired_state: str | None = None, 

100 target_region: str | None = None, 

101 ) -> list[dict[str, Any]]: 

102 """List all endpoints, optionally filtered.""" 

103 response = self._table.scan() 

104 items = [_deserialize_from_dynamo(i) for i in response.get("Items", [])] 

105 

106 if desired_state: 

107 items = [i for i in items if i.get("desired_state") == desired_state] 

108 if target_region: 

109 items = [i for i in items if target_region in i.get("target_regions", [])] 

110 

111 return sorted(items, key=lambda x: x.get("created_at", ""), reverse=True) 

112 

113 def update_desired_state(self, endpoint_name: str, desired_state: str) -> dict[str, Any] | None: 

114 """Update the desired state of an endpoint.""" 

115 try: 

116 response = self._table.update_item( 

117 Key={"endpoint_name": endpoint_name}, 

118 UpdateExpression="SET desired_state = :s, updated_at = :u", 

119 ExpressionAttributeValues={ 

120 ":s": desired_state, 

121 ":u": _utc_now_iso(), 

122 }, 

123 ConditionExpression="attribute_exists(endpoint_name)", 

124 ReturnValues="ALL_NEW", 

125 ) 

126 return _deserialize_from_dynamo(response.get("Attributes", {})) 

127 except ClientError as e: 

128 if e.response["Error"]["Code"] == "ConditionalCheckFailedException": 128 ↛ 130line 128 didn't jump to line 130 because the condition on line 128 was always true

129 return None 

130 raise 

131 

132 def update_spec(self, endpoint_name: str, spec: dict[str, Any]) -> dict[str, Any] | None: 

133 """Update the spec of an endpoint (triggers re-reconciliation).""" 

134 _validate_endpoint_spec(spec) 

135 try: 

136 response = self._table.update_item( 

137 Key={"endpoint_name": endpoint_name}, 

138 UpdateExpression="SET spec = :s, updated_at = :u, desired_state = :ds", 

139 ExpressionAttributeValues={ 

140 ":s": _serialize_for_dynamo(spec), 

141 ":u": _utc_now_iso(), 

142 ":ds": "deploying", 

143 }, 

144 ConditionExpression="attribute_exists(endpoint_name)", 

145 ReturnValues="ALL_NEW", 

146 ) 

147 return _deserialize_from_dynamo(response.get("Attributes", {})) 

148 except ClientError as e: 

149 if e.response["Error"]["Code"] == "ConditionalCheckFailedException": 149 ↛ 151line 149 didn't jump to line 151 because the condition on line 149 was always true

150 return None 

151 raise 

152 

153 def update_region_status( 

154 self, 

155 endpoint_name: str, 

156 region: str, 

157 state: str, 

158 replicas_ready: int = 0, 

159 replicas_desired: int = 0, 

160 error: str | None = None, 

161 extra: dict[str, Any] | None = None, 

162 ) -> None: 

163 """Update the sync status for a specific region. 

164 

165 ``extra`` carries optional, additive sub-status that a richer endpoint 

166 shape needs — for example a role-keyed breakdown of a split topology 

167 (``{"roles": {...}, "store": {...}}``). Its keys are merged into the 

168 stored status alongside the flat fields, so a consumer that only reads 

169 the flat shape is unaffected. 

170 """ 

171 status_value: dict[str, Any] = { 

172 "state": state, 

173 "replicas_ready": replicas_ready, 

174 "replicas_desired": replicas_desired, 

175 "last_sync": _utc_now_iso(), 

176 } 

177 if error: 

178 status_value["error"] = error 

179 if extra: 

180 status_value.update(extra) 

181 

182 try: 

183 self._table.update_item( 

184 Key={"endpoint_name": endpoint_name}, 

185 UpdateExpression="SET region_status.#r = :s, updated_at = :u", 

186 ExpressionAttributeNames={"#r": region}, 

187 ExpressionAttributeValues={ 

188 ":s": status_value, 

189 ":u": _utc_now_iso(), 

190 }, 

191 ) 

192 except ClientError as e: 

193 logger.error( 

194 "Failed to update region status for %s/%s: %s", 

195 endpoint_name, 

196 region, 

197 e, 

198 ) 

199 

200 def delete_endpoint(self, endpoint_name: str) -> bool: 

201 """Delete an endpoint record entirely.""" 

202 try: 

203 self._table.delete_item( 

204 Key={"endpoint_name": endpoint_name}, 

205 ConditionExpression="attribute_exists(endpoint_name)", 

206 ) 

207 return True 

208 except ClientError as e: 

209 if e.response["Error"]["Code"] == "ConditionalCheckFailedException": 209 ↛ 211line 209 didn't jump to line 211 because the condition on line 209 was always true

210 return False 

211 raise 

212 

213 def scale_endpoint(self, endpoint_name: str, replicas: int) -> dict[str, Any] | None: 

214 """Update the replica count in the spec.""" 

215 try: 

216 response = self._table.update_item( 

217 Key={"endpoint_name": endpoint_name}, 

218 UpdateExpression="SET spec.replicas = :r, updated_at = :u", 

219 ExpressionAttributeValues={ 

220 ":r": replicas, 

221 ":u": _utc_now_iso(), 

222 }, 

223 ConditionExpression="attribute_exists(endpoint_name)", 

224 ReturnValues="ALL_NEW", 

225 ) 

226 return _deserialize_from_dynamo(response.get("Attributes", {})) 

227 except ClientError as e: 

228 if e.response["Error"]["Code"] == "ConditionalCheckFailedException": 228 ↛ 230line 228 didn't jump to line 230 because the condition on line 228 was always true

229 return None 

230 raise 

231 

232 

233def _serialize_for_dynamo(obj: Any) -> Any: 

234 """Convert Python objects to DynamoDB-compatible types. 

235 

236 Recurses through nested dicts and lists, so an arbitrarily deep 

237 configuration block carried on an endpoint spec (for example a nested 

238 topology/store/transfer block, including list-valued sub-fields) is 

239 converted in place rather than only at the top level. 

240 

241 Type handling: 

242 - Integers are kept as integers, so whole-number counts are preserved. 

243 - Floats are rendered as their decimal-string form, avoiding binary-float 

244 rounding on store/reload. 

245 - Strings, booleans, and None pass through unchanged. Byte-size values 

246 authored as base-10 integer decimal strings therefore stay strings and 

247 are never routed through a float, so they reload exactly as written 

248 without float-to-Decimal coercion. 

249 """ 

250 if isinstance(obj, dict): 

251 return {k: _serialize_for_dynamo(v) for k, v in obj.items()} 

252 if isinstance(obj, list): 

253 return [_serialize_for_dynamo(i) for i in obj] 

254 if isinstance(obj, (int, float)): 

255 return str(obj) if isinstance(obj, float) else obj 

256 return obj 

257 

258 

259def _deserialize_from_dynamo(item: dict[str, Any]) -> dict[str, Any]: 

260 """Convert a DynamoDB item back to plain Python types. 

261 

262 Recurses through nested dicts and lists to mirror the nested structure 

263 produced by :func:`_serialize_for_dynamo`. DynamoDB returns numbers as 

264 Decimal: whole values become int (so integer counts survive the 

265 round-trip) and fractional values become float. Strings are left 

266 untouched, so a byte-size value stored as a decimal string returns as the 

267 same string. 

268 """ 

269 from decimal import Decimal 

270 

271 def convert(v: Any) -> Any: 

272 if isinstance(v, Decimal): 

273 return int(v) if v == int(v) else float(v) 

274 if isinstance(v, dict): 

275 return {k: convert(val) for k, val in v.items()} 

276 if isinstance(v, list): 

277 return [convert(i) for i in v] 

278 return v 

279 

280 result: dict[str, Any] = convert(item) 

281 return result 

282 

283 

284def get_inference_endpoint_store() -> InferenceEndpointStore: 

285 """Factory function for InferenceEndpointStore.""" 

286 return InferenceEndpointStore()