Skip to content

Write/update DSS API definition atomically#140

Merged
calvinnhieu merged 1 commit intomasterfrom
calvinnhieu-atomic-cache-write
Aug 1, 2018
Merged

Write/update DSS API definition atomically#140
calvinnhieu merged 1 commit intomasterfrom
calvinnhieu-atomic-cache-write

Conversation

@calvinnhieu
Copy link
Copy Markdown

@calvinnhieu calvinnhieu commented Jul 23, 2018

As per @ttung's suggestion (GH-139), these changes update the cached version of the DSS API definition atomically in order to avoid a race condition. This is achieved by writing the fetched file to a temp file and renamed (os.rename) to the desired filename, where the latter operation is atomic.

07/25/18 update:
These changes also handle os.rename Windows specific behavior by deleting the destination file if it exists before renaming. This process is attempted multiple times (5) in order to handle a potential race condition in which the destination file is recreated in between delete and rename operations. These changes also introduce hca.util.fs_helper.py, a static library of functions for interacting with the local filesystem.

Connected to #79

@calvinnhieu calvinnhieu self-assigned this Jul 23, 2018
@calvinnhieu calvinnhieu requested review from kislyuk, mweiden and ttung July 23, 2018 22:34
@codecov-io
Copy link
Copy Markdown

codecov-io commented Jul 23, 2018

Codecov Report

Merging #140 into master will increase coverage by 0.68%.
The diff coverage is 94.87%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #140      +/-   ##
==========================================
+ Coverage   85.75%   86.43%   +0.68%     
==========================================
  Files          31       32       +1     
  Lines        1172     1202      +30     
==========================================
+ Hits         1005     1039      +34     
+ Misses        167      163       -4
Impacted Files Coverage Δ
hca/util/__init__.py 93.09% <100%> (+1.98%) ⬆️
hca/util/fs_helper.py 94.44% <94.44%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 56e2f09...47a5835. Read the comment docs.

Comment thread hca/util/__init__.py Outdated
def swagger_spec(self):
if not self._swagger_spec:
swagger_url = self.config[self.__class__.__name__].swagger_url
temp_swagger_filename = os.path.join(self.config.user_config_dir, "temp_swagger.json")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would use with tempfile.NamedTemporaryFile

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Cool. Thanks!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oof you should then use it with the option to use the user config dir as the tempdir. Otherwise the rename is not atomic either (since it could actually be a copy).

@calvinnhieu calvinnhieu force-pushed the calvinnhieu-atomic-cache-write branch 2 times, most recently from 5370e69 to f331f7b Compare July 24, 2018 16:46
@calvinnhieu
Copy link
Copy Markdown
Author

update: _atomic_write_to_file using NamedTemporaryFile to write temp file and rename (atomic operation) to desired location

Copy link
Copy Markdown
Member

@ttung ttung left a comment

Choose a reason for hiding this comment

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

almost there!

Comment thread hca/util/__init__.py Outdated
"""
with tempfile.NamedTemporaryFile('wb', dir=dir, delete=False) as tmp:
tmp.write(content)
os.rename(tmp.name, filename)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I believe that on Windows, it's not kosher to rename a file that's open. Consider creating the tempfile with delete=False, close it, and then renaming it. Then wrap the entire try-finally with something that cleans up the tempfile in case the rename fails.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also note from https://docs.python.org/2/library/os.html#os.rename:

On Windows, if dst already exists, OSError will be raised even if it is a file

Here's some possible strategies for defeating that: https://bugs.python.org/issue8828

Copy link
Copy Markdown
Contributor

@mweiden mweiden left a comment

Choose a reason for hiding this comment

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

LGTM, once @ttung's last changes have been made.

@calvinnhieu calvinnhieu force-pushed the calvinnhieu-atomic-cache-write branch 2 times, most recently from 6f23f59 to a352bd5 Compare July 24, 2018 21:25
@calvinnhieu
Copy link
Copy Markdown
Author

Addressed Windows issues re: atomic writes

Comment thread hca/util/__init__.py Outdated
with tempfile.NamedTemporaryFile('wb', dir=dir, delete=False) as temp:
temp_filename = temp.name
temp.write(content)
while writing and retry_count < max_retries:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion:

for _ in range(max_retries):
    try:
        os.rename(temp_filename, dest_filename)
        break
    except OSError as ex:
        if ex.errno == errno.EEXIST:
        # handle Windows: https://docs.python.org/2/library/os.html#os.rename
            os.remove(dest_filename)
            continue
        raise
else:
    raise Exception("Could not rename {} to {}".format(temp_filename, dest_filename))

you should be able to get rid of writing and retry_count

Copy link
Copy Markdown
Author

@calvinnhieu calvinnhieu Jul 24, 2018

Choose a reason for hiding this comment

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

Right. My Python's a little rusty, thanks!

Comment thread hca/util/__init__.py Outdated
:param dir: Shared directory of temp and dest file
:param content: Content to write to file
"""
temp_filename = ""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

