diff --git a/deploy/preflight/README.md b/deploy/preflight/README.md index 31564aa..03a1468 100644 --- a/deploy/preflight/README.md +++ b/deploy/preflight/README.md @@ -3,10 +3,12 @@ **Перед любыми миграциями обязателен backup:** `./deploy/backup.sh backup` (см. [`../BACKUP.md`](../BACKUP.md)). Preflight не заменяет backup. -Два скрипта только читают данные: +Скрипты только читают данные: - `groups-courses.sql` — до миграций: проблемы, которые могут помешать; -- `groups-courses-post.sql` — после миграций и bootstrap курса C: проверка результата. +- `groups-courses-post.sql` — после миграций и bootstrap курса C: проверка результата; +- `progress-v10-v13.sql` — проверка истории Flyway и колонок/индекса при переходе + старой feature-миграции V10 на V13. ## До миграций @@ -40,6 +42,34 @@ psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses-post.sql - статусы аккаунтов; - группы без полного разбора. +## Переход V10 → V13 + +Нужен для любой БД, которая когда-либо применяла старую feature-миграцию +`V10__profile_authors.sql`: dev/feature-БД и staging, ненадолго запускавший +master-коммит `bfdae41` (out-of-order + V10). На production/master записи V10 +нет — раздел можно пропустить. + +Порядок: + +1. Backup: `./deploy/backup.sh backup`. +2. Проверить состояние: + + ```bash + psql -h localhost -U lms -d lms -f deploy/preflight/progress-v10-v13.sql + ``` + + Колонки должны быть `uuid`/nullable, индекс `lab_grades_lab_idx` — существовать + и быть валидным; `columns_ok` и `index_ok` должны быть `true`. +3. Если V10 есть в истории с `success=true`, удалить только эту запись истории + (`flyway repair` или точечный `DELETE` из примера в конце скрипта). При + `success=false` сначала проверить откат объектов; V13 идемпотентна и создаст + недостающее. +4. Deploy: обычный запуск применит V13. +5. Postflight: `psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses-post.sql`. + +После V13 переход forward-only: откат на сборку без V13 не пройдёт валидацию +Flyway. Не оставляйте `out-of-order` включённым. + ## Что делать с результатами 1. Группы с нестандартными именами — разобрать вручную и заполнить поля до diff --git a/deploy/preflight/progress-v10-v13.sql b/deploy/preflight/progress-v10-v13.sql new file mode 100644 index 0000000..7030c92 --- /dev/null +++ b/deploy/preflight/progress-v10-v13.sql @@ -0,0 +1,96 @@ +-- Preflight (только чтение): переход progress-миграции профиля V10 -> V13. +-- Проверяет историю Flyway, тип/nullable колонок и валидность индекса. +-- Ничего не изменяет: DELETE в конце приведён только как закомментированный пример. +-- Пример: +-- psql -h localhost -U lms -d lms -f deploy/preflight/progress-v10-v13.sql + +\set ON_ERROR_STOP on +\pset pager off + +\echo '=== 0. Наличие progress.flyway_schema_history ===' +SELECT to_regclass('progress.flyway_schema_history') IS NOT NULL + AS progress_flyway_schema_history_exists \gset + +\if :progress_flyway_schema_history_exists +\echo '=== 1. progress.flyway_schema_history: версии 10-13 ===' +SELECT installed_rank, version, description, type, script, success, installed_on +FROM progress.flyway_schema_history +WHERE version IN ('10', '11', '12', '13') +ORDER BY installed_rank; +\else +\echo 'progress.flyway_schema_history не найдена: сервис ещё не применял миграции progress.' +\echo 'Проверьте LMS_DB_URL / схему progress и повторите preflight.' +\endif + +\echo '=== 2. Колонки профиля: тип uuid и nullable (ok = true) ===' +SELECT expected.table_name, + expected.column_name, + c.data_type, + c.is_nullable, + (c.data_type = 'uuid' AND c.is_nullable = 'YES') AS ok +FROM (VALUES + ('grade_comments', 'author_account_id'), + ('lab_grades', 'updated_by_account_id') +) AS expected(table_name, column_name) +LEFT JOIN information_schema.columns c + ON c.table_schema = 'progress' + AND c.table_name = expected.table_name + AND c.column_name = expected.column_name +ORDER BY expected.table_name, expected.column_name; + +\echo '=== 3. Индекс lab_grades_lab_idx: существует, валиден, определение ===' +SELECT i.indexrelid::regclass AS index_name, + i.indisvalid, + i.indisready, + pg_get_indexdef(i.indexrelid) AS definition +FROM pg_index i +JOIN pg_class idx ON idx.oid = i.indexrelid +JOIN pg_namespace n ON n.oid = idx.relnamespace +WHERE n.nspname = 'progress' + AND idx.relname = 'lab_grades_lab_idx'; + +\echo '=== 4. Сводка: колонки и индекс готовы к V13 ===' +SELECT + ( + SELECT count(*) = 2 + AND bool_and(c.data_type = 'uuid' AND c.is_nullable = 'YES') + FROM information_schema.columns c + WHERE c.table_schema = 'progress' + AND (c.table_name, c.column_name) IN ( + ('grade_comments', 'author_account_id'), + ('lab_grades', 'updated_by_account_id') + ) + ) AS columns_ok, + ( + SELECT count(*) = 1 + AND bool_and(i.indisvalid) + FROM pg_index i + JOIN pg_class idx ON idx.oid = i.indexrelid + JOIN pg_namespace n ON n.oid = idx.relnamespace + WHERE n.nspname = 'progress' + AND idx.relname = 'lab_grades_lab_idx' + ) AS index_ok; + +-- Очистка истории (НЕ выполняется этим скриптом). +-- +-- Если старая feature-миграция V10 применена со success=true, удалите ТОЛЬКО +-- её запись истории: объекты схемы уже созданы, а V13 идемпотентна и создаст +-- недостающие колонки/индекс. Гард по RETURNING: ожидается ровно одна строка; +-- если строк больше или меньше — ROLLBACK. +-- +-- BEGIN; +-- DELETE FROM progress.flyway_schema_history +-- WHERE version = '10' +-- AND script = 'V10__profile_authors.sql' +-- AND success +-- RETURNING installed_rank, version, script, success; +-- -- expect exactly one row; otherwise ROLLBACK +-- COMMIT; +-- +-- Если V10 осталась со success=false, сначала убедитесь, что её частичные +-- изменения откатились на уровне объектов, затем выполните `flyway repair` +-- или удалите только неуспешную строку. V13 идемпотентна и досоздаст +-- недостающие объекты. Перед удалением строки запустите этот preflight: +-- все проверки должны быть true. +-- +-- Не переставляйте V11/V12 и не включайте out-of-order. diff --git a/docs/07-groups-courses.md b/docs/07-groups-courses.md index e7fb68c..bb6d2ae 100644 --- a/docs/07-groups-courses.md +++ b/docs/07-groups-courses.md @@ -95,9 +95,11 @@ ### progress Данные привязаны к `lab_id` и студенту (сдачи, оценки, комментарии, защиты, назначения вариантов, публикации). Схема **в основном не меняется**: доступ регулируется по цепочке -`lab → course → (преподаватель | группа)`. Для профиля студента курса добавляется -`grade_comments.author_account_id` (nullable) и индекс `(student_id, lab_id)`; -остальное — рантайм-проверки. +`lab → course → (преподаватель | группа)`. Для профиля студента курса добавляются +`grade_comments.author_account_id` (nullable) и `lab_grades.updated_by_account_id` +(nullable); существующий индекс `(student_id, lab_id)` (`grade_comments_grade_idx`) +переиспользуется, дополнительно добавляется индекс `lab_grades(lab_id)`. +Остальное — рантайм-проверки. --- @@ -270,7 +272,7 @@ CREATE UNIQUE INDEX chapters_slug_course_uidx ON chapters (course_id, slug) WHER Признак видимости актив наследует от статьи→раздела→главы (course/audience). Требуется проверка доступа в маршруте выдачи активов (см. §6). Отдельных колонок достаточно опционально; минимум — корректная авторизация. -**V10 — курс C: завершение привязки лаб (post-bootstrap шаг)** +**Post-bootstrap шаг academic (условный «V10», не файл `db/migration` и вне обычной цепочки Flyway) — курс C: завершение привязки лаб** ```sql -- Выполняется после bootstrap-сервиса, когда все лабы получили course_id. -- Это отдельный шаг/миграция: обычный Flyway-старт применяет миграции раньше @@ -281,12 +283,14 @@ ALTER TABLE labs ALTER COLUMN course_id SET NOT NULL; ### progress Схема **в основном не меняется**. Проверки доступа — рантайм (по `lab_id → course`). -**V10 — профиль студента курса: автор комментария** +**V13 — профиль студента курса: автор комментария** ```sql -ALTER TABLE grade_comments ADD COLUMN author_account_id uuid; -ALTER TABLE lab_grades ADD COLUMN updated_by_account_id uuid; -CREATE INDEX lab_grades_lab_idx ON lab_grades (lab_id); +ALTER TABLE grade_comments ADD COLUMN IF NOT EXISTS author_account_id uuid; +ALTER TABLE lab_grades ADD COLUMN IF NOT EXISTS updated_by_account_id uuid; +CREATE INDEX IF NOT EXISTS lab_grades_lab_idx ON lab_grades (lab_id); ``` +V13 идемпотентна: если старая feature-миграция V10 уже применялась в dev-БД, +достаточно убрать её запись из истории Flyway (см. план реализации). Индекс `grade_comments (student_id, lab_id)` уже существует (`grade_comments_grade_idx`). Старые комментарии остаются без автора и отображаются как «автор не указан». diff --git a/docs/08-groups-courses-plan.md b/docs/08-groups-courses-plan.md index 9e4335d..58cfe7d 100644 --- a/docs/08-groups-courses-plan.md +++ b/docs/08-groups-courses-plan.md @@ -200,14 +200,38 @@ ## 4. Ориентировочные миграции -Номера уточняются при реализации, чтобы не конфликтовать с текущими максимумами -(identity V3, academic V5, progress V9). +Номера уточняются при реализации, чтобы не конфликтовать с максимумами master +**до этой ветки** (identity V3, academic V5, progress V12); максимумы этой ветки +больше. | Сервис | Миграции | Содержание | |---|---|---| | identity | V4–V9 | роли, поля групп, статусы студентов, `membership_history`, `promotion_runs/items`, `accounts.status` | | academic | V6–V9 + post-bootstrap V10 | курсы, преподаватели, группы курса, `labs.course_id` (nullable), материалы курса/аудитории, частичные индексы, активы; `SET NOT NULL` — отдельный шаг после bootstrap | -| progress | V10 (+ V11 при необходимости аудита) | `grade_comments.author_account_id`, `lab_grades.updated_by_account_id`, индекс `lab_grades(lab_id)`; аудит скачиваний — этап 6 | +| progress | V13 (+ V14 при необходимости аудита) | `grade_comments.author_account_id`, `lab_grades.updated_by_account_id`, индекс `lab_grades(lab_id)`; аудит скачиваний — этап 6 | + +### Переход со старой feature-миграции V10 на V13 + +Старая `progress V10__profile_authors.sql` — временная feature-миграция, её нет +в master. + +- **Production / master:** есть только V11 и V12, записи V10 нет — V13 применяется + обычным порядком, ничего делать не нужно. +- **Dev/feature-БД, где `V10__profile_authors.sql` уже применена и имеет + `success=true`:** удалить только эту запись истории, ничего не удаляя из схемы; + V13 идемпотентна и сама создаст недостающие колонки и индекс. Используйте + `flyway repair` или точечно: + `DELETE FROM progress.flyway_schema_history WHERE version = '10' AND script = 'V10__profile_authors.sql' AND success;`. +- **Staging-БД**, которые ненадолго запускали master-коммит `bfdae41` (out-of-order + + V10), тоже содержат запись V10 — для них действует тот же порядок. +- **Если V10 осталась с `success=false`:** сначала убедитесь, что её частичные + изменения откатились на уровне объектов, затем `flyway repair` или удаление + неуспешной строки; V13 досоздаст недостающее. +- Перед очисткой и деплоем проверьте состояние через + `deploy/preflight/progress-v10-v13.sql`. +- Переход **forward-only**: после V13 откат на сборку без V13 не пройдёт валидацию + Flyway. Не оставляйте `out-of-order` включённым. +- **Нельзя** переставлять V11/V12. --- diff --git a/docs/10-stage0-report.md b/docs/10-stage0-report.md index 5e16343..0c05f9a 100644 --- a/docs/10-stage0-report.md +++ b/docs/10-stage0-report.md @@ -13,10 +13,12 @@ история членства, статусы аккаунтов; - academic `V6–V9`: курсы, преподаватели, группы курса, `labs.course_id`, материалы курса/аудитории, индексы активов; - - progress `V10`: автор комментария и последнего изменения оценки. + - progress `V13`: автор комментария и последнего изменения оценки. - Preflight-скрипты: - `deploy/preflight/groups-courses.sql` — до миграций; - - `deploy/preflight/groups-courses-post.sql` — после миграций/bootstrap. + - `deploy/preflight/groups-courses-post.sql` — после миграций/bootstrap; + - `deploy/preflight/progress-v10-v13.sql` — переход старой feature-миграции + progress V10 на V13. - UI: статус защиты в ведомости различает «готова» и «кончилось время». - Резервное копирование: - `deploy/backup.sh` — БД + решения + материалы, checksums, retention, diff --git a/services/progress-service/src/main/resources/db/migration/V10__profile_authors.sql b/services/progress-service/src/main/resources/db/migration/V10__profile_authors.sql deleted file mode 100644 index 722fa22..0000000 --- a/services/progress-service/src/main/resources/db/migration/V10__profile_authors.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Автор комментария и последнего изменения оценки — для профиля студента курса. --- Старые записи остаются без автора и отображаются как «автор не указан». -ALTER TABLE grade_comments ADD COLUMN author_account_id uuid; -ALTER TABLE lab_grades ADD COLUMN updated_by_account_id uuid; - --- Комментарии уже индексированы grade_comments_grade_idx (student_id, lab_id). -CREATE INDEX lab_grades_lab_idx ON lab_grades (lab_id); diff --git a/services/progress-service/src/main/resources/db/migration/V13__profile_authors.sql b/services/progress-service/src/main/resources/db/migration/V13__profile_authors.sql new file mode 100644 index 0000000..1a0bfa6 --- /dev/null +++ b/services/progress-service/src/main/resources/db/migration/V13__profile_authors.sql @@ -0,0 +1,12 @@ +-- V13: миграция профиля студента курса. +-- Номер V13, потому что V11 (attestations) и V12 (attestation_semester_and_publication) +-- уже существуют в master и применены в production; их порядок нельзя нарушать, +-- а V10 не может быть вставлена перед ними. +-- Автор комментария и последнего изменения оценки — для профиля студента курса. +-- Старые записи остаются без автора и отображаются как «автор не указан». +-- IF NOT EXISTS: безопасно, если старая feature-миграция V10 уже была применена вручную/dev. +ALTER TABLE grade_comments ADD COLUMN IF NOT EXISTS author_account_id uuid; +ALTER TABLE lab_grades ADD COLUMN IF NOT EXISTS updated_by_account_id uuid; + +-- Комментарии уже индексированы grade_comments_grade_idx (student_id, lab_id). +CREATE INDEX IF NOT EXISTS lab_grades_lab_idx ON lab_grades (lab_id); diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/migration/ProfileAuthorsMigrationTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/migration/ProfileAuthorsMigrationTest.kt new file mode 100644 index 0000000..a5f43cf --- /dev/null +++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/migration/ProfileAuthorsMigrationTest.kt @@ -0,0 +1,115 @@ +package ru.ysu.lms.progress.migration + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.core.io.ClassPathResource +import org.springframework.core.io.Resource +import org.springframework.jdbc.datasource.init.ScriptUtils +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.postgresql.PostgreSQLContainer +import java.sql.PreparedStatement +import java.sql.Statement +import javax.sql.DataSource + +/** + * V13 идемпотентна: повторный запуск не должен падать, а колонки и индекс + * остаются в ожидаемом состоянии. Реальная PostgreSQL 18 (Testcontainers). + */ +@Testcontainers +@SpringBootTest +class ProfileAuthorsMigrationTest { + companion object { + @Container + @ServiceConnection + @JvmStatic + val postgres = PostgreSQLContainer("postgres:18") + + private const val COLUMN_QUERY = """ + SELECT data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = 'progress' + AND table_name = ? + AND column_name = ? + """ + + private const val INDEX_QUERY = """ + SELECT i.indisvalid, pg_get_indexdef(i.indexrelid) AS definition + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'progress' + AND c.relname = 'lab_grades_lab_idx' + """ + } + + @Autowired + private lateinit var dataSource: DataSource + + @Test + fun `V13 profile authors migration is idempotent`() { + val migration = ClassPathResource("db/migration/V13__profile_authors.sql") + + repeat(2) { applyMigration(migration) } + + assertThat(column("grade_comments", "author_account_id")) + .isEqualTo("uuid" to "YES") + assertThat(column("lab_grades", "updated_by_account_id")) + .isEqualTo("uuid" to "YES") + + val index = labIndex() + assertThat(index).isNotNull + assertThat(index!!.first).isTrue() + assertThat(index.second).contains("lab_grades").contains("lab_id") + } + + private fun applyMigration(migration: Resource) { + dataSource.connection.use { connection -> + // Flyway работает в схеме progress; сырой JDBC-коннект её не видит. + connection.createStatement().use { statement -> + statement.execute("SET search_path TO progress, public") + } + ScriptUtils.executeSqlScript(connection, migration) + } + } + + private fun column( + table: String, + column: String, + ): Pair? = + dataSource.connection.use { connection -> + connection.prepareStatement(COLUMN_QUERY.trimIndent()).use { statement -> + statement.setString(1, table) + statement.setString(2, column) + readColumn(statement) + } + } + + private fun readColumn(statement: PreparedStatement): Pair? = + statement.executeQuery().use { rows -> + if (rows.next()) { + rows.getString("data_type") to rows.getString("is_nullable") + } else { + null + } + } + + private fun labIndex(): Pair? = + dataSource.connection.use { connection -> + connection.createStatement().use { statement -> + readIndex(statement) + } + } + + private fun readIndex(statement: Statement): Pair? = + statement.executeQuery(INDEX_QUERY.trimIndent()).use { rows -> + if (rows.next()) { + rows.getBoolean("indisvalid") to rows.getString("definition") + } else { + null + } + } +}