ledger.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. """Single-use task-token ledger interfaces and an in-memory test implementation."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import threading
  6. from collections.abc import Mapping
  7. from dataclasses import dataclass
  8. from sqlalchemy import text
  9. @dataclass
  10. class TaskLedgerRecord:
  11. jti: str
  12. binding: Mapping[str, object]
  13. expires_at: int
  14. status: str = "running"
  15. commit_outcome: str = "not_applicable"
  16. safe_detail: str = ""
  17. replay_http_status: int | None = None
  18. replay_body: Mapping[str, object] | None = None
  19. replay_digest: str | None = None
  20. class InMemoryTaskLedger:
  21. def __init__(self):
  22. self._records = {}
  23. self._lock = threading.Lock()
  24. def claim(self, jti, binding, *, expires_at):
  25. with self._lock:
  26. if jti in self._records:
  27. return False
  28. self._records[jti] = TaskLedgerRecord(
  29. jti=str(jti),
  30. binding=dict(binding),
  31. expires_at=int(expires_at),
  32. )
  33. return True
  34. def finish(
  35. self,
  36. jti,
  37. *,
  38. status,
  39. commit_outcome="not_applicable",
  40. safe_detail="",
  41. replay_http_status=None,
  42. replay_body=None,
  43. ):
  44. with self._lock:
  45. record = self._records[str(jti)]
  46. record.status = str(status)
  47. record.commit_outcome = str(commit_outcome)
  48. record.safe_detail = str(safe_detail)[:500]
  49. if replay_body is not None:
  50. encoded = json.dumps(
  51. replay_body,
  52. sort_keys=True,
  53. separators=(",", ":"),
  54. ensure_ascii=False,
  55. ).encode("utf-8")
  56. record.replay_http_status = int(replay_http_status)
  57. record.replay_body = dict(replay_body)
  58. record.replay_digest = hashlib.sha256(encoded).hexdigest()
  59. def get(self, jti) -> TaskLedgerRecord | None:
  60. with self._lock:
  61. return self._records.get(str(jti))
  62. class PostgresTaskLedger:
  63. """Durable, cross-worker single-use ledger backed by the platform DB."""
  64. def __init__(self, engine):
  65. self.engine = engine
  66. def claim(self, jti, binding, *, expires_at):
  67. parameters = {
  68. "jti": str(jti),
  69. "task_uid": binding["task_uid"],
  70. "dataflow_uid": binding["dataflow_uid"],
  71. "deployment_id": binding["deployment_id"],
  72. "environment": binding["environment"],
  73. "workflow_version": int(binding["workflow_version"]),
  74. "correlation_id": binding["correlation_id"],
  75. "node_id": binding["node_id"],
  76. "node_type": binding["node_type"],
  77. "data_source_uid": binding.get("data_source_uid"),
  78. "idempotency_key": binding.get("idempotency_key"),
  79. "expires_at": int(expires_at),
  80. }
  81. with self.engine.begin() as connection:
  82. result = connection.execute(
  83. text(
  84. """
  85. INSERT INTO public.runner_task_executions (
  86. token_jti, task_uid, dataflow_uid, workflow_version,
  87. correlation_id, node_id, node_type, data_source_uid,
  88. idempotency_key, status, commit_outcome, expires_at,
  89. deployment_id, environment
  90. ) VALUES (
  91. CAST(:jti AS uuid), CAST(:task_uid AS uuid),
  92. CAST(:dataflow_uid AS uuid), :workflow_version,
  93. CAST(:correlation_id AS uuid), :node_id, :node_type,
  94. CAST(:data_source_uid AS uuid), :idempotency_key,
  95. 'running', 'not_applicable',
  96. to_timestamp(:expires_at),
  97. CAST(:deployment_id AS uuid), :environment
  98. )
  99. ON CONFLICT DO NOTHING
  100. """
  101. ),
  102. parameters,
  103. )
  104. return int(result.rowcount or 0) == 1
  105. def finish(
  106. self,
  107. jti,
  108. *,
  109. status,
  110. commit_outcome="not_applicable",
  111. safe_detail="",
  112. replay_http_status=None,
  113. replay_body=None,
  114. ):
  115. allowed_statuses = {"success", "failed", "unknown"}
  116. allowed_outcomes = {
  117. "not_applicable",
  118. "not_committed",
  119. "committed",
  120. "unknown",
  121. }
  122. if status not in allowed_statuses or commit_outcome not in allowed_outcomes:
  123. raise ValueError("runner task outcome is invalid")
  124. replay_digest = None
  125. if replay_body is not None:
  126. encoded = json.dumps(
  127. replay_body,
  128. sort_keys=True,
  129. separators=(",", ":"),
  130. ensure_ascii=False,
  131. ).encode("utf-8")
  132. if len(encoded) > 32_768:
  133. raise ValueError("runner replay body exceeds the safe limit")
  134. if replay_http_status != 200:
  135. raise ValueError("runner replay status is invalid")
  136. replay_digest = hashlib.sha256(encoded).hexdigest()
  137. with self.engine.begin() as connection:
  138. connection.execute(
  139. text(
  140. """
  141. UPDATE public.runner_task_executions
  142. SET status = :status,
  143. commit_outcome = :commit_outcome,
  144. safe_detail = :safe_detail,
  145. replay_http_status = :replay_http_status,
  146. replay_body = CAST(:replay_body AS jsonb),
  147. replay_digest = :replay_digest,
  148. finished_at = CURRENT_TIMESTAMP
  149. WHERE token_jti = CAST(:jti AS uuid)
  150. AND status = 'running'
  151. """
  152. ),
  153. {
  154. "jti": str(jti),
  155. "status": status,
  156. "commit_outcome": commit_outcome,
  157. "safe_detail": str(safe_detail)[:500],
  158. "replay_http_status": replay_http_status,
  159. "replay_body": (
  160. json.dumps(replay_body)
  161. if replay_body is not None
  162. else None
  163. ),
  164. "replay_digest": replay_digest,
  165. },
  166. )
  167. def get(self, jti):
  168. with self.engine.begin() as connection:
  169. row = (
  170. connection.execute(
  171. text(
  172. """
  173. SELECT token_jti::text AS jti, task_uid::text,
  174. dataflow_uid::text, deployment_id::text,
  175. environment, workflow_version,
  176. correlation_id::text,
  177. node_id, data_source_uid::text,
  178. idempotency_key, status, commit_outcome,
  179. node_type,
  180. safe_detail, EXTRACT(EPOCH FROM expires_at)::bigint
  181. AS expires_at,
  182. replay_http_status, replay_body,
  183. replay_digest
  184. FROM public.runner_task_executions
  185. WHERE token_jti = CAST(:jti AS uuid)
  186. """
  187. ),
  188. {"jti": str(jti)},
  189. )
  190. .mappings()
  191. .one_or_none()
  192. )
  193. if row is None:
  194. return None
  195. return TaskLedgerRecord(
  196. jti=row["jti"],
  197. binding={
  198. "task_uid": row["task_uid"],
  199. "dataflow_uid": row["dataflow_uid"],
  200. "deployment_id": row["deployment_id"],
  201. "environment": row["environment"],
  202. "workflow_version": row["workflow_version"],
  203. "correlation_id": row["correlation_id"],
  204. "node_id": row["node_id"],
  205. "node_type": row["node_type"],
  206. "data_source_uid": row["data_source_uid"],
  207. "idempotency_key": row["idempotency_key"],
  208. },
  209. expires_at=int(row["expires_at"]),
  210. status=row["status"],
  211. commit_outcome=row["commit_outcome"],
  212. safe_detail=row["safe_detail"] or "",
  213. replay_http_status=row["replay_http_status"],
  214. replay_body=(
  215. dict(row["replay_body"])
  216. if isinstance(row["replay_body"], dict)
  217. else row["replay_body"]
  218. ),
  219. replay_digest=row["replay_digest"],
  220. )