test_data_rule_polars_execution.py 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from datetime import UTC, datetime, timedelta
  5. from pathlib import Path
  6. import polars as pl
  7. import pytest
  8. from minio import Minio
  9. from sqlalchemy import create_engine, text
  10. from app.core.common.identifiers import new_governance_uid
  11. from app.core.data_rules.contracts import rule_spec_hash, validate_rule_spec
  12. from app.core.data_rules.execution_contracts import canonical_schema_hash
  13. COMPOSE = (
  14. Path(__file__).resolve().parents[2]
  15. / "deploy"
  16. / "docker"
  17. / "docker-compose.yml"
  18. )
  19. def _compose_value(pattern):
  20. source = COMPOSE.read_text(encoding="utf-8")
  21. match = re.search(pattern, source, flags=re.DOTALL)
  22. assert match is not None
  23. return match.group(1)
  24. def _schema(schema_ref, fields):
  25. normalized = [
  26. {"name": name, "type": field_type, "nullable": nullable}
  27. for name, field_type, nullable in fields
  28. ]
  29. return {
  30. "id": new_governance_uid(),
  31. "schema_ref": schema_ref,
  32. "schema_hash": canonical_schema_hash(normalized),
  33. "fields": normalized,
  34. "source_revision": "task5:real-cross-source",
  35. }
  36. def _binding(schema, *, source_uid, access_mode, object_ref):
  37. return {
  38. "id": new_governance_uid(),
  39. "data_source_uid": source_uid,
  40. "object_kind": "parquet_artifact",
  41. "object_ref": object_ref,
  42. "schema_snapshot_id": schema["id"],
  43. "access_mode": access_mode,
  44. "dialect": "parquet",
  45. "write_mode": "append",
  46. }
  47. def test_real_postgres_mysql_minio_polars_cross_source_execution(tmp_path):
  48. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  49. from app.runner.artifacts import ArtifactStore, PostgresArtifactResolver
  50. from app.runner.rule_polars import PolarsRulePlanAdapter
  51. from app.runner.rules import PostgresRulePlanRepository, RulePlanExecutor
  52. source_user = _compose_value(
  53. r"source-postgres:.*?POSTGRES_USER:\s*([^\s]+)"
  54. )
  55. source_password = _compose_value(
  56. r"source-postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
  57. )
  58. platform_user = _compose_value(
  59. r"\n postgres:.*?POSTGRES_USER:\s*([^\s]+)"
  60. )
  61. platform_password = _compose_value(
  62. r"\n postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
  63. )
  64. postgres_port = _compose_value(r'"(25432):5432"')
  65. mysql_port = _compose_value(r'"(23306):3306"')
  66. minio_user = _compose_value(r"MINIO_ROOT_USER:\s*([^\s]+)")
  67. minio_password = _compose_value(r"MINIO_ROOT_PASSWORD:\s*([^\s]+)")
  68. minio_port = _compose_value(r'"(19000):9000"')
  69. platform_port = _compose_value(r'"(15432):5432"')
  70. bucket = _compose_value(r"mc mb --ignore-existing local/([^\s]+)")
  71. postgres = create_engine(
  72. f"postgresql+psycopg2://{source_user}:{source_password}"
  73. f"@127.0.0.1:{postgres_port}/acceptance",
  74. pool_pre_ping=True,
  75. )
  76. mysql = create_engine(
  77. f"mysql+pymysql://{source_user}:{source_password}"
  78. f"@127.0.0.1:{mysql_port}/acceptance",
  79. pool_pre_ping=True,
  80. )
  81. platform = create_engine(
  82. f"postgresql+psycopg2://{platform_user}:{platform_password}"
  83. f"@127.0.0.1:{platform_port}/dataops",
  84. pool_pre_ping=True,
  85. )
  86. minio = Minio(
  87. f"127.0.0.1:{minio_port}",
  88. access_key=minio_user,
  89. secret_key=minio_password,
  90. secure=False,
  91. )
  92. store = ArtifactStore(
  93. minio,
  94. bucket=bucket,
  95. max_artifact_bytes=4 * 1024 * 1024,
  96. max_rows=1_000,
  97. memory_limit_bytes=256 * 1024 * 1024,
  98. max_ttl_seconds=3600,
  99. )
  100. correlation_id = new_governance_uid()
  101. failure_correlation_id = new_governance_uid()
  102. unknown_correlation_id = new_governance_uid()
  103. lease_correlation_id = new_governance_uid()
  104. sample_crash_correlation_id = new_governance_uid()
  105. receipt_correlation_id = new_governance_uid()
  106. failed_receipt_correlation_id = new_governance_uid()
  107. sql_binding_id = new_governance_uid()
  108. prefix = f"rules/{correlation_id}/"
  109. customer_table = "task5_polars_customers"
  110. segment_table = "task5_polars_segments"
  111. rule_uid = new_governance_uid()
  112. rule_id = new_governance_uid()
  113. downstream_rule_uid = new_governance_uid()
  114. downstream_rule_id = new_governance_uid()
  115. dataflow_uid = new_governance_uid()
  116. dataflow_version_id = new_governance_uid()
  117. deployment_id = new_governance_uid()
  118. component_binding_id = new_governance_uid()
  119. plan_id = new_governance_uid()
  120. downstream_component_binding_id = new_governance_uid()
  121. downstream_plan_id = new_governance_uid()
  122. ledger_jti = None
  123. retry_ledger_jti = None
  124. try:
  125. with postgres.begin() as connection:
  126. connection.execute(text(f"DROP TABLE IF EXISTS {customer_table}"))
  127. connection.execute(
  128. text(
  129. f"CREATE TABLE {customer_table} ("
  130. "customer_id BIGINT NOT NULL, "
  131. "name VARCHAR(100), mobile VARCHAR(30), "
  132. "segment_code VARCHAR(20), version_no BIGINT NOT NULL)"
  133. )
  134. )
  135. connection.execute(
  136. text(
  137. f"INSERT INTO {customer_table} "
  138. "(customer_id, name, mobile, segment_code, version_no) "
  139. "VALUES "
  140. "(1, ' Alice ', '13800138000', 'A', 1), "
  141. "(1, ' Alice Updated ', '13800138000', 'A', 2), "
  142. "(2, ' Bad ', 'invalid', 'B', 1), "
  143. "(3, ' Carol ', '13900139000', 'C', 1)"
  144. )
  145. )
  146. with mysql.begin() as connection:
  147. connection.execute(text(f"DROP TABLE IF EXISTS {segment_table}"))
  148. connection.execute(
  149. text(
  150. f"CREATE TABLE {segment_table} ("
  151. "code VARCHAR(20) PRIMARY KEY, "
  152. "segment_name VARCHAR(100) NOT NULL)"
  153. )
  154. )
  155. connection.execute(
  156. text(
  157. f"INSERT INTO {segment_table} (code, segment_name) "
  158. "VALUES ('A', 'Gold'), ('B', 'Basic'), ('C', 'Silver')"
  159. )
  160. )
  161. with postgres.connect() as connection:
  162. customer_rows = [
  163. dict(row)
  164. for row in connection.execute(
  165. text(
  166. f"SELECT customer_id, name, mobile, "
  167. f"segment_code, version_no FROM {customer_table}"
  168. )
  169. ).mappings()
  170. ]
  171. with mysql.connect() as connection:
  172. segment_rows = [
  173. dict(row)
  174. for row in connection.execute(
  175. text(
  176. f"SELECT code, segment_name FROM {segment_table}"
  177. )
  178. ).mappings()
  179. ]
  180. input_schema = _schema(
  181. "bd:task5:customer:raw",
  182. [
  183. ("customer_id", "integer", False),
  184. ("name", "string", True),
  185. ("mobile", "string", True),
  186. ("segment_code", "string", True),
  187. ("version_no", "integer", False),
  188. ],
  189. )
  190. lookup_schema = _schema(
  191. "bd:task5:segment:lookup",
  192. [
  193. ("code", "string", False),
  194. ("segment_name", "string", False),
  195. ],
  196. )
  197. output_schema = _schema(
  198. "bd:task5:customer:enriched",
  199. [
  200. ("customer_id", "integer", False),
  201. ("name", "string", True),
  202. ("mobile", "string", True),
  203. ("segment_code", "string", True),
  204. ("version_no", "integer", False),
  205. ("segment_name", "string", True),
  206. ],
  207. )
  208. input_binding = _binding(
  209. input_schema,
  210. source_uid=new_governance_uid(),
  211. access_mode="read",
  212. object_ref="postgres-customer-artifact",
  213. )
  214. lookup_binding = _binding(
  215. lookup_schema,
  216. source_uid=new_governance_uid(),
  217. access_mode="read",
  218. object_ref="mysql-segment-artifact",
  219. )
  220. output_binding = _binding(
  221. output_schema,
  222. source_uid=new_governance_uid(),
  223. access_mode="read_write",
  224. object_ref="polars-output-artifact",
  225. )
  226. downstream_output_binding = _binding(
  227. output_schema,
  228. source_uid=new_governance_uid(),
  229. access_mode="write",
  230. object_ref="polars-downstream-output-artifact",
  231. )
  232. spec = validate_rule_spec(
  233. {
  234. "schema_version": "2.0",
  235. "rule_uid": new_governance_uid(),
  236. "name": "task5_real_cross_source",
  237. "input_schema_ref": input_schema["schema_ref"],
  238. "output_schema_ref": output_schema["schema_ref"],
  239. "steps": [
  240. {
  241. "id": "normalize_name",
  242. "op": "normalize_text",
  243. "column": "name",
  244. "trim": True,
  245. },
  246. {
  247. "id": "join_segment",
  248. "op": "lookup_join",
  249. "lookup": {
  250. "binding_id": lookup_binding["id"],
  251. "left_on": ["segment_code"],
  252. "right_on": ["code"],
  253. "select": {
  254. "segment_name": "segment_name"
  255. },
  256. "how": "left",
  257. },
  258. },
  259. {
  260. "id": "valid_mobile",
  261. "op": "assert",
  262. "expression": "matches(mobile, '^[0-9]{11}$')",
  263. "on_failure": "reject",
  264. "severity": "error",
  265. },
  266. {
  267. "id": "latest_customer",
  268. "op": "deduplicate",
  269. "keys": ["customer_id"],
  270. "order_by": ["version_no"],
  271. "keep": "last",
  272. },
  273. ],
  274. "null_policy": "explicit",
  275. "timezone": "Asia/Shanghai",
  276. }
  277. )
  278. rule = {
  279. "id": rule_id,
  280. "status": "published",
  281. "rule_spec": spec,
  282. "spec_hash": rule_spec_hash(spec),
  283. }
  284. compiled = PolarsRuleCompiler().compile(
  285. rule_version=rule,
  286. input_schema=input_schema,
  287. output_schema=output_schema,
  288. input_binding=input_binding,
  289. output_binding=output_binding,
  290. backend={
  291. "max_rows": 1_000,
  292. "max_artifact_bytes": 4 * 1024 * 1024,
  293. "memory_limit_bytes": 256 * 1024 * 1024,
  294. "masking_policies": {},
  295. "lookup_bindings": {
  296. lookup_binding["id"]: {
  297. "binding": lookup_binding,
  298. "schema": lookup_schema,
  299. }
  300. },
  301. },
  302. )
  303. downstream_spec = validate_rule_spec(
  304. {
  305. "schema_version": "2.0",
  306. "rule_uid": new_governance_uid(),
  307. "name": "task6_real_artifact_handoff",
  308. "input_schema_ref": output_schema["schema_ref"],
  309. "output_schema_ref": output_schema["schema_ref"],
  310. "steps": [
  311. {
  312. "id": "normalize_downstream_name",
  313. "op": "normalize_text",
  314. "column": "name",
  315. "trim": True,
  316. }
  317. ],
  318. "null_policy": "explicit",
  319. "timezone": "Asia/Shanghai",
  320. }
  321. )
  322. downstream_rule = {
  323. "id": downstream_rule_id,
  324. "status": "published",
  325. "rule_spec": downstream_spec,
  326. "spec_hash": rule_spec_hash(downstream_spec),
  327. }
  328. downstream_compiled = PolarsRuleCompiler().compile(
  329. rule_version=downstream_rule,
  330. input_schema=output_schema,
  331. output_schema=output_schema,
  332. input_binding=output_binding,
  333. output_binding=downstream_output_binding,
  334. backend={
  335. "max_rows": 1_000,
  336. "max_artifact_bytes": 4 * 1024 * 1024,
  337. "memory_limit_bytes": 256 * 1024 * 1024,
  338. "masking_policies": {},
  339. "lookup_bindings": {},
  340. },
  341. )
  342. lookup_operation = compiled["plan"]["operations"][1]
  343. schema_hashes = {
  344. "rule_spec_hash": compiled["plan"]["rule_spec_hash"],
  345. "input_schema_snapshot_id": input_schema["id"],
  346. "input_schema_hash": input_schema["schema_hash"],
  347. "output_schema_snapshot_id": output_schema["id"],
  348. "output_schema_hash": output_schema["schema_hash"],
  349. }
  350. with platform.begin() as connection:
  351. for schema in (input_schema, lookup_schema, output_schema):
  352. connection.execute(
  353. text(
  354. """
  355. INSERT INTO public.data_schema_snapshots
  356. (id, schema_ref, schema_hash, fields, source_revision)
  357. VALUES (CAST(:id AS uuid), :schema_ref, :schema_hash,
  358. CAST(:fields AS jsonb), :source_revision)
  359. """
  360. ),
  361. {**schema, "fields": json.dumps(schema["fields"])},
  362. )
  363. connection.execute(
  364. text(
  365. """
  366. INSERT INTO public.data_rules
  367. (id, rule_uid, name, category, status)
  368. VALUES (CAST(:id AS uuid), CAST(:rule_uid AS uuid),
  369. :name, 'general', 'active')
  370. """
  371. ),
  372. {
  373. "id": new_governance_uid(),
  374. "rule_uid": rule_uid,
  375. "name": spec["name"],
  376. },
  377. )
  378. connection.execute(
  379. text(
  380. """
  381. INSERT INTO public.data_rules
  382. (id, rule_uid, name, category, status)
  383. VALUES (CAST(:id AS uuid), CAST(:rule_uid AS uuid),
  384. :name, 'general', 'active')
  385. """
  386. ),
  387. {
  388. "id": new_governance_uid(),
  389. "rule_uid": downstream_rule_uid,
  390. "name": downstream_spec["name"],
  391. },
  392. )
  393. connection.execute(
  394. text(
  395. """
  396. INSERT INTO public.data_rule_versions
  397. (id, rule_uid, version_no, source_text, source_language,
  398. rule_spec, spec_hash, generated_kind, status, published_at)
  399. VALUES (CAST(:id AS uuid), CAST(:rule_uid AS uuid), 1,
  400. :source_text, 'en', CAST(:rule_spec AS jsonb),
  401. :spec_hash, 'polars', 'published',
  402. CURRENT_TIMESTAMP)
  403. """
  404. ),
  405. {
  406. "id": rule_id,
  407. "rule_uid": rule_uid,
  408. "source_text": "Task 5 real cross-source integration",
  409. "rule_spec": json.dumps(spec),
  410. "spec_hash": rule["spec_hash"],
  411. },
  412. )
  413. connection.execute(
  414. text(
  415. """
  416. INSERT INTO public.data_rule_versions
  417. (id, rule_uid, version_no, source_text,
  418. source_language, rule_spec, spec_hash,
  419. generated_kind, status, published_at)
  420. VALUES (
  421. CAST(:id AS uuid), CAST(:rule_uid AS uuid), 1,
  422. :source_text, 'en', CAST(:rule_spec AS jsonb),
  423. :spec_hash, 'polars', 'published',
  424. CURRENT_TIMESTAMP
  425. )
  426. """
  427. ),
  428. {
  429. "id": downstream_rule_id,
  430. "rule_uid": downstream_rule_uid,
  431. "source_text": (
  432. "Task 6 real two-node artifact handoff"
  433. ),
  434. "rule_spec": json.dumps(downstream_spec),
  435. "spec_hash": downstream_rule["spec_hash"],
  436. },
  437. )
  438. connection.execute(
  439. text(
  440. """
  441. INSERT INTO public.dataflow_versions
  442. (id, dataflow_uid, version_no, name, dataflow_spec,
  443. input_schema_hashes, output_schema_hash, status,
  444. released_at)
  445. VALUES (CAST(:id AS uuid), CAST(:dataflow_uid AS uuid), 1,
  446. :name, '{}'::jsonb,
  447. CAST(:input_schema_hashes AS jsonb),
  448. :output_schema_hash, 'released', CURRENT_TIMESTAMP)
  449. """
  450. ),
  451. {
  452. "id": dataflow_version_id,
  453. "dataflow_uid": dataflow_uid,
  454. "name": "Task 5 real cross-source integration",
  455. "input_schema_hashes": json.dumps(
  456. [
  457. input_schema["schema_hash"],
  458. lookup_schema["schema_hash"],
  459. ]
  460. ),
  461. "output_schema_hash": output_schema["schema_hash"],
  462. },
  463. )
  464. connection.execute(
  465. text(
  466. """
  467. INSERT INTO public.dataflow_deployments
  468. (id, dataflow_version_id, environment, deployment_config,
  469. status, activated_at)
  470. VALUES (CAST(:id AS uuid),
  471. CAST(:dataflow_version_id AS uuid), 'test',
  472. '{}'::jsonb, 'active', CURRENT_TIMESTAMP)
  473. """
  474. ),
  475. {
  476. "id": deployment_id,
  477. "dataflow_version_id": dataflow_version_id,
  478. },
  479. )
  480. for logical_ref, binding, binding_hash in (
  481. (
  482. "customers",
  483. input_binding,
  484. compiled["plan"]["input_binding_hash"],
  485. ),
  486. (
  487. "segments",
  488. lookup_binding,
  489. lookup_operation["lookup_binding_hash"],
  490. ),
  491. (
  492. "enriched",
  493. output_binding,
  494. compiled["plan"]["output_binding_hash"],
  495. ),
  496. (
  497. "downstream",
  498. downstream_output_binding,
  499. downstream_compiled["plan"][
  500. "output_binding_hash"
  501. ],
  502. ),
  503. ):
  504. connection.execute(
  505. text(
  506. """
  507. INSERT INTO public.dataflow_dataset_bindings
  508. (id, dataflow_deployment_id, logical_ref,
  509. data_source_uid, object_kind, object_ref,
  510. schema_snapshot_id, dialect, access_mode, write_mode,
  511. binding_hash)
  512. VALUES (CAST(:id AS uuid), CAST(:deployment_id AS uuid),
  513. :logical_ref, CAST(:source_uid AS uuid),
  514. 'parquet_artifact', :object_ref,
  515. CAST(:schema_snapshot_id AS uuid), 'parquet',
  516. :access_mode, 'append', :binding_hash)
  517. """
  518. ),
  519. {
  520. "id": binding["id"],
  521. "deployment_id": deployment_id,
  522. "logical_ref": logical_ref,
  523. "source_uid": binding["data_source_uid"],
  524. "object_ref": binding["object_ref"],
  525. "schema_snapshot_id": binding["schema_snapshot_id"],
  526. "access_mode": binding["access_mode"],
  527. "binding_hash": binding_hash,
  528. },
  529. )
  530. connection.execute(
  531. text(
  532. """
  533. INSERT INTO public.dataflow_component_bindings
  534. (id, dataflow_version_id, component_id, component_kind,
  535. rule_version_id, stage, order_no, idempotency, provenance)
  536. VALUES (CAST(:id AS uuid),
  537. CAST(:dataflow_version_id AS uuid),
  538. 'task5_real_polars', 'rule.apply',
  539. CAST(:rule_version_id AS uuid), 'transform', 0,
  540. CAST(:idempotency AS jsonb), '{}'::jsonb)
  541. """
  542. ),
  543. {
  544. "id": component_binding_id,
  545. "dataflow_version_id": dataflow_version_id,
  546. "rule_version_id": rule_id,
  547. "idempotency": json.dumps(
  548. {
  549. "strategy": "deduplication_key",
  550. "key": "customer_id",
  551. }
  552. ),
  553. },
  554. )
  555. connection.execute(
  556. text(
  557. """
  558. INSERT INTO public.dataflow_component_bindings
  559. (id, dataflow_version_id, component_id,
  560. component_kind, rule_version_id, stage, order_no,
  561. idempotency, provenance)
  562. VALUES (
  563. CAST(:id AS uuid),
  564. CAST(:dataflow_version_id AS uuid),
  565. 'task6_real_handoff', 'rule.apply',
  566. CAST(:rule_version_id AS uuid), 'transform', 1,
  567. CAST(:idempotency AS jsonb), '{}'::jsonb
  568. )
  569. """
  570. ),
  571. {
  572. "id": downstream_component_binding_id,
  573. "dataflow_version_id": dataflow_version_id,
  574. "rule_version_id": downstream_rule_id,
  575. "idempotency": json.dumps(
  576. {
  577. "strategy": "deduplication_key",
  578. "key": "customer_id",
  579. }
  580. ),
  581. },
  582. )
  583. connection.execute(
  584. text(
  585. """
  586. INSERT INTO public.rule_execution_plans
  587. (id, component_binding_id, backend, compiler_version, plan,
  588. plan_hash, schema_hashes, status)
  589. VALUES (CAST(:id AS uuid),
  590. CAST(:component_binding_id AS uuid),
  591. 'polars_batch', :compiler_version,
  592. CAST(:plan AS jsonb), :plan_hash,
  593. CAST(:schema_hashes AS jsonb), 'published')
  594. """
  595. ),
  596. {
  597. "id": plan_id,
  598. "component_binding_id": component_binding_id,
  599. "compiler_version": compiled["compiler_version"],
  600. "plan": json.dumps(compiled["plan"]),
  601. "plan_hash": compiled["plan_hash"],
  602. "schema_hashes": json.dumps(schema_hashes),
  603. },
  604. )
  605. connection.execute(
  606. text(
  607. """
  608. INSERT INTO public.rule_execution_plans
  609. (id, component_binding_id, backend,
  610. compiler_version, plan, plan_hash,
  611. schema_hashes, status)
  612. VALUES (
  613. CAST(:id AS uuid),
  614. CAST(:component_binding_id AS uuid),
  615. 'polars_batch', :compiler_version,
  616. CAST(:plan AS jsonb), :plan_hash,
  617. CAST(:schema_hashes AS jsonb), 'published'
  618. )
  619. """
  620. ),
  621. {
  622. "id": downstream_plan_id,
  623. "component_binding_id": (
  624. downstream_component_binding_id
  625. ),
  626. "compiler_version": downstream_compiled[
  627. "compiler_version"
  628. ],
  629. "plan": json.dumps(downstream_compiled["plan"]),
  630. "plan_hash": downstream_compiled["plan_hash"],
  631. "schema_hashes": json.dumps(
  632. {
  633. "rule_spec_hash": downstream_compiled[
  634. "plan"
  635. ]["rule_spec_hash"],
  636. "input_schema_snapshot_id": output_schema[
  637. "id"
  638. ],
  639. "input_schema_hash": output_schema[
  640. "schema_hash"
  641. ],
  642. "output_schema_snapshot_id": output_schema[
  643. "id"
  644. ],
  645. "output_schema_hash": output_schema[
  646. "schema_hash"
  647. ],
  648. }
  649. ),
  650. },
  651. )
  652. customer_path = tmp_path / "customers.parquet"
  653. segment_path = tmp_path / "segments.parquet"
  654. pl.DataFrame(customer_rows).write_parquet(customer_path)
  655. pl.DataFrame(segment_rows).write_parquet(segment_path)
  656. resolver = PostgresArtifactResolver(platform, store)
  657. customer_artifact = resolver.publish_path(
  658. str(customer_path),
  659. binding_id=input_binding["id"],
  660. binding_hash=compiled["plan"]["input_binding_hash"],
  661. correlation_id=correlation_id,
  662. kind="input",
  663. ttl_seconds=900,
  664. schema_fields=input_schema["fields"],
  665. limits=compiled["plan"]["resource_limits"],
  666. )
  667. segment_artifact = resolver.publish_path(
  668. str(segment_path),
  669. binding_id=lookup_binding["id"],
  670. binding_hash=lookup_operation["lookup_binding_hash"],
  671. correlation_id=correlation_id,
  672. kind="lookup",
  673. ttl_seconds=900,
  674. schema_fields=lookup_schema["fields"],
  675. limits=compiled["plan"]["resource_limits"],
  676. )
  677. node = {
  678. "id": "task5_real_polars",
  679. "type": "rule.apply",
  680. "purpose": "write",
  681. "idempotency": {
  682. "strategy": "deduplication_key",
  683. "key": "customer_id",
  684. },
  685. "config": {
  686. "component_binding_id": component_binding_id,
  687. "rule_version_id": rule["id"],
  688. "execution_plan_hash": compiled["plan_hash"],
  689. },
  690. }
  691. executor = RulePlanExecutor(
  692. PostgresRulePlanRepository(platform),
  693. adapters={
  694. "polars_batch": PolarsRulePlanAdapter(
  695. artifact_store=store,
  696. artifact_resolver=resolver,
  697. artifact_ttl_seconds=900,
  698. )
  699. },
  700. )
  701. result = executor.execute(
  702. node,
  703. {},
  704. write_authorized=True,
  705. correlation_id=correlation_id,
  706. )
  707. repeated = executor.execute(
  708. node,
  709. {},
  710. write_authorized=True,
  711. correlation_id=correlation_id,
  712. )
  713. assert result["rows_in"] == 4
  714. assert result["rows_out"] == 2
  715. assert result["rows_rejected"] == 1
  716. assert result["rows_deduplicated"] == 1
  717. assert result["rows_filtered"] == 0
  718. assert result["rows_join_dropped"] == 0
  719. assert result["rows_aggregated"] == 0
  720. assert result["violation_count"] == 1
  721. assert result["violations"] == [
  722. {"step_id": "valid_mobile", "count": 1}
  723. ]
  724. output = store.read(
  725. result["artifact_ref"],
  726. result["digest"],
  727. expected_schema_fields=output_schema["fields"],
  728. limits=compiled["plan"]["resource_limits"],
  729. ).collect()
  730. assert output.sort("customer_id").to_dicts() == [
  731. {
  732. "customer_id": 1,
  733. "mobile": "13800138000",
  734. "name": "Alice Updated",
  735. "segment_code": "A",
  736. "segment_name": "Gold",
  737. "version_no": 2,
  738. },
  739. {
  740. "customer_id": 3,
  741. "mobile": "13900139000",
  742. "name": "Carol",
  743. "segment_code": "C",
  744. "segment_name": "Silver",
  745. "version_no": 1,
  746. },
  747. ]
  748. assert all(
  749. item.object_name.startswith(prefix)
  750. for item in minio.list_objects(
  751. bucket, prefix=prefix, recursive=True
  752. )
  753. )
  754. assert repeated["rows_out"] == 2
  755. assert repeated["artifact_ref"] == result["artifact_ref"]
  756. assert "schema_fields" not in result
  757. from app.runner.api import create_runner_app
  758. from app.runner.auth import TaskTokenIssuer, TaskTokenVerifier
  759. from app.runner.ledger import PostgresTaskLedger
  760. from app.runner.nodes import NodeRegistry
  761. from app.runner.rule_evidence import PostgresRuleEvidenceWriter
  762. task_secret = "task5-real-http-secret-value-32-bytes"
  763. verifier = TaskTokenVerifier(task_secret)
  764. task_token = TaskTokenIssuer(task_secret).issue(
  765. task_uid=new_governance_uid(),
  766. dataflow_uid=dataflow_uid,
  767. deployment_id=deployment_id,
  768. environment="test",
  769. workflow_version=1,
  770. correlation_id=correlation_id,
  771. node=node,
  772. write_authorized=True,
  773. )
  774. ledger_jti = verifier.verify(task_token, node=node).jti
  775. retry_token = TaskTokenIssuer(task_secret).issue(
  776. task_uid=new_governance_uid(),
  777. dataflow_uid=dataflow_uid,
  778. deployment_id=deployment_id,
  779. environment="test",
  780. workflow_version=1,
  781. correlation_id=correlation_id,
  782. node=node,
  783. write_authorized=True,
  784. )
  785. retry_ledger_jti = verifier.verify(
  786. retry_token, node=node
  787. ).jti
  788. evidenced_executor = RulePlanExecutor(
  789. PostgresRulePlanRepository(platform),
  790. adapters={
  791. "polars_batch": PolarsRulePlanAdapter(
  792. artifact_store=store,
  793. artifact_resolver=resolver,
  794. artifact_ttl_seconds=900,
  795. )
  796. },
  797. evidence_writer=PostgresRuleEvidenceWriter(
  798. platform,
  799. store,
  800. sample_ttl_seconds=900,
  801. ),
  802. )
  803. runner_app = create_runner_app(
  804. verifier=verifier,
  805. ledger=PostgresTaskLedger(platform),
  806. registry=NodeRegistry({"rule.apply": evidenced_executor}),
  807. )
  808. with runner_app.test_client() as client:
  809. http_result = client.post(
  810. "/v1/tasks/execute",
  811. json={
  812. "task_token": task_token,
  813. "node": node,
  814. "parameters": {},
  815. },
  816. )
  817. replay = client.post(
  818. "/v1/tasks/execute",
  819. json={
  820. "task_token": task_token,
  821. "node": node,
  822. "parameters": {},
  823. },
  824. )
  825. retried = client.post(
  826. "/v1/tasks/execute",
  827. json={
  828. "task_token": retry_token,
  829. "node": node,
  830. "parameters": {},
  831. },
  832. )
  833. assert http_result.status_code == 200, http_result.get_json()
  834. assert http_result.get_json()["output_artifact"] == result[
  835. "artifact_ref"
  836. ]
  837. assert http_result.get_json()["result"]["artifact_ref"] == result[
  838. "artifact_ref"
  839. ]
  840. assert replay.status_code == 200
  841. assert replay.headers["X-Idempotent-Replay"] == "true"
  842. assert replay.get_json() == http_result.get_json()
  843. assert retried.status_code == 200
  844. assert retried.get_json()["output_artifact"] == result[
  845. "artifact_ref"
  846. ]
  847. ledger_record = PostgresTaskLedger(platform).get(ledger_jti)
  848. assert ledger_record is not None
  849. assert ledger_record.status == "success"
  850. assert ledger_record.commit_outcome == "committed"
  851. with platform.connect() as connection:
  852. run_evidence = connection.execute(
  853. text(
  854. """
  855. SELECT status, commit_outcome, rows_in, rows_out,
  856. rows_rejected, rows_quarantined, public_result
  857. FROM public.rule_runs
  858. WHERE correlation_id = CAST(:correlation_id AS uuid)
  859. AND component_binding_id =
  860. CAST(:component_binding_id AS uuid)
  861. """
  862. ),
  863. {
  864. "correlation_id": correlation_id,
  865. "component_binding_id": component_binding_id,
  866. },
  867. ).mappings().one()
  868. sample_evidence = connection.execute(
  869. text(
  870. """
  871. SELECT s.artifact_ref, s.artifact_digest,
  872. s.sample_count, s.redaction_policy,
  873. s.expires_at, s.handoff_status
  874. FROM public.rule_violation_samples s
  875. JOIN public.rule_runs r ON r.id = s.rule_run_id
  876. WHERE r.correlation_id =
  877. CAST(:correlation_id AS uuid)
  878. """
  879. ),
  880. {"correlation_id": correlation_id},
  881. ).mappings().one()
  882. assert run_evidence["status"] == "success"
  883. assert run_evidence["commit_outcome"] == "committed"
  884. assert run_evidence["rows_in"] == 4
  885. assert run_evidence["rows_out"] == 2
  886. assert run_evidence["rows_rejected"] == 1
  887. assert run_evidence["rows_quarantined"] == 0
  888. assert run_evidence["public_result"]["output_artifact"] == result[
  889. "artifact_ref"
  890. ]
  891. assert sample_evidence["artifact_digest"]
  892. assert sample_evidence["sample_count"] == 1
  893. assert (
  894. sample_evidence["redaction_policy"]
  895. == "rule-violation-default-v1"
  896. )
  897. assert sample_evidence["handoff_status"] == "ready"
  898. assert store.read(
  899. sample_evidence["artifact_ref"],
  900. sample_evidence["artifact_digest"],
  901. expected_schema_fields=[
  902. {
  903. "name": name,
  904. "type": "string",
  905. "nullable": True,
  906. }
  907. for name in (
  908. "customer_id",
  909. "mobile",
  910. "name",
  911. "segment_code",
  912. "segment_name",
  913. "version_no",
  914. )
  915. ],
  916. ).collect().to_dicts() == [
  917. {
  918. "customer_id": "[REDACTED]",
  919. "mobile": "[REDACTED]",
  920. "name": "[REDACTED]",
  921. "segment_code": "[REDACTED]",
  922. "segment_name": "[REDACTED]",
  923. "version_no": "[REDACTED]",
  924. }
  925. ]
  926. orphan = store.write(
  927. pl.DataFrame({"value": ["orphan"]}),
  928. correlation_id,
  929. 900,
  930. schema_fields=[
  931. {
  932. "name": "value",
  933. "type": "string",
  934. "nullable": True,
  935. }
  936. ],
  937. )
  938. original_clock = store.clock
  939. store.clock = lambda: datetime.now(UTC) + timedelta(seconds=60)
  940. try:
  941. reconciliation = resolver.reconcile(
  942. limit=20,
  943. grace_seconds=30,
  944. )
  945. finally:
  946. store.clock = original_clock
  947. assert reconciliation["orphans_deleted"] >= 1
  948. assert store.describe_optional(orphan["artifact_ref"]) is None
  949. assert store.describe(sample_evidence["artifact_ref"])[
  950. "digest"
  951. ] == sample_evidence["artifact_digest"]
  952. downstream_node = {
  953. "id": "task6_real_handoff",
  954. "type": "rule.apply",
  955. "purpose": "write",
  956. "idempotency": {
  957. "strategy": "deduplication_key",
  958. "key": "customer_id",
  959. },
  960. "config": {
  961. "component_binding_id": (
  962. downstream_component_binding_id
  963. ),
  964. "rule_version_id": downstream_rule_id,
  965. "execution_plan_hash": downstream_compiled[
  966. "plan_hash"
  967. ],
  968. },
  969. }
  970. downstream_result = evidenced_executor.execute(
  971. downstream_node,
  972. {"input_artifact": result["artifact_ref"]},
  973. write_authorized=True,
  974. correlation_id=correlation_id,
  975. dataflow_uid=dataflow_uid,
  976. deployment_id=deployment_id,
  977. environment="test",
  978. workflow_version=1,
  979. node_id="task6_real_handoff",
  980. task_jti=new_governance_uid(),
  981. )
  982. assert downstream_result["rows_in"] == 2
  983. assert downstream_result["rows_out"] == 2
  984. assert downstream_result["output_artifact"] != result[
  985. "artifact_ref"
  986. ]
  987. assert store.read(
  988. downstream_result["artifact_ref"],
  989. downstream_result["digest"],
  990. expected_schema_fields=output_schema["fields"],
  991. limits=downstream_compiled["plan"]["resource_limits"],
  992. ).collect().sort("customer_id").to_dicts() == (
  993. output.sort("customer_id").to_dicts()
  994. )
  995. replayed_downstream = evidenced_executor.execute(
  996. downstream_node,
  997. {"input_artifact": result["artifact_ref"]},
  998. write_authorized=True,
  999. correlation_id=correlation_id,
  1000. dataflow_uid=dataflow_uid,
  1001. deployment_id=deployment_id,
  1002. environment="test",
  1003. workflow_version=1,
  1004. node_id="task6_real_handoff",
  1005. task_jti=new_governance_uid(),
  1006. )
  1007. assert replayed_downstream["artifact_ref"] == downstream_result[
  1008. "artifact_ref"
  1009. ]
  1010. with platform.connect() as connection:
  1011. assert connection.execute(
  1012. text(
  1013. """
  1014. SELECT COUNT(*)
  1015. FROM public.rule_runs
  1016. WHERE correlation_id =
  1017. CAST(:correlation_id AS uuid)
  1018. """
  1019. ),
  1020. {"correlation_id": correlation_id},
  1021. ).scalar_one() == 2
  1022. from app.runner.nodes import NodeExecutionError
  1023. class FailingAdapter:
  1024. def execute(self, **_kwargs):
  1025. raise NodeExecutionError(
  1026. "safe downstream failure",
  1027. commit_outcome="not_committed",
  1028. )
  1029. failed_executor = RulePlanExecutor(
  1030. PostgresRulePlanRepository(platform),
  1031. adapters={"polars_batch": FailingAdapter()},
  1032. evidence_writer=PostgresRuleEvidenceWriter(
  1033. platform,
  1034. store,
  1035. sample_ttl_seconds=900,
  1036. ),
  1037. )
  1038. with pytest.raises(NodeExecutionError, match="safe downstream"):
  1039. failed_executor.execute(
  1040. node,
  1041. {},
  1042. write_authorized=True,
  1043. correlation_id=failure_correlation_id,
  1044. dataflow_uid=dataflow_uid,
  1045. deployment_id=deployment_id,
  1046. environment="test",
  1047. workflow_version=1,
  1048. node_id="task5_real_polars",
  1049. task_jti=new_governance_uid(),
  1050. )
  1051. with platform.connect() as connection:
  1052. failed_evidence = connection.execute(
  1053. text(
  1054. """
  1055. SELECT status, commit_outcome
  1056. FROM public.rule_runs
  1057. WHERE correlation_id =
  1058. CAST(:correlation_id AS uuid)
  1059. """
  1060. ),
  1061. {"correlation_id": failure_correlation_id},
  1062. ).mappings().one()
  1063. assert dict(failed_evidence) == {
  1064. "status": "failed",
  1065. "commit_outcome": "not_committed",
  1066. }
  1067. class UnknownAdapter:
  1068. def execute(self, **_kwargs):
  1069. raise NodeExecutionError(
  1070. "safe uncertain commit",
  1071. commit_outcome="unknown",
  1072. )
  1073. unknown_executor = RulePlanExecutor(
  1074. PostgresRulePlanRepository(platform),
  1075. adapters={"polars_batch": UnknownAdapter()},
  1076. evidence_writer=PostgresRuleEvidenceWriter(
  1077. platform,
  1078. store,
  1079. sample_ttl_seconds=900,
  1080. ),
  1081. )
  1082. with pytest.raises(NodeExecutionError, match="uncertain commit"):
  1083. unknown_executor.execute(
  1084. node,
  1085. {},
  1086. write_authorized=True,
  1087. correlation_id=unknown_correlation_id,
  1088. dataflow_uid=dataflow_uid,
  1089. deployment_id=deployment_id,
  1090. environment="test",
  1091. workflow_version=1,
  1092. node_id="task5_real_polars",
  1093. task_jti=new_governance_uid(),
  1094. )
  1095. with platform.connect() as connection:
  1096. unknown_evidence = connection.execute(
  1097. text(
  1098. """
  1099. SELECT status, commit_outcome
  1100. FROM public.rule_runs
  1101. WHERE correlation_id =
  1102. CAST(:correlation_id AS uuid)
  1103. """
  1104. ),
  1105. {"correlation_id": unknown_correlation_id},
  1106. ).mappings().one()
  1107. assert dict(unknown_evidence) == {
  1108. "status": "unknown",
  1109. "commit_outcome": "unknown",
  1110. }
  1111. evidence_writer = PostgresRuleEvidenceWriter(
  1112. platform,
  1113. store,
  1114. sample_ttl_seconds=900,
  1115. lease_seconds=30,
  1116. )
  1117. lease_owner = new_governance_uid()
  1118. lease_run_id = evidence_writer.start(
  1119. component_binding_id=component_binding_id,
  1120. rule_version_id=rule_id,
  1121. plan_hash=compiled["plan_hash"],
  1122. correlation_id=lease_correlation_id,
  1123. dataflow_uid=dataflow_uid,
  1124. deployment_id=deployment_id,
  1125. environment="test",
  1126. workflow_version=1,
  1127. node_id="task5_real_polars",
  1128. lease_owner=lease_owner,
  1129. )
  1130. with pytest.raises(ValueError, match="not owned"):
  1131. evidence_writer.heartbeat(
  1132. lease_run_id,
  1133. new_governance_uid(),
  1134. )
  1135. evidence_writer.heartbeat(lease_run_id, lease_owner)
  1136. with platform.begin() as connection:
  1137. connection.execute(
  1138. text(
  1139. """
  1140. UPDATE public.rule_runs
  1141. SET lease_expires_at =
  1142. CURRENT_TIMESTAMP - INTERVAL '1 second'
  1143. WHERE id = CAST(:id AS uuid)
  1144. """
  1145. ),
  1146. {"id": lease_run_id},
  1147. )
  1148. assert evidence_writer.start(
  1149. component_binding_id=component_binding_id,
  1150. rule_version_id=rule_id,
  1151. plan_hash=compiled["plan_hash"],
  1152. correlation_id=lease_correlation_id,
  1153. dataflow_uid=dataflow_uid,
  1154. deployment_id=deployment_id,
  1155. environment="test",
  1156. workflow_version=1,
  1157. node_id="task5_real_polars",
  1158. lease_owner=new_governance_uid(),
  1159. ) == lease_run_id
  1160. assert evidence_writer.replay(lease_run_id)["status"] == "unknown"
  1161. class FailOnceConnection:
  1162. def __init__(self, connection, state):
  1163. self.connection = connection
  1164. self.state = state
  1165. def execute(self, statement, parameters=None):
  1166. sql = str(statement)
  1167. if (
  1168. self.state["armed"]
  1169. and "UPDATE public.rule_runs" in sql
  1170. and "rows_in = :rows_in" in sql
  1171. ):
  1172. self.state["armed"] = False
  1173. raise RuntimeError("simulated response loss")
  1174. return self.connection.execute(statement, parameters)
  1175. class FailOnceBegin:
  1176. def __init__(self, context, state):
  1177. self.context = context
  1178. self.state = state
  1179. def __enter__(self):
  1180. return FailOnceConnection(
  1181. self.context.__enter__(),
  1182. self.state,
  1183. )
  1184. def __exit__(self, *args):
  1185. return self.context.__exit__(*args)
  1186. class FailOnceEngine:
  1187. def __init__(self, engine):
  1188. self.engine = engine
  1189. self.state = {"armed": True}
  1190. def connect(self):
  1191. return self.engine.connect()
  1192. def begin(self):
  1193. return FailOnceBegin(
  1194. self.engine.begin(),
  1195. self.state,
  1196. )
  1197. sample_crash_writer = PostgresRuleEvidenceWriter(
  1198. FailOnceEngine(platform),
  1199. store,
  1200. sample_ttl_seconds=900,
  1201. )
  1202. sample_crash_run_id = sample_crash_writer.start(
  1203. component_binding_id=component_binding_id,
  1204. rule_version_id=rule_id,
  1205. plan_hash=compiled["plan_hash"],
  1206. correlation_id=sample_crash_correlation_id,
  1207. dataflow_uid=dataflow_uid,
  1208. deployment_id=deployment_id,
  1209. environment="test",
  1210. workflow_version=1,
  1211. node_id="task5_real_polars",
  1212. lease_owner=new_governance_uid(),
  1213. )
  1214. sample_crash_evidence = {
  1215. "status": "success",
  1216. "rows_in": 1,
  1217. "rows_out": 0,
  1218. "rows_rejected": 1,
  1219. "rows_quarantined": 0,
  1220. "commit_outcome": "committed",
  1221. "timings": {"duration_ms": 1},
  1222. "violation_sample": [{"mobile": "[REDACTED]"}],
  1223. "sample_count": 1,
  1224. "redaction_policy": "rule-violation-default-v1",
  1225. }
  1226. with pytest.raises(RuntimeError, match="outcome is unknown"):
  1227. sample_crash_writer.finish(
  1228. sample_crash_run_id,
  1229. sample_crash_evidence,
  1230. )
  1231. with platform.connect() as connection:
  1232. assert connection.execute(
  1233. text(
  1234. """
  1235. SELECT handoff_status
  1236. FROM public.rule_violation_samples
  1237. WHERE rule_run_id = CAST(:id AS uuid)
  1238. """
  1239. ),
  1240. {"id": sample_crash_run_id},
  1241. ).scalar_one() == "ready"
  1242. evidence_writer.finish(
  1243. sample_crash_run_id,
  1244. sample_crash_evidence,
  1245. )
  1246. assert evidence_writer.replay(sample_crash_run_id)[
  1247. "status"
  1248. ] == "success"
  1249. with platform.begin() as connection:
  1250. connection.execute(
  1251. text(
  1252. """
  1253. INSERT INTO public.dataflow_dataset_bindings
  1254. (id, dataflow_deployment_id, logical_ref,
  1255. data_source_uid, object_kind, object_ref,
  1256. schema_snapshot_id, dialect, access_mode, write_mode,
  1257. binding_hash)
  1258. VALUES (
  1259. CAST(:id AS uuid), CAST(:deployment_id AS uuid),
  1260. 'sql_receipt', CAST(:source_uid AS uuid), 'table',
  1261. 'public.task6_sql_receipt',
  1262. CAST(:schema_snapshot_id AS uuid), 'postgresql',
  1263. 'read_write', 'upsert', :binding_hash
  1264. )
  1265. """
  1266. ),
  1267. {
  1268. "id": sql_binding_id,
  1269. "deployment_id": deployment_id,
  1270. "source_uid": output_binding["data_source_uid"],
  1271. "schema_snapshot_id": output_schema["id"],
  1272. "binding_hash": "b" * 64,
  1273. },
  1274. )
  1275. receipt_run_id = evidence_writer.start(
  1276. component_binding_id=component_binding_id,
  1277. rule_version_id=rule_id,
  1278. plan_hash=compiled["plan_hash"],
  1279. correlation_id=receipt_correlation_id,
  1280. dataflow_uid=dataflow_uid,
  1281. deployment_id=deployment_id,
  1282. environment="test",
  1283. workflow_version=1,
  1284. node_id="task5_real_polars",
  1285. lease_owner=new_governance_uid(),
  1286. )
  1287. receipt = evidence_writer.stage_sql_output(
  1288. receipt_run_id,
  1289. output_binding_id=sql_binding_id,
  1290. )
  1291. assert re.fullmatch(
  1292. r"dataops-staging://[0-9a-f-]{36}",
  1293. receipt,
  1294. )
  1295. with pytest.raises(ValueError, match="not executable"):
  1296. evidence_writer.resolve_sql_staging(
  1297. receipt,
  1298. deployment_id=deployment_id,
  1299. correlation_id=receipt_correlation_id,
  1300. input_binding_id=sql_binding_id,
  1301. )
  1302. receipt_evidence = {
  1303. "status": "success",
  1304. "rows_in": 2,
  1305. "rows_out": 2,
  1306. "rows_rejected": 0,
  1307. "rows_quarantined": 0,
  1308. "commit_outcome": "committed",
  1309. "timings": {"duration_ms": 1},
  1310. "public_result": {
  1311. "rows_in": 2,
  1312. "rows_out": 2,
  1313. "rows_rejected": 0,
  1314. "rows_quarantined": 0,
  1315. "commit_outcome": "committed",
  1316. },
  1317. }
  1318. evidence_writer.finish(receipt_run_id, receipt_evidence)
  1319. evidence_writer.finish(receipt_run_id, receipt_evidence)
  1320. with pytest.raises(ValueError, match="immutable"):
  1321. evidence_writer.finish(
  1322. receipt_run_id,
  1323. {
  1324. **receipt_evidence,
  1325. "rows_out": 1,
  1326. },
  1327. )
  1328. resolved_receipt = evidence_writer.resolve_sql_staging(
  1329. receipt,
  1330. deployment_id=deployment_id,
  1331. correlation_id=receipt_correlation_id,
  1332. input_binding_id=sql_binding_id,
  1333. )
  1334. assert resolved_receipt["relation_ref"] == (
  1335. "public.task6_sql_receipt"
  1336. )
  1337. assert len(resolved_receipt["relation_digest"]) == 64
  1338. with pytest.raises(ValueError, match="not executable"):
  1339. evidence_writer.resolve_sql_staging(
  1340. receipt,
  1341. deployment_id=deployment_id,
  1342. correlation_id=new_governance_uid(),
  1343. input_binding_id=sql_binding_id,
  1344. )
  1345. with pytest.raises(ValueError, match="invalid"):
  1346. evidence_writer.resolve_sql_staging(
  1347. "dataops-staging://public.task6_sql_receipt",
  1348. deployment_id=deployment_id,
  1349. correlation_id=receipt_correlation_id,
  1350. input_binding_id=sql_binding_id,
  1351. )
  1352. failed_receipt_run_id = evidence_writer.start(
  1353. component_binding_id=component_binding_id,
  1354. rule_version_id=rule_id,
  1355. plan_hash=compiled["plan_hash"],
  1356. correlation_id=failed_receipt_correlation_id,
  1357. dataflow_uid=dataflow_uid,
  1358. deployment_id=deployment_id,
  1359. environment="test",
  1360. workflow_version=1,
  1361. node_id="task5_real_polars",
  1362. lease_owner=new_governance_uid(),
  1363. )
  1364. failed_receipt = evidence_writer.stage_sql_output(
  1365. failed_receipt_run_id,
  1366. output_binding_id=sql_binding_id,
  1367. )
  1368. evidence_writer.finish(
  1369. failed_receipt_run_id,
  1370. {
  1371. "status": "failed",
  1372. "commit_outcome": "not_committed",
  1373. "timings": {"duration_ms": 1},
  1374. },
  1375. )
  1376. with pytest.raises(ValueError, match="not executable"):
  1377. evidence_writer.resolve_sql_staging(
  1378. failed_receipt,
  1379. deployment_id=deployment_id,
  1380. correlation_id=failed_receipt_correlation_id,
  1381. input_binding_id=sql_binding_id,
  1382. )
  1383. conflict_path = tmp_path / "conflict.parquet"
  1384. pl.DataFrame(
  1385. {
  1386. "customer_id": [99],
  1387. "mobile": ["13800138000"],
  1388. "name": ["Conflict"],
  1389. "segment_code": ["A"],
  1390. "segment_name": ["Gold"],
  1391. "version_no": [1],
  1392. }
  1393. ).write_parquet(conflict_path)
  1394. object_count_before_conflict = len(
  1395. list(minio.list_objects(bucket, prefix=prefix, recursive=True))
  1396. )
  1397. with pytest.raises(ValueError, match="immutable|digest"):
  1398. resolver.publish_path(
  1399. str(conflict_path),
  1400. binding_id=output_binding["id"],
  1401. binding_hash=compiled["plan"]["output_binding_hash"],
  1402. correlation_id=correlation_id,
  1403. kind="output",
  1404. ttl_seconds=900,
  1405. schema_fields=output_schema["fields"],
  1406. limits=compiled["plan"]["resource_limits"],
  1407. )
  1408. assert len(
  1409. list(minio.list_objects(bucket, prefix=prefix, recursive=True))
  1410. ) == object_count_before_conflict
  1411. assert customer_artifact["digest"]
  1412. assert segment_artifact["digest"]
  1413. with platform.connect() as connection:
  1414. catalog_rows = connection.execute(
  1415. text(
  1416. """
  1417. SELECT artifact_kind, artifact_ref, handoff_status,
  1418. binding_hash
  1419. FROM public.rule_run_artifacts
  1420. WHERE correlation_id = CAST(:correlation_id AS uuid)
  1421. ORDER BY artifact_kind
  1422. """
  1423. ),
  1424. {"correlation_id": correlation_id},
  1425. ).mappings().all()
  1426. # The repeated deterministic output has the same digest and is
  1427. # idempotently retained as one stable catalog handoff.
  1428. assert len(catalog_rows) == 4
  1429. assert all(row["handoff_status"] == "ready" for row in catalog_rows)
  1430. assert all(len(row["binding_hash"]) == 64 for row in catalog_rows)
  1431. assert {
  1432. row["artifact_ref"]
  1433. for row in catalog_rows
  1434. if row["artifact_kind"] == "output"
  1435. } == {
  1436. result["artifact_ref"],
  1437. downstream_result["artifact_ref"],
  1438. }
  1439. assert len(
  1440. list(minio.list_objects(bucket, prefix=prefix, recursive=True))
  1441. ) == 5
  1442. finally:
  1443. for item in list(
  1444. minio.list_objects(bucket, prefix=prefix, recursive=True)
  1445. ):
  1446. minio.remove_object(bucket, item.object_name)
  1447. assert list(
  1448. minio.list_objects(bucket, prefix=prefix, recursive=True)
  1449. ) == []
  1450. with postgres.begin() as connection:
  1451. connection.execute(text(f"DROP TABLE IF EXISTS {customer_table}"))
  1452. with mysql.begin() as connection:
  1453. connection.execute(text(f"DROP TABLE IF EXISTS {segment_table}"))
  1454. with platform.begin() as connection:
  1455. if ledger_jti is not None:
  1456. connection.execute(
  1457. text(
  1458. "DELETE FROM public.runner_task_executions "
  1459. "WHERE token_jti = CAST(:jti AS uuid)"
  1460. ),
  1461. {"jti": ledger_jti},
  1462. )
  1463. if retry_ledger_jti is not None:
  1464. connection.execute(
  1465. text(
  1466. "DELETE FROM public.runner_task_executions "
  1467. "WHERE token_jti = CAST(:jti AS uuid)"
  1468. ),
  1469. {"jti": retry_ledger_jti},
  1470. )
  1471. connection.execute(
  1472. text(
  1473. """
  1474. DELETE FROM public.rule_sql_staging_receipts
  1475. WHERE correlation_id IN (
  1476. CAST(:receipt_correlation_id AS uuid),
  1477. CAST(:failed_receipt_correlation_id AS uuid)
  1478. )
  1479. """
  1480. ),
  1481. {
  1482. "receipt_correlation_id": receipt_correlation_id,
  1483. "failed_receipt_correlation_id": (
  1484. failed_receipt_correlation_id
  1485. ),
  1486. },
  1487. )
  1488. connection.execute(
  1489. text(
  1490. """
  1491. DELETE FROM public.rule_violation_samples s
  1492. USING public.rule_runs r
  1493. WHERE s.rule_run_id = r.id
  1494. AND r.correlation_id IN (
  1495. CAST(:correlation_id AS uuid),
  1496. CAST(:sample_crash_correlation_id AS uuid)
  1497. )
  1498. """
  1499. ),
  1500. {
  1501. "correlation_id": correlation_id,
  1502. "sample_crash_correlation_id": (
  1503. sample_crash_correlation_id
  1504. ),
  1505. },
  1506. )
  1507. connection.execute(
  1508. text(
  1509. "DELETE FROM public.rule_runs "
  1510. "WHERE correlation_id IN ("
  1511. "CAST(:correlation_id AS uuid), "
  1512. "CAST(:failure_correlation_id AS uuid), "
  1513. "CAST(:unknown_correlation_id AS uuid), "
  1514. "CAST(:lease_correlation_id AS uuid), "
  1515. "CAST(:sample_crash_correlation_id AS uuid), "
  1516. "CAST(:receipt_correlation_id AS uuid), "
  1517. "CAST(:failed_receipt_correlation_id AS uuid))"
  1518. ),
  1519. {
  1520. "correlation_id": correlation_id,
  1521. "failure_correlation_id": failure_correlation_id,
  1522. "unknown_correlation_id": unknown_correlation_id,
  1523. "lease_correlation_id": lease_correlation_id,
  1524. "sample_crash_correlation_id": (
  1525. sample_crash_correlation_id
  1526. ),
  1527. "receipt_correlation_id": receipt_correlation_id,
  1528. "failed_receipt_correlation_id": (
  1529. failed_receipt_correlation_id
  1530. ),
  1531. },
  1532. )
  1533. connection.execute(
  1534. text(
  1535. "DELETE FROM public.dataflow_dataset_bindings "
  1536. "WHERE id = CAST(:id AS uuid)"
  1537. ),
  1538. {"id": sql_binding_id},
  1539. )
  1540. connection.execute(
  1541. text(
  1542. "DELETE FROM public.rule_run_artifacts "
  1543. "WHERE correlation_id = CAST(:correlation_id AS uuid)"
  1544. ),
  1545. {"correlation_id": correlation_id},
  1546. )
  1547. connection.execute(
  1548. text(
  1549. "DELETE FROM public.rule_execution_plans "
  1550. "WHERE id IN (CAST(:id AS uuid), "
  1551. "CAST(:downstream_id AS uuid))"
  1552. ),
  1553. {
  1554. "id": plan_id,
  1555. "downstream_id": downstream_plan_id,
  1556. },
  1557. )
  1558. connection.execute(
  1559. text(
  1560. "DELETE FROM public.dataflow_dataset_bindings "
  1561. "WHERE dataflow_deployment_id = CAST(:id AS uuid)"
  1562. ),
  1563. {"id": deployment_id},
  1564. )
  1565. connection.execute(
  1566. text(
  1567. "DELETE FROM public.dataflow_component_bindings "
  1568. "WHERE id IN (CAST(:id AS uuid), "
  1569. "CAST(:downstream_id AS uuid))"
  1570. ),
  1571. {
  1572. "id": component_binding_id,
  1573. "downstream_id": downstream_component_binding_id,
  1574. },
  1575. )
  1576. connection.execute(
  1577. text(
  1578. "DELETE FROM public.dataflow_deployments "
  1579. "WHERE id = CAST(:id AS uuid)"
  1580. ),
  1581. {"id": deployment_id},
  1582. )
  1583. connection.execute(
  1584. text(
  1585. "DELETE FROM public.dataflow_versions "
  1586. "WHERE id = CAST(:id AS uuid)"
  1587. ),
  1588. {"id": dataflow_version_id},
  1589. )
  1590. connection.execute(
  1591. text(
  1592. "DELETE FROM public.data_rule_versions "
  1593. "WHERE id IN (CAST(:id AS uuid), "
  1594. "CAST(:downstream_id AS uuid))"
  1595. ),
  1596. {
  1597. "id": rule_id,
  1598. "downstream_id": downstream_rule_id,
  1599. },
  1600. )
  1601. connection.execute(
  1602. text(
  1603. "DELETE FROM public.data_rules "
  1604. "WHERE rule_uid IN (CAST(:rule_uid AS uuid), "
  1605. "CAST(:downstream_rule_uid AS uuid))"
  1606. ),
  1607. {
  1608. "rule_uid": rule_uid,
  1609. "downstream_rule_uid": downstream_rule_uid,
  1610. },
  1611. )
  1612. for schema in (input_schema, lookup_schema, output_schema):
  1613. connection.execute(
  1614. text(
  1615. "DELETE FROM public.data_schema_snapshots "
  1616. "WHERE id = CAST(:id AS uuid)"
  1617. ),
  1618. {"id": schema["id"]},
  1619. )
  1620. postgres.dispose()
  1621. mysql.dispose()
  1622. platform.dispose()