| # |
| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with |
| # this work for additional information regarding copyright ownership. |
| # The ASF licenses this file to You under the Apache License, Version 2.0 |
| # (the "License"); you may not use this file except in compliance with |
| # the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| from pyspark.sql import Row, Observation, functions as F |
| from pyspark.sql.types import StructType, LongType |
| from pyspark.errors import ( |
| AnalysisException, |
| PySparkAssertionError, |
| PySparkException, |
| PySparkTypeError, |
| PySparkValueError, |
| ) |
| from pyspark.testing.sqlutils import ReusedSQLTestCase |
| from pyspark.testing.utils import assertDataFrameEqual, eventually |
| |
| |
| class DataFrameObservationTestsMixin: |
| def test_observe(self): |
| # SPARK-36263: tests the DataFrame.observe(Observation, *Column) method |
| df = self.spark.createDataFrame( |
| [ |
| (1, 1.0, "one"), |
| (2, 2.0, "two"), |
| (3, 3.0, "three"), |
| ], |
| ["id", "val", "label"], |
| ) |
| |
| unnamed_observation = Observation() |
| named_observation = Observation("metric") |
| |
| with self.assertRaises(PySparkAssertionError) as pe: |
| unnamed_observation.get() |
| |
| self.check_error( |
| exception=pe.exception, |
| errorClass="NO_OBSERVE_BEFORE_GET", |
| messageParameters={}, |
| ) |
| |
| observed = ( |
| df.orderBy("id") |
| .observe( |
| named_observation, |
| F.count(F.lit(1)).alias("cnt"), |
| F.sum(F.col("id")).alias("sum"), |
| F.mean(F.col("val")).alias("mean"), |
| ) |
| .observe(unnamed_observation, F.count(F.lit(1)).alias("rows")) |
| ) |
| |
| # test that observe works transparently |
| actual = observed.collect() |
| self.assertEqual( |
| [ |
| {"id": 1, "val": 1.0, "label": "one"}, |
| {"id": 2, "val": 2.0, "label": "two"}, |
| {"id": 3, "val": 3.0, "label": "three"}, |
| ], |
| [row.asDict() for row in actual], |
| ) |
| |
| # test that we retrieve the metrics |
| self.assertEqual(named_observation.get, dict(cnt=3, sum=6, mean=2.0)) |
| self.assertEqual(unnamed_observation.get, dict(rows=3)) |
| |
| with self.assertRaises(PySparkAssertionError) as pe: |
| df.observe(named_observation, F.count(F.lit(1)).alias("count")) |
| |
| self.check_error( |
| exception=pe.exception, |
| errorClass="REUSE_OBSERVATION", |
| messageParameters={}, |
| ) |
| |
| new_observation = Observation("metric") |
| with self.assertRaises(AnalysisException) as pe: |
| observed.observe(new_observation, 2 * F.count(F.lit(1)).alias("cnt")).collect() |
| |
| self.check_error( |
| exception=pe.exception, |
| errorClass="DUPLICATED_METRICS_NAME", |
| messageParameters={"metricName": "metric"}, |
| ) |
| |
| # observation requires name (if given) to be non empty string |
| with self.assertRaisesRegex(PySparkTypeError, "`name` should be str, got int"): |
| Observation(123) |
| with self.assertRaisesRegex(ValueError, "`name` must be a non-empty string, got ''."): |
| Observation("") |
| |
| # dataframe.observe requires at least one expr |
| with self.assertRaises(PySparkValueError) as pe: |
| df.observe(Observation()) |
| |
| self.check_error( |
| exception=pe.exception, |
| errorClass="CANNOT_BE_EMPTY", |
| messageParameters={"item": "exprs"}, |
| ) |
| |
| # dataframe.observe requires non-None Columns |
| for args in [(None,), ("id",), (F.lit(1), None), (F.lit(1), "id")]: |
| with self.subTest(args=args): |
| with self.assertRaises(PySparkTypeError) as pe: |
| df.observe(Observation(), *args) |
| |
| self.check_error( |
| exception=pe.exception, |
| errorClass="NOT_EXPECTED_TYPE", |
| messageParameters={ |
| "expected_type": "list[Column]", |
| "arg_name": "exprs", |
| "arg_type": "tuple", |
| }, |
| ) |
| |
| def test_observe_str(self): |
| # SPARK-38760: tests the DataFrame.observe(str, *Column) method |
| from pyspark.sql.streaming import StreamingQueryListener |
| |
| observed_metrics = None |
| |
| class TestListener(StreamingQueryListener): |
| def onQueryStarted(self, event): |
| pass |
| |
| def onQueryProgress(self, event): |
| nonlocal observed_metrics |
| observed_metrics = event.progress.observedMetrics |
| |
| def onQueryIdle(self, event): |
| pass |
| |
| def onQueryTerminated(self, event): |
| pass |
| |
| self.spark.streams.addListener(TestListener()) |
| |
| df = self.spark.readStream.format("rate").option("rowsPerSecond", 10).load() |
| df = df.observe( |
| "metric", F.count(F.lit(1)).alias("cnt"), F.sum(F.col("value")).alias("sum") |
| ) |
| q = df.writeStream.format("noop").queryName("test").start() |
| self.assertTrue(q.isActive) |
| |
| @eventually(timeout=10, catch_assertions=True) |
| def check_observed_metrics(): |
| self.assertTrue(isinstance(observed_metrics, dict)) |
| self.assertTrue("metric" in observed_metrics) |
| row = observed_metrics["metric"] |
| self.assertIsInstance(row.cnt, int) |
| self.assertIsInstance(row.sum, int) |
| self.assertGreaterEqual(row.cnt, 0) |
| self.assertGreaterEqual(row.sum, 0) |
| return True |
| |
| check_observed_metrics() |
| |
| q.stop() |
| |
| def test_observe_with_same_name_on_different_dataframe(self): |
| # SPARK-45656: named observations with the same name on different datasets |
| observation1 = Observation("named") |
| df1 = self.spark.range(50) |
| observed_df1 = df1.observe(observation1, F.count(F.lit(1)).alias("cnt")) |
| |
| observation2 = Observation("named") |
| df2 = self.spark.range(100) |
| observed_df2 = df2.observe(observation2, F.count(F.lit(1)).alias("cnt")) |
| |
| observed_df1.collect() |
| observed_df2.collect() |
| |
| self.assertEqual(observation1.get, dict(cnt=50)) |
| self.assertEqual(observation2.get, dict(cnt=100)) |
| |
| def test_observe_on_commands(self): |
| df = self.spark.range(50) |
| schema = StructType().add("id", LongType(), nullable=False) |
| |
| test_table = "test_table" |
| |
| # DataFrameWriter |
| for cache_enabled in [False, True]: |
| with ( |
| self.subTest(cache_enabled=cache_enabled), |
| self.sql_conf({"spark.connect.session.planCache.enabled": cache_enabled}), |
| ): |
| for command, action in [ |
| ("collect", lambda df: df.collect()), |
| ("show", lambda df: df.show(50)), |
| ("save", lambda df: df.write.format("noop").mode("overwrite").save()), |
| ("create", lambda df: df.writeTo(test_table).using("parquet").create()), |
| ]: |
| for select_star in [True, False]: |
| with ( |
| self.subTest(command=command, select_star=select_star), |
| self.table(test_table), |
| ): |
| observation = Observation() |
| observed_df = df.observe(observation, F.count(F.lit(1)).alias("cnt")) |
| if select_star: |
| observed_df = observed_df.select("*") |
| self.assertEqual(observed_df.schema, schema) |
| action(observed_df) |
| self.assertEqual(observation.get, dict(cnt=50)) |
| |
| def test_observe_with_struct_type(self): |
| observation = Observation("struct") |
| |
| df = self.spark.range(10).observe( |
| observation, |
| F.struct(F.count(F.lit(1)).alias("rows"), F.max("id").alias("maxid")).alias("struct"), |
| ) |
| |
| assertDataFrameEqual(df, [Row(id=id) for id in range(10)]) |
| |
| self.assertEqual(observation.get, {"struct": Row(rows=10, maxid=9)}) |
| |
| def test_observe_with_array_type(self): |
| observation = Observation("array") |
| |
| df = self.spark.range(10).observe( |
| observation, |
| F.array(F.count(F.lit(1))).alias("array"), |
| ) |
| |
| assertDataFrameEqual(df, [Row(id=id) for id in range(10)]) |
| |
| self.assertEqual(observation.get, {"array": [10]}) |
| |
| def test_observe_with_map_type(self): |
| observation = Observation("map") |
| |
| df = self.spark.range(10).observe( |
| observation, |
| F.create_map(F.lit("count"), F.count(F.lit(1))).alias("map"), |
| ) |
| |
| assertDataFrameEqual(df, [Row(id=id) for id in range(10)]) |
| |
| self.assertEqual(observation.get, {"map": {"count": 10}}) |
| |
| def test_observation_errors_propagated_to_client(self): |
| observation = Observation("test_observation") |
| observed_df = self.spark.range(10).observe( |
| observation, |
| F.sum("id").alias("sum_id"), |
| F.raise_error(F.lit("test error")).alias("raise_error"), |
| ) |
| actual = observed_df.collect() |
| self.assertEqual( |
| [row.asDict() for row in actual], |
| [{"id": i} for i in range(10)], |
| ) |
| |
| with self.assertRaises(PySparkException) as cm: |
| _ = observation.get |
| |
| self.assertIn("test error", str(cm.exception)) |
| |
| def test_observe_self_join(self): |
| # SPARK-56322: self-joining an observed DataFrame |
| obs = Observation("my_observation") |
| df = ( |
| self.spark.range(100) |
| .selectExpr("id", "CASE WHEN id < 10 THEN 'A' ELSE 'B' END AS group_key") |
| .observe(obs, F.count(F.lit(1)).alias("row_count")) |
| ) |
| |
| df1 = df.where("id < 20") |
| df2 = df.where("id % 2 == 0") |
| |
| joined = df1.alias("a").join(df2.alias("b"), on=["id"], how="inner") |
| result = joined.collect() |
| |
| # The join should produce rows where id < 20 AND id is even |
| expected_ids = sorted([i for i in range(20) if i % 2 == 0]) |
| actual_ids = sorted([row.id for row in result]) |
| self.assertEqual(actual_ids, expected_ids) |
| |
| # The observation should have been collected |
| self.assertEqual(obs.get, {"row_count": 100}) |
| |
| # Check the error conditions |
| with self.assertRaises(PySparkAssertionError) as pe: |
| joined.observe(obs, F.count(F.lit(1)).alias("row_count")).collect() |
| |
| self.check_error( |
| exception=pe.exception, |
| errorClass="REUSE_OBSERVATION", |
| messageParameters={}, |
| ) |
| |
| obs2 = Observation("my_observation") |
| with self.assertRaises(AnalysisException) as pe: |
| joined.observe(obs2, 2 * F.count(F.lit(1)).alias("row_count")).collect() |
| |
| self.check_error( |
| exception=pe.exception, |
| errorClass="DUPLICATED_METRICS_NAME", |
| messageParameters={"metricName": "my_observation"}, |
| ) |
| |
| def test_observe_lateral_join(self): |
| # SPARK-56322: lateral self-joining an observed DataFrame |
| obs = Observation("lateral_join_observation") |
| df = self.spark.range(50).observe(obs, F.count(F.lit(1)).alias("row_count")) |
| |
| joined = ( |
| df.alias("left") |
| .lateralJoin( |
| df.alias("right"), on=F.expr("right.id between left.id - 1 and left.id + 1") |
| ) |
| .selectExpr("left.id as left_id", "right.id as right_id") |
| ) |
| result = joined.collect() |
| |
| # Joins on row 0 should produce rows 0 and 1 |
| bounded_matches = sorted([r.right_id for r in result if r.left_id == 0]) |
| self.assertEqual(bounded_matches, [0, 1]) |
| |
| # Joins on row 25 should produce rows 24, 25, and 26 |
| unbounded_matches = sorted([r.right_id for r in result if r.left_id == 25]) |
| self.assertEqual(unbounded_matches, [24, 25, 26]) |
| |
| # The observation should have been collected |
| self.assertEqual(obs.get, {"row_count": 50}) |
| |
| # Check the error conditions |
| with self.assertRaises(PySparkAssertionError) as reused: |
| joined.observe(obs, F.count(F.lit(1)).alias("row_count")).collect() |
| |
| self.check_error( |
| exception=reused.exception, |
| errorClass="REUSE_OBSERVATION", |
| messageParameters={}, |
| ) |
| |
| obs2 = Observation("lateral_join_observation") |
| with self.assertRaises(AnalysisException) as pe: |
| joined.observe(obs2, F.count(2 * F.lit(1)).alias("row_count")).collect() |
| |
| self.check_error( |
| exception=pe.exception, |
| errorClass="DUPLICATED_METRICS_NAME", |
| messageParameters={"metricName": "lateral_join_observation"}, |
| ) |
| |
| def test_observe_self_join_union(self): |
| # SPARK-56322: union of observed DataFrames with same observation |
| obs = Observation("union_obs") |
| df = self.spark.range(50).observe(obs, F.count(F.lit(1)).alias("cnt")) |
| |
| df1 = df.where("id < 25") |
| df2 = df.where("id >= 25") |
| |
| unioned = df1.union(df2) |
| result = unioned.collect() |
| |
| actual_ids = sorted([row.id for row in result]) |
| self.assertEqual(actual_ids, list(range(50))) |
| self.assertEqual(obs.get, {"cnt": 50}) |
| |
| |
| class DataFrameObservationTests( |
| DataFrameObservationTestsMixin, |
| ReusedSQLTestCase, |
| ): |
| pass |
| |
| |
| if __name__ == "__main__": |
| from pyspark.testing import main |
| |
| main() |