We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Search indexing logic called twice after update or create dataset
**CKAN version**
2.10


**Describe the bug**

When updating or creating a dataset, we are indexing the dataset twice in a row, ie the [`index_package()`](https://github.com/ckan/ckan/blob/9f1b5cfaff8c135b589e2ea0275f1286c2e02711/ckan/lib/search/index.py#L108) function gets called twice during the same operation (and of course any `IPackageController.before_index()` hook gets called twice as well.

The root cause is the the obscure code run in the [`DomainObjectModificationExtension`](https://github.com/ckan/ckan/blob/9f1b5cfaff8c135b589e2ea0275f1286c2e02711/ckan/model/modification.py#L27), which considers the same Package object both new and changed at the same time, and fires two separate notification events.

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `ckan/model/modification.py`
Content:
```
1 # encoding: utf-8
2 
3 import logging
4 from typing import Any
5 
6 from ckan.lib.search import SearchIndexError
7 
8 import ckan.plugins as plugins
9 import ckan.model as model
10 
11 log = logging.getLogger(__name__)
12 
13 __all__ = ['DomainObjectModificationExtension']
14 
15 
16 class DomainObjectModificationExtension(plugins.SingletonPlugin):
17     """
18     Notify observers about domain object modifications before commit.
19 
20     Observers are other plugins implementing the IDomainObjectModification
21     interface.
22     """
23 
24     def before_commit(self, session: Any):
25         self.notify_observers(session, self.notify)
26 
27     def notify_observers(self, session: Any, method: Any):
28         session.flush()
29         if not hasattr(session, '_object_cache'):
30             return
31 
32         obj_cache = session._object_cache
33         new = obj_cache['new']
34         changed = obj_cache['changed']
35         deleted = obj_cache['deleted']
36 
37         for obj in set(new):
38             if isinstance(obj, (model.Package, model.Resource)):
39                 method(obj, model.DomainObjectOperation.new)
40         for obj in set(deleted):
41             if isinstance(obj, (model.Package, model.Resource)):
42                 method(obj, model.DomainObjectOperation.deleted)
43         for obj in set(changed):
44             if isinstance(obj, model.Resource):
45                 method(obj, model.DomainObjectOperation.changed)
46             if getattr(obj, 'url_changed', False):
47                 for item in plugins.PluginImplementations(plugins.IResourceUrlChange):
48                     item.notify(obj)
49 
50         changed_pkgs = set(obj for obj in changed
51                            if isinstance(obj, model.Package))
52 
53         for obj in new | changed | deleted:
54             if not isinstance(obj, model.Package):
55                 try:
56                     changed_pkgs.update(obj.related_packages())
57                 except AttributeError:
58                     continue
59 
60         for obj in changed_pkgs:
61             method(obj, model.DomainObjectOperation.changed)
62 
63     def notify(self, entity: Any, operation: Any):
64         for observer in plugins.PluginImplementations(
65                 plugins.IDomainObjectModification):
66             try:
67                 observer.notify(entity, operation)
68             except SearchIndexError as search_error:
69                 log.exception(search_error)
70                 # Reraise, since it's pretty crucial to ckan if it can't index
71                 # a dataset
72                 raise
73             except Exception as ex:
74                 log.exception(ex)
75                 # Don't reraise other exceptions since they are generally of
76                 # secondary importance so shouldn't disrupt the commit.
77
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/ckan/model/modification.py b/ckan/model/modification.py
--- a/ckan/model/modification.py
+++ b/ckan/model/modification.py
@@ -47,8 +47,12 @@
                 for item in plugins.PluginImplementations(plugins.IResourceUrlChange):
                     item.notify(obj)
 
-        changed_pkgs = set(obj for obj in changed
-                           if isinstance(obj, model.Package))
+
+        changed_pkgs = set()
+        new_pkg_ids = [obj.id for obj in new if isinstance(obj, model.Package)]
+        for obj in changed:
+            if isinstance(obj, model.Package) and obj.id not in new_pkg_ids:
+                changed_pkgs.add(obj)
 
         for obj in new | changed | deleted:
             if not isinstance(obj, model.Package):

{"golden_diff": "diff --git a/ckan/model/modification.py b/ckan/model/modification.py\n--- a/ckan/model/modification.py\n+++ b/ckan/model/modification.py\n@@ -47,8 +47,12 @@\n                 for item in plugins.PluginImplementations(plugins.IResourceUrlChange):\n                     item.notify(obj)\n \n-        changed_pkgs = set(obj for obj in changed\n-                           if isinstance(obj, model.Package))\n+\n+        changed_pkgs = set()\n+        new_pkg_ids = [obj.id for obj in new if isinstance(obj, model.Package)]\n+        for obj in changed:\n+            if isinstance(obj, model.Package) and obj.id not in new_pkg_ids:\n+                changed_pkgs.add(obj)\n \n         for obj in new | changed | deleted:\n             if not isinstance(obj, model.Package):\n", "issue": "Search indexing logic called twice after update or create dataset\n**CKAN version**\r\n2.10\r\n\r\n\r\n**Describe the bug**\r\n\r\nWhen updating or creating a dataset, we are indexing the dataset twice in a row, ie the [`index_package()`](https://github.com/ckan/ckan/blob/9f1b5cfaff8c135b589e2ea0275f1286c2e02711/ckan/lib/search/index.py#L108) function gets called twice during the same operation (and of course any `IPackageController.before_index()` hook gets called twice as well.\r\n\r\nThe root cause is the the obscure code run in the [`DomainObjectModificationExtension`](https://github.com/ckan/ckan/blob/9f1b5cfaff8c135b589e2ea0275f1286c2e02711/ckan/model/modification.py#L27), which considers the same Package object both new and changed at the same time, and fires two separate notification events.\n", "before_files": [{"content": "# encoding: utf-8\n\nimport logging\nfrom typing import Any\n\nfrom ckan.lib.search import SearchIndexError\n\nimport ckan.plugins as plugins\nimport ckan.model as model\n\nlog = logging.getLogger(__name__)\n\n__all__ = ['DomainObjectModificationExtension']\n\n\nclass DomainObjectModificationExtension(plugins.SingletonPlugin):\n    \"\"\"\n    Notify observers about domain object modifications before commit.\n\n    Observers are other plugins implementing the IDomainObjectModification\n    interface.\n    \"\"\"\n\n    def before_commit(self, session: Any):\n        self.notify_observers(session, self.notify)\n\n    def notify_observers(self, session: Any, method: Any):\n        session.flush()\n        if not hasattr(session, '_object_cache'):\n            return\n\n        obj_cache = session._object_cache\n        new = obj_cache['new']\n        changed = obj_cache['changed']\n        deleted = obj_cache['deleted']\n\n        for obj in set(new):\n            if isinstance(obj, (model.Package, model.Resource)):\n                method(obj, model.DomainObjectOperation.new)\n        for obj in set(deleted):\n            if isinstance(obj, (model.Package, model.Resource)):\n                method(obj, model.DomainObjectOperation.deleted)\n        for obj in set(changed):\n            if isinstance(obj, model.Resource):\n                method(obj, model.DomainObjectOperation.changed)\n            if getattr(obj, 'url_changed', False):\n                for item in plugins.PluginImplementations(plugins.IResourceUrlChange):\n                    item.notify(obj)\n\n        changed_pkgs = set(obj for obj in changed\n                           if isinstance(obj, model.Package))\n\n        for obj in new | changed | deleted:\n            if not isinstance(obj, model.Package):\n                try:\n                    changed_pkgs.update(obj.related_packages())\n                except AttributeError:\n                    continue\n\n        for obj in changed_pkgs:\n            method(obj, model.DomainObjectOperation.changed)\n\n    def notify(self, entity: Any, operation: Any):\n        for observer in plugins.PluginImplementations(\n                plugins.IDomainObjectModification):\n            try:\n                observer.notify(entity, operation)\n            except SearchIndexError as search_error:\n                log.exception(search_error)\n                # Reraise, since it's pretty crucial to ckan if it can't index\n                # a dataset\n                raise\n            except Exception as ex:\n                log.exception(ex)\n                # Don't reraise other exceptions since they are generally of\n                # secondary importance so shouldn't disrupt the commit.\n", "path": "ckan/model/modification.py"}], "after_files": [{"content": "# encoding: utf-8\n\nimport logging\nfrom typing import Any\n\nfrom ckan.lib.search import SearchIndexError\n\nimport ckan.plugins as plugins\nimport ckan.model as model\n\nlog = logging.getLogger(__name__)\n\n__all__ = ['DomainObjectModificationExtension']\n\n\nclass DomainObjectModificationExtension(plugins.SingletonPlugin):\n    \"\"\"\n    Notify observers about domain object modifications before commit.\n\n    Observers are other plugins implementing the IDomainObjectModification\n    interface.\n    \"\"\"\n\n    def before_commit(self, session: Any):\n        self.notify_observers(session, self.notify)\n\n    def notify_observers(self, session: Any, method: Any):\n        session.flush()\n        if not hasattr(session, '_object_cache'):\n            return\n\n        obj_cache = session._object_cache\n        new = obj_cache['new']\n        changed = obj_cache['changed']\n        deleted = obj_cache['deleted']\n\n        for obj in set(new):\n            if isinstance(obj, (model.Package, model.Resource)):\n                method(obj, model.DomainObjectOperation.new)\n        for obj in set(deleted):\n            if isinstance(obj, (model.Package, model.Resource)):\n                method(obj, model.DomainObjectOperation.deleted)\n        for obj in set(changed):\n            if isinstance(obj, model.Resource):\n                method(obj, model.DomainObjectOperation.changed)\n            if getattr(obj, 'url_changed', False):\n                for item in plugins.PluginImplementations(plugins.IResourceUrlChange):\n                    item.notify(obj)\n\n\n        changed_pkgs = set()\n        new_pkg_ids = [obj.id for obj in new if isinstance(obj, model.Package)]\n        for obj in changed:\n            if isinstance(obj, model.Package) and obj.id not in new_pkg_ids:\n                changed_pkgs.add(obj)\n\n        for obj in new | changed | deleted:\n            if not isinstance(obj, model.Package):\n                try:\n                    changed_pkgs.update(obj.related_packages())\n                except AttributeError:\n                    continue\n\n        for obj in changed_pkgs:\n            method(obj, model.DomainObjectOperation.changed)\n\n    def notify(self, entity: Any, operation: Any):\n        for observer in plugins.PluginImplementations(\n                plugins.IDomainObjectModification):\n            try:\n                observer.notify(entity, operation)\n            except SearchIndexError as search_error:\n                log.exception(search_error)\n                # Reraise, since it's pretty crucial to ckan if it can't index\n                # a dataset\n                raise\n            except Exception as ex:\n                log.exception(ex)\n                # Don't reraise other exceptions since they are generally of\n                # secondary importance so shouldn't disrupt the commit.\n", "path": "ckan/model/modification.py"}]}