Verrou Locking Library

repository·main·Indexed 42 days ago

https://github.com/julien-r44/verrou

A Node.js locking library providing a consistent API across multiple storage drivers, including Redis, DynamoDB, In-Memory, and SQL databases via Knex or Kysely. It allows developers to seamlessly switch between production environments and infrastructure-free test environments without changing application logic.

Tokens
18.6K
Snippets
64
Records
76
Agent score
52%

What's inside Verrou

  1. Get started with Verrou locking library

    main

    Verrou is a locking library for Node.js designed to provide a consistent API across different storage backends. It allows you to manage distributed locks using various drivers such as Redis, Postgres, MySQL, Sqlite, or In-Memory.

    A key advantage is the ability to switch drivers (e.g., using Redis in production and an In-Memory driver for fast, infrastructure-free testing) without changing your application logic.

    // See Basic usage for a concrete example
    ```ts
    import { Verrou } from '@verrou/core'
    import { redisStore } from '@verrou/core/drivers/redis'
    import { memoryStore } from '@verrou/core/drivers/memory'
    
    const verrou = new Verrou({
      default: 'redis',
      stores: {
        redis: { driver: redisStore() },
        memory: { driver: memoryStore() }
      }
    })
    
    // Lock a resource and run a function
    await verrou.createLock('my-resource').run(async () => {
      await doSomething()
    }) // Lock is automatically released
  2. Prevent race conditions using named locks

    main

    Verrou prevents race conditions by ensuring that only one process can execute code protected by a specific named lock at a time. By using a consistent name (e.g., 'transfer'), concurrent requests for the same resource will wait until the current holder releases the lock.

    router.get('/transfer', async () => {
      // Other requests will wait just here until the lock is released
      await verrou.createLock('transfer').run(async () => {
        const fromAccount = getAccountFromDb(request.input('from'))
        const toAccount = getAccountFromDb(request.input('to'))
    
        fromAccount.balance -= request.input('amount')
        toAccount.balance += request.input('amount')
    
        await fromAccount.save()
        await toAccount.save()
      }) // Lock is automatically released after the callback is executed
    })
  3. Share and restore locks between processes

    main

    By default, only the specific Lock instance that acquired a lock can release it. Attempting to release a lock from a different instance will throw an E_LOCK_NOT_OWNER error.

    To release a lock from a different process (e.g., a worker process receiving a task from a queue), you must:

    1. Serialize the lock in the original process using lock.serialize().
    2. Restore the lock in the new process using verrou.restoreLock(serializedLock).

    Alternatively, you can use lock.forceRelease() to release a lock regardless of who owns it.

    // Process A: Serialize and send
    const lock = verrou.createLock('my-resource', '2s')
    await lock.acquire()
    const serialized = lock.serialize()
    // ... send 'serialized' to another process via queue/API ...
    
    // Process B: Restore and release
    import { verrou } from './verrou.js'
    const restoredLock = verrou.restoreLock(serialized)
    await restoredLock.release()
  4. Inject a logger into Verrou or LockFactory

    main

    You can inject a compatible logger (such as Pino) when creating a Verrou instance or a LockFactory.

    When you provide a logger, Verrou automatically creates a child logger with the label pkg: "verrou", which allows you to easily filter Verrou-specific logs in your logging system.

    import { pino } from 'pino'
    import { LockFactory } from '@verrou/core'
    import { MemoryStore } from '@verrou/core/drivers/memory'
    
    const logger = pino({
      level: 'trace',
      transport: { target: 'pino-pretty' },
    })
    
    // Injecting into Verrou instance
    const verrou = new Verrou({
      // ...
      logger,
    })
    
    // Injecting into LockFactory
    const lockFactory = new LockFactory(new MemoryStore(), { logger })
  5. Manage locks manually with acquire and release

    main

    You can manually control a lock by calling acquire() and release().

    Important: To prevent the lock from being stuck in the event of an error in your critical section, always wrap your logic in a try/catch/finally block to ensure release() is called in the finally block.

    import { verrou } from './verrou.js'
    
    const lock = verrou.createLock('my-resource', null)
    const acquired = await lock.acquire()
    
    if (!acquired) {
      // Handle failure to acquire
      return
    }
    
    try {
      // Do your critical code here
      doSomething()
    } catch (error) {
      // Handle the error
    } finally {
      // Always release the lock
      await lock.release()
    }
  6. Use the Memory driver

    main

    The memory store is a simple in-memory store using async-mutex under the hood.

    Warning: Do not use this driver in a multi-server environment, as the state is not shared between processes.

    // Verrou API
    import { Verrou } from '@verrou/core'
    import { memoryStore } from '@verrou/core/drivers/memory'
    
    const verrou = new Verrou({
      default: 'memory',
      stores: {
        memory: { driver: memoryStore() },
      },
    })
    
    // LockFactory API
    import { Verrou, LockFactory } from '@verrou/core'
    import { MemoryStore } from '@verrou/core/drivers/memory'
    
    const store = new MemoryStore()
    const lockFactory = new LockFactory(store)
  7. Test a custom lock driver with Japa

    main

    To ensure your custom driver complies with Verrou's requirements, you can use the provided test suite integration for Japa.

    You will need to have @japa/assert installed. Use registerStoreTestSuite from @verrou/core/test_suite within a Japa test group to run the standard suite against your driver.

    // title: tests/my_store.spec.ts
    import { test } from '@japa/runner'
    import { MyStore } from '../src/my_store.js'
    import { registerStoreTestSuite } from '@verrou/core/test_suite'
    
    test.group('My Store', (group) => {
      registerStoreTestSuite({
        test,
        createStore: () => new MyStore()
      })
    })
  8. Use Database drivers (Knex or Kysely)

    main

    Verrou supports using relational databases as lock stores via adapters. It provides built-in support for Knex and Kysely. These drivers work by attempting to insert a row and relying on primary key constraints to detect existing locks.

    Common Options for all Database drivers:

    • tableName: The name of the table used to store locks (Default: verrou).
    • autoCreateTable: Whether to automatically create the table if it doesn't exist (Default: true).

    Custom Adapters: You can implement the DatabaseAdapter interface to support other database libraries.

    // Knex Example
    import knex from 'knex'
    import { Verrou } from '@verrou/core'
    import { knexStore } from '@verrou/core/drivers/knex'
    
    const db = knex({ client: 'mysql2', connection: MYSQL_CREDENTIALS })
    const verrou = new Verrou({
      default: 'sqlite',
      stores: {
        sqlite: { driver: knexStore({ connection: db }) },
      },
    })
    
    // Kysely Example
    import { Kysely } from 'kysely'
    import { Verrou } from '@verrou/core'
    import { kyselyStore } from '@verrou/core/drivers/kysely'
    
    const db = new Kysely<Database>({ dialect })
    const verrou = new Verrou({
      default: 'kysely',
      stores: {
        kysely: { driver: kyselyStore({ connection: db }) },
      },
    })
  9. Use the DynamoDB driver

    main

    The DynamoDB driver allows you to store locks in an AWS DynamoDB table. The table will be automatically created if it does not exist.

    Prerequisites: Install @aws-sdk/client-dynamodb.

    // Verrou API
    import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
    import { dynamodbStore } from '@verrou/core/drivers/dynamodb'
    
    const dynamoClient = new DynamoDBClient(/* ... */)
    const verrou = new Verrou({
      default: 'dynamo',
      stores: {
        dynamo: {
          driver: dynamodbStore({
            connection: dynamoClient,
            table: { name: 'locks' },
          }),
        },
      },
    })
    
    // LockFactory API
    import { Verrou, LockFactory } from '@verrou/core'
    import { DynamoDBStore } from '@verrou/core/drivers/dynamodb'
    
    const dynamoClient = new DynamoDBClient(/* ... */)
    const store = new DynamoDBStore({
      connection: dynamoClient,
      table: {
        name: 'locks',
      },
    })
    
    const lockFactory = new LockFactory(store)
  10. Create a custom lock driver

    main

    To extend Verrou with a custom cache driver, you must implement the LockStore interface from @verrou/core/types.

    Once implemented, you must provide a factory function that follows the CreateDriverResult pattern. This factory is used by Verrou at runtime to instantiate your driver.

    To use your new driver, register it in the stores object when initializing a new Verrou instance and set it as the default store.

    import type { CreateDriverResult } from '@verrou/core/types'
    
    // 1. Implement the LockStore interface
    class MyDriver implements LockStore {
      async save(key: string, owner: string, ttl: number | undefined): Promise<boolean> { /* ... */ }
      async delete(key: string, owner: string): Promise<void> { /* ... */ }
      async exists(key: string): Promise<boolean> { /* ... */ }
      async extend(key: string, owner: string, duration: number): Promise<void> { /* ... */ }
    }
    
    // 2. Create the factory function
    export function myStore(config: MyStoreOptions): CreateDriverResult<MyStoreOptions> {
      return { config, factory: () => new MyDriver(config) }
    }
    
    // 3. Register and use in Verrou
    import { Verrou } from '@verrou/core'
    import { myStore } from './my_store.js'
    
    const verrou = new Verrou({
      default: 'myStore',
      stores: {
        myStore: myStore({ /* Your driver options */ })
      }
    })