Skip to content
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

Fix logic bug in external file clean up #956

Merged
merged 19 commits into from
Sep 14, 2021
Merged
Show file tree
Hide file tree
Changes from 17 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
### 0.13.3 -- TBD
* Bugfix - Dependencies not properly loaded on populate. (#902) PR #919
* Bugfix - Replace use of numpy aliases of built-in types with built-in type. (#938) PR #939
* Bugfix - `ExternalTable.delete` should not remove row on error (#953) PR #956
* Bugfix - Fix error handling of remove_object function in `s3.py` (#952) PR #955

### 0.13.2 -- May 7, 2021
Expand Down
28 changes: 17 additions & 11 deletions datajoint/external.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ def _remove_external_file(self, external_path):
if self.spec['protocol'] == 's3':
self.s3.remove_object(external_path)
elif self.spec['protocol'] == 'file':
Path(external_path).unlink()
try:
Path(external_path).unlink()
except FileNotFoundError:
pass

def exists(self, external_filepath):
"""
Expand Down Expand Up @@ -338,17 +341,20 @@ def delete(self, *, delete_external_files=None, limit=None, display_progress=Tru
# delete items one by one, close to transaction-safe
error_list = []
for uuid, external_path in items:
guzman-raphael marked this conversation as resolved.
Show resolved Hide resolved
try:
count = (self & {'hash': uuid}).delete_quick(get_count=True) # optimize
except Exception:
pass # if delete failed, do not remove the external file
else:
assert count in (0, 1)
row = (self & {'hash': uuid}).fetch()
if row.size:
try:
self._remove_external_file(external_path)
except Exception as error:
error_list.append((uuid, external_path,
str(error) if errors_as_string else error))
(self & {'hash': uuid}).delete_quick()
except Exception:
pass # if delete failed, do not remove the external file
else:
try:
self._remove_external_file(external_path)
except Exception as error:
# adding row back into table after failed delete
self.insert1(row[0])
Copy link
Member

Choose a reason for hiding this comment

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

set skip_duplicates=True to address potential race conditions.

Copy link
Member

Choose a reason for hiding this comment

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

I think it's okay not to re-insert if the delete fails. The procedure will return the undeleted files to the user for special handling. Without implementing an actual transaction management process, we can allow some orphaned files in the external storage and provide an additional cleanup utility.

Copy link
Collaborator

@guzman-raphael guzman-raphael Sep 13, 2021

Choose a reason for hiding this comment

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

@dimitri-yatsenko That is a good suggestion for the skipping duplicates. Regarding the cleanup, I feel this is actually necessary. There is basically 2 primary concerns:

  • The user has only one chance to catch the errors. Meaning that running the external delete/clean up multiple times will yield no change though it has only been removed from the external tracking table. This gives a false sense that the system has been 'cleaned-up' but it has not been removed from the actual store (this is the main complaint from the user in issue ExternalTable.delete should not remove row on error #953). We can provide an additional utility to resolve this but that would be expensive to run as it would have to 'crawl' the entire store to find objects that don't exist within the external tracking table. By simply inserting it back when there is an exception, we are allowing the error to be more visible but most importantly reproducible.
  • schema.external[store].delete() is not currently documented that it returns a list of the errors. Users are most likely unaware of this feature and therefore aren't using it properly. @zitrosolrac Could you open an issue on this in our datajoint-docs and reference it in ExternalTable.delete should not remove row on error #953? (Filing for now since our new team members haven't been oriented to the docs setup yet).

Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
self.insert1(row[0])
self.insert1(row[0], skip_duplicates=True)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@guzman-raphael Got it! I'll open an issue on this in the datajoint-docs and add the necessary reference.

error_list.append((uuid, external_path,
str(error) if errors_as_string else error))
return error_list


Expand Down
1 change: 1 addition & 0 deletions docs-parts/intro/Releases_lang1.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
----------------------
* Bugfix - Dependencies not properly loaded on populate. (#902) PR #919
* Bugfix - Replace use of numpy aliases of built-in types with built-in type. (#938) PR #939
* Bugfix - `ExternalTable.delete` should not remove row on error (#953) PR #956
* Bugfix - Fix error handling of remove_object function in `s3.py` (#952) PR #955

0.13.2 -- May 7, 2021
Expand Down
29 changes: 25 additions & 4 deletions tests/test_external.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@
from nose.tools import assert_true, assert_equal
from datajoint.external import ExternalTable
from datajoint.blob import pack, unpack

from .schema_external import schema
import datajoint as dj
from .schema_external import stores_config
from .schema_external import SimpleRemote
from .schema_external import stores_config, SimpleRemote, Simple, schema
import os

current_location_s3 = dj.config['stores']['share']['location']
current_location_local = dj.config['stores']['local']['location']

Expand Down Expand Up @@ -47,6 +46,9 @@ def test_s3_leading_slash(index=100, store='share'):
"""
s3 external storage configured with leading slash
"""

oldConfig = dj.config['stores'][store]['location']

value = np.array([1, 2, 3])

id = index
Expand Down Expand Up @@ -97,9 +99,28 @@ def test_s3_leading_slash(index=100, store='share'):
assert_true(np.array_equal(
value, (SimpleRemote & 'simple={}'.format(id)).fetch1('item')))

dj.config['stores'][store]['location'] = oldConfig


def test_file_leading_slash():
"""
file external storage configured with leading slash
"""
test_s3_leading_slash(index=200, store='local')


def test_remove_fail():
# https://github.com/datajoint/datajoint-python/issues/953
data = dict(simple=2, item=[1, 2, 3])
Simple.insert1(data)
path1 = dj.config['stores']['local']['location'] + '/djtest_extern/4/c/'
currentMode = int(oct(os.stat(path1).st_mode), 8)
os.chmod(path1, 0o40555)
(Simple & 'simple=2').delete()
listOfErrors = schema.external['local'].delete(delete_external_files=True)
assert len(listOfErrors) == 1, 'unexpected number of errors'
assert len(schema.external['local'] & dict(hash=listOfErrors[0][0])) == 1, \
'unexpected number of rows in external table'
# ---------------------CLEAN UP--------------------
os.chmod(path1, currentMode)
listOfErrors = schema.external['local'].delete(delete_external_files=True)