Skip to content

Commit d620b37

Browse files
committed
sdk: add thread-safety to local_file backend
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.
1 parent f61b589 commit d620b37

2 files changed

Lines changed: 136 additions & 14 deletions

File tree

sdk/basyx/aas/backend/local_file.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ def __init__(self, directory_path: str):
175175
= weakref.WeakValueDictionary()
176176
self._object_cache_lock = threading.Lock()
177177

178+
# Prevent concurrent write operations to avoid TOCTOU problems
179+
self._write_lock = threading.Lock()
180+
178181
# We need to prevent multiple instances of LocalFileIdentifiableStore performing R/W operations on the same
179182
# directory in order to ensure cache validity. The directory is locked as soon as it exists.
180183
self._dir_lock = DirectoryLock(self.directory_path)
@@ -266,10 +269,11 @@ def add(self, x: model.Identifiable) -> None:
266269
:raises KeyError: If an object with the same id exists already in the object store
267270
"""
268271
logger.debug("Adding object %s to Local File Store ...", repr(x))
269-
with self._dir_lock.ensure_locked():
270-
if os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))):
271-
raise KeyError("Identifiable with id {} already exists in local file database".format(x.id))
272-
self._write_atomic(x)
272+
with self._write_lock:
273+
with self._dir_lock.ensure_locked():
274+
if os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))):
275+
raise KeyError("Identifiable with id {} already exists in local file database".format(x.id))
276+
self._write_atomic(x)
273277
with self._object_cache_lock:
274278
self._object_cache[x.id] = x
275279

@@ -280,10 +284,11 @@ def commit(self, x: model.Identifiable) -> None:
280284
:param x: The object to persist
281285
:raises KeyError: If the object is not present in the store
282286
"""
283-
with self._dir_lock.ensure_locked():
284-
if not os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))):
285-
raise KeyError("No AAS object with id {} exists in local file database".format(x.id))
286-
self._write_atomic(x)
287+
with self._write_lock:
288+
with self._dir_lock.ensure_locked():
289+
if not os.path.exists("{}/{}.json".format(self.directory_path, self._transform_id(x.id))):
290+
raise KeyError("No AAS object with id {} exists in local file database".format(x.id))
291+
self._write_atomic(x)
287292

288293
def discard(self, x: model.Identifiable) -> None:
289294
"""
@@ -293,11 +298,12 @@ def discard(self, x: model.Identifiable) -> None:
293298
:raises KeyError: If the object does not exist in the database
294299
"""
295300
logger.debug("Deleting object %s from Local File Store database ...", repr(x))
296-
with self._dir_lock.ensure_locked():
297-
try:
298-
os.remove("{}/{}.json".format(self.directory_path, self._transform_id(x.id)))
299-
except FileNotFoundError as e:
300-
raise KeyError("No AAS object with id {} exists in local file database".format(x.id)) from e
301+
with self._write_lock:
302+
with self._dir_lock.ensure_locked():
303+
try:
304+
os.remove("{}/{}.json".format(self.directory_path, self._transform_id(x.id)))
305+
except FileNotFoundError as e:
306+
raise KeyError("No AAS object with id {} exists in local file database".format(x.id)) from e
301307
with self._object_cache_lock:
302308
self._object_cache.pop(x.id, None)
303309

sdk/test/backend/test_local_file.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import tempfile
1111
import threading
1212
import concurrent.futures
13-
from typing import Callable
13+
from typing import Callable, cast
1414

1515
from unittest import TestCase
1616

