diff --git a/auth/access_token_credentials/src/main/resources/log4j2.xml b/auth/access_token_credentials/src/main/resources/log4j2.xml new file mode 100644 index 0000000..2e48fc4 --- /dev/null +++ b/auth/access_token_credentials/src/main/resources/log4j2.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jdbc/pom.xml b/jdbc/pom.xml index 3e6ae92..c974a24 100644 --- a/jdbc/pom.xml +++ b/jdbc/pom.xml @@ -15,7 +15,7 @@ pom - 2.3.13 + 2.3.16 1.7.36 diff --git a/jdbc/ydb-token-app/README.md b/jdbc/ydb-token-app/README.md new file mode 100644 index 0000000..d95f6bf --- /dev/null +++ b/jdbc/ydb-token-app/README.md @@ -0,0 +1,76 @@ +## YDB Token Application +## A simple example of Spring Boot 2 Application working with YDB Database + +### How to build + +Requirements +* Java 17 or newer +* Maven 3.0.0 or newer + +To build the application as a single executable jar file, run the command: +``` +cd ydb-java-examples/jdbc/ydb-token-app +mvn clean package spring-boot:repackage +``` +After that, the compiled `ydb-token-app-1.1.0-SNAPSHOT.jar` can be found in the target folder. + +### What this application does + +This application allows you to create a test table called `app_token` in the YDB database, populate it with data, and +launch a test workload for parallel reading and writing to this table. During the test, the following operations will be +performed in parallel in several threads: +* Read a random token from the database - 50% of operations +* Read and update a random token in the database - 40% of operations +* Read and update 100 random tokens in the database - 10% of operations + +The statistics collected during the test include the number of operations performed, RPS (requests per second), and +average execution time for each type of operation. There is also support for exporting application metrics in Prometheus +format. + +### How to launch + +The application is built as a single executable jar file and can be run with the command: +``` +java -jar ydb-token-app-1.1.0-SNAPSHOT.jar +``` +Where `options` are application parameters (see the Application Parameters section), and `commands` are the sequence of +commands the application will execute one after the other. Currently, the following commands are supported: +* clean - clean the database, the `app_token` table will be deleted +* init - prepare the database, the empty `app_token` table will be created +* load - load test data, the `app_token` table will be filled with initial data +* run - start the test workload +* validate - validate current data stored in database + +Commands can be used individually or sequenced, for example: + +Recreate the `app_token` table and initialize it with initial data: +``` +java -jar ydb-token-app-1.1.0-SNAPSHOT.jar --app.connection=grpcs://my-ydb:2135/my-database clean init load +``` + +Start the test and then clean the database: +``` +java -jar ydb-token-app-1.1.0-SNAPSHOT.jar --app.connection=grpcs://my-ydb:2135/my-database run clean +``` + +Recreate the `app_token` table, initialize it with data, and start the test: +``` +java -jar ydb-token-app-1.1.0-SNAPSHOT.jar --app.connection=grpcs://my-ydb:2135/my-database clean init load run +``` + +### Application parameters + +Application parameters allow you to configure different aspects of the application's operation, primarily the database connection address. +The main parameters list: + +* `app.connection` - database connection address. Specified as `://:/` +* `app.threadsCount` - number of threads the application creates. Defaults to the number of CPU cores on the host. +* `app.recordsCount` - number of records in the table used for testing. Default is 1 million. +* `app.load.batchSize` - batch size for loading data when running the load command. Default is 1000. +* `app.workload.duration` - test duration in seconds when running the run command. Default is 60 seconds. +* `app.rpsLimit` - limit on the number of operations per second during the run command. By default, there is no limit (-1). +* `app.pushMetrics` - flag indicating whether metrics should be exported to Prometheus; disabled by default. +* `app.prometheusUrl` - endpoint of Prometheus to export metrics to. Default is http://localhost:9091. + +All parameters can be passed directly when launching the application (in the format `--param_name=value`) or can be +preconfigured in an `application.properties` file saved next to the executable jar of the application. diff --git a/jdbc/ydb-token-app/pom.xml b/jdbc/ydb-token-app/pom.xml index 931441f..80ab123 100644 --- a/jdbc/ydb-token-app/pom.xml +++ b/jdbc/ydb-token-app/pom.xml @@ -26,23 +26,38 @@ org.springframework.boot spring-boot-starter-data-jpa - ${spring.boot.version} + + + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + io.prometheus + simpleclient_pushgateway org.springframework.retry spring-retry - 2.0.7 jakarta.xml.bind jakarta.xml.bind-api - 2.3.2 tech.ydb.jdbc ydb-jdbc-driver + tech.ydb.dialects hibernate-ydb-dialect-v5 @@ -57,6 +72,35 @@ spring-boot-maven-plugin ${spring.boot.version} + + org.apache.maven.plugins + maven-compiler-plugin + 3.12.1 + + 17 + + -Xlint + + + + + + + + org.springframework.boot + spring-boot-starter-parent + ${spring.boot.version} + pom + import + + + + org.springframework.retry + spring-retry + 2.0.12 + + + \ No newline at end of file diff --git a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/AppMetrics.java b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/AppMetrics.java new file mode 100644 index 0000000..43282e7 --- /dev/null +++ b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/AppMetrics.java @@ -0,0 +1,265 @@ +package tech.ydb.apps; + +import java.sql.SQLException; +import java.time.Duration; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.LongAdder; +import java.util.function.Function; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import org.slf4j.Logger; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.RetryListener; + +import tech.ydb.core.StatusCode; +import tech.ydb.jdbc.exception.YdbStatusable; + +/** + * + * @author Aleksandr Gorshenin + */ +public class AppMetrics { + private static final ThreadLocal LOCAL = new ThreadLocal<>(); + + private static final Counter.Builder SDK_OPERATIONS = Counter.builder("sdk.operations"); + private static final Counter.Builder SDK_OPERATIONS_SUCCESS = Counter.builder("sdk.operations.success"); + private static final Counter.Builder SDK_OPERATIONS_FAILTURE = Counter.builder("sdk.operations.failture"); + private static final Counter.Builder SDK_RETRY_ATTEMPS = Counter.builder("sdk.retry.attempts"); + + private static final Timer.Builder SDK_OPERATION_LATENCY = Timer.builder("sdk.operation.latency") +// .serviceLevelObjectives(Duration.ofMillis(8), Duration.ofMillis(16), Duration.ofMillis(32), +// Duration.ofMillis(64), Duration.ofMillis(128), Duration.ofMillis(256), Duration.ofMillis(512), +// Duration.ofMillis(1024), Duration.ofMillis(2048), Duration.ofMillis(4096)) + .publishPercentiles(0.5, 0.9, 0.95, 0.99); + + public class Method { + private final String name; + + private final LongAdder totalCount = new LongAdder(); + private final LongAdder totalTimeMs = new LongAdder(); + + private final LongAdder count = new LongAdder(); + private final LongAdder timeMs = new LongAdder(); + + private final Counter executionsCounter; + private final Counter successCounter; + private final Map errorsCountersMap = new EnumMap<>(StatusCode.class); + private final Map retriesCountersMap = new EnumMap<>(StatusCode.class); + private final Map durationTimerMap = new EnumMap<>(StatusCode.class); + private final Function errorCounter; + private final Function retriesCounter; + private final Function durationTimer; + + private volatile long lastPrinted = 0; + + public Method(MeterRegistry registry, String name, String label) { + this.name = name; + this.executionsCounter = SDK_OPERATIONS.tag("operation_type", label).register(registry); + this.successCounter = SDK_OPERATIONS_SUCCESS.tag("operation_type", label).register(registry); + this.errorCounter = code -> errorsCountersMap.computeIfAbsent(code, key -> SDK_OPERATIONS_FAILTURE + .tag("operation_type", label) + .tag("operation_status", key.toString()) + .register(registry) + ); + this.retriesCounter = code -> retriesCountersMap.computeIfAbsent(code, key -> SDK_RETRY_ATTEMPS + .tag("operation_type", label) + .tag("operation_status", key.toString()) + .register(registry) + ); + this.durationTimer = code -> durationTimerMap.computeIfAbsent(code, key -> SDK_OPERATION_LATENCY + .tag("operation_type", label) + .tag("operation_status", key.toString()) + .register(registry) + ); + } + + public void measure(Runnable run) { + LOCAL.set(this); + + executionsCounter.increment(); + + StatusCode code = StatusCode.SUCCESS; + long startedAt = System.currentTimeMillis(); + try { + run.run(); + successCounter.increment(); + } catch (RuntimeException ex) { + code = extractStatusCode(ex); + errorCounter.apply(code).increment(); + throw ex; + } finally { + LOCAL.remove(); + + long ms = System.currentTimeMillis() - startedAt; + count.add(1); + totalCount.add(1); + timeMs.add(ms); + totalTimeMs.add(ms); + + durationTimer.apply(code).record(Duration.ofMillis(ms)); + } + } + + public void close() { + successCounter.close(); + executionsCounter.close(); + durationTimerMap.forEach((status, counter) -> counter.close()); + retriesCountersMap.forEach((status, counter) -> counter.close()); + errorsCountersMap.forEach((status, counter) -> counter.close()); + } + + private void reset() { + count.reset(); + timeMs.reset(); + lastPrinted = System.currentTimeMillis(); + } + + private void print(Logger logger) { + if (count.longValue() > 0 && lastPrinted != 0) { + long ms = System.currentTimeMillis() - lastPrinted; + double rps = 1000 * count.longValue() / ms; + logger.info("{}\twas executed {} times\t with RPS {} ops", name, count.longValue(), rps); + } + + reset(); + } + + private void printTotal(Logger logger) { + if (totalCount.longValue() > 0) { + double average = 1.0d * totalTimeMs.longValue() / totalCount.longValue(); + logger.info("{}\twas executed {} times,\twith average time {} ms/op", name, totalCount.longValue(), average); + } + } + } + + private final Logger logger; + private final Method load; + private final Method fetch; + private final Method update; + private final Method batchUpdate; + + private final AtomicInteger executionsCount = new AtomicInteger(0); + private final AtomicInteger failturesCount = new AtomicInteger(0); + private final AtomicInteger retriesCount = new AtomicInteger(0); + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor( + r -> new Thread(r, "ticker") + ); + + public AppMetrics(Logger logger, MeterRegistry meterRegistry) { + this.logger = logger; + this.load = new Method(meterRegistry, "LOAD ", "load"); + this.fetch = new Method(meterRegistry, "FETCH ", "read"); + this.update = new Method(meterRegistry, "UPDATE", "update"); + this.batchUpdate = new Method(meterRegistry, "BULK_UP", "batch_update"); + } + + public Method getLoad() { + return this.load; + } + + public Method getFetch() { + return this.fetch; + } + + public Method getUpdate() { + return this.update; + } + + public Method getBatchUpdate() { + return this.batchUpdate; + } + + public void incrementFaiture() { + failturesCount.incrementAndGet(); + } + + public void runWithMonitor(Runnable runnable) { + Arrays.asList(load, fetch, update, batchUpdate).forEach(Method::reset); + final ScheduledFuture future = scheduler.scheduleAtFixedRate(this::print, 1, 10, TimeUnit.SECONDS); + runnable.run(); + future.cancel(false); + print(); + } + + public void close() throws InterruptedException { + scheduler.shutdownNow(); + scheduler.awaitTermination(20, TimeUnit.SECONDS); + + Arrays.asList(load, fetch, update, batchUpdate).forEach(m -> m.close()); + } + + private void print() { + Arrays.asList(load, fetch, update, batchUpdate).forEach(m -> m.print(logger)); + } + + public void printTotal() { + if (failturesCount.get() > 0) { + logger.error("=========== TOTAL =============="); + Arrays.asList(load, fetch, update, batchUpdate).forEach(m -> m.printTotal(logger)); + logger.error("Executed {} transactions with {} retries and {} failtures", executionsCount.get(), + retriesCount.get() - failturesCount.get(), failturesCount.get()); + } else { + logger.info("=========== TOTAL =============="); + Arrays.asList(load, fetch, update, batchUpdate).forEach(m -> m.printTotal(logger)); + logger.info("Executed {} transactions with {} retries", executionsCount.get(), retriesCount.get()); + } + } + + public RetryListener getRetryListener() { + return new RetryListener() { + @Override + public boolean open(RetryContext ctx, RetryCallback cb) { + executionsCount.incrementAndGet(); + return true; + } + + @Override + public void onError(RetryContext ctx, RetryCallback cb, Throwable th) { + logger.debug("Retry operation with error {} ", printSqlException(th)); + retriesCount.incrementAndGet(); + Method m = LOCAL.get(); + if (m != null) { + m.retriesCounter.apply(extractStatusCode(th)).increment(); + } + } + + @Override + public void onSuccess(RetryContext context, RetryCallback cb, T result) { + // nothing + } + }; + } + + private String printSqlException(Throwable th) { + Throwable ex = th; + while (ex != null) { + if (ex instanceof SQLException) { + return ex.getMessage(); + } + ex = ex.getCause(); + } + return th.getMessage(); + } + + private StatusCode extractStatusCode(Throwable th) { + Throwable ex = th; + while (ex != null) { + if (ex instanceof YdbStatusable) { + return ((YdbStatusable) ex).getStatus().getCode(); + } + ex = ex.getCause(); + } + return StatusCode.CLIENT_INTERNAL_ERROR; + } +} diff --git a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/Application.java b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/Application.java index c594153..a6f1373 100644 --- a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/Application.java +++ b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/Application.java @@ -1,32 +1,33 @@ package tech.ydb.apps; -import java.sql.SQLException; import java.util.ArrayList; import java.util.List; import java.util.Random; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; import java.util.stream.IntStream; import javax.annotation.PreDestroy; +import io.micrometer.core.instrument.MeterRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; -import org.springframework.retry.RetryCallback; -import org.springframework.retry.RetryContext; import org.springframework.retry.RetryListener; import org.springframework.retry.annotation.EnableRetry; import tech.ydb.apps.service.SchemeService; -import tech.ydb.apps.service.TokenService; +import tech.ydb.apps.service.WorkloadService; import tech.ydb.jdbc.YdbTracer; /** @@ -34,39 +35,45 @@ * @author Aleksandr Gorshenin */ @EnableRetry +@EnableConfigurationProperties(Config.class) @SpringBootApplication public class Application implements CommandLineRunner { private static final Logger logger = LoggerFactory.getLogger(Application.class); - private static final int THREADS_COUNT = 32; - private static final int RECORDS_COUNT = 1_000_000; - private static final int LOAD_BATCH_SIZE = 1000; - - private static final int WORKLOAD_DURATION_SECS = 60; // 60 seconds - public static void main(String[] args) { - SpringApplication.run(Application.class, args).close(); + try { + SpringApplication.run(Application.class, args).close(); + } catch (Exception ex) { + logger.error("App finished with error", ex); + } } - private final Ticker ticker = new Ticker(logger); + private final AppMetrics ticker; + private final Config config; private final SchemeService schemeService; - private final TokenService tokenService; + private final WorkloadService workloadService; private final ExecutorService executor; private final AtomicInteger threadCounter = new AtomicInteger(0); - private final AtomicInteger executionsCount = new AtomicInteger(0); - private final AtomicInteger retriesCount = new AtomicInteger(0); + private final AtomicLong logCounter = new AtomicLong(0); + private volatile boolean isStopped = false; + + public Application(Config config, SchemeService scheme, WorkloadService worload, MeterRegistry registry) { + GrpcMetrics.init(registry); - public Application(SchemeService schemeService, TokenService tokenService) { - this.schemeService = schemeService; - this.tokenService = tokenService; + this.config = config; + this.schemeService = scheme; + this.workloadService = worload; + this.ticker = new AppMetrics(logger, registry); - this.executor = Executors.newFixedThreadPool(THREADS_COUNT, this::threadFactory); + logger.info("Create fixed thread pool with size {}", config.getThreadCount()); + this.executor = Executors.newFixedThreadPool(config.getThreadCount(), this::threadFactory); } @PreDestroy public void close() throws Exception { + isStopped = true; logger.info("CLI app is waiting for finishing"); executor.shutdown(); @@ -75,43 +82,23 @@ public void close() throws Exception { ticker.printTotal(); ticker.close(); - logger.info("Executed {} transactions with {} retries", executionsCount.get(), retriesCount.get()); logger.info("CLI app has finished"); } @Bean public RetryListener retryListener() { - return new RetryListener() { - @Override - public boolean open(RetryContext ctx, RetryCallback callback) { - executionsCount.incrementAndGet(); - return true; - } - - @Override - public void onError(RetryContext ctx, RetryCallback callback, Throwable th) { - logger.debug("Retry operation with error {} ", printSqlException(th)); - retriesCount.incrementAndGet(); - } - }; - } - - private String printSqlException(Throwable th) { - Throwable ex = th; - while (ex != null) { - if (ex instanceof SQLException) { - return ex.getMessage(); - } - ex = ex.getCause(); - } - return th.getMessage(); + return ticker.getRetryListener(); } @Override public void run(String... args) { - logger.info("CLI app has started"); + logger.info("CLI app has started with database {}", config.getConnection()); for (String arg : args) { + if (arg.startsWith("--")) { // skip Spring parameters + continue; + } + logger.info("execute {} step", arg); if ("clean".equalsIgnoreCase(arg)) { @@ -130,6 +117,10 @@ public void run(String... args) { ticker.runWithMonitor(this::runWorkloads); } + if ("validate".equalsIgnoreCase(arg)) { + executeValidate(); + } + if ("test".equalsIgnoreCase(arg)) { ticker.runWithMonitor(this::test); } @@ -141,19 +132,25 @@ private Thread threadFactory(Runnable runnable) { } private void loadData() { + int recordsCount = config.getRecordsCount(); + int batchSize = config.getLoadBatchSize(); + List> futures = new ArrayList<>(); int id = 0; - while (id < RECORDS_COUNT) { + while (id < recordsCount) { final int first = id; - id += LOAD_BATCH_SIZE; - final int last = id < RECORDS_COUNT ? id : RECORDS_COUNT; + id += batchSize; + final int last = id < recordsCount ? id : recordsCount; futures.add(CompletableFuture.runAsync(() -> { - try (Ticker.Measure measure = ticker.getLoad().newCall()) { - tokenService.insertBatch(first, last); - logger.debug("inserted tokens [{}, {})", first, last); - measure.inc(); + if (isStopped) { + return; } + + ticker.getLoad().measure(() -> { + workloadService.loadData(first, last); + logger.debug("inserted tokens [{}, {})", first, last); + }); }, executor)); } @@ -163,55 +160,74 @@ private void loadData() { private void test() { YdbTracer.current().markToPrint("test"); + int recordsCount = config.getRecordsCount(); final Random rnd = new Random(); - List randomIds = IntStream.range(0, 100) - .mapToObj(idx -> rnd.nextInt(RECORDS_COUNT)) - .collect(Collectors.toList()); + Set randomIds = IntStream.range(0, 100) + .mapToObj(idx -> rnd.nextInt(recordsCount)) + .collect(Collectors.toSet()); - tokenService.updateBatch(randomIds); + workloadService.updateBatch(randomIds, 0); } private void runWorkloads() { - long finishAt = System.currentTimeMillis() + WORKLOAD_DURATION_SECS * 1000; + RateLimiter rt = config.getRpsLimiter(); + logCounter.set(workloadService.readLastGlobalVersion()); + long finishAt = System.currentTimeMillis() + config.getWorkloadDurationSec() * 1000; List> futures = new ArrayList<>(); - for (int i = 0; i < THREADS_COUNT; i++) { - futures.add(CompletableFuture.runAsync(() -> this.workload(finishAt), executor)); + for (int i = 0; i < config.getThreadCount(); i++) { + futures.add(CompletableFuture.runAsync(() -> this.workload(rt, finishAt), executor)); } futures.forEach(CompletableFuture::join); } - private void workload(long finishAt) { + private void workload(RateLimiter rt, long finishAt) { final Random rnd = new Random(); - while (System.currentTimeMillis() < finishAt) { + final int recordCount = config.getRecordsCount(); + + while ((System.currentTimeMillis() < finishAt) && !isStopped) { + rt.acquire(); int mode = rnd.nextInt(10); try { - if (mode < 2) { - try (Ticker.Measure measure = ticker.getBatchUpdate().newCall()) { - List randomIds = IntStream.range(0, 100) - .mapToObj(idx -> rnd.nextInt(RECORDS_COUNT)) - .collect(Collectors.toList()); - tokenService.updateBatch(randomIds); - measure.inc(); - } - - } else if (mode < 6) { - int id = rnd.nextInt(RECORDS_COUNT); - try (Ticker.Measure measure = ticker.getFetch().newCall()) { - tokenService.fetchToken(id); - measure.inc(); - } + if (mode < 5) { + executeFetch(rnd, recordCount); // 50 percents + } else if (mode < 9) { + executeUpdate(rnd, recordCount); // 40 percents } else { - int id = rnd.nextInt(RECORDS_COUNT); - try (Ticker.Measure measure = ticker.getUpdate().newCall()) { - tokenService.updateToken(id); - measure.inc(); - } + executeBatchUpdate(rnd, recordCount); // 10 percents } } catch (RuntimeException ex) { - logger.debug("got exception {}", ex.getMessage()); + ticker.incrementFaiture(); + logger.warn("got exception {}", ex.getMessage()); } } } + + private void executeFetch(Random rnd, int recordCount) { + int id = rnd.nextInt(recordCount); + ticker.getFetch().measure(() -> workloadService.fetchToken(id)); + } + + private void executeUpdate(Random rnd, int recordCount) { + int id = rnd.nextInt(recordCount); + ticker.getUpdate().measure(() -> workloadService.updateToken(id, logCounter.incrementAndGet())); + } + + private void executeBatchUpdate(Random rnd, int recordCount) { + ticker.getBatchUpdate().measure(() -> { + Set randomIds = IntStream.range(0, 100) + .mapToObj(idx -> rnd.nextInt(recordCount)) + .collect(Collectors.toSet()); + long counter = logCounter.getAndAdd(randomIds.size()) + 1; + workloadService.updateBatch(randomIds, counter); + }); + } + + private void executeValidate() { + logger.info("=========== VALIDATE =============="); + logger.info("Log table size = {}", workloadService.countTokenLogs()); + logger.info("Last log version = {}", workloadService.readLastGlobalVersion()); + logger.info("Token updates count = {}", workloadService.countAllTokenUpdates()); + } } diff --git a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/Config.java b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/Config.java new file mode 100644 index 0000000..c3f5aa3 --- /dev/null +++ b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/Config.java @@ -0,0 +1,57 @@ +package tech.ydb.apps; + + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; +import org.springframework.boot.context.properties.bind.Name; + +/** + * + * @author Aleksandr Gorshenin + */ + +@ConstructorBinding +@ConfigurationProperties(prefix = "app") +public class Config { + private final String connection; + private final int threadsCount; + private final int recordsCount; + private final int loadBatchSize; + private final int workloadDurationSec; + private final int rpsLimit; + + public Config(String connection, int threadsCount, int recordsCount, @Name("load.batchSize") int loadBatchSize, + @Name("workload.duration") int workloadDuration, int rpsLimit, MeterRegistry registy) { + this.connection = connection; + this.threadsCount = threadsCount <= 0 ? Runtime.getRuntime().availableProcessors() : threadsCount; + this.recordsCount = recordsCount; + this.loadBatchSize = loadBatchSize; + this.workloadDurationSec = workloadDuration; + this.rpsLimit = rpsLimit; + } + + public String getConnection() { + return this.connection; + } + + public int getThreadCount() { + return this.threadsCount; + } + + public int getRecordsCount() { + return this.recordsCount; + } + + public int getLoadBatchSize() { + return this.loadBatchSize; + } + + public int getWorkloadDurationSec() { + return workloadDurationSec; + } + + public RateLimiter getRpsLimiter() { + return rpsLimit <= 0 ? RateLimiter.noLimit() : RateLimiter.withRps(rpsLimit); + } +} diff --git a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/GrpcMetrics.java b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/GrpcMetrics.java new file mode 100644 index 0000000..3435634 --- /dev/null +++ b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/GrpcMetrics.java @@ -0,0 +1,142 @@ +package tech.ydb.apps; + +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import javax.annotation.Nullable; + +import io.grpc.Attributes; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; + +/** + * + * @author Aleksandr Gorshenin + */ +public class GrpcMetrics implements Consumer>, ClientInterceptor { + private static final Counter.Builder REQUEST = Counter.builder("grpc.request"); + private static final Counter.Builder RESPONSE = Counter.builder("grpc.response"); + private static final Timer.Builder LATENCY = Timer.builder("grpc.latency") + .publishPercentiles(0.5, 0.9, 0.95, 0.99); + + private static MeterRegistry REGISTRY = null; + + public static void init(MeterRegistry registry) { + REGISTRY = registry; + } + + @Override + public void accept(ManagedChannelBuilder t) { + t.intercept(this); + } + + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + if (REGISTRY != null) { + return new ProxyClientCall<>(REGISTRY, next, method, callOptions); + } + return next.newCall(method, callOptions); + } + + private static class ProxyClientCall extends ClientCall { + private final MeterRegistry registry; + private final String method; + private final String authority; + private final ClientCall delegate; + + private ProxyClientCall(MeterRegistry registry, Channel channel, MethodDescriptor method, + CallOptions callOptions) { + this.registry = registry; + this.method = method.getBareMethodName(); + this.authority = channel.authority(); + this.delegate = channel.newCall(method, callOptions); + } + + @Override + public void request(int numMessages) { + delegate.request(numMessages); + } + + @Override + public void cancel(@Nullable String message, @Nullable Throwable cause) { + delegate.cancel(message, cause); + } + + @Override + public void halfClose() { + delegate.halfClose(); + } + + @Override + public void setMessageCompression(boolean enabled) { + delegate.setMessageCompression(enabled); + } + + @Override + public boolean isReady() { + return delegate.isReady(); + } + + @Override + public Attributes getAttributes() { + return delegate.getAttributes(); + } + + @Override + public void start(Listener listener, Metadata headers) { + REQUEST.tag("method", method).tag("authority", authority).register(registry).increment(); + delegate.start(new ProxyListener(listener), headers); + } + + @Override + public void sendMessage(ReqT message) { + delegate.sendMessage(message); + } + + private class ProxyListener extends Listener { + private final Listener delegate; + private final long startedAt; + + public ProxyListener(Listener delegate) { + this.delegate = delegate; + this.startedAt = System.currentTimeMillis(); + } + + + @Override + public void onHeaders(Metadata headers) { + delegate.onHeaders(headers); + } + + @Override + public void onMessage(RespT message) { + delegate.onMessage(message); + } + + @Override + public void onClose(Status status, Metadata trailers) { + long ms = System.currentTimeMillis() - startedAt; + RESPONSE.tag("method", method).tag("authority", authority).tag("status", status.getCode().toString()) + .register(registry).increment(); + LATENCY.tag("method", method).tag("authority", authority) + .register(registry).record(ms, TimeUnit.MILLISECONDS); + delegate.onClose(status, trailers); + } + + @Override + public void onReady() { + delegate.onReady(); + } + } + } +} diff --git a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/RateLimiter.java b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/RateLimiter.java new file mode 100644 index 0000000..ff04267 --- /dev/null +++ b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/RateLimiter.java @@ -0,0 +1,19 @@ +package tech.ydb.apps; + + +/** + * + * @author Aleksandr Gorshenin + */ +public interface RateLimiter { + void acquire(); + + static RateLimiter noLimit() { + // nothing + return () -> { }; + } + + static RateLimiter withRps(int rps) { + return com.google.common.util.concurrent.RateLimiter.create(rps)::acquire; + } +} diff --git a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/Ticker.java b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/Ticker.java deleted file mode 100644 index 1948c7e..0000000 --- a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/Ticker.java +++ /dev/null @@ -1,141 +0,0 @@ -package tech.ydb.apps; - -import java.util.Arrays; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.LongAdder; - -import org.slf4j.Logger; - -/** - * - * @author Aleksandr Gorshenin - */ -public class Ticker { - public class Measure implements AutoCloseable { - private final Method method; - private final long startedAt; - private long count = 0; - - public Measure(Method method) { - this.method = method; - this.startedAt = System.currentTimeMillis(); - } - - public void inc() { - count += 1; - } - - @Override - public void close() { - if (count == 0) { - return; - } - - long ms = System.currentTimeMillis() - startedAt; - - method.count.add(count); - method.totalCount.add(count); - - method.timeMs.add(ms); - method.totalTimeMs.add(ms); - } - } - - public class Method { - private final String name; - - private final LongAdder totalCount = new LongAdder(); - private final LongAdder totalTimeMs = new LongAdder(); - - private final LongAdder count = new LongAdder(); - private final LongAdder timeMs = new LongAdder(); - - private volatile long lastPrinted = 0; - - public Method(String name) { - this.name = name; - } - - public Measure newCall() { - return new Measure(this); - } - - private void reset() { - count.reset(); - timeMs.reset(); - lastPrinted = System.currentTimeMillis(); - } - - private void print(Logger logger) { - if (count.longValue() > 0 && lastPrinted != 0) { - long ms = System.currentTimeMillis() - lastPrinted; - double rps = 1000 * count.longValue() / ms; - logger.info("{}\twas executed {} times\t with RPS {} ops", name, count.longValue(), rps); - } - - reset(); - } - - private void printTotal(Logger logger) { - if (totalCount.longValue() > 0) { - double average = 1.0d * totalTimeMs.longValue() / totalCount.longValue(); - logger.info("{}\twas executed {} times,\twith average time {} ms/op", name, totalCount.longValue(), average); - } - } - } - - private final Logger logger; - private final Method load = new Method("LOAD "); - private final Method fetch = new Method("FETCH "); - private final Method update = new Method("UPDATE"); - private final Method batchUpdate = new Method("BULK_UP"); - - private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor( - r -> new Thread(r, "ticker") - ); - - public Ticker(Logger logger) { - this.logger = logger; - } - - public Method getLoad() { - return this.load; - } - - public Method getFetch() { - return this.fetch; - } - - public Method getUpdate() { - return this.update; - } - - public Method getBatchUpdate() { - return this.batchUpdate; - } - - public void runWithMonitor(Runnable runnable) { - Arrays.asList(load, fetch, update, batchUpdate).forEach(Method::reset); - final ScheduledFuture future = scheduler.scheduleAtFixedRate(this::print, 1, 10, TimeUnit.SECONDS); - runnable.run(); - future.cancel(false); - print(); - } - - public void close() throws InterruptedException { - scheduler.shutdownNow(); - scheduler.awaitTermination(20, TimeUnit.SECONDS); - } - - private void print() { - Arrays.asList(load, fetch, update, batchUpdate).forEach(m -> m.print(logger)); - } - - public void printTotal() { - logger.info("=========== TOTAL =============="); - Arrays.asList(load, fetch, update, batchUpdate).forEach(m -> m.printTotal(logger)); - } -} diff --git a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/annotation/YdbRetryable.java b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/annotation/YdbRetryable.java index 4225352..c958bcd 100644 --- a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/annotation/YdbRetryable.java +++ b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/annotation/YdbRetryable.java @@ -5,6 +5,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.sql.SQLRecoverableException; +import java.sql.SQLTransientException; import org.springframework.core.annotation.AliasFor; import org.springframework.retry.annotation.Backoff; @@ -17,8 +18,8 @@ @Target({ ElementType.METHOD, ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) @Retryable( - retryFor = SQLRecoverableException.class, - maxAttempts = 5, + retryFor = { SQLRecoverableException.class, SQLTransientException.class }, + maxAttempts = 15, backoff = @Backoff(delay = 100, multiplier = 2.0, maxDelay = 5000, random = true) ) public @interface YdbRetryable { diff --git a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/entity/TokenLog.java b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/entity/TokenLog.java new file mode 100644 index 0000000..c08e038 --- /dev/null +++ b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/entity/TokenLog.java @@ -0,0 +1,99 @@ +package tech.ydb.apps.entity; + +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import javax.persistence.Transient; + +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import org.hibernate.annotations.DynamicUpdate; +import org.springframework.data.domain.Persistable; + +/** + * + * @author Aleksandr Gorshenin + */ +@Entity +@DynamicUpdate +@Table(name = "app_token_log") +public class TokenLog implements Serializable, Persistable { + private static final long serialVersionUID = -3643491448443852677L; + + @Id + private String id; + + @ManyToOne + private Token token; + + @Column + private Long globalVersion; + + @Column + private Instant updatedAt; + + @Column + private Integer updatedTo; + + @Transient + private final boolean isNew; + + @Override + public String getId() { + return this.id; + } + + public Token getToken() { + return this.token; + } + + public Long getGlobalVersion() { + return this.globalVersion; + } + + public Instant getUpdatedAt() { + return this.updatedAt; + } + + public Integer getUpdatedTo() { + return this.updatedTo; + } + + @Override + public boolean isNew() { + return isNew; + } + + public TokenLog() { + this.isNew = false; + } + + public TokenLog(Token token, long version) { + this.id = hash256(token.getId(), version); + this.globalVersion = version; + this.token = token; + this.updatedAt = Instant.now(); + this.updatedTo = token.getVersion(); + this.isNew = true; + } + + @Override + public String toString() { + return "TokenLog{version=" + globalVersion + ", token=" + token.getId() + + ", updateAt='" + updatedAt + "', updateTo=" + updatedTo + "}"; + } + + private static String hash256(UUID uuid, long version) { + Hasher hasher = Hashing.sha256().newHasher(24); + hasher.putLong(uuid.getMostSignificantBits()); + hasher.putLong(uuid.getLeastSignificantBits()); + hasher.putLong(version); + return hasher.hash().toString(); + } +} diff --git a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/repo/TokenLogRepository.java b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/repo/TokenLogRepository.java new file mode 100644 index 0000000..bc581f6 --- /dev/null +++ b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/repo/TokenLogRepository.java @@ -0,0 +1,17 @@ +package tech.ydb.apps.repo; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; + +import tech.ydb.apps.entity.TokenLog; + +/** + * + * @author Aleksandr Gorshenin + */ +public interface TokenLogRepository extends CrudRepository { + @Query("SELECT MAX(log.globalVersion) FROM TokenLog log") + Optional findTopGlobalVersion(); +} diff --git a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/repo/TokenRepository.java b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/repo/TokenRepository.java index e6f3a13..fcd02a1 100644 --- a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/repo/TokenRepository.java +++ b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/repo/TokenRepository.java @@ -1,5 +1,6 @@ package tech.ydb.apps.repo; +import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.Query; @@ -19,4 +20,7 @@ public interface TokenRepository extends CrudRepository { void saveAllAndFlush(Iterable list); void deleteAllByIdInBatch(Iterable ids); + + @Query("SELECT SUM(token.version - 1) FROM Token token") + Optional countAllUpdates(); } diff --git a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/service/TokenService.java b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/service/WorkloadService.java similarity index 51% rename from jdbc/ydb-token-app/src/main/java/tech/ydb/apps/service/TokenService.java rename to jdbc/ydb-token-app/src/main/java/tech/ydb/apps/service/WorkloadService.java index d2aff7d..324d92d 100644 --- a/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/service/TokenService.java +++ b/jdbc/ydb-token-app/src/main/java/tech/ydb/apps/service/WorkloadService.java @@ -3,9 +3,12 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; +import javax.persistence.EntityNotFoundException; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -13,6 +16,8 @@ import tech.ydb.apps.annotation.YdbRetryable; import tech.ydb.apps.entity.Token; +import tech.ydb.apps.entity.TokenLog; +import tech.ydb.apps.repo.TokenLogRepository; import tech.ydb.apps.repo.TokenRepository; /** @@ -20,12 +25,14 @@ * @author Aleksandr Gorshenin */ @Service -public class TokenService { - private final static Logger logger = LoggerFactory.getLogger(TokenService.class); - private final TokenRepository repository; - - public TokenService(TokenRepository repository) { - this.repository = repository; +public class WorkloadService { + private final static Logger logger = LoggerFactory.getLogger(WorkloadService.class); + private final TokenRepository tokenRepo; + private final TokenLogRepository tokenLogRepo; + + public WorkloadService(TokenRepository tokenRepo, TokenLogRepository tokenLogRepo) { + this.tokenRepo = tokenRepo; + this.tokenLogRepo = tokenLogRepo; } private UUID getKey(int id) { @@ -34,22 +41,22 @@ private UUID getKey(int id) { @YdbRetryable @Transactional - public void insertBatch(int firstID, int lastID) { + public void loadData(int firstID, int lastID) { List batch = new ArrayList<>(); for (int id = firstID; id < lastID; id++) { batch.add(new Token("user_" + id)); } - repository.saveAll(batch); + tokenRepo.saveAll(batch); } @YdbRetryable @Transactional public Token fetchToken(int id) { - Optional token = repository.findById(getKey(id)); + Optional token = tokenRepo.findById(getKey(id)); if (!token.isPresent()) { logger.warn("token {} is not found", id); - return null; + throw new EntityNotFoundException("token " + id + " is not found"); } return token.get(); @@ -57,45 +64,55 @@ public Token fetchToken(int id) { @YdbRetryable @Transactional - public void updateToken(int id) { + public void updateToken(int id, long counter) { Token token = fetchToken(id); if (token != null) { token.incVersion(); - repository.save(token); + tokenRepo.save(token); + tokenLogRepo.save(new TokenLog(token, counter)); logger.trace("updated token {} -> {}", id, token.getVersion()); + } else { + logger.warn("token {} is not found", id); + throw new EntityNotFoundException("token " + id + " is not found"); } } @YdbRetryable @Transactional - public void updateBatch(List ids) { + public void updateBatch(Set ids, long counterFrom) { List uuids = ids.stream().map(this::getKey).collect(Collectors.toList()); - Iterable batch = repository.findAllById(uuids); + Iterable batch = tokenRepo.findAllById(uuids); + List logs = new ArrayList<>(); for (Token token: batch) { logger.trace("update token {}", token); token.incVersion(); + logs.add(new TokenLog(token, counterFrom++)); } - repository.saveAllAndFlush(batch); + tokenRepo.saveAll(batch); + tokenLogRepo.saveAll(logs); } @YdbRetryable @Transactional public void removeBatch(List ids) { List uuids = ids.stream().map(this::getKey).collect(Collectors.toList()); - repository.deleteAllByIdInBatch(uuids); + tokenRepo.deleteAllByIdInBatch(uuids); } @YdbRetryable - @Transactional - public void listManyRecords() { - long count = 0; - for (String id : repository.scanFindAll()) { - count ++; - if (count % 1000 == 0) { - logger.info("scan readed {} records", count); - } - } + public long readLastGlobalVersion() { + return tokenLogRepo.findTopGlobalVersion().orElse(0l); + } + + @YdbRetryable + public long countTokenLogs() { + return tokenLogRepo.count(); + } + + @YdbRetryable + public long countAllTokenUpdates() { + return tokenRepo.countAllUpdates().orElse(0l); } } diff --git a/jdbc/ydb-token-app/src/main/resources/application.properties b/jdbc/ydb-token-app/src/main/resources/application.properties index 0a88d58..1cc894b 100644 --- a/jdbc/ydb-token-app/src/main/resources/application.properties +++ b/jdbc/ydb-token-app/src/main/resources/application.properties @@ -1,9 +1,19 @@ -spring.datasource.url=jdbc:ydb:grpc://localhost:2136/local +app.connection=grpc://localhost:2136/local +app.threadsCount=-1 +app.recordsCount=1000000 +app.load.batchSize=1000 +app.workload.duration=60 +app.rpsLimit=-1 +app.pushMetrics=false +app.prometheusUrl=http://localhost:9091 + +spring.application.name=ydb-token-app +spring.datasource.url=jdbc:ydb:${app.connection} spring.datasource.driver-class-name=tech.ydb.jdbc.YdbDriver -spring.datasource.hikari.maximum-pool-size=100 -spring.datasource.hikari.data-source-properties.useQueryService=true +spring.datasource.hikari.maximum-pool-size=200 spring.datasource.hikari.data-source-properties.enableTxTracer=true +spring.datasource.hikari.data-source-properties.channelInitializer=tech.ydb.apps.GrpcMetrics spring.jpa.properties.hibernate.jdbc.batch_size=1000 spring.jpa.properties.hibernate.order_updates=true @@ -11,9 +21,32 @@ spring.jpa.properties.hibernate.order_inserts=true spring.jpa.properties.hibernate.dialect=tech.ydb.hibernate.dialect.YdbDialect #spring.jpa.show-sql = true -logging.level.org.hibernate.engine=OFF +management.metrics.enable.all=false +management.metrics.enable.jdbc=true +management.metrics.enable.sdk=true +management.metrics.enable.grpc=true -#logging.level.tech.ydb.apps=TRACE -#logging.level.tech.ydb.jdbc.YdbDriver=TRACE +# Enable Spring Boot Actuator +#management.endpoints.web.exposure.include=health,metrics,prometheus,info +#management.endpoint.health.enabled=true +#management.endpoint.metrics.enabled=true +#management.endpoint.prometheus.enabled=true +#management.metrics.export.prometheus.enabled=true + +management.metrics.export.prometheus.pushgateway.enabled=${app.pushMetrics} +management.metrics.export.prometheus.pushgateway.push-rate=1s +management.metrics.export.prometheus.pushgateway.base-url=${app.prometheusUrl} + +logging.pattern.console=%clr(%d{yyyy-MM-dd'T'HH:mm:ss.SSS}){faint} %clr(%5p) %clr(---){faint} %clr([%12.12t]){faint} %clr(%-24.24logger{1}){cyan} %clr(:){faint} %m%n%wEx +#logging.level.org.hibernate.engine=OFF + +logging.level.com.zaxxer=OFF +logging.level.org.hibernate=OFF #logging.level.org.hibernate.SQL=DEBUG -#logging.level.org.hibernate.type=TRACE \ No newline at end of file +#logging.level.org.hibernate.type=TRACE + +logging.level.org.springframework=OFF + +#logging.level.tech.ydb.jdbc.YdbDriver=TRACE +#logging.level.tech.ydb.core.impl.YdbDiscovery=DEBUG +#logging.level.tech.ydb.core.impl.pool.EndpointPool=DEBUG diff --git a/jdbc/ydb-token-app/src/main/resources/sql/drop.sql b/jdbc/ydb-token-app/src/main/resources/sql/drop.sql index 3f4fd46..41a0356 100644 --- a/jdbc/ydb-token-app/src/main/resources/sql/drop.sql +++ b/jdbc/ydb-token-app/src/main/resources/sql/drop.sql @@ -1 +1,2 @@ -DROP TABLE app_token; +DROP TABLE IF EXISTS app_token; +DROP TABLE IF EXISTS app_token_log; diff --git a/jdbc/ydb-token-app/src/main/resources/sql/init.sql b/jdbc/ydb-token-app/src/main/resources/sql/init.sql index 5426b4f..c4e2980 100644 --- a/jdbc/ydb-token-app/src/main/resources/sql/init.sql +++ b/jdbc/ydb-token-app/src/main/resources/sql/init.sql @@ -3,4 +3,17 @@ CREATE TABLE app_token ( username Text, version Int32, PRIMARY KEY (id) +) WITH ( + AUTO_PARTITIONING_BY_LOAD=ENABLED +); + +CREATE TABLE app_token_log ( + id Text NOT NULL, + global_version Int64 NOT NULl, + token_id Text NOT NULL, + updated_at Timestamp, + updated_to Int32, + PRIMARY KEY (id) +) WITH ( + AUTO_PARTITIONING_BY_LOAD=ENABLED ); diff --git a/pom.xml b/pom.xml index 8978897..621dae8 100644 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,7 @@ 2.22.1 1.82 - 2.3.17 + 2.3.18