ledger.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  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. import time
  7. from collections.abc import Mapping
  8. from dataclasses import dataclass
  9. from sqlalchemy import text
  10. @dataclass
  11. class TaskLedgerRecord:
  12. jti: str
  13. binding: Mapping[str, object]
  14. expires_at: int
  15. status: str = "running"
  16. commit_outcome: str = "not_applicable"
  17. safe_detail: str = ""
  18. replay_http_status: int | None = None
  19. replay_body: Mapping[str, object] | None = None
  20. replay_digest: str | None = None
  21. lease_expires_at: float = 0
  22. class InMemoryTaskLedger:
  23. def __init__(self, *, clock=None, lease_seconds=300):
  24. self._records = {}
  25. self._lock = threading.Lock()
  26. self._clock = clock or time.time
  27. self._lease_seconds = int(lease_seconds)
  28. def claim(self, jti, binding, *, expires_at):
  29. with self._lock:
  30. if jti in self._records:
  31. return False
  32. self._records[jti] = TaskLedgerRecord(
  33. jti=str(jti),
  34. binding=dict(binding),
  35. expires_at=int(expires_at),
  36. lease_expires_at=(
  37. float(self._clock()) + self._lease_seconds
  38. ),
  39. )
  40. return True
  41. def finish(
  42. self,
  43. jti,
  44. *,
  45. status,
  46. commit_outcome="not_applicable",
  47. safe_detail="",
  48. replay_http_status=None,
  49. replay_body=None,
  50. ):
  51. with self._lock:
  52. record = self._records[str(jti)]
  53. record.status = str(status)
  54. record.commit_outcome = str(commit_outcome)
  55. record.safe_detail = str(safe_detail)[:500]
  56. if replay_body is not None:
  57. encoded = json.dumps(
  58. replay_body,
  59. sort_keys=True,
  60. separators=(",", ":"),
  61. ensure_ascii=False,
  62. ).encode("utf-8")
  63. record.replay_http_status = int(replay_http_status)
  64. record.replay_body = dict(replay_body)
  65. record.replay_digest = hashlib.sha256(encoded).hexdigest()
  66. def get(self, jti) -> TaskLedgerRecord | None:
  67. with self._lock:
  68. return self._records.get(str(jti))
  69. def reconcile_running(self, jti, binding):
  70. with self._lock:
  71. record = self._records.get(str(jti))
  72. if (
  73. record is not None
  74. and record.status == "running"
  75. and record.binding == dict(binding)
  76. and record.lease_expires_at <= float(self._clock())
  77. ):
  78. record.status = "unknown"
  79. record.commit_outcome = "unknown"
  80. record.safe_detail = "task execution lease expired"
  81. return record
  82. class PostgresTaskLedger:
  83. """Durable, cross-worker single-use ledger backed by the platform DB."""
  84. def __init__(self, engine, *, lease_seconds=300):
  85. self.engine = engine
  86. self.lease_seconds = int(lease_seconds)
  87. if self.lease_seconds < 30 or self.lease_seconds > 900:
  88. raise ValueError("runner task lease is invalid")
  89. def claim(self, jti, binding, *, expires_at):
  90. parameters = {
  91. "jti": str(jti),
  92. "task_uid": binding["task_uid"],
  93. "dataflow_uid": binding["dataflow_uid"],
  94. "deployment_id": binding["deployment_id"],
  95. "environment": binding["environment"],
  96. "workflow_version": int(binding["workflow_version"]),
  97. "correlation_id": binding["correlation_id"],
  98. "node_id": binding["node_id"],
  99. "node_type": binding["node_type"],
  100. "data_source_uid": binding.get("data_source_uid"),
  101. "idempotency_key": binding.get("idempotency_key"),
  102. "expires_at": int(expires_at),
  103. "lease_seconds": self.lease_seconds,
  104. }
  105. with self.engine.begin() as connection:
  106. result = connection.execute(
  107. text(
  108. """
  109. INSERT INTO public.runner_task_executions (
  110. token_jti, task_uid, dataflow_uid, workflow_version,
  111. correlation_id, node_id, node_type, data_source_uid,
  112. idempotency_key, status, commit_outcome, expires_at,
  113. deployment_id, environment, lease_expires_at
  114. ) VALUES (
  115. CAST(:jti AS uuid), CAST(:task_uid AS uuid),
  116. CAST(:dataflow_uid AS uuid), :workflow_version,
  117. CAST(:correlation_id AS uuid), :node_id, :node_type,
  118. CAST(:data_source_uid AS uuid), :idempotency_key,
  119. 'running', 'not_applicable',
  120. to_timestamp(:expires_at),
  121. CAST(:deployment_id AS uuid), :environment,
  122. CURRENT_TIMESTAMP
  123. + make_interval(secs => :lease_seconds)
  124. )
  125. ON CONFLICT DO NOTHING
  126. """
  127. ),
  128. parameters,
  129. )
  130. return int(result.rowcount or 0) == 1
  131. def reconcile_running(self, jti, binding):
  132. parameters = {
  133. "jti": str(jti),
  134. "task_uid": binding["task_uid"],
  135. "dataflow_uid": binding["dataflow_uid"],
  136. "deployment_id": binding["deployment_id"],
  137. "environment": binding["environment"],
  138. "workflow_version": int(binding["workflow_version"]),
  139. "correlation_id": binding["correlation_id"],
  140. "node_id": binding["node_id"],
  141. "node_type": binding["node_type"],
  142. }
  143. with self.engine.begin() as connection:
  144. connection.execute(
  145. text(
  146. """
  147. UPDATE public.runner_task_executions
  148. SET status = 'unknown',
  149. commit_outcome = 'unknown',
  150. safe_detail = 'task execution lease expired',
  151. finished_at = CURRENT_TIMESTAMP
  152. WHERE token_jti = CAST(:jti AS uuid)
  153. AND task_uid = CAST(:task_uid AS uuid)
  154. AND dataflow_uid = CAST(:dataflow_uid AS uuid)
  155. AND deployment_id =
  156. CAST(:deployment_id AS uuid)
  157. AND environment = :environment
  158. AND workflow_version = :workflow_version
  159. AND correlation_id =
  160. CAST(:correlation_id AS uuid)
  161. AND node_id = :node_id
  162. AND node_type = :node_type
  163. AND status = 'running'
  164. AND lease_expires_at <= CURRENT_TIMESTAMP
  165. """
  166. ),
  167. parameters,
  168. )
  169. return self.get(jti)
  170. def finish(
  171. self,
  172. jti,
  173. *,
  174. status,
  175. commit_outcome="not_applicable",
  176. safe_detail="",
  177. replay_http_status=None,
  178. replay_body=None,
  179. ):
  180. allowed_statuses = {"success", "failed", "unknown"}
  181. allowed_outcomes = {
  182. "not_applicable",
  183. "not_committed",
  184. "committed",
  185. "unknown",
  186. }
  187. if status not in allowed_statuses or commit_outcome not in allowed_outcomes:
  188. raise ValueError("runner task outcome is invalid")
  189. replay_digest = None
  190. if replay_body is not None:
  191. encoded = json.dumps(
  192. replay_body,
  193. sort_keys=True,
  194. separators=(",", ":"),
  195. ensure_ascii=False,
  196. ).encode("utf-8")
  197. if len(encoded) > 32_768:
  198. raise ValueError("runner replay body exceeds the safe limit")
  199. if replay_http_status != 200:
  200. raise ValueError("runner replay status is invalid")
  201. replay_digest = hashlib.sha256(encoded).hexdigest()
  202. with self.engine.begin() as connection:
  203. connection.execute(
  204. text(
  205. """
  206. UPDATE public.runner_task_executions
  207. SET status = :status,
  208. commit_outcome = :commit_outcome,
  209. safe_detail = :safe_detail,
  210. replay_http_status = :replay_http_status,
  211. replay_body = CAST(:replay_body AS jsonb),
  212. replay_digest = :replay_digest,
  213. finished_at = CURRENT_TIMESTAMP
  214. WHERE token_jti = CAST(:jti AS uuid)
  215. AND status = 'running'
  216. """
  217. ),
  218. {
  219. "jti": str(jti),
  220. "status": status,
  221. "commit_outcome": commit_outcome,
  222. "safe_detail": str(safe_detail)[:500],
  223. "replay_http_status": replay_http_status,
  224. "replay_body": (
  225. json.dumps(replay_body)
  226. if replay_body is not None
  227. else None
  228. ),
  229. "replay_digest": replay_digest,
  230. },
  231. )
  232. def get(self, jti):
  233. with self.engine.begin() as connection:
  234. row = (
  235. connection.execute(
  236. text(
  237. """
  238. SELECT token_jti::text AS jti, task_uid::text,
  239. dataflow_uid::text, deployment_id::text,
  240. environment, workflow_version,
  241. correlation_id::text,
  242. node_id, data_source_uid::text,
  243. idempotency_key, status, commit_outcome,
  244. node_type,
  245. safe_detail, EXTRACT(EPOCH FROM expires_at)::bigint
  246. AS expires_at,
  247. EXTRACT(
  248. EPOCH FROM lease_expires_at
  249. )::double precision AS lease_expires_at,
  250. replay_http_status, replay_body,
  251. replay_digest
  252. FROM public.runner_task_executions
  253. WHERE token_jti = CAST(:jti AS uuid)
  254. """
  255. ),
  256. {"jti": str(jti)},
  257. )
  258. .mappings()
  259. .one_or_none()
  260. )
  261. if row is None:
  262. return None
  263. return TaskLedgerRecord(
  264. jti=row["jti"],
  265. binding={
  266. "task_uid": row["task_uid"],
  267. "dataflow_uid": row["dataflow_uid"],
  268. "deployment_id": row["deployment_id"],
  269. "environment": row["environment"],
  270. "workflow_version": row["workflow_version"],
  271. "correlation_id": row["correlation_id"],
  272. "node_id": row["node_id"],
  273. "node_type": row["node_type"],
  274. "data_source_uid": row["data_source_uid"],
  275. "idempotency_key": row["idempotency_key"],
  276. },
  277. expires_at=int(row["expires_at"]),
  278. status=row["status"],
  279. commit_outcome=row["commit_outcome"],
  280. safe_detail=row["safe_detail"] or "",
  281. replay_http_status=row["replay_http_status"],
  282. replay_body=(
  283. dict(row["replay_body"])
  284. if isinstance(row["replay_body"], dict)
  285. else row["replay_body"]
  286. ),
  287. replay_digest=row["replay_digest"],
  288. lease_expires_at=float(row["lease_expires_at"]),
  289. )