etcd3 Node Client

repository·master·Indexed 19 days ago

https://github.com/microsoft/etcd3

A production-ready, type-safe TypeScript client for the etcd v3 Protocol Buffer-based API. It provides high-level abstractions for complex etcd operations including transactions, lease management, watchers, elections, and ACL-based user and role management.

Tokens
14.6K
Snippets
44
Records
63
Agent score
68%

What's inside etcd3

  1. Quickstart with etcd3 client

    master

    The following example demonstrates how to initialize an Etcd3 client and perform basic operations: putting a value, getting a value as a string, retrieving keys by prefix, and deleting all keys.

    const { Etcd3 } = require('etcd3');
    const client = new Etcd3();
    
    (async () => {
      // Put a value
      await client.put('foo').value('bar');
    
      // Get a value as a string
      const fooValue = await client.get('foo').string();
      console.log('foo was:', fooValue);
    
      // Get all keys with a specific prefix
      const allFValues = await client.getAll().prefix('f').keys();
      console.log('all our keys starting with "f":', allFValues);
    
      // Delete all keys
      await client.delete().all();
    })();
  2. Run etcd3 tests

    master

    To run the project's test suite, you must have a local etcd3 server running via Docker Compose. Follow these steps:

    1. Install dependencies.
    2. Start the etcd3 container in a separate shell.
    3. Run the tests.
    4. Shut down the container.
    $ npm install
    $ cd src/test/containers/3.2 && docker-compose up # in a separate shell
    $ npm test
    $ docker-compose down
  3. Identify and handle recoverable errors

    master

    The etcd3 library distinguishes between transient errors that can be resolved through default fault-handling policies and permanent errors. You can use the isRecoverableError utility to check if an error is transient (e.g., network issues or server unavailability) and should trigger retry logic.

    Recoverable errors are identified by the presence of the RecoverableError symbol. Common recoverable error classes include:

    • GRPCInternalError
    • GRPCCancelledError
    • GRPCUnknownError
    • GRPCDeadlineExceededError
    • GRPCResourceExhastedError
    • GRPCAbortedError
    • GRPCUnavailableError
    import { isRecoverableError } from 'etcd3';
    
    try {
      // ... etcd operation
    } catch (err) {
      if (isRecoverableError(err)) {
        // Handle retry logic for transient errors
      } else {
        // Handle permanent error
      }
    }
  4. How Software Transactional Memory (STM) works

    master

    Software Transactional Memory (STM) allows you to execute a block of code containing multiple reads and writes as a single atomic transaction. The system automatically handles retries if a conflict is detected.

    Critical Rule: Inside the .transact() block, all reads and writes must go through the provided tx object, not the main etcd3 client. If you use the client directly, those operations will not be tracked by the transaction and atomicity will be lost.

    Workflow:

    1. Call etcd3.stm(options).transact(tx => { ... }).
    2. Use tx.get(key), tx.put(key), and tx.delete() within the block.
    3. The library tracks all operations in a ReadSet and WriteSet.
    4. Upon completion of the function, the library attempts to commit all changes atomically. If a conflict is detected, it retries the entire block up to the configured retries limit.
    const amount = 42;
    
    etcd3.stm().transact(tx => {
      return Promise.all([
        tx.get('bank/account1').number(),
        tx.get('bank/account2').number(),
      ]).then(([balance1, balance2]) => {
        if (balance1 < amount) {
          throw new Error('You do not have enough money to transfer!');
        }
    
        return Promise.all([
          tx.put('bank/account1').value(balance1 - amount),
          tx.put('bank/account2').value(balance2 + amount),
        ]);
      });
    });
  5. How etcd roles and users work together

    master

    etcd uses an ACL-style permission model where permissions are not assigned directly to users, but to Roles.

    1. Permissions define what can be done (e.g., read, write) and where (a Range of keys).
    2. Roles are collections of these permissions.
    3. Users are assigned one or more Roles.

    To grant a user access to a specific part of the keyspace, you must:

    1. Create a Role.
    2. Use role.grant() to add permissions to that role.
    3. Use user.addRole(role) or role.addUser(user) to link the user to the role.
  6. Use Election to manage distributed leadership

    master

    The Election class allows multiple distributed nodes to participate in an election to choose a single leader. This is commonly used to ensure only one server performs a specific task (like a singleton job) in a cluster.

    There are two primary patterns:

    1. Campaigning: Using election.campaign(value) to attempt to become the leader. You listen for the elected event to know when you have successfully acquired leadership.
    2. Observing: Using election.observe() to monitor the current leader without participating in the election. You listen for the change event to react to leadership transitions.

    Important Lifecycle Notes:

    • Campaigns are tied to a TTL (Time To Live). If the underlying lease is lost, the campaign fails and an error event is emitted.
    • Observers must be closed using observer.cancel() to prevent resource leaks.
    • If a leader node crashes or fails, a new leader will be elected from the remaining candidates.
    const os = require('os');
    const client = new Etcd3();
    const election = client.election('singleton-job');
    
    // Pattern 1: Campaigning for leadership
    function runCampaign() {
      const campaign = election.campaign(os.hostname());
      
      campaign.on('elected', () => {
        // This server is now the leader!
        doSomeWork();
      });
    
      campaign.on('error', error => {
        // Campaign failed (e.g. lease lost). 
        // Stop work and attempt to re-campaign after a delay.
        console.error(error);
        stopDoingWork();
        setTimeout(runCampaign, 5000);
      });
    }
    
    // Pattern 2: Observing the leader
    async function observeLeader() {
      const observer = await election.observe();
      console.log('The current leader is', observer.leader());
      
      observer.on('change', leader => {
        console.log('The new leader is', leader);
      });
    
      observer.on('error', () => {
        // Fatal interruption in observation. Re-observe after delay.
        setTimeout(observeLeader, 5000);
      });
    }
  7. How the Lock.do() method works

    master

    The do<T>(fn: () => T | Promise<T>) method is a high-level wrapper for managing the lifecycle of a lock. It follows this sequence:

    1. Acquire: Attempts to acquire the lock.
    2. Execute: Runs the provided function fn.
    3. Release: Automatically releases the lock once the function's promise resolves or throws an error.

    This is the recommended way to use locks to ensure that locks are not leaked if an operation fails.

    client.lock('my_resource').do(async () => {
      await performTask();
    });
  8. Define permission requests with IPermissionRequest

    master

    When granting or revoking permissions using the Role class, you use the IPermissionRequest type. This type allows you to specify access based on either a specific key or a defined range.

    An IPermissionRequest can take two forms:

    1. Range-based: { permission: keyof typeof Permission; range: Range } - Grants access to a specific key range.
    2. Key-based: { permission: keyof typeof Permission; key: Buffer | string } - Grants access to a specific key (effectively a prefix if used in certain contexts).
  9. Configure fault-handling policies with Cockatiel

    master

    The faultHandling option allows you to define how the client handles errors using Cockatiel policies. The client uses a two-tier approach: a global policy and a host policy. When the global policy retries, it picks a new host.

    Available configuration keys:

    • host: A function (hostname: string) => IPolicy that returns a policy applied to a specific host (e.g., a circuit breaker).
    • global: An IPolicy applied to all calls (e.g., a retry policy).
    • watchBackoff: An IBackoff used by the watch manager for reconnecting watch streams.

    Default Behavior:

    • global: A three-retry policy.
    • host: A circuit breaker that opens for 5 seconds after 3 consecutive failures.
    • watchBackoff: Exponential backoff with a max 30s delay and decorrelated jitter.

    To disable all fault handling, use Policy.noop for both host and global.

    import { Etcd3, isRecoverableError } from 'etcd3';
    import { Policy, ConsecutiveBreaker, ExponentialBackoff } from 'cockatiel';
    
    const etcd = new Etcd3({
      faultHandling: {
        host: () =>
          Policy.handleWhen(isRecoverableError).circuitBreaker(5_000, new ConsecutiveBreaker(3)),
        global: Policy.handleWhen(isRecoverableError).retry(3),
        watchBackoff: new ExponentialBackoff(),
      },
    });
  10. Perform atomic transactions with ComparatorBuilder

    master

    The ComparatorBuilder allows you to build complex, atomic transactions (Compare-and-Swap/Locking patterns) using if, then, and else clauses.

    Comparison Targets

    When using .and(), you can compare against:

    • Value: The value of the key.
    • Version: The key's version.
    • Create: The creation revision (create_revision).
    • Mod: The modification revision (mod_revision).
    • Lease: The lease associated with the key.

    Comparison Operators

    Supported operators via the comparator map:

    • == or ===: Equal
    • != or !==: Not Equal
    • >: Greater
    • <: Less

    Transaction Workflow

    1. Use .and(key, target, operator, value) to add comparison clauses.
    2. Use .then(...clauses) to define operations if the comparison succeeds.
    3. Use .else(...clauses) to define operations if the comparison fails.
    4. Call .commit() to execute the transaction.
    // Example: Implementing a distributed lock
    const id = uuid.v4();
    
    function lock() {
      return client.if('my_lock', 'Create', '==', 0)
        .then(client.put('my_lock').value(id))
        .else(client.get('my_lock'))
        .commit()
        .then(result => console.log(result.succeeded === id ? 'lock acquired' : 'already locked'));
    }
    
    function unlock() {
      return client.if('my_lock', 'Value', '==', id)
        .then(client.delete().key('my_lock'))
        .commit();
    }
  11. Select STM isolation levels

    master

    The Isolation enum defines the consistency guarantees for your STM transactions:

    • SerializableSnapshot: Provides serializable isolation and checks for write conflicts. This is the default.
    • Serializable: Provides serializable reads within the same transaction attempt, returning data from the revision of the first read.
    • RepeatableReads: Ensures reads within the same transaction attempt always return the same data.
    • ReadCommitted: Reads keys from any committed revision (no specific consistency guarantees for the transaction block).