Files
bd2/go/internal/statetx/transaction.go
T
Flechazo 113e941bef feat: expand gameplay and state persistence
- add character imprint, awakening, and equipment refinement

- add equipment ticket gacha and persistent progression fields

- add request-scoped account transactions with crash recovery

- persist age confirmation and isolate client plugin patches

- separate one-off save repair tools from shipped tooling
2026-09-23 00:55:36 +08:00

201 lines
4.9 KiB
Go

// Package statetx provides a crash-safe write-ahead transaction around one
// account request. It knows filenames and bytes only; domain stores retain all
// JSON and gameplay semantics.
package statetx
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
const journalName = ".account-transaction"
type Coordinator struct {
mu sync.Mutex
root string
allowed map[string]bool
failed bool
cache map[string]fileDigest
}
type fileDigest struct {
info os.FileInfo
digest string
}
// Open validates the account boundary and recovers an interrupted request
// before any domain store reads its state.
func Open(root string, allowed []string) (*Coordinator, error) {
if len(allowed) == 0 {
return nil, errors.New("statetx: no state filenames")
}
s := &Coordinator{root: filepath.Clean(root), allowed: make(map[string]bool, len(allowed)), cache: make(map[string]fileDigest, len(allowed))}
for _, name := range allowed {
if err := validName(name); err != nil || s.allowed[name] {
return nil, fmt.Errorf("statetx: invalid or duplicate allowed filename %q", name)
}
s.allowed[name] = true
}
if err := s.recoverLocked(); err != nil {
return nil, err
}
if err := s.refreshCacheLocked(); err != nil {
return nil, err
}
return s, nil
}
func (s *Coordinator) refreshCacheLocked() error {
next := make(map[string]fileDigest, len(s.allowed))
for name := range s.allowed {
path := filepath.Join(s.root, name)
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("statetx: %s is not a regular state file", name)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
next[name] = fileDigest{info: info, digest: digest(data)}
}
s.cache = next
return nil
}
// Check is the request dispatcher fail-stop gate. Once memory could differ
// from a disk generation restored after an error, only restart may continue.
func (s *Coordinator) Check() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.failed {
return errors.New("statetx: account persistence requires restart recovery")
}
return nil
}
func validName(name string) error {
if name == "" || name == "." || name == ".." || filepath.Base(name) != name || strings.ContainsAny(name, `/\:`) || name == journalName {
return errors.New("filename is not a direct child")
}
return nil
}
func digest(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func (s *Coordinator) recoverLocked() error {
preparing := filepath.Join(s.root, journalName+".preparing")
if info, err := os.Lstat(preparing); err == nil {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("statetx: preparing path is not a directory")
}
if err := os.RemoveAll(preparing); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
journal := filepath.Join(s.root, journalName)
info, err := os.Lstat(journal)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("statetx: journal is not a directory")
}
if _, err := os.Lstat(filepath.Join(journal, "prepared.json")); err == nil {
return s.recoverRequestLocked(journal)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
// Begin writes undo.json and then prepared before gameplay may execute.
// Therefore a journal without undo.json cannot have touched live state.
return os.RemoveAll(journal)
}
func writeAtomicSynced(path string, data []byte) error {
dir := filepath.Dir(path)
file, err := os.CreateTemp(dir, ".statetx-phase-*.tmp")
if err != nil {
return err
}
name := file.Name()
defer os.Remove(name)
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
if err := replaceFile(name, path); err != nil {
return err
}
return syncDir(dir)
}
func writeSynced(path string, data []byte) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
}
_, err = f.Write(data)
if err == nil {
err = f.Sync()
}
if closeErr := f.Close(); err == nil {
err = closeErr
}
return err
}
func copySynced(source, destination string) error {
data, err := os.ReadFile(source)
if err != nil {
return err
}
return writeSynced(destination, data)
}
func replaceFromImage(image, target string) error {
data, err := os.ReadFile(image)
if err != nil {
return err
}
file, err := os.CreateTemp(filepath.Dir(target), ".account-*.tmp")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
return replaceFile(file.Name(), target)
}