state

package
v0.16.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 24, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package state — atomic_write.go provides atomic, durable file writes for all on-disk persistence in internal/state.

Constitution Principle VII requires that "tournament and match state MUST be persisted to disk before the server responds with success." A direct os.WriteFile call is neither atomic (a crash mid-write leaves a half-written file that won't parse on restart) nor durable (page-cache buffered writes return success but can be lost on power loss). This helper closes both gaps for every save in the package.

Algorithm: write to "<path>.tmp-<pid>-<nanos>" next to the target, fsync that file, close it, rename(tmp, target) — atomic on POSIX when src and dst are on the same filesystem — then fsync the parent directory so the rename metadata is durable across power loss (a no-op on Windows where directory fsync isn't supported).

Why a unique-suffix .tmp filename? Per-comp mutex serializes writes to the same target in practice, but an explicit PID + nanosecond suffix is defensive: two processes pointing at the same data folder (e.g. a stuck-old-process / new-process restart overlap) would otherwise collide on a fixed ".tmp" sibling. The unique suffix also avoids ambiguity if the existing cache layer (getFileCache, pools.go:344) ever grew an mtime poll on directory listings — the .tmp file is invisible to the cache key because the cache is keyed by canonical filename, not by directory scan.

Package state — team_lineup.go owns the on-disk persistence for domain.TeamLineup (FR-040, Slice 7.B / T126).

One file per competition lives at tournament-data/competitions/<id>/lineups.yaml and is keyed by "<teamId>-<round>". A missing file is treated as "no lineups submitted yet". All load/mutate/save sequences run under the per-competition write lock (s.getCompLock) so concurrent PUTs to different teams in the same competition serialize correctly without clobbering each other's work — same pattern competitor_status.go uses.

Locking is a hard requirement here, not a nicety: the FR-040 contract says a lineup is mutable up until the round's first match goes live, then frozen. The "is this round live?" check has to happen INSIDE the lock taken by SetTeamLineup (T128a) or two concurrent writers could both pass an unlocked TOCTOU check and one would overwrite a lineup that the other side had just legitimately frozen.

Package state — transactions.go owns Store.WithTransaction, the per-competition-lock primitive that lets a handler perform several load + mutate + save operations against multiple files (config.md, pool-matches.csv, bracket.json, lineups.yaml, …) under a single acquire of the per-comp write lock. T155, NFR-010.

Why this exists. The pre-T155 handler pattern was to call Update*Changed / UpdatePoolMatchByID / UpdateBracket in sequence — each one acquires the per-comp lock, does its work, and releases. Concurrent writers can sneak in between the calls and clobber a half-committed cross-file mutation (e.g. score writes a pool match AND propagates a competitor-status update AND auto-completes the competition: three load+save pairs that should be serialised against each other as one operation, not three).

What "transaction" means here. Lock-level atomicity AND cross-file crash-atomicity via a write-ahead log (T210/T211/T212). Each save invoked through tx is STAGED into a per-transaction WAL instead of landing on disk; after fn returns nil, WithTransaction commits the WAL (atomic-rename of the intent file into <data>/.wal/), applies the staged writes to their target files, then deletes the WAL.

Failure modes:

  • fn returns an error → no WAL on disk (Commit never ran); intents discarded; on-disk state unchanged.
  • Crash after fn returns but before Commit → same as fn-error: intents discarded; on-disk state unchanged.
  • Crash after Commit, before Apply completes → WAL on disk; Store.NewStore Scan replays on next start; targets land.
  • Apply returns an error mid-way → WAL stays on disk for the next-start replay to finish; caller sees the error.

The contract callers MUST honour is "do your validation FIRST then stage writes via tx" — once a tx method writes, that write enters the WAL and will land (either at Apply time or at replay time). A validation failure AFTER a write returns the error AND leaves the staged-but-uncommitted intent to be discarded; the on-disk state stays unchanged. There is still NO undo log: an already-committed WAL can't be rolled back after Apply has partially run; the only recovery is forward-completion via replay.

Read-your-own-writes within a tx IS supported via the WAL's in-memory intent map: tx-internal reads (tx.LoadCompetition, tx.LoadBracket, etc.) check the pending intents BEFORE going to disk, so a tx that saves pool-matches and then re-loads them sees the just-saved data — not the stale on-disk version (which won't update until Apply runs after fn returns). Without this, the existing TestWithTransaction_NestedCallDoesNotDeadlock contract (load-save-load round-trip) would silently see stale data.

Lock ordering. The per-comp lock is a sync.RWMutex; WithTransaction holds the WRITE lock for fn's entire duration. fn MUST call the load/save methods on the supplied StoreTx — those bypass re-locking. Calling any Store method directly from fn (e.g. s.LoadCompetition, s.SavePoolMatches) would deadlock because RWMutex is non-recursive. This mirrors the same advisory that already attaches to UpdateCompetitionChanged, UpdateBracket, UpdatePoolMatchByID.

Index

Constants

View Source
const (
	CompFormatPools    = "pools"
	CompFormatPlayoffs = "playoffs"
	CompFormatMixed    = "mixed"  // FR-050
	CompFormatLeague   = "league" // FR-050
	CompFormatSwiss    = "swiss"  // FR-050, FR-050a (US13)

	PoolFormatFull    = "full"
	PoolFormatPartial = "partial"
)

Competition.Format values.

View Source
const DecisionDraw = "hikiwake"

DecisionDraw is the canonical value for a tied (hikiwake) match.

Variables

View Source
var ErrLineupLocked = errors.New("team lineup locked: round has started")

ErrLineupLocked is returned by SetTeamLineup when the team's lineup for the given round is already frozen (the round's first match went live). Mapped to HTTP 409 by the handler. FR-040.

