persistence.py 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512
  1. """PostgreSQL persistence adapters for the DataOps MCP control plane."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import uuid
  6. from datetime import UTC, datetime
  7. from zoneinfo import ZoneInfo
  8. from croniter import croniter
  9. from sqlalchemy import text
  10. from app.core.common.identifiers import new_governance_uid
  11. def _json(value):
  12. return json.dumps(
  13. value,
  14. sort_keys=True,
  15. separators=(",", ":"),
  16. ensure_ascii=False,
  17. )
  18. def _schedule_hash(schedule_plan):
  19. return hashlib.sha256(_json(schedule_plan).encode("utf-8")).hexdigest()
  20. def _required_string(value, label, maximum=500):
  21. if not isinstance(value, str) or not value.strip():
  22. raise ValueError(f"{label} is required")
  23. normalized = value.strip()
  24. if len(normalized) > maximum:
  25. raise ValueError(f"{label} exceeds {maximum} characters")
  26. return normalized
  27. class PostgresSchedulingPlanStore:
  28. def __init__(self, engine):
  29. self.engine = engine
  30. def create_candidate(self, record):
  31. dataflow_uid = _required_string(record.get("dataflow_uid"), "dataflow_uid", 100)
  32. business_domain = _required_string(
  33. record.get("business_domain"), "business_domain", 200
  34. )
  35. environment = _required_string(record.get("environment"), "environment", 20)
  36. created_by = _required_string(record.get("created_by"), "created_by", 200)
  37. workflow_spec = dict(record.get("workflow_spec") or {})
  38. schedule_plan = dict(record.get("schedule_plan") or {})
  39. workflow_hash = _required_string(
  40. record.get("workflow_hash"), "workflow_hash", 64
  41. )
  42. candidate_id = new_governance_uid()
  43. binding_id = new_governance_uid()
  44. schedule_id = new_governance_uid()
  45. placeholder = f"candidate_{candidate_id.replace('-', '')}"
  46. metadata = {
  47. "gateway_status": "candidate",
  48. "correlation_id": record.get("correlation_id"),
  49. }
  50. with self.engine.begin() as connection:
  51. connection.execute(
  52. text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
  53. {"key": f"{dataflow_uid}:{environment}"},
  54. )
  55. version_no = connection.execute(
  56. text(
  57. "SELECT COALESCE(MAX(version_no), 0) + 1 "
  58. "FROM public.dataflow_workflow_versions "
  59. "WHERE dataflow_uid = CAST(:uid AS uuid) "
  60. "AND environment = :environment"
  61. ),
  62. {"uid": dataflow_uid, "environment": environment},
  63. ).scalar_one()
  64. connection.execute(
  65. text(
  66. "INSERT INTO public.dataflow_workflow_versions "
  67. "(id, dataflow_uid, environment, version_no, "
  68. "n8n_workflow_id, n8n_workflow_name, definition_hash, "
  69. "definition_snapshot, status, created_by, engine_type, "
  70. "engine_definition_id, engine_revision, "
  71. "deployment_metadata, workflow_spec, schedule_plan, "
  72. "business_domain, created_by_subject, write_authorized) "
  73. "VALUES (CAST(:id AS uuid), CAST(:dataflow_uid AS uuid), "
  74. ":environment, :version_no, NULL, NULL, :definition_hash, "
  75. "CAST(:definition_snapshot AS jsonb), 'draft', NULL, "
  76. "'kestra', :engine_definition_id, NULL, "
  77. "CAST(:deployment_metadata AS jsonb), "
  78. "CAST(:workflow_spec AS jsonb), "
  79. "CAST(:schedule_plan AS jsonb), :business_domain, "
  80. ":created_by_subject, FALSE)"
  81. ),
  82. {
  83. "id": candidate_id,
  84. "dataflow_uid": dataflow_uid,
  85. "environment": environment,
  86. "version_no": version_no,
  87. "definition_hash": workflow_hash,
  88. "definition_snapshot": _json(workflow_spec),
  89. "engine_definition_id": placeholder,
  90. "deployment_metadata": _json(metadata),
  91. "workflow_spec": _json(workflow_spec),
  92. "schedule_plan": _json(schedule_plan),
  93. "business_domain": business_domain,
  94. "created_by_subject": created_by,
  95. },
  96. )
  97. connection.execute(
  98. text(
  99. "INSERT INTO public.workflow_engine_bindings "
  100. "(id, workflow_version_id, dataflow_uid, environment, "
  101. "engine_type, engine_definition_id, engine_revision, "
  102. "role, status, metadata) "
  103. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  104. "CAST(:dataflow_uid AS uuid), :environment, 'kestra', "
  105. ":engine_definition_id, NULL, 'standby', 'disabled', "
  106. "CAST(:metadata AS jsonb))"
  107. ),
  108. {
  109. "id": binding_id,
  110. "version_id": candidate_id,
  111. "dataflow_uid": dataflow_uid,
  112. "environment": environment,
  113. "engine_definition_id": placeholder,
  114. "metadata": _json(metadata),
  115. },
  116. )
  117. connection.execute(
  118. text(
  119. "INSERT INTO public.workflow_schedules "
  120. "(id, workflow_version_id, schedule_plan, schedule_hash, "
  121. "timezone, status, created_by) "
  122. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  123. "CAST(:schedule_plan AS jsonb), :schedule_hash, "
  124. ":timezone, 'draft', NULL)"
  125. ),
  126. {
  127. "id": schedule_id,
  128. "version_id": candidate_id,
  129. "schedule_plan": _json(schedule_plan),
  130. "schedule_hash": _schedule_hash(schedule_plan),
  131. "timezone": schedule_plan.get("timezone"),
  132. },
  133. )
  134. return {
  135. **record,
  136. "candidate_id": candidate_id,
  137. "version_no": int(version_no),
  138. "status": "candidate",
  139. }
  140. def get_candidate(self, candidate_id):
  141. with self.engine.connect() as connection:
  142. row = (
  143. connection.execute(
  144. text(
  145. "SELECT id::text AS candidate_id, "
  146. "dataflow_uid::text AS dataflow_uid, business_domain, "
  147. "environment, version_no, workflow_spec, schedule_plan, "
  148. "definition_hash AS workflow_hash, status, "
  149. "created_by_subject AS created_by, write_authorized, "
  150. "deployment_metadata "
  151. "FROM public.dataflow_workflow_versions "
  152. "WHERE id = CAST(:id AS uuid) AND engine_type = 'kestra'"
  153. ),
  154. {"id": candidate_id},
  155. )
  156. .mappings()
  157. .one_or_none()
  158. )
  159. if row is None:
  160. raise KeyError(candidate_id)
  161. result = dict(row)
  162. metadata = dict(result.pop("deployment_metadata") or {})
  163. result["status"] = metadata.get("gateway_status", result["status"])
  164. result["trusted_write_authorized"] = bool(result.pop("write_authorized", False))
  165. return result
  166. def record_deployment(self, candidate_id, deployment):
  167. metadata = {
  168. "gateway_status": deployment["status"],
  169. "deployment": deployment,
  170. }
  171. with self.engine.begin() as connection:
  172. updated = connection.execute(
  173. text(
  174. "UPDATE public.dataflow_workflow_versions "
  175. "SET engine_definition_id = :flow_id, "
  176. "engine_revision = :revision, "
  177. "deployment_metadata = deployment_metadata || "
  178. "CAST(:metadata AS jsonb), updated_at = CURRENT_TIMESTAMP "
  179. "WHERE id = CAST(:id AS uuid) AND engine_type = 'kestra'"
  180. ),
  181. {
  182. "id": candidate_id,
  183. "flow_id": deployment["flow_id"],
  184. "revision": deployment.get("engine_revision"),
  185. "metadata": _json(metadata),
  186. },
  187. )
  188. if updated.rowcount != 1:
  189. raise KeyError(candidate_id)
  190. connection.execute(
  191. text(
  192. "UPDATE public.workflow_engine_bindings "
  193. "SET engine_definition_id = :flow_id, "
  194. "engine_revision = :revision, metadata = CAST(:metadata AS jsonb), "
  195. "updated_at = CURRENT_TIMESTAMP "
  196. "WHERE workflow_version_id = CAST(:id AS uuid) "
  197. "AND engine_type = 'kestra'"
  198. ),
  199. {
  200. "id": candidate_id,
  201. "flow_id": deployment["flow_id"],
  202. "revision": deployment.get("engine_revision"),
  203. "metadata": _json(deployment),
  204. },
  205. )
  206. return dict(deployment)
  207. def ensure_rule_deployment_identity(
  208. self,
  209. candidate_id,
  210. environment,
  211. *,
  212. status,
  213. ):
  214. if status not in {"disabled", "canary"}:
  215. raise ValueError("rule deployment status is invalid")
  216. deployment_id = new_governance_uid()
  217. with self.engine.begin() as connection:
  218. target = connection.execute(
  219. text(
  220. """
  221. SELECT w.id::text AS workflow_version_id,
  222. v.id::text AS dataflow_version_id,
  223. s.id::text AS schedule_plan_id
  224. FROM public.dataflow_workflow_versions w
  225. JOIN public.dataflow_versions v
  226. ON v.dataflow_uid = w.dataflow_uid
  227. AND v.version_no = w.version_no
  228. AND v.status = 'released'
  229. LEFT JOIN public.workflow_schedules s
  230. ON s.workflow_version_id = w.id
  231. WHERE w.id = CAST(:candidate_id AS uuid)
  232. AND w.environment = :environment
  233. """
  234. ),
  235. {
  236. "candidate_id": candidate_id,
  237. "environment": environment,
  238. },
  239. ).mappings().one_or_none()
  240. if target is None:
  241. raise RuntimeError(
  242. "released dataflow version for workflow was not found"
  243. )
  244. existing = connection.execute(
  245. text(
  246. """
  247. SELECT id::text AS deployment_id,
  248. workflow_version_id::text,
  249. environment, status
  250. FROM public.dataflow_deployments
  251. WHERE dataflow_version_id =
  252. CAST(:dataflow_version_id AS uuid)
  253. AND environment = :environment
  254. FOR UPDATE
  255. """
  256. ),
  257. {
  258. "dataflow_version_id": target[
  259. "dataflow_version_id"
  260. ],
  261. "environment": environment,
  262. },
  263. ).mappings().one_or_none()
  264. if existing is None:
  265. existing = connection.execute(
  266. text(
  267. """
  268. INSERT INTO public.dataflow_deployments (
  269. id, dataflow_version_id, environment,
  270. workflow_version_id, schedule_plan_id,
  271. deployment_config, status, updated_at
  272. ) VALUES (
  273. CAST(:id AS uuid),
  274. CAST(:dataflow_version_id AS uuid),
  275. :environment,
  276. CAST(:workflow_version_id AS uuid),
  277. CAST(:schedule_plan_id AS uuid),
  278. '{}'::jsonb, :status, CURRENT_TIMESTAMP
  279. )
  280. RETURNING id::text AS deployment_id,
  281. workflow_version_id::text,
  282. environment, status
  283. """
  284. ),
  285. {
  286. "id": deployment_id,
  287. "dataflow_version_id": target[
  288. "dataflow_version_id"
  289. ],
  290. "environment": environment,
  291. "workflow_version_id": target[
  292. "workflow_version_id"
  293. ],
  294. "schedule_plan_id": target["schedule_plan_id"],
  295. "status": status,
  296. },
  297. ).mappings().one()
  298. else:
  299. if existing["workflow_version_id"] is None:
  300. if existing["status"] != "disabled":
  301. raise RuntimeError(
  302. "unbound deployment is not disabled"
  303. )
  304. existing = connection.execute(
  305. text(
  306. """
  307. UPDATE public.dataflow_deployments
  308. SET workflow_version_id =
  309. CAST(:workflow_version_id AS uuid),
  310. schedule_plan_id =
  311. CAST(:schedule_plan_id AS uuid),
  312. updated_at = CURRENT_TIMESTAMP
  313. WHERE id = CAST(:id AS uuid)
  314. AND workflow_version_id IS NULL
  315. AND status = 'disabled'
  316. RETURNING id::text AS deployment_id,
  317. workflow_version_id::text,
  318. environment, status
  319. """
  320. ),
  321. {
  322. "id": existing["deployment_id"],
  323. "workflow_version_id": target[
  324. "workflow_version_id"
  325. ],
  326. "schedule_plan_id": target[
  327. "schedule_plan_id"
  328. ],
  329. },
  330. ).mappings().one()
  331. elif (
  332. existing["workflow_version_id"]
  333. != target["workflow_version_id"]
  334. ):
  335. raise RuntimeError(
  336. "deployment belongs to another workflow version"
  337. )
  338. if status == "canary" and existing["status"] == "disabled":
  339. existing = connection.execute(
  340. text(
  341. """
  342. UPDATE public.dataflow_deployments
  343. SET status = 'canary',
  344. updated_at = CURRENT_TIMESTAMP
  345. WHERE id = CAST(:id AS uuid)
  346. AND status = 'disabled'
  347. RETURNING id::text AS deployment_id,
  348. workflow_version_id::text,
  349. environment, status
  350. """
  351. ),
  352. {"id": existing["deployment_id"]},
  353. ).mappings().one()
  354. elif existing["status"] not in {status, "canary"}:
  355. raise RuntimeError(
  356. "deployment state cannot enter canary"
  357. )
  358. return dict(existing)
  359. def get_deployment(self, candidate_id):
  360. with self.engine.connect() as connection:
  361. metadata = connection.execute(
  362. text(
  363. "SELECT deployment_metadata "
  364. "FROM public.dataflow_workflow_versions "
  365. "WHERE id = CAST(:id AS uuid) AND engine_type = 'kestra'"
  366. ),
  367. {"id": candidate_id},
  368. ).scalar_one_or_none()
  369. if metadata is None or not dict(metadata).get("deployment"):
  370. raise KeyError(candidate_id)
  371. return dict(metadata)["deployment"]
  372. def get_active_deployment(self, candidate_id):
  373. with self.engine.connect() as connection:
  374. row = (
  375. connection.execute(
  376. text(
  377. "SELECT active.id::text AS candidate_id, "
  378. "active.deployment_metadata "
  379. "FROM public.dataflow_workflow_versions candidate "
  380. "JOIN public.dataflow_workflow_versions active "
  381. "ON active.dataflow_uid = candidate.dataflow_uid "
  382. "AND active.environment = candidate.environment "
  383. "AND active.engine_type = 'kestra' "
  384. "AND active.status = 'active' "
  385. "AND active.id <> candidate.id "
  386. "WHERE candidate.id = CAST(:id AS uuid) "
  387. "ORDER BY active.version_no DESC LIMIT 1"
  388. ),
  389. {"id": candidate_id},
  390. )
  391. .mappings()
  392. .one_or_none()
  393. )
  394. if row is None:
  395. return None
  396. deployment = dict(row["deployment_metadata"] or {}).get("deployment")
  397. if not isinstance(deployment, dict):
  398. raise ValueError("active deployment is incomplete")
  399. return {**deployment, "candidate_id": row["candidate_id"]}
  400. def record_canary_execution(self, candidate_id, evidence):
  401. evidence_id = new_governance_uid()
  402. with self.engine.begin() as connection:
  403. connection.execute(
  404. text(
  405. "INSERT INTO public.workflow_canary_evidence "
  406. "(id, workflow_version_id, engine_execution_id, status, "
  407. "sample_runs, verification_summary) "
  408. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  409. ":execution_id, 'started', 0, '{}'::jsonb)"
  410. ),
  411. {
  412. "id": evidence_id,
  413. "version_id": candidate_id,
  414. "execution_id": evidence["execution_id"],
  415. },
  416. )
  417. return dict(evidence)
  418. def get_canary_evidence(self, candidate_id):
  419. with self.engine.connect() as connection:
  420. row = (
  421. connection.execute(
  422. text(
  423. "SELECT engine_execution_id AS execution_id, status, "
  424. "sample_runs, verified_by, verification_summary "
  425. "FROM public.workflow_canary_evidence "
  426. "WHERE workflow_version_id = CAST(:id AS uuid) "
  427. "ORDER BY created_at DESC LIMIT 1"
  428. ),
  429. {"id": candidate_id},
  430. )
  431. .mappings()
  432. .one_or_none()
  433. )
  434. return dict(row) if row is not None else None
  435. def record_canary_verification(self, candidate_id, evidence):
  436. status = evidence.get("status")
  437. if status not in {"passed", "failed"}:
  438. raise ValueError("canary verification must be passed or failed")
  439. verified_by = _required_string(
  440. evidence.get("verified_by"), "canary verified_by", 200
  441. )
  442. summary = dict(evidence.get("verification_summary") or {})
  443. with self.engine.begin() as connection:
  444. updated = connection.execute(
  445. text(
  446. "UPDATE public.workflow_canary_evidence "
  447. "SET status = :status, sample_runs = :sample_runs, "
  448. "verified_by = :verified_by, "
  449. "verification_summary = CAST(:summary AS jsonb), "
  450. "verified_at = CURRENT_TIMESTAMP "
  451. "WHERE workflow_version_id = CAST(:version_id AS uuid) "
  452. "AND engine_execution_id = :execution_id "
  453. "AND status = 'started'"
  454. ),
  455. {
  456. "status": status,
  457. "sample_runs": int(evidence.get("sample_runs", 0)),
  458. "verified_by": verified_by,
  459. "summary": _json(summary),
  460. "version_id": candidate_id,
  461. "execution_id": evidence.get("execution_id"),
  462. },
  463. )
  464. if updated.rowcount != 1:
  465. raise ValueError("canary evidence is already finalized")
  466. return dict(evidence)
  467. def get_execution_record(self, execution_id):
  468. execution_id = _required_string(execution_id, "execution_id", 255)
  469. with self.engine.connect() as connection:
  470. row = (
  471. connection.execute(
  472. text(
  473. "SELECT run.id::text AS run_id, "
  474. "run.workflow_version_id::text AS workflow_version_id, "
  475. "run.schedule_id::text AS schedule_id, "
  476. "run.engine_execution_id AS execution_id, "
  477. "run.trigger_type, run.status, "
  478. "run.correlation_id::text AS correlation_id, "
  479. "run.attempt, run.run_metadata, "
  480. "version.business_domain, version.environment, "
  481. "(SELECT COUNT(*) FROM public.workflow_runs replay "
  482. "WHERE replay.parent_run_id = run.id) AS retry_count "
  483. "FROM public.workflow_runs run "
  484. "JOIN public.dataflow_workflow_versions version "
  485. "ON version.id = run.workflow_version_id "
  486. "WHERE run.engine_type = 'kestra' "
  487. "AND run.engine_execution_id = :execution_id"
  488. ),
  489. {"execution_id": execution_id},
  490. )
  491. .mappings()
  492. .one_or_none()
  493. )
  494. if row is None:
  495. raise KeyError(execution_id)
  496. return dict(row)
  497. def record_retry(self, execution_id, result):
  498. execution_id = _required_string(execution_id, "execution_id", 255)
  499. replay_execution_id = _required_string(
  500. (result or {}).get("id"), "replay execution id", 255
  501. )
  502. with self.engine.begin() as connection:
  503. original = (
  504. connection.execute(
  505. text(
  506. "SELECT id::text AS run_id, "
  507. "workflow_version_id::text AS workflow_version_id, "
  508. "schedule_id::text AS schedule_id, "
  509. "correlation_id::text AS correlation_id, attempt "
  510. "FROM public.workflow_runs "
  511. "WHERE engine_type = 'kestra' "
  512. "AND engine_execution_id = :execution_id "
  513. "FOR UPDATE"
  514. ),
  515. {"execution_id": execution_id},
  516. )
  517. .mappings()
  518. .one_or_none()
  519. )
  520. if original is None:
  521. raise KeyError(execution_id)
  522. next_attempt = connection.execute(
  523. text(
  524. "SELECT GREATEST(:original_attempt, "
  525. "COALESCE(MAX(attempt), 0)) + 1 "
  526. "FROM public.workflow_runs "
  527. "WHERE parent_run_id = CAST(:run_id AS uuid)"
  528. ),
  529. {
  530. "original_attempt": original["attempt"],
  531. "run_id": original["run_id"],
  532. },
  533. ).scalar_one()
  534. connection.execute(
  535. text(
  536. "INSERT INTO public.workflow_runs "
  537. "(id, workflow_version_id, schedule_id, engine_type, "
  538. "engine_execution_id, trigger_type, status, "
  539. "correlation_id, parent_run_id, attempt, run_metadata) "
  540. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  541. "CAST(:schedule_id AS uuid), 'kestra', :execution_id, "
  542. "'replay', 'queued', CAST(:correlation_id AS uuid), "
  543. "CAST(:parent_run_id AS uuid), :attempt, "
  544. "CAST(:metadata AS jsonb))"
  545. ),
  546. {
  547. "id": new_governance_uid(),
  548. "version_id": original["workflow_version_id"],
  549. "schedule_id": original["schedule_id"],
  550. "execution_id": replay_execution_id,
  551. "correlation_id": original["correlation_id"],
  552. "parent_run_id": original["run_id"],
  553. "attempt": int(next_attempt),
  554. "metadata": _json({"source_execution_id": execution_id}),
  555. },
  556. )
  557. return dict(result)
  558. @staticmethod
  559. def _parse_timestamp(value, label):
  560. value = _required_string(value, label, 100)
  561. try:
  562. parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
  563. except ValueError as exc:
  564. raise ValueError(f"{label} must be an ISO 8601 timestamp") from exc
  565. if parsed.tzinfo is None:
  566. raise ValueError(f"{label} must include a timezone")
  567. return parsed
  568. def estimate_backfill_runs(self, candidate_id, start, end):
  569. candidate = self.get_candidate(candidate_id)
  570. schedule_plan = dict(candidate.get("schedule_plan") or {})
  571. start_at = self._parse_timestamp(start, "backfill start")
  572. end_at = self._parse_timestamp(end, "backfill end")
  573. if end_at <= start_at:
  574. raise ValueError("backfill end must be after start")
  575. try:
  576. timezone = ZoneInfo(schedule_plan.get("timezone") or "UTC")
  577. except (KeyError, ValueError) as exc:
  578. raise ValueError("schedule timezone is invalid") from exc
  579. start_local = start_at.astimezone(timezone)
  580. end_local = end_at.astimezone(timezone)
  581. total = 0
  582. for trigger in schedule_plan.get("triggers") or []:
  583. if trigger.get("type") != "cron":
  584. continue
  585. expression = _required_string(
  586. trigger.get("expression"), "cron expression", 100
  587. )
  588. if len(expression.split()) != 5:
  589. raise ValueError("backfill estimation supports five-field cron only")
  590. iterator = croniter(expression, start_local)
  591. occurrence = iterator.get_next(datetime)
  592. while occurrence <= end_local:
  593. total += 1
  594. if total > 1000:
  595. return total
  596. occurrence = iterator.get_next(datetime)
  597. if total == 0:
  598. raise ValueError("candidate has no cron occurrences in window")
  599. return total
  600. def record_backfill(self, candidate_id, result):
  601. execution_id = _required_string(
  602. (result or {}).get("id"), "backfill execution id", 255
  603. )
  604. estimated_runs = int((result or {}).get("estimated_runs", 0))
  605. if estimated_runs < 1:
  606. raise ValueError("estimated_runs must be positive")
  607. with self.engine.begin() as connection:
  608. schedule_id = connection.execute(
  609. text(
  610. "SELECT id::text FROM public.workflow_schedules "
  611. "WHERE workflow_version_id = CAST(:id AS uuid) "
  612. "ORDER BY created_at DESC LIMIT 1"
  613. ),
  614. {"id": candidate_id},
  615. ).scalar_one_or_none()
  616. if schedule_id is None:
  617. raise KeyError(candidate_id)
  618. connection.execute(
  619. text(
  620. "INSERT INTO public.workflow_runs "
  621. "(id, workflow_version_id, schedule_id, engine_type, "
  622. "engine_execution_id, trigger_type, status, "
  623. "correlation_id, attempt, run_metadata) "
  624. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  625. "CAST(:schedule_id AS uuid), 'kestra', :execution_id, "
  626. "'backfill', 'queued', CAST(:correlation_id AS uuid), "
  627. "1, CAST(:metadata AS jsonb))"
  628. ),
  629. {
  630. "id": new_governance_uid(),
  631. "version_id": candidate_id,
  632. "schedule_id": schedule_id,
  633. "execution_id": execution_id,
  634. "correlation_id": new_governance_uid(),
  635. "metadata": _json({"estimated_runs": estimated_runs}),
  636. },
  637. )
  638. return dict(result)
  639. def _claim_operation(
  640. self,
  641. action,
  642. idempotency_key,
  643. candidate_id,
  644. *,
  645. actor_subject,
  646. correlation_id,
  647. result,
  648. ):
  649. operation_id = new_governance_uid()
  650. with self.engine.begin() as connection:
  651. inserted = connection.execute(
  652. text(
  653. "INSERT INTO public.workflow_gateway_operations "
  654. "(id, workflow_version_id, action, idempotency_key, status, "
  655. "result, actor_subject, correlation_id) "
  656. "VALUES (CAST(:id AS uuid), CAST(:version_id AS uuid), "
  657. ":action, :key, 'claimed', CAST(:result AS jsonb), "
  658. ":actor, CAST(:correlation_id AS uuid)) "
  659. "ON CONFLICT (action, idempotency_key) DO NOTHING "
  660. "RETURNING id::text"
  661. ),
  662. {
  663. "id": operation_id,
  664. "version_id": candidate_id,
  665. "action": action,
  666. "key": idempotency_key,
  667. "result": _json(result),
  668. "actor": actor_subject,
  669. "correlation_id": correlation_id,
  670. },
  671. ).scalar_one_or_none()
  672. if inserted is not None:
  673. return True, result
  674. previous = (
  675. connection.execute(
  676. text(
  677. "SELECT workflow_version_id::text AS version_id, "
  678. "result FROM public.workflow_gateway_operations "
  679. "WHERE action = :action AND idempotency_key = :key"
  680. ),
  681. {"action": action, "key": idempotency_key},
  682. )
  683. .mappings()
  684. .one()
  685. )
  686. if previous["version_id"] != candidate_id:
  687. raise ValueError("idempotency key belongs to another workflow version")
  688. return False, dict(previous["result"])
  689. def claim_promotion(
  690. self,
  691. idempotency_key,
  692. candidate_id,
  693. *,
  694. actor_subject,
  695. correlation_id,
  696. ):
  697. deployment = self.get_deployment(candidate_id)
  698. result = {
  699. **deployment,
  700. "candidate_id": candidate_id,
  701. "status": "promoted",
  702. }
  703. return self._claim_operation(
  704. "promote_candidate",
  705. idempotency_key,
  706. candidate_id,
  707. actor_subject=actor_subject,
  708. correlation_id=correlation_id,
  709. result=result,
  710. )
  711. def _complete_operation(
  712. self, connection, action, idempotency_key, candidate_id, result
  713. ):
  714. updated = connection.execute(
  715. text(
  716. "UPDATE public.workflow_gateway_operations "
  717. "SET status = 'succeeded', result = CAST(:result AS jsonb), "
  718. "safe_error = NULL, updated_at = CURRENT_TIMESTAMP "
  719. "WHERE action = :action AND idempotency_key = :key "
  720. "AND workflow_version_id = CAST(:version_id AS uuid) "
  721. "AND status = 'claimed'"
  722. ),
  723. {
  724. "action": action,
  725. "key": idempotency_key,
  726. "version_id": candidate_id,
  727. "result": _json(result),
  728. },
  729. )
  730. if updated.rowcount != 1:
  731. raise ValueError("gateway operation is not in claimed state")
  732. def complete_promotion(self, idempotency_key, candidate_id, result):
  733. with self.engine.begin() as connection:
  734. row = connection.execute(
  735. text(
  736. "SELECT dataflow_uid::text, environment "
  737. "FROM public.dataflow_workflow_versions "
  738. "WHERE id = CAST(:id AS uuid) FOR UPDATE"
  739. ),
  740. {"id": candidate_id},
  741. ).one_or_none()
  742. if row is None:
  743. raise KeyError(candidate_id)
  744. dataflow_uid, environment = row
  745. connection.execute(
  746. text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
  747. {"key": f"{dataflow_uid}:{environment}"},
  748. )
  749. connection.execute(
  750. text(
  751. "UPDATE public.workflow_engine_bindings "
  752. "SET role = 'archived', status = 'disabled', "
  753. "updated_at = CURRENT_TIMESTAMP "
  754. "WHERE dataflow_uid = CAST(:dataflow_uid AS uuid) "
  755. "AND environment = :environment "
  756. "AND role = 'primary' AND status = 'enabled' "
  757. "AND workflow_version_id <> CAST(:id AS uuid)"
  758. ),
  759. {
  760. "dataflow_uid": dataflow_uid,
  761. "environment": environment,
  762. "id": candidate_id,
  763. },
  764. )
  765. connection.execute(
  766. text(
  767. "UPDATE public.dataflow_workflow_versions "
  768. "SET status = 'superseded', updated_at = CURRENT_TIMESTAMP "
  769. "WHERE dataflow_uid = CAST(:dataflow_uid AS uuid) "
  770. "AND environment = :environment AND status = 'active' "
  771. "AND id <> CAST(:id AS uuid)"
  772. ),
  773. {
  774. "dataflow_uid": dataflow_uid,
  775. "environment": environment,
  776. "id": candidate_id,
  777. },
  778. )
  779. connection.execute(
  780. text(
  781. "UPDATE public.workflow_schedules SET status = 'retired', "
  782. "updated_at = CURRENT_TIMESTAMP "
  783. "WHERE workflow_version_id IN ("
  784. "SELECT id FROM public.dataflow_workflow_versions "
  785. "WHERE dataflow_uid = CAST(:dataflow_uid AS uuid) "
  786. "AND environment = :environment "
  787. "AND id <> CAST(:id AS uuid)) "
  788. "AND status = 'active'"
  789. ),
  790. {
  791. "dataflow_uid": dataflow_uid,
  792. "environment": environment,
  793. "id": candidate_id,
  794. },
  795. )
  796. connection.execute(
  797. text(
  798. "UPDATE public.dataflow_workflow_versions "
  799. "SET status = 'active', "
  800. "deployment_metadata = deployment_metadata || "
  801. '\'{"gateway_status":"promoted"}\'::jsonb, '
  802. "updated_at = CURRENT_TIMESTAMP "
  803. "WHERE id = CAST(:id AS uuid)"
  804. ),
  805. {"id": candidate_id},
  806. )
  807. connection.execute(
  808. text(
  809. "UPDATE public.workflow_engine_bindings "
  810. "SET role = 'primary', status = 'enabled', "
  811. "updated_at = CURRENT_TIMESTAMP "
  812. "WHERE workflow_version_id = CAST(:id AS uuid)"
  813. ),
  814. {"id": candidate_id},
  815. )
  816. connection.execute(
  817. text(
  818. "UPDATE public.workflow_schedules "
  819. "SET status = 'active', updated_at = CURRENT_TIMESTAMP "
  820. "WHERE workflow_version_id = CAST(:id AS uuid)"
  821. ),
  822. {"id": candidate_id},
  823. )
  824. self._complete_operation(
  825. connection,
  826. "promote_candidate",
  827. idempotency_key,
  828. candidate_id,
  829. result,
  830. )
  831. return result
  832. def release_promotion(self, idempotency_key, candidate_id):
  833. self._release_operation("promote_candidate", idempotency_key, candidate_id)
  834. def mark_paused(self, candidate_id):
  835. with self.engine.begin() as connection:
  836. connection.execute(
  837. text(
  838. "UPDATE public.workflow_engine_bindings "
  839. "SET status = 'disabled', updated_at = CURRENT_TIMESTAMP "
  840. "WHERE workflow_version_id = CAST(:id AS uuid)"
  841. ),
  842. {"id": candidate_id},
  843. )
  844. connection.execute(
  845. text(
  846. "UPDATE public.workflow_schedules "
  847. "SET status = 'paused', updated_at = CURRENT_TIMESTAMP "
  848. "WHERE workflow_version_id = CAST(:id AS uuid)"
  849. ),
  850. {"id": candidate_id},
  851. )
  852. updated = connection.execute(
  853. text(
  854. "UPDATE public.dataflow_workflow_versions "
  855. "SET deployment_metadata = deployment_metadata || "
  856. '\'{"gateway_status":"paused"}\'::jsonb, '
  857. "updated_at = CURRENT_TIMESTAMP "
  858. "WHERE id = CAST(:id AS uuid)"
  859. ),
  860. {"id": candidate_id},
  861. )
  862. if updated.rowcount != 1:
  863. raise KeyError(candidate_id)
  864. def get_previous_deployment(self, candidate_id):
  865. with self.engine.connect() as connection:
  866. row = (
  867. connection.execute(
  868. text(
  869. "SELECT previous.id::text AS candidate_id, "
  870. "previous.deployment_metadata "
  871. "FROM public.dataflow_workflow_versions current "
  872. "JOIN LATERAL ("
  873. "SELECT id, deployment_metadata "
  874. "FROM public.dataflow_workflow_versions candidate "
  875. "WHERE candidate.dataflow_uid = current.dataflow_uid "
  876. "AND candidate.environment = current.environment "
  877. "AND candidate.engine_type = 'kestra' "
  878. "AND candidate.version_no < current.version_no "
  879. "ORDER BY candidate.version_no DESC LIMIT 1"
  880. ") previous ON TRUE "
  881. "WHERE current.id = CAST(:id AS uuid)"
  882. ),
  883. {"id": candidate_id},
  884. )
  885. .mappings()
  886. .one_or_none()
  887. )
  888. if row is None:
  889. raise ValueError("previous deployment is not available")
  890. deployment = dict(row["deployment_metadata"] or {}).get("deployment")
  891. if not isinstance(deployment, dict):
  892. raise ValueError("previous deployment is incomplete")
  893. return {**deployment, "candidate_id": row["candidate_id"]}
  894. def claim_rollback(
  895. self,
  896. idempotency_key,
  897. candidate_id,
  898. *,
  899. actor_subject,
  900. correlation_id,
  901. ):
  902. previous = self.get_previous_deployment(candidate_id)
  903. result = {
  904. "candidate_id": candidate_id,
  905. "status": "rolled_back",
  906. "active_candidate_id": previous["candidate_id"],
  907. }
  908. return self._claim_operation(
  909. "rollback_to_previous_version",
  910. idempotency_key,
  911. candidate_id,
  912. actor_subject=actor_subject,
  913. correlation_id=correlation_id,
  914. result=result,
  915. )
  916. def complete_rollback(self, idempotency_key, candidate_id, result):
  917. previous = self.get_previous_deployment(candidate_id)
  918. previous_id = previous["candidate_id"]
  919. with self.engine.begin() as connection:
  920. connection.execute(
  921. text(
  922. "UPDATE public.dataflow_workflow_versions "
  923. "SET status = 'superseded', "
  924. "deployment_metadata = deployment_metadata || "
  925. '\'{"gateway_status":"rolled_back"}\'::jsonb, '
  926. "updated_at = CURRENT_TIMESTAMP "
  927. "WHERE id = CAST(:id AS uuid)"
  928. ),
  929. {"id": candidate_id},
  930. )
  931. connection.execute(
  932. text(
  933. "UPDATE public.workflow_engine_bindings "
  934. "SET role = 'archived', status = 'disabled', "
  935. "updated_at = CURRENT_TIMESTAMP "
  936. "WHERE workflow_version_id = CAST(:id AS uuid)"
  937. ),
  938. {"id": candidate_id},
  939. )
  940. connection.execute(
  941. text(
  942. "UPDATE public.workflow_schedules "
  943. "SET status = 'retired', updated_at = CURRENT_TIMESTAMP "
  944. "WHERE workflow_version_id = CAST(:id AS uuid)"
  945. ),
  946. {"id": candidate_id},
  947. )
  948. connection.execute(
  949. text(
  950. "UPDATE public.dataflow_workflow_versions "
  951. "SET status = 'active', "
  952. "deployment_metadata = deployment_metadata || "
  953. '\'{"gateway_status":"promoted"}\'::jsonb, '
  954. "updated_at = CURRENT_TIMESTAMP "
  955. "WHERE id = CAST(:id AS uuid)"
  956. ),
  957. {"id": previous_id},
  958. )
  959. connection.execute(
  960. text(
  961. "UPDATE public.workflow_engine_bindings "
  962. "SET role = 'primary', status = 'enabled', "
  963. "updated_at = CURRENT_TIMESTAMP "
  964. "WHERE workflow_version_id = CAST(:id AS uuid)"
  965. ),
  966. {"id": previous_id},
  967. )
  968. connection.execute(
  969. text(
  970. "UPDATE public.workflow_schedules "
  971. "SET status = 'active', updated_at = CURRENT_TIMESTAMP "
  972. "WHERE workflow_version_id = CAST(:id AS uuid)"
  973. ),
  974. {"id": previous_id},
  975. )
  976. self._complete_operation(
  977. connection,
  978. "rollback_to_previous_version",
  979. idempotency_key,
  980. candidate_id,
  981. result,
  982. )
  983. return result
  984. def release_rollback(self, idempotency_key, candidate_id):
  985. self._release_operation(
  986. "rollback_to_previous_version", idempotency_key, candidate_id
  987. )
  988. def _release_operation(self, action, idempotency_key, candidate_id):
  989. with self.engine.begin() as connection:
  990. connection.execute(
  991. text(
  992. "DELETE FROM public.workflow_gateway_operations "
  993. "WHERE action = :action AND idempotency_key = :key "
  994. "AND workflow_version_id = CAST(:version_id AS uuid) "
  995. "AND status = 'claimed'"
  996. ),
  997. {
  998. "action": action,
  999. "key": idempotency_key,
  1000. "version_id": candidate_id,
  1001. },
  1002. )
  1003. class PostgresContextRepository:
  1004. """Secret-free PostgreSQL read model for the Context MCP server."""
  1005. def __init__(self, engine, *, max_concurrency=10, clock=None):
  1006. self.engine = engine
  1007. self.max_concurrency = int(max_concurrency)
  1008. self.clock = clock or (lambda: datetime.now(UTC))
  1009. if self.max_concurrency < 1 or self.max_concurrency > 1000:
  1010. raise ValueError("context max concurrency is invalid")
  1011. @staticmethod
  1012. def _dataflow_row(row):
  1013. if row is None:
  1014. return None
  1015. value = dict(row)
  1016. spec = dict(value.pop("workflow_spec") or {})
  1017. return {
  1018. "uid": value["uid"],
  1019. "name": spec.get("name"),
  1020. "description": spec.get("description"),
  1021. "business_domain": value.get("business_domain"),
  1022. "status": value.get("status"),
  1023. }
  1024. def list_dataflows(self):
  1025. with self.engine.connect() as connection:
  1026. rows = (
  1027. connection.execute(
  1028. text(
  1029. "SELECT DISTINCT ON (dataflow_uid) "
  1030. "dataflow_uid::text AS uid, business_domain, status, "
  1031. "workflow_spec "
  1032. "FROM public.dataflow_workflow_versions "
  1033. "WHERE engine_type = 'kestra' "
  1034. "ORDER BY dataflow_uid, version_no DESC"
  1035. )
  1036. )
  1037. .mappings()
  1038. .all()
  1039. )
  1040. return [self._dataflow_row(row) for row in rows]
  1041. def describe_dataflow(self, dataflow_uid):
  1042. with self.engine.connect() as connection:
  1043. row = (
  1044. connection.execute(
  1045. text(
  1046. "SELECT dataflow_uid::text AS uid, business_domain, "
  1047. "status, workflow_spec "
  1048. "FROM public.dataflow_workflow_versions "
  1049. "WHERE dataflow_uid = CAST(:uid AS uuid) "
  1050. "AND engine_type = 'kestra' "
  1051. "ORDER BY version_no DESC LIMIT 1"
  1052. ),
  1053. {"uid": dataflow_uid},
  1054. )
  1055. .mappings()
  1056. .one_or_none()
  1057. )
  1058. if row is None:
  1059. raise KeyError(dataflow_uid)
  1060. return self._dataflow_row(row)
  1061. def _latest_spec(self, dataflow_uid):
  1062. with self.engine.connect() as connection:
  1063. row = (
  1064. connection.execute(
  1065. text(
  1066. "SELECT workflow_spec, business_domain "
  1067. "FROM public.dataflow_workflow_versions "
  1068. "WHERE dataflow_uid = CAST(:uid AS uuid) "
  1069. "AND engine_type = 'kestra' "
  1070. "ORDER BY version_no DESC LIMIT 1"
  1071. ),
  1072. {"uid": dataflow_uid},
  1073. )
  1074. .mappings()
  1075. .one_or_none()
  1076. )
  1077. if row is None:
  1078. raise KeyError(dataflow_uid)
  1079. return dict(row["workflow_spec"] or {}), row["business_domain"]
  1080. def get_dataflow_dependencies(self, dataflow_uid):
  1081. spec, domain = self._latest_spec(dataflow_uid)
  1082. dependencies = []
  1083. seen = set()
  1084. for node in spec.get("nodes") or []:
  1085. if node.get("type") != "subflow":
  1086. continue
  1087. dependency_uid = (node.get("config") or {}).get("dataflow_uid")
  1088. if not dependency_uid or dependency_uid in seen:
  1089. continue
  1090. seen.add(dependency_uid)
  1091. try:
  1092. dependency = self.describe_dataflow(dependency_uid)
  1093. except (KeyError, ValueError):
  1094. continue
  1095. dependencies.append(
  1096. {
  1097. "uid": dependency["uid"],
  1098. "name": dependency["name"],
  1099. "relation": "subflow",
  1100. "business_domain": domain,
  1101. }
  1102. )
  1103. return dependencies
  1104. def get_data_lineage(self, dataflow_uid, depth):
  1105. del depth
  1106. spec, _ = self._latest_spec(dataflow_uid)
  1107. nodes = {node.get("id"): node for node in spec.get("nodes") or []}
  1108. return [
  1109. {
  1110. "from_uid": (
  1111. nodes.get(edge.get("from"), {}).get("data_source_uid")
  1112. or f"{dataflow_uid}:{edge.get('from')}"
  1113. ),
  1114. "to_uid": (
  1115. nodes.get(edge.get("to"), {}).get("data_source_uid")
  1116. or f"{dataflow_uid}:{edge.get('to')}"
  1117. ),
  1118. "relation": "workflow_edge",
  1119. "description": edge.get("condition"),
  1120. }
  1121. for edge in spec.get("edges") or []
  1122. ]
  1123. def list_datasource_capabilities(self, business_domain):
  1124. with self.engine.connect() as connection:
  1125. rows = (
  1126. connection.execute(
  1127. text(
  1128. "WITH latest AS ("
  1129. "SELECT DISTINCT ON (dataflow_uid) workflow_spec "
  1130. "FROM public.dataflow_workflow_versions "
  1131. "WHERE engine_type = 'kestra' "
  1132. "AND business_domain = :domain "
  1133. "ORDER BY dataflow_uid, version_no DESC"
  1134. "), nodes AS ("
  1135. "SELECT jsonb_array_elements("
  1136. "COALESCE(workflow_spec->'nodes', '[]'::jsonb)) AS node "
  1137. "FROM latest"
  1138. "), sources AS ("
  1139. "SELECT node->>'data_source_uid' AS uid, "
  1140. "array_agg(DISTINCT COALESCE("
  1141. "node->>'purpose', 'read')) AS purposes "
  1142. "FROM nodes WHERE node ? 'data_source_uid' "
  1143. "GROUP BY node->>'data_source_uid'"
  1144. ") SELECT sources.uid, sources.purposes, "
  1145. "credentials.status AS credential_status "
  1146. "FROM sources LEFT JOIN "
  1147. "public.datasource_credentials credentials "
  1148. "ON credentials.data_source_uid = "
  1149. "CAST(sources.uid AS uuid) "
  1150. "AND credentials.status = 'active'"
  1151. ),
  1152. {"domain": business_domain},
  1153. )
  1154. .mappings()
  1155. .all()
  1156. )
  1157. return [
  1158. {
  1159. "uid": row["uid"],
  1160. "type": "external",
  1161. "purposes": list(row["purposes"] or []),
  1162. "health": (
  1163. "configured"
  1164. if row["credential_status"] == "active"
  1165. else "credential_unavailable"
  1166. ),
  1167. "capacity": {"available": row["credential_status"] == "active"},
  1168. }
  1169. for row in rows
  1170. ]
  1171. def get_datasource_pool_health(self, data_source_uid):
  1172. with self.engine.connect() as connection:
  1173. configured = bool(
  1174. connection.execute(
  1175. text(
  1176. "SELECT EXISTS("
  1177. "SELECT 1 FROM public.datasource_credentials "
  1178. "WHERE data_source_uid = CAST(:uid AS uuid) "
  1179. "AND status = 'active')"
  1180. ),
  1181. {"uid": data_source_uid},
  1182. ).scalar_one()
  1183. )
  1184. return {
  1185. "uid": data_source_uid,
  1186. "health": "configured" if configured else "credential_unavailable",
  1187. "capacity": {"available": configured},
  1188. }
  1189. def get_execution_history(self, dataflow_uid, limit, days):
  1190. with self.engine.connect() as connection:
  1191. rows = (
  1192. connection.execute(
  1193. text(
  1194. "SELECT run.status, run.correlation_id::text "
  1195. "AS correlation_id, run.started_at, run.finished_at, "
  1196. "run.run_metadata->>'safe_error' AS safe_error "
  1197. "FROM public.workflow_runs run "
  1198. "JOIN public.dataflow_workflow_versions version "
  1199. "ON version.id = run.workflow_version_id "
  1200. "WHERE version.dataflow_uid = CAST(:uid AS uuid) "
  1201. "AND run.created_at >= "
  1202. "CURRENT_TIMESTAMP - make_interval(days => :days) "
  1203. "ORDER BY run.created_at DESC LIMIT :limit"
  1204. ),
  1205. {
  1206. "uid": dataflow_uid,
  1207. "days": int(days),
  1208. "limit": int(limit),
  1209. },
  1210. )
  1211. .mappings()
  1212. .all()
  1213. )
  1214. return [
  1215. {
  1216. **dict(row),
  1217. "started_at": (
  1218. row["started_at"].isoformat()
  1219. if row["started_at"] is not None
  1220. else None
  1221. ),
  1222. "finished_at": (
  1223. row["finished_at"].isoformat()
  1224. if row["finished_at"] is not None
  1225. else None
  1226. ),
  1227. }
  1228. for row in rows
  1229. ]
  1230. def get_sla_constraints(self, dataflow_uid):
  1231. with self.engine.connect() as connection:
  1232. plan = connection.execute(
  1233. text(
  1234. "SELECT schedule_plan "
  1235. "FROM public.dataflow_workflow_versions "
  1236. "WHERE dataflow_uid = CAST(:uid AS uuid) "
  1237. "AND engine_type = 'kestra' "
  1238. "ORDER BY version_no DESC LIMIT 1"
  1239. ),
  1240. {"uid": dataflow_uid},
  1241. ).scalar_one_or_none()
  1242. if plan is None:
  1243. raise KeyError(dataflow_uid)
  1244. plan = dict(plan)
  1245. return {
  1246. "timezone": plan.get("timezone"),
  1247. "max_duration_seconds": plan.get("timeout_seconds"),
  1248. }
  1249. def estimate_schedule_capacity(self, business_domain, schedule_plan):
  1250. with self.engine.connect() as connection:
  1251. running = int(
  1252. connection.execute(
  1253. text(
  1254. "SELECT COUNT(*) FROM public.workflow_runs run "
  1255. "JOIN public.dataflow_workflow_versions version "
  1256. "ON version.id = run.workflow_version_id "
  1257. "WHERE version.business_domain = :domain "
  1258. "AND run.status IN ('queued', 'running')"
  1259. ),
  1260. {"domain": business_domain},
  1261. ).scalar_one()
  1262. )
  1263. available = max(0, self.max_concurrency - running)
  1264. required = int(schedule_plan.get("max_concurrency", 1))
  1265. return {
  1266. "feasible": required <= available,
  1267. "required_slots": required,
  1268. "available_slots": available,
  1269. "warnings": (
  1270. []
  1271. if required <= available
  1272. else ["requested concurrency exceeds current capacity"]
  1273. ),
  1274. }
  1275. def simulate_schedule(self, business_domain, schedule_plan):
  1276. del business_domain
  1277. now = self.clock()
  1278. timezone = ZoneInfo(schedule_plan.get("timezone") or "UTC")
  1279. local_now = now.astimezone(timezone)
  1280. next_runs = []
  1281. warnings = []
  1282. for trigger in schedule_plan.get("triggers") or []:
  1283. kind = trigger.get("type")
  1284. if kind == "cron":
  1285. expression = trigger.get("expression", "")
  1286. if len(str(expression).split()) != 5:
  1287. warnings.append("simulation supports five-field cron only")
  1288. continue
  1289. iterator = croniter(expression, local_now)
  1290. next_runs.extend(
  1291. iterator.get_next(datetime).isoformat() for _ in range(5)
  1292. )
  1293. elif kind == "at":
  1294. next_runs.append(str(trigger.get("at")))
  1295. elif kind in {"manual", "event"}:
  1296. warnings.append(f"{kind} trigger has no deterministic next run")
  1297. return {
  1298. "next_runs": sorted(next_runs)[:20],
  1299. "warnings": warnings,
  1300. }
  1301. def compare_execution_results(self, baseline_execution_id, candidate_execution_id):
  1302. with self.engine.connect() as connection:
  1303. rows = (
  1304. connection.execute(
  1305. text(
  1306. "SELECT engine_execution_id, status, started_at, "
  1307. "finished_at, run_metadata "
  1308. "FROM public.workflow_runs "
  1309. "WHERE engine_type = 'kestra' "
  1310. "AND engine_execution_id IN (:baseline, :candidate)"
  1311. ),
  1312. {
  1313. "baseline": baseline_execution_id,
  1314. "candidate": candidate_execution_id,
  1315. },
  1316. )
  1317. .mappings()
  1318. .all()
  1319. )
  1320. by_id = {row["engine_execution_id"]: dict(row) for row in rows}
  1321. if baseline_execution_id not in by_id or candidate_execution_id not in by_id:
  1322. raise KeyError("execution comparison input is unavailable")
  1323. baseline = by_id[baseline_execution_id]
  1324. candidate = by_id[candidate_execution_id]
  1325. def duration(row):
  1326. if row["started_at"] is None or row["finished_at"] is None:
  1327. return None
  1328. return (row["finished_at"] - row["started_at"]).total_seconds()
  1329. baseline_duration = duration(baseline)
  1330. candidate_duration = duration(candidate)
  1331. baseline_metadata = dict(baseline["run_metadata"] or {})
  1332. candidate_metadata = dict(candidate["run_metadata"] or {})
  1333. baseline_hash = baseline_metadata.get("output_hash")
  1334. candidate_hash = candidate_metadata.get("output_hash")
  1335. hash_match = (
  1336. baseline_hash == candidate_hash
  1337. if baseline_hash is not None and candidate_hash is not None
  1338. else None
  1339. )
  1340. duration_delta = (
  1341. candidate_duration - baseline_duration
  1342. if baseline_duration is not None and candidate_duration is not None
  1343. else None
  1344. )
  1345. equivalent = (
  1346. baseline["status"] == "success"
  1347. and candidate["status"] == "success"
  1348. and hash_match is not False
  1349. )
  1350. return {
  1351. "equivalent": equivalent,
  1352. "metrics": {
  1353. "duration_delta_seconds": duration_delta,
  1354. "output_hash_match": hash_match,
  1355. },
  1356. "differences": (
  1357. [] if equivalent else ["execution status or output hash differs"]
  1358. ),
  1359. }
  1360. class PostgresAuditSink:
  1361. def __init__(self, engine):
  1362. self.engine = engine
  1363. def record(self, event):
  1364. detail = dict(event.get("detail") or {})
  1365. candidate_id = detail.get("candidate_id")
  1366. try:
  1367. workflow_version_id = str(uuid.UUID(candidate_id)) if candidate_id else None
  1368. correlation_id = str(uuid.UUID(event["correlation_id"]))
  1369. except (TypeError, ValueError) as exc:
  1370. raise ValueError("audit identifiers must be UUIDs") from exc
  1371. decision = event.get("decision")
  1372. if decision not in {
  1373. "allowed",
  1374. "rejected",
  1375. "failed",
  1376. "recorded",
  1377. "idempotent_replay",
  1378. }:
  1379. raise ValueError("unsupported audit decision")
  1380. bounded_detail = {
  1381. **detail,
  1382. "roles": list(event.get("roles") or [])[:20],
  1383. "business_domain": event.get("business_domain"),
  1384. "environment": event.get("environment"),
  1385. }
  1386. encoded_detail = _json(bounded_detail)
  1387. if len(encoded_detail.encode("utf-8")) > 1048576:
  1388. raise ValueError("audit detail exceeds size limit")
  1389. def optional_string(name, maximum):
  1390. value = event.get(name)
  1391. return (
  1392. _required_string(value, f"audit {name}", maximum)
  1393. if value is not None
  1394. else None
  1395. )
  1396. def optional_hash(value, label):
  1397. if value is None:
  1398. return None
  1399. normalized = _required_string(value, label, 64).lower()
  1400. if len(normalized) != 64 or any(
  1401. character not in "0123456789abcdef" for character in normalized
  1402. ):
  1403. raise ValueError(f"{label} must be a SHA-256 hex digest")
  1404. return normalized
  1405. schema_version = _required_string(
  1406. event.get("schema_version", "v53"),
  1407. "audit schema_version",
  1408. 40,
  1409. )
  1410. context_hash = optional_hash(
  1411. event.get("context_hash"),
  1412. "audit context_hash",
  1413. )
  1414. candidate_hash = optional_hash(
  1415. detail.get("workflow_hash"),
  1416. "audit candidate_hash",
  1417. )
  1418. with self.engine.begin() as connection:
  1419. connection.execute(
  1420. text(
  1421. "INSERT INTO public.workflow_plan_audits "
  1422. "(id, workflow_version_id, actor_uid, actor_subject, action, "
  1423. "model_provider, model_name, prompt_version, schema_version, "
  1424. "context_hash, candidate_hash, decision, decision_detail, "
  1425. "correlation_id) "
  1426. "VALUES (CAST(:id AS uuid), "
  1427. "CAST(:workflow_version_id AS uuid), NULL, :actor_subject, "
  1428. ":action, :model_provider, :model_name, :prompt_version, "
  1429. ":schema_version, :context_hash, :candidate_hash, :decision, "
  1430. "CAST(:detail AS jsonb), CAST(:correlation_id AS uuid))"
  1431. ),
  1432. {
  1433. "id": new_governance_uid(),
  1434. "workflow_version_id": workflow_version_id,
  1435. "actor_subject": _required_string(
  1436. event.get("subject"), "audit subject", 200
  1437. ),
  1438. "action": _required_string(event.get("action"), "audit action", 80),
  1439. "model_provider": optional_string("model_provider", 80),
  1440. "model_name": optional_string("model_name", 120),
  1441. "prompt_version": optional_string("prompt_version", 80),
  1442. "schema_version": schema_version,
  1443. "context_hash": context_hash,
  1444. "candidate_hash": candidate_hash,
  1445. "decision": decision,
  1446. "detail": encoded_detail,
  1447. "correlation_id": correlation_id,
  1448. },
  1449. )