Repository: dbt-labs/dbt-core

Issue:
[CT-2963] [Bug] CI breaking when a group is changed
### Is this a new bug in dbt-core?

- [X] I believe this is a new bug in dbt-core
- [X] I have searched the existing issues, and I could not find an existing issue for this bug

### Current Behavior

Originally from a slack message from @matt-winkler 

> I'm picking up on what may be an issue with CI/CD when adjusting model access groups. In my case, I changed a group formerly known as hub to customer360 , and the CI test job gave me this Traceback:
  File "/venv/dbt-1.6.0-pre/lib/python3.8/site-packages/dbt/cli/main.py", line 207, in build
    results = task.run()
  File "/venv/dbt-1.6.0-pre/lib/python3.8/site-packages/dbt/task/runnable.py", line 468, in run
    result = self.execute_with_hooks(selected_uids)
  File "/venv/dbt-1.6.0-pre/lib/python3.8/site-packages/dbt/task/runnable.py", line 428, in execute_with_hooks
    self.before_run(adapter, selected_uids)
  File "/venv/dbt-1.6.0-pre/lib/python3.8/site-packages/dbt/task/run.py", line 448, in before_run
    self.defer_to_manifest(adapter, selected_uids)
  File "/venv/dbt-1.6.0-pre/lib/python3.8/site-packages/dbt/task/compile.py", line 122, in defer_to_manifest
    write_manifest(self.manifest, self.config.project_target_path)
  File "/venv/dbt-1.6.0-pre/lib/python3.8/site-packages/dbt/parser/manifest.py", line 1682, in write_manifest
    manifest.write(path)
  File "/venv/dbt-1.6.0-pre/lib/python3.8/site-packages/dbt/contracts/graph/manifest.py", line 960, in write
    self.writable_manifest().write(path)
  File "/venv/dbt-1.6.0-pre/lib/python3.8/site-packages/dbt/contracts/graph/manifest.py", line 941, in writable_manifest
    self.build_group_map()
  File "/venv/dbt-1.6.0-pre/lib/python3.8/site-packages/dbt/contracts/graph/manifest.py", line 936, in build_group_map
    group_map[node.group].append(node.unique_id)
KeyError: 'hub'

There are two parts of this issue:
- is there a way core can handle changes to groups as part of state?
- as a starting point, show a more descriptive error message so the user knows what to fix to move forward

### Expected Behavior

CI should be able to handle a change to the name of a group.

### Steps To Reproduce

1. run `dbt compile` on a project that has a group defined
2. save the manifest somewhere
3. change the group name everywhere it's referenced
4. ALSO make a change to one of the models in that group (ex: add an `order by 1` clause)
5. run `dbt build --select state:modified+ --defer --state path/to/earlier/manifest`

### Relevant log output

_No response_

### Environment

```markdown
- OS:
- Python:
- dbt: 1.6.0
```


### Which database adapter are you using with dbt?

Snowflake

### Additional Context

Above was running in dbt Cloud. State comparison to a previous manifest should be the same though, I think.

Depending on the scope of the fix, we can decide if it makes sense to backport for inclusion in v1.6.x.


Discussion:
Thanks Grace & Matt - added to the milestone!

Depending on the scope of the fix required here, we can decide if it makes sense to backport for inclusion in v1.6.x
Added the backport label for v1.5.x and v1.6.x since groups were first introduced in v1.5.x
I'm not sure if this is related or not (and thus if I should create a new issue), but the last few weeks we've been experiencing a similar issue when an exposure has been renamed/deleted, so a KeyError is returned when comparing to the previuos manifest.
The solution has been to delete the previous manifest (and partial msg pack just in case).

The following error is from a run on September 14th:
```
12:08:41  Running with dbt=1.6.2
12:08:43  Registered adapter: databricks=1.6.3
12:10:20  Encountered an error:
'tine_bi://exposures/datasets/Mottak.yml'
12:10:20  Traceback (most recent call last):
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 87, in wrapper
    result, success = func(*args, **kwargs)
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 72, in wrapper
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 143, in wrapper
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 172, in wrapper
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 219, in wrapper
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 246, in wrapper
    manifest = ManifestLoader.get_full_manifest(
  File "/usr/local/lib/python3.10/dist-packages/dbt/parser/manifest.py", line 315, in get_full_manifest
    manifest = loader.load()
  File "/usr/local/lib/python3.10/dist-packages/dbt/parser/manifest.py", line 491, in load
    self.parse_project(
  File "/usr/local/lib/python3.10/dist-packages/dbt/parser/manifest.py", line 658, in parse_project
    block = FileBlock(self.manifest.files[file_id])
KeyError: 'tine_bi://exposures/datasets/Mottak.yml'
```

