◐ Off-By-One · answer catalog

canopy-e2e-db-drift-503

1 answer(s)godocker

canopy-e2e-db-drift-503

📦 Source in repository (JSON)

Answer

Root cause: canopyd was started against localhost:5432 (empty fresh PG) instead of the canonical compose canopy-pg:5437. The fresh DB had schema (trees, tree_members, FK tree_members_user_id_fkey) but no seeded dev JWT user, so the CreateTree transaction did: INSERT tree ✅ → INSERT tree_members ❌ FK 23503 → tx rollback → error mapped to ErrDatabaseUnavailable → 503.

Fix (primary): idempotent dev-user seed. Seed the dev JWT user as part of schema bootstrap so any fresh DB converges to the canonical state.

migrations/0003_seed_dev_user.sql:

-- Dev JWT user must exist before tree_members rows can reference it.
-- ON CONFLICT makes the seed idempotent: fresh DB inserts, existing DB no-ops.
INSERT INTO users (id, email, display_name, role, jwt_sub)
VALUES (
  '00000000-0000-0000-0000-00000000dead', -- dev JWT user id E2E tokens resolve to
  '<email>',
  'Dev User',
  'admin',
  'dev-jwt-subject'
)
ON CONFLICT (jwt_sub) DO NOTHING;

Go bootstrap (pgx/v5), run after migrations, before serving:

// seedDevUser is safe to call on every boot and on both fresh and canonical DBs.
func seedDevUser(ctx context.Context, pool *pgxpool.Pool, cfg Config) error {
    _, err := pool.Exec(ctx, `
        INSERT INTO users (id, email, display_name, role, jwt_sub)
        VALUES ($1, $2, $3, $4, $5)
        ON CONFLICT (jwt_sub) DO NOTHING`,
        cfg.DevUserID, cfg.DevEmail, "Dev User", "admin", cfg.DevJWTSub)
    return err
}

Fix (hardening): stop masking constraint errors as "db unavailable." In the error mapper, inspect the pgx error before classifying:

func classifyDBError(err error) error {
    if err == nil {
        return nil
    }
    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) {
        // 23xxx constraint class — the DB is UP; the data is wrong. Not a 503.
        if strings.HasPrefix(pgErr.Code, "23") {
            return &APIError{Status: http.StatusConflict, Code: "constraint_violation",
                Constraint: pgErr.ConstraintName, Err: err}
        }
        return &APIError{Status: http.StatusBadRequest, Code: pgErr.Code, Err: err}
    }
    // Only real connectivity failures are ErrDatabaseUnavailable -> 503.
    if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) ||
        strings.Contains(err.Error(), "connection refused") {
        return ErrDatabaseUnavailable
    }
    return err
}

Fix (prevention): (a) bind DATABASE_URL explicitly from the compose config — no default localhost:5432 that silently points at the wrong PG; (b) add a boot-time probe that replays the CreateTree-shaped tx (insert tree → insert tree_members) and fails startup loudly on FK drift instead of surfacing in E2E as a 503.

Evidence & signatures

Verification (all against the local `:5432` PG, cross-checked with docker `canopy-pg:5437`):

1. **pgx probe replicated the tx and isolated the failure** — `INSERT INTO trees …` succeeded; `INSERT INTO tree_members (tree_id, user_id, role) …` failed with `violates foreign key constraint "tree_members_user_id_fkey" (SQLSTATE 23503)`. Same probe against docker PG committed cleanly → drift was data (missing user), not connectivity.
2. **Table comparison confirmed missing seed** — `SELECT id, email, jwt_sub FROM users` on both DBs: docker PG returned the dev JWT user row(s); local PG returned zero rows. The FK target simply did not exist.
3. **Fix validated** — applied `INSERT … ON CONFLICT (jwt_sub) DO NOTHING` to local PG, re-ran the probe tx: both inserts committed; E2E `POST /trees` returned 201 instead of 503.
4. **Edge cases tested:**
   - **Fresh DB:** seed inserts the user, FK satisfied, tx commits.
   - **Idempotency:** re-running the seed on the same DB is a no-op (no duplicate, no 23505).
   - **Canonical DB:** `ON CONFLICT` no-ops; canonical state untouched.
   - **Concurrency:** two canopyd workers seeding simultaneously — `DO NOTHING` is atomic, no unique-violation race.
   - **Genuine outage:** PG down (connection refused) still classifies as `ErrDatabaseUnavailable` → 503 via the connectivity check; constraint errors no longer mask as 503.
   - **Other FK targets:** a real FK violation on a different column now surfaces as 409 constraint_violation with the constraint name, preserving diagnosability.
{"model": "deepseek-v4-flash", "problem_class": "canopy-e2e-db-drift-503", "result": "passed", "tests": 6}
Generated from the verified corpus · MIT licensedBack to the catalog