You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ngclient: Be better with concurrent instances #2836
Users may run multiple updaters at the same time -- it's not useful but it can happen with longer dependency chains like sigstore/sigstore-python#1403: model-signing uses sigstore-python which uses python-tuf...
Currently we don't handle the potential conflicts WRT reading and writing metadata and target files to local cache that result from concurrent updaters. This is especially an issue with windows where concurrent file access leads to issues more often but it can be problematic in linux too -- this is why currently we just document Updater as "should be a singleton".
Possible improvement: lockfile
I think it makes sense to not try to solve this for just a single process but multiple real processes running updaters (something I can easily imagine happening in real life). In that case I think a lock file per repository would be best we can do:
check for lock file before reading/writing
create lock file, read/write, remove lock file
The reason this hasn't been done is that lockfiles are a pain to do especially cross platform.
Some open questions:
what to do when a lock file exists -- log a warning and wait (for how long?)? I don't usually like when libraries wait for things but maybe this works? What if the lock file is stale (let's say hours old)?
manually handling lockfiles in a way that works on windows is painful... but we've also done a lot of work to avoid unnecessary dependencies in this project. Using a dependency for this is likely the smaller evil
when exactly do we lock?
locking for lifetime of Updater probably has annoying side effects and is not expected by users
locking for duration of refresh() and get_targetinfo() seems logical at least at first glance. Locking for duration of download_target() is probably less important but could be done
Possible improvement: global state
If we are not interested in multiple processes but just multiple threads with updaters, we could always add some global state tracking... It sounds quite unappealing but possible. I feel like I'd rather advice python-tuf users to use ngclient Updater as a "per-repository-singleton"?
I have changed it (= venv/lib64/python3.13/site-packages/tuf/ngclient/updater.py) locally to this here and it seems to work:
def _update_root_symlink(self) -> None:
"""Symlink root.json to current trusted root version in root_history/"""
linkname = os.path.join(self._dir, "root.json")
version = self._trusted_set.root.version
current = os.path.join("root_history", f"{version}.root.json")
with open(os.path.join(self._dir, current + ".lock"), "wb") as f: <---- create new file; could also be just '.lock'
lock_file(f) <---- lock it
with contextlib.suppress(FileNotFoundError):
os.remove(linkname)
os.symlink(current, linkname)
PS: What I don't know about TUF is what role the version plays here and what kind of effects the symbolic link would have on version 11 for example when version 12 set the link and version 11 reads a file that may not be in its version 11 format but in v12 format. In this case I would not know what the solution would be. The above resolves the immediate issue of avoiding a FileExistsError.
the root.json symlink is not the only potential conflict. My initial thought was that it would make sense to have a high level lock in the public methods:
locking for duration of refresh() and get_targetinfo() seems logical at least at first glance. Locking for duration of download_target() is probably less important but could be done
If someone makes a convincing case that this high level lock is not needed or that more granular locking is useful, I'll listen...
WRT using a dependency or not, I think the Advisory locking (fcntl.lockf()) is appealing but there's a couple of downsides:
IIUC this does not protect against access from multiple threads in same process (maybe this is not that relevant)
I'm sure we will at some point end up debugging platform specific issues... testing this stuff comprehensively is so very difficult
This has mostly been waiting for my comments, I've now added some to the PR
I'm currently leaning towards accepting the fix for the immediate issue that happens on windows: A more comprehensive solution might be more correct... but I really don't want weird platform specific locking issues becoming the next problem we need to solve
this contained approach seems less likely to create hanging locks and does not create a lot of new complexity
it's possible the concurrency issues WRT metadata dir I was expecting to be there may not actually exist (some details in PR comment)
if this helps, great. If there are still concurrency issues after this, then I can have a go at protecting all metadata_dir access
The PR has some lint/test issues, otherwise it seems fine to me
further testing in the PR seems to show that my initial expectation was correct: protecting access just to the symlink only moves the problem elsewhere. We need to protect all file accesses
I think this means we go back to the drawing board
Design considerations
locking for lifetime of Updater is too much:
it requires the users to release the Updater when they're done
even if the users are doing things correctly the lock time can still be significant (as artifact download could take a long time)
locking individual file writes during refresh() also seems wrong: the core metadata refresh really should be as atomic as we can make it
the middle ground seems to be to handle refresh() and download_target() separately:
refresh() should use a single lock for the runtime of the method
same lock has to be used during init() (when root may be updated) and during get_target_info() when delegated targets may be updated
download _target() could do the same... but the difference is that
a) the call can take a long time: it could include downloading gigabytes of data
b) unlike refresh(), multiple download_target() calls could run at same time without issues (as long as they don't write to same file)
the unoptimized version of download_target() lock looks very easy, just lock before shutil.copyfileobj(target_file, destination_file) and call it a day... unfortunately that looks like it will lock for the duration of the download as well -- but it would already work for parallel downloading of different artifacts
a better version would modify download_target so what we download the whole file into a temp file (on the same disk), then get a lock on the actual file, then move temp file to actual file and release lock
This looks implementable. The optimization of download_target() can/should be left for later to keep this PR minimal.
Tasks
Create a unit test that proves this works on all platforms. Maybe running this in two processes at same time would do it?
for _ in range(1000):
Updater(...).refresh()
lock for the duration of refresh() using advisory locking over <METADATA_DIR>/.lock. Make sure lock is released in case of exceptions etc. Use the same lock during the other times metadata is modified
figure out locking in download _target(): I would prefer if the locking used the actual artifact file as the lock file: any other file in <TARGET_DIR>/ has the potential of conflict with other artifacts
Update existing tests (the new file in <METADATA_DIR> will break tests)
@stefanberger are you interested in continuing with this? I'm happy to let you handle it but if if you're not interested I will get to it eventually.As a special case the same lock should be used for writing the initial root in init()
some sources claim msvcrt.locking() requires (wihtout documenting it) a non-zero file but every test I do seems to not require it (the byte range can be longer than the file is). I will assume this requirement is no longer there
msvcrt.locking() is... not very smart: it tries to get a lock, and if it does not get it it retries 9 times with 1 second delay. So at least in a artifical test case like mentioned above it will eventually error out after 9 seconds (because the other process keeps taking the lock). I guess we could retry locking() indefinitely or at least a few times -- that sort of matches what we do on posix as I think we would wait forever if the lock was not released
this added with the fact that typically open() just fails if another process has the file open (preventing us from running msvcrt.locking() to check the locked status) makes the windows implementation really dumb... In essence msvcrt.locking() has about the same value as just trying to write to the file has.
the code will be complicated enough that there is a chance for bugs
a potential dependency for this is https://github.com/tox-dev/filelock/ -- it looks ok although I really would like to use the actual file as the lock file in the case of artifacts... and filelock does not support that
Activity
spencerschrock commentedon May 22, 2025
I'll just chime in here that lockfiles were what was discussed originally in model-transparency:
sigstore/model-transparency#465 (comment)
Of course it's easier to say lockfile than to be the one to implement it :)
stefanberger commentedon Jun 2, 2025
I have changed it (=
venv/lib64/python3.13/site-packages/tuf/ngclient/updater.py) locally to this here and it seems to work:This relies on file locking proposed here (not using 3rd party library): https://stackoverflow.com/questions/489861/locking-a-file-in-python
PS: What I don't know about TUF is what role the
versionplays here and what kind of effects the symbolic link would have on version 11 for example when version 12 set the link and version 11 reads a file that may not be in its version 11 format but in v12 format. In this case I would not know what the solution would be. The above resolves the immediate issue of avoiding a FileExistsError.jku commentedon Jun 3, 2025
the root.json symlink is not the only potential conflict. My initial thought was that it would make sense to have a high level lock in the public methods:
If someone makes a convincing case that this high level lock is not needed or that more granular locking is useful, I'll listen...
WRT using a dependency or not, I think the Advisory locking (
fcntl.lockf()) is appealing but there's a couple of downsides:Use lock file to avoid exception due to concurrenct symlink creation
Use lock file to avoid exception due to concurrenct symlink creation
Use a lock file to avoid exceptions due to concurrenct symlink creation
Use a lock file to avoid exceptions due to concurrenct symlink creation
jku commentedon Aug 15, 2025
Updating status:
Use a lock file to avoid exceptions due to concurrenct symlink creation
jku commentedon Aug 19, 2025
Status update
Design considerations
refresh()anddownload_target()separately:refresh()should use a single lock for the runtime of the methoddownload _target()could do the same... but the difference is thata) the call can take a long time: it could include downloading gigabytes of data
b) unlike refresh(), multiple
download_target()calls could run at same time without issues (as long as they don't write to same file)shutil.copyfileobj(target_file, destination_file)and call it a day... unfortunately that looks like it will lock for the duration of the download as well -- but it would already work for parallel downloading of different artifactsThis looks implementable. The optimization of download_target() can/should be left for later to keep this PR minimal.
Tasks
refresh()using advisory locking over<METADATA_DIR>/.lock. Make sure lock is released in case of exceptions etc. Use the same lock during the other times metadata is modifieddownload _target(): I would prefer if the locking used the actual artifact file as the lock file: any other file in<TARGET_DIR>/has the potential of conflict with other artifacts<METADATA_DIR>will break tests)@stefanberger are you interested in continuing with this? I'm happy to let you handle it but if if you're not interested I will get to it eventually.As a special case the same lock should be used for writing the initial root in init()
stefanberger commentedon Aug 20, 2025
I think you know this code much better than I do... please go ahead.
jku commentedon Aug 22, 2025
making some notes:
msvcrt.locking()to check the locked status) makes the windows implementation really dumb... In essencemsvcrt.locking()has about the same value as just trying to write to the file has.