portalocker

repository·develop·Indexed 43 days ago

https://github.com/wolph/portalocker

A Python library for cross-platform file locking on Linux, macOS, and Windows. It provides various synchronization primitives including standard file locks (Lock), reentrant locks (RLock), PID file inspection (PidFileLock), concurrency limiting (NamedBoundedSemaphore), and distributed coordination via Redis (RedisLock).

Tokens
13.7K
Snippets
40
Records
57
Agent score
53%

What's inside portalocker

  1. Choose the right lock type for your task

    develop

    Portalocker provides different locking mechanisms depending on your coordination needs:

    • File locks: Use these to coordinate readers and writers on a shared filesystem (Linux, macOS, and Windows).
    • Redis locks: Use these to coordinate processes across different machines using Redis pub/sub.
    • Workers and slots: Use these to run a single worker with a PID file, or to limit concurrency using a semaphore.
  2. Windows msvcrt locker fix for files larger than 64KiB

    develop
    In version 4.0.0, the msvcrt locker on Windows was fixed to lock from byte 0 instead of the current file position for raw file descriptors. This ensures mutual exclusion works correctly for files larger than 64KiB.
  3. Improved race condition handling in TemporaryFileLock and PidFileLock

    develop

    In version 4.0.0, TemporaryFileLock and PidFileLock include fixes for an unlock-then-unlink race condition.

    Key changes:

    • On POSIX, release() now unlinks the file before unlocking.
    • acquire() re-verifies file identity after locking.
    • release() is now a no-op if the object does not hold the lock, preventing stale objects from unlinking files held by current holders.
  4. Behavioral changes in portalocker.open_atomic

    develop
    In version 4.0.0, portalocker.open_atomic no longer silently replaces a destination file that was created by another process while the context was open. If a concurrent process creates the destination file, portalocker.open_atomic will now raise a FileExistsError and preserve the file created by the concurrent winner.
  5. Understand Advisory vs Mandatory locking behavior

    develop

    The behavior of file locks depends heavily on your operating system, which affects how your application interacts with other processes:

    • POSIX (Linux/macOS): Advisory Locking. Locks are a convention. The kernel only enforces them between processes that explicitly request a lock. An unrelated process that simply opens and writes to the file will not be blocked and will succeed immediately. To ensure safety, every participant accessing the file must use a locking mechanism.
    • Windows: Mandatory Locking. The OS enforces locks. An unrelated process attempting to read or write an overlapping range of a locked file will fail with a PermissionError, even if that process never requested a lock.

    Note: Linux removed support for mandatory locking in kernel 5.15. Always treat POSIX locks as advisory.

    # POSIX Example: Process B succeeds even if Process A holds an exclusive lock
    import fcntl
    
    # Process A
    fh = open('data.txt', 'r+')
    fcntl.flock(fh, fcntl.LOCK_EX)
    
    # Process B (No lock requested)
    with open('data.txt', 'r+') as other:
        other.write('clobbered')  # Succeeds on POSIX
  6. Limitations of the combine command

    develop

    When using the combine command, be aware of the following constraints:

    • External Dependencies: The combined file does not inline external dependencies. For example, RedisLock still requires the redis package to be installed in the environment where the combined file is used.
    • ASCII Encoding Requirement: The combine process opens all source files, README.md, and LICENSE using encoding='ascii'. All files in the portalocker source tree must be ASCII-only. Any non-ASCII characters (such as em dashes, curly quotes, or box-drawing characters) will cause a UnicodeDecodeError during the combination process.
  7. Avoid issues with forked child processes

    develop

    A forked child process inherits the RedisLock object and the parent's sockets. However, the child's release() (whether explicit or via garbage collection) only drops local references and does not perform a network teardown.

    Warning: An UNSUBSCRIBE sent over the inherited socket would silently revoke the parent's lock.

    Requirement: If a child process needs to hold a lock, it must construct its own independent RedisLock instance.

  8. Choose the right lock type

    develop

    Select a lock implementation based on your specific coordination requirements:

    TaskLock ClassDescription
    Coordinate file accessLockStandard file locking.
    Reentrant lockingRLockAllows the same lock instance to be acquired again by the owner.
    Limit concurrencyNamedBoundedSemaphoreLimits the number of concurrent workers using named slots.
    Inspect PIDs / SingletonsPidFileLockUsed to check if a process is running or to run a singleton.
    Cross-machine coordinationRedisLockUses Redis to coordinate processes across different machines.
  9. Detecting a lost RedisLock

    develop

    When a RedisLock is revoked (e.g., due to a connection error or subscription loss), the holder can detect this loss through four distinct channels. Note that since version 4.2.0, the subscription uses a dedicated connection with a zero-retry policy, meaning the first read error after revocation is terminal.

    Loss Detection Channels:

    1. RedisLock.lost property: Becomes True and remains True through RedisLock.release(). It is reset only by a subsequent RedisLock.acquire().
    2. RedisLock.ensure_held() method: Raises portalocker.exceptions.LockLostError. This is useful for long critical sections to periodically check if the lock is still valid. It checks local state and does not perform a new network request.
    3. with block exit: If a lock is used as a context manager, the __exit__ method will raise portalocker.exceptions.LockLostError if the lock was lost during the block's execution.
    4. on_lost callback: A function passed to the RedisLock constructor that fires exactly once per loss on a background keep-alive thread.

    Important Notes:

    • Callback constraints: Keep on_lost callbacks short. Do not acquire application locks inside them. Exceptions raised in the callback are logged, not propagated.
    • Interrupt behavior: By default, a loss interrupts the main thread with a KeyboardInterrupt. In portalocker 5.0.0+, interrupt_on_lost defaults to False. Use the interrupt_on_lost parameter to explicitly control this behavior.
    import portalocker
    
    # Using the on_lost callback and ensure_held
    def my_callback():
        print("Lock was lost!")
    
    lock = portalocker.RedisLock(
        'my_channel',
        connection=redis_conn,
        on_lost=my_callback,
        interrupt_on_lost=False
    )
    
    lock.acquire()
    try:
        # Perform work
        lock.ensure_held()
    except portalocker.LockLostError as e:
        print(f"Lost lock on {e.channel}")
  10. How portalocker handles file ranges

    develop

    Whole-file locking

    portalocker is designed to lock whole files, not specific byte ranges. Even though underlying implementations (like Windows msvcrt or POSIX fcntl.lockf) might support ranges, portalocker anchors all locks at byte 0 to ensure that any two processes attempting to lock the same file will always contend.

    Implementation Details

    • POSIX: Uses fcntl.lockf with defaults start=0, whence=SEEK_SET, and len=0 (locking to the end of the file).
    • Windows (msvcrt): Locks 64 KiB (0x10000) starting at byte 0 (it seeks to 0 before locking and restores the position after).
    • Windows (Win32): Uses LockFileEx with an offset of 0 and a fixed large range.

    Limitations

    • No Concurrent Region Access: You cannot use portalocker to allow two processes to work on different regions of the same large file concurrently.
    • Workaround: If you require byte-range locking, you must call fcntl.lockf directly with explicit start and len arguments (note: this is POSIX-only).
    • Mental Model: Treat the lock as a token that names the file, rather than a mechanism protecting a specific range of bytes.
    import portalocker
    
    # Two locks on the same file will always contend, regardless of seek position
    first = portalocker.Lock('example.lock', 'a', fail_when_locked=True)
    first.acquire()
    
    second = portalocker.Lock('example.lock', 'a', fail_when_locked=True)
    try:
        second.acquire()
    except portalocker.AlreadyLocked:
        print('contended, as expected')
    
    first.release()
  11. Important locking considerations

    develop

    When using portalocker, keep these technical details in mind:

    Unix/Linux

    Unix file locks are advisory. All participating processes must explicitly acquire the lock to respect it.

    Network Filesystems (NFS/etc.)

    Network filesystems have unique locking and buffering behaviors. To ensure data integrity before releasing a lock, you may need to manually flush and sync the file handle:

    fh.flush()
    os.fsync(fh.fileno())

    PidFileLock Behavior

    By default, a PidFileLock context is for inspection. The body of the context runs even if another process holds the lock; it returns the holder's PID (or None if the current process acquired it). To ensure the body only runs after successful acquisition, use .fail_closed().

    RedisLock Behavior

    RedisLock uses a pub/sub subscription. Ownership is released when the subscription is removed. If a network partition occurs, the holder may observe a loss of the lock. For advanced usage, see documentation regarding lost, ensure_held(), health checks, and fencing tokens.

  12. How the combine transform works

    develop

    The combine command performs a recursive inlining process to eliminate relative imports:

    1. Inlining: It starts at portalocker/__init__.py and walks through all modules, replacing relative imports (e.g., from .foo import bar) with the actual source code of the referenced module.
    2. Type Checking Preservation: if TYPE_CHECKING: blocks are copied verbatim rather than inlined. This ensures runtime guards (like those used for optional redis support) remain intact.
    3. Cleanup: The process cleans up qualified references left behind by inlining (e.g., converting constants.LockFlags to LockFlags) and strips useless self-assignments (e.g., spam = spam).
    4. Metadata: The README.md and LICENSE files are included at the top of the generated file, wrapped in triple-quoted strings.