Repository: Kinto/kinto

Issue:
Is it possible to reduce write load on create() without reintroducing TOCTOU bugs?
In #1406, we changed the create() method from doing "read, then insert" to doing "insert with on conflict". This introduced a lot of write load. Unlike read load, which can often be served using CPU and memory, write load always goes to disk and is therefore more expensive. This manifested by a dramatic increase on write load in the kintowe stack, because every request is creating a default bucket and a collection in that default bucket.

Is it possible to reduce write load without the risk of TOCTOU bugs? Now that we are fairly confident in the correctness of our `INSERT` + `ON CONFLICT` strategy, we could try to re-add the "read, then insert" strategy. The read would be optimistic -- if it's there, then we don't have to insert, but if it isn't there, we can insert and maybe get a conflict, no big deal. The risk here is that we could find that the row was there, and then fail to create it even though it's been deleted just afterwards by a different transaction. However, this may be acceptable, because child elements don't actually rely on the presence or absence of their ancestor records, and even if they do, such behavior is no different from trying the INSERT + ON CONFLICT and then having another thread delete the record just afterwards.

@leplatrem What do you think?


Discussion:
> . Unlike read load, which can often be served using CPU and memory, write load always goes to disk and is therefore more expensive.

OK I see... The `ON CONFLICT` is more like a `try/except` than a `IF NOT EXISTS`. 

>  The read would be optimistic -- if it's there, then we don't have to insert, but if it isn't there, we can insert and maybe get a conflict, no big deal

👍 Yes, let's do that. 

Will it change anything if we do it in one atomic query `INSERT ... ON CONFLICT .. WHERE NOT EXISTS (SELECT ... )` ?

> such behavior is no different from trying the INSERT + ON CONFLICT and then having another thread delete the record just afterwards

Yes, BTW we do a `self._get_record_or_404()` and then `delete()` :) 

Hmm, that's a good point .. Postgres should be smart enough not to have to write a row if one already exists. I should probably post on Stack Overflow or IRC to try to find out whether INSERT ON CONFLICT IGNORE should cause write load, or if not, what else I might be doing wrong.
I asked on IRC in #postgresql and they said that:
- The INSERT itself optimistically looks for the row in the index and if it finds one is already there, it doesn't try to actually write.
- Locking a row does a write (because the lock is stored in the row itself as part of the t_xmax field).

So probably we can't do much better than this except by going back to a SELECT first. [Edit: And I feel a lot better about doing it because Postgres itself is basically doing it too.] I don't think doing it in the same query is really that much better than doing it as two separate queries. I'll write up a PR.

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
@@ -245,7 +245,19 @@ def create(self, collection_id, parent_id, record, id_generator=None,
                auth=None):
         id_generator = id_generator or self.id_generator
         record = {**record}
-        if id_field not in record:
+        if id_field in record:
+            # Optimistically raise unicity error if record with same
+            # id already exists.
+            # Even if this check doesn't find one, be robust against
+            # conflicts because we could race with another thread.
+            # Still, this reduces write load because SELECTs are
+            # cheaper than INSERTs.
+            try:
+                existing = self.get(collection_id, parent_id, record[id_field])
+                raise exceptions.UnicityError(id_field, existing)
+            except exceptions.RecordNotFoundError:
+                pass
+        else:
             record[id_field] = id_generator()
 
         # Remove redundancy in data field


Test patch:
diff --git a/tests/core/test_storage.py b/tests/core/test_storage.py
--- a/tests/core/test_storage.py
+++ b/tests/core/test_storage.py
@@ -248,6 +248,20 @@ def test_integrity_error_rollsback_transaction(self):
             """)
         self.assertEqual(result.rowcount, 0)
 
+    def test_conflicts_handled_correctly(self):
+        config = self._get_config()
+        storage = self.backend.load_from_config(config)
+        storage.create(collection_id='genre', parent_id='music',
+                       record={'id': 'rock-and-roll'})
+
+        def record_not_found(*args, **kwargs):
+            raise exceptions.RecordNotFoundError()
+
+        with mock.patch.object(storage, 'get', side_effect=record_not_found):
+            with self.assertRaises(exceptions.UnicityError):
+                storage.create(collection_id='genre', parent_id='music',
+                               record={'id': 'rock-and-roll'})
+
 
 class PaginatedTest(unittest.TestCase):
     def setUp(self):