Skip to content

fix tech bugs - #64

Open
karle0wne wants to merge 4 commits into
masterfrom
tech-bugs
Open

fix tech bugs#64
karle0wne wants to merge 4 commits into
masterfrom
tech-bugs

Conversation

@karle0wne

@karle0wne karle0wne commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

проверки:
✅ magista
✅ daway
✅ disputes-api

тут надо еще зонки обновить до совместимого до сб4

схемы обновленного лайфцикла кафки и постгри с объяснением последовательсти локов через семафор

Да. Важно разделить три уровня:

Аннотация
    ↓
JUnit/Spring extension
    ↓
Контейнер и блокировки

Сама аннотация ничего не запускает. Она только сообщает extension’у:

prototype или singleton
нужно ли очищать данные
что исключить из очистки
какие properties добавить в Spring

1. Общий lifecycle аннотации

Теперь у Kafka и PostgreSQL используется одна и та же базовая схема:

JUnit обнаружил тестовый класс с аннотацией
                        ↓
          Extension читает конфигурацию
                        ↓
             getOrStart(testClass)
                        ↓
     Есть ContainerReference для класса?
               ↙                    ↘
             да                      нет
              ↓                       ↓
 вернуть существующий       создать и запустить
              ↓                       ↓
              └───────────┬───────────┘
                          ↓
              контейнер готов к работе

getOrStart() вызывается из двух возможных мест:

JUnit beforeAll()
        ↓
    getOrStart()

или:

Spring создаёт ApplicationContext
        ↓
ContextCustomizer
        ↓
    getOrStart()

Кто пришёл первым, тот и запускает контейнер:

Spring пришёл первым ───────→ запускает контейнер
JUnit beforeAll пришёл позже → получает уже готовый контейнер

или наоборот:

JUnit beforeAll пришёл первым → запускает контейнер
Spring пришёл позже ─────────→ получает уже готовый контейнер

За это отвечает:

CONTAINERS.computeIfAbsent(
    testClass,
    ignored -> createAndStart(...)
);

Поэтому контейнер создаётся один раз на тестовый класс, независимо от порядка вызовов Spring и JUnit.


2. Prototype lifecycle

Для аннотаций:

@KafkaTestcontainer
@PostgresqlTestcontainer

схема такая:

Тестовый класс начинается
        ↓
beforeAll() или Spring ContextCustomizer
        ↓
getOrStart(testClass)
        ↓
создаётся НОВЫЙ контейнер
        ↓
контейнер запускается
        ↓
ContainerReference сохраняется по testClass
        ↓
Spring получает connection properties
        ↓
┌──────────────────────────────────────┐
│           Каждый тестовый метод      │
│                                      │
│ beforeEach                           │
│     ↓                                │
│ TestExecutionLock.acquire            │
│     ↓                                │
│ очистка PostgreSQL / Kafka           │
│     ↓                                │
│ выполнение тестового метода          │
│     ↓                                │
│ автоматический release lock          │
└──────────────────────────────────────┘
        ↓
afterAll()
        ↓
ContainerReference удаляется
        ↓
контейнер останавливается

Главный смысл prototype:

Один тестовый класс
        ↓
Один отдельный контейнер
        ↓
После класса контейнер уничтожается

Пример:

KafkaTestA ──→ Kafka container A ──→ stop
KafkaTestB ──→ Kafka container B ──→ stop

Между классами данные физически не разделяются, потому что контейнеры разные.


3. Singleton lifecycle

Для аннотаций:

@KafkaTestcontainerSingleton
@PostgresqlTestcontainerSingleton

контейнер один на весь JVM-процесс:

TestClass A
     ↓
получает singleton container
     ↓
использует
     ↓
не останавливает

TestClass B
     ↓
получает тот же singleton container
     ↓
использует
     ↓
не останавливает

Завершение JVM
     ↓
ContainerShutdownRegistry
     ↓
stop singleton container

Но перед использованием singleton нужно запретить другому тестовому классу работать с ним одновременно.

Поэтому полный flow такой:

Тестовый класс начинается
        ↓
getOrStart(testClass)
        ↓
Это singleton?
        ↓ да