View Source
var ErrMismatchedTxCompID = errors.New("compID does not match transaction's competition")

ErrMismatchedTxCompID is returned by StoreTx methods when the supplied compID (or c.ID on SaveCompetition) does not match the competition the transaction was opened on. The transaction holds the per-comp lock for one ID only, so dispatching the locked helpers for any other ID would perform unlocked I/O.

View Source
var ErrParticipantNotFound = errors.New("participant not found")

ErrParticipantNotFound is returned by UpdateParticipant when the pid is not in the roster.

Functions

func ApplyCompetitionDefaults

func ApplyCompetitionDefaults(c *Competition)

ApplyCompetitionDefaults fills zero-valued per-phase durations from the legacy MatchDuration field. Idempotent; safe to call repeatedly.

FR-054, NFR-025, R9: old config.md files predating per-phase durations carry only `match_duration`. We MUST preserve their schedule estimates.

func ApplyTournamentDefaults

func ApplyTournamentDefaults(t *Tournament)

ApplyTournamentDefaults fills zero-valued schedule-estimator tuning fields on t with their canonical defaults: ClockToElapsedMultiplier=1.5 and SlowestCourtBufferPct=10. Idempotent; safe to call repeatedly. FR-055, FR-057, R9.

func DeriveQueuePositions

func DeriveQueuePositions(matches []MatchResult) []int

DeriveQueuePositions assigns a 1-indexed queue position to each scheduled match per court. Live (running) and completed matches receive 0.

Ordering: within each court, positions are assigned in (status priority, scheduledAt, original index) order — the same basis used by ScheduleViewer (viewer.jsx) and the client-side SSE recompute (_orderByCourtKey in patch.jsx) — so "Next up / N before yours" labels are consistent between server responses and the post-SSE client view.

FR-025, R3: positions are recomputed at serve time and on every SSE match-state change so viewers see the queue shrink as matches finish. The function is pure and side-effect-free; the caller is responsible for assigning the returned positions onto the MatchResult slice.

func IsDraw

func IsDraw(decision string) bool

IsDraw reports whether a match decision string represents a draw.

func ValidateCompetitionID

func ValidateCompetitionID(id string) error

func ValidateTeamMatchType

func ValidateTeamMatchType(t TeamMatchType, teamSize int) error

ValidateTeamMatchType returns nil when the value is acceptable on the given Competition (empty == fixed default, kachinuki requires TeamSize >= 2). FR-044.

Types

type Announcement

type Announcement struct {
	Message   string    `json:"message" yaml:"message"`
	SentAt    time.Time `json:"sentAt" yaml:"sent_at"`
	ExpiresAt time.Time `json:"expiresAt" yaml:"expires_at"`
}

type AnnouncementStore

type AnnouncementStore struct {
	// contains filtered or unexported fields
}

func NewAnnouncementStore

func NewAnnouncementStore() *AnnouncementStore

func (*AnnouncementStore) Clear

func (s *AnnouncementStore) Clear()

func (*AnnouncementStore) Get

func (s *AnnouncementStore) Get() *Announcement

func (*AnnouncementStore) Set

type Bracket

type Bracket struct {
	Rounds [][]BracketMatch `json:"rounds"`
}

type BracketMatch

type BracketMatch struct {
	ID          string      `json:"id"`
	SideA       string      `json:"sideA"`
	SideB       string      `json:"sideB"`
	Winner      string      `json:"winner"`
	Status      MatchStatus `json:"status"`
	Court       string      `json:"court"`
	ScheduledAt string      `json:"scheduledAt"`
	// Additional fields from design
	ScoreA        string `json:"scoreA"`
	ScoreB        string `json:"scoreB"`
	IsOverridden  bool   `json:"isOverridden"`
	QueuePosition int    `json:"queuePosition,omitempty"`
	// Decision-type metadata mirrors MatchResult so an elimination-stage
	// kiken/fusenpai/encho is reconstructable from bracket.json alone
	// (label rendering, Excel export, SSE replays).
	Decision       string         `json:"decision,omitempty"`
	DecisionBy     string         `json:"decisionBy,omitempty"`
	DecisionReason string         `json:"decisionReason,omitempty"`
	Encho          *EnchoMetadata `json:"encho,omitempty"`
}

type Competition

