Repository: ckan/ckan

Issue:
set datastore active slow (unsafe?) resource update
## CKAN version
all

## Describe the bug
When creating or deleting a datastore table the `datastore_active` flag is updated by reading resource extras, parsing and updating the json in python, then writing to the database. This is slower than necessary and may be unsafe with other updates happening at the same time causing loss of resource metadata changes.

A second related issue with this code: Resource extras are not expired from the cache when updated, so if a datastore table is created then removed, or removed then created in one session the datastore_active flag will be set incorrectly.

### Steps to reproduce
Use datastore_create or datastore_delete on a resource

### Expected behavior
field should be updated in database without round trip to python for parsing and updating

### Additional details
Updating json in db is supported since postgresql 10.x https://www.postgresql.org/docs/10/functions-json.html




Discussion:

Resolving patch:
diff --git a/ckanext/datastore/logic/action.py b/ckanext/datastore/logic/action.py
--- a/ckanext/datastore/logic/action.py
+++ b/ckanext/datastore/logic/action.py
@@ -4,9 +4,13 @@
 from ckan.types import Context
 import logging
 from typing import Any
+import json
 
 import sqlalchemy
 import sqlalchemy.exc
+from sqlalchemy.dialects.postgresql import TEXT, JSONB
+from sqlalchemy.sql.expression import func
+from sqlalchemy.sql.functions import coalesce
 
 import ckan.lib.search as search
 import ckan.lib.navl.dictization_functions
@@ -656,22 +660,28 @@ def set_datastore_active_flag(
 
     Called after creation or deletion of DataStore table.
     '''
-    # We're modifying the resource extra directly here to avoid a
-    # race condition, see issue #3245 for details and plan for a
-    # better fix
     model = context['model']
-    update_dict = {'datastore_active': flag}
-
-    q = model.Session.query(model.Resource). \
-        filter(model.Resource.id == data_dict['resource_id'])
-    resource = q.one()
-
-    # update extras in database for record
-    extras = resource.extras
-    extras.update(update_dict)
-    q.update({'extras': extras}, synchronize_session=False)
-
+    resource = model.Resource.get(data_dict['resource_id'])
+    assert resource
+
+    # update extras json with a single statement
+    model.Session.query(model.Resource).filter(
+        model.Resource.id == data_dict['resource_id']
+    ).update(
+        {
+            'extras': func.jsonb_set(
+                coalesce(
+                    model.resource_table.c.extras,
+                    '{}',
+                ).cast(JSONB),
+                '{datastore_active}',
+                json.dumps(flag),
+            ).cast(TEXT)
+        },
+        synchronize_session='fetch',
+    )
     model.Session.commit()
+    model.Session.expire(resource, ['extras'])
 
     # copied from ckan.lib.search.rebuild
     # using validated packages can cause solr errors.
@@ -690,7 +700,7 @@ def set_datastore_active_flag(
     })
     for resource in _data_dict['resources']:
         if resource['id'] == data_dict['resource_id']:
-            resource.update(update_dict)
+            resource['datastore_active'] = flag
             psi.index_package(_data_dict)
             break
 


Test patch:
diff --git a/ckanext/datastore/tests/test_delete.py b/ckanext/datastore/tests/test_delete.py
--- a/ckanext/datastore/tests/test_delete.py
+++ b/ckanext/datastore/tests/test_delete.py
@@ -50,6 +50,10 @@ def test_delete_basic(self):
         data = {"resource_id": resource["id"], "force": True}
         helpers.call_action("datastore_delete", **data)
 
+        # regression test for #7832
+        resobj = model.Resource.get(data["resource_id"])
+        assert resobj.extras.get('datastore_active') is False
+
         results = execute_sql(
             u"select 1 from pg_views where viewname = %s", u"b\xfck2"
         )