SharedTestResourceLock.acquire(testClass)
        ↓
ожидаем, пока предыдущий singleton-класс завершится
        ↓
получаем singleton из Factory
        ↓
контейнер уже запущен?
      ↙                 ↘
    нет                  да
     ↓                    ↓
запустить           использовать текущий
      └──────────┬─────────┘
                 ↓
очистить старые данные предыдущего класса
                 ↓
сохранить ContainerReference по testClass
                 ↓
запустить тестовые методы
                 ↓
afterAll()
                 ↓
удалить ContainerReference класса
                 ↓
SharedTestResourceLock.release(testClass)
                 ↓
следующий singleton-класс может продолжить

Пример:

KafkaSingletonTestA
    ↓ acquire
    ↓ очистка Kafka
    ↓ test1
    ↓ test2
    ↓ release

KafkaSingletonTestB
    ↓ до release класса A ждёт
    ↓ acquire
    ↓ очистка Kafka
    ↓ test1
    ↓ release

Сам контейнер между A и B остаётся запущенным:

Kafka container:
START ───────────────────────────────────────────── STOP JVM
          ↑ Test A             ↑ Test B

4. Зачем нужен SharedTestResourceLock

SharedTestResourceLock работает на уровне целого тестового класса.

Его задача:

Не позволить двум тестовым классам одновременно пользоваться process-wide singleton-контейнерами.

Без него могло происходить так:

Class A выполняет тест и пишет в Kafka
                        │
                        ├───────────────┐
                        │               │
Class B начинает работу │               │
        ↓               │               │
удаляет все topics ─────┘               │
                                        ↓
                         тест Class A падает

Или с PostgreSQL:

Class A читает таблицу orders
                   │
Class B выполняет TRUNCATE orders
                   │
                   ↓
Class A получает пустой результат

С блокировкой:

Class A
    ↓
SharedTestResourceLock.acquire(A)
    ↓
работает со всеми своими singleton-ресурсами
    ↓
SharedTestResourceLock.release(A)
    ↓
Class B теперь может начинать работу

Почему там не обычный ReentrantLock

JUnit может вызвать lifecycle callbacks на разных потоках:

acquire → worker-thread-1
release → worker-thread-3

ReentrantLock должен разблокировать тот же поток, который его захватил. Здесь это не гарантируется.

Поэтому используется:

Semaphore(1, true)

Semaphore разрешает такую последовательность:

thread-1 → acquire
thread-3 → release

5. Зачем в SharedTestResourceLock Holder и references

Один тестовый класс может одновременно использовать несколько singleton-аннотаций:

@KafkaTestcontainerSingleton
@PostgresqlTestcontainerSingleton
@MinioTestcontainerSingleton
class PaymentTest {
}

Каждый extension вызовет:

SharedTestResourceLock.acquire(PaymentTest.class);

Но глобальный semaphore нужно физически захватить только один раз.

Схематично:

Kafka extension
    ↓ acquire(PaymentTest)
    ↓ первый вызов
    ↓ реально захватывает semaphore
    ↓ references = 1

PostgreSQL extension
    ↓ acquire(PaymentTest)
    ↓ Holder уже существует
    ↓ semaphore повторно не захватывается
    ↓ references = 2

MinIO extension
    ↓ acquire(PaymentTest)
    ↓ references = 3

При завершении:

Kafka afterAll
    ↓ release
    ↓ references: 3 → 2
    ↓ semaphore пока занят

PostgreSQL afterAll
    ↓ release
    ↓ references: 2 → 1
    ↓ semaphore пока занят

MinIO afterAll
    ↓ release
    ↓ references: 1 → 0
    ↓ semaphore реально освобождается

И только после этого другой singleton-тестовый класс может продолжить.

Визуально:

PaymentTest:
Kafka acquire ───────┐
Postgres acquire ────┼──→ один глобальный singleton-lock
MinIO acquire ───────┘

Kafka release ─────── references 3 → 2
Postgres release ──── references 2 → 1
MinIO release ─────── references 1 → 0
                                  ↓
                         глобальный lock свободен

6. Зачем в SharedTestResourceLock CompletableFuture

