Skip to content
This repository has been archived by the owner on Mar 28, 2019. It is now read-only.

Keep last modified #604

Merged
merged 15 commits into from
Dec 1, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion cliquet/resource/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,10 @@ def delete(self):
record = self._get_record_or_404(self.record_id)
self._raise_412_if_modified(record)

deleted = self.model.delete_record(record)
# Retreive the last_modified information from a querystring if present.
last_modified = self.request.GET.get('last_modified')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: do not forget to update the API docs with a .. versionadded:: section


deleted = self.model.delete_record(record, last_modified=last_modified)
return self.postprocess(deleted, action=ACTIONS.DELETE)

#
Expand All @@ -506,6 +509,7 @@ def process_record(self, new, old=None):
.. code-block:: python

def process_record(self, new, old=None):
new = super(MyResource, self).process_record(new, old)
version = old['version'] if old else 0
new['version'] = version + 1
return new
Expand All @@ -517,6 +521,7 @@ def process_record(self, new, old=None):
from cliquet.errors import raise_invalid

def process_record(self, new, old=None):
new = super(MyResource, self).process_record(new, old)
if new['browser'] not in request.headers['User-Agent']:
raise_invalid(self.request, name='browser', error='Wrong')
return new
Expand All @@ -528,6 +533,17 @@ def process_record(self, new, old=None):
:returns: the processed record.
:rtype: dict
"""
new_last_modified = new.get(self.model.modified_field)
not_specified = old is None or self.model.modified_field not in old

if new_last_modified is None or not_specified:
return new
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes me realize that a super() is missing from the examples of process_record in documentation


# Drop the new last_modified if lesser or equal to the old one.
is_greater = new_last_modified <= old[self.model.modified_field]
if new_last_modified and is_greater:
del new[self.model.modified_field]

return new

def apply_changes(self, record, changes):
Expand Down Expand Up @@ -1073,6 +1089,7 @@ def process_record(self, new, old=None):
"""Read permissions from request body, and in the case of ``PUT`` every
existing ACE is removed (using empty list).
"""
new = super(ShareableResource, self).process_record(new, old)
permissions = self.request.validated.get('permissions', {})

annotated = new.copy()
Expand Down
11 changes: 6 additions & 5 deletions cliquet/resource/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def update_record(self, record, parent_id=None,unique_fields=None):
modified_field=self.modified_field,
auth=self.auth)

def delete_record(self, record, parent_id=None):
def delete_record(self, record, parent_id=None, last_modified=None):
"""Delete a record in the collection.

Override to perform actions or post-process records after deletion
Expand Down Expand Up @@ -237,7 +237,8 @@ def delete_record(self, record):
id_field=self.id_field,
modified_field=self.modified_field,
deleted_field=self.deleted_field,
auth=self.auth)
auth=self.auth,
last_modified=last_modified)


class ShareableModel(Model):
Expand Down Expand Up @@ -323,11 +324,11 @@ def update_record(self, record, parent_id=None, unique_fields=None):
annotated[self.permissions_field] = permissions
return annotated

def delete_record(self, record_id, parent_id=None):
def delete_record(self, record_id, parent_id=None, last_modified=None):
"""Delete record and its associated permissions.
"""
record = super(ShareableModel, self).delete_record(record_id,
parent_id)
record = super(ShareableModel, self).delete_record(
record_id, parent_id, last_modified=last_modified)
perm_object_id = self.get_permission_object_id(record_id)
self.permission.delete_object_permissions(perm_object_id)

Expand Down
47 changes: 39 additions & 8 deletions cliquet/storage/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ def strip_deleted_record(self, resource, parent_id, record,
return deleted

def set_record_timestamp(self, collection_id, parent_id, record,
modified_field=DEFAULT_MODIFIED_FIELD):
timestamp = self._bump_timestamp(collection_id, parent_id)
modified_field=DEFAULT_MODIFIED_FIELD,
last_modified=None):
timestamp = self._bump_timestamp(collection_id, parent_id, record,
modified_field,
last_modified=last_modified)
record[modified_field] = timestamp
return record

Expand Down Expand Up @@ -171,7 +174,8 @@ def collection_timestamp(self, collection_id, parent_id, auth=None):
return ts
return self._bump_timestamp(collection_id, parent_id)

def _bump_timestamp(self, collection_id, parent_id):
def _bump_timestamp(self, collection_id, parent_id, record=None,
modified_field=None, last_modified=None):
"""Timestamp are base on current millisecond.

.. note ::
Expand All @@ -180,11 +184,34 @@ def _bump_timestamp(self, collection_id, parent_id):
the time will slide into the future. It is not problematic since
the timestamp notion is opaque, and behaves like a revision number.
"""
# XXX factorize code from memory and redis backends.
is_specified = (record is not None
and modified_field in record
or last_modified is not None)
if is_specified:
# If there is a timestamp in the new record, try to use it.
if last_modified is not None:
current = last_modified
else:
current = record[modified_field]
else:
# Otherwise, use a new one.
current = utils.msec_time()

