8000 Low Level ETag API by alexanderwhatley · Pull Request #59 · firebase/firebase-admin-python · GitHub
[go: up one dir, main page]

Skip to content

Low Level ETag API #59

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Aug 26, 2017
Merged

Low Level ETag API #59

merged 13 commits into from
Aug 26, 2017

Conversation

alexanderwhatley
Copy link

Implemented low level ETag API discussed in #55. I decided not to implement check_etag, as it's easiest just to call get_etag and then check. I also renamed _update_with_etag to _set_with_etag as the name makes more sense.

@hiranya911 hiranya911 self-assigned this Aug 16, 2017
@hiranya911 hiranya911 self-requested a review August 16, 2017 17:40
@@ -125,40 +125,91 @@ def child(self, path):
full_path = self._pathurl + '/' + path
return Reference(client=self._client, path=full_path)

def get(self):
"""Returns the value at the current location of the database.
def _get_with_etag(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

How about we just move this logic straight into the get() method, and get rid of this helper? transaction() can call self.get(etag=True).

etag = headers.get('ETag')
return etag, data

def get_etag(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps call it etag()? That sounds more consistent with the other methods we have in this module like child() and reference().

headers={'X-Firebase-ETag' : 'true'},
resp_headers=True,
params='print=silent')
etag = headers.get('ETag')
Copy link
Contributor

Choose a reason for hiding this comment

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

return headers.get('ETag')

headers={'if-match': etag})
except ApiCallError as error:
detail = error.detail
if detail.response.headers and 'ETag' in detail.response.headers:
Copy link
Contributor

Choose a reason for hiding this comment

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

Check for detail.response is not None here (check the code in master now).


def delete(self):
"""Deletes this node from the database.
"""Deleted this node from the database.
Copy link
Contributor

Choose a reason for hiding this comment

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

Revert.

headers={'X-Firebase-ETag' : 'true'},
resp_headers=True)
etag = headers.get('ETag')
return etag, data
Copy link
Contributor

Choose a reason for hiding this comment

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

Recommend changing the order here to return data, etag


Returns:
object: Decoded JSON value of the current database Reference.
object: ETag of the current database Reference.
Copy link
Contributor

Choose a reason for hiding this comment

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

str: ETag of the....

def _set_with_etag(self, value, etag):
"""Sets the data at this location to the specified value, if the etag matches.
"""
if not value or not isinstance(value, dict):
Copy link
Contributor

Choose a reason for hiding this comment

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

The value doesn't have to be a dict right? I think any JSON-serializable value should work here.

success = True
snapshot = value
try:
self._client.request_oneway('put', self._add_suffix(), json=value,
Copy link
Contributor

Choose a reason for hiding this comment

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

I had done a bit of refactoring here. Make sure some of those changes remain.

'get', self._add_suffix(), headers={'X-Firebase-ETag' : 'true'}, resp_headers=True)
etag = headers.get('ETag')
return etag, data
def _set_with_etag(self, value, etag):
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if this should also be made part of the set() method. That would allow callers of set(value, etag=True) to access the return values.

@hiranya911
Copy link
Contributor

Looks pretty good overall. Just a few things to improve, and a couple of questions.

@alexanderwhatley
Copy link
Author

Ok pushed some changes. There are a couple of lint errors in db.py that I'm not sure how to fix, perhaps you can weigh in? Thanks.


Returns:
object: Tuple of False, current location's etag, and snapshot of location's data
if passed in etag does not match.
Copy link
Contributor

Choose a reason for hiding this comment

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

Indent by 4 spaces. Lint error is possibly due to a trailing whitespace at the end of the line.

TypeError: If the value is not JSON-serializable.
ApiCallError: If an error occurs while communicating with the remote database server.
ApiCallError: If an error occurs while communicating with the remote database server,
or if the ETag does not match.
Copy link
Contributor

Choose a reason for hiding this comment

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

Indent by 4 spaces. Might want to add a lint ignore directive here to fix the 2nd lint error.

# pylint: disable=missing-raises-doc

Might have to experiment a bit to get the placement of the directive right.

@@ -235,38 +249,33 @@ def transaction(self, transaction_update):
Unlike a normal `set()`, which just overwrites the data regardless of its previous state,
`transaction()` is used to modify the existing value to a new value, ensuring there are
no conflicts with other clients simultaneously writing to the same location.

Copy link
Contributor

Choose a reason for hiding this comment

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

Please add these lines back. Here and below.

"""Returns the value at the current location of the database.
def etag(self):
"""Returns the ETag at the current location of the database.
Returns:
Copy link
Contributor

Choose a reason for hiding this comment

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

Add an empty line before Returns:.


Returns:
object: Decoded JSON value of the current database Reference.
object: Decoded JSON value of the current database Reference if etag=False, otherwise
the decoded JSON value and the corresponding ETag.
Copy link
Contributor

Choose a reason for hiding this comment

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

Indent by 4 spaces.

F438 resp = self._do_request(method, urlpath, **kwargs)
if resp_headers:
if resp_headers and params == 'print=silent':
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we just return resp.json(), resp.headers in this case? Does that trigger an error? If not I'd say simply return the tuple, and ignore the payload value (which I assume would get set to None).

Copy link
Author

Choose a reason for hiding this comment

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

No, if print=silent, resp.json() throws an error message.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok. Lets leave it as it is for now. I'll try to think of a better way to refactor this bit out.

@alexanderwhatley
Copy link
Author

Made some more changes, please take a look.

Copy link
Contributor
@hiranya911 hiranya911 left a comment

Choose a reason for hiding this comment

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

LGTM with a few open comments. I will get the internal review process for this started. Thanks @alexanderwhatley for another awesome contribution.

params='print=silent')
return headers.get('ETag')

def get(self, etag=False):
Copy link
Contributor

Choose a reason for hiding this comment

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

Document the etag parameter.

Args:
  etag: A boolean indicating whether to return the ETag of the current location along with the value (optional).

"""Sets the data at this location to the given value.

