How do I leverage StampedLock for high-performance read/write locking?

The StampedLock class in Java’s concurrency utilities (introduced in Java 8) is a high-performance read/write lock that differs from traditional ReadWriteLock (like ReentrantReadWriteLock) due to its ability to provide three locking modes:

  1. Write Lock: Exclusive access.
  2. Read Lock: Shared (non-exclusive) access.
  3. Optimistic Read Lock: A lightweight, non-blocking read lock for scenarios where reads dominate writes, but data consistency needs to be validated.

Below is an explanation of how to use StampedLock effectively for high-performance locking in different contexts:


1. Write Lock

The write lock is used when exclusive access to the shared resource is required, such as for updates. It provides behavior similar to a traditional lock but with better performance in many scenarios.

Example:

package org.kodejava.util.concurrent;

import java.util.concurrent.locks.StampedLock;

public class StampedLockExample {
    private int count = 0;
    private final StampedLock lock = new StampedLock();

    public void increment() {
        long stamp = lock.writeLock(); // Acquire write lock
        try {
            count++;
        } finally {
            lock.unlockWrite(stamp); // Release write lock
        }
    }
}

2. Read Lock

The read lock is used when shared access to a resource is sufficient, and there are no write operations being performed. It provides better throughput than a traditional lock by allowing multiple threads to read concurrently.

Example:

public int getCount() {
    long stamp = lock.readLock(); // Acquire read lock
    try {
        return count;
    } finally {
        lock.unlockRead(stamp); // Release read lock
    }
}

3. Optimistic Read Lock

The optimistic read lock is a key feature of StampedLock and is designed for scenarios where reads dominate and writes are infrequent. This mode allows a thread to proceed without actually acquiring a lock, provided that the shared resource isn’t later invalidated by a write operation.

Process:

  1. Acquire an optimistic read stamp with lock.tryOptimisticRead().
  2. Perform the read operation.
  3. Validate the stamp with lock.validate(stamp). If the stamp is no longer valid (i.e., a write operation occurred), fall back to a read lock.

Example:

public int optimisticReadCount() {
    long stamp = lock.tryOptimisticRead(); // Try optimistic read
    int currentCount = count; // Perform read operation

    if (!lock.validate(stamp)) { // Check if stamp is still valid
        // Fallback to read lock if a write occurred during the read
        stamp = lock.readLock();
        try {
            currentCount = count;
        } finally {
            lock.unlockRead(stamp);
        }
    }

    return currentCount; // Return the read value
}

This approach is highly efficient in scenarios with minimal contention, as it avoids actual locking unless necessary.


4. Combining Reads and Writes

Sometimes, a single operation involves both reads and writes. In such cases, you can upgrade from a read lock to a write lock using the convertToWriteLock method:

Example:

public void conditionalIncrement() {
    long stamp = lock.readLock();
    try {
        if (count < 10) {
            // Upgrade to write lock if modification is needed
            stamp = lock.tryConvertToWriteLock(stamp);
            if (stamp == 0L) { // Failed to upgrade, acquire write lock normally
                stamp = lock.writeLock();
            }
            count++;
        }
    } finally {
        lock.unlock(stamp); // Release the appropriate lock
    }
}

5. Things to Keep in Mind

  • Deadlock Avoidance: StampedLock does not support reentrancy (unlike ReentrantLock). Each thread must acquire the lock only once; otherwise, it may lead to deadlocks.
  • Performance: Using optimistic reads can offer great performance improvements in read-heavy scenarios, but they require careful validation to ensure correctness.
  • Fairness: StampedLock is not fair and does not guarantee lock acquisition order.

When to Use StampedLock

  • Optimistic Reads: If your application has far more reads than writes, and contention is generally low.
  • Shared Data Structures: For use cases like caching or maintaining shared state across threads, where high throughput is critical.

StampedLock strikes a balance between ease of use and performance, making it a valuable tool for high-performance concurrency tasks in Java!

How do I integrate JSch with a custom logging framework for SSH auditing?

Integrating JSch with a custom logging framework to facilitate SSH auditing involves capturing and routing pertinent log information about SSH connections, commands, and activities into your custom logging mechanism. Below are the steps and considerations to achieve this:


