1527 lines
43 KiB
Go
1527 lines
43 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"service/internal/infrastructure/config"
|
|
"service/pkg/logger"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/stdlib"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/lib/pq"
|
|
|
|
// Driver GORM di-import secara eksplisit untuk Migration
|
|
_ "github.com/lib/pq" // Driver PostgreSQL
|
|
"gorm.io/driver/mysql"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/driver/sqlserver"
|
|
"gorm.io/gorm"
|
|
gormlogger "gorm.io/gorm/logger"
|
|
|
|
"go.mongodb.org/mongo-driver/mongo"
|
|
"go.mongodb.org/mongo-driver/mongo/options"
|
|
)
|
|
|
|
type DatabaseType string
|
|
|
|
const (
|
|
Postgres DatabaseType = "postgres"
|
|
MySQL DatabaseType = "mysql"
|
|
SQLServer DatabaseType = "sqlserver"
|
|
SQLite DatabaseType = "sqlite"
|
|
MongoDB DatabaseType = "mongodb"
|
|
)
|
|
|
|
type Service interface {
|
|
Health() map[string]map[string]string
|
|
// Metode utama sekarang mengembalikan *gorm.DB
|
|
GetGormDB(name string) (*gorm.DB, error)
|
|
// Metode untuk mendapatkan koneksi mentah jika diperlukan
|
|
GetDB(name string) (*sql.DB, error)
|
|
GetSQLXDB(name string) (*sqlx.DB, error)
|
|
GetMongoClient(name string) (*mongo.Client, error)
|
|
GetReadDB(name string) (*sql.DB, error)
|
|
Close() error
|
|
ListDBs() []string
|
|
GetDBType(name string) (DatabaseType, error)
|
|
ListenForChanges(ctx context.Context, dbName string, channels []string, callback func(string, string)) error
|
|
NotifyChange(dbName, channel, payload string) error
|
|
GetPrimaryDB(name string) (*sql.DB, error)
|
|
ExecuteQuery(ctx context.Context, dbName string, query string, args ...interface{}) (*sql.Rows, error)
|
|
ExecuteQueryRow(ctx context.Context, dbName string, query string, args ...interface{}) *sql.Row
|
|
Exec(ctx context.Context, dbName string, query string, args ...interface{}) (sql.Result, error)
|
|
// Method untuk menjalankan migrasi database otomatis
|
|
RegisterModel(models ...interface{}) // Method baru untuk registrasi dinamis
|
|
Migrate() error
|
|
// Method untuk mendapatkan informasi semua database yang terkoneksi
|
|
GetAllDatabasesInfo() map[string]interface{}
|
|
}
|
|
|
|
type service struct {
|
|
// GORM DB adalah sumber utama
|
|
gormDatabases map[string]*gorm.DB
|
|
// Map untuk menyimpan koneksi mentah yang diekstrak dari GORM
|
|
sqlDatabases map[string]*sql.DB
|
|
sqlxDatabases map[string]*sqlx.DB
|
|
mongoClients map[string]*mongo.Client
|
|
readReplicas map[string][]*sql.DB
|
|
configs map[string]config.DatabaseConfig
|
|
readConfigs map[string][]config.DatabaseConfig
|
|
mu sync.RWMutex
|
|
readBalancer map[string]int
|
|
listeners map[string]*pq.Listener // Menggunakan sql.Listener dari GORM/driver
|
|
listenersMu sync.RWMutex
|
|
modelsToMigrate []interface{} // Menyimpan daftar entitas yang akan dimigrasi
|
|
}
|
|
|
|
var (
|
|
dbManager *service
|
|
once sync.Once
|
|
)
|
|
|
|
// gormLoggerAdapter adalah adapter untuk menghubungkan logger kita dengan GORM
|
|
type gormLoggerAdapter struct {
|
|
logger.Logger
|
|
loggerConfig logger.Config
|
|
Config gormlogger.Config
|
|
}
|
|
|
|
// NewGormLoggerAdapter membuat instance baru dari adapter
|
|
func NewGormLoggerAdapter(l logger.Logger) gormlogger.Interface {
|
|
// Kita tidak perlu menyimpan logger.Config di sini, karena logger kita sudah diinisialisasi.
|
|
// Kita hanya perlu menentukan log level untuk GORM.
|
|
// Anda bisa membuat ini lebih dinamis dengan membaca dari config jika perlu.
|
|
// Untuk sekarang, kita set ke Info agar semua query terlihat.
|
|
logLevel := gormlogger.Info
|
|
|
|
return &gormLoggerAdapter{
|
|
Logger: l,
|
|
// Konfigurasi logger GORM
|
|
Config: gormlogger.Config{
|
|
SlowThreshold: 200 * time.Millisecond,
|
|
LogLevel: logLevel, // Gunakan level yang sudah ditentukan
|
|
IgnoreRecordNotFoundError: false, // Jangan abaikan error "not found"
|
|
Colorful: false, // Nonaktifkan warna karena kita menggunakan logger terstruktur
|
|
},
|
|
}
|
|
}
|
|
|
|
// LogMode menetapkan level log dan mengembalikan instance baru
|
|
func (l *gormLoggerAdapter) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
|
|
// Buat instance adapter baru dengan level yang berbeda
|
|
newLogger := *l
|
|
newLogger.Config.LogLevel = level
|
|
return &newLogger
|
|
}
|
|
|
|
// Info mencatat log info (jarang dipanggil GORM)
|
|
func (l *gormLoggerAdapter) Info(ctx context.Context, msg string, data ...interface{}) {
|
|
formattedMsg := fmt.Sprintf(msg, data...)
|
|
// Mencegah log notifikasi duplikat (dead notif) yang tidak berguna dari internal GORM
|
|
if strings.Contains(formattedMsg, "replacing callback") {
|
|
return
|
|
}
|
|
l.Logger.WithContext(ctx).Info(formattedMsg)
|
|
}
|
|
|
|
// Warn mencatat log warning
|
|
func (l *gormLoggerAdapter) Warn(ctx context.Context, msg string, data ...interface{}) {
|
|
l.Logger.WithContext(ctx).Warn(fmt.Sprintf(msg, data...))
|
|
}
|
|
|
|
// Error mencatat log error
|
|
func (l *gormLoggerAdapter) Error(ctx context.Context, msg string, data ...interface{}) {
|
|
l.Logger.WithContext(ctx).Error(fmt.Sprintf(msg, data...))
|
|
}
|
|
|
|
// Trace mencatat log trace (SQL query) - ini adalah metode terpenting
|
|
func (l *gormLoggerAdapter) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
|
|
elapsed := time.Since(begin)
|
|
sql, rows := fc()
|
|
|
|
// Siapkan fields menggunakan helper dari logger kita
|
|
fields := []logger.Field{
|
|
logger.Duration("duration_ms", elapsed),
|
|
logger.Int64("rows", rows),
|
|
logger.String("sql", sql),
|
|
}
|
|
|
|
// Tentukan level log dan pesan berdasarkan kondisi
|
|
switch {
|
|
case err != nil && l.Config.LogLevel >= gormlogger.Error:
|
|
// Jika ada error dan log level adalah Error atau lebih tinggi
|
|
l.Logger.WithContext(ctx).WithError(err).WithFields(fields...).Error("Database query failed")
|
|
case elapsed > l.Config.SlowThreshold && l.Config.LogLevel >= gormlogger.Warn:
|
|
// Jika query lambat dan log level adalah Warn atau lebih tinggi
|
|
l.Logger.WithContext(ctx).WithFields(fields...).Warn("Slow database query")
|
|
case l.Config.LogLevel == gormlogger.Info:
|
|
// Jika log level adalah Info, catat semua query sebagai debug
|
|
l.Logger.WithContext(ctx).WithFields(fields...).Debug("Database query executed")
|
|
}
|
|
}
|
|
|
|
func New(cfg *config.Config) Service {
|
|
once.Do(func() {
|
|
dbManager = &service{
|
|
gormDatabases: make(map[string]*gorm.DB),
|
|
sqlDatabases: make(map[string]*sql.DB),
|
|
sqlxDatabases: make(map[string]*sqlx.DB),
|
|
mongoClients: make(map[string]*mongo.Client),
|
|
readReplicas: make(map[string][]*sql.DB),
|
|
configs: make(map[string]config.DatabaseConfig),
|
|
readConfigs: make(map[string][]config.DatabaseConfig),
|
|
readBalancer: make(map[string]int),
|
|
listeners: make(map[string]*pq.Listener),
|
|
}
|
|
|
|
logger.Default().Info("Initializing database service with GORM as primary abstraction...")
|
|
dbManager.loadFromConfig(cfg)
|
|
|
|
if _, exists := dbManager.configs["default"]; !exists {
|
|
logger.Default().Warn("No 'default' database configured in config")
|
|
}
|
|
|
|
for name, dbConfig := range dbManager.configs {
|
|
if err := dbManager.addDatabase(name, dbConfig); err != nil {
|
|
logger.Default().Error("Failed to connect to database", logger.String("db_name", name), logger.ErrorField(err))
|
|
} else {
|
|
logger.Default().Info("Successfully connected to database", logger.String("db_name", name))
|
|
}
|
|
}
|
|
|
|
for name, replicaConfigs := range dbManager.readConfigs {
|
|
for i, replicaConfig := range replicaConfigs {
|
|
if err := dbManager.addReadReplica(name, i, replicaConfig); err != nil {
|
|
logger.Default().Error("Failed to connect to read replica", logger.String("db_name", name), logger.Int("replica_index", i), logger.ErrorField(err))
|
|
} else {
|
|
logger.Default().Info("Successfully connected to read replica", logger.String("db_name", name), logger.Int("replica_index", i))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
return dbManager
|
|
}
|
|
|
|
func (s *service) loadFromConfig(cfg *config.Config) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for name, dbConfig := range cfg.Databases {
|
|
s.configs[name] = dbConfig
|
|
}
|
|
|
|
for name, replicaConfigs := range cfg.ReadReplicas {
|
|
s.readConfigs[name] = replicaConfigs
|
|
}
|
|
}
|
|
|
|
// GetGormDB sekarang menjadi metode utama dan lebih sederhana
|
|
func (s *service) GetGormDB(name string) (*gorm.DB, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
db, exists := s.gormDatabases[name]
|
|
if !exists {
|
|
return nil, fmt.Errorf("database %s not found", name)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// GetDB mengekstrak *sql.DB dari GORM
|
|
func (s *service) GetDB(name string) (*sql.DB, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
db, exists := s.sqlDatabases[name]
|
|
if !exists {
|
|
return nil, fmt.Errorf("database %s not found", name)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// GetSQLXDB mengekstrak *sqlx.DB dari GORM
|
|
func (s *service) GetSQLXDB(name string) (*sqlx.DB, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
db, exists := s.sqlxDatabases[name]
|
|
if !exists {
|
|
return nil, fmt.Errorf("database %s not found", name)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
func (s *service) GetAllDatabasesInfo() map[string]interface{} {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
dbInfo := make(map[string]interface{})
|
|
|
|
for name, gormDB := range s.gormDatabases {
|
|
dbConfig, exists := s.configs[name]
|
|
if !exists {
|
|
continue
|
|
}
|
|
|
|
info := gin.H{
|
|
"name": name,
|
|
"type": dbConfig.Type,
|
|
"host": dbConfig.Host,
|
|
"port": dbConfig.Port,
|
|
"database": dbConfig.Database,
|
|
// "username": dbConfig.Username,
|
|
"status": "connected",
|
|
}
|
|
|
|
// Coba dapatkan informasi tambahan dari database
|
|
sqlDB, err := gormDB.DB()
|
|
if err == nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
if err := sqlDB.PingContext(ctx); err == nil {
|
|
// Coba query spesifik berdasarkan tipe database
|
|
switch dbConfig.Type {
|
|
case "postgres":
|
|
var dbname, version, user string
|
|
if err := gormDB.Raw("SELECT current_database(), version(), current_user").Row().Scan(&dbname, &version, &user); err == nil {
|
|
// info["current_user"] = user
|
|
// info["database_name"] = dbname
|
|
info["database_version"] = version
|
|
}
|
|
case "mysql":
|
|
var dbname, version, user string
|
|
if err := gormDB.Raw("SELECT DATABASE(), VERSION(), USER()").Row().Scan(&dbname, &version, &user); err == nil {
|
|
// info["current_user"] = user
|
|
// info["database_name"] = dbname
|
|
info["database_version"] = version
|
|
}
|
|
case "sqlserver":
|
|
var dbname, version, user string
|
|
if err := gormDB.Raw("SELECT DB_NAME(), @@VERSION, SYSTEM_USER").Row().Scan(&dbname, &version, &user); err == nil {
|
|
// info["current_user"] = user
|
|
// info["database_name"] = dbname
|
|
info["database_version"] = version
|
|
}
|
|
case "sqlite":
|
|
var seq int
|
|
var name, file string
|
|
if err := gormDB.Raw("PRAGMA database_list").Row().Scan(&seq, &name, &file); err == nil {
|
|
// info["database_name"] = name
|
|
info["database_file"] = file
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dbInfo[name] = info
|
|
}
|
|
|
|
return dbInfo
|
|
}
|
|
|
|
func (s *service) addDatabase(name string, dbConfig config.DatabaseConfig) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
var gormDB *gorm.DB
|
|
var err error
|
|
|
|
dbType := DatabaseType(dbConfig.Type)
|
|
|
|
switch dbType {
|
|
case Postgres:
|
|
gormDB, err = s.openPostgresGORM(dbConfig)
|
|
case MySQL:
|
|
gormDB, err = s.openMySQLGORM(dbConfig)
|
|
case SQLServer:
|
|
gormDB, err = s.openSQLServerGORM(dbConfig)
|
|
case SQLite:
|
|
gormDB, err = s.openSQLiteGORM(dbConfig)
|
|
case MongoDB:
|
|
return s.addMongoDB(name, dbConfig)
|
|
default:
|
|
return fmt.Errorf("unsupported database type: %s", dbConfig.Type)
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to GORM for %s: %w", name, err)
|
|
}
|
|
|
|
// Ekstrak koneksi *sql.DB dari GORM
|
|
sqlDB, err := gormDB.DB()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get underlying sql.DB from GORM for %s: %w", name, err)
|
|
}
|
|
|
|
// Optimasi connection pool
|
|
s.optimizeConnectionPool(name, sqlDB, dbConfig)
|
|
|
|
// Simpan instance GORM dan koneksi mentahnya
|
|
s.gormDatabases[name] = gormDB
|
|
s.sqlDatabases[name] = sqlDB
|
|
|
|
// Buat dan simpan instance sqlx.DB
|
|
driverName := getDriverName(dbType)
|
|
s.sqlxDatabases[name] = sqlx.NewDb(sqlDB, driverName)
|
|
|
|
logger.Default().Info("Successfully connected and configured GORM for database", logger.String("db_name", name))
|
|
return nil
|
|
}
|
|
|
|
// Helper untuk mendapatkan nama driver sqlx
|
|
func getDriverName(dbType DatabaseType) string {
|
|
switch dbType {
|
|
case Postgres:
|
|
return "pgx" // Gunakan driver pgx untuk performa lebih baik
|
|
case MySQL:
|
|
return "mysql"
|
|
case SQLServer:
|
|
return "sqlserver"
|
|
case SQLite:
|
|
return "sqlite3"
|
|
default:
|
|
return string(dbType)
|
|
}
|
|
}
|
|
|
|
// --- Metode Koneksi GORM ---
|
|
|
|
func (s *service) openPostgresGORM(config config.DatabaseConfig) (*gorm.DB, error) {
|
|
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=%s TimeZone=Asia/Jakarta",
|
|
config.Host, config.Username, config.Password, config.Database, config.Port, config.SSLMode)
|
|
|
|
if config.ConnectTimeout > 0 {
|
|
dsn += fmt.Sprintf(" connect_timeout=%d", int(config.ConnectTimeout.Seconds()))
|
|
}
|
|
|
|
if config.StatementTimeout > 0 {
|
|
dsn += fmt.Sprintf(" statement_timeout=%d", int(config.StatementTimeout.Milliseconds()))
|
|
}
|
|
|
|
if config.Schema != "" {
|
|
dsn += fmt.Sprintf(" search_path=%s", config.Schema)
|
|
}
|
|
|
|
gormConfig := &gorm.Config{
|
|
Logger: NewGormLoggerAdapter(logger.Default()), // Atur level log GORM
|
|
}
|
|
|
|
if config.RequireSSL {
|
|
// Untuk SSL, kita mungkin perlu mengkonfigurasi tls.Config secara manual
|
|
// dan menggunakannya dalam DSN atau melalui pgx driver config
|
|
}
|
|
|
|
return gorm.Open(postgres.Open(dsn), gormConfig)
|
|
}
|
|
|
|
func (s *service) openMySQLGORM(config config.DatabaseConfig) (*gorm.DB, error) {
|
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
|
config.Username, config.Password, config.Host, config.Port, config.Database)
|
|
|
|
if config.ConnectTimeout > 0 {
|
|
dsn += fmt.Sprintf("&timeout=%s", config.ConnectTimeout.String())
|
|
} else if config.Timeout > 0 {
|
|
dsn += fmt.Sprintf("&timeout=%s", config.Timeout.String())
|
|
}
|
|
|
|
if config.ReadTimeout > 0 {
|
|
dsn += fmt.Sprintf("&readTimeout=%s", config.ReadTimeout.String())
|
|
}
|
|
if config.WriteTimeout > 0 {
|
|
dsn += fmt.Sprintf("&writeTimeout=%s", config.WriteTimeout.String())
|
|
}
|
|
|
|
if config.RequireSSL {
|
|
dsn += "&tls=true"
|
|
}
|
|
|
|
gormConfig := &gorm.Config{
|
|
Logger: NewGormLoggerAdapter(logger.Default()),
|
|
}
|
|
|
|
return gorm.Open(mysql.Open(dsn), gormConfig)
|
|
}
|
|
|
|
func (s *service) openSQLServerGORM(config config.DatabaseConfig) (*gorm.DB, error) {
|
|
dsn := fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s",
|
|
config.Username, config.Password, config.Host, config.Port, config.Database)
|
|
|
|
if config.ConnectTimeout > 0 {
|
|
dsn += fmt.Sprintf("&connection+timeout=%d", int(config.ConnectTimeout.Seconds()))
|
|
}
|
|
|
|
if config.RequireSSL {
|
|
dsn += "&encrypt=true"
|
|
if config.SSLRootCert != "" {
|
|
dsn += "&trustServerCertificate=false"
|
|
} else {
|
|
dsn += "&trustServerCertificate=true"
|
|
}
|
|
}
|
|
|
|
gormConfig := &gorm.Config{
|
|
Logger: NewGormLoggerAdapter(logger.Default()),
|
|
}
|
|
|
|
return gorm.Open(sqlserver.Open(dsn), gormConfig)
|
|
}
|
|
|
|
func (s *service) openSQLiteGORM(config config.DatabaseConfig) (*gorm.DB, error) {
|
|
dsn := config.Path
|
|
if config.Timeout > 0 {
|
|
dsn += fmt.Sprintf("?_busy_timeout=%d", config.Timeout.Milliseconds())
|
|
}
|
|
|
|
gormConfig := &gorm.Config{
|
|
Logger: NewGormLoggerAdapter(logger.Default()),
|
|
}
|
|
|
|
return gorm.Open(sqlite.Open(dsn), gormConfig)
|
|
}
|
|
|
|
// RegisterModel mendaftarkan entitas GORM secara dinamis untuk Automigrate
|
|
func (s *service) RegisterModel(models ...interface{}) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.modelsToMigrate = append(s.modelsToMigrate, models...)
|
|
}
|
|
|
|
// --- PERBAIKAN: Implementasi Migrate Method dengan validasi lebih baik ---
|
|
func (s *service) Migrate() error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
log.Println("🔄 Starting database migration process using GORM AutoMigrate...")
|
|
|
|
if len(s.gormDatabases) == 0 {
|
|
return fmt.Errorf("no GORM databases available for migration")
|
|
}
|
|
|
|
// Gunakan model yang sudah didaftarkan
|
|
models := s.modelsToMigrate
|
|
if len(models) == 0 {
|
|
log.Println("⚠️ No models registered for migration.")
|
|
// Tetap lanjutkan untuk runSQLMigrationsForDB
|
|
}
|
|
|
|
// Jalankan AutoMigrate untuk setiap database
|
|
for name, gormDB := range s.gormDatabases {
|
|
dbConfig := s.configs[name]
|
|
dbType := DatabaseType(dbConfig.Type)
|
|
|
|
if dbType == Postgres || dbType == MySQL || dbType == SQLServer || dbType == SQLite {
|
|
log.Printf("⚙️ Running migration for [%s] (%s)...", name, dbType)
|
|
|
|
// Gunakan AutoMigrate untuk model-model yang terdaftar
|
|
if len(models) > 0 {
|
|
if err := gormDB.AutoMigrate(models...); err != nil {
|
|
return fmt.Errorf("auto migration failed for %s: %w", name, err)
|
|
}
|
|
log.Printf("✅ AutoMigrate successful for [%s]", name)
|
|
}
|
|
|
|
// Jalankan SQL migrations sebagai tambahan
|
|
if err := s.runSQLMigrationsForDB(gormDB, dbType); err != nil {
|
|
return fmt.Errorf("sql migration failed for %s: %w", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Default().Info("All database migrations completed successfully")
|
|
return nil
|
|
}
|
|
|
|
func (s *service) runSQLMigrationsForDB(gormDB *gorm.DB, dbType DatabaseType) error {
|
|
migrationFiles, err := s.readMigrationFiles()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read migration files: %w", err)
|
|
}
|
|
|
|
if len(migrationFiles) == 0 {
|
|
logger.Default().Info("No migration files found")
|
|
return nil
|
|
}
|
|
|
|
logger.Default().Info("Found migration files to execute", logger.Int("count", len(migrationFiles)))
|
|
|
|
sqlDB, err := gormDB.DB()
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get underlying sql.DB: %w", err)
|
|
}
|
|
|
|
if err := s.ensureMigrationTable(sqlDB, dbType); err != nil {
|
|
return fmt.Errorf("failed to ensure migration table: %w", err)
|
|
}
|
|
|
|
for _, filePath := range migrationFiles {
|
|
filename := filepath.Base(filePath)
|
|
if s.isMigrationApplied(sqlDB, filename) {
|
|
logger.Default().Debug("Skipping already applied migration", logger.String("file", filename))
|
|
continue
|
|
}
|
|
|
|
if err := s.executeMigrationFile(sqlDB, filePath); err != nil {
|
|
return fmt.Errorf("migration failed at file %s: %w", filePath, err)
|
|
}
|
|
logger.Default().Info("Successfully executed migration file", logger.String("file", filename))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Fungsi bantu untuk membaca file migrasi
|
|
func (s *service) readMigrationFiles() ([]string, error) {
|
|
migrationsDir := "internal/infrastructure/database/sql"
|
|
|
|
// Cek apakah directory migrations ada
|
|
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
|
|
logger.Default().Info("Migrations directory does not exist", logger.String("path", migrationsDir))
|
|
return nil, nil
|
|
}
|
|
|
|
var files []string
|
|
err := filepath.Walk(migrationsDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Hanya proses file .sql
|
|
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".sql") {
|
|
files = append(files, path)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to walk migrations directory: %w", err)
|
|
}
|
|
|
|
// Sort files by name untuk konsisten
|
|
sort.Strings(files)
|
|
return files, nil
|
|
}
|
|
|
|
// Fungsi bantu untuk cek apakah tabel sudah ada
|
|
func isTableNotExistsError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
errStr := strings.ToLower(err.Error())
|
|
return strings.Contains(errStr, "doesn't exist") ||
|
|
strings.Contains(errStr, "does not exist") ||
|
|
strings.Contains(errStr, "no such table")
|
|
}
|
|
|
|
func (s *service) addReadReplica(name string, index int, config config.DatabaseConfig) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
var db *sql.DB
|
|
var err error
|
|
|
|
dbType := DatabaseType(config.Type)
|
|
|
|
switch dbType {
|
|
case Postgres:
|
|
db, err = s.openPostgresConnection(config)
|
|
case MySQL:
|
|
db, err = s.openMySQLConnection(config)
|
|
case SQLServer:
|
|
db, err = s.openSQLServerConnection(config)
|
|
case SQLite:
|
|
db, err = s.openSQLiteConnection(config)
|
|
default:
|
|
return fmt.Errorf("unsupported database type for read replica: %s", config.Type)
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Optimasi juga untuk read replica
|
|
s.optimizeConnectionPool(fmt.Sprintf("%s-replica-%d", name, index), db, config)
|
|
|
|
// Validasi koneksi
|
|
if err := s.validateConnection(fmt.Sprintf("%s-replica-%d", name, index), db); err != nil {
|
|
db.Close()
|
|
return fmt.Errorf("connection validation failed for read replica %s[%d]: %w", name, index, err)
|
|
}
|
|
|
|
if s.readReplicas[name] == nil {
|
|
s.readReplicas[name] = make([]*sql.DB, 0)
|
|
}
|
|
|
|
for len(s.readReplicas[name]) <= index {
|
|
s.readReplicas[name] = append(s.readReplicas[name], nil)
|
|
}
|
|
|
|
s.readReplicas[name][index] = db
|
|
logger.Default().Info("Successfully connected to read replica", logger.String("db_name", name), logger.Int("replica_index", index))
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper functions untuk mendeteksi jenis error
|
|
func isTableExistsError(err error) bool {
|
|
errMsg := err.Error()
|
|
return strings.Contains(errMsg, "relation already exists") ||
|
|
strings.Contains(errMsg, "table already exists")
|
|
}
|
|
|
|
func isSyntaxError(err error) bool {
|
|
errMsg := err.Error()
|
|
return strings.Contains(errMsg, "syntax error") ||
|
|
strings.Contains(errMsg, "syntax error at") ||
|
|
strings.Contains(errMsg, "invalid syntax")
|
|
}
|
|
|
|
func isDuplicateKeyError(err error) bool {
|
|
errMsg := err.Error()
|
|
return strings.Contains(errMsg, "duplicate key") ||
|
|
strings.Contains(errMsg, "unique constraint")
|
|
}
|
|
|
|
func isTableCreationCommand(cmd string) bool {
|
|
cmd = strings.ToLower(cmd)
|
|
return strings.Contains(cmd, "create table") ||
|
|
strings.Contains(cmd, "create index") ||
|
|
strings.Contains(cmd, "alter table")
|
|
}
|
|
|
|
func (s *service) openPostgresConnection(config config.DatabaseConfig) (*sql.DB, error) {
|
|
connectTimeoutSec := int(config.ConnectTimeout.Seconds())
|
|
statementTimeoutSec := int(config.StatementTimeout.Seconds())
|
|
|
|
// Menggunakan pgx.ParseConfig untuk fleksibilitas lebih tinggi
|
|
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s connect_timeout=%d",
|
|
config.Host,
|
|
config.Port,
|
|
config.Username,
|
|
config.Password,
|
|
config.Database,
|
|
config.SSLMode,
|
|
connectTimeoutSec,
|
|
)
|
|
|
|
if config.Schema != "" {
|
|
dsn += " search_path=" + config.Schema
|
|
}
|
|
|
|
// Jika menggunakan parameter statement_timeout (pg specific)
|
|
if statementTimeoutSec > 0 {
|
|
// Note: pgx driver handle options differently, but usually passed in DSN or via Config
|
|
dsn += fmt.Sprintf(" statement_timeout=%d", statementTimeoutSec*1000)
|
|
}
|
|
|
|
// Setup SSL certificates path if required
|
|
if config.RequireSSL {
|
|
// Pastikan file path valid
|
|
if config.SSLCert != "" && config.SSLKey != "" && config.SSLRootCert != "" {
|
|
dsn += fmt.Sprintf(" sslcert=%s sslkey=%s sslrootcert=%s", config.SSLCert, config.SSLKey, config.SSLRootCert)
|
|
}
|
|
}
|
|
|
|
// Menggunakan stdlib.OpenDB dari pgx untuk kompatibilitas database/sql
|
|
pgxConfig, err := pgx.ParseConfig(dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse pgx config: %w", err)
|
|
}
|
|
|
|
// Apply manual runtime settings if needed here
|
|
|
|
db := stdlib.OpenDB(*pgxConfig)
|
|
|
|
// Set timeout yang lebih panjang untuk koneksi awal
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to ping PostgreSQL database: %w", err)
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
func (s *service) openMySQLConnection(config config.DatabaseConfig) (*sql.DB, error) {
|
|
connStr := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&timeout=%s&readTimeout=%s&writeTimeout=%s",
|
|
config.Username,
|
|
config.Password,
|
|
config.Host,
|
|
config.Port,
|
|
config.Database,
|
|
config.Timeout,
|
|
config.ReadTimeout,
|
|
config.WriteTimeout,
|
|
)
|
|
|
|
if config.RequireSSL {
|
|
connStr += "&tls=true"
|
|
// Setup custom TLS config biasanya dilakukan secara global di driver mysql
|
|
// atau menggunakan nama config yang sudah didaftarkan
|
|
}
|
|
|
|
db, err := sql.Open("mysql", connStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open MySQL connection: %w", err)
|
|
}
|
|
|
|
// Set timeout yang lebih panjang untuk koneksi awal
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to ping MySQL database: %w", err)
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
func (s *service) openSQLServerConnection(config config.DatabaseConfig) (*sql.DB, error) {
|
|
connectTimeoutSec := int(config.ConnectTimeout.Seconds())
|
|
|
|
connStr := fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s&connection timeout=%d",
|
|
config.Username,
|
|
config.Password,
|
|
config.Host,
|
|
config.Port,
|
|
config.Database,
|
|
connectTimeoutSec,
|
|
)
|
|
|
|
if config.RequireSSL {
|
|
connStr += "&encrypt=true"
|
|
if config.SSLRootCert != "" {
|
|
connStr += "&trustServerCertificate=false"
|
|
} else {
|
|
connStr += "&trustServerCertificate=true"
|
|
}
|
|
}
|
|
|
|
db, err := sql.Open("sqlserver", connStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open SQL Server connection: %w", err)
|
|
}
|
|
|
|
// Set timeout yang lebih panjang untuk koneksi awal
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to ping SQL Server database: %w", err)
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
func (s *service) openSQLiteConnection(config config.DatabaseConfig) (*sql.DB, error) {
|
|
// Memerlukan import _ "github.com/mattn/go-sqlite3" di tempat lain atau gunakan driver GORM yang membungkusnya
|
|
// Namun disini kita gunakan driver name "sqlite3" standar
|
|
db, err := sql.Open("sqlite3", config.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open SQLite connection: %w", err)
|
|
}
|
|
|
|
// Performance tuning untuk SQLite (WAL mode)
|
|
_, err = db.Exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to configure SQLite: %w", err)
|
|
}
|
|
|
|
// Set timeout yang lebih panjang untuk koneksi awal
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to ping SQLite database: %w", err)
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
func (s *service) addMongoDB(name string, config config.DatabaseConfig) error {
|
|
timeout := config.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 10 * time.Second
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
uri := fmt.Sprintf("mongodb://%s:%s@%s:%d/%s",
|
|
config.Username,
|
|
config.Password,
|
|
config.Host,
|
|
config.Port,
|
|
config.Database,
|
|
)
|
|
|
|
clientOptions := options.Client().ApplyURI(uri)
|
|
|
|
if config.RequireSSL {
|
|
clientOptions.SetTLSConfig(&tls.Config{
|
|
InsecureSkipVerify: config.SSLMode == "require" || config.SSLMode == "skip-verify",
|
|
MinVersion: tls.VersionTLS12,
|
|
})
|
|
}
|
|
|
|
clientOptions.SetConnectTimeout(config.ConnectTimeout)
|
|
clientOptions.SetServerSelectionTimeout(config.Timeout)
|
|
|
|
client, err := mongo.Connect(ctx, clientOptions)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to MongoDB: %w", err)
|
|
}
|
|
|
|
if err := client.Ping(ctx, nil); err != nil {
|
|
return fmt.Errorf("failed to ping MongoDB: %w", err)
|
|
}
|
|
|
|
s.mongoClients[name] = client
|
|
logger.Default().Info("Successfully connected to MongoDB", logger.String("db_name", name))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *service) configureSQLDB(name string, db *sql.DB, config config.DatabaseConfig) error {
|
|
// Configuration is handled in optimizeConnectionPool, but we can double check or set defaults here
|
|
timeout := config.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return fmt.Errorf("failed to ping database: %w", err)
|
|
}
|
|
|
|
s.sqlDatabases[name] = db
|
|
|
|
dbType := DatabaseType(config.Type)
|
|
var driverName string
|
|
|
|
switch dbType {
|
|
case Postgres:
|
|
driverName = "pgx"
|
|
case MySQL:
|
|
driverName = "mysql"
|
|
case SQLServer:
|
|
driverName = "sqlserver"
|
|
case SQLite:
|
|
driverName = "sqlite3"
|
|
default:
|
|
// Fallback for sqlx
|
|
driverName = string(dbType)
|
|
}
|
|
|
|
sqlxDB := sqlx.NewDb(db, driverName)
|
|
s.sqlxDatabases[name] = sqlxDB
|
|
|
|
logger.Default().Info("Successfully connected to database", logger.String("db_name", name), logger.String("driver", driverName))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *service) Health() map[string]map[string]string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
result := make(map[string]map[string]string)
|
|
|
|
// Check SQL Databases
|
|
for name, db := range s.sqlDatabases {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
defer cancel()
|
|
|
|
stats := make(map[string]string)
|
|
|
|
err := db.PingContext(ctx)
|
|
if err != nil {
|
|
stats["status"] = "down"
|
|
stats["error"] = fmt.Sprintf("db down: %v", err)
|
|
stats["type"] = "sql"
|
|
stats["role"] = "primary"
|
|
result[name] = stats
|
|
continue
|
|
}
|
|
|
|
stats["status"] = "up"
|
|
stats["message"] = "It's healthy"
|
|
stats["type"] = "sql"
|
|
stats["role"] = "primary"
|
|
|
|
dbStats := db.Stats()
|
|
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
|
|
stats["in_use"] = strconv.Itoa(dbStats.InUse)
|
|
stats["idle"] = strconv.Itoa(dbStats.Idle)
|
|
stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10)
|
|
stats["wait_duration"] = dbStats.WaitDuration.String()
|
|
|
|
result[name] = stats
|
|
}
|
|
|
|
// Check Read Replicas
|
|
for name, replicas := range s.readReplicas {
|
|
for i, db := range replicas {
|
|
if db == nil {
|
|
continue
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
defer cancel()
|
|
|
|
replicaName := fmt.Sprintf("%s_replica_%d", name, i)
|
|
stats := make(map[string]string)
|
|
|
|
err := db.PingContext(ctx)
|
|
if err != nil {
|
|
stats["status"] = "down"
|
|
stats["error"] = fmt.Sprintf("read replica down: %v", err)
|
|
stats["role"] = "replica"
|
|
result[replicaName] = stats
|
|
continue
|
|
}
|
|
|
|
stats["status"] = "up"
|
|
stats["role"] = "replica"
|
|
|
|
dbStats := db.Stats()
|
|
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
|
|
result[replicaName] = stats
|
|
}
|
|
}
|
|
|
|
// Check MongoDB
|
|
for name, client := range s.mongoClients {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
defer cancel()
|
|
|
|
stats := make(map[string]string)
|
|
|
|
err := client.Ping(ctx, nil)
|
|
if err != nil {
|
|
stats["status"] = "down"
|
|
stats["error"] = fmt.Sprintf("mongodb down: %v", err)
|
|
stats["type"] = "mongodb"
|
|
result[name] = stats
|
|
continue
|
|
}
|
|
|
|
stats["status"] = "up"
|
|
stats["type"] = "mongodb"
|
|
result[name] = stats
|
|
}
|
|
|
|
return result
|
|
}
|
|
func (s *service) Close() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
var errs []error
|
|
|
|
for name, listener := range s.listeners {
|
|
if err := listener.Close(); err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to close listener for %s: %w", name, err))
|
|
}
|
|
}
|
|
|
|
for name, db := range s.sqlDatabases {
|
|
if err := db.Close(); err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to close database %s: %w", name, err))
|
|
}
|
|
}
|
|
|
|
for name, replicas := range s.readReplicas {
|
|
for i, db := range replicas {
|
|
if db != nil {
|
|
if err := db.Close(); err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to close read replica %s[%d]: %w", name, i, err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for name, client := range s.mongoClients {
|
|
if err := client.Disconnect(context.Background()); err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to disconnect MongoDB client %s: %w", name, err))
|
|
}
|
|
}
|
|
|
|
// Clear maps
|
|
s.sqlDatabases = make(map[string]*sql.DB)
|
|
s.sqlxDatabases = make(map[string]*sqlx.DB)
|
|
s.mongoClients = make(map[string]*mongo.Client)
|
|
s.listeners = make(map[string]*pq.Listener)
|
|
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("errors closing databases: %v", errs)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *service) GetReadDB(name string) (*sql.DB, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
replicas, exists := s.readReplicas[name]
|
|
if !exists || len(replicas) == 0 {
|
|
// Fallback to primary if no replicas
|
|
return s.GetDB(name)
|
|
}
|
|
|
|
// Round-robin selection
|
|
s.readBalancer[name] = (s.readBalancer[name] + 1) % len(replicas)
|
|
selected := replicas[s.readBalancer[name]]
|
|
|
|
if selected == nil {
|
|
return s.GetDB(name)
|
|
}
|
|
|
|
return selected, nil
|
|
}
|
|
|
|
func (s *service) GetMongoClient(name string) (*mongo.Client, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
client, exists := s.mongoClients[name]
|
|
if !exists {
|
|
return nil, fmt.Errorf("MongoDB client %s not found", name)
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
func (s *service) ListDBs() []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
names := make([]string, 0, len(s.sqlDatabases)+len(s.mongoClients))
|
|
|
|
for name := range s.sqlDatabases {
|
|
names = append(names, name)
|
|
}
|
|
|
|
for name := range s.mongoClients {
|
|
names = append(names, name)
|
|
}
|
|
|
|
return names
|
|
}
|
|
|
|
func (s *service) GetDBType(name string) (DatabaseType, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
config, exists := s.configs[name]
|
|
if !exists {
|
|
return "", fmt.Errorf("database %s not found", name)
|
|
}
|
|
|
|
return DatabaseType(config.Type), nil
|
|
}
|
|
|
|
func (s *service) GetPrimaryDB(name string) (*sql.DB, error) {
|
|
return s.GetDB(name)
|
|
}
|
|
|
|
func (s *service) ExecuteQuery(ctx context.Context, dbName string, query string, args ...interface{}) (*sql.Rows, error) {
|
|
db, err := s.GetDB(dbName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get database %s: %w", dbName, err)
|
|
}
|
|
|
|
rows, err := db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute query: %w", err)
|
|
}
|
|
|
|
return rows, nil
|
|
}
|
|
|
|
func (s *service) ExecuteQueryRow(ctx context.Context, dbName string, query string, args ...interface{}) *sql.Row {
|
|
db, err := s.GetDB(dbName)
|
|
if err != nil {
|
|
// Return dummy row that will return error on Scan
|
|
// But standard sql.Row doesn't allow easy mocking of error without query execution.
|
|
// So we must handle nil db check before calling this or panic might occur if not careful?
|
|
// sql.DB methods are safe to call, but we returned error from GetDB.
|
|
// Standard pattern: return row created from db, if db error, we can't create row easily.
|
|
// Workaround: return a row from a failed query on a dummy DB or panic if critical.
|
|
// For now, let's assume GetDB returns error and caller handles it, but since return signature is *sql.Row only...
|
|
// Ideally change signature to (*sql.Row, error) or panic.
|
|
// Here we just return an empty row which will likely fail on scan.
|
|
return &sql.Row{}
|
|
}
|
|
|
|
return db.QueryRowContext(ctx, query, args...)
|
|
}
|
|
|
|
func (s *service) Exec(ctx context.Context, dbName string, query string, args ...interface{}) (sql.Result, error) {
|
|
db, err := s.GetDB(dbName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get database %s: %w", dbName, err)
|
|
}
|
|
|
|
result, err := db.ExecContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute query: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *service) ListenForChanges(ctx context.Context, dbName string, channels []string, callback func(string, string)) error {
|
|
s.mu.RLock()
|
|
config, exists := s.configs[dbName]
|
|
s.mu.RUnlock()
|
|
|
|
if !exists {
|
|
return fmt.Errorf("database %s not found", dbName)
|
|
}
|
|
|
|
if DatabaseType(config.Type) != Postgres {
|
|
return fmt.Errorf("LISTEN/NOTIFY only supported for PostgreSQL databases")
|
|
}
|
|
|
|
// Reconstruct connection string for pq listener
|
|
connectTimeoutSec := int(config.ConnectTimeout.Seconds())
|
|
connStr := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s&connect_timeout=%d",
|
|
config.Username,
|
|
config.Password,
|
|
config.Host,
|
|
config.Port,
|
|
config.Database,
|
|
config.SSLMode,
|
|
connectTimeoutSec,
|
|
)
|
|
|
|
listener := pq.NewListener(
|
|
connStr,
|
|
10*time.Second,
|
|
time.Minute,
|
|
func(ev pq.ListenerEventType, err error) {
|
|
if err != nil {
|
|
log.Printf("Database listener (%s) connection error: %v", dbName, err)
|
|
}
|
|
},
|
|
)
|
|
|
|
s.listenersMu.Lock()
|
|
s.listeners[dbName] = listener
|
|
s.listenersMu.Unlock()
|
|
|
|
for _, channel := range channels {
|
|
if err := listener.Listen(channel); err != nil {
|
|
listener.Close()
|
|
return fmt.Errorf("failed to listen to channel %s: %w", channel, err)
|
|
}
|
|
log.Printf("Listening to database channel: %s on %s", channel, dbName)
|
|
}
|
|
|
|
go func() {
|
|
defer func() {
|
|
listener.Close()
|
|
s.listenersMu.Lock()
|
|
delete(s.listeners, dbName)
|
|
s.listenersMu.Unlock()
|
|
log.Printf("Database listener for %s stopped", dbName)
|
|
}()
|
|
|
|
// Ticker untuk melakukan Ping manual sebagai pengganti MonitorPing
|
|
pingTicker := time.NewTicker(30 * time.Second)
|
|
defer pingTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case n := <-listener.Notify:
|
|
if n != nil {
|
|
// Panggil callback secara async
|
|
go callback(n.Channel, n.Extra)
|
|
}
|
|
case <-pingTicker.C:
|
|
// Manual Ping untuk memastikan koneksi tetap hidup
|
|
go func() {
|
|
if err := listener.Ping(); err != nil {
|
|
log.Printf("⚠️ Listener ping warning for %s: %v", dbName, err)
|
|
}
|
|
}()
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *service) NotifyChange(dbName, channel, payload string) error {
|
|
db, err := s.GetDB(dbName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get database %s: %w", dbName, err)
|
|
}
|
|
|
|
// Pastikan ini Postgres
|
|
s.mu.RLock()
|
|
config, exists := s.configs[dbName]
|
|
s.mu.RUnlock()
|
|
|
|
if !exists || DatabaseType(config.Type) != Postgres {
|
|
return fmt.Errorf("NOTIFY only supported for PostgreSQL")
|
|
}
|
|
|
|
_, err = db.Exec("SELECT pg_notify($1, $2)", channel, payload)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send notification: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *service) optimizeConnectionPool(name string, db *sql.DB, config config.DatabaseConfig) {
|
|
// Jika config menyediakan nilai, gunakan itu. Jika tidak, gunakan formula auto-tuning.
|
|
|
|
maxOpen := config.MaxOpenConns
|
|
maxIdle := config.MaxIdleConns
|
|
|
|
// Auto-tune jika tidak diset di config (0)
|
|
if maxOpen <= 0 {
|
|
cpuCores := runtime.NumCPU()
|
|
maxOpen = cpuCores*2 + 1
|
|
}
|
|
|
|
if maxIdle <= 0 {
|
|
maxIdle = maxOpen / 2
|
|
}
|
|
|
|
db.SetMaxOpenConns(maxOpen)
|
|
db.SetMaxIdleConns(maxIdle)
|
|
|
|
// Gunakan config duration atau default
|
|
if config.ConnMaxLifetime > 0 {
|
|
db.SetConnMaxLifetime(config.ConnMaxLifetime)
|
|
} else {
|
|
db.SetConnMaxLifetime(30 * time.Minute)
|
|
}
|
|
|
|
if config.MaxIdleTime > 0 {
|
|
db.SetConnMaxIdleTime(config.MaxIdleTime)
|
|
} else {
|
|
db.SetConnMaxIdleTime(10 * time.Minute)
|
|
}
|
|
|
|
log.Printf("Connection pool configured for %s: MaxOpen=%d, MaxIdle=%d", name, maxOpen, maxIdle)
|
|
}
|
|
|
|
func (s *service) validateConnection(name string, db *sql.DB) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // Timeout lebih panjang untuk validasi
|
|
defer cancel()
|
|
|
|
if err := db.PingContext(ctx); err != nil {
|
|
return fmt.Errorf("connection validation failed for %s: %w", name, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *service) ensureMigrationTable(db *sql.DB, dbType DatabaseType) error {
|
|
var createTableSQL string
|
|
|
|
switch dbType {
|
|
case Postgres:
|
|
createTableSQL = `
|
|
CREATE TABLE IF NOT EXISTS Migrations (
|
|
id SERIAL PRIMARY KEY,
|
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
|
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
`
|
|
case MySQL:
|
|
createTableSQL = `
|
|
CREATE TABLE IF NOT EXISTS Migrations (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
|
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
`
|
|
case SQLServer:
|
|
createTableSQL = `
|
|
CREATE TABLE IF NOT EXISTS Migrations (
|
|
id INT IDENTITY(1,1) PRIMARY KEY,
|
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
|
executed_at DATETIME DEFAULT GETDATE()
|
|
);
|
|
`
|
|
case SQLite:
|
|
createTableSQL = `
|
|
CREATE TABLE IF NOT EXISTS Migrations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
filename TEXT NOT NULL UNIQUE,
|
|
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
`
|
|
default:
|
|
return fmt.Errorf("unsupported database type for migration table: %s", dbType)
|
|
}
|
|
|
|
// Eksekusi query dengan penanganan error yang lebih baik
|
|
result, err := db.Exec(createTableSQL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create migrations table: %w", err)
|
|
}
|
|
|
|
// Cek apakah tabel berhasil dibuat
|
|
rowsAffected, _ := result.RowsAffected()
|
|
if rowsAffected > 0 {
|
|
logger.Default().Info("Migrations table created successfully")
|
|
} else {
|
|
logger.Default().Debug("Migrations table already exists")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *service) isMigrationApplied(db *sql.DB, filename string) bool {
|
|
var count int
|
|
|
|
// Gunakan query yang sesuai dengan database type
|
|
var query string
|
|
switch s.getDatabaseTypeFromConnection(db) {
|
|
case Postgres:
|
|
query = "SELECT COUNT(*) FROM Migrations WHERE filename = $1"
|
|
case MySQL, SQLite:
|
|
query = "SELECT COUNT(*) FROM Migrations WHERE filename = ?"
|
|
case SQLServer:
|
|
query = "SELECT COUNT(*) FROM Migrations WHERE filename = @p1"
|
|
default:
|
|
query = "SELECT COUNT(*) FROM Migrations WHERE filename = ?"
|
|
}
|
|
|
|
err := db.QueryRow(query, filename).Scan(&count)
|
|
if err != nil {
|
|
// Jika tabel belum ada, anggap migrasi belum dijalankan
|
|
if isTableNotExistsError(err) {
|
|
return false
|
|
}
|
|
logger.Default().Error("Error checking migration status", logger.ErrorField(err))
|
|
return false
|
|
}
|
|
|
|
return count > 0
|
|
}
|
|
|
|
func (s *service) executeMigrationFile(db *sql.DB, filePath string) error {
|
|
// Baca konten file SQL
|
|
sqlContent, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read migration file %s: %w", filePath, err)
|
|
}
|
|
|
|
// Jalankan migrasi dalam transaksi
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
if err != nil {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
// Eksekusi setiap perintah SQL
|
|
commands := strings.Split(string(sqlContent), ";")
|
|
for _, cmd := range commands {
|
|
cmd = strings.TrimSpace(cmd)
|
|
if cmd == "" {
|
|
continue
|
|
}
|
|
|
|
_, err := tx.Exec(cmd)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to execute SQL command: %s, error: %w", cmd, err)
|
|
}
|
|
}
|
|
|
|
// Commit transaksi
|
|
if err = tx.Commit(); err != nil {
|
|
return fmt.Errorf("failed to commit transaction: %w", err)
|
|
}
|
|
|
|
// Catat migrasi sebagai telah dijalankan
|
|
filename := filepath.Base(filePath)
|
|
if err := s.recordMigration(db, filename); err != nil {
|
|
return fmt.Errorf("failed to record migration: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *service) recordMigration(db *sql.DB, filename string) error {
|
|
// Gunakan query yang sesuai dengan database type
|
|
var query string
|
|
switch s.getDatabaseTypeFromConnection(db) {
|
|
case Postgres:
|
|
query = "INSERT INTO Migrations (filename) VALUES ($1)"
|
|
case MySQL, SQLite:
|
|
query = "INSERT INTO Migrations (filename) VALUES (?)"
|
|
case SQLServer:
|
|
query = "INSERT INTO Migrations (filename) VALUES (@p1)"
|
|
default:
|
|
query = "INSERT INTO Migrations (filename) VALUES (?)"
|
|
}
|
|
|
|
_, err := db.Exec(query, filename)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to record migration: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Fungsi bantu untuk mendeteksi tipe database dari koneksi
|
|
func (s *service) getDatabaseTypeFromConnection(db *sql.DB) DatabaseType {
|
|
// Ini adalah implementasi sederhana, Anda mungkin perlu menyesuaikan
|
|
// dengan cara Anda mengelola koneksi database
|
|
// Coba dapatkan driver name dari koneksi
|
|
// Note: Ini mungkin perlu disesuaikan dengan driver yang Anda gunakan
|
|
if postgresDriver := db.Driver(); postgresDriver != nil {
|
|
driverType := fmt.Sprintf("%T", postgresDriver)
|
|
if strings.Contains(driverType, "postgres") {
|
|
return Postgres
|
|
} else if strings.Contains(driverType, "mysql") {
|
|
return MySQL
|
|
} else if strings.Contains(driverType, "sqlserver") {
|
|
return SQLServer
|
|
} else if strings.Contains(driverType, "sqlite") {
|
|
return SQLite
|
|
}
|
|
}
|
|
|
|
// Default fallback
|
|
return Postgres
|
|
}
|