Files
bd2/go/internal/server/gamedata/database_cache_test.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

323 lines
7.9 KiB
Go

package gamedata
import (
"archive/zip"
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
)
func cachePlain(t *testing.T, value int) []byte {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err = db.Exec(fmt.Sprintf("CREATE TABLE test(value INTEGER);INSERT INTO test VALUES(%d)", value)); err != nil {
t.Fatal(err)
}
conn, err := db.Conn(context.Background())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
var raw []byte
err = conn.Raw(func(v any) error {
var e error
raw, e = v.(interface{ Serialize() ([]byte, error) }).Serialize()
return e
})
if err != nil {
t.Fatal(err)
}
return raw
}
func cacheArchive(t *testing.T, root string, marker byte) {
t.Helper()
dir := filepath.Join(root, "v1", "release")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, ArchiveName)
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
for _, logical := range []string{"common", "pack1", "pack2"} {
name, _ := DatabaseName(logical)
entry, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err = entry.Write([]byte{marker}); err != nil {
t.Fatal(err)
}
}
if err = w.Close(); err != nil {
t.Fatal(err)
}
if err = f.Close(); err != nil {
t.Fatal(err)
}
}
func TestDatabaseCacheSingleFlightReadOnlyAndLifetime(t *testing.T) {
root := t.TempDir()
cacheArchive(t, root, 1)
plain := cachePlain(t, 7)
c := NewDatabaseCache(1 << 20)
defer c.Close()
var loads atomic.Int32
c.loader = func(string, string, string) ([]byte, error) { loads.Add(1); return plain, nil }
var wg sync.WaitGroup
for i := 0; i < 12; i++ {
wg.Add(1)
go func() {
defer wg.Done()
err := c.WithDatabase(root, "v1", "common", func(db *sql.DB) error {
var n int
if err := db.QueryRow("SELECT value FROM test").Scan(&n); err != nil {
return err
}
if n != 7 {
return fmt.Errorf("wrong value")
}
if _, err := db.Exec("INSERT INTO test VALUES(8)"); err == nil {
return fmt.Errorf("write accepted")
}
return nil
})
if err != nil {
t.Error(err)
}
}()
}
wg.Wait()
if loads.Load() != 1 {
t.Fatal("duplicate decrypt", loads.Load())
}
db, release, err := c.Open(root, "v1", "common")
if err != nil {
t.Fatal(err)
}
if err = c.Close(); err != nil {
t.Fatal(err)
}
var n int
if err = db.QueryRow("SELECT value FROM test").Scan(&n); err != nil {
t.Fatal("active lease closed", err)
}
release()
release()
if err = db.Ping(); err == nil {
t.Fatal("released retired database alive")
}
}
func TestDatabaseCacheFailureRetryReplacementAndEviction(t *testing.T) {
root := t.TempDir()
cacheArchive(t, root, 1)
plain := cachePlain(t, 1)
c := NewDatabaseCache(int64(len(plain)))
defer c.Close()
var loads int
c.loader = func(string, string, string) ([]byte, error) {
loads++
if loads == 1 {
return nil, fmt.Errorf("failure")
}
return plain, nil
}
if _, _, err := c.Open(root, "v1", "common"); err == nil {
t.Fatal("failure missing")
}
first, release, err := c.Open(root, "v1", "common")
if err != nil {
t.Fatal(err)
}
release()
second, done, err := c.Open(filepath.Join(root, "."), "v1", "common")
if err != nil || first != second || loads != 2 {
t.Fatal("canonical hit failed", err, loads)
}
done()
_, done, err = c.Open(root, "v1", "pack1")
if err != nil {
t.Fatal(err)
}
done()
if err = first.Ping(); err == nil {
t.Fatal("LRU not evicted")
}
old, oldRelease, err := c.Open(root, "v1", "pack1")
if err != nil {
t.Fatal(err)
}
cacheArchive(t, root, 2)
plain = cachePlain(t, 9)
current, currentRelease, err := c.Open(root, "v1", "pack1")
if err != nil {
t.Fatal(err)
}
if old == current {
t.Fatal("replacement reused stale image")
}
var n int
if err = current.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 9 {
t.Fatal("replacement wrong", n, err)
}
oldRelease()
currentRelease()
}
func TestDatabaseCacheMutationDuringLoadFailsAndDoesNotPoison(t *testing.T) {
root := t.TempDir()
cacheArchive(t, root, 1)
plain := cachePlain(t, 3)
c := NewDatabaseCache(1 << 20)
defer c.Close()
calls := 0
c.loader = func(string, string, string) ([]byte, error) {
calls++
if calls == 1 {
cacheArchive(t, root, 2)
}
return plain, nil
}
if _, _, err := c.Open(root, "v1", "common"); err == nil {
t.Fatal("replaced archive was cached")
}
db, release, err := c.Open(root, "v1", "common")
if err != nil {
t.Fatal(err)
}
defer release()
var n int
if err = db.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 3 || calls != 2 {
t.Fatal(n, calls, err)
}
}
func TestSharedDatabaseCacheCloseStartsFreshGeneration(t *testing.T) {
root := t.TempDir()
cacheArchive(t, root, 1)
plain := cachePlain(t, 11)
sharedDatabaseCacheMu.Lock()
previous := sharedDatabaseCache
current := NewDatabaseCache(1 << 20)
current.loader = func(string, string, string) ([]byte, error) { return plain, nil }
sharedDatabaseCache = current
sharedDatabaseCacheMu.Unlock()
t.Cleanup(func() {
_ = CloseDatabaseCache()
sharedDatabaseCacheMu.Lock()
empty := sharedDatabaseCache
sharedDatabaseCache = previous
sharedDatabaseCacheMu.Unlock()
_ = empty.Close()
})
old, releaseOld, err := OpenDatabase(root, "v1", "common")
if err != nil {
t.Fatal(err)
}
defer releaseOld()
if err = CloseDatabaseCache(); err != nil {
t.Fatal(err)
}
sharedDatabaseCacheMu.Lock()
fresh := sharedDatabaseCache
fresh.loader = func(string, string, string) ([]byte, error) { return plain, nil }
sharedDatabaseCacheMu.Unlock()
if fresh == current {
t.Fatal("global generation not replaced")
}
var n int
if err = old.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 11 {
t.Fatal("retired active lease unavailable", n, err)
}
newDB, releaseNew, err := OpenDatabase(root, "v1", "common")
if err != nil {
t.Fatal(err)
}
defer releaseNew()
if newDB == old {
t.Fatal("new generation reused old handle")
}
if err = newDB.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 11 {
t.Fatal(n, err)
}
releaseOld()
if err = old.Ping(); err == nil {
t.Fatal("retired released generation retained database")
}
}
func TestDatabaseCacheRootAndVersionIsolation(t *testing.T) {
rootA, rootB := t.TempDir(), t.TempDir()
cacheArchive(t, rootA, 1)
cacheArchive(t, rootB, 1)
archive, err := os.ReadFile(filepath.Join(rootA, "v1", "release", ArchiveName))
if err != nil {
t.Fatal(err)
}
v2dir := filepath.Join(rootA, "v2", "release")
if err = os.MkdirAll(v2dir, 0700); err != nil {
t.Fatal(err)
}
if err = os.WriteFile(filepath.Join(v2dir, ArchiveName), archive, 0600); err != nil {
t.Fatal(err)
}
plainA, plainB, plainV2 := cachePlain(t, 1), cachePlain(t, 2), cachePlain(t, 3)
c := NewDatabaseCache(1 << 20)
defer c.Close()
loads := 0
c.loader = func(root, version, _ string) ([]byte, error) {
loads++
if version == "v2" {
return plainV2, nil
}
if root == rootB {
return plainB, nil
}
return plainA, nil
}
type item struct {
root, version string
want int
}
handles := map[*sql.DB]bool{}
for _, input := range []item{{rootA, "v1", 1}, {rootB, "v1", 2}, {rootA, "v2", 3}} {
db, release, err := c.Open(input.root, input.version, "common")
if err != nil {
t.Fatal(err)
}
var value int
if err = db.QueryRow("SELECT value FROM test").Scan(&value); err != nil || value != input.want {
release()
t.Fatal("root/version crossed", value, input.want, err)
}
if handles[db] {
release()
t.Fatal("isolated inputs shared handle")
}
handles[db] = true
release()
}
if loads != 3 {
t.Fatal("isolated inputs did not load separately", loads)
}
for _, input := range []item{{rootA, "v1", 1}, {rootB, "v1", 2}, {rootA, "v2", 3}} {
if err = c.WithDatabase(input.root, input.version, "common", func(db *sql.DB) error { return db.Ping() }); err != nil {
t.Fatal(err)
}
}
if loads != 3 {
t.Fatal("isolated caches did not hit", loads)
}
}