1. Set a Custom Logger for JSch

JSch allows integration with custom loggers by implementing the com.jcraft.jsch.Logger interface. This interface defines methods to determine which log levels are enabled and how messages should be logged.

Steps:

  • Implement the Logger Interface:
    Create your custom logging class, implementing the com.jcraft.jsch.Logger interface, and delegate the log messages to your custom logging framework.
package org.kodejava.jsch;

import com.jcraft.jsch.Logger;

import java.util.Map;

public class CustomJSchLogger implements Logger {
   // Map JSch log levels to your framework's log levels
   private static final java.util.Map<Integer, String> LEVELS = Map.of(
           Logger.DEBUG, "DEBUG",
           Logger.INFO, "INFO",
           Logger.WARN, "WARN",
           Logger.ERROR, "ERROR",
           Logger.FATAL, "FATAL"
   );

   @Override
   public boolean isEnabled(int level) {
       // Return true for the desired log levels
       return true; // Adjust based on your application’s needs
   }

   @Override
   public void log(int level, String message) {
       // Route logs to your logging framework
       String levelString = LEVELS.getOrDefault(level, "INFO");
       MyCustomLogger.log(levelString, message); // Replace with your custom logger's method
   }
}
  • Basic Console-Based Logger
    Here is an example of how you can implement a MyCustomLogger class. This implementation simply log messages to the console.
package org.kodejava.jsch;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class MyCustomLogger {
    // Log message with level and message
    public static void log(String level, String message) {
        // Add a timestamp to each log
        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        System.out.printf("[%s] [%s] %s%n", timestamp, level, message);
    }
}
  • Attach the Logger to JSch:
    Assign your custom logger to JSch before creating SSH sessions.
package org.kodejava.jsch;

import com.jcraft.jsch.JSch;

public class JSchWithLogging {
    public static void main(String[] args) {
        // Set the custom logger
        JSch.setLogger(new CustomJSchLogger());

        // Rest of the code to use JSch
        JSch jsch = new JSch();
        // Example: Connect to an SSH server
    }
}

2. Audit SSH Session Details

If you need detailed logging for auditing purposes, you can capture more granular information about the SSH session, such as user authentication, executed commands, or file transfers.

a. Logging Connection and Authentication

You can log events during session creation and authentication:

package org.kodejava.jsch;

import com.jcraft.jsch.*;

public class SSHAuditor {
    public static void main(String[] args) {
        String user = "username";
        String host = "example.com";
        int port = 22;

        JSch jsch = new JSch();
        try {
            // Set logger for auditing
            JSch.setLogger(new CustomJSchLogger());

            // Start the session
            Session session = jsch.getSession(user, host, port);
            session.setPassword("password"); // Avoid hardcoding in production

            // Set session properties
            java.util.Properties config = new java.util.Properties();
            config.put("StrictHostKeyChecking", "no");
            session.setConfig(config);

            // Log connection attempt
            MyCustomLogger.log("INFO", "Attempting to connect to " + host);

            session.connect();

            // Log successful connection
            MyCustomLogger.log("INFO", "Connected successfully to " + host);

        } catch (JSchException e) {
            // Log connection failure
            MyCustomLogger.log("ERROR", "Connection failed: " + e.getMessage());
        }
    }
}

b. Logging Command Execution

Wrap the ChannelExec to log executed commands and their outputs:

package org.kodejava.jsch;

import java.io.InputStream;
import com.jcraft.jsch.*;

