95f5703372
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
The seed-drift guard shipped as refuse-boot: the backend exited when BACKEND_DICT_VERSION disagreed with the flat dir's recorded .seed_version. On the test contour that turned a harmless-in-intent action — bumping the TEST_DICT_VERSION variable to the active release (v1.2.1) on a volume seeded as v1.0.0 — into a crash loop, because DICT_VERSION is the *seed* of a fresh volume, not the active version (which the admin console drives). Make the marker authoritative instead: OpenWithVersions resolves the flat dir's version from .seed_version when present and ignores bootVersion on an already-seeded volume; bootVersion only seeds a fresh volume's marker. So a bumped build seed on a live volume is a no-op (it can't relabel live bytes and can't void games pinned to the prior label), and it correctly seeds the next fresh volume. The subdirectory scan now skips the resolved seed, so a version also present as a subdir (e.g. v1.2.1 uploaded via the console while the build seed is bumped to v1.2.1) is still loaded rather than shadowed by the flat bytes. Tests: marker-wins over a bumped boot version; a bumped boot keeps the matching subdir resident (the live-contour case). Docs updated (ARCHITECTURE §5, READMEs, compose/.env, PRERELEASE DV) from "refuses to boot" to "marker wins / ignored". Verified locally against v1.2.1: gofmt, build, vet, unit, integration green.
246 lines
8.2 KiB
Go
246 lines
8.2 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
dawg "github.com/iliadenisov/dafsa"
|
|
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
|
)
|
|
|
|
// dictFiles maps each variant to its committed DAWG filename, as built by
|
|
// scrabble-solver and delivered in the dictionary directory.
|
|
var dictFiles = map[Variant]string{
|
|
VariantEnglish: "en_sowpods.dawg",
|
|
VariantRussianScrabble: "ru_scrabble.dawg",
|
|
VariantErudit: "ru_erudit.dawg",
|
|
}
|
|
|
|
// entry is one resident dictionary: the loaded finder and the solver built over
|
|
// it. The finder is retained so Close can release it.
|
|
type entry struct {
|
|
finder dawg.Finder
|
|
solver *scrabble.Solver
|
|
}
|
|
|
|
// Registry holds the dictionaries resident in memory, addressed by variant and
|
|
// dictionary version, and the solvers built over them. Several versions of a
|
|
// variant may be resident at once; a game pins the version it started on. The
|
|
// admin reload flow (a later stage) registers a new version through Load.
|
|
// Registry is safe for concurrent use.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
entries map[Variant]map[string]entry
|
|
latest map[Variant]string
|
|
}
|
|
|
|
// NewRegistry constructs an empty Registry. Use Load or Open to populate it.
|
|
func NewRegistry() *Registry {
|
|
return &Registry{
|
|
entries: make(map[Variant]map[string]entry),
|
|
latest: make(map[Variant]string),
|
|
}
|
|
}
|
|
|
|
// Open builds a Registry by loading, at dictionary version, the committed DAWG of
|
|
// every requested variant (or all variants when none are named) from dir. It
|
|
// fails if a variant is unknown or its file is missing or unreadable; a
|
|
// partially loaded registry is closed before the error is returned.
|
|
func Open(dir, version string, variants ...Variant) (*Registry, error) {
|
|
if len(variants) == 0 {
|
|
variants = Variants()
|
|
}
|
|
r := NewRegistry()
|
|
for _, v := range variants {
|
|
if err := r.Load(v, version, dir); err != nil {
|
|
_ = r.Close()
|
|
return nil, err
|
|
}
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// OpenWithVersions builds a Registry by loading the boot version from the flat
|
|
// dir (every variant, as Open) and then every additional version held in an
|
|
// immediate subdirectory of dir: a subdirectory named V contributes, under
|
|
// version V, the variants whose committed DAWG it carries. This is the
|
|
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
|
|
// at runtime is resident again after a restart. The flat dir's version is resolved
|
|
// from its .seed_version marker (see resolveSeedVersion): a fresh dir records
|
|
// bootVersion, an already-seeded dir keeps its recorded label and ignores bootVersion,
|
|
// so a bumped build seed never relabels live bytes. A subdirectory named like the
|
|
// resolved seed version is skipped (the flat dir already is it). A partially loaded
|
|
// registry is closed before any error is returned.
|
|
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
|
|
// Resolve the flat dir's version from its seed marker first: on an already-seeded
|
|
// volume the marker wins and bootVersion is ignored, so a bumped build seed cannot
|
|
// relabel live bytes (see resolveSeedVersion).
|
|
seed, err := resolveSeedVersion(dir, bootVersion)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r, err := Open(dir, seed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
_ = r.Close()
|
|
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
|
|
}
|
|
for _, e := range entries {
|
|
// Skip non-directories, the resolved seed version (already loaded as the flat
|
|
// dir) and dot-prefixed directories (the upload staging area, dir/.staging/).
|
|
if !e.IsDir() || e.Name() == seed || strings.HasPrefix(e.Name(), ".") {
|
|
continue
|
|
}
|
|
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
|
|
_ = r.Close()
|
|
return nil, err
|
|
}
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// Load reads the committed DAWG of variant from dir, builds a solver over it and
|
|
// registers it under version. Reloading the same (variant, version) replaces the
|
|
// previous entry, closing its finder. The most recently loaded version of a
|
|
// variant becomes its latest.
|
|
func (r *Registry) Load(v Variant, version, dir string) error {
|
|
rs, ok := v.ruleset()
|
|
if !ok {
|
|
return fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
|
}
|
|
path := filepath.Join(dir, dictFiles[v])
|
|
finder, err := dawg.Load(path)
|
|
if err != nil {
|
|
return fmt.Errorf("engine: load %s dictionary %q from %s: %w", v, version, path, err)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.entries[v] == nil {
|
|
r.entries[v] = make(map[string]entry)
|
|
}
|
|
if old, ok := r.entries[v][version]; ok {
|
|
_ = old.finder.Close()
|
|
}
|
|
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)}
|
|
r.latest[v] = version
|
|
return nil
|
|
}
|
|
|
|
// LoadAvailable loads, under version, every variant whose committed DAWG is
|
|
// present in dir, skipping a variant whose file is absent. It backs the admin
|
|
// dictionary reload (a version subdirectory may carry only the variants that were
|
|
// rebuilt) and OpenWithVersions' boot-time scan. It returns the variants it
|
|
// loaded, in catalogue order, or the first load error.
|
|
func (r *Registry) LoadAvailable(dir, version string) ([]Variant, error) {
|
|
var loaded []Variant
|
|
for _, v := range Variants() {
|
|
path := filepath.Join(dir, dictFiles[v])
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
return loaded, fmt.Errorf("engine: stat %s dictionary %q in %s: %w", v, version, dir, err)
|
|
}
|
|
if err := r.Load(v, version, dir); err != nil {
|
|
return loaded, err
|
|
}
|
|
loaded = append(loaded, v)
|
|
}
|
|
return loaded, nil
|
|
}
|
|
|
|
// Solver returns the solver for the (variant, version) pair, or ErrUnknownVariant
|
|
// when the variant is absent and ErrUnknownVersion when only the version is.
|
|
func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
versions, ok := r.entries[v]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
|
}
|
|
e, ok := versions[version]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
|
|
}
|
|
return e.solver, nil
|
|
}
|
|
|
|
// Latest returns the most recently loaded version of variant and its solver, or
|
|
// ErrUnknownVariant when none is resident.
|
|
func (r *Registry) Latest(v Variant) (string, *scrabble.Solver, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
version, ok := r.latest[v]
|
|
if !ok {
|
|
return "", nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
|
}
|
|
return version, r.entries[v][version].solver, nil
|
|
}
|
|
|
|
// Versions returns the dictionary versions resident for variant, sorted, or nil
|
|
// when none are.
|
|
func (r *Registry) Versions(v Variant) []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
if len(r.entries[v]) == 0 {
|
|
return nil
|
|
}
|
|
versions := make([]string, 0, len(r.entries[v]))
|
|
for ver := range r.entries[v] {
|
|
versions = append(versions, ver)
|
|
}
|
|
sort.Strings(versions)
|
|
return versions
|
|
}
|
|
|
|
// Lookup reports whether word is present in the (variant, version) dictionary,
|
|
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
|
|
// ErrUnknownVersion when that dictionary is not resident, and an error when word
|
|
// contains a character outside the variant's alphabet. The word is matched as
|
|
// given; callers normalise case to the variant's alphabet first.
|
|
func (r *Registry) Lookup(v Variant, version, word string) (bool, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
versions, ok := r.entries[v]
|
|
if !ok {
|
|
return false, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
|
}
|
|
e, ok := versions[version]
|
|
if !ok {
|
|
return false, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
|
|
}
|
|
idx, err := e.finder.IndexOf(word)
|
|
if err != nil {
|
|
return false, fmt.Errorf("engine: lookup %q in %s/%s: %w", word, v, version, err)
|
|
}
|
|
return idx >= 0, nil
|
|
}
|
|
|
|
// Close releases every resident dictionary and empties the registry. It is safe
|
|
// to call more than once; the first close error is returned after all finders
|
|
// have been closed.
|
|
func (r *Registry) Close() error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
var firstErr error
|
|
for v, versions := range r.entries {
|
|
for ver, e := range versions {
|
|
if err := e.finder.Close(); err != nil && firstErr == nil {
|
|
firstErr = fmt.Errorf("engine: close %s/%s dictionary: %w", v, ver, err)
|
|
}
|
|
}
|
|
}
|
|
r.entries = make(map[Variant]map[string]entry)
|
|
r.latest = make(map[Variant]string)
|
|
return firstErr
|
|
}
|