From 2d49e5b68caacdc337b7a67b17fcab05746c6842 Mon Sep 17 00:00:00 2001 From: Tadas Antanavicius Date: Tue, 30 Sep 2025 06:43:26 -0700 Subject: [PATCH] fix: prevent NULL status values during migration 008 (#587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fixes migration 008 failing with "column contains null values" error - Adds COALESCE protection to handle 126 servers without status fields - Includes test utilities to reproduce and verify fixes with production data ## Problem Migration 008 was failing in production because: - 126 out of 781 servers have no `status` field in their JSON - When extracting `rec.value->>'status'`, these became NULL values - The `ALTER TABLE servers ALTER COLUMN status SET NOT NULL` constraint then failed ## Solution 1. **Prevent NULLs during migration**: Use COALESCE to provide 'active' default when status is NULL/empty 2. **Enhanced debugging**: Added RAISE NOTICE statements to log problematic values 3. **Safety check**: Double-check that throws exception if any NULLs remain 4. **Test utilities**: Created mirror_data tools to test migrations with real production data ## Testing Created and used `scripts/mirror_data/` tools to: - Fetch all 781 servers from production API - Load them into a test database - Verify the migration fix works correctly ### Results: - **Before fix**: 126 NULL status values created during migration - **After fix**: 0 NULL status values, all servers have valid status - Migration completes successfully with NOT NULL constraint ## Test plan - [x] Tested locally with production data using mirror_data tools - [x] Verified 0 NULL values after migration (vs 126 before) - [x] Confirmed NOT NULL constraint adds successfully - [ ] Deploy to staging and verify migration succeeds - [ ] Monitor production deployment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- .../008_separate_official_metadata.sql | 38 ++- scripts/mirror_data/.gitignore | 2 + scripts/mirror_data/README.md | 237 ++++++++++++++++++ scripts/mirror_data/fetch_production_data.go | 87 +++++++ scripts/mirror_data/load_production_data.go | 144 +++++++++++ 5 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 scripts/mirror_data/.gitignore create mode 100644 scripts/mirror_data/README.md create mode 100644 scripts/mirror_data/fetch_production_data.go create mode 100644 scripts/mirror_data/load_production_data.go diff --git a/internal/database/migrations/008_separate_official_metadata.sql b/internal/database/migrations/008_separate_official_metadata.sql index a0b4af4..ad01864 100644 --- a/internal/database/migrations/008_separate_official_metadata.sql +++ b/internal/database/migrations/008_separate_official_metadata.sql @@ -38,7 +38,10 @@ BEGIN -- Note: status is at top level in old format, not in official_meta UPDATE servers SET - status = rec.value->>'status', + status = COALESCE( + NULLIF(NULLIF(rec.value->>'status', ''), 'null'), + 'active' + ), published_at = COALESCE((official_meta->>'publishedAt')::TIMESTAMP WITH TIME ZONE, NOW()), updated_at = COALESCE((official_meta->>'updatedAt')::TIMESTAMP WITH TIME ZONE, NOW()), is_latest = COALESCE((official_meta->>'isLatest')::BOOLEAN, true) @@ -47,7 +50,10 @@ BEGIN -- Handle records without official metadata (set defaults) UPDATE servers SET - status = rec.value->>'status', + status = COALESCE( + NULLIF(NULLIF(rec.value->>'status', ''), 'null'), + 'active' + ), published_at = NOW(), updated_at = NOW(), is_latest = true @@ -84,6 +90,23 @@ DROP FUNCTION migrate_official_metadata(); -- Safety check: Normalize invalid status values before adding NOT NULL constraints -- This handles: NULL (missing field), empty string, string "null", and any invalid values +-- First, log how many records have problematic status values for debugging +DO $$ +DECLARE + null_count INTEGER; + empty_count INTEGER; + invalid_count INTEGER; +BEGIN + SELECT COUNT(*) INTO null_count FROM servers WHERE status IS NULL; + SELECT COUNT(*) INTO empty_count FROM servers WHERE status = '' OR status = 'null'; + SELECT COUNT(*) INTO invalid_count FROM servers WHERE status IS NOT NULL AND status NOT IN ('active', 'deprecated', 'deleted', '', 'null'); + + IF null_count > 0 OR empty_count > 0 OR invalid_count > 0 THEN + RAISE NOTICE 'Status cleanup: NULL=%, empty/null string=%, invalid=%', null_count, empty_count, invalid_count; + END IF; +END $$; + +-- Fix all problematic status values UPDATE servers SET status = 'active' WHERE status IS NULL @@ -91,6 +114,17 @@ WHERE status IS NULL OR status = 'null' OR status NOT IN ('active', 'deprecated', 'deleted'); +-- Double-check that no NULLs remain (this should never fail after the above) +DO $$ +DECLARE + remaining_nulls INTEGER; +BEGIN + SELECT COUNT(*) INTO remaining_nulls FROM servers WHERE status IS NULL; + IF remaining_nulls > 0 THEN + RAISE EXCEPTION 'Still have % NULL status values after cleanup!', remaining_nulls; + END IF; +END $$; + UPDATE servers SET published_at = NOW() WHERE published_at IS NULL; -- Safety check: Ensure updated_at is never NULL diff --git a/scripts/mirror_data/.gitignore b/scripts/mirror_data/.gitignore new file mode 100644 index 0000000..8768ebf --- /dev/null +++ b/scripts/mirror_data/.gitignore @@ -0,0 +1,2 @@ +# Ignore downloaded production data +production_servers.json \ No newline at end of file diff --git a/scripts/mirror_data/README.md b/scripts/mirror_data/README.md new file mode 100644 index 0000000..5735c8d --- /dev/null +++ b/scripts/mirror_data/README.md @@ -0,0 +1,237 @@ +# Mirror Production Data + +> **Note:** These tools were created by Claude Code as a simple way to kick the tires on data migrations. +> They are not intended for production use. + +Tools to fetch and load production registry data for local testing and migration debugging. + +## Overview + +These scripts help you: +1. Fetch all server data from the production registry API +2. Load it into a local PostgreSQL database +3. Test migrations against real production data + +## Prerequisites + +- Go 1.24.x +- PostgreSQL (via Docker or local installation) +- Required Go packages: + ```bash + go get github.com/google/uuid github.com/lib/pq + ``` + +## Usage + +### 1. Fetch Production Data + +```bash +# From the project root directory +go run scripts/mirror_data/fetch_production_data.go +``` + +This will: +- Fetch all servers from https://registry.modelcontextprotocol.io/v0/servers +- Handle pagination automatically +- Save data to `scripts/mirror_data/production_servers.json` +- Be respectful to the API with rate limiting + +Output: `production_servers.json` containing all server records + +### 2. Set Up Test Database + +Start a PostgreSQL container for testing: + +```bash +docker run -d --name test-postgres \ + -p 5433:5432 \ + -e POSTGRES_PASSWORD=testpass \ + -e POSTGRES_DB=registry_test \ + postgres:16 +``` + +Or reset an existing database: + +```bash +docker exec test-postgres psql -U postgres postgres -c "DROP DATABASE IF EXISTS registry_test;" +docker exec test-postgres psql -U postgres postgres -c "CREATE DATABASE registry_test;" +``` + +### 3. Load Production Data + +```bash +# From the project root directory +go run scripts/mirror_data/load_production_data.go +``` + +This will: +1. Connect to PostgreSQL at `localhost:5433` +2. Apply migrations up to a configurable point (default: migration 007) +3. Load all production server data +4. Analyze the data and report statistics +5. Show sample servers with NULL status values + +To test a different migration, edit `maxMigration` in the script (line 24) + +### 4. Test Migrations + +After loading the data, you can test migrations against real production data. + +#### Testing a Single Migration + +```bash +# Test migration 008 +cat internal/database/migrations/008_separate_official_metadata.sql | \ + docker exec -i test-postgres psql -U postgres -d registry_test +``` + +#### Debugging Failed Migrations + +1. **Check the error output:** +```bash +# Run migration and capture all output +cat internal/database/migrations/008_separate_official_metadata.sql | \ + docker exec -i test-postgres psql -U postgres -d registry_test 2>&1 | \ + grep -E "(ERROR|NOTICE|WARNING)" +``` + +2. **Inspect data before migration:** +```bash +# Check for NULL values that might cause issues +docker exec test-postgres psql -U postgres -d registry_test -c \ + "SELECT COUNT(*) FROM servers WHERE value->>'status' IS NULL;" + +# Find sample problematic records +docker exec test-postgres psql -U postgres -d registry_test -c \ + "SELECT value->>'name', value->>'version' FROM servers \ + WHERE value->>'status' IS NULL LIMIT 5;" +``` + +3. **Test partial migrations:** +```bash +# Extract and run only part of a migration to isolate issues +# For example, run only up to the NOT NULL constraint: +docker exec test-postgres psql -U postgres -d registry_test <>'status' IS NULL THEN 1 END) as null_status, + COUNT(CASE WHEN value->>'status' = '' THEN 1 END) as empty_status, + COUNT(CASE WHEN value->>'status' = 'null' THEN 1 END) as string_null_status, + COUNT(CASE WHEN value->>'status' = 'active' THEN 1 END) as active_status, + COUNT(CASE WHEN value->>'status' = 'deprecated' THEN 1 END) as deprecated_status, + COUNT(CASE WHEN value->>'status' = 'deleted' THEN 1 END) as deleted_status + FROM servers + `) + if err != nil { + log.Fatal("Failed to analyze data:", err) + } + defer rows.Close() + + if rows.Next() { + var total, nullStatus, emptyStatus, stringNullStatus, activeStatus, deprecatedStatus, deletedStatus int + rows.Scan(&total, &nullStatus, &emptyStatus, &stringNullStatus, &activeStatus, &deprecatedStatus, &deletedStatus) + + fmt.Printf(" Total servers: %d\n", total) + fmt.Printf(" NULL status: %d\n", nullStatus) + fmt.Printf(" Empty status: %d\n", emptyStatus) + fmt.Printf(" 'null' string status: %d\n", stringNullStatus) + fmt.Printf(" 'active' status: %d\n", activeStatus) + fmt.Printf(" 'deprecated' status: %d\n", deprecatedStatus) + fmt.Printf(" 'deleted' status: %d\n", deletedStatus) + fmt.Printf(" Other/Invalid: %d\n", total-nullStatus-emptyStatus-stringNullStatus-activeStatus-deprecatedStatus-deletedStatus) + } + + // Print sample servers with no status + fmt.Println("\nSample servers with NULL status:") + rows, err = db.Query(` + SELECT value->>'name', value->>'version' + FROM servers + WHERE value->>'status' IS NULL + LIMIT 5 + `) + if err == nil { + defer rows.Close() + for rows.Next() { + var name, version string + rows.Scan(&name, &version) + fmt.Printf(" - %s@%s\n", name, version) + } + } + + fmt.Printf("\nDatabase is ready for testing migration %03d!\n", maxMigration+1) +}