feat(admin): online dictionary update — upload archive, preview word diff, install & activate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
Replace the dictionary hot-reload with an online update flow in the GM console (/_gm/dictionary): the operator uploads scrabble-dawg-vX.Y.Z.tar.gz, previews the per-variant words added/removed against the active dictionary, and confirms to install + activate it. Versions are immutable; in-progress games keep their pinned version while new games use the new one. - engine: DiffWords (enumerate both DAWGs, decode only the differences), OpenFinder, Registry.Finder, DictFiles; OpenWithVersions skips the .staging area. - dictadmin: hardened release-archive validation + extraction (path-traversal, symlink, oversize, entry-count rejection) and staging -> install (atomic rename). - game: active dictionary version persisted in the dictionary_state singleton (single source of truth, restored on boot), concurrency-safe accessor. - storage: BACKEND_DICT_DIR is a named volume seeded from the image (nonroot-owned), so uploaded versions persist across redeploys; the build's DICT_VERSION labels the seed and equals the resident tag (BACKEND_DICT_VERSION). - docs: ARCHITECTURE §5, FUNCTIONAL (+ru), backend README, TESTING, PRERELEASE. Tests: engine + dictadmin unit; integration upload->preview->install->activate-> restart->pin->immutability->CSRF.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
)
|
||||
|
||||
// WordDiff is the set difference between two dictionaries of one variant: the
|
||||
// words present only in the new dictionary (Added) and only in the old one
|
||||
// (Removed), decoded to characters and each sorted by the variant's alphabet
|
||||
// order. It drives the admin dictionary-update preview.
|
||||
type WordDiff struct {
|
||||
Added []string
|
||||
Removed []string
|
||||
}
|
||||
|
||||
// DictFiles returns a copy of the variant-to-committed-DAWG-filename map. It lets
|
||||
// callers outside the package (the dictionary-admin upload validation) check an
|
||||
// archive against the expected file set without sharing the engine's own map.
|
||||
func DictFiles() map[Variant]string {
|
||||
out := make(map[Variant]string, len(dictFiles))
|
||||
maps.Copy(out, dictFiles)
|
||||
return out
|
||||
}
|
||||
|
||||
// OpenFinder loads the committed DAWG of variant v from dir and returns its
|
||||
// finder. The caller owns the finder and must Close it. It backs enumeration of
|
||||
// a staged, not-yet-registered dictionary version.
|
||||
func OpenFinder(dir string, v Variant) (dawg.Finder, error) {
|
||||
name, ok := dictFiles[v]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
finder, err := dawg.Load(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("engine: load %s dictionary from %s: %w", v, path, err)
|
||||
}
|
||||
return finder, nil
|
||||
}
|
||||
|
||||
// Finder returns the loaded finder for the (variant, version) pair so its words
|
||||
// can be enumerated, or ErrUnknownVariant / ErrUnknownVersion when that
|
||||
// dictionary is not resident. The finder remains owned by the registry; callers
|
||||
// must not Close it.
|
||||
func (r *Registry) Finder(v Variant, version string) (dawg.Finder, 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.finder, nil
|
||||
}
|
||||
|
||||
// DiffWords compares the old and new finders of variant v and returns the words
|
||||
// added and removed, decoded through v's alphabet. Both dictionaries are
|
||||
// enumerated as alphabet-index bytes and merged; only the differing words are
|
||||
// decoded, so a full-dictionary comparison does not materialise two large string
|
||||
// sets.
|
||||
func DiffWords(v Variant, old, updated dawg.Finder) (WordDiff, error) {
|
||||
rs, ok := v.ruleset()
|
||||
if !ok {
|
||||
return WordDiff{}, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
oldWords := collectWords(old)
|
||||
newWords := collectWords(updated)
|
||||
|
||||
var diff WordDiff
|
||||
i, j := 0, 0
|
||||
for i < len(oldWords) && j < len(newWords) {
|
||||
switch cmp := bytes.Compare(oldWords[i], newWords[j]); {
|
||||
case cmp == 0:
|
||||
i++
|
||||
j++
|
||||
case cmp < 0:
|
||||
s, err := rs.Alphabet.Decode(oldWords[i])
|
||||
if err != nil {
|
||||
return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err)
|
||||
}
|
||||
diff.Removed = append(diff.Removed, s)
|
||||
i++
|
||||
default:
|
||||
s, err := rs.Alphabet.Decode(newWords[j])
|
||||
if err != nil {
|
||||
return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err)
|
||||
}
|
||||
diff.Added = append(diff.Added, s)
|
||||
j++
|
||||
}
|
||||
}
|
||||
for ; i < len(oldWords); i++ {
|
||||
s, err := rs.Alphabet.Decode(oldWords[i])
|
||||
if err != nil {
|
||||
return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err)
|
||||
}
|
||||
diff.Removed = append(diff.Removed, s)
|
||||
}
|
||||
for ; j < len(newWords); j++ {
|
||||
s, err := rs.Alphabet.Decode(newWords[j])
|
||||
if err != nil {
|
||||
return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err)
|
||||
}
|
||||
diff.Added = append(diff.Added, s)
|
||||
}
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
// collectWords enumerates every complete word of f as a copy of its
|
||||
// alphabet-index bytes, sorted ascending. The dawg already enumerates in index
|
||||
// order; the explicit sort makes the merge in DiffWords robust to that being an
|
||||
// implementation detail.
|
||||
func collectWords(f dawg.Finder) [][]byte {
|
||||
out := make([][]byte, 0, f.NumAdded())
|
||||
f.EnumerateB(func(_ int, word []byte, final bool) dawg.EnumerationResult {
|
||||
if final && len(word) > 0 {
|
||||
cp := make([]byte, len(word))
|
||||
copy(cp, word)
|
||||
out = append(out, cp)
|
||||
}
|
||||
return dawg.Continue
|
||||
})
|
||||
sort.Slice(out, func(a, b int) bool { return bytes.Compare(out[a], out[b]) < 0 })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/alphabet"
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
)
|
||||
|
||||
// buildFinderB builds an in-memory finder over idx from the given words, each
|
||||
// expressed as alphabet-index bytes. The words must be supplied in strictly
|
||||
// increasing alphabet-index order, as the builder requires.
|
||||
func buildFinderB(t *testing.T, idx alphabet.Indexer, words ...[]byte) dawg.Finder {
|
||||
t.Helper()
|
||||
b := dawg.New(idx)
|
||||
for _, w := range words {
|
||||
if err := b.AddB(w); err != nil {
|
||||
t.Fatalf("addB %v: %v", w, err)
|
||||
}
|
||||
}
|
||||
return b.Finish()
|
||||
}
|
||||
|
||||
// mustDecode decodes index bytes through idx or fails the test.
|
||||
func mustDecode(t *testing.T, idx alphabet.Indexer, word []byte) string {
|
||||
t.Helper()
|
||||
s, err := idx.Decode(word)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %v: %v", word, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestDiffWords checks that DiffWords reports the words present only in the new
|
||||
// dictionary as added and those present only in the old one as removed, decoded
|
||||
// to characters through the variant's alphabet.
|
||||
func TestDiffWords(t *testing.T) {
|
||||
rs, _ := VariantEnglish.ruleset()
|
||||
idx := rs.Alphabet
|
||||
|
||||
old := buildFinderB(t, idx, []byte{0}, []byte{0, 1}, []byte{2})
|
||||
defer func() { _ = old.Close() }()
|
||||
updated := buildFinderB(t, idx, []byte{0}, []byte{2}, []byte{2, 3})
|
||||
defer func() { _ = updated.Close() }()
|
||||
|
||||
diff, err := DiffWords(VariantEnglish, old, updated)
|
||||
if err != nil {
|
||||
t.Fatalf("DiffWords: %v", err)
|
||||
}
|
||||
|
||||
wantAdded := []string{mustDecode(t, idx, []byte{2, 3})}
|
||||
wantRemoved := []string{mustDecode(t, idx, []byte{0, 1})}
|
||||
if !equalStrings(diff.Added, wantAdded) {
|
||||
t.Errorf("added = %v, want %v", diff.Added, wantAdded)
|
||||
}
|
||||
if !equalStrings(diff.Removed, wantRemoved) {
|
||||
t.Errorf("removed = %v, want %v", diff.Removed, wantRemoved)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffWordsIdentical checks that comparing a dictionary with itself yields an
|
||||
// empty diff.
|
||||
func TestDiffWordsIdentical(t *testing.T) {
|
||||
rs, _ := VariantEnglish.ruleset()
|
||||
f := buildFinderB(t, rs.Alphabet, []byte{0}, []byte{1}, []byte{1, 2})
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
diff, err := DiffWords(VariantEnglish, f, f)
|
||||
if err != nil {
|
||||
t.Fatalf("DiffWords: %v", err)
|
||||
}
|
||||
if len(diff.Added) != 0 || len(diff.Removed) != 0 {
|
||||
t.Errorf("diff = %+v, want empty", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffWordsUnknownVariant checks that an unrecognised variant is rejected.
|
||||
func TestDiffWordsUnknownVariant(t *testing.T) {
|
||||
rs, _ := VariantEnglish.ruleset()
|
||||
f := buildFinderB(t, rs.Alphabet, []byte{0})
|
||||
defer func() { _ = f.Close() }()
|
||||
if _, err := DiffWords(Variant(250), f, f); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Errorf("err = %v, want ErrUnknownVariant", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenFinder checks that OpenFinder loads a committed DAWG from a directory.
|
||||
func TestOpenFinder(t *testing.T) {
|
||||
f, err := OpenFinder(testDictDir(), VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenFinder: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if f.NumAdded() == 0 {
|
||||
t.Error("english dictionary is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryFinder checks that Finder returns the resident finder for a pair
|
||||
// and the right sentinel when the version is absent.
|
||||
func TestRegistryFinder(t *testing.T) {
|
||||
if _, err := testReg.Finder(VariantEnglish, testVersion); err != nil {
|
||||
t.Errorf("Finder(scrabble_en, %q): %v", testVersion, err)
|
||||
}
|
||||
if _, err := testReg.Finder(VariantEnglish, "absent"); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("Finder absent version err = %v, want ErrUnknownVersion", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDictFiles checks that DictFiles exposes the variant filename map as a copy
|
||||
// the caller cannot use to mutate the engine's own map.
|
||||
func TestDictFiles(t *testing.T) {
|
||||
files := DictFiles()
|
||||
if len(files) != len(Variants()) {
|
||||
t.Fatalf("DictFiles has %d entries, want %d", len(files), len(Variants()))
|
||||
}
|
||||
files[VariantEnglish] = "tampered"
|
||||
if again := DictFiles(); again[VariantEnglish] == "tampered" {
|
||||
t.Error("DictFiles returned a shared map; mutating it changed the engine's map")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
@@ -83,7 +84,9 @@ func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
|
||||
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || e.Name() == bootVersion {
|
||||
// Skip non-directories, the boot version (already loaded as the flat dir)
|
||||
// and dot-prefixed directories (the upload staging area, dir/.staging/).
|
||||
if !e.IsDir() || e.Name() == bootVersion || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
|
||||
|
||||
@@ -91,6 +91,27 @@ func TestOpenWithVersionsScansSubdirs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenWithVersionsSkipsDotDirs verifies the boot scan ignores dot-prefixed
|
||||
// subdirectories (the upload staging area), so a half-extracted archive there
|
||||
// never becomes a resident version.
|
||||
func TestOpenWithVersionsSkipsDotDirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, v := range Variants() {
|
||||
copyDawg(t, testDictDir(), dir, v)
|
||||
}
|
||||
copyDawg(t, testDictDir(), filepath.Join(dir, ".staging"), VariantEnglish)
|
||||
|
||||
reg, err := OpenWithVersions(dir, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("open with versions: %v", err)
|
||||
}
|
||||
defer func() { _ = reg.Close() }()
|
||||
|
||||
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
|
||||
t.Errorf("scrabble_en versions = %v, want only [v1] (.staging skipped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant
|
||||
// already resident, moves the latest pointer and keeps the earlier version.
|
||||
func TestReloadRegistersNewVersion(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user