public class SSHCommandAuditor {
    public static void main(String[] args) {
        String user = "username";
        String host = "example.com";
        int port = 22;

        JSch jsch = new JSch();
        String command = "ls -la";
        try {
            // Start the session
            Session session = jsch.getSession(user, host, port);
            session.setPassword("password"); // Avoid hardcoding in production

            // Set session properties
            java.util.Properties config = new java.util.Properties();
            config.put("StrictHostKeyChecking", "no");
            session.setConfig(config);
            session.connect();

            // Execute command
            ChannelExec channel = (ChannelExec) session.openChannel("exec");
            channel.setCommand(command);

            // Log the command
            MyCustomLogger.log("INFO", "Executing command: " + command);

            // Read command output
            InputStream input = channel.getInputStream();
            channel.connect();

            byte[] buffer = new byte[1024];
            int bytesRead;
            StringBuilder output = new StringBuilder();

            while ((bytesRead = input.read(buffer)) != -1) {
                output.append(new String(buffer, 0, bytesRead));
            }

            // Log command output
            MyCustomLogger.log("INFO", "Command output: " + output.toString());

            channel.disconnect();

        } catch (Exception e) {
            // Log errors
            MyCustomLogger.log("ERROR", "Command execution failed: " + e.getMessage());
        }
    }
}

c. Logging File Transfers with SftpChannel

When using SFTP for file transfers, you can log the operations for auditing:

import com.jcraft.jsch.*;

public class SftpAudit {
    public static void main(String[] args) {
        try {
            // Set up the session (as shown previously)
            Session session = ...;

            // Open an SFTP channel
            ChannelSftp sftpChannel = (ChannelSftp) session.openChannel("sftp");
            sftpChannel.connect();

            // Log file upload
            String localFile = "/path/to/local/file.txt";
            String remoteFile = "/path/to/remote/file.txt";
            MyCustomLogger.log("INFO", "Uploading file: " + localFile + " to " + remoteFile);

            sftpChannel.put(localFile, remoteFile);

            // Log successful upload
            MyCustomLogger.log("INFO", "File uploaded successfully!");

            sftpChannel.disconnect();

        } catch (Exception e) {
            // Log errors
            MyCustomLogger.log("ERROR", "SFTP operation failed: " + e.getMessage());
        }
    }
}

3. Auditing Best Practices

  • Secure Handling of Credentials: Ensure passwords and keys are stored securely using tools like a secrets manager.
  • Log Security: Protect log files to prevent exposure of sensitive data like credentials or command details.
  • Log Level Filtering: Filter log levels appropriately (e.g., exclude DEBUG and INFO levels in production environments).
  • Include Timestamps: Add timestamps to log entries for better traceability.

By integrating JSch with your custom logging framework, you can ensure detailed auditing of SSH activities. This provides better observability and supports troubleshooting, compliance, and security efforts effectively.


Maven Dependencies

<dependency>
    <groupId>com.jcraft</groupId>
    <artifactId>jsch</artifactId>
    <version>0.1.55</version>
</dependency>

Maven Central

How do I optimize task splitting strategies in RecursiveTask?

Optimizing task-splitting strategies in RecursiveTask (a subclass of the ForkJoin framework in Java) is crucial for improving performance and minimizing inefficiencies like excessive overhead or poor parallelism. Here are some strategies and tips to achieve efficient task splitting:


1. Choose an Optimal Threshold

  • The optimal threshold (commonly called a “granularity threshold”) determines when you should stop dividing a task and process it directly.
  • If the threshold is too small, you create too many small tasks, increasing overhead (task creation, scheduling, and context switching).
  • If the threshold is too large, you may not utilize multiple threads effectively, reducing parallelism.

Solution:

  • Experiment with different threshold values based on the size of your workload and the granularity of your computational task.
  • You can use the size of the task (e.g., array length) and the computational complexity per element to determine a range for your threshold:
private static final int THRESHOLD = 10_000; // Example threshold

2. Use Proper Workload Division

  • The strategy for splitting work impacts the overall performance. Common approaches include:
    • Half-split: Divide the workload into two equal parts recursively. This ensures effective workload distribution between threads.
    • Chunking: Split into fixed or dynamic chunks (e.g., divide into smaller, equally sized chunks).

Example:
Splitting a task into smaller subsets for processing large arrays:

@Override
protected Long compute() {
   if (end - start <= THRESHOLD) {
       return computeDirectly();
   } else {
       int mid = (start + end) / 2;
       RecursiveTask<Long> leftTask = new MyTask(start, mid);
       RecursiveTask<Long> rightTask = new MyTask(mid, end);
       leftTask.fork();  // Fork the left
       long rightResult = rightTask.compute(); // Compute right directly (avoiding too much forking)
       long leftResult = leftTask.join(); // Wait for the left
       return leftResult + rightResult;
   }
}

