Repository: feast-dev/feast

Issue:
BigQuerySource does not validate whether the table exists or not
## Expected Behavior 

When specifying an invalid table, feast should surface an error that explicitly states this.


## Current Behavior

We currently get an error that implies that the schema cannot be parsed.

```
$ feast apply
Traceback (most recent call last):
  File "/Users/achal/tecton/feast/.direnv/python-3.7.10/bin/feast", line 33, in <module>
    sys.exit(load_entry_point('feast', 'console_scripts', 'feast')())
  File "/Users/achal/tecton/feast/.direnv/python-3.7.10/lib/python3.7/site-packages/click/core.py", line 829, in __call__
    return self.main(*args, **kwargs)
  File "/Users/achal/tecton/feast/.direnv/python-3.7.10/lib/python3.7/site-packages/click/core.py", line 782, in main
    rv = self.invoke(ctx)
  File "/Users/achal/tecton/feast/.direnv/python-3.7.10/lib/python3.7/site-packages/click/core.py", line 1259, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/Users/achal/tecton/feast/.direnv/python-3.7.10/lib/python3.7/site-packages/click/core.py", line 1066, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/Users/achal/tecton/feast/.direnv/python-3.7.10/lib/python3.7/site-packages/click/core.py", line 610, in invoke
    return callback(*args, **kwargs)
  File "/Users/achal/tecton/feast/.direnv/python-3.7.10/lib/python3.7/site-packages/click/decorators.py", line 21, in new_func
    return f(get_current_context(), *args, **kwargs)
  File "/Users/achal/tecton/feast/sdk/python/feast/cli.py", line 192, in apply_total_command
    apply_total(repo_config, repo)
  File "/Users/achal/tecton/feast/sdk/python/feast/telemetry.py", line 151, in exception_logging_wrapper
    result = func(*args, **kwargs)
  File "/Users/achal/tecton/feast/sdk/python/feast/repo_operations.py", line 131, in apply_total
    repo = parse_repo(repo_path)
  File "/Users/achal/tecton/feast/sdk/python/feast/repo_operations.py", line 97, in parse_repo
    module = importlib.import_module(module_path)
  File "/usr/local/opt/python@3.7/Frameworks/Python.framework/Versions/3.7/lib/python3.7/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1006, in _gcd_import
  File "<frozen importlib._bootstrap>", line 983, in _find_and_load
  File "<frozen importlib._bootstrap>", line 967, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 677, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 728, in exec_module
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "/Users/achal/tecton/feast/splendid_lark/example.py", line 46, in <module>
    table_ref="feast-oss.demo_data.driver_activity"
  File "/Users/achal/tecton/feast/sdk/python/feast/data_source.py", line 677, in __init__
    or self._infer_event_timestamp_column("TIMESTAMP|DATETIME"),
  File "/Users/achal/tecton/feast/sdk/python/feast/data_source.py", line 547, in _infer_event_timestamp_column
    """
TypeError:
                    Unable to infer DataSource event_timestamp_column due to an absence of columns that satisfy the criteria.
                     Please specify event_timestamp_column explicitly.
```

## Steps to reproduce

- Create a feature view with an invalid BigQuery source.
```

driver_stats_fv = FeatureView(
    name="driver_activity",
    entities=["driver_id"],
    input=BigQuerySource(
        table_ref="feast-oss.demo_data.driver_activity"
    )
)

```
and then run `feast apply`

### Specifications

- Version:
- Platform:
- Subsystem:

## Possible Solution

Run `client.get_table` as validation before any other operations.


Discussion:
I'll be working on this, supervised by David Liu @mavysavydav from Twitter
Sounds good!

Resolving patch:
diff --git a/sdk/python/feast/data_source.py b/sdk/python/feast/data_source.py
--- a/sdk/python/feast/data_source.py
+++ b/sdk/python/feast/data_source.py
@@ -20,6 +20,7 @@
 
 from feast import type_map
 from feast.data_format import FileFormat, StreamFormat
+from feast.errors import DataSourceNotFoundException
 from feast.protos.feast.core.DataSource_pb2 import DataSource as DataSourceProto
 from feast.value_type import ValueType
 
@@ -519,6 +520,12 @@ def to_proto(self) -> DataSourceProto:
         """
         raise NotImplementedError
 
+    def validate(self):
+        """
+        Validates the underlying data source.
+        """
+        raise NotImplementedError
+
 
 class FileSource(DataSource):
     def __init__(
@@ -615,6 +622,10 @@ def to_proto(self) -> DataSourceProto:
 
         return data_source_proto
 
+    def validate(self):
+        # TODO: validate a FileSource
+        pass
+
     @staticmethod
     def source_datatype_to_feast_value_type() -> Callable[[str], ValueType]:
         return type_map.pa_to_feast_value_type
@@ -692,6 +703,17 @@ def to_proto(self) -> DataSourceProto:
 
         return data_source_proto
 
+    def validate(self):
+        if not self.query:
+            from google.api_core.exceptions import NotFound
+            from google.cloud import bigquery
+
+            client = bigquery.Client()
+            try:
+                client.get_table(self.table_ref)
+            except NotFound:
+                raise DataSourceNotFoundException(self.table_ref)
+
     def get_table_query_string(self) -> str:
         """Returns a string that can directly be used to reference this table in SQL"""
         if self.table_ref:
diff --git a/sdk/python/feast/errors.py b/sdk/python/feast/errors.py
--- a/sdk/python/feast/errors.py
+++ b/sdk/python/feast/errors.py
@@ -3,6 +3,13 @@
 from colorama import Fore, Style
 
 
+class DataSourceNotFoundException(Exception):
+    def __init__(self, path):
+        super().__init__(
+            f"Unable to find table at '{path}'. Please check that table exists."
+        )
+
+
 class FeastObjectNotFoundException(Exception):
     pass
 
diff --git a/sdk/python/feast/repo_operations.py b/sdk/python/feast/repo_operations.py
--- a/sdk/python/feast/repo_operations.py
+++ b/sdk/python/feast/repo_operations.py
@@ -154,11 +154,12 @@ def apply_total(repo_config: RepoConfig, repo_path: Path):
 
     data_sources = [t.input for t in repo.feature_views]
 
-    # Make sure the data source used by this feature view is supported by
+    # Make sure the data source used by this feature view is supported by Feast
     for data_source in data_sources:
         assert_offline_store_supports_data_source(
             repo_config.offline_store, data_source
         )
+        data_source.validate()
 
     update_data_sources_with_inferred_event_timestamp_col(data_sources)
     for view in repo.feature_views:


Test patch:
diff --git a/sdk/python/tests/example_feature_repo_with_missing_bq_source.py b/sdk/python/tests/example_feature_repo_with_missing_bq_source.py
new file mode 100644
--- /dev/null
+++ b/sdk/python/tests/example_feature_repo_with_missing_bq_source.py
@@ -0,0 +1,20 @@
+from datetime import timedelta
+
+from feast import BigQuerySource, Entity, Feature, FeatureView, ValueType
+
+nonexistent_source = BigQuerySource(
+    table_ref="project.dataset.nonexistent_table", event_timestamp_column=""
+)
+
+driver = Entity(name="driver", value_type=ValueType.INT64, description="driver id",)
+
+nonexistent_features = FeatureView(
+    name="driver_locations",
+    entities=["driver"],
+    ttl=timedelta(days=1),
+    features=[
+        Feature(name="lat", dtype=ValueType.FLOAT),
+        Feature(name="lon", dtype=ValueType.STRING),
+    ],
+    input=nonexistent_source,
+)
diff --git a/sdk/python/tests/test_cli_gcp.py b/sdk/python/tests/test_cli_gcp.py
--- a/sdk/python/tests/test_cli_gcp.py
+++ b/sdk/python/tests/test_cli_gcp.py
@@ -53,3 +53,38 @@ def test_basic() -> None:
 
         result = runner.run(["teardown"], cwd=repo_path)
         assert result.returncode == 0
+
+
+@pytest.mark.integration
+def test_missing_bq_source_fail() -> None:
+    project_id = "".join(
+        random.choice(string.ascii_lowercase + string.digits) for _ in range(10)
+    )
+    runner = CliRunner()
+    with tempfile.TemporaryDirectory() as repo_dir_name, tempfile.TemporaryDirectory() as data_dir_name:
+
+        repo_path = Path(repo_dir_name)
+        data_path = Path(data_dir_name)
+
+        repo_config = repo_path / "feature_store.yaml"
+
+        repo_config.write_text(
+            dedent(
+                f"""
+        project: {project_id}
+        registry: {data_path / "registry.db"}
+        provider: gcp
+        """
+            )
+        )
+
+        repo_example = repo_path / "example.py"
+        repo_example.write_text(
+            (
+                Path(__file__).parent / "example_feature_repo_with_missing_bq_source.py"
+            ).read_text()
+        )
+
+        returncode, output = runner.run_with_output(["apply"], cwd=repo_path)
+        assert returncode == 1
+        assert b"DataSourceNotFoundException" in output
diff --git a/sdk/python/tests/test_cli_local.py b/sdk/python/tests/test_cli_local.py
--- a/sdk/python/tests/test_cli_local.py
+++ b/sdk/python/tests/test_cli_local.py
@@ -4,12 +4,14 @@
 from textwrap import dedent
 
 import assertpy
+import pytest
 
 from feast.feature_store import FeatureStore
 from tests.cli_utils import CliRunner
 from tests.online_read_write_test import basic_rw_test
 
 
+@pytest.mark.integration
 def test_workflow() -> None:
     """
     Test running apply on a sample repo, and make sure the infra gets created.
@@ -78,6 +80,7 @@ def test_workflow() -> None:
         assertpy.assert_that(result.returncode).is_equal_to(0)
 
 
+@pytest.mark.integration
 def test_non_local_feature_repo() -> None:
     """
     Test running apply on a sample repo, and make sure the infra gets created.
diff --git a/sdk/python/tests/test_online_retrieval.py b/sdk/python/tests/test_online_retrieval.py
--- a/sdk/python/tests/test_online_retrieval.py
+++ b/sdk/python/tests/test_online_retrieval.py
@@ -14,6 +14,7 @@
 from tests.cli_utils import CliRunner, get_example_repo
 
 
+@pytest.mark.integration
 def test_online() -> None:
     """
     Test reading from the online store in local mode.
@@ -238,6 +239,7 @@ def test_online() -> None:
         os.rename(store.config.registry + "_fake", store.config.registry)
 
 
+@pytest.mark.integration
 def test_online_to_df():
     """
     Test dataframe conversion. Make sure the response columns and rows are
diff --git a/sdk/python/tests/test_partial_apply.py b/sdk/python/tests/test_partial_apply.py
--- a/sdk/python/tests/test_partial_apply.py
+++ b/sdk/python/tests/test_partial_apply.py
@@ -1,3 +1,4 @@
+import pytest
 from google.protobuf.duration_pb2 import Duration
 
 from feast import BigQuerySource, Feature, FeatureView, ValueType
@@ -5,6 +6,7 @@
 from tests.online_read_write_test import basic_rw_test
 
 
+@pytest.mark.integration
 def test_partial() -> None:
     """
     Add another table to existing repo using partial apply API. Make sure both the table