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
- Variables
- func ApplyCompetitionDefaults(c *Competition)
- func ApplyTournamentDefaults(t *Tournament)
- func DeriveQueuePositions(matches []MatchResult) []int
- func IsDraw(decision string) bool
- func ValidateCompetitionID(id string) error
- func ValidateTeamMatchType(t TeamMatchType, teamSize int) error
- type Announcement
- type AnnouncementStore
- type Bracket
- type BracketMatch
- type Competition
- type CompetitionStatus
- type EnchoMetadata
- type LoadParticipantsOpts
- type MatchResult
- type MatchStatus
- type Overrides
- type PlayerStanding
- type ReservedSlot
- type ScheduleEntry
- type Store
- func (s *Store) AddReservedSlot(compID string, sourceCompID string, sourceRank int, withZekkenName bool) (*ReservedSlot, error)
- func (s *Store) AnnouncementStore() *AnnouncementStore
- func (s *Store) DeleteCompetition(id string) error
- func (s *Store) DeleteTeamLineup(compID, teamID string, round int) error
- func (s *Store) FileMtime(compID, filename string) int64
- func (s *Store) GetFolder() string
- func (s *Store) ListCompetitions() ([]string, error)
- func (s *Store) LoadBracket(compID string) (*Bracket, error)
- func (s *Store) LoadCompetition(id string) (*Competition, error)
- func (s *Store) LoadCompetitorStatus(compID string) (map[string]domain.CompetitorStatus, error)
- func (s *Store) LoadOverrides(compID string) (*Overrides, error)
- func (s *Store) LoadParticipants(compID string, withZekkenName bool) ([]domain.Player, error)
- func (s *Store) LoadParticipantsOpt(compID string, withZekkenName bool, opts LoadParticipantsOpts) ([]domain.Player, error)
- func (s *Store) LoadPoolMatches(compID string) ([]MatchResult, error)
- func (s *Store) LoadPoolMatchesLocked(compID string) ([]MatchResult, error)
- func (s *Store) LoadPools(compID string) ([]helper.Pool, error)
- func (s *Store) LoadReservedSlots(compID string) ([]ReservedSlot, error)
- func (s *Store) LoadSchedule(compID string) ([]ScheduleEntry, error)
- func (s *Store) LoadSeeds(compID string) ([]domain.SeedAssignment, error)
- func (s *Store) LoadTeamLineups(compID string) (map[string]domain.TeamLineup, error)
- func (s *Store) LoadTournament() (*Tournament, error)
- func (s *Store) LockTeamLineupsForRound(compID string, round int, lockedAt time.Time) error
- func (s *Store) RemoveReservedSlot(compID string, slotID string, withZekkenName bool) error
- func (s *Store) ResetOverrides(compID string) error
- func (s *Store) ResetOverridesChanged(compID string) (bool, error)
- func (s *Store) SaveBracket(compID string, b *Bracket) error
- func (s *Store) SaveCompetition(c *Competition) error
- func (s *Store) SaveCompetitionChanged(c *Competition) (bool, error)
- func (s *Store) SaveOverrides(compID string, o *Overrides) error
- func (s *Store) SaveParticipants(compID string, players []domain.Player) error
- func (s *Store) SavePoolMatches(compID string, results []MatchResult) error
- func (s *Store) SavePools(compID string, pools []helper.Pool) error
- func (s *Store) SaveRankOverride(compID, poolID, playerName string, rank int) error
- func (s *Store) SaveRankOverrideChanged(compID, poolID, playerName string, rank int) (bool, error)
- func (s *Store) SaveReservedSlots(compID string, slots []ReservedSlot) error
- func (s *Store) SaveSchedule(compID string, entries []ScheduleEntry) error
- func (s *Store) SaveScheduleChanged(compID string, entries []ScheduleEntry) (bool, error)
- func (s *Store) SaveSeeds(compID string, assignments []domain.SeedAssignment) error
- func (s *Store) SaveTournament(t *Tournament) error
- func (s *Store) SaveTournamentChanged(t *Tournament) (bool, error)
- func (s *Store) SaveWinnerOverride(compID, matchID, winnerName string) error
- func (s *Store) SetCompetitorStatus(compID string, status domain.CompetitorStatus) error
- func (s *Store) SetTeamLineup(compID string, lineup domain.TeamLineup, teamSize int) error
- func (s *Store) UpdateBracket(compID string, mutate func(*Bracket) error) error
- func (s *Store) UpdateCompetitionChanged(id string, transform func(current *Competition) (*Competition, error)) (bool, error)
- func (s *Store) UpdateParticipant(compID string, pid string, withZekkenName bool, ...) (*domain.Player, error)
- func (s *Store) UpdatePoolMatchByID(compID, matchID string, mutate func(*MatchResult)) (bool, error)
- func (s *Store) UpdateTournamentChanged(desired *Tournament, ...) (bool, error)
- func (s *Store) WithCompetitionRenameLock(fn func() error) error
- func (s *Store) WithTransaction(compID string, fn func(tx StoreTx) error) error
- type StoreTx
- type SubMatchResult
- type TeamMatchType
- type Tournament
Constants ¶
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.
const DecisionDraw = "hikiwake"
DecisionDraw is the canonical value for a tied (hikiwake) match.
Variables ¶
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.
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.
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 ValidateCompetitionID ¶
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 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 ¶
func (s *AnnouncementStore) Set(msg string, dur time.Duration) Announcement
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 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 (*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 (*Store) DeleteTeamLineup ¶
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 ¶
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) ListCompetitions ¶
func (*Store) LoadCompetition ¶
func (s *Store) LoadCompetition(id string) (*Competition, error)
func (*Store) LoadCompetitorStatus ¶
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) LoadParticipants ¶
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) 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 ¶
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 ¶
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 ¶
RemoveReservedSlot deletes a slot and its placeholder participant.
func (*Store) ResetOverrides ¶
func (*Store) ResetOverridesChanged ¶
ResetOverridesChanged clears all overrides and reports whether the file changed (false when overrides were already empty).
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) SaveParticipants ¶
func (*Store) SavePoolMatches ¶
func (s *Store) SavePoolMatches(compID string, results []MatchResult) error
func (*Store) SaveRankOverride ¶
func (*Store) SaveRankOverrideChanged ¶
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 (*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 ¶
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 ¶
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 ¶
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 ¶
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"`
}
Source Files
¶
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. |