PgLock

repository·main·Indexed 41 days ago

https://github.com/heroku/pg_lock

A Ruby gem that uses PostgreSQL advisory locks to provide distributed synchronization across multiple processes and machines. It supports configurable TTL, retry attempts, and manual lock management via create, delete, and acquired? methods.

Tokens
2.8K
Snippets
16
Records
17
Agent score
49%

What's inside PgLock

  1. How session-based locking works

    main

    PgLock uses Postgres advisory locks, which are unique to a database session. If the same process (sharing the same database connection session) attempts to acquire the same lock, it will succeed immediately without blocking.

    Warning: PgLock does not synchronize code execution within the same process/session. If you need to synchronize code within a single process, use a Ruby Mutex instead.

    key = "all_your_base"
    PgLock.new(name: key).lock do
      puts "First block called"
      PgLock.new(name: key).lock do
        puts "Second block called because it's sharing the same session"
      end
    end
    # Result: 
    # First block called
    # Second block called because it's sharing the same session
  2. Install pg_lock

    main

    PgLock requires Ruby 2.1+. You can install it via Bundler by adding it to your Gemfile, or by installing the gem directly via the command line.

    # Add to Gemfile
    gem 'pg_lock'

    Or install via CLI

    $ gem install pg_lock

    Then run bundle

    $ bundle

  3. Configure logging for PgLock

    main

    You can provide a logging block to capture data during the lock lifecycle. The block receives a single argument: a Hash containing lock information.

    • Instance logging: Pass a proc to the log argument in PgLock.new.
    • Global logging: Set PgLock::DEFAULT_LOG to a proc.

    Note: When logging is enabled, exceptions raised during lock deletion are swallowed. To handle them, check data[:exception] in your logging block.

    # Instance logging
    PgLock.new(name: "all_your_base", log: ->(data) { puts data.inspect }).lock do
      # stuff
    end
    
    # Global logging
    PgLock::DEFAULT_LOG = ->(data) { puts data.inspect }
  4. Configure retry attempts and intervals

    main

    If a lock cannot be acquired immediately, PgLock defaults to 3 attempts with a 1-second delay between each. You can customize this using:

    • attempts: The number of times to try acquiring the lock.
    • attempt_interval: The delay (in seconds) between attempts.
    # Try 10 times with a 5 second delay
    PgLock.new(name: "all_your_base", attempts: 10, attempt_interval: 5).lock do
      # stuff
    end
    
    # Run exactly once
    PgLock.new(name: "all_your_base", attempts: 1).lock do
      # stuff
    end
  5. Configure lock timeout (TTL)

    main

    By default, locked blocks timeout after 60 seconds. If the block exceeds this time, the lock is released and a Timeout::Error is raised.

    • Use the ttl argument to set a custom timeout in seconds.
    • Pass a falsey value (e.g., false) to disable the timeout entirely.
    # Custom 30s timeout
    begin
      PgLock.new(name: "all_your_base", ttl: 30).lock do
        # stuff
      end
    rescue Timeout::Error
      puts "Took longer than 30 seconds to execute"
    end
    
    # Disable timeout
    PgLock.new(name: "all_your_base", ttl: false).lock do
      # stuff
    end
  6. Use PgLock to synchronize code execution

    main

    To ensure a block of code executes exclusively across different processes and machines, create a PgLock.new instance with a unique name and call the lock method. Only one block of code with the same name will be allowed to execute at a time globally.

    PgLock.new(name: "all_your_base").lock do
      # stuff
    end
  7. Manage locks manually with create, delete, and acquired?

    main

    Instead of using the block-based lock method, you can manage the lock lifecycle manually:

    • create: Attempts to acquire the lock. Returns the PgLock instance if successful, or false if it fails.
    • acquired?: Returns true if the lock is currently held by the session.
    • delete: Releases the lock.

    Always use an ensure block to call delete to prevent leaking locks.

    begin
      lock = PgLock.new(name: "all_your_base")
      if lock.create
        if lock.acquired?
          # do stuff
        end
      end
    ensure
      lock.delete
    end
  8. Use a custom database connection

    main

    PgLock defaults to using Active Record. To use a different database library (like Sequel), pass a connection object to PgLock.new. The object must implement an exec method that accepts two arguments:

    1. A SQL query string.
    2. An array of bind arguments.

    Example using a wrapper for Sequel:

    # Example wrapper for Sequel
    connection = Module do
      def self.exec(sql, bind)
        DB.fetch(sql, bind)
      end
    end
    
    PgLock.new(name: "all_your_base", connection: connection).lock do
      # stuff
    end
  9. Raise error on failed lock with lock!

    main

    If you want the application to raise an error immediately when a lock cannot be acquired within the configured number of attempts, use the lock! method instead of lock. This raises a PgLock::UnableToLockError.

    begin
      PgLock.new(name: "all_your_base").lock! do
        # stuff
      end
    rescue PgLock::UnableToLockError
      # do stuff
    end
  10. Initialize PgLock

    main

    Create a new PgLock instance to manage advisory locks in PostgreSQL. You must provide a name (which will be hashed via CRC32) and a valid connection object (e.g., a raw PG::Connection from the pg gem or an ActiveRecord::Base.connection.raw_connection).

    Parameters

    • name: The identifier for the lock.
    • attempts: (Integer, default: 3) The number of times to try acquiring the lock before giving up.
    • attempt_interval: (Integer, default: 1) Seconds to wait between retry attempts.
    • ttl: (Integer, default: 60) Time-to-live in seconds. If set to 0, the timeout is disabled. This controls how long the provided block is allowed to run.
    • connection: The database connection object.
    • log: (Proc, optional) A callable object used for logging lock events.
    • return_result: (Boolean, default: true) If true, the lock method returns the result of the block. If false, it returns true on success.
    lock = PgLock.new(
      name: 'my_resource_lock',
      attempts: 5,
      attempt_interval: 2,
      ttl: 30,
      connection: ActiveRecord::Base.connection.raw_connection
    )