Coverage for src/lexigram/notification/delivery/stores.py: 51%

102 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 07:17 +0800

1"""Delivery-state stores for :class:`~lexigram.notification.delivery.retry.RetryingMailer`. 

2 

3Two implementations of 

4:class:`~lexigram.contracts.notification.delivery.DeliveryStoreProtocol`: 

5 

6- :class:`MemoryDeliveryStore` — in-process, tests/dev. 

7- :class:`SqlDeliveryStore` — SQL-backed via ``DatabaseProviderProtocol``; 

8 the table is created automatically on first use (mirrors 

9 ``DatabaseInboxStore``). 

10""" 

11 

12from __future__ import annotations 

13 

14from datetime import UTC, datetime, timedelta 

15from typing import Any 

16import uuid 

17 

18from lexigram.contracts.data.sql.database import DatabaseProviderProtocol 

19from lexigram.logging import get_logger 

20 

21logger = get_logger(__name__) 

22 

23_DEFAULT_TABLE = "notification_delivery_state" 

24 

25_CREATE_TABLE_SQL = """ 

26CREATE TABLE IF NOT EXISTS {table} ( 

27 delivery_id TEXT PRIMARY KEY, 

28 recipient TEXT NOT NULL DEFAULT '', 

29 subject TEXT NOT NULL DEFAULT '', 

30 body TEXT NOT NULL DEFAULT '', 

31 status TEXT NOT NULL DEFAULT 'pending', 

32 attempts INTEGER NOT NULL DEFAULT 0, 

33 last_error TEXT NOT NULL DEFAULT '', 

34 message JSONB NOT NULL DEFAULT '{{}}', 

35 created_at TIMESTAMPTZ NOT NULL, 

36 retry_after TIMESTAMPTZ 

37); 

38CREATE INDEX IF NOT EXISTS {table}_status_idx ON {table} (status, retry_after); 

39""" 

40 

41 

42class MemoryDeliveryStore: 

43 """In-process :class:`DeliveryStoreProtocol` for tests/dev.""" 

44 

45 def __init__(self) -> None: 

46 """Initialise empty stores.""" 

47 self._state: dict[str, dict[str, Any]] = {} 

48 self._attempts: dict[str, list[dict[str, Any]]] = {} 

49 

50 async def record_attempt( 

51 self, delivery_id: str, recipient: str, subject: str, attempt_number: int 

52 ) -> None: 

53 """Record one attempt row. 

54 

55 Args: 

56 delivery_id: Delivery operation identifier. 

57 recipient: Comma-separated recipients. 

58 subject: Message subject. 

59 attempt_number: 1-based attempt counter. 

60 """ 

61 self._attempts.setdefault(delivery_id, []).append( 

62 { 

63 "recipient": recipient, 

64 "subject": subject, 

65 "attempt_number": attempt_number, 

66 "attempted_at": datetime.now(UTC), 

67 } 

68 ) 

69 

70 async def create_pending(self, message: Any) -> str: 

71 """Persist a pending delivery and return its id. 

72 

73 Args: 

74 message: Object exposing ``to``, ``subject``, ``body``. 

75 

76 Returns: 

77 Generated delivery identifier. 

78 """ 

79 delivery_id = str(uuid.uuid4()) 

80 self._state[delivery_id] = { 

81 "delivery_id": delivery_id, 

82 "recipient": ",".join(message.to) if getattr(message, "to", None) else "", 

83 "subject": message.subject, 

84 "body": message.body, 

85 "message": {"subject": message.subject, "body": message.body}, 

86 "status": "pending", 

87 "attempts": 0, 

88 "last_error": "", 

89 "created_at": datetime.now(UTC), 

90 "retry_after": None, 

91 } 

92 return delivery_id 

93 

94 async def mark_delivered(self, delivery_id: str) -> None: 

95 """Mark a delivery delivered. 

96 

97 Args: 

98 delivery_id: Identifier of the delivery operation. 

99 """ 

100 entry = self._state.get(delivery_id) 

101 if entry is not None: 

102 entry["status"] = "delivered" 

103 

104 async def get_retry_count(self, delivery_id: str) -> int: 

105 """Return the number of attempts made for a delivery. 

106 

107 Args: 

108 delivery_id: Identifier of the delivery operation. 

109 

110 Returns: 

111 Current attempt count; 0 when unknown. 

112 """ 

113 entry = self._state.get(delivery_id) 

114 return int(entry["attempts"]) if entry else 0 

115 

116 async def increment_retry(self, delivery_id: str) -> int: 

117 """Increment and return the attempt counter. 

118 

119 Args: 

120 delivery_id: Identifier of the delivery operation. 

121 

122 Returns: 

123 New attempt count after incrementing. 

124 """ 

