prompt: 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()
  
  ```
  
golden_diff: 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):
  
verification_info: {"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"}]}