fs storage

This commit is contained in:
Ilia Denisov
2026-03-13 21:07:23 +02:00
committed by GitHub
parent 43039a79bf
commit 9ade76e21d
117 changed files with 1734 additions and 176 deletions
+680 -51
View File
@@ -1,75 +1,704 @@
// fs implements galaxy/storage.Storage with filesystem
// Package fs implements galaxy/storage.Storage using the filesystem.
package fs
/*
Общие правила:
1. Все хранимые объекты сериализуются / десериализуются как JSON.
2. Структура хранения файлов:
- storageRoot \
|
+-- state.dat
|
+-- {GameID} \
| |
| +-- {Turn}.dat (client.GameData)
| +-- {Turn}.dat (client.GameData)
| +-- ...
|
+-- {GameID} \
|
+-- ...
*/
import (
"encoding/json"
"errors"
"fmt"
"galaxy/util"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"galaxy/model/client"
"galaxy/model/order"
"galaxy/model/report"
"galaxy/util"
)
const (
// Name of the file under the storage's root where [model.State] is stored.
// stateFileName is the file name under the storage root where [client.State] is stored.
stateFileName = "state.dat"
// Suffix of a Game's file inder the storage's root where [model.GameData] is stored.
// gameDataFileSuffix is the extension for per-turn [client.GameData] files.
gameDataFileSuffix = ".dat"
defaultFilePerm = 0o644
oldFileSuffix = ".old"
newFileSuffix = ".new"
)
// StateFilePath returns client's state file path relative to the root,
// file name and extension are pre-defined constant.
// StateFilePath returns the path to the persisted [client.State] file under root.
func StateFilePath(root string) string {
return filepath.Join(root, stateFileName)
}
// GameDataPath returns game's data file path relative to the root,
// data file name is GameID string representation and extension is a pre-defined constant.
// GameDataFilePath returns the legacy per-game data file path under root.
//
// The storage implementation keeps turn data in per-turn files under a game directory
// and does not use this helper internally.
func GameDataFilePath(root string, id fmt.Stringer) string {
return filepath.Join(root, id.String()) + gameDataFileSuffix
}
type fsStorage struct {
storageRoot string
type pathLock struct {
mu sync.Mutex
refs int
}
// NewFS returns on-filesystem implementation of the "galaxy/storage.Storage" with root located at storageRoot.
// storageRoot must me a directory and has write access to the current user. If initial checks failed, return nil and non-nil error.
func NewStorage(storageRoot string) (*fsStorage, error) {
if ok, err := util.PathExists(storageRoot, true); err != nil {
return nil, fmt.Errorf("new storage: check path %q exists: %w", storageRoot, err)
} else if !ok {
return nil, fmt.Errorf("new storage: path %q does not exists", storageRoot)
}
if ok, err := util.Writable(storageRoot); err != nil {
return nil, fmt.Errorf("new storage: check path %q writable: %w", storageRoot, err)
} else if !ok {
return nil, fmt.Errorf("new storage: path %q is not writable", storageRoot)
}
s := &fsStorage{
storageRoot: storageRoot,
}
return s, nil
type fsStorage struct {
storageRoot string
locksMu sync.Mutex
locks map[string]*pathLock
readFileFn func(string) ([]byte, error)
writeFileFn func(string, []byte, os.FileMode) error
renameFileFn func(string, string) error
removeFileFn func(string) error
}
type storedOrder struct {
UpdatedAt int `json:"updatedAt"`
Commands []json.RawMessage `json:"cmd"`
}
type storedGameData struct {
Turn uint `json:"turn"`
Report report.Report `json:"report"`
Order *storedOrder `json:"order,omitempty"`
}
// NewFS returns a filesystem-backed implementation of galaxy/storage.Storage rooted at storageRoot.
// storageRoot must already exist, be a directory, and be writable by the current user.
func NewFS(storageRoot string) (*fsStorage, error) {
fmt.Println("using fs root:", storageRoot)
absRoot, err := filepath.Abs(storageRoot)
if err != nil {
return nil, fmt.Errorf("new fs storage: resolve absolute path for %q: %w", storageRoot, err)
}
if ok, err := util.PathExists(absRoot, true); err != nil {
return nil, fmt.Errorf("new fs storage: check path %q exists: %w", absRoot, err)
} else if !ok {
return nil, fmt.Errorf("new fs storage: path %q does not exist", absRoot)
}
if ok, err := util.Writable(absRoot); err != nil {
return nil, fmt.Errorf("new fs storage: check path %q writable: %w", absRoot, err)
} else if !ok {
return nil, fmt.Errorf("new fs storage: path %q is not writable", absRoot)
}
return &fsStorage{
storageRoot: absRoot,
locks: make(map[string]*pathLock),
readFileFn: os.ReadFile,
writeFileFn: os.WriteFile,
renameFileFn: os.Rename,
removeFileFn: os.Remove,
}, nil
}
func (s *fsStorage) StateExists(callback func(bool, error)) {
go func() {
exists, err := s.FileExists(stateFileName)
if callback != nil {
callback(exists, err)
}
}()
}
func (s *fsStorage) LoadState(callback func(client.State, error)) {
go func() {
state, err := s.loadStateSync()
if callback != nil {
callback(state, err)
}
}()
}
func (s *fsStorage) SaveState(state client.State, callback func(error)) {
go func() {
err := s.saveStateSync(state)
if callback != nil {
callback(err)
}
}()
}
func (s *fsStorage) LoadReport(id client.GameID, turn uint, callback func(report.Report, error)) {
go func() {
rep, err := s.loadReportSync(id, turn)
if callback != nil {
callback(rep, err)
}
}()
}
func (s *fsStorage) SaveReport(id client.GameID, turn uint, rep report.Report, callback func(error)) {
go func() {
err := s.saveReportSync(id, turn, rep)
if callback != nil {
callback(err)
}
}()
}
func (s *fsStorage) LoadOrder(id client.GameID, turn uint, callback func(order.Order, error)) {
go func() {
o, err := s.loadOrderSync(id, turn)
if callback != nil {
callback(o, err)
}
}()
}
func (s *fsStorage) SaveOrder(id client.GameID, turn uint, o order.Order, callback func(error)) {
go func() {
err := s.saveOrderSync(id, turn, o)
if callback != nil {
callback(err)
}
}()
}
func (s *fsStorage) FileExists(path string) (bool, error) {
absPath, err := s.resolvePath(path)
if err != nil {
return false, err
}
var exists bool
err = s.withPathLock(absPath, func() error {
var opErr error
exists, opErr = s.fileExistsUnlocked(absPath)
return opErr
})
return exists, err
}
func (s *fsStorage) ReadFile(path string) ([]byte, error) {
absPath, err := s.resolvePath(path)
if err != nil {
return nil, err
}
var data []byte
err = s.withPathLock(absPath, func() error {
var opErr error
data, opErr = s.readFileUnlocked(absPath)
return opErr
})
return data, err
}
func (s *fsStorage) WriteFile(path string, data []byte) error {
absPath, err := s.resolvePath(path)
if err != nil {
return err
}
return s.withPathLock(absPath, func() error {
return s.writeFileUnlocked(absPath, data)
})
}
func (s *fsStorage) DeleteFile(path string) error {
absPath, err := s.resolvePath(path)
if err != nil {
return err
}
return s.withPathLock(absPath, func() error {
exists, err := s.fileExistsUnlocked(absPath)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("delete file %q: %w", absPath, os.ErrNotExist)
}
if err := s.removeFileFn(absPath); err != nil {
return fmt.Errorf("delete file %q: %w", absPath, err)
}
return nil
})
}
func (s *fsStorage) ListFiles() ([]string, error) {
files := make([]string, 0)
err := filepath.WalkDir(s.storageRoot, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(d.Name(), newFileSuffix) || strings.HasSuffix(d.Name(), oldFileSuffix) {
return nil
}
relPath, err := filepath.Rel(s.storageRoot, path)
if err != nil {
return fmt.Errorf("resolve relative path for %q: %w", path, err)
}
files = append(files, filepath.Clean(relPath))
return nil
})
if err != nil {
return nil, fmt.Errorf("list files under %q: %w", s.storageRoot, err)
}
slices.Sort(files)
return files, nil
}
func (s *fsStorage) loadStateSync() (client.State, error) {
data, err := s.ReadFile(stateFileName)
if err != nil {
return client.State{}, err
}
return unmarshalState(data)
}
func (s *fsStorage) saveStateSync(state client.State) error {
data, err := marshalState(state)
if err != nil {
return err
}
return s.WriteFile(stateFileName, data)
}
func (s *fsStorage) loadReportSync(id client.GameID, turn uint) (report.Report, error) {
gameData, err := s.loadGameDataSync(id, turn)
if err != nil {
return report.Report{}, err
}
return gameData.Report, nil
}
func (s *fsStorage) saveReportSync(id client.GameID, turn uint, rep report.Report) error {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil {
return err
}
return s.withPathLock(absPath, func() error {
gameData, err := s.loadGameDataUnlocked(absPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
gameData = client.GameData{Turn: turn}
}
gameData.Turn = turn
gameData.Report = rep
return s.writeGameDataUnlocked(absPath, gameData)
})
}
func (s *fsStorage) loadOrderSync(id client.GameID, turn uint) (order.Order, error) {
gameData, err := s.loadGameDataSync(id, turn)
if err != nil {
return order.Order{}, err
}
if gameData.Order == nil {
return order.Order{}, fmt.Errorf("load order for game %q turn %d: %w", id, turn, os.ErrNotExist)
}
return *gameData.Order, nil
}
func (s *fsStorage) saveOrderSync(id client.GameID, turn uint, o order.Order) error {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil {
return err
}
return s.withPathLock(absPath, func() error {
gameData, err := s.loadGameDataUnlocked(absPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("save order for game %q turn %d: %w", id, turn, os.ErrNotExist)
}
return err
}
gameData.Turn = turn
gameData.Order = &o
return s.writeGameDataUnlocked(absPath, gameData)
})
}
func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameData, error) {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil {
return client.GameData{}, err
}
var gameData client.GameData
err = s.withPathLock(absPath, func() error {
var opErr error
gameData, opErr = s.loadGameDataUnlocked(absPath)
return opErr
})
return gameData, err
}
func (s *fsStorage) loadGameDataUnlocked(absPath string) (client.GameData, error) {
data, err := s.readFileUnlocked(absPath)
if err != nil {
return client.GameData{}, err
}
return unmarshalGameData(data)
}
func (s *fsStorage) writeGameDataUnlocked(absPath string, gameData client.GameData) error {
data, err := marshalGameData(gameData)
if err != nil {
return err
}
return s.writeFileUnlocked(absPath, data)
}
func marshalState(state client.State) ([]byte, error) {
return marshalJSON(state)
}
func unmarshalState(data []byte) (client.State, error) {
var state client.State
if err := unmarshalJSON(data, &state); err != nil {
return client.State{}, err
}
return state, nil
}
func marshalGameData(gameData client.GameData) ([]byte, error) {
stored, err := makeStoredGameData(gameData)
if err != nil {
return nil, err
}
return marshalJSON(stored)
}
func unmarshalGameData(data []byte) (client.GameData, error) {
var stored storedGameData
if err := unmarshalJSON(data, &stored); err != nil {
return client.GameData{}, err
}
return stored.toGameData()
}
func marshalJSON(value any) ([]byte, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("marshal json: %w", err)
}
return data, nil
}
func unmarshalJSON(data []byte, target any) error {
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("unmarshal json: %w", err)
}
return nil
}
func makeStoredGameData(gameData client.GameData) (storedGameData, error) {
stored := storedGameData{
Turn: gameData.Turn,
Report: gameData.Report,
}
if gameData.Order == nil {
return stored, nil
}
storedOrder, err := makeStoredOrder(*gameData.Order)
if err != nil {
return storedGameData{}, err
}
stored.Order = &storedOrder
return stored, nil
}
func (d storedGameData) toGameData() (client.GameData, error) {
gameData := client.GameData{
Turn: d.Turn,
Report: d.Report,
}
if d.Order == nil {
return gameData, nil
}
o, err := d.Order.toOrder()
if err != nil {
return client.GameData{}, err
}
gameData.Order = o
return gameData, nil
}
func makeStoredOrder(o order.Order) (storedOrder, error) {
result := storedOrder{
UpdatedAt: o.UpdatedAt,
Commands: make([]json.RawMessage, len(o.Commands)),
}
for i := range o.Commands {
data, err := marshalJSON(o.Commands[i])
if err != nil {
return storedOrder{}, fmt.Errorf("marshal order command %d: %w", i, err)
}
result.Commands[i] = data
}
return result, nil
}
func (o *storedOrder) toOrder() (*order.Order, error) {
if o == nil {
return nil, nil
}
result := &order.Order{
UpdatedAt: o.UpdatedAt,
Commands: make([]order.DecodableCommand, len(o.Commands)),
}
for i := range o.Commands {
cmd, err := parseOrderCommand(o.Commands[i])
if err != nil {
return nil, fmt.Errorf("decode order command %d: %w", i, err)
}
result.Commands[i] = cmd
}
return result, nil
}
func parseOrderCommand(data json.RawMessage) (order.DecodableCommand, error) {
meta := new(order.CommandMeta)
if err := json.Unmarshal(data, meta); err != nil {
return nil, fmt.Errorf("decode order command metadata: %w", err)
}
switch meta.CmdType {
case order.CommandTypeRaceQuit:
return decodeOrderCommand(data, new(order.CommandRaceQuit))
case order.CommandTypeRaceVote:
return decodeOrderCommand(data, new(order.CommandRaceVote))
case order.CommandTypeRaceRelation:
return decodeOrderCommand(data, new(order.CommandRaceRelation))
case order.CommandTypeShipClassCreate:
return decodeOrderCommand(data, new(order.CommandShipClassCreate))
case order.CommandTypeShipClassMerge:
return decodeOrderCommand(data, new(order.CommandShipClassMerge))
case order.CommandTypeShipClassRemove:
return decodeOrderCommand(data, new(order.CommandShipClassRemove))
case order.CommandTypeShipGroupBreak:
return decodeOrderCommand(data, new(order.CommandShipGroupBreak))
case order.CommandTypeShipGroupLoad:
return decodeOrderCommand(data, new(order.CommandShipGroupLoad))
case order.CommandTypeShipGroupUnload:
return decodeOrderCommand(data, new(order.CommandShipGroupUnload))
case order.CommandTypeShipGroupSend:
return decodeOrderCommand(data, new(order.CommandShipGroupSend))
case order.CommandTypeShipGroupUpgrade:
return decodeOrderCommand(data, new(order.CommandShipGroupUpgrade))
case order.CommandTypeShipGroupMerge:
return decodeOrderCommand(data, new(order.CommandShipGroupMerge))
case order.CommandTypeShipGroupDismantle:
return decodeOrderCommand(data, new(order.CommandShipGroupDismantle))
case order.CommandTypeShipGroupTransfer:
return decodeOrderCommand(data, new(order.CommandShipGroupTransfer))
case order.CommandTypeShipGroupJoinFleet:
return decodeOrderCommand(data, new(order.CommandShipGroupJoinFleet))
case order.CommandTypeFleetMerge:
return decodeOrderCommand(data, new(order.CommandFleetMerge))
case order.CommandTypeFleetSend:
return decodeOrderCommand(data, new(order.CommandFleetSend))
case order.CommandTypeScienceCreate:
return decodeOrderCommand(data, new(order.CommandScienceCreate))
case order.CommandTypeScienceRemove:
return decodeOrderCommand(data, new(order.CommandScienceRemove))
case order.CommandTypePlanetRename:
return decodeOrderCommand(data, new(order.CommandPlanetRename))
case order.CommandTypePlanetProduce:
return decodeOrderCommand(data, new(order.CommandPlanetProduce))
case order.CommandTypePlanetRouteSet:
return decodeOrderCommand(data, new(order.CommandPlanetRouteSet))
case order.CommandTypePlanetRouteRemove:
return decodeOrderCommand(data, new(order.CommandPlanetRouteRemove))
default:
return nil, fmt.Errorf("unknown order command type %q", meta.CmdType)
}
}
func decodeOrderCommand[T order.DecodableCommand](data json.RawMessage, target T) (T, error) {
if err := json.Unmarshal(data, target); err != nil {
return target, err
}
return target, nil
}
func (s *fsStorage) resolvePath(path string) (string, error) {
relPath, err := normalizeRelativePath(path)
if err != nil {
return "", err
}
return filepath.Join(s.storageRoot, relPath), nil
}
func normalizeRelativePath(path string) (string, error) {
path = strings.ReplaceAll(path, "\\", string(filepath.Separator))
path = strings.TrimLeft(path, string(filepath.Separator))
path = filepath.Clean(path)
switch {
case path == "." || path == "":
return "", errors.New("path must not be empty")
case filepath.IsAbs(path):
return "", fmt.Errorf("path %q must be relative", path)
case filepath.VolumeName(path) != "":
return "", fmt.Errorf("path %q must not include a volume name", path)
case path == "..":
return "", fmt.Errorf("path %q escapes storage root", path)
case strings.HasPrefix(path, ".."+string(filepath.Separator)):
return "", fmt.Errorf("path %q escapes storage root", path)
}
return path, nil
}
func gameTurnFilePath(id fmt.Stringer, turn uint) string {
return filepath.Join(id.String(), strconv.FormatUint(uint64(turn), 10)+gameDataFileSuffix)
}
func (s *fsStorage) withPathLock(absPath string, fn func() error) error {
lock := s.acquirePathLock(absPath)
defer s.releasePathLock(absPath)
lock.mu.Lock()
defer lock.mu.Unlock()
return fn()
}
func (s *fsStorage) acquirePathLock(absPath string) *pathLock {
s.locksMu.Lock()
defer s.locksMu.Unlock()
lock, ok := s.locks[absPath]
if !ok {
lock = &pathLock{}
s.locks[absPath] = lock
}
lock.refs++
return lock
}
func (s *fsStorage) releasePathLock(absPath string) {
s.locksMu.Lock()
defer s.locksMu.Unlock()
lock, ok := s.locks[absPath]
if !ok {
return
}
lock.refs--
if lock.refs == 0 {
delete(s.locks, absPath)
}
}
func (s *fsStorage) fileExistsUnlocked(absPath string) (bool, error) {
ok, err := util.FileExists(absPath)
if err != nil {
return false, fmt.Errorf("check file %q exists: %w", absPath, err)
}
return ok, nil
}
func (s *fsStorage) readFileUnlocked(absPath string) ([]byte, error) {
data, err := s.readFileFn(absPath)
if err != nil {
return nil, fmt.Errorf("read file %q: %w", absPath, err)
}
return data, nil
}
func (s *fsStorage) writeFileUnlocked(absPath string, data []byte) error {
if err := s.ensureParentDir(absPath); err != nil {
return err
}
targetExists, err := s.fileExistsUnlocked(absPath)
if err != nil {
return err
}
oldPath := absPath + oldFileSuffix
oldExists, err := s.fileExistsUnlocked(oldPath)
if err != nil {
return err
}
if oldExists {
return fmt.Errorf("write file %q: old file already exists at %q", absPath, oldPath)
}
newPath := absPath + newFileSuffix
newExists, err := s.fileExistsUnlocked(newPath)
if err != nil {
return err
}
if newExists {
return fmt.Errorf("write file %q: new file already exists at %q", absPath, newPath)
}
if err := s.writeFileFn(newPath, data, defaultFilePerm); err != nil {
return fmt.Errorf("write new file %q: %w", newPath, err)
}
if targetExists {
if err := s.renameFileFn(absPath, oldPath); err != nil {
return errors.Join(
fmt.Errorf("rename file %q to %q: %w", absPath, oldPath, err),
s.cleanupTempFile(newPath),
)
}
}
if err := s.renameFileFn(newPath, absPath); err != nil {
var restoreErr error
if targetExists {
restoreErr = s.renameFileFn(oldPath, absPath)
if restoreErr != nil {
restoreErr = fmt.Errorf("restore file %q from %q: %w", absPath, oldPath, restoreErr)
}
}
return errors.Join(
fmt.Errorf("rename new file %q to %q: %w", newPath, absPath, err),
restoreErr,
s.cleanupTempFile(newPath),
)
}
if !targetExists {
return nil
}
if err := s.removeFileFn(oldPath); err != nil {
return fmt.Errorf("remove old file %q: %w", oldPath, err)
}
return nil
}
func (s *fsStorage) cleanupTempFile(path string) error {
if err := s.removeFileFn(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove temp file %q: %w", path, err)
}
return nil
}
func (s *fsStorage) ensureParentDir(absPath string) error {
parentDir := filepath.Dir(absPath)
if err := os.MkdirAll(parentDir, os.ModePerm); err != nil {
return fmt.Errorf("create parent directory %q: %w", parentDir, err)
}
return nil
}
+529
View File
@@ -0,0 +1,529 @@
package fs
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"galaxy/model/client"
"galaxy/model/order"
"galaxy/model/report"
)
const testTimeout = time.Second
type callbackResult[T any] struct {
value T
err error
}
func TestStateRoundTripAsync(t *testing.T) {
s := newTestStorage(t)
want := sampleState()
saveDone := make(chan error, 1)
s.SaveState(want, func(err error) {
saveDone <- err
})
if err := waitError(t, saveDone); err != nil {
t.Fatalf("save state: %v", err)
}
existsDone := make(chan callbackResult[bool], 1)
s.StateExists(func(ok bool, err error) {
existsDone <- callbackResult[bool]{value: ok, err: err}
})
exists := waitResult(t, existsDone)
if exists.err != nil {
t.Fatalf("state exists: %v", exists.err)
}
if !exists.value {
t.Fatal("state file should exist after save")
}
loadDone := make(chan callbackResult[client.State], 1)
s.LoadState(func(state client.State, err error) {
loadDone <- callbackResult[client.State]{value: state, err: err}
})
got := waitResult(t, loadDone)
if got.err != nil {
t.Fatalf("load state: %v", got.err)
}
if !reflect.DeepEqual(got.value, want) {
t.Fatalf("loaded state mismatch\nwant: %#v\ngot: %#v", want, got.value)
}
}
func TestReportAndOrderRoundTripAsync(t *testing.T) {
s := newTestStorage(t)
id := client.GameID("game-1")
turn := uint(7)
initialReport := sampleReport(turn, "Terran")
updatedReport := sampleReport(turn, "Zenith")
wantOrder := sampleOrder()
saveReportDone := make(chan error, 1)
s.SaveReport(id, turn, initialReport, func(err error) {
saveReportDone <- err
})
if err := waitError(t, saveReportDone); err != nil {
t.Fatalf("save report: %v", err)
}
saveOrderDone := make(chan error, 1)
s.SaveOrder(id, turn, wantOrder, func(err error) {
saveOrderDone <- err
})
if err := waitError(t, saveOrderDone); err != nil {
t.Fatalf("save order: %v", err)
}
saveUpdatedReportDone := make(chan error, 1)
s.SaveReport(id, turn, updatedReport, func(err error) {
saveUpdatedReportDone <- err
})
if err := waitError(t, saveUpdatedReportDone); err != nil {
t.Fatalf("save updated report: %v", err)
}
loadReportDone := make(chan callbackResult[report.Report], 1)
s.LoadReport(id, turn, func(rep report.Report, err error) {
loadReportDone <- callbackResult[report.Report]{value: rep, err: err}
})
gotReport := waitResult(t, loadReportDone)
if gotReport.err != nil {
t.Fatalf("load report: %v", gotReport.err)
}
if !reflect.DeepEqual(gotReport.value, updatedReport) {
t.Fatalf("loaded report mismatch\nwant: %#v\ngot: %#v", updatedReport, gotReport.value)
}
loadOrderDone := make(chan callbackResult[order.Order], 1)
s.LoadOrder(id, turn, func(got order.Order, err error) {
loadOrderDone <- callbackResult[order.Order]{value: got, err: err}
})
gotOrder := waitResult(t, loadOrderDone)
if gotOrder.err != nil {
t.Fatalf("load order: %v", gotOrder.err)
}
if !reflect.DeepEqual(gotOrder.value, wantOrder) {
t.Fatalf("loaded order mismatch\nwant: %#v\ngot: %#v", wantOrder, gotOrder.value)
}
}
func TestSaveOrderBeforeReportReturnsNotExist(t *testing.T) {
s := newTestStorage(t)
done := make(chan error, 1)
s.SaveOrder("game-2", 3, sampleOrder(), func(err error) {
done <- err
})
err := waitError(t, done)
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("save order error = %v, want os.ErrNotExist", err)
}
}
func TestRawFileCRUDAndList(t *testing.T) {
s := newTestStorage(t)
if err := s.WriteFile("/nested/alpha.txt", []byte("alpha")); err != nil {
t.Fatalf("write alpha: %v", err)
}
if err := s.WriteFile("beta.txt", []byte("beta")); err != nil {
t.Fatalf("write beta: %v", err)
}
alphaExists, err := s.FileExists("nested/alpha.txt")
if err != nil {
t.Fatalf("file exists: %v", err)
}
if !alphaExists {
t.Fatal("nested/alpha.txt should exist")
}
alphaData, err := s.ReadFile("nested/alpha.txt")
if err != nil {
t.Fatalf("read alpha: %v", err)
}
if string(alphaData) != "alpha" {
t.Fatalf("read alpha = %q, want %q", alphaData, "alpha")
}
if err := os.WriteFile(filepath.Join(s.storageRoot, "skip.txt"+newFileSuffix), []byte("tmp"), 0o644); err != nil {
t.Fatalf("create stale .new file: %v", err)
}
if err := os.WriteFile(filepath.Join(s.storageRoot, "skip.txt"+oldFileSuffix), []byte("tmp"), 0o644); err != nil {
t.Fatalf("create stale .old file: %v", err)
}
files, err := s.ListFiles()
if err != nil {
t.Fatalf("list files: %v", err)
}
wantFiles := []string{
"beta.txt",
filepath.Join("nested", "alpha.txt"),
}
if !reflect.DeepEqual(files, wantFiles) {
t.Fatalf("listed files mismatch\nwant: %#v\ngot: %#v", wantFiles, files)
}
if err := s.DeleteFile("beta.txt"); err != nil {
t.Fatalf("delete beta: %v", err)
}
if err := s.DeleteFile("beta.txt"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("delete missing beta error = %v, want os.ErrNotExist", err)
}
}
func TestPathTraversalRejected(t *testing.T) {
s := newTestStorage(t)
for _, path := range []string{"../escape.txt", "..\\escape.txt", ""} {
t.Run(path, func(t *testing.T) {
err := s.WriteFile(path, []byte("blocked"))
if err == nil {
t.Fatalf("write %q unexpectedly succeeded", path)
}
})
}
}
func TestAtomicWriteFirstAndOverwrite(t *testing.T) {
s := newTestStorage(t)
target := filepath.Join("turns", "12.bin")
if err := s.WriteFile(target, []byte("first")); err != nil {
t.Fatalf("first write: %v", err)
}
assertFileContent(t, s, target, "first")
assertNoTempArtifacts(t, s, target)
if err := s.WriteFile(target, []byte("second")); err != nil {
t.Fatalf("overwrite: %v", err)
}
assertFileContent(t, s, target, "second")
assertNoTempArtifacts(t, s, target)
}
func TestAtomicWriteStaleTempCollision(t *testing.T) {
t.Run("stale new file", func(t *testing.T) {
s := newTestStorage(t)
target := "collision-new.txt"
absTarget, err := s.resolvePath(target)
if err != nil {
t.Fatalf("resolve target: %v", err)
}
if err := os.MkdirAll(filepath.Dir(absTarget), os.ModePerm); err != nil {
t.Fatalf("create parent dir: %v", err)
}
if err := os.WriteFile(absTarget+newFileSuffix, []byte("stale"), 0o644); err != nil {
t.Fatalf("write stale new file: %v", err)
}
err = s.WriteFile(target, []byte("payload"))
if err == nil || !strings.Contains(err.Error(), "new file already exists") {
t.Fatalf("write error = %v, want stale new file error", err)
}
})
t.Run("stale old file", func(t *testing.T) {
s := newTestStorage(t)
target := "collision-old.txt"
absTarget, err := s.resolvePath(target)
if err != nil {
t.Fatalf("resolve target: %v", err)
}
if err := os.WriteFile(absTarget, []byte("current"), 0o644); err != nil {
t.Fatalf("write target: %v", err)
}
if err := os.WriteFile(absTarget+oldFileSuffix, []byte("stale"), 0o644); err != nil {
t.Fatalf("write stale old file: %v", err)
}
err = s.WriteFile(target, []byte("payload"))
if err == nil || !strings.Contains(err.Error(), "old file already exists") {
t.Fatalf("write error = %v, want stale old file error", err)
}
})
}
func TestAtomicWriteRollbackOnRenameFailure(t *testing.T) {
s := newTestStorage(t)
target := filepath.Join("rollback", "state.txt")
absTarget, err := s.resolvePath(target)
if err != nil {
t.Fatalf("resolve target: %v", err)
}
if err := s.WriteFile(target, []byte("original")); err != nil {
t.Fatalf("seed target file: %v", err)
}
origRename := s.renameFileFn
s.renameFileFn = func(oldPath, newPath string) error {
if oldPath == absTarget+newFileSuffix && newPath == absTarget {
return errors.New("forced rename failure")
}
return origRename(oldPath, newPath)
}
err = s.WriteFile(target, []byte("replacement"))
if err == nil || !strings.Contains(err.Error(), "forced rename failure") {
t.Fatalf("write error = %v, want forced rename failure", err)
}
assertFileContent(t, s, target, "original")
assertNoTempArtifacts(t, s, target)
}
func TestSamePathOperationsSerialize(t *testing.T) {
s := newTestStorage(t)
target := "shared.txt"
absTarget, err := s.resolvePath(target)
if err != nil {
t.Fatalf("resolve target: %v", err)
}
entered := make(chan struct{})
release := make(chan struct{})
origWrite := s.writeFileFn
var writes atomic.Int32
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
if path == absTarget+newFileSuffix && writes.Add(1) == 1 {
close(entered)
<-release
}
return origWrite(path, data, perm)
}
firstDone := make(chan error, 1)
go func() {
firstDone <- s.WriteFile(target, []byte("one"))
}()
waitSignal(t, entered, "first write entered")
secondDone := make(chan error, 1)
go func() {
secondDone <- s.WriteFile(target, []byte("two"))
}()
select {
case err := <-secondDone:
t.Fatalf("second write finished before first released: %v", err)
case <-time.After(50 * time.Millisecond):
}
if writes.Load() != 1 {
t.Fatalf("same-path write reached file hook %d times before release, want 1", writes.Load())
}
close(release)
if err := waitError(t, firstDone); err != nil {
t.Fatalf("first write: %v", err)
}
if err := waitError(t, secondDone); err != nil {
t.Fatalf("second write: %v", err)
}
}
func TestDifferentPathOperationsDoNotBlockEachOther(t *testing.T) {
s := newTestStorage(t)
blockedTarget := "blocked.txt"
absTarget, err := s.resolvePath(blockedTarget)
if err != nil {
t.Fatalf("resolve blocked target: %v", err)
}
entered := make(chan struct{})
release := make(chan struct{})
origWrite := s.writeFileFn
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
if path == absTarget+newFileSuffix {
close(entered)
<-release
}
return origWrite(path, data, perm)
}
blockedDone := make(chan error, 1)
go func() {
blockedDone <- s.WriteFile(blockedTarget, []byte("blocked"))
}()
waitSignal(t, entered, "blocked write entered")
freeDone := make(chan error, 1)
go func() {
freeDone <- s.WriteFile("free.txt", []byte("free"))
}()
select {
case err := <-freeDone:
if err != nil {
t.Fatalf("free write: %v", err)
}
case <-time.After(testTimeout):
t.Fatal("write for a different path should not block")
}
close(release)
if err := waitError(t, blockedDone); err != nil {
t.Fatalf("blocked write: %v", err)
}
}
func TestSaveStateIsNonBlockingAndCallbackBased(t *testing.T) {
s := newTestStorage(t)
entered := make(chan struct{})
release := make(chan struct{})
origWrite := s.writeFileFn
s.writeFileFn = func(path string, data []byte, perm os.FileMode) error {
close(entered)
<-release
return origWrite(path, data, perm)
}
callbacks := make(chan error, 2)
s.SaveState(sampleState(), func(err error) {
callbacks <- err
})
waitSignal(t, entered, "async save entered")
select {
case err := <-callbacks:
t.Fatalf("callback fired before storage write completed: %v", err)
default:
}
close(release)
if err := waitError(t, callbacks); err != nil {
t.Fatalf("callback error: %v", err)
}
select {
case err := <-callbacks:
t.Fatalf("callback fired more than once: %v", err)
case <-time.After(50 * time.Millisecond):
}
}
func newTestStorage(t *testing.T) *fsStorage {
t.Helper()
s, err := NewFS(t.TempDir())
if err != nil {
t.Fatalf("new test storage: %v", err)
}
return s
}
func sampleState() client.State {
return client.State{
GameState: []client.GameState{
{ID: client.GameID("game-1"), LastTurn: 12, ActiveTurn: 11},
{ID: client.GameID("game-2"), LastTurn: 4, ActiveTurn: 4},
},
ActiveGameID: client.GameID("game-2"),
}
}
func sampleReport(turn uint, race string) report.Report {
return report.Report{
Turn: turn,
Width: 160,
Height: 90,
PlanetCount: 8,
Race: race,
VoteFor: "assembly",
}
}
func sampleOrder() order.Order {
return order.Order{
UpdatedAt: 1700,
Commands: []order.DecodableCommand{
&order.CommandPlanetRename{
CommandMeta: order.CommandMeta{
CmdType: order.CommandTypePlanetRename,
CmdID: "rename-planet",
},
Number: 2,
Name: "Nova Prime",
},
&order.CommandRaceVote{
CommandMeta: order.CommandMeta{
CmdType: order.CommandTypeRaceVote,
CmdID: "vote-race",
},
Acceptor: "ZENITH",
},
},
}
}
func assertFileContent(t *testing.T, s *fsStorage, path, want string) {
t.Helper()
got, err := s.ReadFile(path)
if err != nil {
t.Fatalf("read %q: %v", path, err)
}
if string(got) != want {
t.Fatalf("content for %q = %q, want %q", path, got, want)
}
}
func assertNoTempArtifacts(t *testing.T, s *fsStorage, path string) {
t.Helper()
absPath, err := s.resolvePath(path)
if err != nil {
t.Fatalf("resolve path %q: %v", path, err)
}
for _, tempPath := range []string{absPath + newFileSuffix, absPath + oldFileSuffix} {
if _, err := os.Stat(tempPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("temp artifact %q should not exist, stat err = %v", tempPath, err)
}
}
}
func waitSignal(t *testing.T, ch <-chan struct{}, name string) {
t.Helper()
select {
case <-ch:
case <-time.After(testTimeout):
t.Fatalf("timeout waiting for %s", name)
}
}
func waitError(t *testing.T, ch <-chan error) error {
t.Helper()
select {
case err := <-ch:
return err
case <-time.After(testTimeout):
t.Fatal("timeout waiting for error callback")
return nil
}
}
func waitResult[T any](t *testing.T, ch <-chan callbackResult[T]) callbackResult[T] {
t.Helper()
select {
case result := <-ch:
return result
case <-time.After(testTimeout):
t.Fatal("timeout waiting for callback result")
return callbackResult[T]{}
}
}