feat(bdd): parallel-safe schema-per-package isolation (T12 stage 2/2)

Re-enables BDD_SCHEMA_ISOLATION=true with the foundation from PR #34
(NewPostgresRepositoryFromDSN). Achieves ~2.85x speedup on the BDD
test suite by running feature packages in parallel.

ARCHITECTURE

When BDD_SCHEMA_ISOLATION=true, each test package (process) gets its
own isolated PostgreSQL schema:
  1. testserver.Start() generates a deterministic schema name per FEATURE
  2. CREATE SCHEMA <name>
  3. Open a per-package gorm.DB with DSN search_path=<name>
  4. AutoMigrate runs in the isolated schema (creates users table)
  5. Build a per-package server.Server with this isolated repo via
     server.NewServerWithUserRepo
  6. Stop() drops the schema + closes the per-package pool

Packages then run in parallel (default Go test parallelism) without
contention because each has its own schema + connection pool.

CHANGES

- pkg/server/server.go : NEW factory NewServerWithUserRepo(cfg, ctx,
  userRepo, userService) that injects a per-test repo. Existing NewServer
  becomes a thin wrapper.
- pkg/bdd/testserver/server.go : Start() chooses isolated mode based on
  BDD_SCHEMA_ISOLATION env var. Stop() drops schema + closes pool.
- pkg/user/postgres_repository.go : Exec(sql) helper for the schema
  lifecycle (CREATE/DROP) used by testserver.
- scripts/run-bdd-tests.sh : -p 1 only when BDD_SCHEMA_ISOLATION!=true.
  When true, default Go parallelism (~ NumCPU packages concurrent).
- .gitea/workflows/ci-cd.yaml : exports BDD_SCHEMA_ISOLATION=true.
- adr/0025-bdd-scenario-isolation-strategies.md : Status to "Implemented".

VALIDATION

5x AuthBDD with isolation: 5/5 PASS, public.users count=0 after runs.

Local benchmark on the full features/... suite:
- Sequential -p 1 (no isolation):     12.87s
- Parallel + isolation (this PR):      4.51s
- Speedup: 2.85x

🤖 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:41:20 +02:00
parent 4452620df8
commit 54c1eefb6c
6 changed files with 116 additions and 24 deletions

View File

@@ -48,11 +48,13 @@ type Server struct {
port int
baseURL string
db *sql.DB
authService user.AuthService // Reference to auth service for cleanup
cacheService cache.Service // Reference to cache service for cleanup
schemaMutex sync.Mutex // Protects schema operations
currentSchema string // Current schema being used
originalSearchPath string // Original search_path to restore
authService user.AuthService // Reference to auth service for cleanup
cacheService cache.Service // Reference to cache service for cleanup
isolatedRepo *user.PostgresRepository // Per-package isolated repo (BDD_SCHEMA_ISOLATION=true)
isolatedSchemaName string // Per-package schema name to drop on Stop()
schemaMutex sync.Mutex // Protects schema operations
currentSchema string // Current schema being used
originalSearchPath string // Original search_path to restore
}
// getDatabaseHost returns the database host from environment variable or defaults to localhost
@@ -148,9 +150,55 @@ func (s *Server) Start() error {
// This is the ONLY place where we check env vars for v2 configuration
v2Enabled := s.shouldEnableV2()
// Create real server instance from pkg/server
// Create real server instance from pkg/server.
// When BDD_SCHEMA_ISOLATION=true, each test package (process) gets its own
// isolated PostgreSQL schema with its own connection pool + migrations.
// This makes `go test ./features/...` parallel-safe because each feature
// package runs in its own process and gets its own schema.
cfg := createTestConfig(s.port, v2Enabled)
realServer := server.NewServer(cfg, context.Background())
var realServer *server.Server
if isSchemaIsolationEnabled() {
feature := os.Getenv("FEATURE")
if feature == "" {
feature = "bdd"
}
schemaName := generateSchemaName(feature, "package_root")
log.Info().Str("schema", schemaName).Str("feature", feature).Msg("ISOLATION: Building per-package isolated repo")
// Connect a default repo briefly just to CREATE SCHEMA (uses cfg from env vars)
bootstrapRepo, err := user.NewPostgresRepository(cfg)
if err != nil {
return fmt.Errorf("ISOLATION bootstrap repo failed: %w", err)
}
// Drop + recreate to ensure clean slate per process
_ = bootstrapRepo.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", schemaName))
if err := bootstrapRepo.Exec(fmt.Sprintf("CREATE SCHEMA %s", schemaName)); err != nil {
bootstrapRepo.Close()
return fmt.Errorf("ISOLATION CREATE SCHEMA failed: %w", err)
}
bootstrapRepo.Close()
// Build the per-package isolated repo (runs migrations in the new schema)
dsn := user.BuildSchemaIsolatedDSN(cfg, schemaName)
isolatedRepo, err := user.NewPostgresRepositoryFromDSN(cfg, dsn)
if err != nil {
return fmt.Errorf("ISOLATION isolated repo failed: %w", err)
}
s.isolatedRepo = isolatedRepo
s.isolatedSchemaName = schemaName
// Build user service backed by the isolated repo
jwtConfig := user.JWTConfig{
Secret: cfg.GetJWTSecret(),
ExpirationTime: time.Hour * 24,
Issuer: "dance-lessons-coach",
}
isolatedUserService := user.NewUserService(isolatedRepo, jwtConfig, cfg.GetAdminMasterPassword())
realServer = server.NewServerWithUserRepo(cfg, context.Background(), isolatedRepo, isolatedUserService)
} else {
realServer = server.NewServer(cfg, context.Background())
}
// Store auth service for cleanup
s.authService = realServer.GetAuthService()
@@ -639,6 +687,21 @@ func (s *Server) getCurrentSearchPath() (string, error) {
}
func (s *Server) Stop() error {
// Cleanup the per-package isolated schema + close its pool, if any.
// (BDD_SCHEMA_ISOLATION=true path - see Start().)
if s.isolatedRepo != nil {
if s.isolatedSchemaName != "" {
if err := s.isolatedRepo.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", s.isolatedSchemaName)); err != nil {
log.Warn().Err(err).Str("schema", s.isolatedSchemaName).Msg("ISOLATION: failed to drop schema on Stop")
}
}
if err := s.isolatedRepo.Close(); err != nil {
log.Warn().Err(err).Msg("ISOLATION: failed to close isolated repo")
}
s.isolatedRepo = nil
s.isolatedSchemaName = ""
}
if s.httpServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()