postgres.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. """PostgreSQL repository and best-effort writer for pipeline tracing."""
  2. from __future__ import annotations
  3. import logging
  4. from typing import Any
  5. from uuid import UUID
  6. import psycopg2.extras
  7. from core.config import CreationDbConfig
  8. from core.db_session import transaction
  9. from pipeline.models import PipelineJob, PipelineRun, PipelineRunEvent, PipelineStage
  10. from pipeline.tracing import TraceConfig, TraceContext, sanitize_payload
  11. Json = psycopg2.extras.Json
  12. psycopg2.extras.register_uuid()
  13. logger = logging.getLogger(__name__)
  14. class PostgresPipelineRepository:
  15. """Repository for orchestration state and trace ledger rows.
  16. This class owns no transaction boundary; API dependencies pass a pooled
  17. connection, while the best-effort trace writer opens short transactions.
  18. """
  19. def __init__(self, conn: Any):
  20. self.conn = conn
  21. def _one(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any]:
  22. with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
  23. cur.execute(sql, params)
  24. row = cur.fetchone()
  25. if row is None:
  26. raise RuntimeError("expected one row, got none")
  27. return dict(row)
  28. def _one_or_none(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any] | None:
  29. with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
  30. cur.execute(sql, params)
  31. row = cur.fetchone()
  32. return dict(row) if row else None
  33. def _all(self, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
  34. with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
  35. cur.execute(sql, params)
  36. return [dict(row) for row in cur.fetchall()]
  37. def create_pipeline_run(
  38. self,
  39. *,
  40. run_key: str | None = None,
  41. batch_id: UUID | None = None,
  42. status: str = "pending",
  43. current_stage: str | None = None,
  44. config: dict[str, Any] | None = None,
  45. metadata: dict[str, Any] | None = None,
  46. ) -> PipelineRun:
  47. row = self._one(
  48. """
  49. INSERT INTO pipeline_runs(
  50. run_key, batch_id, status, current_stage, config, metadata, started_at
  51. )
  52. VALUES (%s, %s, %s, %s, %s, %s, CASE WHEN %s = 'running' THEN now() ELSE NULL END)
  53. ON CONFLICT (run_key) DO UPDATE SET
  54. batch_id = COALESCE(EXCLUDED.batch_id, pipeline_runs.batch_id),
  55. status = EXCLUDED.status,
  56. current_stage = COALESCE(EXCLUDED.current_stage, pipeline_runs.current_stage),
  57. config = pipeline_runs.config || EXCLUDED.config,
  58. metadata = pipeline_runs.metadata || EXCLUDED.metadata,
  59. started_at = COALESCE(pipeline_runs.started_at, EXCLUDED.started_at)
  60. RETURNING *
  61. """,
  62. (
  63. run_key,
  64. batch_id,
  65. status,
  66. current_stage,
  67. Json(config or {}),
  68. Json(metadata or {}),
  69. status,
  70. ),
  71. )
  72. return _pipeline_run(row)
  73. def mark_pipeline_run_status(
  74. self,
  75. run_id: UUID,
  76. *,
  77. status: str,
  78. current_stage: str | None = None,
  79. error_message: str | None = None,
  80. metadata: dict[str, Any] | None = None,
  81. ) -> PipelineRun:
  82. row = self._one(
  83. """
  84. UPDATE pipeline_runs SET
  85. status = %s,
  86. current_stage = COALESCE(%s, current_stage),
  87. error_message = %s,
  88. metadata = CASE WHEN %s THEN metadata || %s ELSE metadata END,
  89. finished_at = CASE WHEN %s IN ('done', 'partial', 'failed') THEN now() ELSE finished_at END
  90. WHERE id = %s
  91. RETURNING *
  92. """,
  93. (
  94. status,
  95. current_stage,
  96. error_message,
  97. metadata is not None,
  98. Json(metadata or {}),
  99. status,
  100. run_id,
  101. ),
  102. )
  103. return _pipeline_run(row)
  104. def get_pipeline_run(self, run_id: UUID) -> PipelineRun:
  105. return _pipeline_run(self._one("SELECT * FROM pipeline_runs WHERE id = %s", (run_id,)))
  106. def get_pipeline_run_by_acquisition_run(self, acquisition_run_id: UUID) -> PipelineRun:
  107. row = self._one(
  108. """
  109. SELECT pr.* FROM pipeline_runs pr
  110. JOIN pipeline_run_events pre ON pre.pipeline_run_id = pr.id
  111. WHERE pre.acquisition_run_id = %s
  112. ORDER BY pre.created_at DESC
  113. LIMIT 1
  114. """,
  115. (acquisition_run_id,),
  116. )
  117. return _pipeline_run(row)
  118. def save_pipeline_job(
  119. self,
  120. *,
  121. run_id: UUID,
  122. stage: PipelineStage | str,
  123. target_id: UUID | None = None,
  124. target_table: str | None = None,
  125. status: str = "pending",
  126. metadata: dict[str, Any] | None = None,
  127. ) -> PipelineJob:
  128. row = self._one(
  129. """
  130. INSERT INTO pipeline_jobs(
  131. pipeline_run_id, stage, target_table, target_id, status, metadata, started_at
  132. )
  133. VALUES (%s, %s, %s, %s, %s, %s, CASE WHEN %s = 'running' THEN now() ELSE NULL END)
  134. RETURNING *
  135. """,
  136. (run_id, stage, target_table, target_id, status, Json(metadata or {}), status),
  137. )
  138. return _pipeline_job(row)
  139. def mark_job_status(
  140. self,
  141. job_id: UUID,
  142. *,
  143. status: str,
  144. error_message: str | None = None,
  145. metadata: dict[str, Any] | None = None,
  146. ) -> PipelineJob:
  147. row = self._one(
  148. """
  149. UPDATE pipeline_jobs SET
  150. status = %s,
  151. error_message = %s,
  152. metadata = CASE WHEN %s THEN metadata || %s ELSE metadata END,
  153. finished_at = CASE WHEN %s IN ('done', 'partial', 'failed', 'skipped') THEN now() ELSE finished_at END
  154. WHERE id = %s
  155. RETURNING *
  156. """,
  157. (
  158. status,
  159. error_message,
  160. metadata is not None,
  161. Json(metadata or {}),
  162. status,
  163. job_id,
  164. ),
  165. )
  166. return _pipeline_job(row)
  167. def append_event(
  168. self,
  169. *,
  170. context: TraceContext,
  171. stage: str,
  172. event_type: str,
  173. status: str | None = None,
  174. severity: str = "info",
  175. target_table: str | None = None,
  176. target_id: UUID | None = None,
  177. message: str | None = None,
  178. payload: dict[str, Any] | None = None,
  179. error_message: str | None = None,
  180. duration_ms: int | None = None,
  181. attempt_index: int | None = None,
  182. ) -> PipelineRunEvent:
  183. row = self._one(
  184. """
  185. INSERT INTO pipeline_run_events(
  186. pipeline_run_id, pipeline_job_id, stage, event_type, status, severity,
  187. target_table, target_id, acquisition_run_id, acquisition_job_id,
  188. query_id, item_id, decode_job_id, decode_result_id, payload_draft_id,
  189. ingest_record_id, platform, attempt_index, message, payload,
  190. error_message, duration_ms
  191. )
  192. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  193. RETURNING *
  194. """,
  195. (
  196. context.pipeline_run_id,
  197. context.pipeline_job_id,
  198. stage,
  199. event_type,
  200. status,
  201. severity,
  202. target_table,
  203. target_id,
  204. context.acquisition_run_id,
  205. context.acquisition_job_id,
  206. context.query_id,
  207. context.item_id,
  208. context.decode_job_id,
  209. context.decode_result_id,
  210. context.payload_draft_id,
  211. context.ingest_record_id,
  212. context.platform,
  213. attempt_index,
  214. message,
  215. Json(payload or {}),
  216. error_message,
  217. duration_ms,
  218. ),
  219. )
  220. return PipelineRunEvent.model_validate(_event_row(row))
  221. def record_candidate_hit(
  222. self,
  223. *,
  224. context: TraceContext,
  225. item_id: UUID | None,
  226. platform: str,
  227. unique_key: str | None = None,
  228. platform_item_id: str | None = None,
  229. search_provider: str | None = None,
  230. detail_provider: str | None = None,
  231. attempt_index: int | None = None,
  232. page_index: int | None = None,
  233. page_rank: int | None = None,
  234. candidate_rank: int | None = None,
  235. source_cursor: str | None = None,
  236. is_duplicate_hit: bool = False,
  237. raw_candidate: dict[str, Any] | None = None,
  238. metadata: dict[str, Any] | None = None,
  239. ) -> dict[str, Any]:
  240. return self._one(
  241. """
  242. INSERT INTO candidate_item_hits(
  243. pipeline_run_id, acquisition_run_id, acquisition_job_id,
  244. query_id, item_id, platform, unique_key, platform_item_id,
  245. search_provider, detail_provider, attempt_index, page_index,
  246. page_rank, candidate_rank, source_cursor, is_duplicate_hit,
  247. raw_candidate, metadata
  248. )
  249. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  250. RETURNING *
  251. """,
  252. (
  253. context.pipeline_run_id,
  254. context.acquisition_run_id,
  255. context.acquisition_job_id,
  256. context.query_id,
  257. item_id,
  258. platform,
  259. unique_key,
  260. platform_item_id,
  261. search_provider,
  262. detail_provider,
  263. attempt_index,
  264. page_index,
  265. page_rank,
  266. candidate_rank,
  267. source_cursor,
  268. is_duplicate_hit,
  269. Json(raw_candidate or {}),
  270. Json(metadata or {}),
  271. ),
  272. )
  273. def save_llm_call_trace(
  274. self,
  275. *,
  276. context: TraceContext,
  277. stage: str,
  278. substage: str | None = None,
  279. provider: str | None = None,
  280. model_name: str | None = None,
  281. endpoint: str | None = None,
  282. prompt_name: str | None = None,
  283. prompt_hash: str | None = None,
  284. request_payload: dict[str, Any] | None = None,
  285. response_payload: dict[str, Any] | None = None,
  286. parsed_payload: dict[str, Any] | None = None,
  287. status: str = "pending",
  288. error_message: str | None = None,
  289. latency_ms: int | None = None,
  290. attempt_index: int | None = None,
  291. ) -> dict[str, Any]:
  292. return self._one(
  293. """
  294. INSERT INTO llm_call_traces(
  295. pipeline_run_id, pipeline_job_id, stage, substage, provider,
  296. model_name, endpoint, prompt_name, prompt_hash, trace_context,
  297. request_payload, response_payload, parsed_payload, status,
  298. error_message, latency_ms, attempt_index
  299. )
  300. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  301. RETURNING *
  302. """,
  303. (
  304. context.pipeline_run_id,
  305. context.pipeline_job_id,
  306. stage,
  307. substage,
  308. provider,
  309. model_name,
  310. endpoint,
  311. prompt_name,
  312. prompt_hash,
  313. Json(context.as_payload()),
  314. Json(request_payload or {}),
  315. Json(response_payload or {}),
  316. Json(parsed_payload or {}),
  317. status,
  318. error_message,
  319. latency_ms,
  320. attempt_index,
  321. ),
  322. )
  323. def list_timeline(self, run_id: UUID) -> list[dict[str, Any]]:
  324. return self._all(
  325. """
  326. SELECT * FROM pipeline_run_events
  327. WHERE pipeline_run_id = %s
  328. ORDER BY created_at, id
  329. """,
  330. (run_id,),
  331. )
  332. def list_jobs(self, run_id: UUID) -> list[dict[str, Any]]:
  333. return self._all(
  334. """
  335. SELECT * FROM pipeline_jobs
  336. WHERE pipeline_run_id = %s
  337. ORDER BY created_at, id
  338. """,
  339. (run_id,),
  340. )
  341. def list_candidate_hits(self, run_id: UUID) -> list[dict[str, Any]]:
  342. return self._all(
  343. """
  344. SELECT * FROM candidate_item_hits
  345. WHERE pipeline_run_id = %s
  346. ORDER BY created_at, page_index NULLS LAST, page_rank NULLS LAST, id
  347. """,
  348. (run_id,),
  349. )
  350. def list_llm_call_traces(self, run_id: UUID, *, limit: int = 500) -> list[dict[str, Any]]:
  351. return self._all(
  352. """
  353. SELECT * FROM llm_call_traces
  354. WHERE pipeline_run_id = %s
  355. ORDER BY created_at, id
  356. LIMIT %s
  357. """,
  358. (run_id, limit),
  359. )
  360. def get_timeline_bundle(self, run_id: UUID) -> dict[str, Any]:
  361. return {
  362. "events": self.list_timeline(run_id),
  363. "jobs": self.list_jobs(run_id),
  364. "candidate_hits": self.list_candidate_hits(run_id),
  365. "llm_call_traces": self.list_llm_call_traces(run_id),
  366. }
  367. def get_resume_cursor(self, run_id: UUID):
  368. return None
  369. class PostgresTraceWriter:
  370. """Best-effort writer that never lets tracing break the business flow."""
  371. def __init__(self, *, db_config: CreationDbConfig, config: TraceConfig):
  372. self.db_config = db_config
  373. self.config = config
  374. def event(self, *, context: TraceContext, stage: str, event_type: str, **kwargs: Any) -> None:
  375. self._best_effort(
  376. lambda repo: repo.append_event(
  377. context=context,
  378. stage=stage,
  379. event_type=event_type,
  380. payload=sanitize_payload(kwargs.pop("payload", {}) or {}, max_chars=self.config.max_payload_chars),
  381. **kwargs,
  382. )
  383. )
  384. def candidate_hit(self, *, context: TraceContext, **kwargs: Any) -> None:
  385. self._best_effort(
  386. lambda repo: repo.record_candidate_hit(
  387. context=context,
  388. raw_candidate=sanitize_payload(kwargs.pop("raw_candidate", {}) or {}, max_chars=self.config.max_payload_chars),
  389. metadata=sanitize_payload(kwargs.pop("metadata", {}) or {}, max_chars=self.config.max_payload_chars),
  390. **kwargs,
  391. )
  392. )
  393. def llm_call(self, *, context: TraceContext, stage: str, substage: str | None = None, **kwargs: Any) -> None:
  394. request_payload = kwargs.pop("request_payload", {}) if self.config.capture_request else {}
  395. response_payload = kwargs.pop("response_payload", {}) if self.config.capture_response else {}
  396. parsed_payload = kwargs.pop("parsed_payload", {})
  397. self._best_effort(
  398. lambda repo: repo.save_llm_call_trace(
  399. context=context,
  400. stage=stage,
  401. substage=substage,
  402. request_payload=sanitize_payload(request_payload or {}, max_chars=self.config.max_payload_chars),
  403. response_payload=sanitize_payload(response_payload or {}, max_chars=self.config.max_payload_chars),
  404. parsed_payload=sanitize_payload(parsed_payload or {}, max_chars=self.config.max_payload_chars),
  405. **kwargs,
  406. )
  407. )
  408. def _best_effort(self, fn) -> None:
  409. if not self.config.enabled:
  410. return
  411. try:
  412. with transaction(self.db_config) as conn:
  413. fn(PostgresPipelineRepository(conn))
  414. except Exception as exc: # pragma: no cover - defensive isolation
  415. logger.warning("pipeline trace write skipped: %s", exc)
  416. def _pipeline_run(row: dict[str, Any]) -> PipelineRun:
  417. return PipelineRun(
  418. id=row.get("id"),
  419. run_key=row.get("run_key"),
  420. batch_id=row.get("batch_id"),
  421. status=row.get("status") or "pending",
  422. current_stage=row.get("current_stage"),
  423. metadata={
  424. **(row.get("config") or {}),
  425. **(row.get("metadata") or {}),
  426. },
  427. error_message=row.get("error_message"),
  428. started_at=row.get("started_at"),
  429. finished_at=row.get("finished_at"),
  430. )
  431. def _pipeline_job(row: dict[str, Any]) -> PipelineJob:
  432. return PipelineJob(
  433. id=row.get("id"),
  434. run_id=row.get("pipeline_run_id"),
  435. stage=row.get("stage"),
  436. status=row.get("status") or "pending",
  437. target_table=row.get("target_table"),
  438. target_id=row.get("target_id"),
  439. attempt_count=row.get("attempt_count") or 0,
  440. metadata=row.get("metadata") or {},
  441. error_message=row.get("error_message"),
  442. started_at=row.get("started_at"),
  443. finished_at=row.get("finished_at"),
  444. )
  445. def _event_row(row: dict[str, Any]) -> dict[str, Any]:
  446. out = dict(row)
  447. out["pipeline_run_id"] = out.pop("pipeline_run_id", None)
  448. return out