While we got a similar error a week later on September 22nd, but this time related to a model:
```
13:54:21  Running with dbt=1.6.3
13:54:23  Registered adapter: databricks=1.6.4
13:56:19  Encountered an error:
'tine_bi://models/Domener/Produksjon/4_SRV/ProsesstyringMeieri/SRV_DimProsesstyringObjektlKode.yml'
13:56:19  Traceback (most recent call last):
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 87, in wrapper
    result, success = func(*args, **kwargs)
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 72, in wrapper
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 143, in wrapper
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 172, in wrapper
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 219, in wrapper
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.10/dist-packages/dbt/cli/requires.py", line 246, in wrapper
    manifest = ManifestLoader.get_full_manifest(
  File "/usr/local/lib/python3.10/dist-packages/dbt/parser/manifest.py", line 315, in get_full_manifest
    manifest = loader.load()
  File "/usr/local/lib/python3.10/dist-packages/dbt/parser/manifest.py", line 491, in load
    self.parse_project(
  File "/usr/local/lib/python3.10/dist-packages/dbt/parser/manifest.py", line 658, in parse_project
    block = FileBlock(self.manifest.files[file_id])
KeyError: 'tine_bi://models/Domener/Produksjon/4_SRV/ProsesstyringMeieri/SRV_DimProsesstyringObjektlKode.yml'
```
@grindheim That sounds like an error with partial parsing. Could I ask you to open a new issue for it, and include as many details / reproduction steps as possible?
We're not able to reproduce this bug in dbt-core, so I'm closing it out - I've alerted our cloud team who is going to take a look :)
Was able to finally reproduce!

The reason we weren’t able to reproduce this bug originally is that group name changes don’t actually count as something that is “modified” (@jtcohen6 is this expected behavior?) so nothing is selected / built when we did `dbt build --select state:modified` after _only_ changing the group name.

But! If we changed another model in that group (we just added an `order by` clause to one of the models in the group) so that it would be picked up in the selection `--select state:modified` then we get the error.

Resolving patch:
diff --git a/core/dbt/contracts/graph/manifest.py b/core/dbt/contracts/graph/manifest.py
--- a/core/dbt/contracts/graph/manifest.py
+++ b/core/dbt/contracts/graph/manifest.py
@@ -945,7 +945,14 @@ def build_group_map(self):
         group_map = {group.name: [] for group in self.groups.values()}
         for node in groupable_nodes:
             if node.group is not None:
-                group_map[node.group].append(node.unique_id)
+                # group updates are not included with state:modified and
+                # by ignoring the groups that aren't in the group map we
+                # can avoid hitting errors for groups that are not getting
+                # updated.  This is a hack but any groups that are not
+                # valid will be caught in
+                # parser.manifest.ManifestLoader.check_valid_group_config_node
+                if node.group in group_map:
+                    group_map[node.group].append(node.unique_id)
         self.group_map = group_map
 
     def writable_manifest(self) -> "WritableManifest":


