From f8cb2ac71dd41a3249abd51a37632aa55ee0704d Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Fri, 18 Sep 2026 14:11:51 +0200 Subject: [PATCH 1/2] refactor(seed): the boards and the esports history load on start, not in a migration Both seeds were repeatable Flyway migrations written in Kotlin that read CSV off the classpath. Seed data is not schema, and once migrations run in their own Job the runner cannot execute Kotlin at all, so they had to leave first. They become ShippedBoards and ShippedEsports: the same raw-JDBC upserts, keyed the same way, in one transaction as Flyway gave them, behind the same bean-plus-listener shape the art steps already use. Records are ordered ahead of art, which the migrator used to guarantee by running before the context was up. flyway.ignore-migration-patterns is set to repeatable:missing. Every database that ran these still carries their history rows, validate refuses an applied migration it cannot resolve, and that would stop the application booting rather than fail a test. A versioned migration going missing stays an error. detekt exempted these files through db/migration, which they have left; the two rules that actually fire on them, MagicNumber and LongMethod, name them instead. --- config/detekt/detekt.yml | 6 +- .../api/board/domain/ShippedBoardArtIT.kt | 13 +-- .../api/board/persistence/BoardSeedLoadIT.kt | 14 +-- .../api/esports/domain/ShippedArtIT.kt | 13 +-- .../esports/persistence/EsportsSeedLoadIT.kt | 14 +-- .../persistence/RecoveredAttributionIT.kt | 14 +-- .../api/board/domain/ShippedBoardArt.kt | 3 + .../api/board/domain/ShippedBoards.kt} | 86 +++++++++++----- .../api/esports/domain/ShippedArt.kt | 3 + .../api/esports/domain/ShippedEsports.kt} | 97 +++++++++++++------ .../blueshell/api/shared/seed/SeedOrder.kt | 9 ++ .../api/src/main/resources/application.yaml | 3 + .../api/board/domain}/BoardSeedParsingTest.kt | 4 +- .../esports/domain}/EsportsSeedParsingTest.kt | 16 +-- 14 files changed, 168 insertions(+), 127 deletions(-) rename services/api/src/main/kotlin/{db/migration/R__Boards_seed.kt => net/blueshell/api/board/domain/ShippedBoards.kt} (82%) rename services/api/src/main/kotlin/{db/migration/R__Esports_seed.kt => net/blueshell/api/esports/domain/ShippedEsports.kt} (87%) create mode 100644 services/api/src/main/kotlin/net/blueshell/api/shared/seed/SeedOrder.kt rename services/api/src/test/kotlin/{db/migration => net/blueshell/api/board/domain}/BoardSeedParsingTest.kt (98%) rename services/api/src/test/kotlin/{db/migration => net/blueshell/api/esports/domain}/EsportsSeedParsingTest.kt (69%) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 4185ae2bb..6dc7273e8 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -38,7 +38,7 @@ complexity: LongMethod: # Seed and fixture builders are long because the data is long — straight # line, no branches. Production code keeps detekt's 60-line ceiling. - excludes: ['**/test/**', '**/integrationTest/**', '**/testFixtures/**', '**/db/migration/**'] + excludes: ['**/test/**', '**/integrationTest/**', '**/testFixtures/**', '**/db/migration/**', '**/ShippedBoards.kt', '**/ShippedEsports.kt'] TooManyFunctions: excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/integrationTest/**', '**/testFixtures/**', '**/db/migration/**'] # A Spring service that owns one aggregate lands at twelve to fourteen @@ -110,7 +110,9 @@ style: # sits in: `@Order(50)` says what 50 is, and hoisting it to a constant puts # the ordering one indirection away from the thing being ordered. ignoreAnnotation: true - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/integrationTest/**', '**/testFixtures/**', '**/db/migration/**', '**/*.kts'] + # The two seed loaders set JDBC parameters by position: the number is the + # position, and a constant naming it would name nothing. + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/integrationTest/**', '**/testFixtures/**', '**/db/migration/**', '**/ShippedBoards.kt', '**/ShippedEsports.kt', '**/*.kts'] MaxLineLength: excludes: ['**/db/migration/**'] # ktlint is the other gate landing on this job and its ktlint_official style diff --git a/services/api/src/integrationTest/kotlin/net/blueshell/api/board/domain/ShippedBoardArtIT.kt b/services/api/src/integrationTest/kotlin/net/blueshell/api/board/domain/ShippedBoardArtIT.kt index defb3cec4..678271dc7 100644 --- a/services/api/src/integrationTest/kotlin/net/blueshell/api/board/domain/ShippedBoardArtIT.kt +++ b/services/api/src/integrationTest/kotlin/net/blueshell/api/board/domain/ShippedBoardArtIT.kt @@ -1,6 +1,5 @@ package net.blueshell.api.board.domain -import db.migration.R__Boards_seed import net.blueshell.api.board.persistence.BoardMemberRepository import net.blueshell.api.board.persistence.BoardRepository import net.blueshell.api.file.api.FileService @@ -10,7 +9,6 @@ import net.blueshell.api.shared.enums.FileType import net.blueshell.api.shared.enums.Role import net.blueshell.api.testsupport.UserTestSupport import org.assertj.core.api.Assertions.assertThat -import org.flywaydb.core.api.migration.Context import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -19,7 +17,6 @@ import org.springframework.boot.test.context.SpringBootTest import java.io.ByteArrayInputStream import java.nio.file.Files import java.nio.file.Paths -import java.sql.Connection import javax.sql.DataSource /** @@ -49,15 +46,7 @@ class ShippedBoardArtIT : UserTestSupport() { @BeforeEach fun loadTheRecords() { - dataSource.connection.use { connection -> - R__Boards_seed().migrate( - object : Context { - override fun getConfiguration() = null - - override fun getConnection(): Connection = connection - }, - ) - } + ShippedBoards(dataSource, transactionTemplate).apply() } /** One board's photograph, read where its widths can still be read. */ diff --git a/services/api/src/integrationTest/kotlin/net/blueshell/api/board/persistence/BoardSeedLoadIT.kt b/services/api/src/integrationTest/kotlin/net/blueshell/api/board/persistence/BoardSeedLoadIT.kt index 16faac95e..b5e0999b0 100644 --- a/services/api/src/integrationTest/kotlin/net/blueshell/api/board/persistence/BoardSeedLoadIT.kt +++ b/services/api/src/integrationTest/kotlin/net/blueshell/api/board/persistence/BoardSeedLoadIT.kt @@ -1,17 +1,15 @@ package net.blueshell.api.board.persistence -import db.migration.R__Boards_seed import net.blueshell.api.board.domain.BoardSeed +import net.blueshell.api.board.domain.ShippedBoards import net.blueshell.api.shared.enums.Role import net.blueshell.api.testsupport.UserTestSupport import net.blueshell.api.user.persistence.User import org.assertj.core.api.Assertions.assertThat -import org.flywaydb.core.api.migration.Context import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.jdbc.core.JdbcTemplate -import java.sql.Connection import java.time.LocalDate import javax.sql.DataSource @@ -420,15 +418,7 @@ class BoardSeedLoadIT : UserTestSupport() { )!! private fun runLoader() { - dataSource.connection.use { connection -> - R__Boards_seed().migrate( - object : Context { - override fun getConfiguration() = null - - override fun getConnection(): Connection = connection - }, - ) - } + ShippedBoards(dataSource, transactionTemplate).apply() } private companion object { diff --git a/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/domain/ShippedArtIT.kt b/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/domain/ShippedArtIT.kt index 83ed4866c..fb08e1c16 100644 --- a/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/domain/ShippedArtIT.kt +++ b/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/domain/ShippedArtIT.kt @@ -1,6 +1,5 @@ package net.blueshell.api.esports.domain -import db.migration.R__Esports_seed import net.blueshell.api.esports.persistence.GameRepository import net.blueshell.api.esports.persistence.TeamRepository import net.blueshell.api.esports.persistence.TeamSeason @@ -14,7 +13,6 @@ import net.blueshell.api.testsupport.EsportsSeedFixture import net.blueshell.api.testsupport.UserTestSupport import net.blueshell.api.user.api.UserService import org.assertj.core.api.Assertions.assertThat -import org.flywaydb.core.api.migration.Context import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -24,7 +22,6 @@ import org.springframework.boot.test.context.SpringBootTest import java.io.ByteArrayInputStream import java.nio.file.Files import java.nio.file.Paths -import java.sql.Connection import javax.sql.DataSource /** @@ -65,15 +62,7 @@ class ShippedArtIT : UserTestSupport() { @BeforeEach fun loadTheRecords() { - dataSource.connection.use { connection -> - R__Esports_seed(EsportsSeedFixture.files).migrate( - object : Context { - override fun getConfiguration() = null - - override fun getConnection(): Connection = connection - }, - ) - } + ShippedEsports(dataSource, transactionTemplate, EsportsSeedFixture.files).apply() } /** diff --git a/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/persistence/EsportsSeedLoadIT.kt b/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/persistence/EsportsSeedLoadIT.kt index 7821ea682..ada8ab162 100644 --- a/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/persistence/EsportsSeedLoadIT.kt +++ b/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/persistence/EsportsSeedLoadIT.kt @@ -1,16 +1,14 @@ package net.blueshell.api.esports.persistence -import db.migration.R__Esports_seed import net.blueshell.api.testsupport.EsportsSeedFixture +import net.blueshell.api.esports.domain.ShippedEsports import net.blueshell.api.testsupport.UserTestSupport import org.assertj.core.api.Assertions.assertThat -import org.flywaydb.core.api.migration.Context import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.jdbc.core.JdbcTemplate -import java.sql.Connection import javax.sql.DataSource /** @@ -221,15 +219,7 @@ class EsportsSeedLoadIT : UserTestSupport() { } private fun runLoader() { - dataSource.connection.use { connection -> - R__Esports_seed(EsportsSeedFixture.files).migrate( - object : Context { - override fun getConfiguration() = null - - override fun getConnection(): Connection = connection - }, - ) - } + ShippedEsports(dataSource, transactionTemplate, EsportsSeedFixture.files).apply() } @AfterEach diff --git a/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/persistence/RecoveredAttributionIT.kt b/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/persistence/RecoveredAttributionIT.kt index c2b8622e4..808696c70 100644 --- a/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/persistence/RecoveredAttributionIT.kt +++ b/services/api/src/integrationTest/kotlin/net/blueshell/api/esports/persistence/RecoveredAttributionIT.kt @@ -1,18 +1,16 @@ package net.blueshell.api.esports.persistence -import db.migration.R__Esports_seed import net.blueshell.api.shared.enums.Role +import net.blueshell.api.esports.domain.ShippedEsports import net.blueshell.api.testsupport.EsportsSeedFixture import net.blueshell.api.testsupport.UserTestSupport import net.blueshell.api.user.persistence.User import org.assertj.core.api.Assertions.assertThat -import org.flywaydb.core.api.migration.Context import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.jdbc.core.JdbcTemplate -import java.sql.Connection import javax.sql.DataSource /** @@ -120,15 +118,7 @@ class RecoveredAttributionIT : UserTestSupport() { } private fun runLoader() { - dataSource.connection.use { connection -> - R__Esports_seed(EsportsSeedFixture.files).migrate( - object : Context { - override fun getConfiguration() = null - - override fun getConnection(): Connection = connection - }, - ) - } + ShippedEsports(dataSource, transactionTemplate, EsportsSeedFixture.files).apply() } @AfterEach diff --git a/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoardArt.kt b/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoardArt.kt index 45a3156d3..b84797862 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoardArt.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoardArt.kt @@ -7,9 +7,11 @@ import net.blueshell.api.file.persistence.File import net.blueshell.api.shared.enums.FileType import net.blueshell.api.user.api.UserService import net.blueshell.api.user.persistence.User +import net.blueshell.api.shared.seed.SeedOrder import org.slf4j.LoggerFactory import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.event.EventListener +import org.springframework.core.annotation.Order import org.springframework.stereotype.Component import org.springframework.transaction.support.TransactionTemplate @@ -235,6 +237,7 @@ class ShippedBoardArt( * yesterday, and refusing to start over one would take the whole site down. */ @Component +@Order(SeedOrder.ART) class ShippedBoardArtOnStartup( private val art: ShippedBoardArt, ) { diff --git a/services/api/src/main/kotlin/db/migration/R__Boards_seed.kt b/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoards.kt similarity index 82% rename from services/api/src/main/kotlin/db/migration/R__Boards_seed.kt rename to services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoards.kt index f431b99f8..be1393244 100644 --- a/services/api/src/main/kotlin/db/migration/R__Boards_seed.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoards.kt @@ -1,33 +1,47 @@ -package db.migration +package net.blueshell.api.board.domain -import net.blueshell.api.board.domain.BoardSeed import net.blueshell.api.shared.seed.SeedCsv -import org.flywaydb.core.api.migration.BaseJavaMigration -import org.flywaydb.core.api.migration.Context +import net.blueshell.api.shared.seed.SeedOrder import org.slf4j.LoggerFactory +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.core.annotation.Order +import org.springframework.jdbc.datasource.DataSourceUtils +import org.springframework.stereotype.Component +import org.springframework.transaction.support.TransactionTemplate import java.sql.Connection import java.sql.Date import java.sql.Types +import javax.sql.DataSource /** - * Loads the boards and their members from the seed files under `db/seed/boards`. - * - * Repeatable and keyed on the files' contents, so correcting a row is an edit and a deploy. - * Deletion outranks the files: a soft-deleted board or member stays deleted while its row is - * still listed, and removing the row is how it leaves for good. The recorded name is the key, - * so the files can correct a role, a nickname, a blurb or a photograph but not a name. - * A member is attached to the account matching their name once and never re-matched, so - * detaching somebody stays detached. `photo` and `portrait` are not written here: storing a - * picture needs the volume and converter a migration runner lacks, so `ShippedBoardArt` fills - * them once the application is up. + * Loads the boards from `db/seed/boards`, upserting on the recorded name so a second run is a no-op. + * Deletion outranks the files, an attached account is never re-matched, and the art follows separately. */ -@Suppress("unused", "ClassNaming") -class R__Boards_seed : BaseJavaMigration() { - /** Hashes the files' contents: Flyway re-runs a repeatable migration when its checksum moves. */ - override fun getChecksum(): Int = SEED_FILES.fold(11) { acc, name -> 31 * acc + read(name).hashCode() } +@Component +class ShippedBoards( + private val dataSource: DataSource, + private val transactions: TransactionTemplate, + private val seed: SeedCsv = BoardSeed.files, +) { + data class Applied( + val boards: Int, + val members: Int, + val leftDeleted: Int, + ) - override fun migrate(context: Context) { - val connection = context.connection + // One transaction, as the migration had. DataSourceUtils returns the connection it is bound to. + fun apply(): Applied = + transactions.execute { + val connection = DataSourceUtils.getConnection(dataSource) + try { + load(connection) + } finally { + DataSourceUtils.releaseConnection(connection, dataSource) + } + }!! + + private fun load(connection: Connection): Applied { val boards = parse(read("boards.csv")) val members = parse(read("members.csv")) @@ -56,6 +70,11 @@ class R__Boards_seed : BaseJavaMigration() { written, outcomes.size - written, ) + return Applied( + boards = boardIds.values.count { it != null }, + members = written, + leftDeleted = outcomes.size - written, + ) } /** What became of one member the file lists. */ @@ -260,11 +279,10 @@ class R__Boards_seed : BaseJavaMigration() { statement.executeQuery().use { rows -> if (rows.next()) rows.getLong(1) else null } } - private fun read(name: String): String = BoardSeed.files.read(name) + private fun read(name: String): String = seed.read(name) companion object { - private val log = LoggerFactory.getLogger(R__Boards_seed::class.java) - private val SEED_FILES = listOf("boards.csv", "members.csv") + private val log = LoggerFactory.getLogger(ShippedBoards::class.java) /** The sentinel a live row carries, as every soft-deleted table here uses it. */ private const val ACTIVE = "deleted_at = '9999-12-31 23:59:59'" @@ -278,3 +296,25 @@ class R__Boards_seed : BaseJavaMigration() { fun parse(content: String): List> = SeedCsv.parse(content) } } + +/** + * A separate bean so the transaction is opened by the proxy, and a failure never blocks start-up. + */ +@Component +@Order(SeedOrder.RECORDS) +class ShippedBoardsOnStartup( + private val boards: ShippedBoards, +) { + @EventListener(ApplicationReadyEvent::class) + fun onReady() { + try { + boards.apply() + } catch (e: Exception) { + log.warn("[boards-seed] could not load the boards that ship: {}", e.message) + } + } + + private companion object { + val log = LoggerFactory.getLogger(ShippedBoardsOnStartup::class.java) + } +} diff --git a/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedArt.kt b/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedArt.kt index 4ca217fab..fe532dea6 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedArt.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedArt.kt @@ -7,11 +7,13 @@ import net.blueshell.api.file.api.FileService import net.blueshell.api.file.persistence.File import net.blueshell.api.shared.enums.FileType import net.blueshell.api.shared.seed.SeedCsv +import net.blueshell.api.shared.seed.SeedOrder import net.blueshell.api.user.api.UserService import net.blueshell.api.user.persistence.User import org.slf4j.LoggerFactory import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.event.EventListener +import org.springframework.core.annotation.Order import org.springframework.stereotype.Component import org.springframework.transaction.support.TransactionTemplate @@ -237,6 +239,7 @@ class ShippedArt( * pointed yesterday, and refusing to start over one would take the whole site down. */ @Component +@Order(SeedOrder.ART) class ShippedArtOnStartup( private val art: ShippedArt, ) { diff --git a/services/api/src/main/kotlin/db/migration/R__Esports_seed.kt b/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedEsports.kt similarity index 87% rename from services/api/src/main/kotlin/db/migration/R__Esports_seed.kt rename to services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedEsports.kt index 19f6e787a..dbd7a88ca 100644 --- a/services/api/src/main/kotlin/db/migration/R__Esports_seed.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedEsports.kt @@ -1,38 +1,51 @@ -package db.migration +package net.blueshell.api.esports.domain -import net.blueshell.api.esports.domain.EsportsSeed import net.blueshell.api.shared.seed.SeedCsv -import org.flywaydb.core.api.migration.BaseJavaMigration -import org.flywaydb.core.api.migration.Context +import net.blueshell.api.shared.seed.SeedOrder import org.slf4j.LoggerFactory +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.core.annotation.Order +import org.springframework.jdbc.datasource.DataSourceUtils +import org.springframework.stereotype.Component +import org.springframework.transaction.support.TransactionTemplate import java.sql.Connection import java.sql.Date import java.sql.Statement import java.sql.Types +import javax.sql.DataSource /** - * Loads the recovered esports history from the seed files under `db/seed/esports`, which are its - * only record: one row per game, season, team and roster entry. - * - * Repeatable and keyed on the files' contents, so correcting a row is an edit and a deploy. - * Deletion outranks the files: a soft-deleted season, team or entry stays deleted while its row - * is still listed, and removing the row is how it leaves for good. A place is matched to the - * member whose name it carries once, when the place is created, and never re-matched, so - * detaching somebody stays detached. + * Loads the esports history from `db/seed/esports`, which is its only record. Upserts on the + * recorded name, deletion outranks the files, and an attached account is never re-matched. */ -@Suppress("unused", "ClassNaming") -class R__Esports_seed( - /** Flyway builds this with no arguments, so the shipped seed is the default. Tests pass their own. */ +@Component +class ShippedEsports( + private val dataSource: DataSource, + private val transactions: TransactionTemplate, + // Defaulted so tests can pass their own, as the migration allowed. private val seed: SeedCsv = EsportsSeed.files, -) : BaseJavaMigration() { - /** - * The files are the migration. Flyway re-runs a repeatable migration when its checksum - * moves, so hashing their contents is what makes an edit take effect. - */ - override fun getChecksum(): Int = SEED_FILES.fold(7) { acc, name -> 31 * acc + read(name).hashCode() } +) { + data class Applied( + val games: Int, + val seasons: Int, + val teams: Int, + val entries: Int, + val leftDeleted: Int, + ) + + // One transaction, as the migration had. DataSourceUtils returns the connection it is bound to. + fun apply(): Applied = + transactions.execute { + val connection = DataSourceUtils.getConnection(dataSource) + try { + load(connection) + } finally { + DataSourceUtils.releaseConnection(connection, dataSource) + } + }!! - override fun migrate(context: Context) { - val connection = context.connection + private fun load(connection: Connection): Applied { // The games come first: a team names one, and the database now enforces that it exists. val games = parse(read("games.csv")) games.forEach { row -> upsertGame(connection, row) } @@ -74,6 +87,13 @@ class R__Esports_seed( written, skipped, ) + return Applied( + games = games.size, + seasons = seasonIds.size, + teams = teamIds.size, + entries = written, + leftDeleted = skipped, + ) } /** @@ -401,18 +421,33 @@ class R__Esports_seed( private fun read(name: String): String = seed.read(name) companion object { - private val log = LoggerFactory.getLogger(R__Esports_seed::class.java) - private val SEED_FILES = listOf("games.csv", "seasons.csv", "teams.csv", "roster.csv") + private val log = LoggerFactory.getLogger(ShippedEsports::class.java) /** The sentinel a live row carries, as every soft-deleted table here uses it. */ private const val ACTIVE = "deleted_at = '9999-12-31 23:59:59'" - /** - * The rows of one seed file. - * - * Delegates to [SeedCsv], which the start-up step that puts the art on these records - * reads the same files with. Kept here as the name the migration's own tests call. - */ fun parse(content: String): List> = SeedCsv.parse(content) } } + +/** + * A separate bean so the transaction is opened by the proxy, and a failure never blocks start-up. + */ +@Component +@Order(SeedOrder.RECORDS) +class ShippedEsportsOnStartup( + private val esports: ShippedEsports, +) { + @EventListener(ApplicationReadyEvent::class) + fun onReady() { + try { + esports.apply() + } catch (e: Exception) { + log.warn("[esports-seed] could not load the esports history that ships: {}", e.message) + } + } + + private companion object { + val log = LoggerFactory.getLogger(ShippedEsportsOnStartup::class.java) + } +} diff --git a/services/api/src/main/kotlin/net/blueshell/api/shared/seed/SeedOrder.kt b/services/api/src/main/kotlin/net/blueshell/api/shared/seed/SeedOrder.kt new file mode 100644 index 000000000..618ebaa08 --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/shared/seed/SeedOrder.kt @@ -0,0 +1,9 @@ +package net.blueshell.api.shared.seed + +/** + * Records before art: both are ApplicationReadyEvent listeners now, so nothing else orders them. + */ +object SeedOrder { + const val RECORDS = 100 + const val ART = 200 +} diff --git a/services/api/src/main/resources/application.yaml b/services/api/src/main/resources/application.yaml index 8a0da2b16..f5565825c 100644 --- a/services/api/src/main/resources/application.yaml +++ b/services/api/src/main/resources/application.yaml @@ -71,6 +71,9 @@ spring: enabled: true locations: classpath:db/migration validate-migration-naming: true + # The two seed repeatables are start-up steps now, but every database still has their history + # rows, and validate refuses an applied migration it cannot resolve. Versioned stays an error. + ignore-migration-patterns: repeatable:missing modulith: # The modules are nested under domain/, platform/ and infrastructure/ today, so the diff --git a/services/api/src/test/kotlin/db/migration/BoardSeedParsingTest.kt b/services/api/src/test/kotlin/net/blueshell/api/board/domain/BoardSeedParsingTest.kt similarity index 98% rename from services/api/src/test/kotlin/db/migration/BoardSeedParsingTest.kt rename to services/api/src/test/kotlin/net/blueshell/api/board/domain/BoardSeedParsingTest.kt index ba72f7925..b22102037 100644 --- a/services/api/src/test/kotlin/db/migration/BoardSeedParsingTest.kt +++ b/services/api/src/test/kotlin/net/blueshell/api/board/domain/BoardSeedParsingTest.kt @@ -1,6 +1,4 @@ -package db.migration - -import net.blueshell.api.board.domain.BoardSeed +package net.blueshell.api.board.domain import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import java.time.LocalDate diff --git a/services/api/src/test/kotlin/db/migration/EsportsSeedParsingTest.kt b/services/api/src/test/kotlin/net/blueshell/api/esports/domain/EsportsSeedParsingTest.kt similarity index 69% rename from services/api/src/test/kotlin/db/migration/EsportsSeedParsingTest.kt rename to services/api/src/test/kotlin/net/blueshell/api/esports/domain/EsportsSeedParsingTest.kt index 607ab9849..2b248504c 100644 --- a/services/api/src/test/kotlin/db/migration/EsportsSeedParsingTest.kt +++ b/services/api/src/test/kotlin/net/blueshell/api/esports/domain/EsportsSeedParsingTest.kt @@ -1,4 +1,4 @@ -package db.migration +package net.blueshell.api.esports.domain import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test class EsportsSeedParsingTest { @Test fun `reads a row against the header rather than by position`() { - val rows = R__Esports_seed.parse("name,start_date,end_date\nAutumn 2020,2020-09-01,2021-01-31\n") + val rows = ShippedEsports.parse("name,start_date,end_date\nAutumn 2020,2020-09-01,2021-01-31\n") assertThat(rows).singleElement().isEqualTo( mapOf("name" to "Autumn 2020", "start_date" to "2020-09-01", "end_date" to "2021-01-31"), @@ -17,33 +17,33 @@ class EsportsSeedParsingTest { @Test fun `a quoted field keeps the comma inside it`() { // A team is free to have a comma in its name; it is still one field. - val rows = R__Esports_seed.parse("game,name\nVALORANT,\"BS Ohm, Sweet Ohm\"\n") + val rows = ShippedEsports.parse("game,name\nVALORANT,\"BS Ohm, Sweet Ohm\"\n") assertThat(rows.single()["name"]).isEqualTo("BS Ohm, Sweet Ohm") } @Test fun `a doubled quote inside a quoted field is one quote`() { - val rows = R__Esports_seed.parse("handle\n\"the \"\"wall\"\"\"\n") + val rows = ShippedEsports.parse("handle\n\"the \"\"wall\"\"\"\n") assertThat(rows.single()["handle"]).isEqualTo("the \"wall\"") } @Test fun `an empty field is empty rather than absent`() { - val rows = R__Esports_seed.parse("game,name,image\nSMASH,BS Smashers,\n") + val rows = ShippedEsports.parse("game,name,image\nSMASH,BS Smashers,\n") assertThat(rows.single()).containsEntry("image", "") } @Test fun `a file with only a header holds no records`() { - assertThat(R__Esports_seed.parse("name,start_date,end_date\n")).isEmpty() + assertThat(ShippedEsports.parse("name,start_date,end_date\n")).isEmpty() } @Test fun `a blank line is not a record`() { - val rows = R__Esports_seed.parse("name\nAutumn 2020\n\n") + val rows = ShippedEsports.parse("name\nAutumn 2020\n\n") assertThat(rows).hasSize(1) } @@ -51,7 +51,7 @@ class EsportsSeedParsingTest { @Test fun `a row that does not fit the header is refused rather than silently shifted`() { // A missing comma would otherwise put a season's end date into its start. - assertThatThrownBy { R__Esports_seed.parse("name,start_date,end_date\nAutumn,2020-09-01\n") } + assertThatThrownBy { ShippedEsports.parse("name,start_date,end_date\nAutumn,2020-09-01\n") } .isInstanceOf(IllegalArgumentException::class.java) .hasMessageContaining("header has 3") } From b8a26f248135f5dfd1324b92166b008e22c996ad Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Fri, 18 Sep 2026 14:49:42 +0200 Subject: [PATCH 2/2] fix(seed): the listener order sits on the method, where Spring reads it BoardArtSystemTest failed with "No board in the seeded history carries a photo": the art ran before the records it hangs on. @Order was on the class, and ApplicationListenerMethodAdapter resolves an @EventListener's order from the method alone, so it was never applied and the two listeners raced. A class-level @Order looks correct and does nothing, so a test asserts the annotation is on each onReady and that records sort before art. Both cases fail when the annotation is moved back to the class. --- .../api/board/domain/ShippedBoardArt.kt | 2 +- .../api/board/domain/ShippedBoards.kt | 2 +- .../api/esports/domain/ShippedArt.kt | 2 +- .../api/esports/domain/ShippedEsports.kt | 2 +- .../api/shared/seed/SeedStartupOrderTest.kt | 43 +++++++++++++++++++ 5 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 services/api/src/test/kotlin/net/blueshell/api/shared/seed/SeedStartupOrderTest.kt diff --git a/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoardArt.kt b/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoardArt.kt index b84797862..717ce707d 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoardArt.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoardArt.kt @@ -237,10 +237,10 @@ class ShippedBoardArt( * yesterday, and refusing to start over one would take the whole site down. */ @Component -@Order(SeedOrder.ART) class ShippedBoardArtOnStartup( private val art: ShippedBoardArt, ) { + @Order(SeedOrder.ART) @EventListener(ApplicationReadyEvent::class) fun onReady() { try { diff --git a/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoards.kt b/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoards.kt index be1393244..8e8df9415 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoards.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/board/domain/ShippedBoards.kt @@ -301,10 +301,10 @@ class ShippedBoards( * A separate bean so the transaction is opened by the proxy, and a failure never blocks start-up. */ @Component -@Order(SeedOrder.RECORDS) class ShippedBoardsOnStartup( private val boards: ShippedBoards, ) { + @Order(SeedOrder.RECORDS) @EventListener(ApplicationReadyEvent::class) fun onReady() { try { diff --git a/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedArt.kt b/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedArt.kt index fe532dea6..a836b83ff 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedArt.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedArt.kt @@ -239,10 +239,10 @@ class ShippedArt( * pointed yesterday, and refusing to start over one would take the whole site down. */ @Component -@Order(SeedOrder.ART) class ShippedArtOnStartup( private val art: ShippedArt, ) { + @Order(SeedOrder.ART) @EventListener(ApplicationReadyEvent::class) fun onReady() { try { diff --git a/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedEsports.kt b/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedEsports.kt index dbd7a88ca..53f9954bb 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedEsports.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/esports/domain/ShippedEsports.kt @@ -434,10 +434,10 @@ class ShippedEsports( * A separate bean so the transaction is opened by the proxy, and a failure never blocks start-up. */ @Component -@Order(SeedOrder.RECORDS) class ShippedEsportsOnStartup( private val esports: ShippedEsports, ) { + @Order(SeedOrder.RECORDS) @EventListener(ApplicationReadyEvent::class) fun onReady() { try { diff --git a/services/api/src/test/kotlin/net/blueshell/api/shared/seed/SeedStartupOrderTest.kt b/services/api/src/test/kotlin/net/blueshell/api/shared/seed/SeedStartupOrderTest.kt new file mode 100644 index 000000000..0b2ca46a4 --- /dev/null +++ b/services/api/src/test/kotlin/net/blueshell/api/shared/seed/SeedStartupOrderTest.kt @@ -0,0 +1,43 @@ +package net.blueshell.api.shared.seed + +import net.blueshell.api.board.domain.ShippedBoardArtOnStartup +import net.blueshell.api.board.domain.ShippedBoardsOnStartup +import net.blueshell.api.esports.domain.ShippedArtOnStartup +import net.blueshell.api.esports.domain.ShippedEsportsOnStartup +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.core.annotation.AnnotatedElementUtils +import org.springframework.core.annotation.Order +import kotlin.reflect.jvm.javaMethod + +/** + * Spring resolves an @EventListener's order from the method, never the declaring class, so an + * @Order sitting on the class is ignored and the art can run before the records it needs. + */ +class SeedStartupOrderTest { + private fun orderOf(type: Class<*>): Int? { + val method = type.declaredMethods.single { it.name == "onReady" } + return AnnotatedElementUtils.findMergedAnnotation(method, Order::class.java)?.value + } + + @Test + fun `every seed listener carries its order on the method`() { + listOf( + ShippedBoardsOnStartup::class.java, + ShippedEsportsOnStartup::class.java, + ShippedBoardArtOnStartup::class.java, + ShippedArtOnStartup::class.java, + ).forEach { type -> + assertThat(orderOf(type)) + .describedAs("%s.onReady must carry @Order; on the class it is ignored", type.simpleName) + .isNotNull() + } + } + + @Test + fun `records run before the art that hangs on them`() { + assertThat(SeedOrder.RECORDS).isLessThan(SeedOrder.ART) + assertThat(orderOf(ShippedBoardsOnStartup::class.java)).isLessThan(orderOf(ShippedBoardArtOnStartup::class.java)) + assertThat(orderOf(ShippedEsportsOnStartup::class.java)).isLessThan(orderOf(ShippedArtOnStartup::class.java)) + } +}