Предположим, два extension’а одного класса почти одновременно вызывают acquire():

Kafka extension thread
        ↓
создал Holder
        ↓
начал ждать глобальный semaphore

PostgreSQL extension thread
        ↓
увидел тот же Holder

PostgreSQL extension нельзя пропускать дальше до того, как Kafka extension действительно получил глобальный semaphore.

Поэтому второй вызов ждёт:

holder.acquired.join();

А первый после реального захвата semaphore сообщает:

holder.acquired.complete(null);

Схема:

Первый extension
    ↓
создаёт Holder
    ↓
SINGLETON_TESTS.acquire()
    ↓
acquired.complete()
              │
              └──────────────┐
                             ↓
Второй extension       acquired.join()
                             ↓
                       теперь можно идти дальше

То есть CompletableFuture здесь — это не асинхронная задача, а сигнал:

«Глобальная блокировка для этого тестового класса уже действительно получена».


7. Важная особенность SharedTestResourceLock

В текущем патче semaphore один:

private static final Semaphore SINGLETON_TESTS = new Semaphore(1, true);

Это означает, что блокируются все singleton-тестовые классы, даже использующие разные ресурсы.

Например:

Class A использует только Kafka singleton
Class B использует только PostgreSQL singleton

Они всё равно будут выполняться последовательно:

Class A: Kafka ──────────────────────┐
                                    ↓ release
Class B: PostgreSQL                  └──────────────→ start

Это консервативная модель:

максимальная изоляция
        ↕
меньше параллелизма

Плюс — никакие singleton-ресурсы разных классов не пересекутся.

Минус — Kafka-only и PostgreSQL-only тесты тоже не идут параллельно, хотя технически могли бы.


8. Зачем нужен TestExecutionLock

SharedTestResourceLock разделяет тестовые классы.

TestExecutionLock разделяет тестовые методы внутри одного класса.

Это разные уровни:

SharedTestResourceLock
    └── Class A против Class B

TestExecutionLock
    └── ClassA.test1 против ClassA.test2

Проблема появляется при JUnit parallel execution:

@Test
void testCreateOrder() {
    // работает с PostgreSQL
}

@Test
void testCancelOrder() {
    // работает с тем же PostgreSQL
}

Без блокировки:

testCreateOrder                   testCancelOrder
      ↓                                  ↓
beforeEach: TRUNCATE               beforeEach: TRUNCATE
      ↓                                  ↓
INSERT order                       DELETE/INSERT
      ↓                                  ↓
проверка неожиданно падает

beforeEach недостаточно просто синхронизировать. Нужно удерживать блокировку на протяжении всего тестового метода.

Неправильная схема:

lock
  ↓
cleanup
  ↓
unlock
  ↓
test выполняется без защиты

В этот момент другой тест может очистить ресурс.

Правильная схема:

lock
  ↓
cleanup
  ↓
весь тестовый метод
  ↓
afterEach и закрытие method context
  ↓
unlock

Именно это делает TestExecutionLock.


9. Полный lifecycle одного тестового метода

Например, PostgreSQL:

JUnit начинает testMethod1
        ↓
PostgresqlExtension.beforeEach()
        ↓
TestExecutionLock.acquire(context)
        ↓
cleanupDatabaseTables()
        ↓
beforeEach завершился,
НО lock остаётся захвачен
        ↓
@BeforeEach пользователя
        ↓
@Test testMethod1()
        ↓
@AfterEach пользователя
        ↓
JUnit закрывает method ExtensionContext.Store
        ↓
LockHandle.close()
        ↓
semaphore.release()

Kafka аналогично:

beforeEach
    ↓
TestExecutionLock.acquire
    ↓
deleteTopics
    ↓
createTopics
    ↓
тест работает с Kafka
    ↓
JUnit закрывает method context
    ↓
lock освобождается

10. Почему lock освобождается автоматически

В method store кладётся объект:

LockHandle implements ExtensionContext.Store.CloseableResource

JUnit закрывает CloseableResource, когда закрывается контекст тестового метода:

method context создан
        ↓
LockHandle помещён в Store
        ↓
тест выполнен успешно или упал
        ↓