Test patch:
diff --git a/tests/functional/defer_state/fixtures.py b/tests/functional/defer_state/fixtures.py
--- a/tests/functional/defer_state/fixtures.py
+++ b/tests/functional/defer_state/fixtures.py
@@ -359,3 +359,66 @@
 
 {% endsnapshot %}
 """
+
+model_1_sql = """
+select * from {{ ref('seed') }}
+"""
+
+modified_model_1_sql = """
+select * from  {{ ref('seed') }}
+order by 1
+"""
+
+model_2_sql = """
+select id from  {{ ref('model_1') }}
+"""
+
+modified_model_2_sql = """
+select * from  {{ ref('model_1') }}
+order by 1
+"""
+
+
+group_schema_yml = """
+groups:
+  - name: finance
+    owner:
+      email: finance@jaffleshop.com
+
+models:
+  - name: model_1
+    config:
+      group: finance
+  - name: model_2
+    config:
+      group: finance
+"""
+
+
+group_modified_schema_yml = """
+groups:
+  - name: accounting
+    owner:
+      email: finance@jaffleshop.com
+models:
+  - name: model_1
+    config:
+      group: accounting
+  - name: model_2
+    config:
+      group: accounting
+"""
+
+group_modified_fail_schema_yml = """
+groups:
+  - name: finance
+    owner:
+      email: finance@jaffleshop.com
+models:
+  - name: model_1
+    config:
+      group: accounting
+  - name: model_2
+    config:
+      group: finance
+"""
diff --git a/tests/functional/defer_state/test_group_updates.py b/tests/functional/defer_state/test_group_updates.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/defer_state/test_group_updates.py
@@ -0,0 +1,121 @@
+import os
+
+import pytest
+
+from dbt.tests.util import run_dbt, write_file, copy_file
+from dbt.exceptions import ParsingError
+
+
+from tests.functional.defer_state.fixtures import (
+    seed_csv,
+    model_1_sql,
+    modified_model_1_sql,
+    model_2_sql,
+    modified_model_2_sql,
+    group_schema_yml,
+    group_modified_schema_yml,
+    group_modified_fail_schema_yml,
+)
+
+
+class GroupSetup:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "model_1.sql": model_1_sql,
+            "model_2.sql": model_2_sql,
+            "schema.yml": group_schema_yml,
+        }
+
+    @pytest.fixture(scope="class")
+    def seeds(self):
+        return {
+            "seed.csv": seed_csv,
+        }
+
+    def group_setup(self):
+        # save initial state
+        run_dbt(["seed"])
+        results = run_dbt(["compile"])
+
+        # add sanity checks for first result
+        assert len(results) == 3
+        seed_result = results[0].node
+        assert seed_result.unique_id == "seed.test.seed"
+        model_1_result = results[1].node
+        assert model_1_result.unique_id == "model.test.model_1"
+        assert model_1_result.group == "finance"
+        model_2_result = results[2].node
+        assert model_2_result.unique_id == "model.test.model_2"
+        assert model_2_result.group == "finance"
+
+
+class TestFullyModifiedGroups(GroupSetup):
+    def test_changed_groups(self, project):
+        self.group_setup()
+
+        # copy manifest.json to "state" directory
+        os.makedirs("state")
+        target_path = os.path.join(project.project_root, "target")
+        copy_file(target_path, "manifest.json", project.project_root, ["state", "manifest.json"])
+
+        # update group name, modify model so it gets picked up
+        write_file(modified_model_1_sql, "models", "model_1.sql")
+        write_file(modified_model_2_sql, "models", "model_2.sql")
+        write_file(group_modified_schema_yml, "models", "schema.yml")
+
+        # this test is flaky if you don't clean first before the build
+        run_dbt(["clean"])
+        # only thing in results should be model_1
+        results = run_dbt(["build", "-s", "state:modified", "--defer", "--state", "./state"])
+
+        assert len(results) == 2
+        model_1_result = results[0].node
+        assert model_1_result.unique_id == "model.test.model_1"
+        assert model_1_result.group == "accounting"  # new group name!
+        model_2_result = results[1].node
+        assert model_2_result.unique_id == "model.test.model_2"
+        assert model_2_result.group == "accounting"  # new group name!
+
+
+class TestPartiallyModifiedGroups(GroupSetup):
+    def test_changed_groups(self, project):
+        self.group_setup()
+
+        # copy manifest.json to "state" directory
+        os.makedirs("state")
+        target_path = os.path.join(project.project_root, "target")
+        copy_file(target_path, "manifest.json", project.project_root, ["state", "manifest.json"])
+
+        # update group name, modify model so it gets picked up
+        write_file(modified_model_1_sql, "models", "model_1.sql")
+        write_file(group_modified_schema_yml, "models", "schema.yml")
+
+        # this test is flaky if you don't clean first before the build
+        run_dbt(["clean"])
+        # only thing in results should be model_1
+        results = run_dbt(["build", "-s", "state:modified", "--defer", "--state", "./state"])
+
+        assert len(results) == 1
+        model_1_result = results[0].node
+        assert model_1_result.unique_id == "model.test.model_1"
+        assert model_1_result.group == "accounting"  # new group name!
+
+
+class TestBadGroups(GroupSetup):
+    def test_changed_groups(self, project):
+        self.group_setup()
+
+        # copy manifest.json to "state" directory
+        os.makedirs("state")
+        target_path = os.path.join(project.project_root, "target")
+        copy_file(target_path, "manifest.json", project.project_root, ["state", "manifest.json"])
+
+        # update group with invalid name, modify model so it gets picked up
+        write_file(modified_model_1_sql, "models", "model_1.sql")
+        write_file(group_modified_fail_schema_yml, "models", "schema.yml")
+
+        # this test is flaky if you don't clean first before the build
+        run_dbt(["clean"])
+        with pytest.raises(ParsingError, match="Invalid group 'accounting'"):
+            run_dbt(["build", "-s", "state:modified", "--defer", "--state", "./state"])