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:
@@ -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 (2–4), 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)
|
||||
|
||||
@@ -767,6 +767,41 @@ func (s *Store) MarkChangesApplied(ctx context.Context, variant, version string)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetActiveDictVersion returns the persisted active dictionary version and true,
|
||||
// or ("", false, nil) when none has been recorded yet (a fresh database). It reads
|
||||
// the dictionary_state singleton (docs/ARCHITECTURE.md §5).
|
||||
func (s *Store) GetActiveDictVersion(ctx context.Context) (string, bool, error) {
|
||||
stmt := postgres.SELECT(table.DictionaryState.ActiveVersion).
|
||||
FROM(table.DictionaryState).
|
||||
WHERE(table.DictionaryState.ID.EQ(postgres.Bool(true)))
|
||||
var row model.DictionaryState
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("game: get active dict version: %w", err)
|
||||
}
|
||||
return row.ActiveVersion, true, nil
|
||||
}
|
||||
|
||||
// SetActiveDictVersion records version as the active dictionary version, upserting
|
||||
// the dictionary_state singleton so the choice survives a restart.
|
||||
func (s *Store) SetActiveDictVersion(ctx context.Context, version string) error {
|
||||
now := time.Now().UTC()
|
||||
stmt := table.DictionaryState.
|
||||
INSERT(table.DictionaryState.ID, table.DictionaryState.ActiveVersion, table.DictionaryState.UpdatedAt).
|
||||
VALUES(true, version, postgres.TimestampzT(now)).
|
||||
ON_CONFLICT(table.DictionaryState.ID).
|
||||
DO_UPDATE(postgres.SET(
|
||||
table.DictionaryState.ActiveVersion.SET(postgres.String(version)),
|
||||
table.DictionaryState.UpdatedAt.SET(postgres.TimestampzT(now)),
|
||||
))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("game: set active dict version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountComplaints returns the number of complaints, optionally restricted to a
|
||||
// status, for the admin queue pager and the dashboard counts.
|
||||
func (s *Store) CountComplaints(ctx context.Context, status string) (int, error) {
|
||||
|
||||
@@ -248,7 +248,7 @@ type Complaint struct {
|
||||
// complaint: Add reports whether Word should be added (DispositionAcceptAdd) or
|
||||
// removed (DispositionAcceptRemove) for Variant. The admin console lists the
|
||||
// pending changes as the input to the offline DAWG rebuild; once a rebuilt
|
||||
// dictionary version is hot-reloaded they are marked applied.
|
||||
// dictionary version is installed they are marked applied.
|
||||
type DictionaryChange struct {
|
||||
ComplaintID uuid.UUID
|
||||
Variant engine.Variant
|
||||
|
||||
Reference in New Issue
Block a user