Copy link
Contributor

Choose a reason for hiding this comment

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

Add this line back.


Args:
value: JSON-serialable value to be set at this location.
etag: Value of ETag that we want to check.
Copy link
Contributor

Choose a reason for hiding this comment

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

Add (optional). at the end.

resp = self._do_request(method, urlpath, **kwargs)
if resp_headers:
if resp_headers and params == 'print=silent':
Copy link
Contributor

Choose a reason for hiding this comment

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

Ok. Lets leave it as it is for now. I'll try to think of a better way to refactor this bit out.

@hiranya911
Copy link
Contributor
hiranya911 commented Aug 17, 2017

After thinking about this API a bit more deeply, I think I've arrived at the following design:

ref.get(etag=False)
ref.etag()
ref.compare_and_set(expected_etag, update_value)

I didn't much like the fact that set() method not returning anything in one case, and returning a 3-tuple in the other. So I left it unchanged, and introduced a new compare_and_set() method. This should make the intent clear and improve readability of user code as well.

I will use this as a starting point for our internal reviews. But lets not make any code changes just yet. Lets wait for the review process to come to an end. In either case I believe this PR has all the bits of logic necessary to support the functionality we wish to expose. So it should be easy to refactor the public API in anyway we want.

@hiranya911
Copy link
Contributor
hiranya911 commented Aug 24, 2017

Hi @alexanderwhatley. Sorry about the delay, but I finally have an update to share with you. After our internal review process, we have reached consensus on the following API:

ref.get(etag=False)
ref.set_if_unchanged(expected_etag, value) => (success, value, etag)
ref.get_if_changed(etag) => (success, value, etag)

The argument was get_if_changed() is more useful in practice than a method that only returns the etag string. For example, in your caching scenario:

# Initial read to populate cache
value, etag = ref.get(etag=True)
update_cache(ref.key, value, etag)

And after some time...

success, value, etag = ref.get_if_changed(etag_from_cache)
if success:
  update_cache(ref.key, value, etag)

I believe you already have code necessary for get() and set_if_unchanged(). To implement get_if_changed() you need to use the if-none-match header as mentioned by @caseycrogers in #55. Are you in a position to make these changes in the PR? Otherwise I can try to make the necessary changes on top of what you have implemented so far.