Tip:
Avoid over-forking as it can degrade performance. You can compute one subtask directly while forking the other.


3. Avoid Nested ForkJoin Computations

  • If the subtasks themselves spawn other fork() calls, it can lead to additional overhead due to deeper task queues and increased contention.
  • Instead, ensure that each task completes most of its logic within itself. Use invokeAll() for evenly splitting tasks without complex recursion patterns.

4. Leverage ForkJoinPool Properly

  • Avoid creating multiple ForkJoinPool instances. Use one shared pool whenever possible.
  • Set the parallelism level of the pool to match the available number of processor cores (or slightly less if your program has other non-ForkJoin workloads).
ForkJoinPool pool = new ForkJoinPool(Runtime.getRuntime().availableProcessors());

5. Minimize Task Result Storage

  • If possible, avoid returning large objects between tasks or accumulating results in shared resources during parallel execution.
  • Utilize lightweight primitives (e.g., long, int) for combining results.

6. Profile and Benchmark

  • Use benchmarking tools like JMH (Java Microbenchmark Harness) to evaluate the performance of your RecursiveTask implementation.
  • Measure overhead versus the actual computational gain. Adjust your threshold size and splitting strategy accordingly.
  • Profile the pool for thread contention or task queue bottlenecks.

7. Avoid Redundant Forking

  • If your tasks reach a size below the threshold or don’t contain enough work to justify parallelism, directly compute the result instead of creating unnecessary tasks.

Example of an Optimized RecursiveTask

package org.kodejava.util.concurrent;

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

public class OptimizedTask extends RecursiveTask<Long> {

    private static final int THRESHOLD = 10_000; // Optimal split threshold
    private final int[] array;
    private final int start, end;

    public OptimizedTask(int[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        if (end - start <= THRESHOLD) {
            // If work is below threshold, compute sequentially
            return computeDirectly();
        } else {
            // Split workload into smaller tasks
            int mid = (start + end) / 2;
            OptimizedTask leftTask = new OptimizedTask(array, start, mid);
            OptimizedTask rightTask = new OptimizedTask(array, mid, end);

            // Fork the left task, compute the right directly
            leftTask.fork();
            long rightResult = rightTask.compute();
            long leftResult = leftTask.join();

            // Combine results
            return leftResult + rightResult;
        }
    }

    private Long computeDirectly() {
        long sum = 0;
        for (int i = start; i < end; i++) {
            sum += array[i];
        }
        return sum;
    }

    public static void main(String[] args) {
        int[] array = new int[100_000];
        for (int i = 0; i < array.length; i++) {
            array[i] = i + 1;
        }

        long result;
        try (ForkJoinPool pool = new ForkJoinPool()) {
            OptimizedTask task = new OptimizedTask(array, 0, array.length);

            result = pool.invoke(task);
        }
        System.out.println("Sum: " + result);
    }
}

Key Takeaways

  1. Tune the threshold and balance parallelism against overhead.
  2. Avoid excessive task creation by computing smaller tasks directly.
  3. Monitor ForkJoinPool utilization to ensure effective thread usage.
  4. Profile and benchmark your code to identify bottlenecks and adjust strategies dynamically.

By fine-tuning these aspects, you can optimize the performance of your RecursiveTask implementation.

How do I implement a custom blocking queue for special use cases?

To implement a custom blocking queue in Java for special use cases, you can extend the AbstractQueue or directly implement the BlockingQueue<T> interface available in the java.util.concurrent package. A blocking queue is a data structure that supports thread-safe operations and blocks threads attempting to enqueue or dequeue elements when the queue is full or empty, respectively.

The following is a detailed guide on implementing a custom blocking queue suitable for your special requirements:

Steps to Implement a Custom Blocking Queue