do you actually need this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Not necessarily, but it clears an IDE warning (and possibly linter?)

Comment thread hca/util/__init__.py Outdated
assert "swagger" in res.json()
with open(swagger_filename, "wb") as fh:
fh.write(res.content)
self._atomic_write_to_file(self._get_path_leaf(swagger_filename),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

os.path.basename doesn't work here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Oh, it would - I was covering an edge case that, actually, we don't need to worry about: https://stackoverflow.com/questions/8384737/extract-file-name-from-path-no-matter-what-the-os-path-format
-> Will replace ntpath.basename with os.path.basename

However, _get_path_leaf handles the trailing slash case (e.g. path/to/file/).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why would swagger_filename end with a trailing slash?

@calvinnhieu calvinnhieu force-pushed the calvinnhieu-atomic-cache-write branch 8 times, most recently from c05639c to 02be576 Compare July 25, 2018 06:54
Comment thread hca/util/__init__.py Outdated
is_cached = os.path.exists(swagger_filename)
if (not is_cached) or (is_cached and
self._get_days_since_last_modified(swagger_filename) >= self._spec_valid_for_days):
if (not is_cached) or \
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

stylistically, i think:

if ((not is_cached) or
    (is_cached and FSHelper.get_days_since_last_modified(swagger_filename) >= self._spec_valid_for_days)):

is better.

Furthermore, I would say that you can remove the second is_cached as that is implied if you ever reach that clause.

Comment thread hca/util/__init__.py Outdated
assert "swagger" in res.json()
with open(swagger_filename, "wb") as fh:
fh.write(res.content)
self._atomic_write_to_file(self._get_path_leaf(swagger_filename),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why would swagger_filename end with a trailing slash?

Comment thread hca/util/fs_helper.py
temp.write(content)
FSHelper._atomic_rename(temp_filename, dest_filename)
finally:
if temp_filename and os.path.isfile(temp_filename):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

if NamedTemporaryFile throws as exception, then temp_filename will not be defined. one possible strategy is:

if 'temp_filename' in locals() and os.path.isfile(temp_filename):

another strategy is to define temp_filename before the loop.

Comment thread hca/util/fs_helper.py Outdated
FSHelper._atomic_rename(temp_filename, dest_filename)
finally:
if temp_filename and os.path.isfile(temp_filename):
os.remove(temp_filename)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

you should wrap os.remove in a try-except EnvironmentError clause.

Comment thread hca/util/fs_helper.py Outdated
os.remove(temp_filename)

@staticmethod
def _atomic_rename(src, dest):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion:

def _atomic_rename(src, dest, max_retries=5):

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I like it

Comment thread hca/util/fs_helper.py
FSHelper._handle_rename_error(e, src, dest)

@staticmethod
def _handle_rename_error(err, src, dest):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think you could just inline this method. It's not particularly large and it's not called elsewhere.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yea I agree, however, it's pulled out now since when it was inline in _atomic_rename, it violated our linter's Code Complexity limit (7 > 6)

Comment thread hca/util/fs_helper.py Outdated
:param dest: Rename destination filename
"""
if err.errno == errno.EEXIST:
os.remove(dest)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

os.remove(..) should be wrapped in try-except EnvironmentError

@calvinnhieu calvinnhieu force-pushed the calvinnhieu-atomic-cache-write branch 5 times, most recently from 9a209ff to ef55643 Compare July 26, 2018 20:03
@calvinnhieu calvinnhieu force-pushed the calvinnhieu-atomic-cache-write branch 2 times, most recently from 298950a to de89aa7 Compare July 26, 2018 22:12
@calvinnhieu calvinnhieu requested a review from ttung July 26, 2018 22:51
@calvinnhieu calvinnhieu force-pushed the calvinnhieu-atomic-cache-write branch from de89aa7 to 1870bdc Compare July 26, 2018 23:18
Comment thread hca/util/__init__.py Outdated
if "swagger_filename" in self.config:
swagger_filename = self.config.swagger_filename
if not swagger_filename.startswith("/"):
use_local_config = True
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

shouldn't use_local_config always be set if swagger_filename is in the config?

furthermore, can you not just use that clause to test rather than creating a new variable?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Right. Good catch

Comment thread hca/util/__init__.py Outdated
is_cached = os.path.exists(swagger_filename)
if (not is_cached) or (is_cached and
self._get_days_since_last_modified(swagger_filename) >= self._spec_valid_for_days):
if ((not use_local_config) and ((not os.path.exists(swagger_filename)) or
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this is the wrong set of boolean conditions, if I understand the requirements correctly. If there's a swagger_filename in the config, then we shouldn't ever refresh the config.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Right

Comment thread hca/util/fs_helper.py Outdated
:param shared_dir: Shared directory of temp and dest file
:param content: Content to write to file
"""
temp_filename = ""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

time_filename = None

Comment thread hca/util/fs_helper.py Outdated
try:
os.remove(temp_filename)
except EnvironmentError:
print("Failed to clean up temporary file {}".format(temp_filename))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks!

Comment thread hca/util/fs_helper.py Outdated
os.remove(temp_filename)
except EnvironmentError:
print("Failed to clean up temporary file {}".format(temp_filename))
raise
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think this is a case where we should fatal. I would just warn and move on.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Makes sense

Comment thread hca/util/fs_helper.py Outdated
try:
os.remove(dest)
except EnvironmentError:
print("Failed to delete {}".format(dest))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Comment thread hca/util/fs_helper.py Outdated
except EnvironmentError:
print("Failed to delete {}".format(dest))
else:
print("Failed to rename {} to {}".format(src, dest))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

raise Exception("Failed to rename {} to {}".format(src, dest), err)

Comment thread hca/util/fs_helper.py Outdated
return (now - last_modified).days

@staticmethod
def get_path_leaf(path):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is still unnecessary as we will never pass in a path with a trailing backslash.

Comment thread Makefile Outdated
test: lint install
# https://github.com/HumanCellAtlas/dcp-cli/issues/127
coverage run --source=hca -m unittest discover -v -t . -s test/upload
coverage run --source=hca -m unittest discover -v -t . -s test -p test_dss_*.py
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can you make this a separate PR and land this ASAP? Seems like a pretty big deal.

@calvinnhieu calvinnhieu force-pushed the calvinnhieu-atomic-cache-write branch from 1870bdc to da2d24a Compare July 27, 2018 18:51
@calvinnhieu calvinnhieu requested a review from ttung July 27, 2018 20:09
Comment thread Makefile Outdated
# https://github.com/HumanCellAtlas/dcp-cli/issues/127
coverage run --source=hca -m unittest discover -v -t . -s test/upload
coverage run --source=hca -m unittest discover -v -t . -s test -p test_dss_*.py
coverage run --source=hca -m unittest discover -v -t . -s test -p test_fs_helper.py
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

remove this change, please.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

How come?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Oh got it. I'll merge #143 first

Comment thread hca/util/__init__.py Outdated
is_cached = os.path.exists(swagger_filename)
if (not is_cached) or (is_cached and
self._get_days_since_last_modified(swagger_filename) >= self._spec_valid_for_days):
if (("swagger_filename" not in self.config) and ((not os.path.exists(swagger_filename)) or
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

do you want the auto-update to happen if someone specifies swagger_filename in the config?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No, if someone specifies swagger_filename in the config, we assume that they are using a local copy of the Swagger file. We wouldn't want to overwrite their local copy and only want to update to the latest Swagger file in the user config directory ~/hca/.config.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I made a mistake reading your code. I think it's correct, but your indentation is confusing. I would do it like this:

if (("swagger_filename" not in self.config) and
    ((not os.path.exists(swagger_filename)) or
     (fs.get_days_since_last_modified(swagger_filename) >= self._spec_valid_for_days))):

@calvinnhieu calvinnhieu force-pushed the calvinnhieu-atomic-cache-write branch from da2d24a to ea138fd Compare July 30, 2018 18:36
@calvinnhieu calvinnhieu requested a review from ttung July 30, 2018 18:41
Copy link
Copy Markdown
Member

@ttung ttung left a comment

Choose a reason for hiding this comment

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

see comment about indentation.

Comment thread hca/util/__init__.py Outdated
is_cached = os.path.exists(swagger_filename)
if (not is_cached) or (is_cached and
self._get_days_since_last_modified(swagger_filename) >= self._spec_valid_for_days):
if (("swagger_filename" not in self.config) and ((not os.path.exists(swagger_filename)) or
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I made a mistake reading your code. I think it's correct, but your indentation is confusing. I would do it like this:

if (("swagger_filename" not in self.config) and
    ((not os.path.exists(swagger_filename)) or
     (fs.get_days_since_last_modified(swagger_filename) >= self._spec_valid_for_days))):

@calvinnhieu calvinnhieu force-pushed the calvinnhieu-atomic-cache-write branch from ea138fd to 47a5835 Compare August 1, 2018 22:32
@calvinnhieu calvinnhieu merged commit c68f119 into master Aug 1, 2018
@mweiden mweiden deleted the calvinnhieu-atomic-cache-write branch August 1, 2018 22:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants