Repository: Kinto/kinto

Issue:
Rows from the timestamps table are never deleted
When we delete a bucket, all its descendants are deleted. But the `timestamps` table will still retain the row for all its collections and groups.

For clients, it means that when you recreate a collection, it will reuse the previous timestamp instead of a new one.
Rows from the timestamps table are never deleted
When we delete a bucket, all its descendants are deleted. But the `timestamps` table will still retain the row for all its collections and groups.

For clients, it means that when you recreate a collection, it will reuse the previous timestamp instead of a new one.


Discussion:

Resolving patch:
diff --git a/kinto/core/storage/postgresql/__init__.py b/kinto/core/storage/postgresql/__init__.py
--- a/kinto/core/storage/postgresql/__init__.py
+++ b/kinto/core/storage/postgresql/__init__.py
@@ -502,12 +502,12 @@ def purge_deleted(self, collection_id, parent_id, before=None,
                       id_field=DEFAULT_ID_FIELD,
                       modified_field=DEFAULT_MODIFIED_FIELD,
                       auth=None):
-        query = """
+        delete_tombstones = """
         DELETE
         FROM deleted
         WHERE {parent_id_filter}
               {collection_id_filter}
-              {conditions_filter};
+              {conditions_filter}
         """
         id_field = id_field or self.id_field
         modified_field = modified_field or self.modified_field
@@ -533,9 +533,19 @@ def purge_deleted(self, collection_id, parent_id, before=None,
             placeholders['before'] = before
 
         with self.client.connect() as conn:
-            result = conn.execute(query.format_map(safeholders), placeholders)
+            result = conn.execute(delete_tombstones.format_map(safeholders), placeholders)
+            deleted = result.rowcount
+
+            # If purging everything from a parent_id, then clear timestamps.
+            if collection_id is None and before is None:
+                delete_timestamps = """
+                DELETE
+                FROM timestamps
+                WHERE {parent_id_filter}
+                """
+                conn.execute(delete_timestamps.format_map(safeholders), placeholders)
 
-        return result.rowcount
+        return deleted
 
     def get_all(self, collection_id, parent_id, filters=None, sorting=None,
                 pagination_rules=None, limit=None, include_deleted=False,


Test patch:
diff --git a/kinto/core/storage/testing.py b/kinto/core/storage/testing.py
--- a/kinto/core/storage/testing.py
+++ b/kinto/core/storage/testing.py
@@ -981,6 +981,28 @@ def test_purge_deleted_remove_all_tombstones_by_parent_id(self):
                                                  collection_id=None)
         self.assertEqual(num_removed, 2)
 
+    def test_purge_deleted_removes_timestamps_by_parent_id(self):
+        self.create_record(parent_id='/abc/a', collection_id='c')
+        self.create_record(parent_id='/abc/a', collection_id='c')
+        self.create_record(parent_id='/efg', collection_id='c')
+
+        before1 = self.storage.collection_timestamp(parent_id='/abc/a', collection_id='c')
+        # Different parent_id with record.
+        before2 = self.storage.collection_timestamp(parent_id='/efg', collection_id='c')
+        # Different parent_id without record.
+        before3 = self.storage.collection_timestamp(parent_id='/ijk', collection_id='c')
+
+        self.storage.delete_all(parent_id='/abc/*', collection_id=None, with_deleted=False)
+        self.storage.purge_deleted(parent_id='/abc/*', collection_id=None)
+
+        after1 = self.storage.collection_timestamp(parent_id='/abc/a', collection_id='c')
+        after2 = self.storage.collection_timestamp(parent_id='/efg', collection_id='c')
+        after3 = self.storage.collection_timestamp(parent_id='/ijk', collection_id='c')
+
+        self.assertNotEqual(before1, after1)
+        self.assertEqual(before2, after2)
+        self.assertEqual(before3, after3)
+
     def test_purge_deleted_works_when_no_tombstones(self):
         num_removed = self.storage.purge_deleted(**self.storage_kw)
         self.assertEqual(num_removed, 0)
diff --git a/tests/test_views_buckets.py b/tests/test_views_buckets.py
--- a/tests/test_views_buckets.py
+++ b/tests/test_views_buckets.py
@@ -190,10 +190,11 @@ def setUp(self):
                           headers=self.headers)
         self.app.put_json(self.collection_url, MINIMALIST_COLLECTION,
                           headers=self.headers)
-        r = self.app.post_json(self.collection_url + '/records',
-                               MINIMALIST_RECORD,
-                               headers=self.headers)
-        record_id = r.json['data']['id']
+        resp = self.app.post_json(self.collection_url + '/records',
+                                  MINIMALIST_RECORD,
+                                  headers=self.headers)
+        record_id = resp.json['data']['id']
+        self.previous_ts = resp.headers['ETag']
         self.record_url = self.collection_url + '/records/{}'.format(record_id)
         # Delete the bucket.
         self.app.delete(self.bucket_url, headers=self.headers)
@@ -236,6 +237,17 @@ def test_every_collections_are_deleted_too(self):
                             headers=self.headers)
         self.assertEqual(len(resp.json['data']), 0)
 
+    def test_timestamps_are_refreshed_when_collection_is_recreated(self):
+        # Kinto/kinto#1223
+        # Recreate with same name.
+        self.app.put_json(self.bucket_url, MINIMALIST_BUCKET,
+                          headers=self.headers)
+        self.app.put_json(self.collection_url, MINIMALIST_COLLECTION,
+                          headers=self.headers)
+        resp = self.app.get(self.collection_url + '/records', headers=self.headers)
+        records_ts = resp.headers['ETag']
+        self.assertNotEqual(self.previous_ts, records_ts)
+
     def test_every_groups_are_deleted_too(self):
         self.app.put_json(self.bucket_url, MINIMALIST_BUCKET,
                           headers=self.headers)
diff --git a/tests/test_views_collections.py b/tests/test_views_collections.py
--- a/tests/test_views_collections.py
+++ b/tests/test_views_collections.py
@@ -110,10 +110,11 @@ def setUp(self):
                           headers=self.headers)
         self.app.put_json(self.collection_url, MINIMALIST_COLLECTION,
                           headers=self.headers)
-        r = self.app.post_json(self.collection_url + '/records',
-                               MINIMALIST_RECORD,
-                               headers=self.headers)
-        record_id = r.json['data']['id']
+        resp = self.app.post_json(self.collection_url + '/records',
+                                  MINIMALIST_RECORD,
+                                  headers=self.headers)
+        self.previous_ts = resp.headers['ETag']
+        record_id = resp.json['data']['id']
         self.record_url = self.collection_url + '/records/{}'.format(record_id)
         self.app.delete(self.collection_url, headers=self.headers)
 
@@ -143,6 +144,15 @@ def test_records_of_collection_are_deleted_too(self):
                             headers=self.headers)
         self.assertEqual(len(resp.json['data']), 0)
 
+    def test_timestamps_are_refreshed_when_collection_is_recreated(self):
+        # Kinto/kinto#1223
+        # Recreate with same name.
+        self.app.put_json(self.collection_url, MINIMALIST_COLLECTION,
+                          headers=self.headers)
+        resp = self.app.get(self.collection_url + '/records', headers=self.headers)
+        records_ts = resp.headers['ETag']
+        self.assertNotEqual(self.previous_ts, records_ts)
+
     def test_can_be_created_after_deletion_with_if_none_match_star(self):
         headers = {**self.headers, 'If-None-Match': '*'}
         self.app.put_json(self.collection_url, MINIMALIST_COLLECTION,