ingestion.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import re
  5. from dataclasses import replace
  6. from typing import Any, Callable
  7. from app.core.common.identifiers import new_governance_uid
  8. from app.core.common.timezone_utils import now_china_naive
  9. from app.core.data_research.errors import (
  10. IngestionJobNotFound,
  11. IngestionPayloadInvalid,
  12. InvalidJobTransition,
  13. )
  14. from app.core.data_research.models import IngestionJobRecord, IngestionJobSpec
  15. ALLOWED_TRANSITIONS = {
  16. "created": frozenset({"queued", "cancelled"}),
  17. "queued": frozenset({"extracting", "failed", "cancelled"}),
  18. "extracting": frozenset({"normalizing", "failed", "cancelled"}),
  19. "normalizing": frozenset({"matching", "failed", "cancelled"}),
  20. "matching": frozenset(
  21. {"awaiting_review", "partial", "failed", "cancelled"}
  22. ),
  23. "awaiting_review": frozenset({"published", "partial", "cancelled"}),
  24. "partial": frozenset({"queued", "cancelled"}),
  25. "failed": frozenset({"queued", "cancelled"}),
  26. "published": frozenset(),
  27. "cancelled": frozenset(),
  28. }
  29. TERMINAL_STATUSES = frozenset({"published", "cancelled"})
  30. _SECRET_ASSIGNMENT = re.compile(
  31. r"(?i)\b(password|token|secret|authorization)\s*[:=]\s*[^\s,;]+"
  32. )
  33. def _required_text(payload: dict[str, Any], name: str) -> str:
  34. value = str(payload.get(name) or "").strip()
  35. if not value:
  36. raise IngestionPayloadInvalid(f"{name} is required")
  37. return value
  38. def _sanitize_error(error: Any) -> str | None:
  39. if error is None:
  40. return None
  41. value = _SECRET_ASSIGNMENT.sub(lambda match: f"{match.group(1)}=[redacted]", str(error))
  42. return value[:1000]
  43. class IngestionService:
  44. def __init__(
  45. self,
  46. repository,
  47. *,
  48. uid_factory: Callable[[], str] = new_governance_uid,
  49. nonce_factory: Callable[[], str] = new_governance_uid,
  50. clock: Callable[[], Any] = now_china_naive,
  51. commit: Callable[[], Any] = lambda: None,
  52. rollback: Callable[[], Any] = lambda: None,
  53. ):
  54. self.repository = repository
  55. self.uid_factory = uid_factory
  56. self.nonce_factory = nonce_factory
  57. self.clock = clock
  58. self.commit = commit
  59. self.rollback = rollback
  60. @staticmethod
  61. def _spec(payload: dict[str, Any]) -> IngestionJobSpec:
  62. if not isinstance(payload, dict):
  63. raise IngestionPayloadInvalid("payload must be an object")
  64. parameters = payload.get("parameters", {})
  65. if parameters is None:
  66. parameters = {}
  67. if not isinstance(parameters, dict):
  68. raise IngestionPayloadInvalid("parameters must be an object")
  69. artifact_uid = str(payload.get("artifact_uid") or "").strip() or None
  70. return IngestionJobSpec(
  71. source_uid=_required_text(payload, "source_uid"),
  72. artifact_uid=artifact_uid,
  73. job_type=_required_text(payload, "job_type"),
  74. parser_version=_required_text(payload, "parser_version"),
  75. parameters=dict(parameters),
  76. force_rerun=bool(payload.get("force_rerun", False)),
  77. )
  78. def _idempotency_key(self, spec: IngestionJobSpec) -> str:
  79. value = {
  80. "source_uid": spec.source_uid,
  81. "artifact_uid": spec.artifact_uid,
  82. "job_type": spec.job_type,
  83. "parser_version": spec.parser_version,
  84. "parameters": spec.parameters,
  85. }
  86. if spec.force_rerun:
  87. value["rerun_nonce"] = self.nonce_factory()
  88. canonical = json.dumps(
  89. value,
  90. sort_keys=True,
  91. ensure_ascii=False,
  92. separators=(",", ":"),
  93. )
  94. return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  95. def create_job(self, payload: dict[str, Any], actor_uid: str | None):
  96. spec = self._spec(payload)
  97. idempotency_key = self._idempotency_key(spec)
  98. if not spec.force_rerun:
  99. existing = self.repository.get_by_idempotency_key(idempotency_key)
  100. if existing is not None:
  101. return existing, False
  102. now = self.clock()
  103. record = IngestionJobRecord(
  104. uid=self.uid_factory(),
  105. source_uid=spec.source_uid,
  106. artifact_uid=spec.artifact_uid,
  107. job_type=spec.job_type,
  108. parser_version=spec.parser_version,
  109. idempotency_key=idempotency_key,
  110. actor_uid=actor_uid,
  111. parameters=spec.parameters,
  112. force_rerun=spec.force_rerun,
  113. created_at=now,
  114. updated_at=now,
  115. )
  116. try:
  117. saved = self.repository.add(record)
  118. self.commit()
  119. return saved, True
  120. except Exception:
  121. self.rollback()
  122. raise
  123. def _get(self, uid: str) -> IngestionJobRecord:
  124. record = self.repository.get(str(uid))
  125. if record is None:
  126. raise IngestionJobNotFound(f"ingestion job {uid} was not found")
  127. return record
  128. def transition(
  129. self,
  130. uid: str,
  131. target_status: str,
  132. statistics: dict[str, Any] | None = None,
  133. error: Any = None,
  134. ) -> IngestionJobRecord:
  135. record = self._get(uid)
  136. target_status = str(target_status or "").strip()
  137. if target_status not in ALLOWED_TRANSITIONS.get(record.status, frozenset()):
  138. raise InvalidJobTransition(f"{record.status} -> {target_status} is not allowed")
  139. now = self.clock()
  140. updated = replace(
  141. record,
  142. status=target_status,
  143. statistics=dict(statistics or record.statistics),
  144. last_error=_sanitize_error(error),
  145. updated_at=now,
  146. started_at=(now if target_status == "extracting" and record.started_at is None else record.started_at),
  147. finished_at=(now if target_status in TERMINAL_STATUSES | {"failed", "partial"} else None),
  148. )
  149. try:
  150. saved = self.repository.save(updated)
  151. self.commit()
  152. return saved
  153. except Exception:
  154. self.rollback()
  155. raise
  156. def get_job(self, uid: str) -> IngestionJobRecord:
  157. return self._get(uid)
  158. def list_jobs(self, filters: dict[str, Any] | None = None):
  159. return self.repository.list(filters or {})
  160. def retry(self, uid: str) -> IngestionJobRecord:
  161. return self.transition(uid, "queued")
  162. def cancel(self, uid: str) -> IngestionJobRecord:
  163. return self.transition(uid, "cancelled")