type Competition struct {
	ID                   string            `yaml:"id" json:"id"`
	Name                 string            `yaml:"name" json:"name"`
	Kind                 string            `yaml:"kind" json:"kind"`
	Format               string            `yaml:"format" json:"format"`
	PoolFormat           string            `yaml:"pool_format,omitempty" json:"poolFormat,omitempty"` // "full" (default) | "partial"
	TeamSize             int               `yaml:"team_size" json:"teamSize"`
	PoolSize             int               `yaml:"pool_size" json:"poolSize"`
	PoolSizeMode         string            `yaml:"pool_size_mode" json:"poolSizeMode"`
	PoolWinners          int               `yaml:"pool_winners" json:"poolWinners"`
	RoundRobin           bool              `yaml:"round_robin" json:"roundRobin"`
	Courts               []string          `yaml:"courts" json:"courts"`
	StartTime            string            `yaml:"start_time" json:"startTime"`
	Date                 string            `yaml:"date" json:"date"`
	Status               CompetitionStatus `yaml:"status" json:"status"`
	Mirror               bool              `yaml:"mirror" json:"mirror"`
	WithZekkenName       bool              `yaml:"with_zekken_name" json:"withZekkenName"`
	NumberPrefix         string            `yaml:"number_prefix,omitempty" json:"numberPrefix,omitempty"`
	HasParticipantIDs    bool              `yaml:"has_participant_ids,omitempty" json:"hasParticipantIDs,omitempty"`
	PoolMatchDuration    int               `yaml:"pool_match_duration,omitempty" json:"poolMatchDuration,omitempty"`
	PlayoffMatchDuration int               `yaml:"playoff_match_duration,omitempty" json:"playoffMatchDuration,omitempty"`
	// MaxEnchoPeriods caps how many encho (overtime) periods one match
	// may run before the operator must call daihyosen. Zero means
	// unlimited (FIK general default). T104, CHK029.
	MaxEnchoPeriods int `yaml:"max_encho_periods,omitempty" json:"maxEnchoPeriods,omitempty"`

	// TeamMatchType selects the team-match format (FR-044). Empty value
	// is treated as TeamMatchTypeFixed for backward compatibility — all
	// N×1 bouts are pre-scheduled by position. TeamMatchTypeKachinuki
	// schedules only the first bout; subsequent bouts are derived
	// dynamically from prior bout outcomes ("winner stays on"). See
	// engine/kachinuki.go for the advancement semantics. Ignored when
	// TeamSize is 0 (individual competitions).
	TeamMatchType TeamMatchType `yaml:"team_match_type,omitempty" json:"teamMatchType,omitempty"`

	// Legacy single-phase duration. Captured at unmarshal time and used by
	// ApplyCompetitionDefaults to populate the per-phase fields above when
	// they are zero. Not persisted on save — only here so older YAML files
	// round-trip through the new schema.
	MatchDuration int `yaml:"match_duration,omitempty" json:"matchDuration,omitempty"`

	// SwissRounds is the number of rounds played in a Swiss-format
	// competition (FR-050a). Ignored when Format != CompFormatSwiss.
	// Persisted so resuming a Swiss tournament reads the same round
	// budget on subsequent loads.
	SwissRounds int `yaml:"swiss_rounds,omitempty" json:"swissRounds,omitempty"`

	// SwissCurrentRound tracks which round has been generated so far
	// (FR-050d). 0 = not started; the value increments after each
	// successful GenerateSwissRound. Used by the "Generate next round"
	// gate to refuse re-generation of an in-progress round.
	SwissCurrentRound int `yaml:"swiss_current_round,omitempty" json:"swissCurrentRound,omitempty"`

	// Naginata selects the Naginata ippon set for this competition.
	// When true, the score editor offers an extra "S" (Sune) button
	// in addition to the standard M/K/D/T/H set. Default false = Kendo.
	Naginata bool `yaml:"naginata,omitempty" json:"naginata"`

	Players []domain.Player `yaml:"-" json:"players"`
}

func (Competition) IsPlayoffEnabled

func (c Competition) IsPlayoffEnabled() bool

IsPlayoffEnabled reports whether this competition runs a knockout/playoff phase. League and pure-pools formats do not; mixed and playoffs do.

FR-050, FR-051: when Format == "league", the UI must hide playoff-bracket affordances and present pool standings as final.

type CompetitionStatus

type CompetitionStatus string
const (
	CompStatusSetup    CompetitionStatus = "setup"
	CompStatusPools    CompetitionStatus = "pools"
	CompStatusPlayoffs CompetitionStatus = "playoffs"
	CompStatusComplete CompetitionStatus = "completed"
	CompStatusInvalid  CompetitionStatus = "invalid"
)

type EnchoMetadata

type EnchoMetadata struct {
	PeriodCount int `json:"periodCount" yaml:"periodCount"`
}

EnchoMetadata records overtime / sudden-death periods played in a match. Read/persisted only in Slice 1; the score endpoint accepts it but does not yet act on it. Slice 3 (T076) will wire it into the decision logic.

FR-032

type LoadParticipantsOpts

type LoadParticipantsOpts struct {
	WithSeeds bool  // set false to skip the seeds.csv read (hot list paths)
	HasIDs    *bool // nil = auto-detect from first line; non-nil uses cached Competition.HasParticipantIDs
}

LoadParticipantsOpts controls optional behavior in LoadParticipants.

type MatchResult

type MatchResult struct {
	ID             string           `json:"id"`
	SideA          string           `json:"sideA"` // Player/Team Name
	SideB          string           `json:"sideB"`
	Winner         string           `json:"winner"`
	IpponsA        []string         `json:"ipponsA"` // M, K, D, T, H
	IpponsB        []string         `json:"ipponsB"`
	HansokuA       int              `json:"hansokuA"`
	HansokuB       int              `json:"hansokuB"`
	Decision       string           `json:"decision"`
	DecisionBy     string           `json:"decisionBy,omitempty"`
	DecisionReason string           `json:"decisionReason,omitempty"`
	Status         MatchStatus      `json:"status"`
	Court          string           `json:"court"`
	ScheduledAt    string           `json:"scheduledAt"`
	SubResults     []SubMatchResult `json:"subResults,omitempty"`
	Encho          *EnchoMetadata   `json:"encho,omitempty" yaml:"encho,omitempty"`
	QueuePosition  int              `json:"queuePosition,omitempty" yaml:"-"`
}

type MatchStatus

type MatchStatus string
const (
	MatchStatusScheduled MatchStatus = "scheduled"
	MatchStatusRunning   MatchStatus = "running"
	MatchStatusCompleted MatchStatus = "completed"
)

type Overrides

type Overrides struct {
	PoolRanks map[string]map[string]int `json:"poolRanks"` // PoolID -> PlayerName -> Rank
	Winners   map[string]string         `json:"winners"`   // MatchID -> WinnerName
}

type PlayerStanding

