Skip to content

ngclient: Be better with concurrent instances #2836

Open

Description

@jku

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"?

CC @spencerschrock, let me know if you have thoughts

Activity

  1. spencerschrock commented on May 22, 2025

    @spencerschrock

    Possible improvement: lockfile

    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 :)

  2. stefanberger commented on Jun 2, 2025

    @stefanberger

    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)
    
    

    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 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.

  3. jku commented on Jun 3, 2025

    @jku
    MemberAuthor

    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
  4. jku commented on Aug 15, 2025

    @jku
    MemberAuthor

    Updating status:

    • 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
  5. jku commented on Aug 19, 2025

    @jku
    MemberAuthor

    Status update

    • 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()

  6. stefanberger commented on Aug 20, 2025

    @stefanberger

    @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.

    I think you know this code much better than I do... please go ahead.

  7. jku commented on Aug 22, 2025

    @jku
    MemberAuthor

    making some notes:

    • 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions