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

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:
Ilia Denisov
2026-06-13 23:29:06 +02:00
parent 5ff07da025
commit 0f3671f42d
31 changed files with 1590 additions and 78 deletions
+74 -10
View File
@@ -9,6 +9,7 @@ import (
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
@@ -30,12 +31,16 @@ type Service struct {
registry *engine.Registry
cache *gameCache
locks *keyedMutex
version string
clock func() time.Time
rng func() int64
pub notify.Publisher
metrics *gameMetrics
log *zap.Logger
// verMu guards version, the dictionary version new games pin. It is read on
// the game-create path and rewritten when an operator activates a new version
// through the admin console, so access is serialised.
verMu sync.RWMutex
version string
clock func() time.Time
rng func() int64
pub notify.Publisher
metrics *gameMetrics
log *zap.Logger
}
// NewService constructs a Service. store and accounts wrap the same pool;
@@ -70,6 +75,62 @@ func (svc *Service) SetNotifier(p notify.Publisher) {
}
}
// activeVersion returns the dictionary version new games currently pin.
func (svc *Service) activeVersion() string {
svc.verMu.RLock()
defer svc.verMu.RUnlock()
return svc.version
}
// ActiveVersion returns the dictionary version new games currently pin. It backs
// the admin console's "active version" display.
func (svc *Service) ActiveVersion() string {
return svc.activeVersion()
}
// InitActiveVersion reconciles the persisted active dictionary version with the
// registry at startup. If a version was persisted and is resident, it becomes the
// active version; otherwise the configured seed version is kept and persisted as
// the initial active version. Call once during wiring, before serving traffic.
func (svc *Service) InitActiveVersion(ctx context.Context) error {
persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
if err != nil {
return err
}
if ok && svc.versionResident(persisted) {
svc.verMu.Lock()
svc.version = persisted
svc.verMu.Unlock()
return nil
}
return svc.store.SetActiveDictVersion(ctx, svc.activeVersion())
}
// SetActiveVersion records version as the active dictionary version, persisting it
// and updating the in-memory pointer new games read. The caller must have made the
// version resident in the registry first.
func (svc *Service) SetActiveVersion(ctx context.Context, version string) error {
if err := svc.store.SetActiveDictVersion(ctx, version); err != nil {
return err
}
svc.verMu.Lock()
svc.version = version
svc.verMu.Unlock()
return nil
}
// versionResident reports whether any variant of version is loaded in the
// registry, so a persisted active version pointing at a no-longer-present version
// (e.g. after a volume loss) falls back to the seed.
func (svc *Service) versionResident(version string) bool {
for _, v := range engine.Variants() {
if _, err := svc.registry.Solver(v, version); err == nil {
return true
}
}
return false
}
// Create starts and persists a new game seating the given accounts in turn order
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
// the player count (24), the move clock, the hint allowance and that every seat
@@ -106,9 +167,12 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
if seed == 0 {
seed = svc.rng()
}
// Capture the active version once so the engine game and the persisted
// dict_version agree even if an operator activates a new version concurrently.
version := svc.activeVersion()
g, err := engine.New(svc.registry, engine.Options{
Variant: params.Variant,
Version: svc.version,
Version: version,
Players: len(params.Seats),
Seed: seed,
DropoutTiles: params.DropoutTiles,
@@ -128,7 +192,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
ins := gameInsert{
id: id,
variant: params.Variant.String(),
dictVersion: svc.version,
dictVersion: version,
seed: seed,
players: len(params.Seats),
turnTimeoutSecs: int(timeout / time.Second),
@@ -180,7 +244,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
ins := gameInsert{
id: id,
variant: params.Variant.String(),
dictVersion: svc.version,
dictVersion: svc.activeVersion(),
seed: seed,
players: 2,
turnTimeoutSecs: int(timeout / time.Second),
@@ -759,7 +823,7 @@ func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange,
}
// MarkChangesApplied records that every pending accepted change for variant has
// been folded into the dictionary version that was just hot-reloaded, removing
// been folded into the dictionary version that was just installed, removing
// them from DictionaryChanges. It returns the number of changes marked.
func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) {
return svc.store.MarkChangesApplied(ctx, variant.String(), version)