method context закрывается
        ↓
LockHandle.close()
        ↓
semaphore.release()

Благодаря этому lock снимается даже при исключении в тесте:

@Test выбросил RuntimeException
        ↓
JUnit всё равно закрывает method context
        ↓
lock освобождён

Если ошибка произошла ещё внутри cleanup, extension снимает lock явно:

catch (RuntimeException ex) {
    TestExecutionLock.release(context);
    throw ex;
}

Иначе тестовый метод вообще не запустится, а блокировка могла бы остаться до закрытия контекста.


11. Несколько extension’ов в одном тестовом методе

Допустим, тест использует Kafka и PostgreSQL:

@KafkaTestcontainerSingleton
@PostgresqlTestcontainerSingleton
class PaymentTest {
}

Оба extension’а вызывают TestExecutionLock.acquire(context).

Но блокировка должна быть одна на тестовый метод:

PostgreSQL beforeEach
    ↓
TestExecutionLock.acquire
    ↓
реально захватывает semaphore
    ↓
очищает PostgreSQL

Kafka beforeEach
    ↓
TestExecutionLock.acquire
    ↓
видит METHOD_LOCK в method Store
    ↓
повторно semaphore не захватывает
    ↓
очищает Kafka

@Test
    ↓
работает и с PostgreSQL, и с Kafka
    ↓
method context закрывается
    ↓
один общий release

Если бы каждый extension захватывал собственный lock повторно, произошёл бы self-deadlock:

PostgreSQL extension захватил semaphore
        ↓
Kafka extension пытается захватить тот же semaphore
        ↓
ждёт сам себя бесконечно

Проверка:

if (methodStore.get(METHOD_LOCK) != null) {
    return;
}

предотвращает эту ситуацию.


12. Совместная временная шкала singleton-класса

Полная схема класса с Kafka и PostgreSQL:

PaymentTest начинается
        ↓
Kafka getOrStart
        ↓
SharedTestResourceLock.acquire(PaymentTest)
        ↓
глобальный class lock захвачен
        ↓
Kafka singleton start/get
        ↓
Kafka initial cleanup
        ↓
PostgreSQL getOrStart
        ↓
SharedTestResourceLock.acquire(PaymentTest)
        ↓
references увеличивается,
повторного глобального acquire нет
        ↓
PostgreSQL singleton start/get
        ↓
PostgreSQL initial cleanup
        ↓
Spring context создаётся
        ↓
properties Kafka/PostgreSQL добавлены
        ↓
──────────────── testMethod1 ────────────────
        ↓
TestExecutionLock.acquire
        ↓
Kafka cleanup
        ↓
PostgreSQL cleanup
        ↓
testMethod1 выполняется
        ↓
TestExecutionLock auto-release
        ↓
──────────────── testMethod2 ────────────────
        ↓
TestExecutionLock.acquire
        ↓
Kafka cleanup
        ↓
PostgreSQL cleanup
        ↓
testMethod2 выполняется
        ↓
TestExecutionLock auto-release
        ↓
Kafka afterAll
        ↓
Shared lock references: 2 → 1
        ↓
PostgreSQL afterAll
        ↓
Shared lock references: 1 → 0
        ↓
глобальный class lock освобождён
        ↓
следующий singleton-тестовый класс может стартовать

13. В чём разница двух locks одной фразой

SharedTestResourceLock
        ↓
«Никакой другой singleton-тестовый КЛАСС
не должен выполняться одновременно с этим классом»
TestExecutionLock
        ↓
«Никакой другой тестовый МЕТОД этого класса
не должен очистить ресурсы, пока текущий метод выполняется»

И совсем компактно:

SharedTestResourceLock:
Class A ──────────────→ Class B

TestExecutionLock:
ClassA.test1 ─────────→ ClassA.test2

14. Карта ответственности файлов

KafkaTestcontainerExtension
    ├── читает Kafka-аннотацию
    ├── запускает/получает Kafka
    ├── создаёт и очищает topics
    ├── добавляет Kafka properties в Spring
    └── управляет lifecycle Kafka для тестового класса