type PlayerStanding struct {
	Player           domain.Player `json:"player"`
	Wins             int           `json:"wins"`
	Losses           int           `json:"losses"`
	Draws            int           `json:"draws"`
	IpponsGiven      int           `json:"ipponsGiven"`
	IpponsTaken      int           `json:"ipponsTaken"`
	Points           int           `json:"points"`
	ScoreSummary     string        `json:"scoreSummary,omitempty"`
	Rank             int           `json:"rank"`
	IsOverridden     bool          `json:"isOverridden"`
	IndividualWins   int           `json:"individualWins,omitempty"`
	IndividualLosses int           `json:"individualLosses,omitempty"`
	IndividualDraws  int           `json:"individualDraws,omitempty"`
	PointsWon        int           `json:"pointsWon,omitempty"`
	PointsLost       int           `json:"pointsLost,omitempty"`
}

type ReservedSlot

type ReservedSlot struct {
	ID            string `json:"id"`            // unique slot ID
	ParticipantID string `json:"participantID"` // ID of the placeholder in participants.csv
	SourceCompID  string `json:"sourceCompID"`
	SourceRank    int    `json:"sourceRank"`
}

ReservedSlot represents a placeholder participant that will be resolved to the actual player who achieves a given rank in another competition.

type ScheduleEntry

type ScheduleEntry struct {
	MatchType   string `json:"matchType"` // pool | bracket | break
	MatchRef    string `json:"matchRef"`  // ID of the match (empty for breaks)
	Court       string `json:"court"`
	Date        string `json:"date"`        // DD-MM-YYYY (matches Tournament.Date / Competition.Date canonical) — reserved for future multi-day tournament use
	ScheduledAt string `json:"scheduledAt"` // HH:MM
	Status      string `json:"status"`
	IsBreak     bool   `json:"isBreak,omitempty"`
	Label       string `json:"label,omitempty"` // display label for breaks
}

type Store

type Store struct {
	// contains filtered or unexported fields
}

func NewStore

func NewStore(folder string) (*Store, error)

func (*Store) AddReservedSlot

func (s *Store) AddReservedSlot(compID string, sourceCompID string, sourceRank int, withZekkenName bool) (*ReservedSlot, error)

AddReservedSlot creates a placeholder participant and a reserved-slot entry linking it to sourceCompID at the given rank. It returns the new slot.

func (*Store) AnnouncementStore

func (s *Store) AnnouncementStore() *AnnouncementStore

func (*Store) DeleteCompetition

func (s *Store) DeleteCompetition(id string) error

func (*Store) DeleteTeamLineup

func (s *Store) DeleteTeamLineup(compID, teamID string, round int) error

DeleteTeamLineup removes the lineup for (teamID, round) if present. Same lock-protected refusal as SetTeamLineup when the entry is already locked: deleting a frozen lineup would re-open the round to edits and break the freeze contract.

Returns nil when no entry exists (idempotent delete).

func (*Store) FileMtime

func (s *Store) FileMtime(compID, filename string) int64

FileMtime returns the UnixNano mtime of a file inside a competition directory. Returns 0 if the file does not exist or stat fails.

func (*Store) GetFolder

func (s *Store) GetFolder() string

func (*Store) ListCompetitions

func (s *Store) ListCompetitions() ([]string, error)

func (*Store) LoadBracket

func (s *Store) LoadBracket(compID string) (*Bracket, error)

func (*Store) LoadCompetition

func (s *Store) LoadCompetition(id string) (*Competition, error)

func (*Store) LoadCompetitorStatus

func (s *Store) LoadCompetitorStatus(compID string) (map[string]domain.CompetitorStatus, error)

LoadCompetitorStatus returns the per-player status map for compID. A missing file is treated as "all eligible" per FR-034 / NFR-025.

Uses the per-competition lock (consistent with pools/bracket/etc.) so a concurrent ineligibility write on a different competition does not serialize behind this read.

func (*Store) LoadOverrides

func (s *Store) LoadOverrides(compID string) (*Overrides, error)

func (*Store) LoadParticipants

func (s *Store) LoadParticipants(compID string, withZekkenName bool) ([]domain.Player, error)

LoadParticipants loads participants with seeds merged (default behavior).

func (*Store) LoadParticipantsOpt

func (s *Store) LoadParticipantsOpt(compID string, withZekkenName bool, opts LoadParticipantsOpts) ([]domain.Player, error)

LoadParticipantsOpt loads participants with configurable options.

func (*Store) LoadPoolMatches

func (s *Store) LoadPoolMatches(compID string) ([]MatchResult, error)

func (*Store) LoadPoolMatchesLocked

func (s *Store) LoadPoolMatchesLocked(compID string) ([]MatchResult, error)

LoadPoolMatchesLocked loads pool matches WITHOUT acquiring the per-competition lock. Caller MUST already hold the write lock for this competition — typically from inside a transform passed to UpdatePoolMatchByID, UpdateBracket, or UpdateCompetitionChanged. Bypasses the cache deliberately: the cache mtime can lag a concurrent writer that the caller may be in the middle of making, and we want the most-recent on-disk state.

Motivating use case: MaybeAutoCompletePools (engine/competition.go) re-checks "are all matches completed?" INSIDE its UpdateCompetitionChanged transform to close a TOCTOU window where the outer LoadPoolMatches snapshot can go stale. The transform holds the per-comp write lock, so the standard LoadPoolMatches would deadlock (sync.RWMutex non-recursive); this helper provides the lock-free read for that context.

func (*Store) LoadPools

func (s *Store) LoadPools(compID string) ([]helper.Pool, error)

func (*Store) LoadReservedSlots

