runtime.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. """Connector execution controls: idempotency, bounded retry, cancellation and evidence."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import re
  6. import threading
  7. import time
  8. import uuid
  9. from collections import deque
  10. from dataclasses import replace
  11. from app.core.connectors.errors import (
  12. ConnectorCancelledError,
  13. ConnectorConfigurationError,
  14. ConnectorConflictError,
  15. ConnectorRateLimitError,
  16. classify_error,
  17. )
  18. from app.core.connectors.sdk import OperationResult
  19. from app.core.connectors.store import UpdateOutcome
  20. MAX_EVIDENCE_BYTES = 32768
  21. MAX_CURSOR_BYTES = 32768
  22. MAX_CHECKPOINT_BYTES = 262144
  23. MAX_RECORDS_BYTES = 1048576
  24. MAX_RECORDS = 10000
  25. MAX_TOTAL_ATTEMPTS = 5
  26. SENSITIVE_KEYS = {
  27. "password",
  28. "passwd",
  29. "secret",
  30. "token",
  31. "api_key",
  32. "authorization",
  33. "credential",
  34. }
  35. def deterministic_idempotency_key(connector_id, version, request):
  36. payload = {
  37. "connector_id": connector_id,
  38. "version": version,
  39. "source_uid": request.source_uid,
  40. "principal_uid": request.principal_uid,
  41. "business_domain_uid": request.business_domain_uid,
  42. "environment": request.environment,
  43. "process_key": request.process_key,
  44. "source_binding_uid": request.source_binding_uid,
  45. "source_binding_version": request.source_binding_version,
  46. "operation": request.operation,
  47. "config": request.config,
  48. "scope": request.scope,
  49. "cursor": request.cursor,
  50. "checkpoint": request.checkpoint,
  51. "dry_run": request.dry_run,
  52. "client_idempotency_hint": request.idempotency_key,
  53. }
  54. encoded = json.dumps(
  55. payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
  56. ).encode()
  57. return hashlib.sha256(encoded).hexdigest()
  58. def redact_evidence(
  59. value, *, max_bytes=MAX_EVIDENCE_BYTES, summarize=True, truncate_lists=True
  60. ):
  61. def visit(item):
  62. if isinstance(item, dict):
  63. return {
  64. str(key): "[REDACTED]"
  65. if any(marker in str(key).lower() for marker in SENSITIVE_KEYS)
  66. else visit(child)
  67. for key, child in item.items()
  68. }
  69. if isinstance(item, (list, tuple)):
  70. values = item[:1000] if truncate_lists else item
  71. return [visit(child) for child in values]
  72. if isinstance(item, str):
  73. text_value = item[:4096]
  74. text_value = re.sub(
  75. r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]+=*", "Bearer [REDACTED]", text_value
  76. )
  77. text_value = re.sub(r"\bdopc_[A-Za-z0-9_-]+", "[REDACTED]", text_value)
  78. text_value = re.sub(
  79. r"(?i)(password|token|secret|api[_-]?key)=([^&\s]+)",
  80. r"\1=[REDACTED]",
  81. text_value,
  82. )
  83. text_value = re.sub(
  84. r"(?i)(https?://)[^/@\s]+@", r"\1[REDACTED]@", text_value
  85. )
  86. return text_value
  87. return item
  88. safe = visit(value)
  89. encoded = json.dumps(safe, sort_keys=True, default=str).encode()
  90. if summarize and len(encoded) > max_bytes:
  91. return {
  92. "truncated": True,
  93. "sha256": hashlib.sha256(encoded).hexdigest(),
  94. "original_bytes": len(encoded),
  95. }
  96. return safe
  97. def _bounded_channel(value, *, max_bytes, channel):
  98. raw = json.dumps(
  99. value, sort_keys=True, separators=(",", ":"), default=str
  100. ).encode()
  101. if len(raw) > max_bytes:
  102. raise ConnectorConfigurationError(f"connector {channel} exceeds safe limit")
  103. safe = redact_evidence(
  104. value, max_bytes=max_bytes, summarize=False, truncate_lists=False
  105. )
  106. encoded = json.dumps(
  107. safe, sort_keys=True, separators=(",", ":"), default=str
  108. ).encode()
  109. if len(encoded) > max_bytes:
  110. raise ConnectorConfigurationError(f"connector {channel} exceeds safe limit")
  111. return safe
  112. def sanitize_operation_result(result):
  113. if not isinstance(result.records, (list, tuple)) or len(result.records) > MAX_RECORDS:
  114. raise ConnectorConfigurationError("connector records exceed safe limit")
  115. records = _bounded_channel(
  116. list(result.records), max_bytes=MAX_RECORDS_BYTES, channel="records"
  117. )
  118. if any(not isinstance(item, dict) for item in records):
  119. raise ConnectorConfigurationError("connector records must be objects")
  120. cursor = _bounded_channel(
  121. dict(result.cursor), max_bytes=MAX_CURSOR_BYTES, channel="cursor"
  122. )
  123. checkpoint = _bounded_channel(
  124. dict(result.checkpoint), max_bytes=MAX_CHECKPOINT_BYTES, channel="checkpoint"
  125. )
  126. evidence = _bounded_channel(
  127. dict(result.evidence), max_bytes=MAX_EVIDENCE_BYTES, channel="evidence"
  128. )
  129. return replace(
  130. result,
  131. records=tuple(records),
  132. cursor=cursor,
  133. checkpoint=checkpoint,
  134. evidence=evidence,
  135. )
  136. def snapshot_diff(previous, current):
  137. def identity(item):
  138. if isinstance(item, dict):
  139. return (
  140. str(item.get("asset_key") or item.get("key") or "")
  141. + ":"
  142. + str(item.get("field") or "")
  143. )
  144. return json.dumps(item, sort_keys=True, default=str)
  145. before = {identity(item): item for item in previous}
  146. after = {identity(item): item for item in current}
  147. shared = before.keys() & after.keys()
  148. return {
  149. "added": tuple(after[key] for key in sorted(after.keys() - before.keys())),
  150. "removed": tuple(before[key] for key in sorted(before.keys() - after.keys())),
  151. "changed": tuple(
  152. {"before": before[key], "after": after[key]}
  153. for key in sorted(shared)
  154. if before[key] != after[key]
  155. ),
  156. }
  157. class InMemoryRunStore:
  158. """Test/reference store. Production API injects the PostgreSQL repository."""
  159. def __init__(self):
  160. self._lock = threading.RLock()
  161. self._runs = {}
  162. self._hints = {}
  163. def claim(self, key, record):
  164. with self._lock:
  165. hint = record.get("client_hint_hash")
  166. if hint and hint in self._hints and self._hints[hint] != key:
  167. raise ConnectorConflictError("idempotency hint conflicts with another request")
  168. if key in self._runs:
  169. if self._runs[key].get("request_hash") != record.get("request_hash"):
  170. raise ConnectorConflictError("idempotency request binding conflicts")
  171. return self._runs[key], False
  172. self._runs[key] = dict(record)
  173. if hint:
  174. self._hints[hint] = key
  175. return self._runs[key], True
  176. def update(self, key, **values):
  177. with self._lock:
  178. record = self._runs[key]
  179. current = record.get("status")
  180. requested = values.get("status")
  181. expected_attempt = values.pop("expected_attempt", None)
  182. lease_token = values.pop("lease_token", None)
  183. acquired = True
  184. if requested == "resumable":
  185. acquired = current in {"failed", "cancelled"}
  186. elif requested == "cancelled":
  187. acquired = current == "running"
  188. elif requested == "running":
  189. attempt = int(values.get("attempt_count", -1))
  190. acquired = (
  191. current in {"running", "resumable", "failed"}
  192. and int(record.get("attempt_count", 0)) < attempt
  193. )
  194. elif requested in {"succeeded", "dry_run", "failed"}:
  195. acquired = (
  196. current == "running"
  197. and not record.get("cancel_requested", False)
  198. and int(record.get("attempt_count", -1)) == int(expected_attempt or -1)
  199. and record.get("attempt_lease_token") == lease_token
  200. )
  201. if not acquired:
  202. return UpdateOutcome(dict(record), False)
  203. self._runs[key].update(values)
  204. if requested == "running":
  205. self._runs[key]["attempt_lease_token"] = lease_token
  206. return UpdateOutcome(dict(self._runs[key]), True)
  207. def cancel(self, key):
  208. outcome = self.update(key, status="cancelled", cancel_requested=True)
  209. if not outcome.acquired:
  210. raise ConnectorConfigurationError("connector run cannot be cancelled")
  211. return outcome.record
  212. def is_cancel_requested(self, key):
  213. with self._lock:
  214. return bool(self._runs.get(key, {}).get("cancel_requested"))
  215. def get(self, key):
  216. with self._lock:
  217. value = self._runs.get(key)
  218. if value is None:
  219. canonical = self._hints.get(hashlib.sha256(str(key).encode()).hexdigest())
  220. value = self._runs.get(canonical) if canonical else None
  221. return dict(value) if value else None
  222. def list(self):
  223. with self._lock:
  224. return [dict(item) for item in self._runs.values()]
  225. class SlidingWindowLimiter:
  226. def __init__(self, limit=30, window_seconds=60, clock=None):
  227. self.limit = int(limit)
  228. self.window_seconds = float(window_seconds)
  229. self.clock = clock or time.monotonic
  230. self._lock = threading.Lock()
  231. self._events = {}
  232. def acquire(self, key):
  233. now = self.clock()
  234. with self._lock:
  235. events = self._events.setdefault(key, deque())
  236. while events and events[0] <= now - self.window_seconds:
  237. events.popleft()
  238. if len(events) >= self.limit:
  239. raise ConnectorRateLimitError("connector runtime rate limit exceeded")
  240. events.append(now)
  241. class ConnectorRuntime:
  242. def __init__(
  243. self, registry, store=None, limiter=None, max_attempts=3, sleeper=None
  244. ):
  245. if max_attempts < 1 or max_attempts > 5:
  246. raise ConnectorConfigurationError("max_attempts must be between 1 and 5")
  247. self.registry = registry
  248. self.store = store or InMemoryRunStore()
  249. self.limiter = limiter or SlidingWindowLimiter()
  250. self.max_attempts = max_attempts
  251. self.sleeper = sleeper or time.sleep
  252. def _acquire_rate_limit(self, connector_id, source_uid):
  253. key = f"{connector_id}:{source_uid}"
  254. if hasattr(self.store, "acquire_rate_limit"):
  255. self.store.acquire_rate_limit(key)
  256. else:
  257. self.limiter.acquire(key)
  258. def _run_operation(self, key, record, request, operation):
  259. first_attempt = int(record.get("attempt_count", 0)) + 1
  260. remaining = MAX_TOTAL_ATTEMPTS - first_attempt + 1
  261. invocation_attempts = min(self.max_attempts, remaining)
  262. if invocation_attempts <= 0:
  263. raise ConnectorConfigurationError(
  264. "connector run attempt budget is exhausted"
  265. )
  266. for attempt in range(first_attempt, first_attempt + invocation_attempts):
  267. lease_token = str(uuid.uuid4())
  268. started = self.store.update(
  269. key,
  270. attempt_count=attempt,
  271. status="running",
  272. error_category=None,
  273. error_code=None,
  274. lease_token=lease_token,
  275. )
  276. if not started.acquired:
  277. if started.record.get("status") == "cancelled":
  278. raise ConnectorCancelledError()
  279. raise ConnectorConfigurationError(
  280. "connector run attempt is already owned"
  281. )
  282. started_record = started.record
  283. if started_record.get("status") == "cancelled":
  284. raise ConnectorCancelledError()
  285. if (
  286. started_record.get("status") != "running"
  287. or int(started_record.get("attempt_count", -1)) != attempt
  288. ):
  289. raise ConnectorConfigurationError(
  290. "connector run attempt could not be claimed"
  291. )
  292. try:
  293. def cancel_probe():
  294. return bool(
  295. hasattr(self.store, "is_cancel_requested")
  296. and self.store.is_cancel_requested(key)
  297. )
  298. attempt_request = replace(
  299. request,
  300. run_key=key,
  301. lease_token=lease_token,
  302. cancel_probe=cancel_probe,
  303. )
  304. if cancel_probe():
  305. raise ConnectorCancelledError()
  306. result = operation(attempt_request)
  307. if not isinstance(result, OperationResult):
  308. raise ConnectorConfigurationError(
  309. "connector returned an invalid result"
  310. )
  311. if result.status != "succeeded":
  312. raise ConnectorConfigurationError(
  313. "connector operation returned an invalid status"
  314. )
  315. if cancel_probe():
  316. raise ConnectorCancelledError()
  317. safe_result = sanitize_operation_result(result)
  318. status = "dry_run" if request.dry_run else "succeeded"
  319. safe_result = replace(safe_result, status=status)
  320. updated = self.store.update(
  321. key,
  322. status=status,
  323. result=safe_result,
  324. checkpoint=dict(safe_result.checkpoint),
  325. cursor=dict(safe_result.cursor),
  326. error_category=None,
  327. error_code=None,
  328. expected_attempt=attempt,
  329. lease_token=lease_token,
  330. )
  331. if not updated.acquired and updated.record.get("status") == "cancelled":
  332. raise ConnectorCancelledError()
  333. if not updated.acquired:
  334. raise ConnectorConfigurationError(
  335. "connector run completion ownership was lost"
  336. )
  337. return safe_result
  338. except Exception as error:
  339. classified = classify_error(error)
  340. updated = self.store.update(
  341. key,
  342. status="failed",
  343. error_category=classified.category,
  344. error_code=type(classified).__name__,
  345. expected_attempt=attempt,
  346. lease_token=lease_token,
  347. )
  348. if not updated.acquired and updated.record.get("status") == "cancelled":
  349. raise ConnectorCancelledError() from error
  350. if not updated.acquired:
  351. raise ConnectorConfigurationError(
  352. "connector run failure ownership was lost"
  353. ) from error
  354. final_attempt = attempt == first_attempt + invocation_attempts - 1
  355. if not classified.retryable or final_attempt:
  356. raise classified from error
  357. self.sleeper(min(0.1 * (2 ** (attempt - 1)), 1.0))
  358. raise ConnectorConfigurationError("connector run did not complete")
  359. def execute(self, connector_id, version, request):
  360. connector = self.registry.resolve(connector_id, version, request.operation)
  361. config = self.registry.validate(connector_id, version, request.config)
  362. request = replace(request, config=config)
  363. key = deterministic_idempotency_key(connector_id, version, request)
  364. client_hint_hash = (
  365. hashlib.sha256(str(request.idempotency_key).encode()).hexdigest()
  366. if request.idempotency_key
  367. else None
  368. )
  369. self._acquire_rate_limit(connector_id, request.source_uid)
  370. record, created = self.store.claim(
  371. key,
  372. {
  373. "idempotency_key": key,
  374. "request_hash": key,
  375. "client_hint_hash": client_hint_hash,
  376. "connector_id": connector_id,
  377. "connector_version": version,
  378. "source_uid": request.source_uid,
  379. "operation": request.operation,
  380. "config": dict(request.config),
  381. "scope": dict(request.scope),
  382. "status": "running",
  383. "attempt_count": 0,
  384. "checkpoint": dict(request.checkpoint),
  385. "cursor": dict(request.cursor),
  386. "dry_run": request.dry_run,
  387. "principal_uid": request.principal_uid,
  388. "business_domain_uid": request.business_domain_uid,
  389. "environment": request.environment,
  390. "process_key": request.process_key,
  391. "source_binding_uid": request.source_binding_uid,
  392. "source_binding_version": request.source_binding_version,
  393. "cancel_requested": False,
  394. },
  395. )
  396. if not created:
  397. if record["status"] in {"succeeded", "dry_run"}:
  398. return record.get("result") or OperationResult(
  399. cursor=record.get("cursor") or {},
  400. checkpoint=record.get("checkpoint") or {},
  401. evidence={"idempotent_replay": True},
  402. status=record["status"],
  403. )
  404. if record["status"] == "running":
  405. raise ConnectorConfigurationError(
  406. "idempotent operation is already running"
  407. )
  408. if record.get("status") == "cancelled":
  409. raise ConnectorCancelledError()
  410. operation = getattr(connector, request.operation)
  411. if request.dry_run:
  412. health = connector.health(config)
  413. def validation_only(_request):
  414. return OperationResult(
  415. evidence={
  416. "validation_only": True,
  417. "health_status": health.status,
  418. "network_requested": False,
  419. },
  420. )
  421. operation = validation_only
  422. return self._run_operation(key, record, request, operation)
  423. def cancel(self, key):
  424. record = self.store.get(key)
  425. if not record:
  426. raise ConnectorConfigurationError("connector run was not found")
  427. if record["status"] in {"succeeded", "failed", "cancelled"}:
  428. raise ConnectorConfigurationError("connector run cannot be cancelled")
  429. key = record.get("idempotency_key", key)
  430. connector = self.registry.resolve(
  431. record["connector_id"], record["connector_version"], "cancel"
  432. )
  433. from app.core.connectors.sdk import OperationRequest
  434. request = OperationRequest(
  435. source_uid=record["source_uid"],
  436. operation="cancel",
  437. config=record.get("config") or {},
  438. scope=record.get("scope") or {},
  439. cursor=record.get("cursor") or {},
  440. checkpoint=record.get("checkpoint") or {},
  441. idempotency_key=key,
  442. dry_run=bool(record.get("dry_run")),
  443. principal_uid=record.get("principal_uid"),
  444. business_domain_uid=record.get("business_domain_uid"),
  445. environment=record.get("environment"),
  446. process_key=record.get("process_key"),
  447. source_binding_uid=record.get("source_binding_uid"),
  448. source_binding_version=record.get("source_binding_version"),
  449. run_key=key,
  450. )
  451. if hasattr(self.store, "cancel"):
  452. cancelled = self.store.cancel(key)
  453. else:
  454. outcome = self.store.update(
  455. key, status="cancelled", cancel_requested=True
  456. )
  457. if not outcome.acquired:
  458. raise ConnectorConfigurationError("connector run cannot be cancelled")
  459. cancelled = outcome.record
  460. def cancel_probe():
  461. return bool(
  462. hasattr(self.store, "is_cancel_requested")
  463. and self.store.is_cancel_requested(key)
  464. )
  465. request = replace(request, cancel_probe=cancel_probe)
  466. if not request.dry_run:
  467. connector.cancel(request)
  468. return cancelled
  469. def resume(self, key):
  470. record = self.store.get(key)
  471. if not record or record["status"] not in {"failed", "cancelled"}:
  472. raise ConnectorConfigurationError("connector run cannot be resumed")
  473. key = record.get("idempotency_key", key)
  474. connector = self.registry.resolve(
  475. record["connector_id"], record["connector_version"], "resume"
  476. )
  477. config = self.registry.validate(
  478. record["connector_id"],
  479. record["connector_version"],
  480. record.get("config") or {},
  481. )
  482. self._acquire_rate_limit(record["connector_id"], record["source_uid"])
  483. from app.core.connectors.sdk import OperationRequest
  484. request = OperationRequest(
  485. source_uid=record["source_uid"],
  486. operation="resume",
  487. config=config,
  488. scope=record.get("scope") or {},
  489. cursor=record.get("cursor") or {},
  490. checkpoint=record.get("checkpoint") or {},
  491. idempotency_key=key,
  492. dry_run=bool(record.get("dry_run")),
  493. principal_uid=record.get("principal_uid"),
  494. business_domain_uid=record.get("business_domain_uid"),
  495. environment=record.get("environment"),
  496. process_key=record.get("process_key"),
  497. source_binding_uid=record.get("source_binding_uid"),
  498. source_binding_version=record.get("source_binding_version"),
  499. )
  500. resumable = self.store.update(
  501. key,
  502. status="resumable",
  503. resumed_from=record.get("uid"),
  504. cancel_requested=False,
  505. )
  506. if not resumable.acquired:
  507. raise ConnectorConfigurationError("connector run cannot be resumed")
  508. operation = connector.resume
  509. if request.dry_run:
  510. health = connector.health(config)
  511. def validation_only(_request):
  512. return OperationResult(
  513. evidence={
  514. "validation_only": True,
  515. "health_status": health.status,
  516. "network_requested": False,
  517. "resumed": True,
  518. }
  519. )
  520. operation = validation_only
  521. return self._run_operation(key, resumable.record, request, operation)
  522. __all__ = [
  523. "ConnectorRuntime",
  524. "InMemoryRunStore",
  525. "SlidingWindowLimiter",
  526. "MAX_TOTAL_ATTEMPTS",
  527. "deterministic_idempotency_key",
  528. "snapshot_diff",
  529. "redact_evidence",
  530. ]