test_data_rule_polars_execution.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. from __future__ import annotations
  2. import re
  3. from pathlib import Path
  4. import polars as pl
  5. from minio import Minio
  6. from sqlalchemy import create_engine, text
  7. from app.core.common.identifiers import new_governance_uid
  8. from app.core.data_rules.contracts import rule_spec_hash, validate_rule_spec
  9. from app.core.data_rules.execution_contracts import canonical_schema_hash
  10. COMPOSE = (
  11. Path(__file__).resolve().parents[2]
  12. / "deploy"
  13. / "docker"
  14. / "docker-compose.yml"
  15. )
  16. def _compose_value(pattern):
  17. source = COMPOSE.read_text(encoding="utf-8")
  18. match = re.search(pattern, source, flags=re.DOTALL)
  19. assert match is not None
  20. return match.group(1)
  21. def _schema(schema_ref, fields):
  22. normalized = [
  23. {"name": name, "type": field_type, "nullable": nullable}
  24. for name, field_type, nullable in fields
  25. ]
  26. return {
  27. "id": new_governance_uid(),
  28. "schema_ref": schema_ref,
  29. "schema_hash": canonical_schema_hash(normalized),
  30. "fields": normalized,
  31. "source_revision": "task5:real-cross-source",
  32. }
  33. def _binding(schema, *, source_uid, access_mode, object_ref):
  34. return {
  35. "id": new_governance_uid(),
  36. "data_source_uid": source_uid,
  37. "object_kind": "parquet_artifact",
  38. "object_ref": object_ref,
  39. "schema_snapshot_id": schema["id"],
  40. "access_mode": access_mode,
  41. "dialect": "parquet",
  42. "write_mode": "append",
  43. }
  44. class Resolver:
  45. def __init__(self, artifacts):
  46. self.artifacts = artifacts
  47. def resolve(self, *, binding_id, correlation_id):
  48. artifact = self.artifacts[binding_id]
  49. assert f"/rules/{correlation_id}/" in artifact["artifact_ref"]
  50. return artifact
  51. def test_real_postgres_mysql_minio_polars_cross_source_execution():
  52. from app.core.data_rules.compilers.polars import PolarsRuleCompiler
  53. from app.runner.artifacts import ArtifactStore
  54. from app.runner.rule_polars import PolarsRulePlanAdapter
  55. source_user = _compose_value(
  56. r"source-postgres:.*?POSTGRES_USER:\s*([^\s]+)"
  57. )
  58. source_password = _compose_value(
  59. r"source-postgres:.*?POSTGRES_PASSWORD:\s*([^\s]+)"
  60. )
  61. postgres_port = _compose_value(r'"(25432):5432"')
  62. mysql_port = _compose_value(r'"(23306):3306"')
  63. minio_user = _compose_value(r"MINIO_ROOT_USER:\s*([^\s]+)")
  64. minio_password = _compose_value(r"MINIO_ROOT_PASSWORD:\s*([^\s]+)")
  65. minio_port = _compose_value(r'"(19000):9000"')
  66. bucket = _compose_value(r"mc mb --ignore-existing local/([^\s]+)")
  67. postgres = create_engine(
  68. f"postgresql+psycopg2://{source_user}:{source_password}"
  69. f"@127.0.0.1:{postgres_port}/acceptance",
  70. pool_pre_ping=True,
  71. )
  72. mysql = create_engine(
  73. f"mysql+pymysql://{source_user}:{source_password}"
  74. f"@127.0.0.1:{mysql_port}/acceptance",
  75. pool_pre_ping=True,
  76. )
  77. minio = Minio(
  78. f"127.0.0.1:{minio_port}",
  79. access_key=minio_user,
  80. secret_key=minio_password,
  81. secure=False,
  82. )
  83. store = ArtifactStore(
  84. minio,
  85. bucket=bucket,
  86. max_artifact_bytes=4 * 1024 * 1024,
  87. max_rows=1_000,
  88. memory_limit_bytes=16 * 1024 * 1024,
  89. max_ttl_seconds=3600,
  90. )
  91. correlation_id = new_governance_uid()
  92. prefix = f"rules/{correlation_id}/"
  93. customer_table = "task5_polars_customers"
  94. segment_table = "task5_polars_segments"
  95. try:
  96. with postgres.begin() as connection:
  97. connection.execute(text(f"DROP TABLE IF EXISTS {customer_table}"))
  98. connection.execute(
  99. text(
  100. f"CREATE TABLE {customer_table} ("
  101. "customer_id BIGINT NOT NULL, "
  102. "name VARCHAR(100), mobile VARCHAR(30), "
  103. "segment_code VARCHAR(20), version_no BIGINT NOT NULL)"
  104. )
  105. )
  106. connection.execute(
  107. text(
  108. f"INSERT INTO {customer_table} "
  109. "(customer_id, name, mobile, segment_code, version_no) "
  110. "VALUES "
  111. "(1, ' Alice ', '13800138000', 'A', 1), "
  112. "(1, ' Alice Updated ', '13800138000', 'A', 2), "
  113. "(2, ' Bad ', 'invalid', 'B', 1), "
  114. "(3, ' Carol ', '13900139000', 'C', 1)"
  115. )
  116. )
  117. with mysql.begin() as connection:
  118. connection.execute(text(f"DROP TABLE IF EXISTS {segment_table}"))
  119. connection.execute(
  120. text(
  121. f"CREATE TABLE {segment_table} ("
  122. "code VARCHAR(20) PRIMARY KEY, "
  123. "segment_name VARCHAR(100) NOT NULL)"
  124. )
  125. )
  126. connection.execute(
  127. text(
  128. f"INSERT INTO {segment_table} (code, segment_name) "
  129. "VALUES ('A', 'Gold'), ('B', 'Basic'), ('C', 'Silver')"
  130. )
  131. )
  132. with postgres.connect() as connection:
  133. customer_rows = [
  134. dict(row)
  135. for row in connection.execute(
  136. text(
  137. f"SELECT customer_id, name, mobile, "
  138. f"segment_code, version_no FROM {customer_table}"
  139. )
  140. ).mappings()
  141. ]
  142. with mysql.connect() as connection:
  143. segment_rows = [
  144. dict(row)
  145. for row in connection.execute(
  146. text(
  147. f"SELECT code, segment_name FROM {segment_table}"
  148. )
  149. ).mappings()
  150. ]
  151. customer_artifact = store.write(
  152. pl.DataFrame(customer_rows).lazy(), correlation_id, 900
  153. )
  154. segment_artifact = store.write(
  155. pl.DataFrame(segment_rows).lazy(), correlation_id, 900
  156. )
  157. input_schema = _schema(
  158. "bd:task5:customer:raw",
  159. [
  160. ("customer_id", "integer", False),
  161. ("name", "string", True),
  162. ("mobile", "string", True),
  163. ("segment_code", "string", True),
  164. ("version_no", "integer", False),
  165. ],
  166. )
  167. lookup_schema = _schema(
  168. "bd:task5:segment:lookup",
  169. [
  170. ("code", "string", False),
  171. ("segment_name", "string", False),
  172. ],
  173. )
  174. output_schema = _schema(
  175. "bd:task5:customer:enriched",
  176. [
  177. ("customer_id", "integer", False),
  178. ("name", "string", True),
  179. ("mobile", "string", True),
  180. ("segment_code", "string", True),
  181. ("version_no", "integer", False),
  182. ("segment_name", "string", True),
  183. ],
  184. )
  185. input_binding = _binding(
  186. input_schema,
  187. source_uid=new_governance_uid(),
  188. access_mode="read",
  189. object_ref="postgres-customer-artifact",
  190. )
  191. lookup_binding = _binding(
  192. lookup_schema,
  193. source_uid=new_governance_uid(),
  194. access_mode="read",
  195. object_ref="mysql-segment-artifact",
  196. )
  197. output_binding = _binding(
  198. output_schema,
  199. source_uid=new_governance_uid(),
  200. access_mode="write",
  201. object_ref="polars-output-artifact",
  202. )
  203. spec = validate_rule_spec(
  204. {
  205. "schema_version": "2.0",
  206. "rule_uid": new_governance_uid(),
  207. "name": "task5_real_cross_source",
  208. "input_schema_ref": input_schema["schema_ref"],
  209. "output_schema_ref": output_schema["schema_ref"],
  210. "steps": [
  211. {
  212. "id": "normalize_name",
  213. "op": "normalize_text",
  214. "column": "name",
  215. "trim": True,
  216. },
  217. {
  218. "id": "join_segment",
  219. "op": "lookup_join",
  220. "lookup": {
  221. "binding_id": lookup_binding["id"],
  222. "left_on": ["segment_code"],
  223. "right_on": ["code"],
  224. "select": {
  225. "segment_name": "segment_name"
  226. },
  227. "how": "left",
  228. },
  229. },
  230. {
  231. "id": "valid_mobile",
  232. "op": "assert",
  233. "expression": "matches(mobile, '^[0-9]{11}$')",
  234. "on_failure": "reject",
  235. "severity": "error",
  236. },
  237. {
  238. "id": "latest_customer",
  239. "op": "deduplicate",
  240. "keys": ["customer_id"],
  241. "order_by": ["version_no"],
  242. "keep": "last",
  243. },
  244. ],
  245. "null_policy": "explicit",
  246. "timezone": "Asia/Shanghai",
  247. }
  248. )
  249. rule = {
  250. "id": new_governance_uid(),
  251. "status": "published",
  252. "rule_spec": spec,
  253. "spec_hash": rule_spec_hash(spec),
  254. }
  255. compiled = PolarsRuleCompiler().compile(
  256. rule_version=rule,
  257. input_schema=input_schema,
  258. output_schema=output_schema,
  259. input_binding=input_binding,
  260. output_binding=output_binding,
  261. backend={
  262. "max_rows": 1_000,
  263. "max_artifact_bytes": 4 * 1024 * 1024,
  264. "memory_limit_bytes": 16 * 1024 * 1024,
  265. "masking_policies": {},
  266. "lookup_bindings": {
  267. lookup_binding["id"]: {
  268. "binding": lookup_binding,
  269. "schema": lookup_schema,
  270. }
  271. },
  272. },
  273. )
  274. lookup_operation = compiled["plan"]["operations"][1]
  275. resolver = Resolver(
  276. {
  277. input_binding["id"]: {
  278. **customer_artifact,
  279. "binding_hash": compiled["plan"][
  280. "input_binding_hash"
  281. ],
  282. },
  283. lookup_binding["id"]: {
  284. **segment_artifact,
  285. "binding_hash": lookup_operation[
  286. "lookup_binding_hash"
  287. ],
  288. },
  289. }
  290. )
  291. node = {
  292. "id": "task5_real_polars",
  293. "type": "rule.apply",
  294. "purpose": "write",
  295. "idempotency": {
  296. "strategy": "deduplication_key",
  297. "key": "customer_id",
  298. },
  299. "config": {
  300. "component_binding_id": new_governance_uid(),
  301. "rule_version_id": rule["id"],
  302. "execution_plan_hash": compiled["plan_hash"],
  303. },
  304. }
  305. result = PolarsRulePlanAdapter(
  306. artifact_store=store,
  307. artifact_resolver=resolver,
  308. artifact_ttl_seconds=900,
  309. ).execute(
  310. plan=compiled["plan"],
  311. node=node,
  312. parameters={},
  313. write_authorized=True,
  314. correlation_id=correlation_id,
  315. )
  316. assert result["rows_in"] == 4
  317. assert result["rows_out"] == 2
  318. assert result["rows_rejected"] == 2
  319. assert result["violation_count"] == 1
  320. assert result["violations"] == [
  321. {"step_id": "valid_mobile", "count": 1}
  322. ]
  323. output = store.read(
  324. result["artifact_ref"], result["digest"]
  325. ).collect()
  326. assert output.sort("customer_id").to_dicts() == [
  327. {
  328. "customer_id": 1,
  329. "mobile": "13800138000",
  330. "name": "Alice Updated",
  331. "segment_code": "A",
  332. "segment_name": "Gold",
  333. "version_no": 2,
  334. },
  335. {
  336. "customer_id": 3,
  337. "mobile": "13900139000",
  338. "name": "Carol",
  339. "segment_code": "C",
  340. "segment_name": "Silver",
  341. "version_no": 1,
  342. },
  343. ]
  344. assert all(
  345. item.object_name.startswith(prefix)
  346. for item in minio.list_objects(
  347. bucket, prefix=prefix, recursive=True
  348. )
  349. )
  350. finally:
  351. for item in list(
  352. minio.list_objects(bucket, prefix=prefix, recursive=True)
  353. ):
  354. minio.remove_object(bucket, item.object_name)
  355. assert list(
  356. minio.list_objects(bucket, prefix=prefix, recursive=True)
  357. ) == []
  358. with postgres.begin() as connection:
  359. connection.execute(text(f"DROP TABLE IF EXISTS {customer_table}"))
  360. with mysql.begin() as connection:
  361. connection.execute(text(f"DROP TABLE IF EXISTS {segment_table}"))
  362. postgres.dispose()
  363. mysql.dispose()