Repository: Kinto/kinto

Issue:
collection_timestamp() always fails when Kinto is readonly
Regression from [7.5.1 to 8.1.3](https://github.com/Kinto/kinto/compare/7.5.1...8.1.3)

In the past, we were only attempting to insert into the timestamp table when everything was empty. We could have an error when accessing the collection via a readonly instance on an empty table. But accessing via a writable instance was enough, since it would create the row and never try again. 

It seems that in the last version, it always try to create (UPSERT?) even if it's not empty. It thus always fails to execute collection_timestamp() on a readonly instance.
collection_timestamp() always fails when Kinto is readonly
Regression from [7.5.1 to 8.1.3](https://github.com/Kinto/kinto/compare/7.5.1...8.1.3)

In the past, we were only attempting to insert into the timestamp table when everything was empty. We could have an error when accessing the collection via a readonly instance on an empty table. But accessing via a writable instance was enough, since it would create the row and never try again. 

It seems that in the last version, it always try to create (UPSERT?) even if it's not empty. It thus always fails to execute collection_timestamp() on a readonly instance.


Discussion:

Resolving patch:
diff --git a/kinto/core/storage/memory.py b/kinto/core/storage/memory.py
--- a/kinto/core/storage/memory.py
+++ b/kinto/core/storage/memory.py
@@ -83,8 +83,9 @@ class Storage(MemoryBasedStorage):
 
         kinto.storage_strict_json = true
     """
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, readonly=False, **kwargs):
         super().__init__(*args, **kwargs)
+        self.readonly = readonly
         self.flush()
 
     def flush(self, auth=None):
@@ -97,6 +98,9 @@ def collection_timestamp(self, collection_id, parent_id, auth=None):
         ts = self._timestamps[parent_id].get(collection_id)
         if ts is not None:
             return ts
+        if self.readonly:
+            error_msg = "Cannot initialize empty collection timestamp when running in readonly."
+            raise exceptions.BackendError(message=error_msg)
         return self._bump_timestamp(collection_id, parent_id)
 
     def _bump_timestamp(self, collection_id, parent_id, record=None,
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
@@ -77,10 +77,11 @@ class Storage(StorageBase, MigratorMixin):
     schema_file = os.path.join(HERE, 'schema.sql')
     migrations_directory = os.path.join(HERE, 'migrations')
 
-    def __init__(self, client, max_fetch_size, *args, **kwargs):
+    def __init__(self, client, max_fetch_size, *args, readonly=False, **kwargs):
         super().__init__(*args, **kwargs)
         self.client = client
         self._max_fetch_size = max_fetch_size
+        self.readonly = readonly
 
     def create_schema(self, dry_run=False):
         """Override create_schema to ensure DB encoding and TZ are OK.
@@ -224,9 +225,17 @@ def collection_timestamp(self, collection_id, parent_id, auth=None):
             row = ts_result.fetchone()  # Will return (None, None) when empty.
             existing_ts = row['last_modified']
 
-            create_result = conn.execute(create_if_missing,
-                                         dict(last_modified=existing_ts, **placeholders))
-            record = create_result.fetchone() or row
+            # If the backend is readonly, we should not try to create the timestamp.
+            if self.readonly:
+                if existing_ts is None:
+                    error_msg = ("Cannot initialize empty collection timestamp "
+                                 "when running in readonly.")
+                    raise exceptions.BackendError(message=error_msg)
+                record = row
+            else:
+                create_result = conn.execute(create_if_missing,
+                                             dict(last_modified=existing_ts, **placeholders))
+                record = create_result.fetchone() or row
 
         return record['last_epoch']
 
@@ -879,8 +888,10 @@ def load_from_config(config):
     settings = config.get_settings()
     max_fetch_size = int(settings['storage_max_fetch_size'])
     strict = settings.get('storage_strict_json', False)
+    readonly = settings.get('readonly', False)
     client = create_from_config(config, prefix='storage_')
-    return Storage(client=client, max_fetch_size=max_fetch_size, strict_json=strict)
+    return Storage(client=client, max_fetch_size=max_fetch_size, strict_json=strict,
+                   readonly=readonly)
 
 
 UNKNOWN_SCHEMA_VERSION_MESSAGE = """


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
@@ -744,6 +744,21 @@ def test_timestamp_are_always_incremented_above_existing_value(self):
         self.assertTrue(0 < current < after,
                         '0 < {} < {}'.format(current, after))
 
+    def test_collection_timestamp_raises_error_when_empty_and_readonly(self):
+        kw = {**self.storage_kw, 'collection_id': 'will-be-empty'}
+        self.storage.readonly = True
+        with self.assertRaises(exceptions.BackendError):
+            self.storage.collection_timestamp(**kw)
+        self.storage.readonly = False
+
+    def test_collection_timestamp_returns_current_while_readonly(self):
+        kw = {**self.storage_kw, 'collection_id': 'will-be-empty'}
+        ts1 = self.storage.collection_timestamp(**kw)
+        self.storage.readonly = True
+        ts2 = self.storage.collection_timestamp(**kw)
+        self.assertEqual(ts1, ts2)
+        self.storage.readonly = False
+
     def test_create_uses_specified_last_modified_if_collection_empty(self):
         # Collection is empty, create a new record with a specified timestamp.
         last_modified = 1448881675541