# Bump the timestamp only if it's more than the previous one.
previous = self._timestamps[collection_id].get(parent_id)
current = utils.msec_time()
if previous and previous >= current:
current = previous + 1
self._timestamps[collection_id][parent_id] = current
collection_timestamp = previous + 1
else:
collection_timestamp = current

# In case the timestamp was specified, the collection timestamp will
# be different from the updated timestamp. As such, we want to return
# the one of the record, and not the collection one.
if not is_specified:
current = collection_timestamp

self._timestamps[collection_id][parent_id] = collection_timestamp
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Since the flow of this function is a bit complex, I would suggest putting a summary of the spec in the docstring (or a bit more comments).
It has been only a few days since we paired on this, and I already feel unsure about the intentions :D

return current

def create(self, collection_id, parent_id, record, id_generator=None,
Expand Down Expand Up @@ -233,10 +260,14 @@ def delete(self, collection_id, parent_id, object_id,
id_field=DEFAULT_ID_FIELD, with_deleted=True,
modified_field=DEFAULT_MODIFIED_FIELD,
deleted_field=DEFAULT_DELETED_FIELD,
auth=None):
auth=None, last_modified=None):
existing = self.get(collection_id, parent_id, object_id)
# Need to delete the last_modified field of the record.
del existing[modified_field]