@alexanderwhatley
Copy link
Author
alexanderwhatley commented Aug 24, 2017 via email

@alexanderwhatley
Copy link
Author

@hiranya911, I pushed some changes for the new API. It's not clear to me how to handle the case where the ETag matches in the get_if_changed method, however. If the method fails, then it raises a 304 status, which is not an error status, and so it is not caught.
Furthermore, it seems like the response's JSON in this case (accessed by resp.json()) is empty, and so it throws a JSONDecodeError when trying to unserialize it. How should I go about handling this case? Thanks.

@hiranya911
Copy link
Contributor

@alexanderwhatley I suppose, for now, you can directly call client._do_request() from your method, and test for the status code. You will have to add a pylint directive to ignore the warning generated for calling a hidden method. This is ugly, but it's ok for now. I've been separately working on refactoring all the HTTP client code in the SDK, and that will take care of it.

@googlebot
Copy link

We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for the commit author(s). If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA (login here to double check)? If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google.
In order to pass this check, please resolve this problem and have the pull request author add another comment and the bot will run again.

@alexanderwhatley
Copy link
Author

Hi @hiranya911, I pushed some more changes. It still seems like if the if-none-match header matches the ETag provided, then the server does not return any of the data stored in the current location.

@googlebot
Copy link

CLAs look good, thanks!

@hiranya911
Copy link
Contributor

That is expected with a 304 Not Modified response. Since the content at server hasn't changed, we can rely on the client-side cached values. You should just return (False, None, None) for this case.

Copy link
Contributor
@hiranya911 hiranya911 left a comment

Choose a reason for hiding this comment

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

This looks almost ready. Lets remove the etag() method, and fix the Git conflict (just sync up with the master branch), and then I can merge.

I will do a bit of refactoring on top of what you've implemented once this has been merged.

@@ -125,31 +125,70 @@ def child(self, path):
full_path = self._pathurl + '/' + path
return Reference(client=self._client, path=full_path)

def get(self):
"""Returns the value at the current location of the database.
def etag(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

Lets remove this method. Our API review team decided to implement get_if_changed() instead of etag().

@googlebot
Copy link

We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for the commit author(s). If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA (login here to double check)? If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google.
In order to pass this check, please resolve this problem and have the pull request author add another comment and the bot will run again.

@googlebot
Copy link

CLAs look good, thanks!

@alexanderwhatley
Copy link
Author

Done. I also made some changes to the unit tests.

@hiranya911 hiranya911 merged commit 5744b69 into firebase:master Aug 26, 2017
@hiranya911
Copy link
Contributor

@alexanderwhatley Thank you for patiently seeing this through. Great to have you as a contributor.

@hiranya911
Copy link
Contributor
hiranya911 commented Aug 26, 2017

@alexanderwhatley I'm in the process of refactoring the DB module. However, I'm seeing a weird behavior with the new get_if_changed() method. I would appreciate if you can verify this for me independently.

Steps:

  1. Checkout the branch https://github.com/firebase/firebase-admin-python/tree/hkj-db-refactor
  2. Uncomment line 98 of integration/test_db.py
  3. Run the integration test suite

At least in my test environment, after executing test_get_if_changed(), the test suite seems to hang. It looks like a 304 response from the server leaves the HTTP session in an inconsistent state. Can you or somebody verify this for me?

Update:

Actually I see it now in master branch too. The integration test suite hangs after test_get_if_changed().

@alexanderwhatley
Copy link
Author
alexanderwhatley commented Aug 27, 2017

@hiranya911, hmm yeah, I'm getting it hanging too. It actually happened yesterday before I pushed the code as well, but I assumed it was due to the slow hotel wifi...

@hiranya911
Copy link
Contributor

Thanks for checking. I'm looking into it. This might be a server-side issue.

@hiranya911
Copy link
Contributor

This was partially released today. We removed the get_if_changed() method from the public API temporarily due to a bug in the server-side API. We'll add it back once the server team has fixed the issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants
0