  1. Choose a base implementation:
    • Decide on the backing data structure (e.g., an Array, LinkedList, or any custom data structure).
    • Implement thread-safe operations using synchronization primitives, such as synchronized, ReentrantLock, or higher-level concurrency tools like Condition.
  2. Implement blocking behavior:
    • Threads should block if the queue is full (on put()).
    • Threads should block if the queue is empty (on take()).
  3. Implement synchronization:
    • Use wait() and notifyAll() (or Condition objects) to manage thread signaling between producers and consumers.
  4. Handle boundary conditions:
    • Implement additional logic for managing maximum capacity, null elements (optional), or custom priorities.

Example: Custom Blocking Queue Implementation (Array-based)

Here is a working example of an array-based blocking queue:

package org.kodejava.util.concurrent;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class CustomBlockingQueue<T> {
    private final T[] elements;
    private int head = 0;  // Points to the oldest element
    private int tail = 0;  // Points to the next insertion point
    private int count = 0; // Number of elements in the queue

    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notEmpty = lock.newCondition();
    private final Condition notFull = lock.newCondition();

    public CustomBlockingQueue(int capacity) {
        if (capacity <= 0)
            throw new IllegalArgumentException("Queue capacity must be greater than 0.");
        elements = (T[]) new Object[capacity];
    }

    // Add an element to the queue (blocks if full)
    public void put(T element) throws InterruptedException {
        if (element == null) throw new NullPointerException("Null elements are not allowed.");
        lock.lock();
        try {
            while (count == elements.length) {
                notFull.await(); // Wait until there is space
            }

            elements[tail] = element;
            tail = (tail + 1) % elements.length; // Circular buffer logic
            count++;
            notEmpty.signal(); // Notify a waiting consumer
        } finally {
            lock.unlock();
        }
    }

    // Retrieve and remove the head of the queue (blocks if empty)
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await(); // Wait until there is something to consume
            }

            T element = elements[head];
            elements[head] = null; // Remove the element
            head = (head + 1) % elements.length; // Circular buffer logic
            count--;
            notFull.signal(); // Notify a waiting producer
            return element;
        } finally {
            lock.unlock();
        }
    }

    // Return the current number of elements in the queue
    public int size() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }

    // Return the capacity of the queue
    public int capacity() {
        return elements.length;
    }
}

How It Works

  1. Internal Storage:
    • The queue uses a fixed-size circular array (elements) to store elements. It manages positions in the array using head and tail indices.
  2. Thread Safety:
    • A ReentrantLock ensures that only one thread can modify the queue at a time.
    • Condition objects (notEmpty and notFull) are used for blocking threads when the queue is empty or full.
  3. Blocking Behavior:
    • put() blocks (using notFull.await()) until there is space in the queue.
    • take() blocks (using notEmpty.await()) until the queue contains an element.
  4. Circular Array:
    • The head and tail indices wrap around using modulo arithmetic to implement a circular buffer.

How to Use the CustomBlockingQueue

package org.kodejava.util.concurrent;

public class CustomBlockingQueueDemo {
   public static void main(String[] args) {
      CustomBlockingQueue<Integer> queue = new CustomBlockingQueue<>(5);

      // Producer thread
      Thread producer = new Thread(() -> {
         try {
            for (int i = 1; i <= 10; i++) {
               System.out.println("Producing: " + i);
               queue.put(i);
               Thread.sleep(100); // Simulate time to produce
            }
         } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
         }
      });

      // Consumer thread
      Thread consumer = new Thread(() -> {
         try {
            for (int i = 1; i <= 10; i++) {
               int value = queue.take();
               System.out.println("Consuming: " + value);
               Thread.sleep(300); // Simulate time to consume
            }
         } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
         }
      });

      producer.start();
      consumer.start();
   }
}

Key Points to Note

  1. Thread Safety:
    • Use proper synchronization. In the example, a ReentrantLock ensures thread-safe operations.
  2. Custom Behavior:
    • You can modify or extend the behavior of the blocking queue to include priorities, timeouts, or other features.
  3. Optimization:
    • If the queue must be used in high-throughput scenarios, consider using more advanced synchronization mechanisms like those in the java.util.concurrent package.

This implementation provides a solid foundation for a custom blocking queue, and you can adapt it to your specific use cases.

How do I create a reusable SSH connection pool with JSch in a multithreaded application?

Creating a reusable SSH connection pool using JSch in a multithreaded application involves managing connections efficiently and ensuring thread safety. JSch (Java Secure Channel) does not natively provide a connection pooling feature, so you have to implement it manually using a pooling library or write your own pooling logic.

Below is the step-by-step guide to implementing a reusable SSH connection pool with JSch.

1. Define an SSH Connection Pool

You can use a thread-safe pool, such as Java’s BlockingQueue, to manage SSH connections. Here’s how:

Define a Connection Pool Manager

package org.kodejava.jsch;

import com.jcraft.jsch.*;
import java.util.concurrent.*;

public class SSHConnectionPool {
    private final BlockingQueue<Session> pool;
    private final JSch jsch;
    private final String username;
    private final String host;
    private final int port;
    private final String password; // or private key if using key-based authentication

    public SSHConnectionPool(int poolSize, String username, String password, 
                             String host, int port) throws JSchException {
        this.pool = new LinkedBlockingQueue<>(poolSize); // Thread-safe pool
        this.jsch = new JSch();
        this.username = username;
        this.host = host;
        this.port = port;
        this.password = password;

        for (int i = 0; i < poolSize; i++) {
            pool.offer(createSession()); // Initialize the pool with SSH sessions
        }
    }

    private Session createSession() throws JSchException {
        Session session = jsch.getSession(username, host, port);
        session.setPassword(password);

        // Configuration - Disable strict host checking for simplicity
        java.util.Properties config = new java.util.Properties();
        config.put("StrictHostKeyChecking", "no");
        session.setConfig(config);

        session.connect();
        return session;
    }

    public Session borrowSession() throws InterruptedException {
        return pool.take(); // Borrow a session from the pool
    }

    public void returnSession(Session session) {
        if (session != null) {
            pool.offer(session); // Return session to the pool
        }
    }

    public void close() {
        // Close all sessions and clear the pool
        for (Session session : pool) {
            session.disconnect();
        }
        pool.clear();
    }
}

2. Usage in a Multi-Threaded Application

You can now use SSHConnectionPool in a multithreaded environment. For every task, borrow a session, perform the necessary operations, and return the session to the pool.

Example

package org.kodejava.jsch;

import com.jcraft.jsch.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SSHPoolDemo {
    public static void main(String[] args) {
        try {
            // Create a pool with 5 connections
            SSHConnectionPool pool = new SSHConnectionPool(5, "username", 
                    "password", "example.com", 22);

            // Thread pool for executing tasks
            ExecutorService executorService = Executors.newFixedThreadPool(10);

            for (int i = 0; i < 10; i++) {
                executorService.submit(() -> {
                    Session session = null;
                    try {
                        // Borrow a session
                        session = pool.borrowSession();

                        // Execute commands via ChannelExec
                        ChannelExec channel = (ChannelExec) session.openChannel("exec");
                        channel.setCommand("echo Hello, World!");
                        channel.setInputStream(null);
                        channel.setErrStream(System.err);

                        channel.connect();

                        // Read the output
                        try (var input = channel.getInputStream()) {
                            int data;
                            while ((data = input.read()) != -1) {
                                System.out.print((char) data);
                            }
                        }

                        channel.disconnect();
                    } catch (Exception e) {
                        e.printStackTrace();
                    } finally {
                        // Return the session to the pool
                        pool.returnSession(session);
                    }
                });
            }

            // Shutdown thread pool after tasks are complete
            executorService.shutdown();

            // Clean up the connection pool
            pool.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

3. Notes

  • Thread Safety: LinkedBlockingQueue ensures thread-safe access to the pool.
  • Session Validity: Before returning a session to the pool, consider checking if it is still alive. JSch does not reconnect automatically if a session is disconnected.
  • Connection Configuration: You can use private key authentication by adding:
jsch.addIdentity("/path/to/private_key");
  • Resource Cleanup: Always close the pool properly to avoid resource leaks.

By following this setup, you can create a reusable and thread-safe SSH connection pool in a multithreaded application.


Maven Dependencies

<dependency>
    <groupId>com.jcraft</groupId>
    <artifactId>jsch</artifactId>
    <version>0.1.55</version>
</dependency>

Maven Central