fix(migrations): opt-in dirty auto-heal, raise timeout default, failure alarm - #1124
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAdds ordered migrator recovery (operator override then default-on dirty auto-heal), increases migration timeout to 120s, makes an analytics migration idempotent, adds unit/integration tests for timeout/auto-heal/idempotency, and wires CloudWatch metric+alarm (optional SNS) for migration failures. ChangesMigration Resilience Implementation
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
…re alarm
Harden the lazy-on-connect migration path that caused a prod outage: a slow
migration (index build on a growing table) blew the 20s timeout mid-run,
golang-migrate left schema_migrations dirty, and since dirty is terminal every
later cold start fail-opened and served 500s on any query needing the
unapplied columns (070-073 never applied).
Three changes:
1. Default-on dirty auto-heal (internal/database/postgres/migrations/migrate.go).
New maybeAutoHealDirty runs after maybeForceMigrationVersion and before
Up(): when the DB is dirty it Force()s the CURRENT recorded version to clear
the flag, then Up() re-applies the pending tail, so a cold start self-recovers
instead of staying broken until a manual force (the multi-hour outage shape).
Enabled by default; CUDLY_MIGRATION_AUTOHEAL=false is the escape hatch.
Forces the current version, NEVER lower: forcing below already-applied seed
migrations re-runs guards that raise on a second run (e.g. 000059 "Purchaser
already exists"). The app ALWAYS starts: if auto-heal still can't reach head,
the error propagates to ensureDB which fail-opens (existing behavior) and the
alarm fires -- never a crash-loop. CUDLY_FORCE_MIGRATION_VERSION still takes
precedence. Relies on migrations being idempotent (documented + tested).
2. Raise defaultMigrationsTimeout 20s -> 120s (internal/server/app.go), still
well under the 300s Lambda limit, so a normal index build can't be killed
mid-run. CUDLY_MIGRATION_TIMEOUT override unchanged.
3. CloudWatch migration-failure alarm (terraform/modules/compute/aws/lambda).
Log metric filter on the "Migration failed" line + alarm, since the app
fail-opens and AWS/Lambda Errors stays clean during a broken migration.
Notification target optional via alarm_sns_topic_arn (default []); reuses
the existing alarm/metric-filter patterns, no new SNS infra.
Fix latent migration bug surfaced by the new idempotency test: migration
000067 widened savings_snapshots.account_id BEFORE dropping the materialized
views that depend on it, so the full stack failed to reach head on a fresh DB
("cannot alter type of a column used by a view"). Moved the view DROPs ahead
of the ALTER COLUMN. This moved the migrations integration suite from 4/37 to
28/42 passing; the remaining failures are pre-existing and unrelated (users
role column, savings-plans split assertions).
Tests:
- TestMigrations_FullStackIdempotent (integration): migrate to head twice,
second run is a clean no-op and not dirty.
- TestMigrations_AutoHealDirty (integration): dirty DB self-heals by default
and with =true; CUDLY_MIGRATION_AUTOHEAL=false disables it so a dirty DB
errors with the dirty flag intact.
- TestResolveMigrationsTimeout (unit): default is 120s, honors the override,
falls back on invalid/non-positive input.
Spec specs/migration-resilience.md updated for the new default, the auto-heal
flag, precedence, and the alarm.
832ff27 to
be11517
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
RunMigrations hit cyclomatic complexity 11 (gocyclo budget is 10) after the default-on dirty auto-heal added a second pre-Up error branch, failing the pre-commit CI hook. Extract migrator creation plus the operator-force and auto-heal hooks into newMigratorWithRecovery, dropping RunMigrations to 9 while preserving hook ordering and error propagation. The helper closes the migrator on its own error paths so callers never receive a half-initialized migrator.
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
specs/migration-resilience.md (1)
51-56: 💤 Low valueClarify that
alarm_sns_topic_arnaccepts a list of SNS topic ARNs.Line 56 says "Wire its
alarm_sns_topic_arn" (singular), but the Terraform variable is typelist(string)and accepts multiple SNS topic ARNs. Consider revising to "Wire thealarm_sns_topic_arnvariable (a list of SNS topic ARNs)" for clarity.📝 Suggested wording improvement
-`alarm_sns_topic_arn` to the monitoring module's SNS topic to get paged. +`alarm_sns_topic_arn` variable (list of SNS topic ARNs) to the monitoring +module's SNS topics to get paged.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@specs/migration-resilience.md` around lines 51 - 56, Update the documentation to clarify that the Terraform variable alarm_sns_topic_arn is a list of SNS ARNs (type list(string)) and may contain multiple ARNs; change the sentence that currently reads "Wire its `alarm_sns_topic_arn`" to explicitly reference the variable as a list (e.g., "Wire the `alarm_sns_topic_arn` variable (a list of SNS topic ARNs) to the monitoring module's SNS topic(s)") so readers understand multiple ARNs are supported.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@specs/migration-resilience.md`:
- Around line 51-56: Update the documentation to clarify that the Terraform
variable alarm_sns_topic_arn is a list of SNS ARNs (type list(string)) and may
contain multiple ARNs; change the sentence that currently reads "Wire its
`alarm_sns_topic_arn`" to explicitly reference the variable as a list (e.g.,
"Wire the `alarm_sns_topic_arn` variable (a list of SNS topic ARNs) to the
monitoring module's SNS topic(s)") so readers understand multiple ARNs are
supported.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9b68bfde-1c56-441d-906b-d17868055e22
📒 Files selected for processing (10)
internal/database/postgres/migrations/000067_analytics_snapshot_correctness.up.sqlinternal/database/postgres/migrations/migrate.gointernal/database/postgres/migrations/migrate_autoheal_test.gointernal/database/postgres/migrations/migrate_idempotency_test.gointernal/server/app.gointernal/server/app_test.gospecs/migration-resilience.mdterraform/modules/compute/aws/lambda/migration-alarm.tfterraform/modules/compute/aws/lambda/outputs.tfterraform/modules/compute/aws/lambda/variables.tf
Add a static guard test that reads every *.up.sql / *.down.sql under the migrations dir and fails if any file contains a statement PostgreSQL refuses to run inside a transaction block (CREATE/DROP INDEX CONCURRENTLY, REINDEX, VACUUM, ALTER SYSTEM, CREATE/DROP DATABASE, CREATE TABLESPACE, REFRESH MATERIALIZED VIEW CONCURRENTLY). golang-migrate runs each migration file in one implicit transaction, so forbidding non-transactional statements guarantees a failed migration rolls back atomically with no partial schema. This is orthogonal to the dirty-flag auto-heal in this PR; it closes the partial-apply hole. The scanner strips SQL comments and dollar-quoted function/DO bodies before matching, so CONCURRENTLY inside a stored function (e.g. migration 000003's refresh_savings_materialized_views) is correctly treated as benign. Files may opt out via the marker comment "-- migrate:no-transaction"; such files are skipped with a loud warning and MUST be applied via the deploy-time runner (issue #1125), never the in-request auto-migrate path. Also address the CodeRabbit nitpick on specs/migration-resilience.md: clarify that alarm_sns_topic_arn is a list of SNS topic ARNs.
|
Pushed 1. Transactional-migration enforcement test ( Static guard that reads every
2. Addressed the CR nitpick on @coderabbitai review |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Fetch the PR diff for the latest commit
gh pr view 1124 --repo LeanerCloud/CUDly --json headRefName,headRefOid,commits
# Get the diff of the latest commit
gh api repos/LeanerCloud/CUDly/commits/7ff2c77c6 --jq '.files[] | {filename: .filename, patch: .patch}' 2>/dev/null || true
# Also get the full file content of the new test
gh api repos/LeanerCloud/CUDly/contents/internal/database/postgres/migrations/migration_transactional_test.go?ref=7ff2c77c6 --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || trueLength of output: 21942
|
| Missing | Notes |
|---|---|
DROP TABLESPACE |
CREATE TABLESPACE is listed; DROP TABLESPACE is equally non-transactional |
CLUSTER |
Non-transactional (without CONCURRENTLY); rare in migrations but should be covered for completeness |
Suggested additions to forbiddenPatterns:
{"DROP TABLESPACE", regexp.MustCompile(`(?is)\bdrop\s+tablespace\b`)},
{"CLUSTER", regexp.MustCompile(`(?is)\bcluster\b`)},Minor nit — dollarTagRe regex breadth
\$[A-Za-z0-9_]*\$ technically matches digit-only tags like $1$, which aren't valid PostgreSQL dollar-quote tags (PostgreSQL requires the tag to start with a letter or _). In practice this is harmless (no real migration would have $1$ at the top level), but tightening it avoids potential confusion:
var dollarTagRe = regexp.MustCompile(`\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$`)Minor nit — sanitizeSQL ordering comment
The ordering (block comments → line comments → dollar bodies) is intentional and non-obvious. A one-line comment would make it self-documenting:
// Strip in this order: block comments first (so /* -- */ doesn't seed a line
// comment), then line comments, then dollar-quoted bodies.specs/migration-resilience.md — ✅ clean
The alarm_sns_topic_arn list-clarification reads well.
None of the above are blockers. The transactional-guard approach is a solid addition that closes the partial-apply hole; the CLUSTER / DROP TABLESPACE omissions are the only ones worth a follow-up if you want the coverage to be exhaustive.
✅ Action performed
Review finished.
Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.
…to unblock deploys (#1130) #1124's migration-failure metric filter requires logs:PutMetricFilter, which the CI deploy SA lacks until the ci-cd-permissions bootstrap grants it, so every terraform apply 403s and blocks all deploys (bootstrap-vs-runtime IAM split, see CLAUDE.md CI/CD IAM). Gate the metric filter and alarm behind a new enable_migration_alarm variable (default false) so deploys succeed; enable it later by re-applying the bootstrap with the metric-filter permissions and setting enable_migration_alarm=true.
Why
Migrations run lazily via
golang-migrateon DB connect, bounded bydefaultMigrationsTimeout(was 20s,internal/server/app.go). Underconcurrent Lambda cold-starts a slow migration (e.g. an index build on a
growing table) blew the 20s bound mid-run,
golang-migrateleftschema_migrations.dirty = true, and because dirty is terminal everylater boot fail-opened ("Migration failed - app continuing with existing
schema",
app.go:617) and served 500s on any query needing the unappliedcolumns. This caused a prod outage (migrations 070-073 never applied;
purchase_delay_hours/revocation_window_closes_at/executed_by_user_idmissing). Recovery today is the manualCUDLY_FORCE_MIGRATION_VERSIONenv var.Root cause = 20s bound too tight + terminal dirty state with no
automated recovery.
What
Three smallest-blast-radius changes:
1. Default-on dirty auto-heal
internal/database/postgres/migrations/migrate.go— newmaybeAutoHealDirtyruns after
maybeForceMigrationVersionand beforeUp(). When the DB isdirty it logs loudly,
Force()s the current recorded version to clear thedirty flag, then
Up()re-applies the pending tail, so a cold startself-recovers instead of staying broken until a manual force (the
multi-hour outage shape). Enabled by default;
CUDLY_MIGRATION_AUTOHEAL=falseis the escape hatch for environments whose migrations aren't idempotent.
Two load-bearing safety properties:
seed migrations re-runs guards that raise on a second run (e.g.
000059errors "a group named Purchaser already exists with a different id" because
000064already relocated it).Force(current)+Up()is the only safe shape.broken migration file), the error propagates to
ensureDB, which fail-opens(existing behavior, untouched) and records the failure so the alarm fires —
never a crash-loop. A broken schema surfaces via
/health+ the alarm.CUDLY_FORCE_MIGRATION_VERSIONstill takes precedence (it runs first andleaves the row clean). Relies on migrations being idempotent — a documented
invariant now enforced by a test.
2. Raise the default migration timeout
internal/server/app.go—defaultMigrationsTimeout20s -> 120s, stillwell under the 300s Lambda hard limit, so a normal index build can't be killed
mid-run.
CUDLY_MIGRATION_TIMEOUToverride unchanged.3. CloudWatch migration-failure alarm
terraform/modules/compute/aws/lambda/migration-alarm.tf— log metric filteron the
"Migration failed"log line + alarm. Because the app fail-opens, thebuilt-in
AWS/LambdaErrorsmetric stays clean during a broken migration;this alarm is what surfaces it. Notification target is optional via
alarm_sns_topic_arn(default[]) — wire it to the monitoring module's SNStopic to get paged; no new SNS infra invented. Mirrors the existing
application_errorsalarm + metric-filter patterns.terraform fmt+validateclean.Bonus: latent migration bug fixed (surfaced by the idempotency test)
Migration
000067widenedsavings_snapshots.account_idbefore droppingthe materialized views that depend on it, so the full stack failed to reach
head on a fresh DB ("cannot alter type of a column used by a view"). Moved the
view DROPs ahead of the
ALTER COLUMN. This is exactly the class of latentmigration failure this PR is about. It moved the migrations integration suite
from 4/37 → 28/42 passing; the remaining 14 failures are pre-existing and
unrelated (
users.rolecolumn, savings-plans split assertions) — filedseparately.
Tests
TestMigrations_FullStackIdempotent(integration): migrate to head twice;the second run is a clean no-op (
ErrNoChange) and not dirty. This caughtand drove the
000067fix.TestMigrations_AutoHealDirty(integration): a dirty DB self-heals bydefault (and with
=true);CUDLY_MIGRATION_AUTOHEAL=falsedisables it soa dirty DB errors with the dirty flag left intact (escape hatch).
TestResolveMigrationsTimeout(unit): default is 120s, honors the override,falls back to the default on invalid / non-positive input.
All three new tests pass.
go build ./...andgo test ./internal/database/... ./internal/server/...green.Follow-up
The structural fix — moving migrations off the request/connect path into a
one-shot deploy-time runner — is tracked separately (see the linked
follow-up issue). This PR is the in-place hardening.
Summary by CodeRabbit
New Features
Improvements
Documentation
Tests