self.set_record_timestamp(collection_id, parent_id, existing,
modified_field=modified_field)
modified_field=modified_field,
last_modified=last_modified)
existing = self.strip_deleted_record(collection_id,
parent_id,
existing)
Expand Down
24 changes: 15 additions & 9 deletions cliquet/storage/postgresql/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,16 @@ def create(self, collection_id, parent_id, record, id_generator=None,
AND parent_id = :parent_id
AND collection_id = :collection_id
)
INSERT INTO records (id, parent_id, collection_id, data)
INSERT INTO records (id, parent_id, collection_id, data, last_modified)
VALUES (:object_id, :parent_id,
:collection_id, (:data)::JSONB)
:collection_id, (:data)::JSONB,
from_epoch(:last_modified))
RETURNING id, as_epoch(last_modified) AS last_modified;
"""
placeholders = dict(object_id=record_id,
parent_id=parent_id,
collection_id=collection_id,
last_modified=record.get(modified_field),
data=json.dumps(record))
with self.client.connect() as conn:
# Check that it does violate the resource unicity rules.
Expand Down Expand Up @@ -267,14 +269,16 @@ def update(self, collection_id, parent_id, object_id, record,
modified_field=DEFAULT_MODIFIED_FIELD,
auth=None):
query_create = """
INSERT INTO records (id, parent_id, collection_id, data)
INSERT INTO records (id, parent_id, collection_id, data, last_modified)
VALUES (:object_id, :parent_id,
:collection_id, (:data)::JSONB)
:collection_id, (:data)::JSONB,
from_epoch(:last_modified))
RETURNING as_epoch(last_modified) AS last_modified;
"""

query_update = """
UPDATE records SET data=(:data)::JSONB
UPDATE records SET data=(:data)::JSONB,
last_modified=from_epoch(:last_modified)
WHERE id = :object_id
AND parent_id = :parent_id
AND collection_id = :collection_id
Expand All @@ -283,6 +287,7 @@ def update(self, collection_id, parent_id, object_id, record,
placeholders = dict(object_id=object_id,
parent_id=parent_id,
collection_id=collection_id,
last_modified=record.get(modified_field),
data=json.dumps(record))

record = record.copy()
Expand Down Expand Up @@ -312,7 +317,7 @@ def delete(self, collection_id, parent_id, object_id,
id_field=DEFAULT_ID_FIELD, with_deleted=True,
modified_field=DEFAULT_MODIFIED_FIELD,
deleted_field=DEFAULT_DELETED_FIELD,
auth=None):
auth=None, last_modified=None):
if with_deleted:
query = """
WITH deleted_record AS (
Expand All @@ -323,8 +328,8 @@ def delete(self, collection_id, parent_id, object_id,
AND collection_id = :collection_id
RETURNING id
)
INSERT INTO deleted (id, parent_id, collection_id)
SELECT id, :parent_id, :collection_id
INSERT INTO deleted (id, parent_id, collection_id, last_modified)
SELECT id, :parent_id, :collection_id, from_epoch(:last_modified)
FROM deleted_record
RETURNING as_epoch(last_modified) AS last_modified;
"""
Expand All @@ -339,7 +344,8 @@ def delete(self, collection_id, parent_id, object_id,
"""
placeholders = dict(object_id=object_id,
parent_id=parent_id,
collection_id=collection_id)
collection_id=collection_id,
last_modified=last_modified)

with self.client.connect() as conn:
result = conn.execute(query, placeholders)
Expand Down
41 changes: 41 additions & 0 deletions cliquet/storage/postgresql/migrations/migration_008_009.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
CREATE OR REPLACE FUNCTION from_epoch(epoch BIGINT) RETURNS TIMESTAMP AS $$
BEGIN
RETURN TIMESTAMP WITH TIME ZONE 'epoch' + epoch * INTERVAL '1 millisecond';
END;
$$ LANGUAGE plpgsql
IMMUTABLE;


CREATE OR REPLACE FUNCTION bump_timestamp()
RETURNS trigger AS $$
DECLARE
previous TIMESTAMP;
current TIMESTAMP;

BEGIN
--
-- This bumps the current timestamp to 1 msec in the future if the previous
-- timestamp is equal to the current one (or higher if was bumped already).
--
-- If a bunch of requests from the same user on the same collection
-- arrive in the same millisecond, the unicity constraint can raise
-- an error (operation is cancelled).
-- See https://github.com/mozilla-services/cliquet/issues/25
--
previous := collection_timestamp(NEW.parent_id, NEW.collection_id);

IF NEW.last_modified IS NULL THEN
current := clock_timestamp();
IF previous >= current THEN
current := previous + INTERVAL '1 milliseconds';
END IF;
NEW.last_modified := current;
END IF;

RETURN NEW;
END;
$$ LANGUAGE plpgsql;


-- Bump storage schema version.
INSERT INTO metadata (name, value) VALUES ('storage_schema_version', '9');
19 changes: 14 additions & 5 deletions cliquet/storage/postgresql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ END;
$$ LANGUAGE plpgsql
IMMUTABLE;

CREATE OR REPLACE FUNCTION from_epoch(epoch BIGINT) RETURNS TIMESTAMP AS $$
BEGIN
RETURN TIMESTAMP WITH TIME ZONE 'epoch' + epoch * INTERVAL '1 millisecond';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this handle epoch being null?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently it does 👍

END;
$$ LANGUAGE plpgsql
IMMUTABLE;

--
-- Actual records
--
Expand Down Expand Up @@ -161,6 +168,7 @@ RETURNS trigger AS $$
DECLARE
previous TIMESTAMP;
current TIMESTAMP;

BEGIN
--
-- This bumps the current timestamp to 1 msec in the future if the previous
Expand All @@ -172,14 +180,15 @@ BEGIN
-- See https://github.com/mozilla-services/cliquet/issues/25
--
previous := collection_timestamp(NEW.parent_id, NEW.collection_id);
current := clock_timestamp();

IF previous >= current THEN
current := previous + INTERVAL '1 milliseconds';
IF NEW.last_modified IS NULL THEN
current := clock_timestamp();
IF previous >= current THEN
current := previous + INTERVAL '1 milliseconds';
END IF;
NEW.last_modified := current;
END IF;

NEW.last_modified := current;

RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Expand Down
38 changes: 32 additions & 6 deletions cliquet/storage/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,41 @@ def collection_timestamp(self, collection_id, parent_id, auth=None):
return self._bump_timestamp(collection_id, parent_id)

@wrap_redis_error
def _bump_timestamp(self, collection_id, parent_id):
def _bump_timestamp(self, collection_id, parent_id, record=None,
modified_field=None, last_modified=None):

key = '{0}.{1}.timestamp'.format(collection_id, parent_id)
while 1:
with self._client.pipeline() as pipe:
try:
pipe.watch(key)
previous = pipe.get(key)
pipe.multi()
current = utils.msec_time()
# XXX factorize code from memory and redis backends.
is_specified = (record is not None
and modified_field in record
or last_modified is not None)
if is_specified:
# If there is a timestamp in the new record,
# try to use it.
if last_modified is not None:
current = last_modified
else:
current = record[modified_field]
else:
current = utils.msec_time()

if previous and int(previous) >= current:
current = int(previous) + 1
pipe.set(key, current)
collection_timestamp = int(previous) + 1
else:
collection_timestamp = current

# Return the newly generated timestamp as the current one
# only if nothing else was specified.
if not is_specified:
current = collection_timestamp
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Redis is a MemoryBasedStorage I believe this piece of code can be shared between the two


pipe.set(key, collection_timestamp)
pipe.execute()
return current
except redis.WatchError: # pragma: no cover
Expand Down Expand Up @@ -192,7 +214,7 @@ def delete(self, collection_id, parent_id, object_id,
id_field=DEFAULT_ID_FIELD, with_deleted=True,
modified_field=DEFAULT_MODIFIED_FIELD,
deleted_field=DEFAULT_DELETED_FIELD,
auth=None):
auth=None, last_modified=None):
record_key = '{0}.{1}.{2}.records'.format(collection_id,
parent_id,
object_id)
Expand All @@ -211,8 +233,12 @@ def delete(self, collection_id, parent_id, object_id,

existing = self._decode(encoded_item)

# Need to delete the last_modified field.
del existing[modified_field]

self.set_record_timestamp(collection_id, parent_id, existing,
modified_field=modified_field)
modified_field=modified_field,
last_modified=last_modified)
existing = self.strip_deleted_record(collection_id, parent_id,
existing)

Expand Down
Loading