125 entry = self._state.get(delivery_id) 

126 if entry is None: 

127 return 0 

128 entry["attempts"] = int(entry["attempts"]) + 1 

129 entry["status"] = "retrying" 

130 return int(entry["attempts"]) 

131 

132 async def schedule_retry(self, delivery_id: str, delay_seconds: float) -> None: 

133 """Record when the next retry should run. 

134 

135 Args: 

136 delivery_id: Identifier of the delivery operation. 

137 delay_seconds: Seconds from now. 

138 """ 

139 entry = self._state.get(delivery_id) 

140 if entry is not None: 

141 entry["retry_after"] = datetime.now(UTC) + timedelta(seconds=delay_seconds) 

142 

143 async def mark_failed( 

144 self, delivery_id: str, reason: str = "", final: bool = True 

145 ) -> None: 

146 """Mark a delivery failed. 

147 

148 Args: 

149 delivery_id: Identifier of the delivery operation. 

150 reason: Human-readable failure description. 

151 final: Whether retries are exhausted. 

152 """ 

153 entry = self._state.get(delivery_id) 

154 if entry is not None: 

155 entry["status"] = "failed" 

156 entry["last_error"] = reason 

157 

158 # -- Worker support (not part of the protocol) ------------------------ 

159 

160 async def due_deliveries(self, limit: int = 50) -> list[dict[str, Any]]: 

161 """Return deliveries whose backoff window has elapsed. 

162 

163 Args: 

164 limit: Maximum rows to return. 

165 

166 Returns: 

167 Raw state dicts for due, still-retryable deliveries. 

168 """ 

169 now = datetime.now(UTC) 

170 due = [ 

171 dict(entry) 

172 for entry in self._state.values() 

173 if entry["status"] in ("pending", "retrying") 

174 and int(entry["attempts"]) < 5 

175 and (entry["retry_after"] is None or entry["retry_after"] <= now) 

176 ] 

177 return sorted(due, key=lambda e: e["created_at"])[:limit] 

178 

179 

180class SqlDeliveryStore: 

181 """SQL-backed :class:`DeliveryStoreProtocol` implementation. 

182 

183 Args: 

184 db: Database provider injected via DI. 

185 table: Table name. Defaults to ``notification_delivery_state``. 

186 """ 

187 

188 def __init__( 

189 self, 

190 db: DatabaseProviderProtocol, 

191 *, 

192 table: str = _DEFAULT_TABLE, 

193 ) -> None: 

194 """Initialise with a database provider and table name. 

195 

196 Args: 

197 db: Database provider instance. 

198 table: Backing table name. 

199 """ 

200 self._db = db 

201 self._table = table 

202 

203 async def _ensure_table(self) -> None: 

204 """Create the backing table/index if absent.""" 

205 sql = _CREATE_TABLE_SQL.format(table=self._table) 

206 for statement in sql.split(";"): 

207 if statement.strip(): 

208 await self._db.execute_query(statement) 

209 

210 @staticmethod 

211 def _row_to_state(row: Any) -> dict[str, Any]: 

212 """Normalise a driver row into a plain state dict.""" 

213 return { 

214 "delivery_id": str(row["delivery_id"]), 

215 "recipient": str(row.get("recipient") or ""), 

216 "subject": str(row.get("subject") or ""), 

217 "body": str(row.get("body") or ""), 

218 "message": { 

219 "subject": str(row.get("subject") or ""), 

220 "body": str(row.get("body") or ""), 

221 }, 

222 "status": str(row.get("status") or "pending"), 

223 "attempts": int(row.get("attempts") or 0), 

224 "last_error": str(row.get("last_error") or ""), 

225 "created_at": row.get("created_at"), 

226 "retry_after": row.get("retry_after"), 

227 } 

228 

229 async def record_attempt( 

230 self, delivery_id: str, recipient: str, subject: str, attempt_number: int 

231 ) -> None: 

232 """Record one attempt row. 

233 

234 Args: 

235 delivery_id: Delivery operation identifier. 

236 recipient: Comma-separated recipients. 

237 subject: Message subject. 

238 attempt_number: 1-based attempt counter. 

239 """ 

240 await self._db.execute_insert( 

241 f"{self._table}_attempts", 

242 { 

243 "delivery_id": delivery_id, 

244 "recipient": recipient, 

245 "subject": subject, 

246 "attempt_number": attempt_number, 

247 "attempted_at": datetime.now(UTC), 

248 }, 

249 ) 

250 

251 async def create_pending(self, message: Any) -> str: 

252 """Persist a pending delivery row and return its id. 

253 

254 Args: 

255 message: Object exposing ``to``, ``subject``, ``body``. 

256 

257 Returns: 

258 Generated delivery identifier. 

259 """ 

