185 lines
4.6 KiB
Go
185 lines
4.6 KiB
Go
package data
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
"go.mongodb.org/mongo-driver/mongo"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// retireGrace bounds how long a client replaced by a hot reload stays open.
|
|
// In-flight operations still hold the old handle, so it cannot be closed at
|
|
// swap time; keeping it until shutdown would leak one full pool per reload.
|
|
const retireGrace = 5 * time.Minute
|
|
|
|
// retiredSet holds clients replaced by a hot reload and closes each of them
|
|
// once the grace period expires. Whatever is still pending at shutdown is
|
|
// drained by the owner's close.
|
|
type retiredSet[T comparable] struct {
|
|
mu sync.Mutex
|
|
items []T
|
|
}
|
|
|
|
func (s *retiredSet[T]) retire(item T, closeItem func(T)) {
|
|
s.mu.Lock()
|
|
s.items = append(s.items, item)
|
|
s.mu.Unlock()
|
|
time.AfterFunc(retireGrace, func() {
|
|
if s.take(item) {
|
|
closeItem(item)
|
|
}
|
|
})
|
|
}
|
|
|
|
// take removes item and reports whether this caller now owns closing it.
|
|
func (s *retiredSet[T]) take(item T) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for index, existing := range s.items {
|
|
if existing == item {
|
|
s.items = append(s.items[:index], s.items[index+1:]...)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// forget removes every pending retirement for item without closing it. A
|
|
// reload can legitimately publish a previously retired handle again (for
|
|
// example A -> B -> A); its old timers must no longer own that live handle.
|
|
func (s *retiredSet[T]) forget(item T) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for index := 0; index < len(s.items); {
|
|
if s.items[index] != item {
|
|
index++
|
|
continue
|
|
}
|
|
s.items = append(s.items[:index], s.items[index+1:]...)
|
|
}
|
|
}
|
|
|
|
func (s *retiredSet[T]) drain() []T {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
items := s.items
|
|
s.items = nil
|
|
return items
|
|
}
|
|
|
|
// reloadable owns one hot-swappable storage client. The database, Redis and
|
|
// Mongo handles all need the same three things — a racy-free read on every
|
|
// request, an atomic swap on reload, and a close that also drains whatever the
|
|
// grace period has not reaped yet — so they share this one implementation.
|
|
type reloadable[T comparable] struct {
|
|
mu sync.RWMutex
|
|
current T
|
|
retired retiredSet[T]
|
|
closeOne func(T)
|
|
}
|
|
|
|
func newReloadable[T comparable](current T, closeOne func(T)) *reloadable[T] {
|
|
return &reloadable[T]{current: current, closeOne: closeOne}
|
|
}
|
|
|
|
// load returns the active client, or the zero value when the client was never
|
|
// configured, so optional backends need no separate presence flag.
|
|
func (r *reloadable[T]) load() T {
|
|
var zero T
|
|
if r == nil {
|
|
return zero
|
|
}
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.current
|
|
}
|
|
|
|
// replace publishes next and retires the handle it displaced.
|
|
func (r *reloadable[T]) replace(next T) {
|
|
var zero T
|
|
if next != zero {
|
|
r.retired.forget(next)
|
|
}
|
|
r.mu.Lock()
|
|
old := r.current
|
|
r.current = next
|
|
r.mu.Unlock()
|
|
if old != zero && old != next {
|
|
r.retired.retire(old, func(item T) {
|
|
// Serialize the final liveness check with replace: a handle that was
|
|
// re-published while its timer fired must never be closed underneath
|
|
// the new active configuration.
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.current != item {
|
|
r.closeOne(item)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func (r *reloadable[T]) close() {
|
|
var zero T
|
|
r.mu.Lock()
|
|
current := r.current
|
|
r.current = zero
|
|
r.mu.Unlock()
|
|
seen := make(map[T]struct{})
|
|
for _, item := range append([]T{current}, r.retired.drain()...) {
|
|
if item == zero {
|
|
continue
|
|
}
|
|
if _, ok := seen[item]; ok {
|
|
continue
|
|
}
|
|
seen[item] = struct{}{}
|
|
r.closeOne(item)
|
|
}
|
|
}
|
|
|
|
// rollback collects the cleanup for every client a multi-step reload opened
|
|
// before it knows whether the reload will succeed. Any early return closes
|
|
// exactly what was opened, in reverse order; commit hands every handle over to
|
|
// the process and cancels all of it. One flag replaces one accepted-boolean plus
|
|
// one deferred closure per client.
|
|
type rollback struct {
|
|
cleanups []func()
|
|
committed bool
|
|
}
|
|
|
|
func (r *rollback) add(cleanup func()) { r.cleanups = append(r.cleanups, cleanup) }
|
|
|
|
func (r *rollback) commit() { r.committed = true }
|
|
|
|
func (r *rollback) run() {
|
|
if r.committed {
|
|
return
|
|
}
|
|
for index := len(r.cleanups) - 1; index >= 0; index-- {
|
|
r.cleanups[index]()
|
|
}
|
|
}
|
|
|
|
// newReloadableDB owns only the hot-swappable database handle. Domain-specific
|
|
// callbacks are attached by Data when a pool becomes active.
|
|
func newReloadableDB(db *gorm.DB) *reloadable[*gorm.DB] {
|
|
return newReloadable(db, closeGormDB)
|
|
}
|
|
|
|
func closeGormDB(db *gorm.DB) {
|
|
if sqlDB, err := db.DB(); err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
}
|
|
|
|
func closeMongoClient(client *mongo.Client) {
|
|
_ = client.Disconnect(context.Background())
|
|
}
|
|
|
|
func closeRedisClient(client redis.UniversalClient) {
|
|
_ = client.Close()
|
|
}
|