RSS Amplifier

~aorith · Apr 13, 2024

locking mechanisms for python scripts

0
Sign in to vote or save

This page cannot be shown here. You can still read it on the original site — the toolbar below keeps your place in the directory.

Today, I wanted to create a basic locking mechanism in Python to prevent certain commands/functions from running concurrently. I have an script that runs inside of a CI/CD pipeline but it can also be triggered manually. The problem is that certain commands within the script shouldn't be executed in parallel and I would like to handle that gracefully. use case Imagine a Python script…

Today, I wanted to create a basic locking mechanism in Python to prevent certain commands/functions from running concurrently.</p>

I have an script that runs inside of a CI/CD pipeline but it can also be triggered manually. The problem is that certain commands within the script shouldn't be executed in parallel and I would like to handle that gracefully.</p>

use case</h2>

Imagine a Python script that manages the compilation and distribution of software builds with two main command modes: build</code> and deploy</code>.</p>

  1. python3 script.py build</code></strong>: Compiles source code into executables and libraries, creating compilation artifacts.</p> </li>

  2. python3 script.py deploy</code></strong>: Tags, packages, and deploys compiled artifacts to an environment.</p> </li> </ol>

    It's easy to notice that it would be a bad idea to run these commands in parallel. If a deploy</code> operation starts while a build</code> is still processing, the deployment might catch incomplete or outdated artifacts.</p>

    There are many things that could go wrong in this scenario.</p>

    first try: initial implementation</h2>

    I thought that this would be pretty easy to implement, I reached the fcntl</a> documentation and came up with this:</p>

    import</span> fcntl</span></span>
    import</span> sys</span></span>
    import</span> time</span></span>
    from</span> contextlib</span> import</span> contextmanager</span></span>
    from</span> pathlib</span> import</span> Path</span></span>
    </span>
    </span>
    @</span>contextmanager</span></span>
    def</span> lock_execution</span>(</span>lock_file_path</span>:</span> Path</span> =</span> Path</span>(</span>"</span>/tmp/.example.lock</span>"</span>)</span>)</span>:</span></span>
        """</span>Prevents parallel execution.</span>"""</span></span>
    </span>
        with</span> open</span>(</span>lock_file_path</span>,</span> "</span>w</span>"</span>)</span> as</span> f</span>:</span></span>
            try</span>:</span></span>
                fcntl</span>.</span>flock</span>(</span>f</span>,</span> fcntl</span>.</span>LOCK_EX</span> |</span> fcntl</span>.</span>LOCK_NB</span>)</span></span>
                yield</span></span>
            except</span> IOError</span>:</span></span>
                print</span>(</span></span>
                    f</span>"</span>Another instance using the lock </span>'</span>{</span>lock_file_path</span>}</span>' is already running.</span>"</span>,</span></span>
                    file</span>=</span>sys</span>.</span>stderr</span>,</span></span>
                )</span></span>
                sys</span>.</span>exit</span>(</span>0</span>)</span></span>
            finally</span>:</span></span>
                fcntl</span>.</span>flock</span>(</span>f</span>,</span> fcntl</span>.</span>LOCK_UN</span>)</span></span>
    </span>
    </span>
    def</span> main</span>(</span>)</span>:</span></span>
        print</span>(</span>"</span>Running main ...</span>"</span>)</span></span>
        with</span> lock_execution</span>(</span>)</span>:</span></span>
            print</span>(</span>"</span>Lock acquired, running ...</span>"</span>)</span></span>
            time</span>.</span>sleep</span>(</span>5</span>)</span></span>
            print</span>(</span>"</span>Done.</span>"</span>)</span></span>
    </span>
    </span>
    if</span> __name__</span> ==</span> "</span>__main__</span>"</span>:</span></span>
        main</span>(</span>)</span></span></code></pre>
    

    The idea is to wrap the code that shouldn't be parallelized with the context manager lock_execution</code> function. Now, when I try to run this script from two terminal windows I get the following:</p>

    #</span> Terminal 1</span></span>
    [</span>aorith@arcadia:</span>~</span>]</span> $ python3 /tmp/first.py</span></span>
    Running</span> main</span> ...</span></span>
    Lock</span> acquired,</span> running</span> ...</span></span>
    </span>
    #</span> Terminal 2</span></span>
    [</span>aorith@arcadia:</span>~</span>]</span> $ python3 /tmp/first.py</span></span>
    Running</span> main</span> ...</span></span>
    Another</span> instance</span> using</span> the</span> lock</span> '</span>/tmp/.example.lock</span>'</span> is</span> already</span> running.</span></span>
    </span>
    #</span> Terminal 1 (after 5 seconds)</span></span>
    Done.</span></span></code></pre>
    

    But what if this script is executed by different users? Only the user that creates the lock file will be able to use the script. Other users will be greeted with this error:</p>

    PermissionError</span>:</span> [</span>Errno</span> 13</span>]</span> Permission</span> denied</span>:</span> '</span>/tmp/.example.lock</span>'</span></span></code></pre>

    second try: making it compatible with multiple users</h2>

    The script cannot just delete the lock file when it finishes as it would introduce race conditions that are very difficult to catch and debug. So let's try chmoding</em> the file upon creation and see what happens.</p>

    @</span>contextmanager</span></span>
    def</span> lock_execution</span>(</span>lock_file_path</span>:</span> Path</span> =</span> Path</span>(</span>"</span>/tmp/.example.lock</span>"</span>)</span>)</span>:</span></span>
        """</span>Prevents parallel execution.</span>"""</span></span>
    </span>
        try</span>:</span></span>
            lock_file_path</span>.</span>touch</span>(</span>)</span></span>
            os</span>.</span>chmod</span>(</span>lock_file_path</span>,</span> 0o</span>666</span>)</span></span>
        except</span> PermissionError</span>:</span></span>
            pass</span></span></code></pre>
    

    Ok, that should give rw</code> permissions to everyone upon creation. Let's try it and see what happens:</p>

    #</span> Terminal 1 (root)</span></span>
    [</span>root@arcadia:</span>~</span>]</span> % python3 /tmp/second.py</span></span>
    Running</span> main</span> ...</span></span>
    Lock</span> acquired,</span> running</span> ...</span></span>
    </span>
    #</span> Terminal 2 (user)</span></span>
    [</span>aorith@arcadia:</span>~</span>]</span> $ python3 /tmp/second.py</span></span>
    Running</span> main</span> ...</span></span>
    Another</span> instance</span> using</span> the</span> lock</span> '</span>/tmp/.example.lock</span>'</span> is</span> already</span> running.</span></span>
    </span>
    #</span> Now the other way around</span></span>
    #</span> Terminal 1 (user)</span></span>
    [</span>aorith@arcadia:</span>~</span>]</span> $ python3 /tmp/second.py</span></span>
    Running</span> main</span> ...</span></span>
    Lock</span> acquired,</span> running</span> ...</span></span>
    </span>
    </span>
    #</span> Terminal 2 (root)</span></span>
    [</span>root@arcadia:</span>~</span>]</span> % python3 /tmp/second.py</span></span>
    Running</span> main</span> ...</span></span>
    Another</span> instance</span> using</span> the</span> lock</span> '</span>/tmp/.example.lock</span>'</span> is</span> already</span> running.</span></span></code></pre>
    

    Looks good, the lock file is created and owned by root but all the users can use it a as lock file without issues.</p>

    Hmm, just to be safe, let's remove the lock file and create it with a regular user:</p>

    #</span> Terminal 1 (root)</span></span>
    [</span>root@arcadia:</span>~</span>]</span> % rm /tmp/.example.lock</span></span>
    [</span>root@arcadia:</span>~</span>]</span> %</span></span>
    </span>
    #</span> Terminal 2 (user)</span></span>
    [</span>aorith@arcadia:</span>~</span>]</span> $ python3 /tmp/second.py</span></span>
    Running</span> main</span> ...</span></span>
    Lock</span> acquired,</span> running</span> ...</span></span>
    </span>
    #</span> Terminal 1 (root)</span></span>
    [</span>root@arcadia:</span>~</span>]</span> % python3 /tmp/second.py</span></span>
    Running</span> main</span> ...</span></span>
    Traceback</span> (most</span> recent</span> call</span> last</span>):</span></span>
      File</span> "</span>/tmp/second.py</span>"</span>,</span> line</span> 42,</span> in</span> <</span>modul</span>e</span>></span></span>
        main</span>(</span>)</span></span>
      File</span> "</span>/tmp/second.py</span>"</span>,</span> line</span> 35,</span> in</span> main</span></span>
        with</span> lock_execution</span>(</span>)</span>:</span></span>
      File</span> "</span>/nix/store/il591rdaydbqr2cysh6vsa2kazxprzsn-python3-3.11.8/lib/python3.11/contextlib.py</span>"</span>,</span> line</span> 137,</span> in</span> __enter__</span></span>
        return</span> next</span>(</span>self.gen</span>)</span></span>
               ^^^^^^^^^^^^^^</span></span>
      File</span> "</span>/tmp/second.py</span>"</span>,</span> line</span> 19,</span> in</span> lock_execution</span></span>
        with</span> open</span>(</span>lock_file_path,</span> "</span>w</span>"</span>)</span> as</span> f:</span></span>
             ^^^^^^^^^^^^^^^^^^^^^^^^^</span></span>
    PermissionError:</span> [Errno</span> 13]</span> Permission</span> denied:</span> '</span>/tmp/.example.lock</span>'</span></span>
    </span>
    [</span>root@arcadia:</span>~</span>]</span> % ls -lrt /tmp/.example.lock</span></span>
    -rw-rw-rw-</span> 1</span> aorith</span> aorith</span> 0</span> abr</span> 13</span> 13:41</span> /tmp/.example.lock</span></span></code></pre>
    

    Wait, what? How comes that root doesn't have permissions ...?</p>

    third try: fix the permissions issue</h2>

    Alright, let's modify the code to workaround the security measure. We are already creating the file with a touch</code> command when it's missing and the write permissions are not required to lock it. So let's change the open</code> mode:</p>

        with</span> open</span>(</span>lock_file_path</span>,</span> "</span>r</span>"</span>)</span> as</span> f</span>:</span></span>
            try</span>:</span></span>
                fcntl</span>.</span>flock</span>(</span>f</span>,</span> fcntl</span>.</span>LOCK_EX</span> |</span> fcntl</span>.</span>LOCK_NB</span>)</span></span></code></pre>
    

    Now the lock file is only open in read mode and it seems to work fine:</p>

    #</span> Terminal 1 (user)</span></span>
    [</span>aorith@arcadia:</span>~</span>]</span> $ python3 /tmp/third.py</span></span>
    Running</span> main</span> ...</span></span>
    Lock</span> acquired,</span> running</span> ...</span></span>
    </span>
    #</span> Terminal 2 (root)</span></span>
    [</span>root@arcadia:</span>~</span>]</span> % python3 /tmp/third.py</span></span>
    Running</span> main</span> ...</span></span>
    Another</span> instance</span> using</span> the</span> lock</span> '</span>/tmp/.example.lock</span>'</span> is</span> already</span> running.</span></span>
    </span>
    [</span>root@arcadia:</span>~</span>]</span> % ls -l /tmp/.example.lock</span></span>
    -rw-rw-rw-</span> 1</span> aorith</span> aorith</span> 0</span> abr</span> 13</span> 13:56</span> /tmp/.example.lock</span></span></code></pre>
    

    Hmm, am I done? Not really... What happens if the protected code raises another IOError</code> exception?</p>

    def</span> main</span>(</span>)</span>:</span></span>
        print</span>(</span>"</span>Running main ...</span>"</span>)</span></span>
        with</span> lock_execution</span>(</span>)</span>:</span></span>
            print</span>(</span>"</span>Lock acquired, running ...</span>"</span>)</span></span>
            Path</span>(</span>"</span>/tmp/this/path/does/not/exists</span>"</span>)</span>.</span>touch</span>(</span>)</span></span>
            time</span>.</span>sleep</span>(</span>5</span>)</span></span>
            print</span>(</span>"</span>Done.</span>"</span>)</span></span></code></pre>
    #</span> Terminal 1 (the only one running the script)</span></span>
    [</span>aorith@arcadia:</span>~</span>]</span> $ python3 /tmp/third.py</span></span>
    Running</span> main</span> ...</span></span>
    Lock</span> acquired,</span> running</span> ...</span></span>
    Another</span> instance</span> using</span> the</span> lock</span> '</span>/tmp/.example.lock</span>'</span> is</span> already</span> running.</span></span></code></pre>
    

    Ehm... I don't have another instance running, what is going on? The answer is that the code block wrapped with the context manager function runs at the position of the yield</code> statement, and that statement is wrapped with a try/except</code> block that captures all the IOError</code> exceptions.</p>

    Not good, errors would be hidden unexpectedly.</p>

    final version: running the code outside of the try/except block</h2>

    We need to move the yield</code> statement outside of the try/except</code> block and be very careful with the finally</code> block as it has priority with some statements as explained here</a>.</p>

    I'm also adding a boolean to move the sys.exit</code> out of the IOError</code> exception, just to be safe:</p>

    @</span>contextmanager</span></span>
    def</span> lock_execution</span>(</span>lock_file_path</span>:</span> Path</span> =</span> Path</span>(</span>"</span>/tmp/.example.lock</span>"</span>)</span>)</span>:</span></span>
        """</span>Prevents parallel execution.</span>"""</span></span>
    </span>
        try</span>:</span></span>
            lock_file_path</span>.</span>touch</span>(</span>)</span></span>
            os</span>.</span>chmod</span>(</span>lock_file_path</span>,</span> 0o</span>666</span>)</span></span>
        except</span> PermissionError</span>:</span></span>
            pass</span></span>
    </span>
        locked</span>:</span> bool</span> =</span> False</span></span>
    </span>
        #</span> https://unix.stackexchange.com/questions/691441/root-cannot-write-to-file-that-is-owned-by-regular-user</span></span>
        with</span> open</span>(</span>lock_file_path</span>,</span> "</span>r</span>"</span>)</span> as</span> f</span>:</span></span>
            try</span>:</span></span>
                fcntl</span>.</span>flock</span>(</span>f</span>,</span> fcntl</span>.</span>LOCK_EX</span> |</span> fcntl</span>.</span>LOCK_NB</span>)</span></span>
                #</span> we don't yield here, we want to raise IOError if it's not coming from flock</span></span>
            except</span> IOError</span>:</span></span>
                locked</span> =</span> True</span></span>
                print</span>(</span></span>
                    f</span>"</span>Another instance using the lock </span>'</span>{</span>lock_file_path</span>}</span>' is already running.</span>"</span>,</span></span>
                    file</span>=</span>sys</span>.</span>stderr</span>,</span></span>
                )</span></span>
            finally</span>:</span></span>
                if</span> locked</span>:</span></span>
                    sys</span>.</span>exit</span>(</span>0</span>)</span></span>
    </span>
                yield</span> #</span> code runs here</span></span>
                fcntl</span>.</span>flock</span>(</span>f</span>,</span> fcntl</span>.</span>LOCK_UN</span>)</span></span></code></pre>
    

    Now when I apply this context manager function over a block of code that raises an IOError</code> (or any) exception its raised normally:</p>

    [</span>aorith@arcadia:</span>~</span>]</span> $ python3 /tmp/finalversion.py</span></span>
    Running</span> main</span> ...</span></span>
    Lock</span> acquired,</span> running</span> ...</span></span>
    Traceback</span> (most</span> recent</span> call</span> last</span>):</span></span>
      File</span> "</span>/tmp/finalversion.py</span>"</span>,</span> line</span> 48,</span> in</span> <</span>modul</span>e</span>></span></span>
        main</span>(</span>)</span></span>
      File</span> "</span>/tmp/finalversion.py</span>"</span>,</span> line</span> 42,</span> in</span> main</span></span>
        Path(</span>"</span>/tmp/this/path/does/not/exists</span>"</span>)</span>.touch</span>(</span>)</span></span>
      File</span> "</span>/nix/store/il591rdaydbqr2cysh6vsa2kazxprzsn-python3-3.11.8/lib/python3.11/pathlib.py</span>"</span>,</span> line</span> 1108,</span> in</span> touch</span></span>
        fd</span> =</span> os.open</span>(</span>self,</span> flags,</span> mode</span>)</span></span>
             ^^^^^^^^^^^^^^^^^^^^^^^^^^</span></span>
    FileNotFoundError:</span> [Errno</span> 2]</span> No</span> such</span> file</span> or</span> directory:</span> '</span>/tmp/this/path/does/not/exists</span>'</span></span></code></pre>

    extra: other ways of acquiring a lock</h2>

    I have considered other ways that do not use a file for locking, for example we can bind a port using the socket</code> library:</p>

    import</span> sys</span></span>
    import</span> socket</span></span>
    </span>
    def</span> some_command</span>(</span>)</span>:</span></span>
            try</span>:</span></span>
                s</span> =</span> socket</span>.</span>socket</span>(</span>)</span></span>
                s</span>.</span>bind</span>(</span>(</span>"</span>127.0.0.1</span>"</span>,</span> 12340</span>)</span>)</span></span>
            except</span> OSError</span>:</span></span>
                print</span>(</span>"</span>Another instance is running...</span>"</span>)</span></span>
                sys</span>.</span>exit</span>(</span>0</span>)</span></span>
    </span>
            #</span> Main code goes here ...</span></span></code></pre>
    

    The problem with this approach is that we need to reserve port numbers and if we are using this locking mechanism with multiple scripts on the same machine we have to pay attention not to use the same port if those scripts can run in parallel. Also port numbers aren't very intuitive for this, when we use a lock file we can give it a descriptive name.</p>

    Another method would be to bind to a unix socket, so we don't lose the advantage of having a lock file:</p>

    @</span>contextmanager</span></span>
    def</span> lock_execution</span>(</span>lock_file_path</span>:</span> Path</span> =</span> Path</span>(</span>"</span>/tmp/.example.lock</span>"</span>)</span>)</span>:</span></span>
        """</span>Prevents parallel execution.</span>"""</span></span>
    </span>
        locked</span>:</span> bool</span> =</span> False</span></span>
    </span>
        try</span>:</span></span>
            s</span> =</span> socket</span>.</span>socket</span>(</span>socket</span>.</span>AF_UNIX</span>,</span> socket</span>.</span>SOCK_DGRAM</span>)</span></span>
            s</span>.</span>bind</span>(</span>str</span>(</span>lock_file_path</span>)</span>)</span></span>
        except</span> OSError</span>:</span></span>
            locked</span> =</span> True</span></span>
            print</span>(</span></span>
                f</span>"</span>Another instance using the lock </span>'</span>{</span>lock_file_path</span>}</span>' is already running.</span>"</span>,</span></span>
                file</span>=</span>sys</span>.</span>stderr</span>,</span></span>
            )</span></span>
        finally</span>:</span></span>
            if</span> locked</span>:</span></span>
                sys</span>.</span>exit</span>(</span>0</span>)</span></span>
    </span>
            try</span>:</span></span>
                yield</span></span>
            finally</span>:</span></span>
                #</span> Make sure that the socket is deleted even if the protected code raises an exception or exits</span></span>
                lock_file_path</span>.</span>unlink</span>(</span>missing_ok</span>=</span>True</span>)</span></span></code></pre>
    

    This seems to also work in all the scenarios that I've tested. It requires an extra try/except</code> block to ensure that the lock/socket file is removed at the end.</p>

    I'll keep using the fcntl</code> method because it seems less hacky</em>.</p>

    Please, leave a comment if you have some other interesting implementations :)</p>

    After some digging, I found out that it's a security measure introduced in the Linux kernel for versions >= 4.9, this stackexchange post</a> has more details about it.</p>

Read on /blog/locking-mechanisms-for-python-scripts/

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.