diff --git a/.github/workflows/slo-test.yml b/.github/workflows/slo-test.yml
new file mode 100644
index 0000000..f0c5bde
--- /dev/null
+++ b/.github/workflows/slo-test.yml
@@ -0,0 +1,110 @@
+name: SLO JDBC
+
+on:
+ push:
+ branches: [master, main, add-slo-workload]
+ paths:
+ - 'slo-workload/**'
+ - '.github/workflows/slo-test.yml'
+ pull_request:
+ paths:
+ - 'slo-workload/**'
+ - '.github/workflows/slo-test.yml'
+
+jobs:
+ slo-workload-test:
+ if: (!contains(github.event.pull_request.labels.*.name, 'no slo'))
+ name: Test SLO Workload
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ workload:
+ - simple-jdbc
+ include:
+ - workload: simple-jdbc
+ test_duration: 60
+ read_rps: 1000
+ write_rps: 100
+ read_timeout: 1000
+ write_timeout: 1000
+
+ concurrency:
+ group: slo-${{ github.ref }}-${{ matrix.workload }}
+ cancel-in-progress: true
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Initialize YDB SLO
+ uses: ydb-platform/ydb-slo-action/init@53e02500d4a98a6b67d9009bc46e839236f15f81
+ with:
+ github_pull_request_number: ${{ github.event.pull_request.number }}
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ workload_name: ${{ matrix.workload }}
+ ydb_database_node_count: 5
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 21
+ cache: maven
+
+ - name: Build project
+ run: mvn clean install -DskipTests -q
+
+ - name: Wait for YDB
+ run: |
+ echo "Waiting for YDB to start..."
+ for i in {1..30}; do
+ if nc -zv localhost 2135 2>&1 | grep -q "succeeded"; then
+ echo "YDB is ready!"
+ break
+ fi
+ echo "Attempt $i/30..."
+ sleep 2
+ done
+
+ - name: Run SLO test
+ env:
+ WORKLOAD_NAME: ${{ matrix.workload }}
+ YDB_JDBC_URL: jdbc:ydb:grpc://localhost:2135/Root/testdb
+ PROM_PGW: http://localhost:9091
+ TEST_DURATION: ${{ matrix.test_duration }}
+ READ_RPS: ${{ matrix.read_rps }}
+ WRITE_RPS: ${{ matrix.write_rps }}
+ READ_TIMEOUT: ${{ matrix.read_timeout }}
+ WRITE_TIMEOUT: ${{ matrix.write_timeout }}
+ REPORT_PERIOD: 1000
+ run: |
+ mvn test -pl slo-workload/${{ matrix.workload }} \
+ -Dskip.jdbc.tests=false \
+ -Dtest=JdbcSloTest
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: slo-test-results-${{ matrix.workload }}
+ path: |
+ slo-workload/**/target/surefire-reports/
+ slo-workload/**/target/*.log
+ retention-days: 3
+
+ publish-slo-report:
+ name: Publish SLO Report
+ runs-on: ubuntu-latest
+ needs: slo-workload-test
+ if: success()
+ permissions:
+ contents: read
+ pull-requests: write
+ actions: read
+ steps:
+ - name: Publish report
+ uses: ydb-platform/ydb-slo-action/report@53e02500d4a98a6b67d9009bc46e839236f15f81
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ github_run_id: ${{ github.run_id }}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 8978897..8b23a80 100644
--- a/pom.xml
+++ b/pom.xml
@@ -30,6 +30,7 @@
url-shortener-demo
jdbc
project-course
+ slo-workload
diff --git a/slo-workload/README.md b/slo-workload/README.md
new file mode 100644
index 0000000..d48ea9e
--- /dev/null
+++ b/slo-workload/README.md
@@ -0,0 +1,508 @@
+# YDB SLO Workload Tests
+
+Набор SLO (Service Level Objectives) тестов для проверки производительности и надежности YDB клиентов на Java.
+
+## Назначение
+
+SLO тесты измеряют соответствие реальной производительности заявленным целевым метрикам:
+- **Latency**: время отклика операций (P50, P95, P99)
+- **Availability**: процент успешных операций
+- **Throughput**: количество операций в секунду
+- **Stability**: стабильность под длительной нагрузкой
+
+## Стратегия тестирования
+
+### Фазы выполнения
+
+#### 1. Инициализация (Setup Phase)
+```
+Действия:
+- Создание таблицы slo_table
+- Проверка подключения к YDB
+- Инициализация метрик
+
+Длительность: ~5 секунд
+```
+
+#### 2. Прогрев (Warmup Phase)
+```
+Зачем нужен:
+- JIT компиляция "горячих" методов
+- Инициализация connection pool
+- Стабилизация JVM (GC, memory allocation)
+- Прогрев кэшей YDB
+
+Параметры:
+- Потоки: 10 (read: 8, write: 2)
+- Длительность: 10 секунд
+- RPS: 50% от целевой нагрузки
+
+Критерий готовности:
+- Latency P95 стабилизировалась (не растет)
+- Success rate > 95%
+```
+
+**Почему 10 потоков?**
+- Достаточно для JIT компиляции критичных методов
+- Не создает избыточную нагрузку на YDB
+- Позволяет обнаружить проблемы до основного теста
+
+#### 3. Рабочая нагрузка (Load Phase)
+```
+Параметры:
+- Потоки: 30 (read: 24, write: 6)
+- Длительность: TEST_DURATION секунд (default: 60)
+- Read RPS: READ_RPS (default: 1000)
+- Write RPS: WRITE_RPS (default: 100)
+
+Соотношение read/write = 10:1 (типичное для OLTP)
+```
+
+**Почему 30 потоков?**
+- Имитирует реальную многопользовательскую нагрузку
+- Достаточно для насыщения connection pool
+- Выявляет проблемы конкурентного доступа
+- Соответствует типичной нагрузке веб-приложений (20-50 одновременных запросов)
+- **Рассчитано на кластер из 5 нод** (текущее значение `ydb_database_node_count`)
+
+**Распределение потоков (24 read / 6 write):**
+- Отражает реальное соотношение операций в OLTP системах
+- Read-heavy workload типичен для большинства приложений
+- Write потоки создают достаточную конкуренцию за ресурсы
+
+> ⚠️ **Важно:** Количество потоков следует масштабировать в зависимости от размера кластера:
+> - **3 ноды:** 18-20 потоков (6 потоков на ноду)
+> - **5 нод:** 30 потоков (6 потоков на ноду) ← текущая конфигурация
+> - **10 нод:** 50-60 потоков (5-6 потоков на ноду)
+>
+> Эмпирическое правило: **~6 потоков на ноду** обеспечивает оптимальную утилизацию без перегрузки.
+> В будущих версиях планируется автоматический расчет количества потоков на основе `ydb_database_node_count`.
+
+#### 4. Валидация результатов (Validation Phase)
+```
+Проверяемые SLO:
+✓ P50 latency < 10 ms
+✓ P95 latency < 50 ms
+✓ P99 latency < 100 ms
+✓ Success rate > 99.9%
+
+Если хотя бы один порог не пройден → тест FAILED
+```
+
+#### 5. Экспорт метрик (Export Phase)
+```
+Действия:
+- Отправка метрик в Prometheus Push Gateway
+- Сохранение результатов в файл
+- Генерация отчета для GitHub
+```
+
+## Метрики
+
+### Основные метрики Prometheus
+```
+sdk_operations_total{operation_type, sdk, sdk_version}
+ Counter - общее количество операций
+ Labels: read, write, upsert
+
+sdk_operations_success_total{operation_type, sdk, sdk_version}
+ Counter - успешные операции
+ Используется для расчета Success Rate
+
+sdk_operation_latency_seconds{operation_type, operation_status}
+ Histogram - распределение latency
+ Buckets: 1ms, 2ms, 3ms, 4ms, 5ms, 7.5ms, 10ms, 20ms, 50ms, 100ms, 200ms, 500ms, 1s
+ Позволяет вычислить P50, P95, P99
+
+sdk_pending_operations{operation_type}
+ Gauge - количество операций в процессе выполнения
+ Индикатор перегрузки системы
+```
+
+### Вычисляемые метрики
+
+**Success Rate:**
+```
+success_rate = (sdk_operations_success_total / sdk_operations_total) * 100%
+```
+
+**Error Rate:**
+```
+error_rate = 100% - success_rate
+```
+
+**Throughput (RPS):**
+```
+actual_rps = sdk_operations_total / test_duration_seconds
+```
+
+**Percentiles (P50, P95, P99):**
+```
+Вычисляются из histogram buckets в Prometheus/Grafana
+```
+
+## Retry механизм
+
+Все SLO тесты используют единую стратегию retry:
+```
+Стратегия: Exponential Backoff
+Максимум попыток: 5
+Backoff delays: 100ms → 200ms → 400ms → 800ms → 1600ms
+
+Повторяемые ошибки (transient):
+- Connection timeout
+- Network errors
+- Session expired
+- Overload (503)
+- Unavailable (503)
+
+Не повторяемые ошибки (permanent):
+- Schema errors (table not found, column mismatch)
+- Constraint violations (unique, foreign key)
+- Syntax errors
+- Permission denied
+```
+
+**Почему именно такая стратегия?**
+- Exponential backoff снижает нагрузку на перегруженную систему
+- 5 попыток достаточно для восстановления после кратковременных сбоев
+- Максимальная задержка 1.6s не блокирует тест надолго
+
+## Структура проекта
+```
+slo-workload/
+├── pom.xml # Родительский POM (Java 21, зависимости)
+├── README.md # Этот файл
+│
+├── simple-jdbc/ # SLO тест для базового JDBC
+│ ├── src/
+│ │ ├── main/java/tech/ydb/slo/
+│ │ │ ├── JdbcSloTableContext.java # Управление таблицей
+│ │ │ ├── MetricsReporter.java # Экспорт метрик
+│ │ │ ├── SloTableRow.java # DTO
+│ │ │ └── SimpleJdbcConfig.java # Spring конфиг
+│ │ └── test/java/tech/ydb/slo/
+│ │ └── JdbcSloTest.java # Основной тест
+│ ├── pom.xml
+│ └── README.md
+│
+└── [future workloads]/
+ ├── spring-jdbc/ # Spring JdbcTemplate
+ ├── spring-data-jdbc/ # Spring Data JDBC
+ └── spring-data-jpa/ # Spring Data JPA
+```
+
+## Текущие реализации
+
+### ✅ simple-jdbc
+Базовый JDBC драйвер без фреймворков.
+
+**Статус:** Готов
+**Технологии:** YDB JDBC Driver, Spring Boot (только DI)
+**Документация:** [simple-jdbc/README.md](simple-jdbc/README.md)
+
+## Планируемые реализации
+
+### 🔄 spring-jdbc
+Использование Spring JdbcTemplate.
+
+**Цель:** Проверить overhead Spring JdbcTemplate
+**Ожидаемый результат:** Latency +2-3ms по сравнению с plain JDBC
+
+### 🔄 spring-data-jdbc
+Spring Data JDBC (без JPA).
+
+**Цель:** Измерить производительность Spring Data абстракции
+**Ожидаемый результат:** Latency +5-10ms, упрощение кода
+
+### 🔄 spring-data-jpa
+Полноценный JPA/Hibernate stack.
+
+**Цель:** Оценить overhead JPA
+**Ожидаемый результат:** Latency +10-20ms, максимальное удобство разработки
+
+### 🔄 webflux-r2dbc
+Реактивный подход (WebFlux + R2DBC).
+
+**Цель:** Сравнить reactive vs blocking под высокой нагрузкой
+**Ожидаемый результат:** Лучшая throughput при >100 одновременных запросах
+
+## Планируемые улучшения
+
+### Инфраструктура
+
+#### 🔄 Динамическое масштабирование потоков
+**Проблема:** Фиксированное количество потоков (30) не оптимально для кластеров разного размера.
+
+**Решение:** Автоматический расчет на основе `ydb_database_node_count`:
+```java
+int optimalThreads = ydbNodeCount * THREADS_PER_NODE; // 6 потоков на ноду
+int readThreads = (int) (optimalThreads * 0.8); // 80% read
+int writeThreads = optimalThreads - readThreads; // 20% write
+```
+
+**Преимущества:**
+- Оптимальная утилизация кластера любого размера
+- Предсказуемая нагрузка на каждую ноду
+- Лучшее распределение запросов
+
+**Статус:** Планируется после стабилизации текущих тестов
+
+#### 🔄 Адаптивная стратегия warmup
+Длительность прогрева также зависит от размера кластера:
+- 3 ноды: 5-7 секунд
+- 5 нод: 10 секунд (текущее)
+- 10+ нод: 15-20 секунд
+
+#### 🔄 Тесты масштабируемости
+Автоматическое тестирование на кластерах разного размера:
+```yaml
+matrix:
+ ydb_nodes: [3, 5, 10]
+ # Автоматически: threads = nodes * 6
+```
+
+**Ожидаемые результаты:**
+- Linear scaling throughput: 3 ноды → 5 нод = +67% RPS
+- Latency остается стабильной при правильном масштабировании потоков
+
+### Тестовые сценарии
+
+#### 🔄 Chaos Testing
+- Рестарты нод во время теста
+- Network partitions
+- Увеличение latency сети
+
+#### 🔄 Soak Tests
+- Длительные тесты (24h+)
+- Детекция memory leaks
+- Проверка стабильности под постоянной нагрузкой
+
+#### 🔄 Различные размеры payload
+- Small (100 bytes)
+- Medium (1 KB) ← текущий
+- Large (10 KB)
+- Extra Large (100 KB)
+
+## Использование в CI/CD
+
+### В ydb-java-examples (текущий репозиторий)
+
+Workflow: `.github/workflows/slo-test.yml`
+```yaml
+Триггеры:
+- Push в master/main
+- Pull Request с изменениями в slo-workload/
+
+Что делает:
+1. Поднимает YDB через ydb-slo-action
+2. Собирает проект (Maven)
+3. Запускает SLO тесты
+4. Публикует графики в PR
+```
+
+### В ydb-jdbc-driver / ydb-java-sdk (будущее)
+
+Workflow: `.github/workflows/slo.yml`
+```yaml
+Что будет делать:
+1. Checkout текущего SDK/JDBC драйвера
+2. Checkout ydb-java-examples
+3. Собрать текущую версию драйвера
+4. Обновить версию в SLO проекте
+5. Запустить SLO тесты с новой версией
+6. Публиковать результаты
+
+Цель:
+- Проверять каждый PR на регрессию производительности
+- Блокировать merge при нарушении SLO
+```
+
+## Добавление нового workload
+
+### Шаг 1: Создайте модуль
+```bash
+cd slo-workload
+cp -r simple-jdbc your-workload
+cd your-workload
+```
+
+### Шаг 2: Модифицируйте код
+```java
+// YourSloTest.java
+@SpringBootTest
+public class YourSloTest {
+
+ @Test
+ public void runSloTest() {
+ // 1. Setup
+ // 2. Warmup (10 threads, 10s)
+ // 3. Load test (30 threads, TEST_DURATION)
+ // 4. Validate SLO
+ // 5. Export metrics
+ }
+}
+```
+
+### Шаг 3: Обновите pom.xml
+```xml
+
+
+ simple-jdbc
+ your-workload
+
+```
+
+### Шаг 4: Обновите workflow
+```yaml
+# .github/workflows/slo-test.yml
+matrix:
+ workload:
+ - simple-jdbc
+ - your-workload
+```
+
+### Шаг 5: Документация
+
+Создайте `your-workload/README.md` с описанием специфики вашего теста.
+
+## Интерпретация результатов
+
+### ✅ Успешный тест
+```
+✓ P50: 5.2 ms (порог: 10 ms)
+✓ P95: 23.1 ms (порог: 50 ms)
+✓ P99: 45.8 ms (порог: 100 ms)
+✓ Success Rate: 99.95% (порог: 99.9%)
+```
+
+**Интерпретация:** Производительность в норме, можно мержить PR.
+
+### ⚠️ Warning: близко к порогу
+```
+✓ P50: 8.7 ms (порог: 10 ms) ⚠️
+✓ P95: 47.3 ms (порог: 50 ms) ⚠️
+✓ P99: 89.2 ms (порог: 100 ms)
+✓ Success Rate: 99.91% (порог: 99.9%)
+```
+
+**Интерпретация:** Тест прошел, но метрики близки к порогам. Рекомендуется:
+- Проверить код на возможные оптимизации
+- Запустить тест повторно для подтверждения
+- Рассмотреть профилирование
+
+### ❌ Failed: нарушение SLO
+```
+✓ P50: 12.4 ms (порог: 10 ms) ❌
+✓ P95: 78.9 ms (порог: 50 ms) ❌
+✓ P99: 156.3 ms (порог: 100 ms) ❌
+✓ Success Rate: 99.45% (порог: 99.9%) ❌
+```
+
+**Интерпретация:** Регрессия производительности. Действия:
+1. Сравнить с предыдущим успешным прогоном
+2. Найти изменения, которые могли повлиять
+3. Профилировать код (JProfiler, async-profiler)
+4. Проверить конфигурацию (connection pool, timeouts)
+
+## Best Practices
+
+### При разработке нового workload
+
+✅ **DO:**
+- Используйте единую стратегию прогрева (10 потоков, 10с)
+- Следуйте паттерну 30 потоков для основной нагрузки (на 5 нод)
+- Реализуйте retry с exponential backoff
+- Экспортируйте стандартные метрики
+- Документируйте специфику вашего теста
+
+❌ **DON'T:**
+- Не меняйте SLO пороги без обоснования
+- Не пропускайте фазу warmup
+- Не используйте фиксированные задержки вместо exponential backoff
+- Не игнорируйте ошибки (всегда логируйте и считайте)
+
+### При настройке нагрузки
+
+✅ **DO:**
+- Учитывайте размер кластера при выборе количества потоков
+- Используйте формулу: `threads ≈ nodes * 6` как отправную точку
+- Мониторьте утилизацию каждой ноды (должна быть равномерной)
+- Начинайте с меньшей нагрузки и постепенно увеличивайте
+
+❌ **DON'T:**
+- Не используйте 30 потоков для кластера из 3 нод (перегрузка)
+- Не используйте 30 потоков для кластера из 20 нод (недогрузка)
+- Не сравнивайте результаты тестов на кластерах разного размера
+- Не запускайте 100+ потоков без обоснования
+
+### При анализе результатов
+
+✅ **DO:**
+- Смотрите на тренды (несколько прогонов)
+- Сравнивайте с baseline (предыдущие успешные тесты)
+- Учитывайте вариативность (±5% норма)
+- Проверяйте все метрики, не только latency
+
+❌ **DON'T:**
+- Не принимайте решения по одному прогону
+- Не игнорируйте Success Rate ради latency
+- Не сравнивайте разные конфигурации YDB
+
+## Troubleshooting
+
+### Высокая latency только в начале теста
+
+**Причина:** Недостаточный warmup
+**Решение:** Увеличить длительность warmup до 15-20 секунд
+
+### Success Rate < 99.9% из-за timeout
+
+**Причина:** Таймауты слишком короткие для текущей нагрузки
+**Решение:** Увеличить `READ_TIMEOUT` / `WRITE_TIMEOUT` в workflow
+
+### Нестабильные результаты (разброс >10%)
+
+**Причина:** Конкуренция за ресурсы в GitHub Actions runner
+**Решение:** Запустить несколько раз, усреднить результаты
+
+### "Too many retries" ошибки
+
+**Причина:** YDB перегружен или недостаточно нод
+**Решение:** Увеличить `ydb_database_node_count` в workflow
+
+### Неравномерная нагрузка на ноды кластера
+
+**Причина:** Количество потоков не соответствует размеру кластера
+**Диагностика:**
+```bash
+# Проверить утилизацию нод в YDB Monitoring
+# Если разброс > 20% между нодами → проблема
+```
+
+**Решение:**
+- Для 3 нод: уменьшить до 18-20 потоков
+- Для 10 нод: увеличить до 50-60 потоков
+- Проверить балансировку connection pool
+
+### Высокая latency при увеличении размера кластера
+
+**Причина:** Фиксированное количество потоков (30) недостаточно для утилизации большого кластера
+**Решение:** Масштабировать количество потоков пропорционально количеству нод
+
+**Пример:**
+```yaml
+# Для 10 нод (требует доработки кода)
+env:
+ THREAD_COUNT: 60
+```
+
+**Temporary workaround:** Запускать несколько инстансов теста параллельно
+
+## Ссылки
+
+- [YDB JDBC Driver](https://github.com/ydb-platform/ydb-jdbc-driver)
+- [YDB Java SDK](https://github.com/ydb-platform/ydb-java-sdk)
+- [YDB SLO Action](https://github.com/ydb-platform/ydb-slo-action)
+- [YDB Documentation](https://ydb.tech/docs/)
+- [Prometheus Client Java](https://github.com/prometheus/client_java)
\ No newline at end of file
diff --git a/slo-workload/pom.xml b/slo-workload/pom.xml
new file mode 100644
index 0000000..c7e9f8c
--- /dev/null
+++ b/slo-workload/pom.xml
@@ -0,0 +1,83 @@
+
+ 4.0.0
+
+ tech.ydb.examples
+ ydb-sdk-examples
+ 1.1.0-SNAPSHOT
+ ../pom.xml
+
+
+ slo-workload
+ pom
+
+ YDB SLO Workload Tests
+ SLO testing applications for YDB performance validation
+
+
+ 21
+ 21
+ 21
+ UTF-8
+
+ 3.5.9
+ 2.3.20
+ 0.16.0
+ 5.12.2
+ true
+
+
+
+ simple-jdbc
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-dependencies
+ ${spring-boot.version}
+ pom
+ import
+
+
+
+
+ tech.ydb.jdbc
+ ydb-jdbc-driver
+ ${ydb.jdbc.version}
+
+
+
+
+ io.prometheus
+ simpleclient
+ ${prometheus.version}
+
+
+ io.prometheus
+ simpleclient_pushgateway
+ ${prometheus.version}
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 21
+ 21
+ 21
+
+
+
+
+
+
\ No newline at end of file
diff --git a/slo-workload/simple-jdbc/README.md b/slo-workload/simple-jdbc/README.md
new file mode 100644
index 0000000..ef31f60
--- /dev/null
+++ b/slo-workload/simple-jdbc/README.md
@@ -0,0 +1,137 @@
+# Simple JDBC SLO Test
+
+Базовый SLO тест для YDB JDBC Driver без использования фреймворков (plain JDBC).
+
+## Описание
+
+Тестирует производительность и надежность YDB JDBC Driver под нагрузкой:
+- **Latency**: P50 < 10ms, P95 < 50ms, P99 < 100ms
+- **Success Rate**: > 99.9%
+- **Технологии**: YDB JDBC Driver, Spring Boot (только для DI)
+
+> 📖 **Подробнее о стратегии тестирования, метриках и архитектуре:** [slo-workload/README.md](../README.md)
+
+## Быстрый старт
+
+### Запуск в CI/CD
+
+Тест автоматически запускается через GitHub Actions при изменениях в `slo-workload/**`.
+
+**Workflow:** `.github/workflows/slo-test.yml`
+
+Пропустить тест: добавьте label `no slo` к PR.
+
+### Параметры
+
+| Параметр | Описание | Значение по умолчанию |
+|----------|----------|----------------------|
+| `TEST_DURATION` | Длительность теста (секунды) | 60 |
+| `READ_RPS` | Read операций в секунду | 1000 |
+| `WRITE_RPS` | Write операций в секунду | 100 |
+| `READ_TIMEOUT` | Timeout для read (ms) | 1000 |
+| `WRITE_TIMEOUT` | Timeout для write (ms) | 1000 |
+
+## Компоненты
+
+### JdbcSloTableContext
+Сервисный класс для работы с таблицей `slo_table`.
+
+**Основные методы:**
+```java
+createTable(timeout) // Создание таблицы
+save(row, timeout) // UPSERT с retry
+select(guid, id, timeout) // SELECT с retry
+```
+
+**Особенности:**
+- Retry с exponential backoff (5 попыток)
+- Автоматическое восстановление после временных ошибок
+- Подробности: см. [родительский README](../README.md#retry-механизм)
+
+### SloTableRow
+DTO для строки таблицы (~1KB payload).
+```java
+SloTableRow row = SloTableRow.generate(id);
+// Поля: guid, id, payloadStr, payloadDouble, payloadTimestamp
+```
+
+### JdbcSloTest
+Основной тест JUnit, выполняющий полный цикл SLO тестирования.
+
+**Фазы:**
+1. Инициализация таблицы
+2. Warmup (10 потоков, 10s)
+3. Load test (30 потоков, TEST_DURATION)
+4. Валидация SLO
+5. Экспорт метрик
+
+### MetricsReporter
+Экспорт метрик в Prometheus Push Gateway.
+
+**Метрики:**
+- `sdk_operations_total` - всего операций
+- `sdk_operations_success_total` - успешных операций
+- `sdk_operation_latency_seconds` - histogram latency
+- `sdk_pending_operations` - активных операций
+
+Подробнее о метриках: [родительский README](../README.md#метрики)
+
+## Локальная разработка
+
+Разработка кода возможна локально, но **полноценный запуск теста только в CI/CD** (требуется YDB):
+```bash
+# Компиляция
+mvn clean compile -pl slo-workload/simple-jdbc
+
+# Тесты (требует YDB)
+mvn test -pl slo-workload/simple-jdbc -Dskip.jdbc.tests=false
+```
+
+## Troubleshooting
+
+### JDBC-специфичные проблемы
+
+**Connection pool exhausted**
+```
+Симптом: Много ошибок "Cannot get connection from pool"
+Решение: Увеличить pool size в SimpleJdbcConfig
+```
+
+**Prepared statement cache issues**
+```
+Симптом: OutOfMemoryError: Metaspace
+Решение: Ограничить кэш prepared statements
+```
+
+**Long GC pauses**
+```
+Симптом: Периодические всплески latency P99
+Решение: Настроить JVM параметры (-XX:+UseG1GC)
+```
+
+### Общие проблемы
+
+Для общих проблем (compilation, YDB connection, метрики) см. [Troubleshooting в родительском README](../README.md#troubleshooting).
+
+## Развитие
+
+### Сравнение с другими реализациями
+
+После появления других workload'ов (Spring JdbcTemplate, Spring Data JDBC, JPA) можно будет сравнить:
+- Overhead каждого слоя абстракции
+- Trade-off между производительностью и удобством
+- Рекомендации по выбору стека
+
+### Оптимизации
+
+Потенциальные улучшения этого теста:
+- [ ] Batch operations для write
+- [ ] Использование prepared statements
+- [ ] Connection pool tuning
+- [ ] Асинхронное логирование
+
+## Ссылки
+
+- [Родительский README (стратегия, метрики, архитектура)](../README.md)
+- [YDB JDBC Driver](https://github.com/ydb-platform/ydb-jdbc-driver)
+- [YDB Documentation](https://ydb.tech/docs/)
\ No newline at end of file
diff --git a/slo-workload/simple-jdbc/pom.xml b/slo-workload/simple-jdbc/pom.xml
new file mode 100644
index 0000000..2d05435
--- /dev/null
+++ b/slo-workload/simple-jdbc/pom.xml
@@ -0,0 +1,74 @@
+
+ 4.0.0
+
+
+ tech.ydb.examples
+ slo-workload
+ 1.1.0-SNAPSHOT
+ ../pom.xml
+
+
+ simple-jdbc
+ jar
+ Simple JDBC SLO Test
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+ tech.ydb.jdbc
+ ydb-jdbc-driver
+
+
+
+
+ io.prometheus
+ simpleclient
+
+
+ io.prometheus
+ simpleclient_pushgateway
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 21
+ 21
+ 21
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ ${skip.jdbc.tests}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/slo-workload/simple-jdbc/src/main/java/tech/ydb/slo/JdbcSloTableContext.java b/slo-workload/simple-jdbc/src/main/java/tech/ydb/slo/JdbcSloTableContext.java
new file mode 100644
index 0000000..a424499
--- /dev/null
+++ b/slo-workload/simple-jdbc/src/main/java/tech/ydb/slo/JdbcSloTableContext.java
@@ -0,0 +1,196 @@
+package tech.ydb.slo;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.util.UUID;
+
+public class JdbcSloTableContext {
+
+ private static final String TABLE_NAME = "slo_table";
+ private static final int MAX_RETRY_ATTEMPTS = 5;
+ private static final int INITIAL_BACKOFF_MS = 100;
+
+ private final DataSource dataSource;
+
+ public JdbcSloTableContext(DataSource dataSource) {
+ this.dataSource = dataSource;
+ }
+
+ public void createTable(int operationTimeoutMs) throws SQLException {
+ String createTableSql = String.format("""
+ CREATE TABLE `%s` (
+ Guid Utf8,
+ Id Int32,
+ PayloadStr Utf8,
+ PayloadDouble Double,
+ PayloadTimestamp Timestamp,
+ PRIMARY KEY (Guid, Id)
+ )
+ """, TABLE_NAME);
+
+ try (Connection conn = dataSource.getConnection();
+ Statement stmt = conn.createStatement()) {
+ stmt.setQueryTimeout(operationTimeoutMs / 1000);
+ stmt.execute(createTableSql);
+ }
+ }
+
+ public int save(SloTableRow row, int writeTimeoutMs) throws SQLException {
+ String upsertSql = String.format("""
+ UPSERT INTO `%s` (Guid, Id, PayloadStr, PayloadDouble, PayloadTimestamp)
+ VALUES (?, ?, ?, ?, ?)
+ """, TABLE_NAME);
+
+ int attempts = 0;
+ SQLException lastException = null;
+
+ while (attempts < MAX_RETRY_ATTEMPTS) {
+ attempts++;
+
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement stmt = conn.prepareStatement(upsertSql)) {
+
+ stmt.setQueryTimeout(writeTimeoutMs / 1000);
+ stmt.setString(1, row.guid.toString());
+ stmt.setInt(2, row.id);
+ stmt.setString(3, row.payloadStr);
+ stmt.setDouble(4, row.payloadDouble);
+ stmt.setTimestamp(5, new Timestamp(row.payloadTimestamp.getTime()));
+
+ stmt.executeUpdate();
+ return attempts;
+
+ } catch (SQLException e) {
+ lastException = e;
+
+ if (!isRetryableError(e) || attempts >= MAX_RETRY_ATTEMPTS) {
+ throw new SQLException("Failed to save after " + attempts + " attempts", e);
+ }
+
+ try {
+ long backoffMs = INITIAL_BACKOFF_MS * (1L << (attempts - 1));
+ Thread.sleep(backoffMs);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw new SQLException("Interrupted during retry", ie);
+ }
+ }
+ }
+
+ throw new SQLException("Failed to save after " + attempts + " attempts", lastException);
+ }
+
+ public SloTableRow select(UUID guid, int id, int readTimeoutMs) throws SQLException {
+ String selectSql = String.format("""
+ SELECT Guid, Id, PayloadStr, PayloadDouble, PayloadTimestamp
+ FROM `%s` WHERE Guid = ? AND Id = ?
+ """, TABLE_NAME);
+
+ int attempts = 0;
+ SQLException lastException = null;
+
+ while (attempts < MAX_RETRY_ATTEMPTS) {
+ attempts++;
+
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement stmt = conn.prepareStatement(selectSql)) {
+
+ stmt.setQueryTimeout(readTimeoutMs / 1000);
+ stmt.setString(1, guid.toString());
+ stmt.setInt(2, id);
+
+ try (ResultSet rs = stmt.executeQuery()) {
+ if (rs.next()) {
+ SloTableRow row = new SloTableRow();
+ row.guid = UUID.fromString(rs.getString("Guid"));
+ row.id = rs.getInt("Id");
+ row.payloadStr = rs.getString("PayloadStr");
+ row.payloadDouble = rs.getDouble("PayloadDouble");
+ row.payloadTimestamp = rs.getTimestamp("PayloadTimestamp");
+ return row;
+ } else {
+ throw new SQLException("Row not found: guid=" + guid + ", id=" + id);
+ }
+ }
+
+ } catch (SQLException e) {
+ lastException = e;
+
+ if (!isRetryableError(e) || attempts >= MAX_RETRY_ATTEMPTS) {
+ throw new SQLException("Failed to select after " + attempts + " attempts", e);
+ }
+
+ try {
+ long backoffMs = INITIAL_BACKOFF_MS * (1L << (attempts - 1));
+ Thread.sleep(backoffMs);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw new SQLException("Interrupted during retry", ie);
+ }
+ }
+ }
+
+ throw new SQLException("Failed to select after " + attempts + " attempts", lastException);
+ }
+
+ public int selectCount() throws SQLException {
+ String selectSql = String.format("SELECT COUNT(*) as cnt FROM `%s`", TABLE_NAME);
+
+ try (Connection conn = dataSource.getConnection();
+ Statement stmt = conn.createStatement();
+ ResultSet rs = stmt.executeQuery(selectSql)) {
+
+ return rs.next() ? rs.getInt("cnt") : 0;
+ }
+ }
+
+ public boolean tableExists() {
+ String checkSql = String.format("SELECT 1 FROM `%s` WHERE 1=0", TABLE_NAME);
+
+ try (Connection conn = dataSource.getConnection();
+ Statement stmt = conn.createStatement();
+ ResultSet rs = stmt.executeQuery(checkSql)) {
+ return true;
+ } catch (SQLException e) {
+ return false;
+ }
+ }
+
+ private boolean isRetryableError(SQLException e) {
+ String message = e.getMessage().toLowerCase();
+ String sqlState = e.getSQLState();
+
+ if (message.contains("timeout") ||
+ message.contains("connection") ||
+ message.contains("network") ||
+ message.contains("unavailable") ||
+ message.contains("overload") ||
+ message.contains("too many requests") ||
+ message.contains("throttle")) {
+ return true;
+ }
+
+ if (message.contains("session") && message.contains("expired")) {
+ return true;
+ }
+
+ if (sqlState != null && sqlState.startsWith("YDB")) {
+ return true;
+ }
+
+ if (message.contains("already exists") ||
+ message.contains("not found") ||
+ message.contains("syntax error") ||
+ message.contains("constraint") ||
+ message.contains("duplicate key")) {
+ return false;
+ }
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/slo-workload/simple-jdbc/src/main/java/tech/ydb/slo/SloTableRow.java b/slo-workload/simple-jdbc/src/main/java/tech/ydb/slo/SloTableRow.java
new file mode 100644
index 0000000..0b37a23
--- /dev/null
+++ b/slo-workload/simple-jdbc/src/main/java/tech/ydb/slo/SloTableRow.java
@@ -0,0 +1,57 @@
+package tech.ydb.slo;
+
+import java.sql.Timestamp;
+import java.util.UUID;
+
+/**
+ * Представление строки таблицы для SLO тестов
+ */
+public class SloTableRow {
+ public UUID guid;
+ public int id;
+ public String payloadStr;
+ public double payloadDouble;
+ public Timestamp payloadTimestamp;
+
+ public SloTableRow() {
+ }
+
+ /**
+ * Создание строки с заданными значениями
+ */
+ public SloTableRow(UUID guid, int id, String payloadStr, double payloadDouble, Timestamp payloadTimestamp) {
+ this.guid = guid;
+ this.id = id;
+ this.payloadStr = payloadStr;
+ this.payloadDouble = payloadDouble;
+ this.payloadTimestamp = payloadTimestamp;
+ }
+
+ /**
+ * Генерация случайной строки для тестирования
+ */
+ public static SloTableRow generate(int id) {
+ return new SloTableRow(
+ UUID.randomUUID(),
+ id,
+ generatePayloadString(1024), // 1KB payload
+ Math.random() * 1000.0,
+ new Timestamp(System.currentTimeMillis())
+ );
+ }
+
+ /**
+ * Генерация строки заданного размера
+ */
+ private static String generatePayloadString(int sizeBytes) {
+ StringBuilder sb = new StringBuilder(sizeBytes);
+ String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+
+ for (int i = 0; i < sizeBytes; i++) {
+ int index = (int) (Math.random() * chars.length());
+ sb.append(chars.charAt(index));
+ }
+
+ return sb.toString();
+ }
+}
\ No newline at end of file
diff --git a/slo-workload/simple-jdbc/src/test/java/tech/ydb/slo/JdbcSloTest.java b/slo-workload/simple-jdbc/src/test/java/tech/ydb/slo/JdbcSloTest.java
new file mode 100644
index 0000000..52002cc
--- /dev/null
+++ b/slo-workload/simple-jdbc/src/test/java/tech/ydb/slo/JdbcSloTest.java
@@ -0,0 +1,292 @@
+package tech.ydb.slo;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import javax.sql.DataSource;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest(classes = SimpleJdbcConfig.class)
+public class JdbcSloTest {
+
+ @Autowired
+ DataSource dataSource;
+
+ private static int testDuration;
+ private static int readRps;
+ private static int writeRps;
+ private static int readTimeout;
+ private static int writeTimeout;
+ private static String promPgw;
+ private static int initialDataCount;
+ private static int reportPeriod;
+
+ private static final double MAX_P50_LATENCY_MS = 10.0;
+ private static final double MAX_P95_LATENCY_MS = 50.0;
+ private static final double MAX_P99_LATENCY_MS = 100.0;
+ private static final double MIN_SUCCESS_RATE = 99.9;
+
+ @BeforeAll
+ static void setup() {
+ testDuration = Integer.parseInt(System.getenv().getOrDefault("TEST_DURATION", "60"));
+ readRps = Integer.parseInt(System.getenv().getOrDefault("READ_RPS", "100"));
+ writeRps = Integer.parseInt(System.getenv().getOrDefault("WRITE_RPS", "10"));
+ readTimeout = Integer.parseInt(System.getenv().getOrDefault("READ_TIMEOUT", "1000"));
+ writeTimeout = Integer.parseInt(System.getenv().getOrDefault("WRITE_TIMEOUT", "1000"));
+ promPgw = System.getenv().getOrDefault("PROM_PGW", "http://localhost:9091");
+ reportPeriod = Integer.parseInt(System.getenv().getOrDefault("REPORT_PERIOD", "10000"));
+ initialDataCount = Math.max(100, writeRps * testDuration / 10);
+ }
+
+ @Test
+ void sloFullTest() throws Exception {
+ JdbcSloTableContext context = new JdbcSloTableContext(dataSource);
+ String jobName = System.getenv().getOrDefault("WORKLOAD_NAME", "jdbc-slo-test");
+ MetricsReporter metrics = new MetricsReporter(promPgw, jobName);
+
+ context.createTable(writeTimeout);
+ assertTrue(context.tableExists());
+
+ List testData = new ArrayList<>();
+ for (int i = 0; i < initialDataCount; i++) {
+ testData.add(SloTableRow.generate(i));
+ }
+
+ writeInitialData(context, testData, writeTimeout, metrics);
+ assertTrue(context.selectCount() >= testData.size() * 0.99);
+
+ SloTestResult result = runSloTest(context, testData, metrics);
+
+ validateSloResults(result);
+
+ int finalCount = context.selectCount();
+ int expectedMinCount = initialDataCount + (int)(writeRps * testDuration * 0.95);
+ assertTrue(finalCount >= expectedMinCount);
+
+ metrics.push();
+ metrics.saveToFile("target/test-metrics.txt", result.avgLatencySeconds);
+ }
+
+ private void writeInitialData(
+ JdbcSloTableContext context,
+ List data,
+ int timeout,
+ MetricsReporter metrics
+ ) throws InterruptedException, ExecutionException {
+
+ ExecutorService executor = Executors.newFixedThreadPool(10);
+ List> futures = new ArrayList<>();
+ AtomicInteger errors = new AtomicInteger(0);
+
+ for (SloTableRow row : data) {
+ futures.add(executor.submit(() -> {
+ long start = System.nanoTime();
+ try {
+ context.save(row, timeout);
+ metrics.recordSuccess("write_initial", (System.nanoTime() - start) / 1_000_000_000.0);
+ } catch (SQLException e) {
+ errors.incrementAndGet();
+ metrics.recordError("write_initial", e.getClass().getSimpleName());
+ }
+ return null;
+ }));
+ }
+
+ for (Future future : futures) {
+ future.get();
+ }
+
+ executor.shutdown();
+ executor.awaitTermination(1, TimeUnit.MINUTES);
+
+ if (errors.get() > data.size() * 0.01) {
+ throw new RuntimeException("Too many errors: " + errors.get());
+ }
+ }
+
+ private SloTestResult runSloTest(
+ JdbcSloTableContext context,
+ List testData,
+ MetricsReporter metrics
+ ) throws InterruptedException, ExecutionException {
+
+ ExecutorService executor = Executors.newFixedThreadPool(30);
+
+ AtomicInteger successCount = new AtomicInteger(0);
+ AtomicInteger errorCount = new AtomicInteger(0);
+ AtomicLong totalLatencyNanos = new AtomicLong(0);
+ AtomicInteger totalAttempts = new AtomicInteger(0);
+ List latencies = new CopyOnWriteArrayList<>();
+
+ long testStartTime = System.currentTimeMillis();
+ long testEndTime = testStartTime + (testDuration * 1000L);
+ long lastReportTime = testStartTime;
+
+ int nextWriteId = testData.size();
+ List> activeFutures = new ArrayList<>();
+
+ while (System.currentTimeMillis() < testEndTime) {
+ long iterationStart = System.currentTimeMillis();
+
+ for (int i = 0; i < readRps && System.currentTimeMillis() < testEndTime; i++) {
+ SloTableRow row = testData.get(ThreadLocalRandom.current().nextInt(testData.size()));
+
+ activeFutures.add(executor.submit(() -> {
+ long opStart = System.nanoTime();
+ try {
+ context.select(row.guid, row.id, readTimeout);
+ long opEnd = System.nanoTime();
+ double latency = (opEnd - opStart) / 1_000_000_000.0;
+
+ successCount.incrementAndGet();
+ totalLatencyNanos.addAndGet(opEnd - opStart);
+ latencies.add(latency);
+ metrics.recordSuccess("read", latency);
+ } catch (SQLException e) {
+ errorCount.incrementAndGet();
+ metrics.recordError("read", e.getClass().getSimpleName());
+ }
+ }));
+ }
+
+ for (int i = 0; i < writeRps && System.currentTimeMillis() < testEndTime; i++) {
+ final int writeId = nextWriteId++;
+ SloTableRow row = SloTableRow.generate(writeId);
+
+ activeFutures.add(executor.submit(() -> {
+ long opStart = System.nanoTime();
+ try {
+ int attempts = context.save(row, writeTimeout);
+ long opEnd = System.nanoTime();
+ double latency = (opEnd - opStart) / 1_000_000_000.0;
+
+ successCount.incrementAndGet();
+ totalLatencyNanos.addAndGet(opEnd - opStart);
+ totalAttempts.addAndGet(attempts);
+ latencies.add(latency);
+ metrics.recordSuccess("write", latency);
+ } catch (SQLException e) {
+ errorCount.incrementAndGet();
+ metrics.recordError("write", e.getClass().getSimpleName());
+ }
+ }));
+ }
+
+ long now = System.currentTimeMillis();
+ if (now - lastReportTime >= reportPeriod) {
+ metrics.push();
+ lastReportTime = now;
+ }
+
+ long iterationDuration = System.currentTimeMillis() - iterationStart;
+ if (iterationDuration < 1000) {
+ Thread.sleep(1000 - iterationDuration);
+ }
+ }
+
+ for (Future> future : activeFutures) {
+ try {
+ future.get(writeTimeout * 2L, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException e) {
+ future.cancel(true);
+ } catch (Exception ignored) {
+ }
+ }
+
+ executor.shutdown();
+ executor.awaitTermination(30, TimeUnit.SECONDS);
+
+ return calculateMetrics(successCount.get(), errorCount.get(),
+ totalLatencyNanos.get(), totalAttempts.get(), latencies);
+ }
+
+ private SloTestResult calculateMetrics(
+ int successCount,
+ int errorCount,
+ long totalLatencyNanos,
+ int totalAttempts,
+ List latencies
+ ) {
+ List sortedLatencies = new ArrayList<>(latencies);
+ sortedLatencies.sort(Double::compareTo);
+
+ int totalRequests = successCount + errorCount;
+ double avgLatency = totalRequests > 0 ?
+ totalLatencyNanos / 1_000_000_000.0 / totalRequests : 0.0;
+ double avgAttempts = successCount > 0 ?
+ (double)totalAttempts / successCount : 0.0;
+
+ return new SloTestResult(
+ successCount,
+ errorCount,
+ totalRequests,
+ avgLatency,
+ avgAttempts,
+ getPercentile(sortedLatencies, 0.50),
+ getPercentile(sortedLatencies, 0.95),
+ getPercentile(sortedLatencies, 0.99),
+ totalRequests > 0 ? (double)successCount / totalRequests * 100.0 : 0.0
+ );
+ }
+
+ private double getPercentile(List sortedValues, double percentile) {
+ if (sortedValues.isEmpty()) return 0.0;
+ int index = (int) Math.ceil(sortedValues.size() * percentile) - 1;
+ index = Math.max(0, Math.min(index, sortedValues.size() - 1));
+ return sortedValues.get(index);
+ }
+
+ private void validateSloResults(SloTestResult result) {
+ assertTrue(result.p50Latency * 1000 <= MAX_P50_LATENCY_MS,
+ String.format("P50 latency %.2fms exceeds threshold %.0fms",
+ result.p50Latency * 1000, MAX_P50_LATENCY_MS));
+
+ assertTrue(result.p95Latency * 1000 <= MAX_P95_LATENCY_MS,
+ String.format("P95 latency %.2fms exceeds threshold %.0fms",
+ result.p95Latency * 1000, MAX_P95_LATENCY_MS));
+
+ assertTrue(result.p99Latency * 1000 <= MAX_P99_LATENCY_MS,
+ String.format("P99 latency %.2fms exceeds threshold %.0fms",
+ result.p99Latency * 1000, MAX_P99_LATENCY_MS));
+
+ assertTrue(result.successRate >= MIN_SUCCESS_RATE,
+ String.format("Success rate %.2f%% below threshold %.1f%%",
+ result.successRate, MIN_SUCCESS_RATE));
+ }
+
+ static class SloTestResult {
+ final int successCount;
+ final int errorCount;
+ final int totalRequests;
+ final double avgLatencySeconds;
+ final double avgAttempts;
+ final double p50Latency;
+ final double p95Latency;
+ final double p99Latency;
+ final double successRate;
+
+ SloTestResult(int successCount, int errorCount, int totalRequests,
+ double avgLatencySeconds, double avgAttempts,
+ double p50, double p95, double p99, double successRate) {
+ this.successCount = successCount;
+ this.errorCount = errorCount;
+ this.totalRequests = totalRequests;
+ this.avgLatencySeconds = avgLatencySeconds;
+ this.avgAttempts = avgAttempts;
+ this.p50Latency = p50;
+ this.p95Latency = p95;
+ this.p99Latency = p99;
+ this.successRate = successRate;
+ }
+ }
+}
\ No newline at end of file
diff --git a/slo-workload/simple-jdbc/src/test/java/tech/ydb/slo/MetricsReporter.java b/slo-workload/simple-jdbc/src/test/java/tech/ydb/slo/MetricsReporter.java
new file mode 100644
index 0000000..26ced2c
--- /dev/null
+++ b/slo-workload/simple-jdbc/src/test/java/tech/ydb/slo/MetricsReporter.java
@@ -0,0 +1,155 @@
+package tech.ydb.slo;
+
+import io.prometheus.client.CollectorRegistry;
+import io.prometheus.client.Counter;
+import io.prometheus.client.Gauge;
+import io.prometheus.client.Histogram;
+import io.prometheus.client.exporter.PushGateway;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.URI;
+import java.net.URL;
+import java.util.Map;
+
+public class MetricsReporter {
+ private static final Logger log = LoggerFactory.getLogger(MetricsReporter.class);
+
+ private final CollectorRegistry registry = new CollectorRegistry();
+ private final PushGateway pushGateway;
+ private final String jobName;
+ private final String javaVersion;
+
+ private final Counter operationsTotal;
+ private final Counter operationsSuccessTotal;
+ private final Histogram operationLatencySeconds;
+ private final Gauge pendingOperations;
+
+ private int totalSuccess = 0;
+ private int totalErrors = 0;
+
+ public MetricsReporter(String promPgwUrl, String jobName) {
+ this.jobName = jobName;
+ this.javaVersion = System.getProperty("java.version");
+
+ try {
+ URL url = URI.create(promPgwUrl).toURL();
+ this.pushGateway = new PushGateway(url);
+ log.info("Initialized PushGateway: {}", promPgwUrl);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to initialize PushGateway: " + promPgwUrl, e);
+ }
+
+ // Total operations (including errors)
+ this.operationsTotal = Counter.build()
+ .name("sdk_operations_total")
+ .labelNames("operation_type", "sdk", "sdk_version", "workload", "workload_version")
+ .help("Total number of operations performed by the SDK")
+ .register(registry);
+
+ // Successful operations only
+ this.operationsSuccessTotal = Counter.build()
+ .name("sdk_operations_success_total")
+ .labelNames("operation_type", "sdk", "sdk_version", "workload", "workload_version")
+ .help("Total number of successful operations")
+ .register(registry);
+
+ // Operation latency
+ this.operationLatencySeconds = Histogram.build()
+ .name("sdk_operation_latency_seconds")
+ .labelNames("operation_type", "operation_status", "sdk", "sdk_version", "workload", "workload_version")
+ .help("Operation latency in seconds")
+ .buckets(0.001, 0.002, 0.003, 0.004, 0.005, 0.0075, 0.010, 0.020, 0.050, 0.100, 0.200, 0.500, 1.000)
+ .register(registry);
+
+ // Pending operations gauge
+ this.pendingOperations = Gauge.build()
+ .name("sdk_pending_operations")
+ .labelNames("operation_type", "sdk", "sdk_version", "workload", "workload_version")
+ .help("Current number of pending operations")
+ .register(registry);
+ }
+
+ /**
+ * Record successful operation
+ */
+ public void recordSuccess(String operation, double latencySeconds) {
+ operationsTotal.labels(operation, "java", javaVersion, jobName, "0.0.0").inc();
+ operationsSuccessTotal.labels(operation, "java", javaVersion, jobName, "0.0.0").inc();
+ operationLatencySeconds.labels(operation, "success", "java", javaVersion, jobName, "0.0.0")
+ .observe(latencySeconds);
+
+ totalSuccess++;
+
+ // Log only at milestones
+ if (totalSuccess % 1000 == 0) {
+ log.info("{} operations completed successfully (avg latency: {:.2f} ms)",
+ totalSuccess, latencySeconds * 1000);
+ }
+ }
+
+ /**
+ * Record failed operation
+ */
+ public void recordError(String operation, String errorType) {
+ operationsTotal.labels(operation, "java", javaVersion, jobName, "0.0.0").inc();
+ totalErrors++;
+
+ log.warn("Operation failed: {} (type: {}, total errors: {})", operation, errorType, totalErrors);
+ }
+
+ /**
+ * Set pending operations count
+ */
+ public void setPendingOperations(String operationType, int count) {
+ pendingOperations.labels(operationType, "java", javaVersion, jobName, "0.0.0").set(count);
+ }
+
+ /**
+ * Push metrics to Prometheus Push Gateway
+ */
+ public void push() {
+ try {
+ pushGateway.pushAdd(
+ registry,
+ jobName,
+ Map.of(
+ "workload", jobName,
+ "instance", "jdbc"
+ )
+ );
+ log.info("Metrics pushed to Prometheus (success: {}, errors: {})", totalSuccess, totalErrors);
+ } catch (IOException e) {
+ log.error("Failed to push metrics to Prometheus", e);
+ }
+ }
+
+ /**
+ * Save metrics summary to file
+ */
+ public void saveToFile(String filename, double latencySeconds) {
+ try (PrintWriter writer = new PrintWriter(new FileWriter(filename))) {
+ writer.println("SUCCESS_COUNT=" + totalSuccess);
+ writer.println("ERROR_COUNT=" + totalErrors);
+ writer.println("LATENCY_MS=" + String.format("%.2f", latencySeconds * 1000));
+ writer.println("PENDING_OPERATIONS=0");
+
+ log.info("Metrics saved to {} (success: {}, errors: {}, latency: {:.2f} ms)",
+ filename, totalSuccess, totalErrors, latencySeconds * 1000);
+ } catch (IOException e) {
+ log.error("Failed to save metrics to file: {}", filename, e);
+ }
+ }
+
+ /**
+ * Get summary statistics
+ */
+ public String getSummary() {
+ return String.format("Success: %d, Errors: %d, Error rate: %.2f%%",
+ totalSuccess, totalErrors,
+ totalSuccess > 0 ? (totalErrors * 100.0 / (totalSuccess + totalErrors)) : 0.0);
+ }
+}
\ No newline at end of file
diff --git a/slo-workload/simple-jdbc/src/test/java/tech/ydb/slo/SimpleJdbcConfig.java b/slo-workload/simple-jdbc/src/test/java/tech/ydb/slo/SimpleJdbcConfig.java
new file mode 100644
index 0000000..4f64923
--- /dev/null
+++ b/slo-workload/simple-jdbc/src/test/java/tech/ydb/slo/SimpleJdbcConfig.java
@@ -0,0 +1,32 @@
+package tech.ydb.slo;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+
+import javax.sql.DataSource;
+
+@Configuration
+public class SimpleJdbcConfig {
+
+ @Value("${spring.datasource.url}")
+ private String jdbcUrl;
+
+ @Value("${spring.datasource.driver-class-name}")
+ private String driverClassName;
+
+ @Bean
+ public DataSource dataSource() {
+ DriverManagerDataSource ds = new DriverManagerDataSource();
+ ds.setDriverClassName(driverClassName);
+ ds.setUrl(jdbcUrl);
+ return ds;
+ }
+
+ @Bean
+ public JdbcTemplate jdbcTemplate(DataSource ds) {
+ return new JdbcTemplate(ds);
+ }
+}
diff --git a/slo-workload/simple-jdbc/src/test/resources/application.yaml b/slo-workload/simple-jdbc/src/test/resources/application.yaml
new file mode 100644
index 0000000..df03a3b
--- /dev/null
+++ b/slo-workload/simple-jdbc/src/test/resources/application.yaml
@@ -0,0 +1,4 @@
+spring:
+ datasource:
+ driver-class-name: tech.ydb.jdbc.YdbDriver
+ url: ${YDB_JDBC_URL:jdbc:ydb:grpc://localhost:2136/Root/testdb}