✨ 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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user