@@ -307,3 +307,119 @@ def test_reload_discard(self) -> None:
307307
self.identifiable_store = local_file.LocalFileIdentifiableStore(store_path)
308308
self.identifiable_store.discard(example_submodel)
309309
self.assertNotIn(example_submodel, self.identifiable_store)
310+
311+
312+
class _GatedLocalFileStore(local_file.LocalFileIdentifiableStore):
313+
"""
314+
Patch :meth:`_write_atomic()` to control and enforce concurrent writes.
315+
"""
316+
317+
def __init__(self, directory_path: str, *args, **kwargs):
318+
super().__init__(directory_path)
319+
self._gate = threading.Event() # gate to run _write_atomic()
320+
self._gate.set()
321+
self._inside = threading.Event() # signal _write_atomic() entry
322+
323+
def _write_atomic(self, x: model.Identifiable) -> None:
324+
self._inside.set()
325+
self._gate.wait(timeout=5)
326+
super()._write_atomic(x)
327+
328+
329+
class LocalFileBackendConcurrencyTest(TestCase):
330+
def setUp(self):
331+
self.store = _GatedLocalFileStore(store_path)
332+
self.store.check_directory(create=True)
333+
334+
def tearDown(self):
335+
try:
336+
self.store.clear()
337+
finally:
338+
self.store.close()
339+
shutil.rmtree(store_path)
340+
341+
def _example_submodels(self) -> tuple[model.Submodel, model.Submodel]:
342+
first_submodel = model.Submodel(
343+
id_='https://example.org/BackendTest',
344+
submodel_element={
345+
model.Property(id_short='Prop', value_type=model.datatypes.String, value='first')
346+
}
347+
)
348+
349+
second_submodel = model.Submodel(
350+
id_='https://example.org/BackendTest',
351+
submodel_element={
352+
model.Property(id_short='Prop', value_type=model.datatypes.String, value='second')
353+
}
354+
)
355+
356+
return first_submodel, second_submodel
357+
358+
def test_concurrent_add(self):
359+
"""Checks that second add for same ID fails"""
360+
first_submodel, second_submodel = self._example_submodels()
361+
362+
self.store._gate.clear()
363+
self.store._inside.clear()
364+
barrier = threading.Barrier(2, timeout=5)
365+
all_done = threading.Barrier(3, timeout=5)
366+
367+
def first():
368+
self.store.add(first_submodel)
369+
all_done.wait()
370+
371+
def second():
372+
self.store._inside.wait()
373+
barrier.wait()
374+
with self.assertRaises(KeyError) as ex:
375+
self.store.add(second_submodel)
376+
377+
all_done.wait()
378+
self.assertIn("already exists", ex.exception.args[0])
379+
380+
def control():
381+
self.store._inside.wait()
382+
barrier.wait()
383+
self.store._gate.set()
384+
385+
all_done.wait()
386+
submodel = self.store.get_item("https://example.org/BackendTest")
387+
self.assertIsInstance(submodel, model.Submodel)
388+
sm_property = submodel.get_referable("Prop")
389+
self.assertIsInstance(sm_property, model.Property)
390+
self.assertEqual(sm_property.value, "first")
391+
392+
run_threads([first, second, control])
393+
394+
def test_concurrent_commit_discard(self):
395+
"""Checks that discard is not overwritten by concurrent commit"""
396+
submodel, altered_submodel = self._example_submodels()
397+
self.store.add(submodel)
398+
399+
self.store._gate.clear()
400+
self.store._inside.clear()
401+
barrier = threading.Barrier(2, timeout=5)
402+
all_done = threading.Barrier(3, timeout=5)
403+
404+
def first():
405+
self.store.commit(submodel)
406+
all_done.wait()
407+
408+
def second():
409+
self.store._inside.wait()
410+
barrier.wait()
411+
self.store.discard(submodel)
412+
all_done.wait()
413+
414+
def control():
415+
self.store._inside.wait()
416+
barrier.wait()
417+
self.store._gate.set()
418+
419+
all_done.wait()
420+
with self.assertRaises(KeyError) as ex:
421+
self.store.get_item("https://example.org/BackendTest")
422+
423+
self.assertIn("No Identifiable", ex.exception.args[0])
424+
425+
run_threads([first, second, control])

0 commit comments

Comments
 (0)