func (s *Store) LoadReservedSlots(compID string) ([]ReservedSlot, error)

func (*Store) LoadSchedule

func (s *Store) LoadSchedule(compID string) ([]ScheduleEntry, error)

func (*Store) LoadSeeds

func (s *Store) LoadSeeds(compID string) ([]domain.SeedAssignment, error)

LoadSeeds and SaveSeeds use the PER-COMPETITION lock (not the store-wide `s.mu`) so they serialize against other per-comp readers/writers — in particular against the StartCompetition transform held by UpdateCompetitionChanged. Pre-fix, SaveSeeds took `s.mu.Lock()` (store-wide) and the StartCompetition transform took the per-comp lock, so the seeds drift check inside the transform (via FileMtime) had a race window: a concurrent SaveSeeds could land AFTER the mtime check but BEFORE the status commit, leaving status=Pools on disk with seeds.csv reflecting roster the engine never read.

Switching to per-comp locking ALSO improves scalability — concurrent seed saves for DIFFERENT comps no longer block each other on the global store mutex. Same locking strategy participants.csv and pools.csv already use.

func (*Store) LoadTeamLineups

func (s *Store) LoadTeamLineups(compID string) (map[string]domain.TeamLineup, error)

LoadTeamLineups returns every lineup persisted for compID, keyed by "<teamID>-<round>". A missing file is treated as "no lineups yet" and returns an empty map (consistent with LoadCompetitorStatus).

Uses the per-competition read lock so concurrent writes for the same competition can't race with this read.

func (*Store) LoadTournament

func (s *Store) LoadTournament() (*Tournament, error)

func (*Store) LockTeamLineupsForRound

func (s *Store) LockTeamLineupsForRound(compID string, round int, lockedAt time.Time) error

LockTeamLineupsForRound stamps LockedAt on every persisted lineup whose Round matches `round`. Idempotent: lineups already locked keep their original LockedAt (the first-live-match time stays canonical; re-running this method on the same round is a no-op for those records).

Called by the engine when a team match transitions to running (T128). Returns nil when no lineups exist for this competition or this round.

func (*Store) RemoveReservedSlot

func (s *Store) RemoveReservedSlot(compID string, slotID string, withZekkenName bool) error

RemoveReservedSlot deletes a slot and its placeholder participant.

func (*Store) ResetOverrides

func (s *Store) ResetOverrides(compID string) error

func (*Store) ResetOverridesChanged

func (s *Store) ResetOverridesChanged(compID string) (bool, error)

ResetOverridesChanged clears all overrides and reports whether the file changed (false when overrides were already empty).

func (*Store) SaveBracket

func (s *Store) SaveBracket(compID string, b *Bracket) error

func (*Store) SaveCompetition

func (s *Store) SaveCompetition(c *Competition) error

func (*Store) SaveCompetitionChanged

func (s *Store) SaveCompetitionChanged(c *Competition) (bool, error)

SaveCompetitionChanged persists c and reports whether the on-disk content actually changed. Use this instead of SaveCompetition when you need to gate a broadcast on a real mutation.

func (*Store) SaveOverrides

func (s *Store) SaveOverrides(compID string, o *Overrides) error

func (*Store) SaveParticipants

func (s *Store) SaveParticipants(compID string, players []domain.Player) error

func (*Store) SavePoolMatches

func (s *Store) SavePoolMatches(compID string, results []MatchResult) error

func (*Store) SavePools

func (s *Store) SavePools(compID string, pools []helper.Pool) error

func (*Store) SaveRankOverride

func (s *Store) SaveRankOverride(compID, poolID, playerName string, rank int) error

func (*Store) SaveRankOverrideChanged

func (s *Store) SaveRankOverrideChanged(compID, poolID, playerName string, rank int) (bool, error)

SaveRankOverrideChanged saves the rank override and reports whether the overrides file actually changed. Use this to gate broadcasts.

func (*Store) SaveReservedSlots

func (s *Store) SaveReservedSlots(compID string, slots []ReservedSlot) error

func (*Store) SaveSchedule

func (s *Store) SaveSchedule(compID string, entries []ScheduleEntry) error

func (*Store) SaveScheduleChanged

func (s *Store) SaveScheduleChanged(compID string, entries []ScheduleEntry) (bool, error)

SaveScheduleChanged persists entries and reports whether the on-disk content actually changed. Use this instead of SaveSchedule when you need to gate a broadcast on a real mutation.

func (*Store) SaveSeeds

func (s *Store) SaveSeeds(compID string, assignments []domain.SeedAssignment) error

func (*Store) SaveTournament

func (s *Store) SaveTournament(t *Tournament) error

func (*Store) SaveTournamentChanged

func (s *Store) SaveTournamentChanged(t *Tournament) (bool, error)

SaveTournamentChanged persists t and reports whether the on-disk content actually changed. Use this instead of SaveTournament when you need to gate a broadcast on a real mutation.

func (*Store) SaveWinnerOverride

func (s *Store) SaveWinnerOverride(compID, matchID, winnerName string) error

func (*Store) SetCompetitorStatus

func (s *Store) SetCompetitorStatus(compID string, status domain.CompetitorStatus) error

SetCompetitorStatus persists a status entry, replacing any prior entry for the same PlayerID. RecordedAt defaults to time.Now().UTC() when the caller leaves it zero.

Uses the per-competition lock so the load-mutate-save cycle is atomic against other competitor-status writers for the same competition.

func (*Store) SetTeamLineup

func (s *Store) SetTeamLineup(compID string, lineup domain.TeamLineup, teamSize int) error

SetTeamLineup validates and persists a lineup, replacing any prior entry for the same (teamID, round). The caller MUST pass the competition's team size — Validate() enforces the FIK back-fill rule against it.

