Files
bd2/go/internal/server/gamedata/database_cache.go
T
Flechazo d8771caef2 feat(all): add hunting and events, commerce and rewards, binary calendars, GameData caching and login diagnostics
Implement hunting AP and progress, event tasks, exchanges, games, stories and actions; connect battle and reward handling.

Add CashShop plugin and installation, server purchases and entitlements, recharge rules and plugin version metadata.

Distribute binary calendars independently; cache GameData in memory and fix single-connection query deadlocks.

Improve automatic login and recovery diagnostics; document configuration and release flows.
2026-10-05 11:50:31 +08:00

311 lines
7.8 KiB
Go

package gamedata
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
_ "modernc.org/sqlite"
)
const DefaultDatabaseCacheBytes int64 = 512 << 20
type databaseCacheKey struct {
Path, Version, Logical string
Size, Modified int64
}
type databaseCacheEntry struct {
db *sql.DB
size int64
refs int
used uint64
ready chan struct{}
err error
retired bool
}
// DatabaseCache owns read-only SQLite connections. A lease must be released
// after closing all rows; callers must never Close the returned shared DB.
// The budget bounds retained SQLite images, excluding transient decrypt buffers
// and SQLite page-cache overhead. Active leases may temporarily exceed it.
type DatabaseCache struct {
mu sync.Mutex
loadMu sync.Mutex
entries map[databaseCacheKey]*databaseCacheEntry
budget, bytes int64
clock uint64
closed bool
loader func(string, string, string) ([]byte, error)
}
func NewDatabaseCache(maxBytes int64) *DatabaseCache {
if maxBytes <= 0 {
maxBytes = DefaultDatabaseCacheBytes
}
return &DatabaseCache{budget: maxBytes, entries: map[databaseCacheKey]*databaseCacheEntry{}, loader: ReadDatabase}
}
var sharedDatabaseCache = NewDatabaseCache(DefaultDatabaseCacheBytes)
var sharedDatabaseCacheMu sync.RWMutex
var databaseMemoryID atomic.Uint64
func databaseKey(root, version, logical string) (databaseCacheKey, error) {
if _, err := DatabaseName(logical); err != nil {
return databaseCacheKey{}, err
}
if version == "" || version == "." || version == ".." || strings.ContainsAny(version, `/\`) {
return databaseCacheKey{}, fmt.Errorf("gamedata: invalid database version")
}
abs, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return databaseCacheKey{}, err
}
abs, err = filepath.EvalSymlinks(abs)
if err != nil {
return databaseCacheKey{}, err
}
archive := filepath.Join(abs, version, "release", ArchiveName)
stat, err := os.Stat(archive)
if err != nil {
return databaseCacheKey{}, err
}
path := archive
if runtime.GOOS == "windows" {
path = strings.ToLower(path)
}
return databaseCacheKey{Path: path, Version: version, Logical: logical, Size: stat.Size(), Modified: stat.ModTime().UnixNano()}, nil
}
func memoryDatabase(plain []byte) (*sql.DB, error) {
db, err := sql.Open("sqlite", fmt.Sprintf("file:bd2-gamedata-%d?mode=memory&cache=private", databaseMemoryID.Add(1)))
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
conn, err := db.Conn(context.Background())
if err != nil {
db.Close()
return nil, err
}
err = conn.Raw(func(driver any) error {
d, ok := driver.(interface{ Deserialize([]byte) error })
if !ok {
return fmt.Errorf("gamedata: SQLite driver lacks Deserialize")
}
return d.Deserialize(plain)
})
if err == nil {
_, err = conn.ExecContext(context.Background(), "PRAGMA query_only=ON")
}
if err == nil {
_, err = conn.ExecContext(context.Background(), "PRAGMA cache_size=-4096")
}
if err == nil {
var n int
err = conn.QueryRowContext(context.Background(), "SELECT count(*) FROM sqlite_schema").Scan(&n)
}
closeErr := conn.Close()
if err == nil {
err = closeErr
}
if err != nil {
db.Close()
return nil, err
}
return db, nil
}
func (c *DatabaseCache) Open(root, version, logical string) (*sql.DB, func(), error) {
key, err := databaseKey(root, version, logical)
if err != nil {
return nil, nil, err
}
for {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil, nil, fmt.Errorf("gamedata: database cache closed")
}
if e, ok := c.entries[key]; ok {
if e.ready != nil {
ready := e.ready
c.mu.Unlock()
<-ready
if e.err != nil {
return nil, nil, e.err
}
continue
}
e.refs++
c.clock++
e.used = c.clock
c.mu.Unlock()
return e.db, c.release(e), nil
}
e := &databaseCacheEntry{ready: make(chan struct{})}
c.entries[key] = e
c.mu.Unlock()
// Serial loading limits peak encrypted + plaintext + SQLite image memory.
c.loadMu.Lock()
loadStarted := time.Now()
plain, loadErr := c.loader(root, version, logical)
var db *sql.DB
size := int64(len(plain))
if loadErr == nil {
current, e2 := databaseKey(root, version, logical)
if e2 != nil {
loadErr = e2
} else if current != key {
loadErr = fmt.Errorf("gamedata: archive changed during database load")
} else {
db, loadErr = memoryDatabase(plain)
}
}
plain = nil
c.loadMu.Unlock()
if loadErr == nil {
slog.Debug("GameData database cached", "logical", logical, "version", version, "image_bytes", size, "load_ms", float64(time.Since(loadStarted).Microseconds())/1000)
}
c.mu.Lock()
e.err = loadErr
ready := e.ready
e.ready = nil
if loadErr != nil || c.closed {
delete(c.entries, key)
if db != nil {
_ = db.Close()
}
if loadErr == nil {
e.err = fmt.Errorf("gamedata: database cache closed")
}
close(ready)
c.mu.Unlock()
return nil, nil, e.err
}
e.db = db
e.size = size
e.refs = 1
c.clock++
e.used = c.clock
c.bytes += size
// Retire earlier versions of a replaced archive. Existing leases remain
// valid until released, but cannot become a new request's database.
for oldKey, old := range c.entries {
if oldKey != key && oldKey.Path == key.Path && oldKey.Logical == key.Logical && old.ready == nil {
delete(c.entries, oldKey)
old.retired = true
if old.refs == 0 {
c.bytes -= old.size
_ = old.db.Close()
}
}
}
c.evictLocked()
close(ready)
c.mu.Unlock()
return db, c.release(e), nil
}
}
func (c *DatabaseCache) release(e *databaseCacheEntry) func() {
var once sync.Once
return func() {
once.Do(func() {
c.mu.Lock()
defer c.mu.Unlock()
e.refs--
if e.refs == 0 && e.retired {
c.bytes -= e.size
_ = e.db.Close()
}
c.evictLocked()
})
}
}
func (c *DatabaseCache) evictLocked() {
for c.bytes > c.budget {
var key databaseCacheKey
var oldest *databaseCacheEntry
for k, e := range c.entries {
if e.ready == nil && e.refs == 0 && (oldest == nil || e.used < oldest.used) {
key = k
oldest = e
}
}
if oldest == nil {
return
}
delete(c.entries, key)
c.bytes -= oldest.size
oldest.retired = true
_ = oldest.db.Close()
}
}
// Close retires all images; active queries finish before their final release.
func (c *DatabaseCache) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
var first error
for key, e := range c.entries {
if e.ready != nil {
continue
}
delete(c.entries, key)
e.retired = true
if e.refs == 0 {
c.bytes -= e.size
if err := e.db.Close(); err != nil && first == nil {
first = err
}
}
}
return first
}
func (c *DatabaseCache) WithDatabase(root, version, logical string, query func(*sql.DB) error) error {
if query == nil {
return fmt.Errorf("gamedata: missing query callback")
}
db, release, err := c.Open(root, version, logical)
if err != nil {
return err
}
defer release()
return query(db)
}
func OpenDatabase(root, version, logical string) (*sql.DB, func(), error) {
sharedDatabaseCacheMu.RLock()
defer sharedDatabaseCacheMu.RUnlock()
return sharedDatabaseCache.Open(root, version, logical)
}
func WithDatabase(root, version, logical string, query func(*sql.DB) error) error {
if query == nil {
return fmt.Errorf("gamedata: missing query callback")
}
db, release, err := OpenDatabase(root, version, logical)
if err != nil {
return err
}
defer release()
return query(db)
}
// CloseDatabaseCache retires the current generation. Active leases finish
// normally; the next OpenDatabase starts with a fresh empty cache.
func CloseDatabaseCache() error {
sharedDatabaseCacheMu.Lock()
old := sharedDatabaseCache
sharedDatabaseCache = NewDatabaseCache(DefaultDatabaseCacheBytes)
sharedDatabaseCacheMu.Unlock()
return old.Close()
}