PostgresqlTestcontainerExtension
    ├── читает PostgreSQL-аннотацию
    ├── запускает/получает PostgreSQL
    ├── очищает таблицы
    ├── добавляет datasource/Flyway properties в Spring
    └── управляет lifecycle PostgreSQL для тестового класса

SharedTestResourceLock
    ├── уровень: тестовый класс
    ├── используется только для singleton
    ├── удерживается от start класса до afterAll
    └── не даёт singleton-классам пересекаться

TestExecutionLock
    ├── уровень: тестовый метод
    ├── используется при destructive cleanup
    ├── удерживается от beforeEach до конца метода
    └── не даёт параллельным методам портить данные друг друга

ContainerReference
    ├── контейнер
    └── признак singleton/prototype

CONTAINERS<Class<?>, ContainerReference>
    ├── связывает контейнер с тестовым классом
    └── заменяет старый ThreadLocal

ContainerShutdownRegistry
    ├── хранит process-wide singleton-контейнеры
    └── останавливает их при завершении JVM

Главная итоговая схема:

АННОТАЦИЯ
    ↓
EXTENSION
    ↓
getOrStart(testClass)
    ↓
┌─────────────────────────────────────┐
│ prototype                           │
│   новый контейнер → тесты → stop    │
└─────────────────────────────────────┘
                 или
┌────────────────────────────────────────────┐
│ singleton                                  │
│   SharedTestResourceLock                   │
│       ↓                                    │
│   общий контейнер → тесты → release класса │
│       ↓                                    │
│   stop только при завершении JVM           │
└────────────────────────────────────────────┘

Внутри каждого тестового метода:
TestExecutionLock
    ↓
cleanup
    ↓
выполнение теста
    ↓
automatic release

[Патч с реализацией](sandbox:/mnt/data/testcontainers-annotations-all-fixes.patch)

testcontainers-annotations-code-review

Технический аудит testcontainers-annotations

Проверено: 46 Java-файлов production-кода, 2 integration-test класса, pom.xml, META-INF/spring.factories, конфигурация контейнеров и GitHub workflows.

Ограничение проверки: в среде отсутствует Maven и в архиве нет Maven Wrapper, поэтому локальный mvn test не запускался. Выводы ниже основаны на полном статическом разборе исходников и lifecycle JUnit/Spring/Testcontainers.

Критические и высокие проблемы

1. Реальная утечка Kafka consumer/listener-контейнеров

Файл: src/main/java/dev/vality/testcontainers/annotations/kafka/config/KafkaConsumer.java:45-50

read() создаёт ConcurrentMessageListenerContainer, запускает его и теряет ссылку:

var container = new ConcurrentMessageListenerContainer<>(...);
container.start();

Контейнер не возвращается вызывающему коду, не сохраняется в поле, не останавливается при закрытии Spring context. В результате остаются consumer threads, сетевые соединения и Kafka consumer instances. Повторные вызовы read() накапливают фоновые контейнеры.

Исправление: возвращать ConcurrentMessageListenerContainer из read() либо хранить созданные контейнеры и реализовать DisposableBean/AutoCloseable/@PreDestroy, вызывая stop() и destroy().

2. ThreadLocal используется как registry жизненного цикла контейнеров

Файлы:

  • ClickhouseTestcontainerExtension.java:41
  • KafkaTestcontainerExtension.java:55
  • PostgresqlTestcontainerExtension.java:41
  • MinioTestcontainerExtension.java:43
  • OpensearchTestcontainerExtension.java:27
  • EmbeddedPostgresqlTestExtension.java:16

Контейнер записывается в ThreadLocal в одном callback, а читается в Spring ContextCustomizer, beforeEach и afterAll. Это предполагает, что все стадии выполняются одним потоком, чего JUnit 5 не гарантирует при parallel execution и некоторых lifecycle-сценариях.

Последствия:

  • beforeEach может получить null и пропустить очистку;
  • afterAll может не увидеть контейнер и не остановить prototype;
  • embedded PostgreSQL может запуститься повторно на worker thread;
  • Spring context может получить NullPointerException при чтении URL;
  • при @TestInstance(PER_CLASS) Spring context способен инициализироваться до пользовательского BeforeAllCallback.

