Conversation
WagerMeister
force-pushed
the
tech-bugs
branch
from
August 27, 2026 13:03
e563ad4 to
1600313
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
проверки:
✅ magista
✅ daway
✅ disputes-api
тут надо еще зонки обновить до совместимого до сб4
схемы обновленного лайфцикла кафки и постгри с объяснением последовательсти локов через семафор
Да. Важно разделить три уровня:
Сама аннотация ничего не запускает. Она только сообщает extension’у:
1. Общий lifecycle аннотации
Теперь у Kafka и PostgreSQL используется одна и та же базовая схема:
getOrStart()вызывается из двух возможных мест:или:
Кто пришёл первым, тот и запускает контейнер:
или наоборот:
За это отвечает:
Поэтому контейнер создаётся один раз на тестовый класс, независимо от порядка вызовов Spring и JUnit.
2. Prototype lifecycle
Для аннотаций:
схема такая:
Главный смысл prototype:
Пример:
Между классами данные физически не разделяются, потому что контейнеры разные.
3. Singleton lifecycle
Для аннотаций:
контейнер один на весь JVM-процесс:
Но перед использованием singleton нужно запретить другому тестовому классу работать с ним одновременно.
Поэтому полный flow такой:
Пример:
Сам контейнер между A и B остаётся запущенным:
4. Зачем нужен
SharedTestResourceLockSharedTestResourceLockработает на уровне целого тестового класса.Его задача:
Без него могло происходить так:
Или с PostgreSQL:
С блокировкой:
Почему там не обычный
ReentrantLockJUnit может вызвать lifecycle callbacks на разных потоках:
ReentrantLockдолжен разблокировать тот же поток, который его захватил. Здесь это не гарантируется.Поэтому используется:
Semaphore разрешает такую последовательность:
5. Зачем в
SharedTestResourceLockHolderиreferencesОдин тестовый класс может одновременно использовать несколько singleton-аннотаций:
Каждый extension вызовет:
Но глобальный semaphore нужно физически захватить только один раз.
Схематично:
При завершении:
И только после этого другой singleton-тестовый класс может продолжить.
Визуально:
6. Зачем в
SharedTestResourceLockCompletableFutureПредположим, два extension’а одного класса почти одновременно вызывают
acquire():PostgreSQL extension нельзя пропускать дальше до того, как Kafka extension действительно получил глобальный semaphore.
Поэтому второй вызов ждёт:
А первый после реального захвата semaphore сообщает:
Схема:
То есть
CompletableFutureздесь — это не асинхронная задача, а сигнал:7. Важная особенность
SharedTestResourceLockВ текущем патче semaphore один:
Это означает, что блокируются все singleton-тестовые классы, даже использующие разные ресурсы.
Например:
Они всё равно будут выполняться последовательно:
Это консервативная модель:
Плюс — никакие singleton-ресурсы разных классов не пересекутся.
Минус — Kafka-only и PostgreSQL-only тесты тоже не идут параллельно, хотя технически могли бы.
8. Зачем нужен
TestExecutionLockSharedTestResourceLockразделяет тестовые классы.TestExecutionLockразделяет тестовые методы внутри одного класса.Это разные уровни:
Проблема появляется при JUnit parallel execution:
Без блокировки:
beforeEachнедостаточно просто синхронизировать. Нужно удерживать блокировку на протяжении всего тестового метода.Неправильная схема:
В этот момент другой тест может очистить ресурс.
Правильная схема:
Именно это делает
TestExecutionLock.9. Полный lifecycle одного тестового метода
Например, PostgreSQL:
Kafka аналогично:
10. Почему lock освобождается автоматически
В method store кладётся объект:
JUnit закрывает
CloseableResource, когда закрывается контекст тестового метода:Благодаря этому lock снимается даже при исключении в тесте:
Если ошибка произошла ещё внутри cleanup, extension снимает lock явно:
Иначе тестовый метод вообще не запустится, а блокировка могла бы остаться до закрытия контекста.
11. Несколько extension’ов в одном тестовом методе
Допустим, тест использует Kafka и PostgreSQL:
Оба extension’а вызывают
TestExecutionLock.acquire(context).Но блокировка должна быть одна на тестовый метод:
Если бы каждый extension захватывал собственный lock повторно, произошёл бы self-deadlock:
Проверка:
предотвращает эту ситуацию.
12. Совместная временная шкала singleton-класса
Полная схема класса с Kafka и PostgreSQL:
13. В чём разница двух locks одной фразой
И совсем компактно:
14. Карта ответственности файлов
Главная итоговая схема:
[Патч с реализацией](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.Критические и высокие проблемы
1. Реальная утечка Kafka consumer/listener-контейнеров
Файл:
src/main/java/dev/vality/testcontainers/annotations/kafka/config/KafkaConsumer.java:45-50read()создаётConcurrentMessageListenerContainer, запускает его и теряет ссылку:Контейнер не возвращается вызывающему коду, не сохраняется в поле, не останавливается при закрытии Spring context. В результате остаются consumer threads, сетевые соединения и Kafka consumer instances. Повторные вызовы
read()накапливают фоновые контейнеры.Исправление: возвращать
ConcurrentMessageListenerContainerизread()либо хранить созданные контейнеры и реализоватьDisposableBean/AutoCloseable/@PreDestroy, вызываяstop()иdestroy().2.
ThreadLocalиспользуется как registry жизненного цикла контейнеровФайлы:
ClickhouseTestcontainerExtension.java:41KafkaTestcontainerExtension.java:55PostgresqlTestcontainerExtension.java:41MinioTestcontainerExtension.java:43OpensearchTestcontainerExtension.java:27EmbeddedPostgresqlTestExtension.java:16Контейнер записывается в
ThreadLocalв одном callback, а читается в SpringContextCustomizer,beforeEachиafterAll. Это предполагает, что все стадии выполняются одним потоком, чего JUnit 5 не гарантирует при parallel execution и некоторых lifecycle-сценариях.Последствия:
beforeEachможет получитьnullи пропустить очистку;afterAllможет не увидеть контейнер и не остановить prototype;NullPointerExceptionпри чтении URL;@TestInstance(PER_CLASS)Spring context способен инициализироваться до пользовательскогоBeforeAllCallback.Исправление: убрать
ThreadLocal. Использовать registry, ключованный конфигурацией/test class, либоExtensionContext.Store; запуск, публикация properties и закрытие должны принадлежать одному lifecycle-компоненту. Для shared state дополнительно запретить параллельное выполнение или использовать@ResourceLock.3. Singleton-фабрики навсегда сохраняют параметры первого тестового класса
Файлы:
KafkaTestcontainerFactory.java:36-42ClickhouseTestcontainerFactory.java:33-39Для Kafka первый вызов фиксирует
providerи списокtopics; последующие вызовы с другой конфигурацией получают старый объект. Для ClickHouse фиксируютсяdatabaseNameиmigrationsпервого класса.Последствия:
APACHE/CONFLUENTмолча игнорируется;Исправление: singleton должен быть keyed по полной immutable-конфигурации либо фабрика должна валидировать совпадение конфигурации и падать с ясной ошибкой. Для Kafka допустимо динамически объединять topics, но provider обязан совпадать.
4. Все
ContextCustomizerFactoryвозвращают customizer даже для нерелевантных тестовФайлы:
ClickhouseTestcontainerExtension.java:103-113KafkaTestcontainerExtension.java:147-157PostgresqlTestcontainerExtension.java:118-128MinioTestcontainerExtension.java:92-109OpensearchTestcontainerExtension.java:89-99EmbeddedKafkaTestContextCustomizerFactory.java:16-21EmbeddedPostgresqlTestContextCustomizerFactory.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Два параллельных класса могут получить один объект, оба увидеть
!isRunning()и одновременно вызватьstart(). После запуска параллельныеbeforeEachмогут очищать общую БД/topics/indexes во время чужого теста.Исправление: единый synchronized/atomic lifecycle handle; singleton tests должны сериализоваться по backend resource.
6. Kafka producer lifecycle не управляется Spring корректно
Файлы:
KafkaProducerTestConfig.java:35-47KafkaProducer.java:32-44DefaultKafkaProducerFactoryсоздаётся внутри конструктора другого 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-81EmbeddedPostgresqlTestExtension.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Проблемы:
TRUNCATEвыполняется в autocommit, при ошибке получается частично очищенное состояние;RESTART IDENTITY, sequences продолжают старые значения;Исправление: корректно quote identifiers, собрать один transactional cleanup, использовать
TRUNCATE ... RESTART IDENTITY CASCADE, поддержатьschema.tableexclusions.11. MinIO singleton сознательно не изолирует данные и не создаёт bucket
Файлы:
MinioTestcontainerSingleton.java:25-26MinioTestcontainerExtension.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-53DELETE /*не имеет списка исключений и может затронуть служебные индексы. Поведение зависит от настройки destructive wildcard API; ошибка пробрасывается через@SneakyThrowsбез нормального описания.Исправление: получать список тестовых индексов и удалять их явно; добавить prefixes/exclusions и понятную обработку 404/403.
Проблемы средней важности
13. Kafka shell-валидация даёт ложные результаты
Файл:
KafkaContainerExtension.java:64-65, 99-100, 116-127actual.contains(topic)), а не точное совпадение строк;localhost:9093и paths завязаны на конкретный layout images;Нужно проверять exit code и парсить stdout в
Set<String>.14. Документация
excludeTruncateTopicsпротиворечит реализацииФайлы:
KafkaTestcontainer.java:128-134KafkaTestcontainerSingleton.java:141-147KafkaTestcontainerExtension.java:90-103JavaDoc предлагает передавать property key (
kafka.topics.invoicing.id), а код сравнивает exclusion с уже загруженным реальным topic name. При следовании документации исключение не сработает.15. AssertJ используется в production control flow
Файлы:
GenericContainerUtil.javaKafkaContainerExtension.javaSpringApplicationPropertiesLoader.javaПри этом
spring-boot-starter-testобъявлен какprovided(pom.xml:86-90). У consumer может не быть AssertJ runtime, что дастNoClassDefFoundError; ошибки также представлены какAssertionError, а не domain exception.Нужно заменить assertions на обычные проверки и собственные исключения.
16. Конфигурационный loader неполно повторяет Spring Boot semantics
Файл:
SpringApplicationPropertiesLoader.javaapplication.yml/yaml/properties/xml;PropertySource(getFirst()), multi-document YAML игнорируется;"null"(String.valueOf(null));Map<String, OriginTrackedValue>;Лучше читать значения из 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-63fromTime/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-120Date/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-64DROP 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публикуется как глобальный primaryStringbeanФайл:
KafkaProducerTestConfig.java:28-33@Primary Stringможет случайно участвовать в unrelated dependency injection. Надёжнее использовать@Qualifierили configuration properties object.24. POM содержит спорные runtime scopes
provided(pom.xml:98-103), хотя library сама вызывает JDBC migrations; без явной зависимости consumer получитNo suitable driver.junit-vintage-engineобъявлен compile dependency (pom.xml:155-158), хотя README утверждает JUnit 5 only; engine лучше удалить или сделать test scope.commons-ioиспользуется напрямую, но не объявлен явно в текущем POM — возможна скрытая зависимость от parent/transitive graph.