test_rule_publication_lifecycle.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import os
  5. import time
  6. from datetime import UTC, datetime, timedelta
  7. import polars as pl
  8. import pytest
  9. from minio import Minio
  10. from sqlalchemy import create_engine, text
  11. from sqlalchemy.orm import Session
  12. from app.core.common.identifiers import new_governance_uid
  13. from app.core.data_rules.contracts import rule_spec_hash
  14. from app.core.data_rules.execution_contracts import canonical_schema_hash
  15. from app.core.data_rules.publication import (
  16. GenerationReceiptSigner,
  17. LogicalRuleCompiler,
  18. RulePublicationService,
  19. ServerOwnedLogicalDryRunRunner,
  20. ServerOwnedPhysicalPreflightRunner,
  21. generation_receipt_claims,
  22. )
  23. from app.core.data_rules.release import ProductionLineReleaseService
  24. from app.core.data_rules.repository import DataRuleRepository
  25. from app.runner.artifacts import ArtifactStore
  26. from tests.core.data_rules.test_contracts import (
  27. valid_dataflow_spec,
  28. valid_rule_spec,
  29. )
  30. from tests.integration.test_data_rule_polars_execution import _compose_value
  31. pytestmark = pytest.mark.integration
  32. def _hash(value):
  33. return hashlib.sha256(
  34. json.dumps(
  35. value,
  36. sort_keys=True,
  37. separators=(",", ":"),
  38. ensure_ascii=False,
  39. ).encode("utf-8")
  40. ).hexdigest()
  41. @pytest.fixture()
  42. def database_url():
  43. value = os.environ.get("TEST_DATABASE_URL")
  44. if not value:
  45. pytest.skip("TEST_DATABASE_URL is not configured")
  46. return value
  47. def test_real_postgres_receipt_to_logical_compile_test_publish(database_url):
  48. minio_user = _compose_value(r"MINIO_ROOT_USER:\s*([^\s]+)")
  49. minio_password = _compose_value(r"MINIO_ROOT_PASSWORD:\s*([^\s]+)")
  50. minio_port = _compose_value(r'"(19000):9000"')
  51. bucket = _compose_value(r"mc mb --ignore-existing local/([^\s]+)")
  52. store = ArtifactStore(
  53. Minio(
  54. f"127.0.0.1:{minio_port}",
  55. access_key=minio_user,
  56. secret_key=minio_password,
  57. secure=False,
  58. ),
  59. bucket=bucket,
  60. max_artifact_bytes=32 * 1024 * 1024,
  61. max_rows=100_000,
  62. memory_limit_bytes=256 * 1024 * 1024,
  63. max_ttl_seconds=3600,
  64. )
  65. input_fields = [
  66. {"name": "name", "type": "string", "nullable": True},
  67. {"name": "mobile", "type": "string", "nullable": True},
  68. ]
  69. output_fields = [
  70. *input_fields,
  71. {"name": "name_copy", "type": "string", "nullable": True},
  72. ]
  73. sample = store.write(
  74. pl.DataFrame(
  75. {
  76. "name": [" Alice ", " Bob ", " Carol "],
  77. "mobile": ["13800138000", "invalid", "13900139000"],
  78. }
  79. ),
  80. new_governance_uid(),
  81. 600,
  82. schema_fields=input_fields,
  83. )
  84. golden = store.write(
  85. pl.DataFrame(
  86. {
  87. "name": ["Alice", "Carol"],
  88. "mobile": ["13800138000", "13900139000"],
  89. "name_copy": ["Alice", "Carol"],
  90. }
  91. ),
  92. new_governance_uid(),
  93. 600,
  94. schema_fields=output_fields,
  95. )
  96. engine = create_engine(database_url)
  97. with engine.connect() as connection:
  98. transaction = connection.begin()
  99. try:
  100. actor = connection.execute(
  101. text(
  102. "SELECT id::text FROM public.users "
  103. "WHERE status = 'active' ORDER BY created_at LIMIT 1"
  104. )
  105. ).scalar_one_or_none()
  106. if actor is None:
  107. actor = new_governance_uid()
  108. connection.execute(
  109. text(
  110. "INSERT INTO public.users "
  111. "(id, username, display_name, password_hash, status) "
  112. "VALUES (CAST(:id AS uuid), :username, "
  113. "'Task7 Integration', 'not-a-login-secret', 'active')"
  114. ),
  115. {
  116. "id": actor,
  117. "username": f"task7-{actor[:8]}",
  118. },
  119. )
  120. session = Session(bind=connection)
  121. repository = DataRuleRepository(session)
  122. input_snapshot_value = {
  123. "schema_ref": "bd:rule-publication:input",
  124. "source_revision": "integration:1",
  125. "fields": input_fields,
  126. }
  127. input_snapshot_value["schema_hash"] = canonical_schema_hash(
  128. input_snapshot_value["fields"]
  129. )
  130. input_snapshot = repository.persist_schema_snapshot(
  131. snapshot=input_snapshot_value
  132. )
  133. output_snapshot_value = {
  134. "schema_ref": "bd:rule-publication:output",
  135. "source_revision": "integration:1",
  136. "fields": output_fields,
  137. }
  138. output_snapshot_value["schema_hash"] = canonical_schema_hash(
  139. output_snapshot_value["fields"]
  140. )
  141. output_snapshot = repository.persist_schema_snapshot(
  142. snapshot=output_snapshot_value
  143. )
  144. validation_context = {
  145. "input_schema_snapshot_id": input_snapshot["id"],
  146. "input_schema_hash": input_snapshot["schema_hash"],
  147. "input_fields": input_snapshot["fields"],
  148. "output_schema_snapshot_id": output_snapshot["id"],
  149. "output_schema_hash": output_snapshot["schema_hash"],
  150. "output_fields": output_snapshot["fields"],
  151. "input_sample_artifact_ref": sample["artifact_ref"],
  152. "input_sample_artifact_digest": sample["digest"],
  153. "golden_output_artifact_ref": golden["artifact_ref"],
  154. "golden_output_artifact_digest": golden["digest"],
  155. }
  156. spec = valid_rule_spec()
  157. spec["input_schema_ref"] = input_snapshot["schema_ref"]
  158. spec["output_schema_ref"] = output_snapshot["schema_ref"]
  159. spec["steps"][1]["on_failure"] = "quarantine"
  160. spec["steps"].append(
  161. {
  162. "id": "copy_name",
  163. "op": "derive",
  164. "target": "name_copy",
  165. "expression": "name",
  166. }
  167. )
  168. candidate = {
  169. "schema_version": "1.0",
  170. "candidate_type": "rule",
  171. "rule_spec": spec,
  172. "standard_spec": None,
  173. "assumptions": [],
  174. "ambiguities": [],
  175. "confidence": 0.99,
  176. "explanation": "integration candidate",
  177. }
  178. evidence = {
  179. "status": "ready",
  180. "source_text": "手机号去空格后必须为11位数字",
  181. "authoring_surface": "data_standard",
  182. "candidate": candidate,
  183. "model_provider": "integration",
  184. "model_name": "closed-fixture",
  185. "prompt_version": "integration-v1",
  186. "schema_version": "1.0",
  187. "context_hash": _hash(validation_context),
  188. "candidate_hash": repository.candidate_hash(candidate),
  189. "model_hash": "a" * 64,
  190. "prompt_hash": "b" * 64,
  191. "repair_attempts": 0,
  192. "generation_attempts": [],
  193. }
  194. generation = repository.record_generation_run(
  195. evidence=evidence,
  196. created_by=actor,
  197. validation_context=validation_context,
  198. )
  199. signer = GenerationReceiptSigner(
  200. "integration-receipt-secret-with-entropy"
  201. )
  202. claims = generation_receipt_claims(
  203. generation_run_id=generation["id"],
  204. actor_uid=actor,
  205. source_text=evidence["source_text"],
  206. candidate_hash=evidence["candidate_hash"],
  207. rule_spec=spec,
  208. model_hash=evidence["model_hash"],
  209. prompt_hash=evidence["prompt_hash"],
  210. context_hash=evidence["context_hash"],
  211. expires_at=datetime.now(UTC) + timedelta(seconds=2),
  212. )
  213. receipt = signer.issue(claims)
  214. service = RulePublicationService(
  215. repository,
  216. receipt_signer=signer,
  217. compiler=LogicalRuleCompiler(),
  218. test_runner=ServerOwnedLogicalDryRunRunner(store),
  219. )
  220. draft = service.create_draft(
  221. rule_spec=spec,
  222. source_text=evidence["source_text"],
  223. actor_uid=actor,
  224. generation_receipt=receipt,
  225. category="standard_clause",
  226. source_language="zh-CN",
  227. generated_kind="rulespec",
  228. )
  229. assert draft["status"] == "draft"
  230. assert draft["spec_hash"] == rule_spec_hash(spec)
  231. assert service.create_draft(
  232. rule_spec=spec,
  233. source_text=evidence["source_text"],
  234. actor_uid=actor,
  235. generation_receipt=receipt,
  236. category="standard_clause",
  237. source_language="zh-CN",
  238. generated_kind="rulespec",
  239. ) == draft
  240. while int(datetime.now(UTC).timestamp()) < claims["expires_at"]:
  241. time.sleep(0.05)
  242. assert service.create_draft(
  243. rule_spec=spec,
  244. source_text=evidence["source_text"],
  245. actor_uid=actor,
  246. generation_receipt=receipt,
  247. category="standard_clause",
  248. source_language="zh-CN",
  249. generated_kind="rulespec",
  250. ) == draft
  251. next_generation = repository.record_generation_run(
  252. evidence=evidence,
  253. created_by=actor,
  254. validation_context=validation_context,
  255. )
  256. next_claims = generation_receipt_claims(
  257. generation_run_id=next_generation["id"],
  258. actor_uid=actor,
  259. source_text=evidence["source_text"],
  260. candidate_hash=evidence["candidate_hash"],
  261. rule_spec=spec,
  262. model_hash=evidence["model_hash"],
  263. prompt_hash=evidence["prompt_hash"],
  264. context_hash=evidence["context_hash"],
  265. expires_at=datetime.now(UTC) + timedelta(seconds=2),
  266. )
  267. next_receipt = signer.issue(next_claims)
  268. while (
  269. int(datetime.now(UTC).timestamp())
  270. < next_claims["expires_at"]
  271. ):
  272. time.sleep(0.05)
  273. with pytest.raises(ValueError, match="expired"):
  274. service.create_draft(
  275. rule_spec=spec,
  276. source_text=evidence["source_text"],
  277. actor_uid=actor,
  278. generation_receipt=next_receipt,
  279. category="standard_clause",
  280. source_language="zh-CN",
  281. generated_kind="rulespec",
  282. )
  283. compiled = service.validate(draft["id"], actor)
  284. assert compiled["plan_status"] == "compiled"
  285. assert service.validate(draft["id"], actor) == compiled
  286. logical_plan = (
  287. connection.execute(
  288. text(
  289. "SELECT plan, plan_hash, schema_hashes "
  290. "FROM public.rule_logical_plans "
  291. "WHERE id = CAST(:id AS uuid)"
  292. ),
  293. {"id": compiled["plan_id"]},
  294. )
  295. .mappings()
  296. .one()
  297. )
  298. physical_result = ServerOwnedPhysicalPreflightRunner(store).run(
  299. {
  300. "backend": "polars_batch",
  301. "plan": logical_plan["plan"],
  302. "plan_hash": logical_plan["plan_hash"],
  303. "schema_hashes": logical_plan["schema_hashes"],
  304. "binding_hashes": {},
  305. "sample_artifact": {
  306. "artifact_ref": sample["artifact_ref"],
  307. "digest": sample["digest"],
  308. "schema_fields": input_fields,
  309. },
  310. }
  311. )
  312. assert physical_result["counts"]["rows_quarantined"] == 1
  313. tampered_sample = repository.load_logical_test_context(
  314. version_id=draft["id"],
  315. plan_id=compiled["plan_id"],
  316. )
  317. tampered_sample["input_sample_artifact_digest"] = "f" * 64
  318. with pytest.raises(ValueError, match="drifted"):
  319. ServerOwnedLogicalDryRunRunner(store).run(tampered_sample)
  320. tested = service.test(
  321. draft["id"], actor, plan_id=compiled["plan_id"]
  322. )
  323. assert tested["version_status"] == "validated"
  324. assert tested["test_evidence"]["counts"]["rows_quarantined"] == 1
  325. assert tested["test_evidence"]["counts"]["rows_rejected"] == 0
  326. connection.execute(
  327. text(
  328. "UPDATE public.data_schema_snapshots "
  329. "SET schema_hash = :drifted_hash "
  330. "WHERE id = CAST(:id AS uuid)"
  331. ),
  332. {
  333. "id": output_snapshot["id"],
  334. "drifted_hash": "f" * 64,
  335. },
  336. )
  337. with pytest.raises(ValueError, match="drifted"):
  338. service.publish(draft["id"], actor)
  339. connection.execute(
  340. text(
  341. "UPDATE public.data_schema_snapshots "
  342. "SET schema_hash = :schema_hash "
  343. "WHERE id = CAST(:id AS uuid)"
  344. ),
  345. {
  346. "id": output_snapshot["id"],
  347. "schema_hash": output_snapshot["schema_hash"],
  348. },
  349. )
  350. published = service.publish(draft["id"], actor)
  351. assert published["status"] == "published"
  352. assert published["plan_status"] == "published"
  353. assert service.publish(draft["id"], actor) == published
  354. assert service.catalog(query=spec["name"], limit=10)[0][
  355. "id"
  356. ] == draft["id"]
  357. flow = valid_dataflow_spec(rule_version_id=draft["id"])
  358. flow["input_schema_refs"] = [spec["input_schema_ref"]]
  359. flow["output_schema_ref"] = spec["output_schema_ref"]
  360. flow["components"] = [
  361. component
  362. for component in flow["components"]
  363. if component["type"] == "rule.apply"
  364. ]
  365. class PinnedResolver:
  366. def resolve(self, schema_ref):
  367. if schema_ref == input_snapshot["schema_ref"]:
  368. return input_snapshot
  369. if schema_ref == output_snapshot["schema_ref"]:
  370. return output_snapshot
  371. raise ValueError("unexpected schema ref")
  372. release_service = ProductionLineReleaseService(
  373. repository,
  374. schema_resolver=PinnedResolver(),
  375. )
  376. logical_test_id = tested["test_evidence_id"]
  377. connection.execute(
  378. text(
  379. "UPDATE public.rule_logical_test_evidence "
  380. "SET status = 'failed' "
  381. "WHERE id = CAST(:id AS uuid)"
  382. ),
  383. {"id": logical_test_id},
  384. )
  385. with pytest.raises(ValueError, match="published rule"):
  386. release_service.release(
  387. dataflow_uid=flow["dataflow_uid"],
  388. dataflow_spec=flow,
  389. source_text="跨模式数据生产线",
  390. created_by=actor,
  391. )
  392. connection.execute(
  393. text(
  394. "UPDATE public.rule_logical_test_evidence "
  395. "SET status = 'success', schema_hashes = '{}'::jsonb "
  396. "WHERE id = CAST(:id AS uuid)"
  397. ),
  398. {"id": logical_test_id},
  399. )
  400. with pytest.raises(ValueError, match="published rule"):
  401. release_service.release(
  402. dataflow_uid=flow["dataflow_uid"],
  403. dataflow_spec=flow,
  404. source_text="跨模式数据生产线",
  405. created_by=actor,
  406. )
  407. connection.execute(
  408. text(
  409. "UPDATE public.rule_logical_test_evidence te "
  410. "SET schema_hashes = lp.schema_hashes "
  411. "FROM public.rule_logical_plans lp "
  412. "WHERE te.logical_plan_id = lp.id "
  413. "AND te.id = CAST(:id AS uuid)"
  414. ),
  415. {"id": logical_test_id},
  416. )
  417. released = release_service.release(
  418. dataflow_uid=flow["dataflow_uid"],
  419. dataflow_spec=flow,
  420. source_text="跨模式数据生产线",
  421. created_by=actor,
  422. )
  423. assert released["status"] == "released"
  424. rows = connection.execute(
  425. text(
  426. "SELECT rv.status, lp.status, "
  427. "(SELECT COUNT(*) FROM public.rule_logical_compile_evidence "
  428. "WHERE logical_plan_id = lp.id) AS compile_count, "
  429. "(SELECT COUNT(*) FROM public.rule_logical_test_evidence "
  430. "WHERE logical_plan_id = lp.id) AS test_count "
  431. "FROM public.data_rule_versions rv "
  432. "JOIN public.rule_logical_plans lp "
  433. "ON lp.rule_version_id = rv.id "
  434. "WHERE rv.id = CAST(:id AS uuid)"
  435. ),
  436. {"id": draft["id"]},
  437. ).one()
  438. assert tuple(rows) == ("published", "published", 1, 1)
  439. finally:
  440. transaction.rollback()
  441. engine.dispose()
  442. store.delete(sample["artifact_ref"])
  443. store.delete(golden["artifact_ref"])