Исправление: убрать ThreadLocal. Использовать registry, ключованный конфигурацией/test class, либо ExtensionContext.Store; запуск, публикация properties и закрытие должны принадлежать одному lifecycle-компоненту. Для shared state дополнительно запретить параллельное выполнение или использовать @ResourceLock.

3. Singleton-фабрики навсегда сохраняют параметры первого тестового класса

Файлы:

  • KafkaTestcontainerFactory.java:36-42
  • ClickhouseTestcontainerFactory.java:33-39
  • аналогичный паттерн в PostgreSQL/MinIO/OpenSearch factories.

Для Kafka первый вызов фиксирует provider и список topics; последующие вызовы с другой конфигурацией получают старый объект. Для ClickHouse фиксируются databaseName и migrations первого класса.

Последствия:

  • второй Kafka test class не получает свои topics;
  • смена APACHE/CONFLUENT молча игнорируется;
  • второй ClickHouse test class применяет чужие migrations и дропает чужую БД;
  • ошибка зависит от порядка запуска тестов.

Исправление: singleton должен быть keyed по полной immutable-конфигурации либо фабрика должна валидировать совпадение конфигурации и падать с ясной ошибкой. Для Kafka допустимо динамически объединять topics, но provider обязан совпадать.

4. Все ContextCustomizerFactory возвращают customizer даже для нерелевантных тестов

Файлы:

  • ClickhouseTestcontainerExtension.java:103-113
  • KafkaTestcontainerExtension.java:147-157
  • PostgresqlTestcontainerExtension.java:118-128
  • MinioTestcontainerExtension.java:92-109
  • OpensearchTestcontainerExtension.java:89-99
  • EmbeddedKafkaTestContextCustomizerFactory.java:16-21
  • EmbeddedPostgresqlTestContextCustomizerFactory.java:14-20

Фабрики зарегистрированы глобально через META-INF/spring.factories, но всегда возвращают lambda, даже если соответствующей аннотации нет. Lambda не имеет содержательного equals/hashCode, поэтому Spring получает разные context customizer objects и теряет возможность переиспользовать одинаковый cached context.

Это затрагивает все Spring tests, в classpath которых присутствует библиотека: лишние поднятия контекста, рост времени тестов и давления на память.

Исправление: возвращать null, если аннотация отсутствует. Для активного случая использовать отдельный immutable-класс/record с корректным equals/hashCode, включающим фактическую конфигурацию.

5. Singleton-контейнеры не потокобезопасны при старте и очистке

Фабрика синхронизирует только создание объекта. Проверка isRunning() и start() выполняются уже вне lock:

  • KafkaTestcontainerExtension.java:69-72
  • аналогично PostgreSQL, ClickHouse, MinIO, OpenSearch.

Два параллельных класса могут получить один объект, оба увидеть !isRunning() и одновременно вызвать start(). После запуска параллельные beforeEach могут очищать общую БД/topics/indexes во время чужого теста.

Исправление: единый synchronized/atomic lifecycle handle; singleton tests должны сериализоваться по backend resource.

6. Kafka producer lifecycle не управляется Spring корректно

Файлы:

  • KafkaProducerTestConfig.java:35-47
  • KafkaProducer.java:32-44

DefaultKafkaProducerFactory создаётся внутри конструктора другого bean и сам bean’ом не является, поэтому Spring не вызывает его destroy(). После успешной отправки вызывается reset(), но если send(...).join() завершится исключением, reset не выполнится. Кроме того, reset после каждой отправки закрывает все producers фабрики и опасен при конкурентной отправке.

Исправление: объявить ProducerFactory и KafkaTemplate отдельными bean’ами с управляемым destroy lifecycle; не делать reset() после каждой отправки. При необходимости временного producer — try/finally и timeout.

7. Утечка prototype Kafka container при ошибке создания topics

Файл: KafkaTestcontainerExtension.java:62-65

Контейнер стартует, затем создаются topics, и только после этого ссылка кладётся в THREAD_CONTAINER. Если createTopics() упадёт, afterAll не сможет найти и остановить контейнер.