260 await self._ensure_table() 

261 delivery_id = str(uuid.uuid4()) 

262 await self._db.execute_insert( 

263 self._table, 

264 { 

265 "delivery_id": delivery_id, 

266 "recipient": ",".join(message.to) 

267 if getattr(message, "to", None) 

268 else "", 

269 "subject": message.subject, 

270 "body": message.body, 

271 "status": "pending", 

272 "attempts": 0, 

273 "last_error": "", 

274 "message": {"subject": message.subject, "body": message.body}, 

275 "created_at": datetime.now(UTC), 

276 }, 

277 ) 

278 return delivery_id 

279 

280 async def mark_delivered(self, delivery_id: str) -> None: 

281 """Mark a delivery delivered. 

282 

283 Args: 

284 delivery_id: Identifier of the delivery operation. 

285 """ 

286 result = await self._db.execute_update( 

287 self._table, 

288 {"status": "delivered"}, 

289 "delivery_id = ?", 

290 [delivery_id], 

291 ) 

292 if result.affected_rows == 0: 

293 logger.warning("delivery_mark_delivered_missing", delivery_id=delivery_id) 

294 

295 async def get_retry_count(self, delivery_id: str) -> int: 

296 """Return the attempt counter for a delivery. 

297 

298 Args: 

299 delivery_id: Identifier of the delivery operation. 

300 

301 Returns: 

302 Current attempt count; 0 when unknown. 

303 """ 

304 await self._ensure_table() 

305 result = await self._db.execute_query( 

306 f"SELECT attempts FROM {self._table} WHERE delivery_id = ?", 

307 [delivery_id], 

308 ) 

309 rows = getattr(result, "rows", []) 

310 return int(rows[0]["attempts"]) if rows else 0 

311 

312 async def increment_retry(self, delivery_id: str) -> int: 

313 """Increment and return the attempt counter. 

314 

315 Args: 

316 delivery_id: Identifier of the delivery operation. 

317 

318 Returns: 

319 New attempt count after incrementing. 

320 """ 

321 current = await self.get_retry_count(delivery_id) 

322 new_value = current + 1 

323 await self._db.execute_update( 

324 self._table, 

325 {"attempts": new_value, "status": "retrying"}, 

326 "delivery_id = ?", 

327 [delivery_id], 

328 ) 

329 return new_value 

330 

331 async def schedule_retry(self, delivery_id: str, delay_seconds: float) -> None: 

332 """Record when the next retry should run. 

333 

334 Args: 

335 delivery_id: Identifier of the delivery operation. 

336 delay_seconds: Seconds from now. 

337 """ 

338 retry_after = datetime.now(UTC) + timedelta(seconds=delay_seconds) 

339 await self._db.execute_update( 

340 self._table, 

341 {"retry_after": retry_after}, 

342 "delivery_id = ?", 

343 [delivery_id], 

344 ) 

345 

346 async def mark_failed( 

347 self, delivery_id: str, reason: str = "", final: bool = True 

348 ) -> None: 

349 """Mark a delivery failed. 

350 

351 Args: 

352 delivery_id: Identifier of the delivery operation. 

353 reason: Human-readable failure description. 

354 final: Whether retries are exhausted. 

355 """ 

356 await self._db.execute_update( 

357 self._table, 

358 {"status": "failed", "last_error": reason}, 

359 "delivery_id = ?", 

360 [delivery_id], 

361 ) 

362 

363 async def due_deliveries(self, limit: int = 50) -> list[dict[str, Any]]: 

364 """Fetch deliveries whose backoff window has elapsed. 

365 

366 Args: 

367 limit: Maximum rows to return. 

368 

369 Returns: 

370 Normalised state dicts including a ``message`` payload. 

371 """ 

372 await self._ensure_table() 

373 now = datetime.now(UTC) 

374 result = await self._db.execute_query( 

375 f"SELECT * FROM {self._table} " 

376 "WHERE status IN ('pending', 'retrying') AND attempts < 5 " 

377 "AND (retry_after IS NULL OR retry_after <= ?) " 

378 "ORDER BY created_at ASC LIMIT ?", 

379 [now, limit], 

380 ) 

381 rows = getattr(result, "rows", []) 

382 states = [] 

383 for row in rows: 

384 state = self._row_to_state(row) 

385 raw_message = state["message"] 

386 if isinstance(raw_message, str): 

387 from lexigram.serialization import loads 

388 

389 try: 

390 state["message"] = loads(raw_message) 

391 except (TypeError, ValueError): 

392 state["message"] = {} 

393 states.append(state) 

394 return states 

395 

396 

397__all__ = ["MemoryDeliveryStore", "SqlDeliveryStore"]