Refuses with ErrLineupLocked when the prior entry has a non-nil LockedAt (the round's first match has gone live). The check runs INSIDE the per-comp write lock so a concurrent LockTeamLineupsForRound can't race with this set (T128a).

FR-040, FR-041 / R4 / CHK012.

func (*Store) UpdateBracket

func (s *Store) UpdateBracket(compID string, mutate func(*Bracket) error) error

UpdateBracket atomically loads the bracket for compID, calls mutate with the loaded bracket (always non-nil — parseBracketFile returns an empty `&Bracket{Rounds: [][]BracketMatch{}}` when no file exists yet, so callers can rely on a non-nil receiver and an empty Rounds slice as the "no bracket yet" sentinel), and — if mutate returns nil — persists the bracket. The entire load + mutate + save sequence runs under the per-competition lock so concurrent calls serialize correctly.

mutate may modify the bracket arbitrarily (e.g. update one match AND propagate the winner to the next round) — this is the more general primitive that supports recordBracketMatchResult's propagateBracketWinner behavior. For single-match mutations, see also engine.withBracketMatch which delegates to this.

If mutate returns a non-nil error, no write happens and the error is returned unchanged (callers can use errors.Is to discriminate not-found vs validation vs I/O). Importantly, returning errors from mutate is how callers signal "match not found, don't save the unchanged bracket back" — the alternative ("found" bool) would either save unnecessarily or duplicate the not-found error path at every caller.

IMPORTANT: mutate runs while this method holds the per-competition lock. It MUST NOT call any other Store method that acquires the same lock (SavePoolMatches, SaveBracket, SaveCompetitionChanged, recursive UpdateBracket / UpdatePoolMatchByID / UpdateBracket calls, etc.) — `sync.Mutex` is non-recursive and would deadlock.

func (*Store) UpdateCompetitionChanged

func (s *Store) UpdateCompetitionChanged(id string, transform func(current *Competition) (*Competition, error)) (bool, error)

UpdateCompetitionChanged atomically loads the competition with id, calls transform with the loaded record (which may be nil if no file exists yet), and — if transform returns a non-nil *Competition — persists it. Returns (changed, err) where changed reports whether the on-disk content actually differs from the prior version (same semantics as SaveCompetitionChanged). The entire load + transform + save sequence runs under the per-competition lock so concurrent calls serialize correctly without losing each other's mutations.

transform's return value selects what to save:

  • return current (after mutating in place), nil → save the mutated record
  • return a NEW *Competition, nil → save that (use this for the PUT-create case where current is nil and the body becomes the new on-disk state)
  • return nil, nil → skip the save (no-op, used when the precondition the caller wanted to commit on is no longer true)
  • return _, err → propagate the error unchanged; no save

Pre-atomic-primitive, handlers and engine code called LoadCompetition + SaveCompetitionChanged sequentially with no shared lock between the two calls. A concurrent writer could change the on-disk state between Load and Save; the late save would clobber that change with stale data. Specific failure modes this fix closes:

  • POST /invalidate vs. concurrent score-save's MaybeAutoCompletePools: admin's "invalid" status overwritten back to "complete" (or vice versa).
  • PUT /competitions/:id uniqueness-check race: two new competitions with the same name both pass the check and both land.
  • StartCompetition vs. concurrent edit of the same competition.

IMPORTANT: transform runs while this method holds the per-competition lock. It MUST NOT call any other Store method that acquires the same lock (SaveCompetition, SaveCompetitionChanged, SaveParticipants, SavePools, SaveBracket, SavePoolMatches, recursive UpdateCompetitionChanged / UpdateBracket / UpdatePoolMatchByID, etc.) — `sync.Mutex` is non-recursive and would deadlock. For cross-file operations (e.g. participants save), perform them AFTER UpdateCompetitionChanged returns.

func (*Store) UpdateParticipant

func (s *Store) UpdateParticipant(compID string, pid string, withZekkenName bool, transform func(p *domain.Player) error) (*domain.Player, error)

UpdateParticipant atomically loads the participant list, applies transform to the target player, and persists the result. Used to avoid TOCTOU races on concurrent check-ins.

func (*Store) UpdatePoolMatchByID

func (s *Store) UpdatePoolMatchByID(compID, matchID string, mutate func(*MatchResult)) (bool, error)

UpdatePoolMatchByID atomically loads the pool-matches CSV for compID, finds the match with matchID, calls mutate on it, and persists the updated slice. Returns (found, err): found is false when no match has that ID, allowing callers to fall through (e.g. to the bracket store for elimination-round matches).

The entire load + find + mutate + save sequence runs under the per-competition lock so concurrent calls — even for different match IDs in the same competition — serialize correctly without losing each other's mutations.

Without this primitive, the equivalent engine helper (engine.withPoolMatch) had a TOCTOU window: two operators scoring different matches on different courts could each LoadPoolMatches into separate copies, mutate their target match, and SavePoolMatches in sequence — the later save would overwrite the earlier save's mutation with stale data for the OTHER match. One operator's score would be silently lost during a live tournament.

func (*Store) UpdateTournamentChanged

func (s *Store) UpdateTournamentChanged(desired *Tournament, transform func(current *Tournament, desired *Tournament) error) (bool, error)

UpdateTournamentChanged atomically loads the current tournament under the store's write lock, calls transform(current, desired) which may modify desired in place (e.g. to preserve fields from current), and — if transform returns nil — persists desired. Returns (changed, err) like SaveTournamentChanged.

This is the race-free primitive for "load the existing record, copy some fields forward, save the result." Without it, a handler that calls LoadTournament + SaveTournamentChanged sequentially has a TOCTOU window between the two calls during which a concurrent writer can land changes that the load-modify-save then clobbers.

Specifically motivated by the PUT /api/tournament password-preserve semantics: when the incoming body sends Password == "", the handler must copy the stored Password into the desired record. Two concurrent PUTs — one with empty Password (intent: keep), one with a new password (intent: change) — could race in the old code so that the empty-Password PUT's late save clobbers the change-Password PUT's earlier save. With this method, the load + transform + save sequence is serialized under the store's lock.

`current` is nil ONLY when the tournament.md file does not exist yet (first-ever save). When the file exists but parses cleanly, `current` holds the parsed record. When the file exists but the front matter is corrupt, the load below falls back to a default Tournament record (matching LoadTournament's behavior) and that default is passed to `transform` — `current` is still non-nil in that case. transform must handle both cases. If transform returns a non-nil error, no write happens and the error is returned to the caller as-is (callers can use errors.Is to discriminate validation vs. I/O).

IMPORTANT: transform runs while this method holds both s.mu and s.tournamentMu (non-recursive locks). It MUST NOT call back into any Store method that acquires either lock — that includes LoadTournament, SaveTournament, SaveTournamentChanged, and a recursive UpdateTournamentChanged. Deadlock would result. The transform should only mutate `desired` (possibly by copying fields from `current`) and return; for any cross-resource coordination (e.g. updating a competition during a tournament update), perform the load BEFORE calling this method and pass the resulting data in via a closure over local variables.

func (*Store) WithCompetitionRenameLock

func (s *Store) WithCompetitionRenameLock(fn func() error) error

WithCompetitionRenameLock runs fn while holding the store's rename-coordination mutex (s.compRenameMu). Use it to wrap a competition uniqueness-check + save sequence so two concurrent renames of DIFFERENT competitions to the SAME new name can't both pass the check (each seeing the other still has its old name) and both land — leaving two competitions with the same effective Name.

The mutex is finer-grained than s.mu (which covers all store state) and coarser than getCompLock(id) (which covers a single competition). It only serializes "name-uniqueness" operations against each other; per-competition score saves, schedule edits, override-rank, etc. remain fully concurrent across competitions.

Lock ordering note: fn typically calls LoadCompetition on OTHER competitions (via checkUniqueCompName) and SaveCompetitionChanged on THIS competition. Both of those take per-comp locks internally — safe because s.compRenameMu is a different mutex from any per-comp lock, and the per-comp locks are acquired one at a time in fn. No AB-BA deadlock is possible because s.compRenameMu serializes the outer operation.

IMPORTANT: s.compRenameMu is a sync.Mutex (non-recursive). fn MUST NOT recursively call WithCompetitionRenameLock — that would deadlock on the second acquire. Same advisory as the Update*Changed family: transforms / closures running under a store mutex should perform only the load + check + save work for the resource they're locking, not invoke other lock-acquiring Store methods for the SAME lock. Calls into methods that acquire OTHER locks (per-comp via SaveCompetitionChanged / LoadCompetition for different IDs) are fine — those locks are independent.

func (*Store) WithTransaction

func (s *Store) WithTransaction(compID string, fn func(tx StoreTx) error) error

WithTransaction runs fn under the per-competition write lock for compID. fn receives a StoreTx that can call multiple load/save methods without re-acquiring the lock — the lock is held for the entire fn body and released exactly once on return (success OR error).

Crash-atomicity. Every save invoked through tx is STAGED into a per-transaction write-ahead log (see internal/state/wal) instead of going straight to disk. After fn returns nil, this method Commits the WAL (atomic-renames the intent file into <data>/.wal/), then Applies the staged writes one-by-one to their target files, then deletes the WAL file. A crash after Commit but before all Applies finish leaves the WAL on disk for replay on next start (Store.NewStore scans the directory); a crash before Commit leaves no on-disk trace and the partial in-memory work is dropped. Multi-file transactions that previously could land file A but not file B now either land both or replay both — cross-file atomicity across a process crash (closing the v3 review A1 finding).

Lock semantics. Per-comp lock is held for the entire fn body AND across Commit + Apply, so other writers see the WAL transition from "absent" → "applied" → "absent" as an atomic event.

fn MUST call methods on tx, NOT on the underlying *Store directly. The per-comp mutex is a non-recursive sync.RWMutex; a direct s.Save* call from inside fn would re-acquire and deadlock.

fn read-after-write within the same tx. Tx-internal reads (tx.LoadCompetition, tx.LoadBracket, etc.) read from disk via the *Locked helpers and DO NOT see the WAL-staged writes — the on-disk file isn't updated until Apply runs after fn returns. The current engine paths (RecordMatchResultWithIneligibilityTx, RecordDecisionTx, K3 rollback) read BEFORE they write within a single tx and never read-after-write the same file, so this limitation is invisible to them. If a future tx body needs to read its own pending write, the WAL exposes Intents() — but that's a code-smell and probably indicates the load/save should be re-ordered.

T155, NFR-010, T210/T211/T212 (A1 WAL).

type StoreTx

type StoreTx interface {
	LoadCompetition(compID string) (*Competition, error)
	SaveCompetition(c *Competition) error
	LoadPoolMatches(compID string) ([]MatchResult, error)
	SavePoolMatches(compID string, matches []MatchResult) error
	LoadBracket(compID string) (*Bracket, error)
	SaveBracket(compID string, b *Bracket) error
	LoadCompetitorStatus(compID string) (map[string]domain.CompetitorStatus, error)
	SetCompetitorStatus(compID string, status domain.CompetitorStatus) error
	LoadTeamLineups(compID string) (map[string]domain.TeamLineup, error)
	SetTeamLineup(compID string, l domain.TeamLineup, teamSize int) error
	LoadParticipants(compID string, withZekkenName bool) ([]domain.Player, error)

	// UpdatePoolMatchByID is the tx-aware twin of
	// Store.UpdatePoolMatchByID. Same semantics, same return values; the
	// only difference is that it skips re-acquiring the per-comp lock
	// (already held by WithTransaction). Score / decision handlers (T156)
	// use this to keep their match-result write under the SAME lock
	// acquire as the competitor-status + lineup-lock side effects.
	UpdatePoolMatchByID(compID, matchID string, mutate func(*MatchResult)) (bool, error)
	// UpdateBracket is the tx-aware twin of Store.UpdateBracket. The
	// mutate closure may modify the bracket arbitrarily and signal "match
	// not found" by returning an error (typically wrapping the engine's
	// match-not-found sentinel — see engine.withBracketMatch).
	UpdateBracket(compID string, mutate func(*Bracket) error) error
	// LockTeamLineupsForRound is the tx-aware twin of
	// Store.LockTeamLineupsForRound. Used by the score-path tx body
	// (T128 / T156) so the lineup freeze happens under the same lock
	// acquire as the score write.
	LockTeamLineupsForRound(compID string, round int, lockedAt time.Time) error
}

StoreTx is the transactional handle passed to fn in WithTransaction. Methods mirror the corresponding *Store methods but DO NOT re-acquire the per-competition lock — that's already held by WithTransaction.

The compID parameter on each method is intentional duplication: it keeps StoreTx methods source-compatible with their *Store siblings, so the migration path for a handler is "wrap in WithTransaction + replace `store.` with `tx.`" with no further rewrites. The transaction is bound to a single competition (passed to WithTransaction); every StoreTx method guards the supplied compID against the bound one and returns ErrMismatchedTxCompID on mismatch, so a stale or wrong ID surfaces as a normal error instead of silently doing unlocked I/O against another competition's files.

type SubMatchResult

type SubMatchResult struct {
	Position int      `json:"position"`
	SideA    string   `json:"sideA"`
	SideB    string   `json:"sideB"`
	IpponsA  []string `json:"ipponsA"`
	IpponsB  []string `json:"ipponsB"`
	HansokuA int      `json:"hansokuA"`
	HansokuB int      `json:"hansokuB"`
	Winner   string   `json:"winner"`
	Decision string   `json:"decision"`
}

type TeamMatchType

type TeamMatchType string

TeamMatchType selects the team-match format. FR-044.

  • TeamMatchTypeFixed: every N×1 bout is scheduled up-front by position (Senpo×Senpo, Jiho×Jiho, …). This is the historical default and the empty value resolves to it for backward compat.
  • TeamMatchTypeKachinuki: only the first bout is scheduled. After each bout, the winner stays on and faces the next un-retired player from the losing side; on a hikiwake both retire. The team match ends when one side has no remaining un-retired players. See engine/kachinuki.go.
const (
	TeamMatchTypeFixed     TeamMatchType = "fixed"
	TeamMatchTypeKachinuki TeamMatchType = "kachinuki"
)

type Tournament

type Tournament struct {
	Name     string   `yaml:"name" json:"name"`
	Date     string   `yaml:"date" json:"date"`
	Venue    string   `yaml:"venue" json:"venue"`
	Courts   []string `yaml:"courts" json:"courts"`
	Password string   `yaml:"password" json:"password"`

	// Ceremony blocks expressed as human duration strings (e.g. "30m",
	// "1h"). When set, the auto-scheduler reserves a contiguous range
	// at the appropriate point in the day and skips match slots that
	// would land inside it. Optional; zero/empty means no block.
	// FR-056, R9, data-model §6.
	OpeningBlock string `yaml:"opening_block,omitempty" json:"openingBlock,omitempty"`
	LunchBlock   string `yaml:"lunch_block,omitempty" json:"lunchBlock,omitempty"`
	ClosingBlock string `yaml:"closing_block,omitempty" json:"closingBlock,omitempty"`

	// ClockToElapsedMultiplier scales the on-clock match duration to
	// "real elapsed minutes" — coin tosses, scoring transitions, salutes
	// and crossings, etc. Defaults to 1.5 via ApplyTournamentDefaults
	// when zero. FR-055, R9.
	ClockToElapsedMultiplier float64 `yaml:"clock_to_elapsed_multiplier,omitempty" json:"clockToElapsedMultiplier,omitempty"`

	// SlowestCourtBufferPct is the % buffer added when distributing total
	// elapsed minutes across N parallel courts — the slowest court usually
	// runs longer than the mean. Defaults to 10 via ApplyTournamentDefaults
	// when zero. FR-057, R9.
	SlowestCourtBufferPct int `yaml:"slowest_court_buffer_pct,omitempty" json:"slowestCourtBufferPct,omitempty"`

	// Check-in window configuration (HH:MM strings on the tournament day).
	// Informational only; the head table can still manually toggle check-ins
	// after the window closes.
	CheckInWindowStart string `yaml:"check_in_window_start,omitempty" json:"checkInWindowStart,omitempty"`
	CheckInWindowEnd   string `yaml:"check_in_window_end,omitempty" json:"checkInWindowEnd,omitempty"`
}

Directories

Path Synopsis
Package wal implements a tiny write-ahead log so that state.Store.WithTransaction can commit MULTIPLE on-disk files atomically across a process crash.
Package wal implements a tiny write-ahead log so that state.Store.WithTransaction can commit MULTIPLE on-disk files atomically across a process crash.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL