Repository navigation
Conversation
Previously the LocalFileIdentifiableStore allowed multiple instances (potentially across multiple processes) to acces the same directory. This can lead to undetected invalid cache entries. Additionally it would increase the overhead for adding thread-safety to the R/W operations as synchronization across multiple instances would be necessary. In order to overcome these limitations, these changes introduce a directory lock mechanism, that only allows one instance of the class to hold a lock on a `.lock` file in the directory. A contex manager is used to secure operations on the directory and prevent concurrent release of the lock. For now this only work on POSIX machines, as it relies on `flock` functionatily which is not provided by Windows.
Until now the `LocalFileIdentifiableStore` had a TOCTOU race condition
in the `add()` and `commit()` methods. After a file was checked
for existance a concurrent running `add()` or `discard()` could still
create or remove the file before the original invocation accesses it.
To fix this, all write accesses to a file need to be mutual exclusive.
In this implementation I chose to use a global `_writing_lock` to
lock write operations to all files of the store, as locking individual
files has no performance benefit, due to serialization of multiple threads
through Pythons GIL.
This implementation prevents the following concurrency issues:
1. Two concurrent `add()`: Instead of silent overwriting, first call will
succeed, second failes with `KeyError`.
2. `commit()` + `discard()`: Instead of `commit()` creating file after
`discard()` deleted it, the file is now certainly discarded.
|
@hpoeche could you update this branch with the changes from |
|
I resolved all merge conflicts and made sure |
s-heppner
left a comment
There was a problem hiding this comment.
The DescriptorStore in server/app/backend/local_file.py needs the same mechanism. I'm fine with moving this into a separate issue.
The cache is now updated and read under the same `self._write_lock` as the files to prevent inconsitencies between actual file existance and cache entries.
Previously, systems not providing the fcntl library could run the LocalFileIdentifiableStore, only a warning would be logged but no concurrency control was available during runtime. With these changes, the standard behavior is to fail on `DirectoryLock` init with `RuntimeError` if fcntl is not available. Users can opt-out from this check via constructor parameters. Corresponding tests were added.
s-heppner
left a comment
There was a problem hiding this comment.
I'll mention it now here, so that we hopefully don't forget:
Failing by default on systems without fcntl is a breaking change for Windows users, so we need to mention it in the release notes.
| with self._dir_lock.ensure_locked(): | ||
| for name in os.listdir(self.directory_path): | ||
| if name.lower().endswith(".json"): | ||
| yield self.get_identifiable_by_hash(name[:-5]) |
There was a problem hiding this comment.
The ensure_locked() stays locked as long as the iterator is alive.
If someone stops iterating early, _active_accesses never drops back to 0, and close() / release() blocks until the iterator is garbage collected. If that happens in the same thread, it never returns:
it = iter(store)
next(it)
store.close() # hangsI think this might be solved by moving the yield outside of the lock, but I'm not sure if that can cause other problems again:
with self._dir_lock.ensure_locked():
names = [name for name in os.listdir(self.directory_path) if name.lower().endswith(".json")]
for name in names:
yield self.get_identifiable_by_hash(name[:-5])| with self._dir_lock.ensure_locked(): | ||
| try: | ||
| with open("{}/{}.json".format(self.directory_path, hash_), "r") as file: | ||
| data = json.load(file, cls=json_deserialization.AASFromJsonDecoder) | ||
| obj = data["data"] | ||
| except FileNotFoundError as e: | ||
| raise KeyError( |
There was a problem hiding this comment.
This is the same problem as my comment on add() / discard(), but when reading.
The file is read without _write_lock, and the cache is only updated afterwards. So this can happen:
- Thread A reads the file and has
objin hand. - Thread B runs
discard(): deletes the file and pops the cache entry. - Thread A takes
_write_lockand putsobjback into the cache.
After that, get_item() returns an object whose file no longer exists. Can you move the file read under _write_lock too?
| self._dir_lock_file = open(self.lock_path, "a") # use "a" to not swap the inode as in "w" | ||
|
|
||
| if _fcntl is None: | ||
| if self._strict_locking: | ||
| raise RuntimeError("fcntl unavailable, directory locking not possible." | ||
| "Set strict_locking=False to continue unsafe operation on this system.") | ||
|
|
||
| logger.warning("fcntl unavailable; directory locking is disabled (Windows?)") | ||
| self._is_locked_flag = True | ||
| return | ||
|
|
||
| try: | ||
| _fcntl.flock(self._dir_lock_file.fileno(), _fcntl.LOCK_EX | _fcntl.LOCK_NB) | ||
| except BlockingIOError: | ||
| # dir_lock already taken by other process | ||
| self._dir_lock_file.close() | ||
| self._dir_lock_file = None | ||
| raise LockedDirectoryError(f"Directory {self.directory_path} " | ||
| "is already locked by another DirectoryLock") |
There was a problem hiding this comment.
If the program encounters any other error than BlockingIOError, (for example a filesystem specific error), _dir_lock_file stays open. Can you add a second try that closes the file on any failure?
| raise RuntimeError("fcntl unavailable, directory locking not possible." | ||
| "Set strict_locking=False to continue unsafe operation on this system.") |
There was a problem hiding this comment.
Missing space:
| raise RuntimeError("fcntl unavailable, directory locking not possible." | |
| "Set strict_locking=False to continue unsafe operation on this system.") | |
| raise RuntimeError("fcntl unavailable, directory locking not possible. " | |
| "Set strict_locking=False to continue unsafe operation on this system.") |
| return hashlib.sha256(identifier.encode("utf-8")).hexdigest() | ||
|
|
||
|
|
||
| class LocalFileObjectStore(LocalFileIdentifiableStore): |
There was a problem hiding this comment.
One could argue that this class needs the same disable_strict_locking option as LocalFileIdentifiableStore.
On the other hand, Windows users using the old class need to touch it anyways, might be a good time to migrate to the new class. But that means, they'd need to know...
How would you solve this?
| except LockedDirectoryError as ex: | ||
| raise RuntimeError(f"Directory {self.directory_path} is already in use" | ||
| f" by another LocalFileIdentifiableStore instance.") from ex |
There was a problem hiding this comment.
We catch a more specific LockedDirectoryError (which is a subclass of RuntimeError) and raise a more generic RuntimeError. I say we raise the new error as LockedDirectoryError:
| except LockedDirectoryError as ex: | |
| raise RuntimeError(f"Directory {self.directory_path} is already in use" | |
| f" by another LocalFileIdentifiableStore instance.") from ex | |
| except LockedDirectoryError as ex: | |
| raise LockedDirectoryError(f"Directory {self.directory_path} is already in use" | |
| f" by another LocalFileIdentifiableStore instance.") from ex |
| logger = logging.getLogger(__name__) | ||
|
|
||
| class LockedDirectoryError(RuntimeError): | ||
| pass |
There was a problem hiding this comment.
| logger = logging.getLogger(__name__) | |
| class LockedDirectoryError(RuntimeError): | |
| pass | |
| logger = logging.getLogger(__name__) | |
| class LockedDirectoryError(RuntimeError): | |
| pass |
Previously the
LocalFileIdentifiableStorehad no access control for concurrent access through the same or multiple instances on the same directory.This caused serious problems especially if this store is used as storage backend for a multi-worker WSGI server.
Issues with multiple instances
Multiple instances of the
LocalFileIdentifiableStorewere allowed to operate on the same directory. The main issue lies in theself._object_cacheof each instance which does not get updated or invalidated if the other instance is modifying the underlying files through a write operation. In order for the caches to be coherent with the persistent content a inter-process cache synchronization would be necessary. This would introduce to much overhead so we decided to lock the access to a directory to a single instance ofLocalFileIdentifiableStore. This also helps to simplify resolving the following issues.Issues with concurrent calls to a single instance
Concurrent calls to writing methods
add()andcommit()currently race in the_write_atomic()method on theos.replace(...)call. Whenever two threads invoke the method with an identifiable that has the same ID but different content both calls succeed. However, the persisted content depends on which thread gets scheduled to execute theos.replace(...)last.This occurs multiple times across the writing functions. For example in the
add()function the time of check (TOC) for existence may be different that the time of use (TOU) where the new file is created. A concurrently executedadd()might be scheduled such that at TOC both threads find no file for the ID so they both create a new file at TOU. Here again the last scheduled thread silently overwrites the content from the first.Changes
In order to fix the multi-instance issue, these changes introduce a directory lock mechanism, that only allows one instance of the class to hold a lock on a
.lockfile in the directory. A context manager is used to secure operations on the directory and prevent concurrent release of the lock.Caveat: For now this only work on POSIX machines, as it relies on
flockfunctionality which is not provided by Windows.To fix concurrent writes from the same store instance, all write accesses to a file need to be mutual exclusive.
In this implementation I chose to use a global
_writing_lockto lock write operations to all files of the store, as locking individual files has no performance benefit, due to serialization of multiple threads through Pythons GIL.This implementation prevents the following concurrency issues:
add(): Instead of silent overwriting, first call will second fails withKeyError.commit()+discard(): Instead ofcommit()creating file afterdiscard()deleted it, the file is now certainly discarded. Depending on the scheduled ordercommit()may raise aKeyError.Fixes #554