ledger.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """Single-use task-token ledger interfaces and an in-memory test implementation."""
  2. from __future__ import annotations
  3. import threading
  4. from dataclasses import dataclass
  5. from typing import Mapping, Optional
  6. from sqlalchemy import text
  7. @dataclass
  8. class TaskLedgerRecord:
  9. jti: str
  10. binding: Mapping[str, object]
  11. expires_at: int
  12. status: str = "running"
  13. commit_outcome: str = "not_applicable"
  14. safe_detail: str = ""
  15. class InMemoryTaskLedger:
  16. def __init__(self):
  17. self._records = {}
  18. self._lock = threading.Lock()
  19. def claim(self, jti, binding, *, expires_at):
  20. with self._lock:
  21. if jti in self._records:
  22. return False
  23. self._records[jti] = TaskLedgerRecord(
  24. jti=str(jti),
  25. binding=dict(binding),
  26. expires_at=int(expires_at),
  27. )
  28. return True
  29. def finish(
  30. self,
  31. jti,
  32. *,
  33. status,
  34. commit_outcome="not_applicable",
  35. safe_detail="",
  36. ):
  37. with self._lock:
  38. record = self._records[str(jti)]
  39. record.status = str(status)
  40. record.commit_outcome = str(commit_outcome)
  41. record.safe_detail = str(safe_detail)[:500]
  42. def get(self, jti) -> Optional[TaskLedgerRecord]:
  43. with self._lock:
  44. return self._records.get(str(jti))
  45. class PostgresTaskLedger:
  46. """Durable, cross-worker single-use ledger backed by the platform DB."""
  47. def __init__(self, engine):
  48. self.engine = engine
  49. def claim(self, jti, binding, *, expires_at):
  50. parameters = {
  51. "jti": str(jti),
  52. "task_uid": binding["task_uid"],
  53. "dataflow_uid": binding["dataflow_uid"],
  54. "workflow_version": int(binding["workflow_version"]),
  55. "correlation_id": binding["correlation_id"],
  56. "node_id": binding["node_id"],
  57. "node_type": binding["node_type"],
  58. "data_source_uid": binding.get("data_source_uid"),
  59. "idempotency_key": binding.get("idempotency_key"),
  60. "expires_at": int(expires_at),
  61. }
  62. with self.engine.begin() as connection:
  63. result = connection.execute(
  64. text(
  65. """
  66. INSERT INTO public.runner_task_executions (
  67. token_jti, task_uid, dataflow_uid, workflow_version,
  68. correlation_id, node_id, node_type, data_source_uid,
  69. idempotency_key, status, commit_outcome, expires_at
  70. ) VALUES (
  71. CAST(:jti AS uuid), CAST(:task_uid AS uuid),
  72. CAST(:dataflow_uid AS uuid), :workflow_version,
  73. CAST(:correlation_id AS uuid), :node_id, :node_type,
  74. CAST(:data_source_uid AS uuid), :idempotency_key,
  75. 'running', 'not_applicable',
  76. to_timestamp(:expires_at)
  77. )
  78. ON CONFLICT DO NOTHING
  79. """
  80. ),
  81. parameters,
  82. )
  83. return int(result.rowcount or 0) == 1
  84. def finish(
  85. self,
  86. jti,
  87. *,
  88. status,
  89. commit_outcome="not_applicable",
  90. safe_detail="",
  91. ):
  92. allowed_statuses = {"success", "failed", "unknown"}
  93. allowed_outcomes = {
  94. "not_applicable",
  95. "not_committed",
  96. "committed",
  97. "unknown",
  98. }
  99. if status not in allowed_statuses or commit_outcome not in allowed_outcomes:
  100. raise ValueError("runner task outcome is invalid")
  101. with self.engine.begin() as connection:
  102. connection.execute(
  103. text(
  104. """
  105. UPDATE public.runner_task_executions
  106. SET status = :status,
  107. commit_outcome = :commit_outcome,
  108. safe_detail = :safe_detail,
  109. finished_at = CURRENT_TIMESTAMP
  110. WHERE token_jti = CAST(:jti AS uuid)
  111. AND status = 'running'
  112. """
  113. ),
  114. {
  115. "jti": str(jti),
  116. "status": status,
  117. "commit_outcome": commit_outcome,
  118. "safe_detail": str(safe_detail)[:500],
  119. },
  120. )
  121. def get(self, jti):
  122. with self.engine.begin() as connection:
  123. row = (
  124. connection.execute(
  125. text(
  126. """
  127. SELECT token_jti::text AS jti, task_uid::text,
  128. node_id, data_source_uid::text,
  129. idempotency_key, status, commit_outcome,
  130. safe_detail, EXTRACT(EPOCH FROM expires_at)::bigint
  131. AS expires_at
  132. FROM public.runner_task_executions
  133. WHERE token_jti = CAST(:jti AS uuid)
  134. """
  135. ),
  136. {"jti": str(jti)},
  137. )
  138. .mappings()
  139. .one_or_none()
  140. )
  141. if row is None:
  142. return None
  143. return TaskLedgerRecord(
  144. jti=row["jti"],
  145. binding={
  146. "task_uid": row["task_uid"],
  147. "node_id": row["node_id"],
  148. "data_source_uid": row["data_source_uid"],
  149. "idempotency_key": row["idempotency_key"],
  150. },
  151. expires_at=int(row["expires_at"]),
  152. status=row["status"],
  153. commit_outcome=row["commit_outcome"],
  154. safe_detail=row["safe_detail"] or "",
  155. )