test_data_rule_polars_execution.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from pathlib import Path
  5. import polars as pl
  6. import pytest
  7. from minio import Minio
  8. from sqlalchemy import create_engine, text
  9. from app.core.common.identifiers import new_governance_uid
  10. from app.core.data_rules.contracts import rule_spec_hash, validate_rule_spec
  11. from app.core.data_rules.execution_contracts import canonical_schema_hash
  12. COMPOSE = (
  13. Path(__file__).resolve().parents[2]
  14. / "deploy"
  15. / "docker"
  16. / "docker-compose.yml"
  17. )
  18. def _compose_value(pattern):
  19. source = COMPOSE.read_text(encoding="utf-8")
  20. match = re.search(pattern, source, flags=re.DOTALL)
  21. assert match is not None
  22. return match.group(1)
  23. def _schema(schema_ref, fields):
  24. normalized = [
  25. {"name": name, "type": field_type, "nullable": nullable}
  26. for name, field_type, nullable in fields
  27. ]
  28. return {
  29. "id": new_governance_uid(),
  30. "schema_ref": schema_ref,
  31. "schema_hash": canonical_schema_hash(normalized),
  32. "fields": normalized,
  33. "source_revision": "task5:real-cross-source",
  34. }
  35. def _binding(schema, *, source_uid, access_mode, object_ref):
  36. return {
  37. "id": new_governance_uid(),
  38. "data_source_uid": source_uid,
  39. "object_kind": "parquet_artifact",
  40. "object_ref": object_ref,
  41. "schema_snapshot_id": schema["id"],
  42. "access_mode": access_mode,
  43. "dialect": "parquet",
  44. "write_mode": "append",
  45. }
  46. def test_real_postgres_mysql_minio_polars_cross_source_execution(tmp_path):
  47. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  48. from app.runner.artifacts import ArtifactStore, PostgresArtifactResolver
  49. from app.runner.rule_polars import PolarsRulePlanAdapter
  50. from app.runner.rules import PostgresRulePlanRepository, RulePlanExecutor
  51. source_user = _compose_value(
  52. r"source-postgres:.*?POSTGRES_USER:\s*([^\s]+)"
  53. )
  54. source_password = _compose_value(
  55. r"source-postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
  56. )
  57. platform_user = _compose_value(
  58. r"\n postgres:.*?POSTGRES_USER:\s*([^\s]+)"
  59. )
  60. platform_password = _compose_value(
  61. r"\n postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
  62. )
  63. postgres_port = _compose_value(r'"(25432):5432"')
  64. mysql_port = _compose_value(r'"(23306):3306"')
  65. minio_user = _compose_value(r"MINIO_ROOT_USER:\s*([^\s]+)")
  66. minio_password = _compose_value(r"MINIO_ROOT_PASSWORD:\s*([^\s]+)")
  67. minio_port = _compose_value(r'"(19000):9000"')
  68. platform_port = _compose_value(r'"(15432):5432"')
  69. bucket = _compose_value(r"mc mb --ignore-existing local/([^\s]+)")
  70. postgres = create_engine(
  71. f"postgresql+psycopg2://{source_user}:{source_password}"
  72. f"@127.0.0.1:{postgres_port}/acceptance",
  73. pool_pre_ping=True,
  74. )
  75. mysql = create_engine(
  76. f"mysql+pymysql://{source_user}:{source_password}"
  77. f"@127.0.0.1:{mysql_port}/acceptance",
  78. pool_pre_ping=True,
  79. )
  80. platform = create_engine(
  81. f"postgresql+psycopg2://{platform_user}:{platform_password}"
  82. f"@127.0.0.1:{platform_port}/dataops",
  83. pool_pre_ping=True,
  84. )
  85. minio = Minio(
  86. f"127.0.0.1:{minio_port}",
  87. access_key=minio_user,
  88. secret_key=minio_password,
  89. secure=False,
  90. )
  91. store = ArtifactStore(
  92. minio,
  93. bucket=bucket,
  94. max_artifact_bytes=4 * 1024 * 1024,
  95. max_rows=1_000,
  96. memory_limit_bytes=256 * 1024 * 1024,
  97. max_ttl_seconds=3600,
  98. )
  99. correlation_id = new_governance_uid()
  100. prefix = f"rules/{correlation_id}/"
  101. customer_table = "task5_polars_customers"
  102. segment_table = "task5_polars_segments"
  103. rule_uid = new_governance_uid()
  104. rule_id = new_governance_uid()
  105. dataflow_uid = new_governance_uid()
  106. dataflow_version_id = new_governance_uid()
  107. deployment_id = new_governance_uid()
  108. component_binding_id = new_governance_uid()
  109. plan_id = new_governance_uid()
  110. ledger_jti = None
  111. try:
  112. with postgres.begin() as connection:
  113. connection.execute(text(f"DROP TABLE IF EXISTS {customer_table}"))
  114. connection.execute(
  115. text(
  116. f"CREATE TABLE {customer_table} ("
  117. "customer_id BIGINT NOT NULL, "
  118. "name VARCHAR(100), mobile VARCHAR(30), "
  119. "segment_code VARCHAR(20), version_no BIGINT NOT NULL)"
  120. )
  121. )
  122. connection.execute(
  123. text(
  124. f"INSERT INTO {customer_table} "
  125. "(customer_id, name, mobile, segment_code, version_no) "
  126. "VALUES "
  127. "(1, ' Alice ', '13800138000', 'A', 1), "
  128. "(1, ' Alice Updated ', '13800138000', 'A', 2), "
  129. "(2, ' Bad ', 'invalid', 'B', 1), "
  130. "(3, ' Carol ', '13900139000', 'C', 1)"
  131. )
  132. )
  133. with mysql.begin() as connection:
  134. connection.execute(text(f"DROP TABLE IF EXISTS {segment_table}"))
  135. connection.execute(
  136. text(
  137. f"CREATE TABLE {segment_table} ("
  138. "code VARCHAR(20) PRIMARY KEY, "
  139. "segment_name VARCHAR(100) NOT NULL)"
  140. )
  141. )
  142. connection.execute(
  143. text(
  144. f"INSERT INTO {segment_table} (code, segment_name) "
  145. "VALUES ('A', 'Gold'), ('B', 'Basic'), ('C', 'Silver')"
  146. )
  147. )
  148. with postgres.connect() as connection:
  149. customer_rows = [
  150. dict(row)
  151. for row in connection.execute(
  152. text(
  153. f"SELECT customer_id, name, mobile, "
  154. f"segment_code, version_no FROM {customer_table}"
  155. )
  156. ).mappings()
  157. ]
  158. with mysql.connect() as connection:
  159. segment_rows = [
  160. dict(row)
  161. for row in connection.execute(
  162. text(
  163. f"SELECT code, segment_name FROM {segment_table}"
  164. )
  165. ).mappings()
  166. ]
  167. input_schema = _schema(
  168. "bd:task5:customer:raw",
  169. [
  170. ("customer_id", "integer", False),
  171. ("name", "string", True),
  172. ("mobile", "string", True),
  173. ("segment_code", "string", True),
  174. ("version_no", "integer", False),
  175. ],
  176. )
  177. lookup_schema = _schema(
  178. "bd:task5:segment:lookup",
  179. [
  180. ("code", "string", False),
  181. ("segment_name", "string", False),
  182. ],
  183. )
  184. output_schema = _schema(
  185. "bd:task5:customer:enriched",
  186. [
  187. ("customer_id", "integer", False),
  188. ("name", "string", True),
  189. ("mobile", "string", True),
  190. ("segment_code", "string", True),
  191. ("version_no", "integer", False),
  192. ("segment_name", "string", True),
  193. ],
  194. )
  195. input_binding = _binding(
  196. input_schema,
  197. source_uid=new_governance_uid(),
  198. access_mode="read",
  199. object_ref="postgres-customer-artifact",
  200. )
  201. lookup_binding = _binding(
  202. lookup_schema,
  203. source_uid=new_governance_uid(),
  204. access_mode="read",
  205. object_ref="mysql-segment-artifact",
  206. )
  207. output_binding = _binding(
  208. output_schema,
  209. source_uid=new_governance_uid(),
  210. access_mode="write",
  211. object_ref="polars-output-artifact",
  212. )
  213. spec = validate_rule_spec(
  214. {
  215. "schema_version": "2.0",
  216. "rule_uid": new_governance_uid(),
  217. "name": "task5_real_cross_source",
  218. "input_schema_ref": input_schema["schema_ref"],
  219. "output_schema_ref": output_schema["schema_ref"],
  220. "steps": [
  221. {
  222. "id": "normalize_name",
  223. "op": "normalize_text",
  224. "column": "name",
  225. "trim": True,
  226. },
  227. {
  228. "id": "join_segment",
  229. "op": "lookup_join",
  230. "lookup": {
  231. "binding_id": lookup_binding["id"],
  232. "left_on": ["segment_code"],
  233. "right_on": ["code"],
  234. "select": {
  235. "segment_name": "segment_name"
  236. },
  237. "how": "left",
  238. },
  239. },
  240. {
  241. "id": "valid_mobile",
  242. "op": "assert",
  243. "expression": "matches(mobile, '^[0-9]{11}$')",
  244. "on_failure": "reject",
  245. "severity": "error",
  246. },
  247. {
  248. "id": "latest_customer",
  249. "op": "deduplicate",
  250. "keys": ["customer_id"],
  251. "order_by": ["version_no"],
  252. "keep": "last",
  253. },
  254. ],
  255. "null_policy": "explicit",
  256. "timezone": "Asia/Shanghai",
  257. }
  258. )
  259. rule = {
  260. "id": rule_id,
  261. "status": "published",
  262. "rule_spec": spec,
  263. "spec_hash": rule_spec_hash(spec),
  264. }
  265. compiled = PolarsRuleCompiler().compile(
  266. rule_version=rule,
  267. input_schema=input_schema,
  268. output_schema=output_schema,
  269. input_binding=input_binding,
  270. output_binding=output_binding,
  271. backend={
  272. "max_rows": 1_000,
  273. "max_artifact_bytes": 4 * 1024 * 1024,
  274. "memory_limit_bytes": 256 * 1024 * 1024,
  275. "masking_policies": {},
  276. "lookup_bindings": {
  277. lookup_binding["id"]: {
  278. "binding": lookup_binding,
  279. "schema": lookup_schema,
  280. }
  281. },
  282. },
  283. )
  284. lookup_operation = compiled["plan"]["operations"][1]
  285. schema_hashes = {
  286. "rule_spec_hash": compiled["plan"]["rule_spec_hash"],
  287. "input_schema_snapshot_id": input_schema["id"],
  288. "input_schema_hash": input_schema["schema_hash"],
  289. "output_schema_snapshot_id": output_schema["id"],
  290. "output_schema_hash": output_schema["schema_hash"],
  291. }
  292. with platform.begin() as connection:
  293. for schema in (input_schema, lookup_schema, output_schema):
  294. connection.execute(
  295. text(
  296. """
  297. INSERT INTO public.data_schema_snapshots
  298. (id, schema_ref, schema_hash, fields, source_revision)
  299. VALUES (CAST(:id AS uuid), :schema_ref, :schema_hash,
  300. CAST(:fields AS jsonb), :source_revision)
  301. """
  302. ),
  303. {**schema, "fields": json.dumps(schema["fields"])},
  304. )
  305. connection.execute(
  306. text(
  307. """
  308. INSERT INTO public.data_rules
  309. (id, rule_uid, name, category, status)
  310. VALUES (CAST(:id AS uuid), CAST(:rule_uid AS uuid),
  311. :name, 'general', 'active')
  312. """
  313. ),
  314. {
  315. "id": new_governance_uid(),
  316. "rule_uid": rule_uid,
  317. "name": spec["name"],
  318. },
  319. )
  320. connection.execute(
  321. text(
  322. """
  323. INSERT INTO public.data_rule_versions
  324. (id, rule_uid, version_no, source_text, source_language,
  325. rule_spec, spec_hash, generated_kind, status, published_at)
  326. VALUES (CAST(:id AS uuid), CAST(:rule_uid AS uuid), 1,
  327. :source_text, 'en', CAST(:rule_spec AS jsonb),
  328. :spec_hash, 'polars', 'published',
  329. CURRENT_TIMESTAMP)
  330. """
  331. ),
  332. {
  333. "id": rule_id,
  334. "rule_uid": rule_uid,
  335. "source_text": "Task 5 real cross-source integration",
  336. "rule_spec": json.dumps(spec),
  337. "spec_hash": rule["spec_hash"],
  338. },
  339. )
  340. connection.execute(
  341. text(
  342. """
  343. INSERT INTO public.dataflow_versions
  344. (id, dataflow_uid, version_no, name, dataflow_spec,
  345. input_schema_hashes, output_schema_hash, status,
  346. released_at)
  347. VALUES (CAST(:id AS uuid), CAST(:dataflow_uid AS uuid), 1,
  348. :name, '{}'::jsonb,
  349. CAST(:input_schema_hashes AS jsonb),
  350. :output_schema_hash, 'released', CURRENT_TIMESTAMP)
  351. """
  352. ),
  353. {
  354. "id": dataflow_version_id,
  355. "dataflow_uid": dataflow_uid,
  356. "name": "Task 5 real cross-source integration",
  357. "input_schema_hashes": json.dumps(
  358. [
  359. input_schema["schema_hash"],
  360. lookup_schema["schema_hash"],
  361. ]
  362. ),
  363. "output_schema_hash": output_schema["schema_hash"],
  364. },
  365. )
  366. connection.execute(
  367. text(
  368. """
  369. INSERT INTO public.dataflow_deployments
  370. (id, dataflow_version_id, environment, deployment_config,
  371. status, activated_at)
  372. VALUES (CAST(:id AS uuid),
  373. CAST(:dataflow_version_id AS uuid), 'test',
  374. '{}'::jsonb, 'active', CURRENT_TIMESTAMP)
  375. """
  376. ),
  377. {
  378. "id": deployment_id,
  379. "dataflow_version_id": dataflow_version_id,
  380. },
  381. )
  382. for logical_ref, binding, binding_hash in (
  383. (
  384. "customers",
  385. input_binding,
  386. compiled["plan"]["input_binding_hash"],
  387. ),
  388. (
  389. "segments",
  390. lookup_binding,
  391. lookup_operation["lookup_binding_hash"],
  392. ),
  393. (
  394. "enriched",
  395. output_binding,
  396. compiled["plan"]["output_binding_hash"],
  397. ),
  398. ):
  399. connection.execute(
  400. text(
  401. """
  402. INSERT INTO public.dataflow_dataset_bindings
  403. (id, dataflow_deployment_id, logical_ref,
  404. data_source_uid, object_kind, object_ref,
  405. schema_snapshot_id, dialect, access_mode, write_mode,
  406. binding_hash)
  407. VALUES (CAST(:id AS uuid), CAST(:deployment_id AS uuid),
  408. :logical_ref, CAST(:source_uid AS uuid),
  409. 'parquet_artifact', :object_ref,
  410. CAST(:schema_snapshot_id AS uuid), 'parquet',
  411. :access_mode, 'append', :binding_hash)
  412. """
  413. ),
  414. {
  415. "id": binding["id"],
  416. "deployment_id": deployment_id,
  417. "logical_ref": logical_ref,
  418. "source_uid": binding["data_source_uid"],
  419. "object_ref": binding["object_ref"],
  420. "schema_snapshot_id": binding["schema_snapshot_id"],
  421. "access_mode": binding["access_mode"],
  422. "binding_hash": binding_hash,
  423. },
  424. )
  425. connection.execute(
  426. text(
  427. """
  428. INSERT INTO public.dataflow_component_bindings
  429. (id, dataflow_version_id, component_id, component_kind,
  430. rule_version_id, stage, order_no, idempotency, provenance)
  431. VALUES (CAST(:id AS uuid),
  432. CAST(:dataflow_version_id AS uuid),
  433. 'task5_real_polars', 'rule.apply',
  434. CAST(:rule_version_id AS uuid), 'transform', 0,
  435. CAST(:idempotency AS jsonb), '{}'::jsonb)
  436. """
  437. ),
  438. {
  439. "id": component_binding_id,
  440. "dataflow_version_id": dataflow_version_id,
  441. "rule_version_id": rule_id,
  442. "idempotency": json.dumps(
  443. {
  444. "strategy": "deduplication_key",
  445. "key": "customer_id",
  446. }
  447. ),
  448. },
  449. )
  450. connection.execute(
  451. text(
  452. """
  453. INSERT INTO public.rule_execution_plans
  454. (id, component_binding_id, backend, compiler_version, plan,
  455. plan_hash, schema_hashes, status)
  456. VALUES (CAST(:id AS uuid),
  457. CAST(:component_binding_id AS uuid),
  458. 'polars_batch', :compiler_version,
  459. CAST(:plan AS jsonb), :plan_hash,
  460. CAST(:schema_hashes AS jsonb), 'published')
  461. """
  462. ),
  463. {
  464. "id": plan_id,
  465. "component_binding_id": component_binding_id,
  466. "compiler_version": compiled["compiler_version"],
  467. "plan": json.dumps(compiled["plan"]),
  468. "plan_hash": compiled["plan_hash"],
  469. "schema_hashes": json.dumps(schema_hashes),
  470. },
  471. )
  472. customer_path = tmp_path / "customers.parquet"
  473. segment_path = tmp_path / "segments.parquet"
  474. pl.DataFrame(customer_rows).write_parquet(customer_path)
  475. pl.DataFrame(segment_rows).write_parquet(segment_path)
  476. resolver = PostgresArtifactResolver(platform, store)
  477. customer_artifact = resolver.publish_path(
  478. str(customer_path),
  479. binding_id=input_binding["id"],
  480. binding_hash=compiled["plan"]["input_binding_hash"],
  481. correlation_id=correlation_id,
  482. kind="input",
  483. ttl_seconds=900,
  484. schema_fields=input_schema["fields"],
  485. limits=compiled["plan"]["resource_limits"],
  486. )
  487. segment_artifact = resolver.publish_path(
  488. str(segment_path),
  489. binding_id=lookup_binding["id"],
  490. binding_hash=lookup_operation["lookup_binding_hash"],
  491. correlation_id=correlation_id,
  492. kind="lookup",
  493. ttl_seconds=900,
  494. schema_fields=lookup_schema["fields"],
  495. limits=compiled["plan"]["resource_limits"],
  496. )
  497. node = {
  498. "id": "task5_real_polars",
  499. "type": "rule.apply",
  500. "purpose": "write",
  501. "idempotency": {
  502. "strategy": "deduplication_key",
  503. "key": "customer_id",
  504. },
  505. "config": {
  506. "component_binding_id": component_binding_id,
  507. "rule_version_id": rule["id"],
  508. "execution_plan_hash": compiled["plan_hash"],
  509. },
  510. }
  511. executor = RulePlanExecutor(
  512. PostgresRulePlanRepository(platform),
  513. adapters={
  514. "polars_batch": PolarsRulePlanAdapter(
  515. artifact_store=store,
  516. artifact_resolver=resolver,
  517. artifact_ttl_seconds=900,
  518. )
  519. },
  520. )
  521. result = executor.execute(
  522. node,
  523. {},
  524. write_authorized=True,
  525. correlation_id=correlation_id,
  526. )
  527. repeated = executor.execute(
  528. node,
  529. {},
  530. write_authorized=True,
  531. correlation_id=correlation_id,
  532. )
  533. assert result["rows_in"] == 4
  534. assert result["rows_out"] == 2
  535. assert result["rows_rejected"] == 1
  536. assert result["rows_deduplicated"] == 1
  537. assert result["rows_filtered"] == 0
  538. assert result["rows_join_dropped"] == 0
  539. assert result["rows_aggregated"] == 0
  540. assert result["violation_count"] == 1
  541. assert result["violations"] == [
  542. {"step_id": "valid_mobile", "count": 1}
  543. ]
  544. output = store.read(
  545. result["artifact_ref"],
  546. result["digest"],
  547. expected_schema_fields=output_schema["fields"],
  548. limits=compiled["plan"]["resource_limits"],
  549. ).collect()
  550. assert output.sort("customer_id").to_dicts() == [
  551. {
  552. "customer_id": 1,
  553. "mobile": "13800138000",
  554. "name": "Alice Updated",
  555. "segment_code": "A",
  556. "segment_name": "Gold",
  557. "version_no": 2,
  558. },
  559. {
  560. "customer_id": 3,
  561. "mobile": "13900139000",
  562. "name": "Carol",
  563. "segment_code": "C",
  564. "segment_name": "Silver",
  565. "version_no": 1,
  566. },
  567. ]
  568. assert all(
  569. item.object_name.startswith(prefix)
  570. for item in minio.list_objects(
  571. bucket, prefix=prefix, recursive=True
  572. )
  573. )
  574. assert repeated["rows_out"] == 2
  575. assert repeated["artifact_ref"] == result["artifact_ref"]
  576. assert "schema_fields" not in result
  577. from app.runner.api import create_runner_app
  578. from app.runner.auth import TaskTokenIssuer, TaskTokenVerifier
  579. from app.runner.ledger import PostgresTaskLedger
  580. from app.runner.nodes import NodeRegistry
  581. task_secret = "task5-real-http-secret-value-32-bytes"
  582. verifier = TaskTokenVerifier(task_secret)
  583. task_token = TaskTokenIssuer(task_secret).issue(
  584. task_uid=new_governance_uid(),
  585. dataflow_uid=dataflow_uid,
  586. workflow_version=1,
  587. correlation_id=correlation_id,
  588. node=node,
  589. write_authorized=True,
  590. )
  591. ledger_jti = verifier.verify(task_token, node=node).jti
  592. runner_app = create_runner_app(
  593. verifier=verifier,
  594. ledger=PostgresTaskLedger(platform),
  595. registry=NodeRegistry({"rule.apply": executor}),
  596. )
  597. with runner_app.test_client() as client:
  598. http_result = client.post(
  599. "/v1/tasks/execute",
  600. json={
  601. "task_token": task_token,
  602. "node": node,
  603. "parameters": {},
  604. },
  605. )
  606. replay = client.post(
  607. "/v1/tasks/execute",
  608. json={
  609. "task_token": task_token,
  610. "node": node,
  611. "parameters": {},
  612. },
  613. )
  614. assert http_result.status_code == 200
  615. assert http_result.get_json()["result"]["artifact_ref"] == result[
  616. "artifact_ref"
  617. ]
  618. assert replay.status_code == 409
  619. ledger_record = PostgresTaskLedger(platform).get(ledger_jti)
  620. assert ledger_record is not None
  621. assert ledger_record.status == "success"
  622. assert ledger_record.commit_outcome == "committed"
  623. conflict_path = tmp_path / "conflict.parquet"
  624. pl.DataFrame(
  625. {
  626. "customer_id": [99],
  627. "mobile": ["13800138000"],
  628. "name": ["Conflict"],
  629. "segment_code": ["A"],
  630. "segment_name": ["Gold"],
  631. "version_no": [1],
  632. }
  633. ).write_parquet(conflict_path)
  634. object_count_before_conflict = len(
  635. list(minio.list_objects(bucket, prefix=prefix, recursive=True))
  636. )
  637. with pytest.raises(ValueError, match="immutable|digest"):
  638. resolver.publish_path(
  639. str(conflict_path),
  640. binding_id=output_binding["id"],
  641. binding_hash=compiled["plan"]["output_binding_hash"],
  642. correlation_id=correlation_id,
  643. kind="output",
  644. ttl_seconds=900,
  645. schema_fields=output_schema["fields"],
  646. limits=compiled["plan"]["resource_limits"],
  647. )
  648. assert len(
  649. list(minio.list_objects(bucket, prefix=prefix, recursive=True))
  650. ) == object_count_before_conflict
  651. assert customer_artifact["digest"]
  652. assert segment_artifact["digest"]
  653. with platform.connect() as connection:
  654. catalog_rows = connection.execute(
  655. text(
  656. """
  657. SELECT artifact_kind, artifact_ref, handoff_status,
  658. binding_hash
  659. FROM public.rule_run_artifacts
  660. WHERE correlation_id = CAST(:correlation_id AS uuid)
  661. ORDER BY artifact_kind
  662. """
  663. ),
  664. {"correlation_id": correlation_id},
  665. ).mappings().all()
  666. # The repeated deterministic output has the same digest and is
  667. # idempotently retained as one stable catalog handoff.
  668. assert len(catalog_rows) == 3
  669. assert all(row["handoff_status"] == "ready" for row in catalog_rows)
  670. assert all(len(row["binding_hash"]) == 64 for row in catalog_rows)
  671. assert next(
  672. row["artifact_ref"]
  673. for row in catalog_rows
  674. if row["artifact_kind"] == "output"
  675. ) == result["artifact_ref"]
  676. assert len(
  677. list(minio.list_objects(bucket, prefix=prefix, recursive=True))
  678. ) == 3
  679. finally:
  680. for item in list(
  681. minio.list_objects(bucket, prefix=prefix, recursive=True)
  682. ):
  683. minio.remove_object(bucket, item.object_name)
  684. assert list(
  685. minio.list_objects(bucket, prefix=prefix, recursive=True)
  686. ) == []
  687. with postgres.begin() as connection:
  688. connection.execute(text(f"DROP TABLE IF EXISTS {customer_table}"))
  689. with mysql.begin() as connection:
  690. connection.execute(text(f"DROP TABLE IF EXISTS {segment_table}"))
  691. with platform.begin() as connection:
  692. if ledger_jti is not None:
  693. connection.execute(
  694. text(
  695. "DELETE FROM public.runner_task_executions "
  696. "WHERE token_jti = CAST(:jti AS uuid)"
  697. ),
  698. {"jti": ledger_jti},
  699. )
  700. connection.execute(
  701. text(
  702. "DELETE FROM public.rule_run_artifacts "
  703. "WHERE correlation_id = CAST(:correlation_id AS uuid)"
  704. ),
  705. {"correlation_id": correlation_id},
  706. )
  707. connection.execute(
  708. text(
  709. "DELETE FROM public.rule_execution_plans "
  710. "WHERE id = CAST(:id AS uuid)"
  711. ),
  712. {"id": plan_id},
  713. )
  714. connection.execute(
  715. text(
  716. "DELETE FROM public.dataflow_dataset_bindings "
  717. "WHERE dataflow_deployment_id = CAST(:id AS uuid)"
  718. ),
  719. {"id": deployment_id},
  720. )
  721. connection.execute(
  722. text(
  723. "DELETE FROM public.dataflow_component_bindings "
  724. "WHERE id = CAST(:id AS uuid)"
  725. ),
  726. {"id": component_binding_id},
  727. )
  728. connection.execute(
  729. text(
  730. "DELETE FROM public.dataflow_deployments "
  731. "WHERE id = CAST(:id AS uuid)"
  732. ),
  733. {"id": deployment_id},
  734. )
  735. connection.execute(
  736. text(
  737. "DELETE FROM public.dataflow_versions "
  738. "WHERE id = CAST(:id AS uuid)"
  739. ),
  740. {"id": dataflow_version_id},
  741. )
  742. connection.execute(
  743. text(
  744. "DELETE FROM public.data_rule_versions "
  745. "WHERE id = CAST(:id AS uuid)"
  746. ),
  747. {"id": rule_id},
  748. )
  749. connection.execute(
  750. text(
  751. "DELETE FROM public.data_rules "
  752. "WHERE rule_uid = CAST(:rule_uid AS uuid)"
  753. ),
  754. {"rule_uid": rule_uid},
  755. )
  756. for schema in (input_schema, lookup_schema, output_schema):
  757. connection.execute(
  758. text(
  759. "DELETE FROM public.data_schema_snapshots "
  760. "WHERE id = CAST(:id AS uuid)"
  761. ),
  762. {"id": schema["id"]},
  763. )
  764. postgres.dispose()
  765. mysql.dispose()
  766. platform.dispose()