Исправление: зарегистрировать lifecycle handle до дальнейшей инициализации либо оборачивать post-start настройку в try/catch с обязательным stop().

8. Embedded PostgreSQL параметры database, username, password фактически не инициализируют сервер

Файлы:

  • EmbeddedPostgresqlTest.java:61-81
  • EmbeddedPostgresqlTestExtension.java:68-73

Код вызывает только EmbeddedPostgres.start(), затем формирует JDBC URL для переданных database/user. Он не создаёт указанную БД, роль и не задаёт пароль. Поэтому значения, отличные от defaults, либо не работают, либо пароль просто не соответствует реальной конфигурации.

Исправление: либо удалить неподдерживаемые параметры, либо после старта создать DB/role и настроить credentials через admin connection.

9. ClickHouse migrations разбиваются простым split(";")

Файл: ClickhouseContainerExtension.java:75-85

Такой парсер ломает SQL с ; внутри строк, комментариев, функций и сложных выражений. Выполнение также не атомарно: часть migration может примениться до ошибки.

Исправление: использовать dialect-aware script parser/runner либо выполнять подготовленные migration units без наивного split. Добавить контекст ошибки: имя файла и номер statement.

10. PostgreSQL cleaner некорректно работает с identifiers и может оставить БД частично очищенной

Файл: PostgresqlDatabaseCleaner.java:80-89

Проблемы:

  • schema/table вставляются без quoting: reserved words, mixed-case и специальные символы сломаются;
  • каждый TRUNCATE выполняется в autocommit, при ошибке получается частично очищенное состояние;
  • отсутствует RESTART IDENTITY, sequences продолжают старые значения;
  • exclusions сравниваются только по table name, без schema;
  • очистка идёт по всем пользовательским schemas.

Исправление: корректно quote identifiers, собрать один transactional cleanup, использовать TRUNCATE ... RESTART IDENTITY CASCADE, поддержать schema.table exclusions.

11. MinIO singleton сознательно не изолирует данные и не создаёт bucket

Файлы:

  • MinioTestcontainerSingleton.java:25-26
  • MinioTestcontainerExtension.java:112-145

Код лишь публикует bucket name в properties. Bucket не создаётся и содержимое не очищается. Данные гарантированно протекают между methods/classes при одинаковом bucket.

Исправление: create bucket on startup; добавить cleanupBucket/exclude-prefix options и cleanup beforeEach/afterEach. Либо явно сделать default bucket уникальным для test class.

12. OpenSearch перед каждым тестом удаляет все индексы

Файл: OpensearchTestcontainerExtension.java:45-53

DELETE /* не имеет списка исключений и может затронуть служебные индексы. Поведение зависит от настройки destructive wildcard API; ошибка пробрасывается через @SneakyThrows без нормального описания.

Исправление: получать список тестовых индексов и удалять их явно; добавить prefixes/exclusions и понятную обработку 404/403.

Проблемы средней важности

13. Kafka shell-валидация даёт ложные результаты

Файл: KafkaContainerExtension.java:64-65, 99-100, 116-127

  • topics проверяются через substring (actual.contains(topic)), а не точное совпадение строк;
  • exit code и stderr команды игнорируются;
  • hardcoded localhost:9093 и paths завязаны на конкретный layout images;
  • при delete неуспешная команда с пустым stdout может выглядеть как успешное удаление.

Нужно проверять exit code и парсить stdout в Set<String>.

14. Документация excludeTruncateTopics противоречит реализации

Файлы:

  • KafkaTestcontainer.java:128-134
  • KafkaTestcontainerSingleton.java:141-147
  • сравнение: KafkaTestcontainerExtension.java:90-103

JavaDoc предлагает передавать property key (kafka.topics.invoicing.id), а код сравнивает exclusion с уже загруженным реальным topic name. При следовании документации исключение не сработает.

15. AssertJ используется в production control flow

Файлы:

  • GenericContainerUtil.java
  • KafkaContainerExtension.java
  • SpringApplicationPropertiesLoader.java

При этом spring-boot-starter-test объявлен как provided (pom.xml:86-90). У consumer может не быть AssertJ runtime, что даст NoClassDefFoundError; ошибки также представлены как AssertionError, а не domain exception.

Нужно заменить assertions на обычные проверки и собственные исключения.

16. Конфигурационный loader неполно повторяет Spring Boot semantics

Файл: SpringApplicationPropertiesLoader.java

  • выбирается только первый найденный application.yml/yaml/properties/xml;
  • используется только первый PropertySource (getFirst()), multi-document YAML игнорируется;
  • не учитываются profiles, environment, system properties и test properties;
  • отсутствующий default key превращается в строку "null" (String.valueOf(null));
  • unsafe cast к Map<String, OriginTrackedValue>;
  • обязательность topic keys проверяется AssertJ assertion.

Лучше читать значения из Spring Environment; ранние image settings — через явную config model с validation.

17. Singleton-контейнеры и Network.SHARED не закрываются детерминированно

Singleton extensions удаляют только ThreadLocal, но никогда не вызывают stop(). Все контейнеры присоединяются к Network.SHARED, которым библиотека также не управляет.

Ryuk обычно очистит Docker resources при завершении JVM, но внутри долгоживущего test process/daemon lifecycle не детерминирован. Для predictable cleanup нужен root-level closeable resource/shutdown hook и возможность reset.

18. ValuesGenerator содержит устаревающее статическое время и DST-ошибку

Файл: ValuesGenerator.java:21-23, 53-63

  • fromTime/toTime/inFromToPeriodTime вычисляются один раз при загрузке класса; через часы/дни значения перестают быть «текущим окном»;
  • plusDays(1) переводится в Instant с offset текущего момента, что неверно на переходе DST;
  • generateLong/int/string каждый раз создаёт EasyRandom с одинаковым seed и может возвращать одинаковое первое значение.

Использовать Clock, вычислять значения при вызове и ZonedDateTime.now(clock).plusDays(1).toInstant().

19. RandomBeans с seed не является полностью детерминированным

Файл: RandomBeans.java:73-120

Date/time randomizers используют now(), поэтому одинаковый seed не воспроизводит объект полностью. randomThrift* тоже пишет Instant.now().

Следует принимать Clock/фиксированное base time или документировать, что seed не распространяется на temporal fields.

20. ValuesGenerator.getContent(InputStream) не закрывает stream

Файл: ValuesGenerator.java:65-67

Само по себе это может быть корректной ownership-моделью, но API никак её не обозначает. Если метод считается terminal reader, stream течёт. Лучше принимать Resource, явно документировать ownership или закрывать через try-with-resources.

21. ClickHouse database name вставляется в SQL без quoting

Файл: ClickhouseContainerExtension.java:57-64

DROP DATABASE IF EXISTS %s ломается на нестандартном identifier и допускает SQL injection через annotation value. Нужно валидировать identifier или quote его средствами драйвера/диалекта.

22. Kafka cleanup выполняется дважды для уже запущенного singleton

Файл: KafkaTestcontainerExtension.java:73-79 и 85-105

При входе в новый test class cleanup выполняется в beforeAll, затем ещё раз непосредственно перед первым test method в beforeEach. PostgreSQL имеет такой же лишний двойной cleanup (PostgresqlTestcontainerExtension.java:53-58 и 65-83). Это увеличивает время и расширяет окно гонки.

23. KafkaProducer.bootstrapAddress публикуется как глобальный primary String bean

Файл: KafkaProducerTestConfig.java:28-33

@Primary String может случайно участвовать в unrelated dependency injection. Надёжнее использовать @Qualifier или configuration properties object.

24. POM содержит спорные runtime scopes

  • ClickHouse JDBC driver — provided (pom.xml:98-103), хотя library сама вызывает JDBC migrations; без явной зависимости consumer получит No suitable driver.
  • AssertJ требуется main-коду, но приходит через provided test starter.
  • junit-vintage-engine объявлен compile dependency (pom.xml:155-158), хотя README утверждает JUnit 5 only; engine лучше удалить или сделать test scope.
  • commons-io используется напрямую, но не объявлен явно в текущем POM — возможна скрытая зависимость от parent/transitive graph.

@karle0wne
karle0wne requested a review from a team as a code owner July 25, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants