Repository: dbt-labs/dbt-core

Issue:
dbt build: Correct non-fast-failing behavior to match other tasks such as run
`dbt build` is exiting earlier than it should when faced with a broken project.  This leads to an inconsistent user experience, and an incomplete process artifacts (`run_results.json`).  Work done to correct this seemed to lead into larger discussions about the expected behavior of the `fail-fast` flag. 

References:
- https://github.com/dbt-labs/dbt/pull/3490#pullrequestreview-705849186
- https://github.com/dbt-labs/dbt/pull/3490#pullrequestreview-709735773


Discussion:
> `dbt build` is exiting earlier than it should when faced with a broken project

It seems to exit as soon as it encounters any runtime error, including SQL errors when running models—so not a broken project per se. That means its logging output looks different from all other tasks, and it never writes `run_results.json`. Not good!

Sometimes that "instant exit on error" is a feature, not a bug—it should only be when the `-x, --fail-fast` flag is passed. Incidentally, `dbt build --fail-fast` seems to work just fine (and differently!) when it encounters an error. (Today, `--fail-fast` errors for all commands prevent writing the result artifact. Is that good as a general rule? Fair question: https://github.com/dbt-labs/dbt/issues/3600.)

What's surprising to me is how/why `dbt build` has different behavior from standard-issue tasks like `dbt run` and `dbt snapshot`, when I figure it inherits so much of its behavior from the base tasks.

Resolving patch:
diff --git a/core/dbt/task/build.py b/core/dbt/task/build.py
--- a/core/dbt/task/build.py
+++ b/core/dbt/task/build.py
@@ -1,6 +1,4 @@
-from .compile import CompileTask
-
-from .run import ModelRunner as run_model_runner
+from .run import RunTask, ModelRunner as run_model_runner
 from .snapshot import SnapshotRunner as snapshot_model_runner
 from .seed import SeedRunner as seed_runner
 from .test import TestRunner as test_runner
@@ -10,7 +8,7 @@
 from dbt.node_types import NodeType
 
 
-class BuildTask(CompileTask):
+class BuildTask(RunTask):
     """The Build task processes all assets of a given process and attempts to 'build'
     them in an opinionated fashion.  Every resource type outlined in RUNNER_MAP
     will be processed by the mapped runner class.
diff --git a/core/dbt/task/printer.py b/core/dbt/task/printer.py
--- a/core/dbt/task/printer.py
+++ b/core/dbt/task/printer.py
@@ -87,9 +87,12 @@ def print_hook_end_line(
 
 
 def print_skip_line(
-    model, schema: str, relation: str, index: int, num_models: int
+    node, schema: str, relation: str, index: int, num_models: int
 ) -> None:
-    msg = 'SKIP relation {}.{}'.format(schema, relation)
+    if node.resource_type in NodeType.refable():
+        msg = f'SKIP relation {schema}.{relation}'
+    else:
+        msg = f'SKIP {node.resource_type} {node.name}'
     print_fancy_output_line(
         msg, ui.yellow('SKIP'), logger.info, index, num_models)
 


Test patch:
diff --git a/test/integration/069_build_test/models-failing/model_0.sql b/test/integration/069_build_test/models-failing/model_0.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/069_build_test/models-failing/model_0.sql
@@ -0,0 +1,3 @@
+{{ config(materialized='table') }}
+
+select * from {{ ref('countries') }}
\ No newline at end of file
diff --git a/test/integration/069_build_test/models-failing/model_1.sql b/test/integration/069_build_test/models-failing/model_1.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/069_build_test/models-failing/model_1.sql
@@ -0,0 +1,3 @@
+{{ config(materialized='table') }}
+
+select bad_column from {{ ref('snap_0') }}
\ No newline at end of file
diff --git a/test/integration/069_build_test/models-failing/model_2.sql b/test/integration/069_build_test/models-failing/model_2.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/069_build_test/models-failing/model_2.sql
@@ -0,0 +1,3 @@
+{{ config(materialized='table') }}
+
+select * from {{ ref('snap_1') }}
\ No newline at end of file
diff --git a/test/integration/069_build_test/models-failing/model_99.sql b/test/integration/069_build_test/models-failing/model_99.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/069_build_test/models-failing/model_99.sql
@@ -0,0 +1,3 @@
+{{ config(materialized='table') }}
+
+select '1' as "num"
\ No newline at end of file
diff --git a/test/integration/069_build_test/models-failing/test.yml b/test/integration/069_build_test/models-failing/test.yml
new file mode 100644
--- /dev/null
+++ b/test/integration/069_build_test/models-failing/test.yml
@@ -0,0 +1,15 @@
+version: 2
+
+models:
+  - name: model_0
+    columns:
+      - name: iso3
+        tests:
+          - unique
+          - not_null
+  - name: model_2
+    columns:
+      - name: iso3
+        tests:
+          - unique
+          - not_null
diff --git a/test/integration/069_build_test/test_build.py b/test/integration/069_build_test/test_build.py
--- a/test/integration/069_build_test/test_build.py
+++ b/test/integration/069_build_test/test_build.py
@@ -17,7 +17,6 @@ def project_config(self):
             "config-version": 2,
             "snapshot-paths": ["snapshots"],
             "data-paths": ["data"],
-            "source-paths": ["models"],
             "seeds": {
                 "quote_columns": False,
             },
@@ -35,3 +34,21 @@ def build(self, expect_pass=True, extra_args=None, **kwargs):
     @use_profile("postgres")
     def test__postgres_build_happy_path(self):
         self.build()
+
+
+class TestFailingBuild(TestBuild):
+    @property
+    def schema(self):
+        return "build_test_069"
+
+    @property
+    def models(self):
+        return "models-failing"
+        
+    @use_profile("postgres")
+    def test__postgres_build_happy_path(self):
+        results = self.build(expect_pass=False)
+        self.assertEqual(len(results), 12)
+        actual = [r.status for r in results]
+        expected = ['error']*1 + ['skipped']*4 + ['pass']*2 + ['success']*5
+        self.assertEqual(sorted(actual), sorted(expected))