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 9 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
16 changes: 15 additions & 1 deletion cliquet/resource/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,11 @@ 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)
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I think elsewhere we do this:

        deleted = self.model.delete_record(record,
                                           last_modified=last_modified)

return self.postprocess(deleted, action=ACTIONS.DELETE)

#
Expand Down Expand Up @@ -528,6 +532,15 @@ def process_record(self, new, old=None):
:returns: the processed record.
:rtype: dict
"""
if old is None or self.model.modified_field not in old:
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.
new_last_modified = new.get(self.model.modified_field)
if (new_last_modified and
new_last_modified <= old[self.model.modified_field]):
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: please use intermediary variable to avoid indenting in if conditions

del new[self.model.modified_field]

return new

def apply_changes(self, record, changes):
Expand Down Expand Up @@ -1069,6 +1082,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
42 changes: 34 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,28 @@ 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.
"""
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()

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

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 +254,15 @@ 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 for it to not
# be kept (in no case we want to keep the old one).
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: for it to not be kept is not really useful after need to delete

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 @@ -214,14 +214,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 @@ -265,14 +267,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 @@ -281,6 +285,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 @@ -310,7 +315,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 @@ -321,8 +326,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 @@ -337,7 +342,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
40 changes: 34 additions & 6 deletions cliquet/storage/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,42 @@ 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()
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


print(is_specified, current, collection_timestamp, record)
Copy link
Contributor

Choose a reason for hiding this comment

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

print() left


pipe.set(key, collection_timestamp)
pipe.execute()
return current
except redis.WatchError: # pragma: no cover
Expand Down Expand Up @@ -192,7 +215,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 +234,13 @@ def delete(self, collection_id, parent_id, object_id,

existing = self._decode(encoded_item)

# Need to delete the last_modified field of the record for it to not
# be kept (in no case we want to keep the old one).
del existing[modified_field]

Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Need to remove whitespaces. for flake8

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