native polish: guest-locale banner, seamless guest sign-in (+local-game fix), 'Opponent's turn' label, deploy-authoritative dictionary #273
+7
-2
@@ -49,9 +49,14 @@ COPY --from=build /out/backend /usr/local/bin/backend
|
|||||||
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
|
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
|
||||||
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
|
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
|
||||||
# can write new version subdirectories at runtime. The volume preserves uploaded
|
# can write new version subdirectories at runtime. The volume preserves uploaded
|
||||||
# versions across deploys and, once seeded, is not re-seeded — so after bootstrap
|
# versions across deploys and, once seeded, is not re-seeded (ARCHITECTURE.md §5).
|
||||||
# every dictionary change goes through the console, not a rebuild (ARCHITECTURE.md §5).
|
|
||||||
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg
|
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg
|
||||||
|
# A second, UNSHADOWED copy of the build's DAWGs: the /opt/dawg volume mount hides the
|
||||||
|
# image's copy there, so this is where engine.DeliverVersion reads the build version from
|
||||||
|
# to add it (add-only) to the volume on boot — the deploy-delivers path that lets a bumped
|
||||||
|
# BACKEND_DICT_VERSION go live for new games without an admin upload (ARCHITECTURE.md §5).
|
||||||
|
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg-seed
|
||||||
ENV BACKEND_DICT_DIR=/opt/dawg
|
ENV BACKEND_DICT_DIR=/opt/dawg
|
||||||
|
ENV BACKEND_DICT_SEED_DIR=/opt/dawg-seed
|
||||||
ENV BACKEND_DICT_VERSION=${DICT_VERSION}
|
ENV BACKEND_DICT_VERSION=${DICT_VERSION}
|
||||||
ENTRYPOINT ["/usr/local/bin/backend"]
|
ENTRYPOINT ["/usr/local/bin/backend"]
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
}
|
}
|
||||||
logger.Info("database migrations applied")
|
logger.Info("database migrations applied")
|
||||||
|
|
||||||
|
// Deliver the build's dictionary version onto the persistent volume if a redeploy
|
||||||
|
// bumped it (add-only; old versions in-flight games pin are untouched), so the new
|
||||||
|
// dictionary is resident and can be activated below without an admin upload.
|
||||||
|
if err := engine.DeliverVersion(cfg.Game.DictDir, cfg.Game.DictSeedDir, cfg.Game.DictVersion); err != nil {
|
||||||
|
return fmt.Errorf("deliver dictionary version: %w", err)
|
||||||
|
}
|
||||||
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
|
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load dictionaries: %w", err)
|
return fmt.Errorf("load dictionaries: %w", err)
|
||||||
@@ -118,6 +124,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
defer func() { _ = registry.Close() }()
|
defer func() { _ = registry.Close() }()
|
||||||
logger.Info("dictionaries loaded",
|
logger.Info("dictionaries loaded",
|
||||||
zap.String("dir", cfg.Game.DictDir),
|
zap.String("dir", cfg.Game.DictDir),
|
||||||
|
zap.String("seed_dir", cfg.Game.DictSeedDir),
|
||||||
zap.String("version", cfg.Game.DictVersion))
|
zap.String("version", cfg.Game.DictVersion))
|
||||||
|
|
||||||
// Admin console: an optional backend client to the Telegram connector
|
// Admin console: an optional backend client to the Telegram connector
|
||||||
@@ -147,10 +154,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
accounts := account.NewStore(db)
|
accounts := account.NewStore(db)
|
||||||
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
||||||
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
||||||
// Reconcile the persisted active dictionary version with the registry: a
|
// Reconcile the active dictionary version with the registry: the build version
|
||||||
// version activated through the admin console (and written to the dictionary
|
// (delivered above) becomes active so a redeploy that bumped it goes live for new
|
||||||
// volume) is adopted again after a restart; otherwise the configured seed
|
// games, except a newer version installed out-of-band through the admin console,
|
||||||
// version is kept and persisted (docs/ARCHITECTURE.md §5).
|
// which is not downgraded by a restart (docs/ARCHITECTURE.md §5).
|
||||||
if err := games.InitActiveVersion(ctx); err != nil {
|
if err := games.InitActiveVersion(ctx); err != nil {
|
||||||
return fmt.Errorf("init active dictionary version: %w", err)
|
return fmt.Errorf("init active dictionary version: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ func Load() (Config, error) {
|
|||||||
gm := game.DefaultConfig()
|
gm := game.DefaultConfig()
|
||||||
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
|
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
|
||||||
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
|
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
|
||||||
|
gm.DictSeedDir = envOr("BACKEND_DICT_SEED_DIR", gm.DictSeedDir)
|
||||||
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
|
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeliverVersion makes the build's dictionary version resident on the persistent
|
||||||
|
// dictionary volume without disturbing the versions in-flight games already pin.
|
||||||
|
//
|
||||||
|
// The volume is seeded from the image once and never re-seeded, and the image's
|
||||||
|
// /opt/dawg is shadowed by that volume mount, so a redeploy that bumped
|
||||||
|
// BACKEND_DICT_VERSION cannot otherwise make the new DAWGs reachable at runtime.
|
||||||
|
// The image therefore keeps an unshadowed read-only copy at seedDir, and this
|
||||||
|
// copies it into dictDir/<version>/ when the version is not already present —
|
||||||
|
// neither the flat seed's own recorded label nor an existing version
|
||||||
|
// subdirectory. It is add-only and idempotent: the flat seed and any prior admin
|
||||||
|
// uploads are never touched, so old games keep the version they pin while the new
|
||||||
|
// one becomes resident and activatable (see game.Service.InitActiveVersion). A
|
||||||
|
// blank seedDir disables delivery (the pre-existing seed-only behaviour).
|
||||||
|
func DeliverVersion(dictDir, seedDir, version string) error {
|
||||||
|
if seedDir == "" || version == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
target := filepath.Join(dictDir, version)
|
||||||
|
if _, err := os.Stat(target); err == nil {
|
||||||
|
return nil // already delivered on a prior boot
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("engine: stat delivered dictionary %s: %w", target, err)
|
||||||
|
}
|
||||||
|
// A fresh volume is populated from the image and recorded as this version by the
|
||||||
|
// marker: it is the flat dir, so there is nothing to deliver. resolveSeedVersion
|
||||||
|
// records the label on first use, matching OpenWithVersions.
|
||||||
|
seed, err := resolveSeedVersion(dictDir, version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if seed == version {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Stage into a dot-prefixed directory (skipped by OpenWithVersions' version scan)
|
||||||
|
// then rename, so a crash mid-copy never leaves a half-populated version subdir.
|
||||||
|
staging := filepath.Join(dictDir, ".staging-deliver-"+version)
|
||||||
|
if err := os.RemoveAll(staging); err != nil {
|
||||||
|
return fmt.Errorf("engine: clear deliver staging %s: %w", staging, err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("engine: create deliver staging %s: %w", staging, err)
|
||||||
|
}
|
||||||
|
for _, file := range dictFiles {
|
||||||
|
src := filepath.Join(seedDir, file)
|
||||||
|
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue // a variant absent from the seed is simply absent under this version
|
||||||
|
} else if err != nil {
|
||||||
|
_ = os.RemoveAll(staging)
|
||||||
|
return fmt.Errorf("engine: stat seed dawg %s: %w", src, err)
|
||||||
|
}
|
||||||
|
if err := copyFile(src, filepath.Join(staging, file)); err != nil {
|
||||||
|
_ = os.RemoveAll(staging)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Rename(staging, target); err != nil {
|
||||||
|
_ = os.RemoveAll(staging)
|
||||||
|
return fmt.Errorf("engine: publish delivered dictionary %s: %w", target, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile copies a single file, truncating the destination.
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("engine: open %s: %w", src, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = in.Close() }()
|
||||||
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("engine: create %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
_ = out.Close()
|
||||||
|
return fmt.Errorf("engine: copy %s -> %s: %w", src, dst, err)
|
||||||
|
}
|
||||||
|
if err := out.Close(); err != nil {
|
||||||
|
return fmt.Errorf("engine: finalise %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeliverVersion(t *testing.T) {
|
||||||
|
// A blank seed directory disables delivery (the pre-existing seed-only behaviour).
|
||||||
|
if err := DeliverVersion(t.TempDir(), "", "v1.3.1"); err != nil {
|
||||||
|
t.Fatalf("blank seedDir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An existing volume flat-seeded as v1.3.0 gets v1.3.1 delivered as a subdirectory,
|
||||||
|
// add-only: the flat seed's marker is left intact and the new version's DAWGs land.
|
||||||
|
dict := t.TempDir()
|
||||||
|
seed := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dict, seedMarkerFile), []byte("v1.3.0\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, f := range dictFiles {
|
||||||
|
if err := os.WriteFile(filepath.Join(seed, f), []byte("dawg:"+f), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
|
||||||
|
t.Fatalf("deliver v1.3.1: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range dictFiles {
|
||||||
|
got, err := os.ReadFile(filepath.Join(dict, "v1.3.1", f))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delivered %s: %v", f, err)
|
||||||
|
}
|
||||||
|
if string(got) != "dawg:"+f {
|
||||||
|
t.Errorf("delivered %s = %q, want the seed bytes", f, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m, _ := os.ReadFile(filepath.Join(dict, seedMarkerFile)); string(m) != "v1.3.0\n" {
|
||||||
|
t.Errorf("flat seed marker relabelled to %q (delivery must be add-only)", m)
|
||||||
|
}
|
||||||
|
// Idempotent: a second call is a no-op and leaves no staging directory behind.
|
||||||
|
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
|
||||||
|
t.Fatalf("re-deliver v1.3.1: %v", err)
|
||||||
|
}
|
||||||
|
if entries, _ := os.ReadDir(dict); hasStaging(entries) {
|
||||||
|
t.Errorf("a staging directory survived delivery")
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a fresh directory the build version becomes the flat seed, so nothing is delivered
|
||||||
|
// as a redundant subdirectory.
|
||||||
|
fresh := t.TempDir()
|
||||||
|
if err := DeliverVersion(fresh, seed, "v1.3.1"); err != nil {
|
||||||
|
t.Fatalf("fresh deliver: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(fresh, "v1.3.1")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("fresh volume got a redundant v1.3.1 subdir; it should be the flat seed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasStaging(entries []os.DirEntry) bool {
|
||||||
|
for _, e := range entries {
|
||||||
|
if len(e.Name()) >= len(".staging") && e.Name()[:len(".staging")] == ".staging" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -25,11 +25,11 @@ const seedMarkerFile = ".seed_version"
|
|||||||
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
|
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
|
||||||
// - already-seeded directory: return the recorded marker and ignore bootVersion.
|
// - already-seeded directory: return the recorded marker and ignore bootVersion.
|
||||||
//
|
//
|
||||||
// So bumping the build seed on a live volume is a harmless no-op (it only takes
|
// So a later build seed never relabels the already-seeded flat bytes — which would void
|
||||||
// effect on a future fresh volume) instead of relabelling the already-seeded bytes —
|
// games pinned to the prior label and mis-serve new ones. The new build version is instead
|
||||||
// which would void games pinned to the prior label and mis-serve new ones. New games
|
// delivered as a NEW subdirectory (DeliverVersion) and made active (Service.InitActiveVersion),
|
||||||
// still pin the active version (DB-persisted, set by the admin console), which is the
|
// so a redeploy still moves new games to it while old games keep the flat label. New games
|
||||||
// real way a running contour moves to a new release.
|
// pin the active version (DB-persisted); a console upload can also set it out-of-band.
|
||||||
//
|
//
|
||||||
// A directory that cannot be written makes the first record fail; that also breaks
|
// A directory that cannot be written makes the first record fail; that also breaks
|
||||||
// the admin console (which writes version subdirectories here), so the error is
|
// the admin console (which writes version subdirectories here), so the error is
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ type Config struct {
|
|||||||
// DictVersion labels the dictionary version new games pin. Sourced from
|
// DictVersion labels the dictionary version new games pin. Sourced from
|
||||||
// BACKEND_DICT_VERSION.
|
// BACKEND_DICT_VERSION.
|
||||||
DictVersion string
|
DictVersion string
|
||||||
|
// DictSeedDir holds the image's read-only copy of the build's DictVersion DAWGs,
|
||||||
|
// on a path the persistent DictDir volume does NOT shadow. On boot the backend
|
||||||
|
// delivers this version into DictDir/<DictVersion>/ if absent (add-only), so a
|
||||||
|
// redeploy that bumped DICT_VERSION makes the new dictionary resident and activatable
|
||||||
|
// without disturbing the versions in-flight games already pin. Sourced from
|
||||||
|
// BACKEND_DICT_SEED_DIR; empty disables delivery (the pre-existing seed-only behaviour).
|
||||||
|
DictSeedDir string
|
||||||
// TimeoutSweepInterval is how often the sweeper scans for overdue turns.
|
// TimeoutSweepInterval is how often the sweeper scans for overdue turns.
|
||||||
// Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL.
|
// Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL.
|
||||||
TimeoutSweepInterval time.Duration
|
TimeoutSweepInterval time.Duration
|
||||||
|
|||||||
@@ -232,21 +232,31 @@ func (svc *Service) ActiveVersion() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InitActiveVersion reconciles the persisted active dictionary version with the
|
// InitActiveVersion reconciles the persisted active dictionary version with the
|
||||||
// registry at startup. If a version was persisted and is resident, it becomes the
|
// registry at startup and makes the build's version (BACKEND_DICT_VERSION, delivered
|
||||||
// active version; otherwise the configured seed version is kept and persisted as
|
// resident by engine.DeliverVersion) authoritative: a redeploy that bumped the build
|
||||||
// the initial active version. Call once during wiring, before serving traffic.
|
// version thus activates it for new games without any admin step, while old games keep
|
||||||
|
// the version they pin. The one exception is a version installed out-of-band through
|
||||||
|
// the admin console that is NEWER than the build version and still resident — it is
|
||||||
|
// kept, so a restart on an older image never downgrades a hotfix. A build version that
|
||||||
|
// is not resident (delivery disabled or a partial state) falls back to a resident
|
||||||
|
// persisted version. The chosen version is persisted so the admin console reflects it.
|
||||||
|
// Call once during wiring, before serving traffic.
|
||||||
func (svc *Service) InitActiveVersion(ctx context.Context) error {
|
func (svc *Service) InitActiveVersion(ctx context.Context) error {
|
||||||
persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
|
persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ok && svc.versionResident(persisted) {
|
target := svc.activeVersion() // the build seed version (config.DictVersion)
|
||||||
svc.verMu.Lock()
|
if ok && svc.versionResident(persisted) && compareDictVersions(persisted, target) > 0 {
|
||||||
svc.version = persisted
|
target = persisted // a newer out-of-band install wins over the build version
|
||||||
svc.verMu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return svc.store.SetActiveDictVersion(ctx, svc.activeVersion())
|
if !svc.versionResident(target) && ok && svc.versionResident(persisted) {
|
||||||
|
target = persisted // the build version is unloadable — keep a resident one
|
||||||
|
}
|
||||||
|
svc.verMu.Lock()
|
||||||
|
svc.version = target
|
||||||
|
svc.verMu.Unlock()
|
||||||
|
return svc.store.SetActiveDictVersion(ctx, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActiveVersion records version as the active dictionary version, persisting it
|
// SetActiveVersion records version as the active dictionary version, persisting it
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compareDictVersions orders two dictionary version labels of the shape "vMAJOR.MINOR.PATCH"
|
||||||
|
// numerically, returning -1, 0 or +1 as a is less than, equal to or greater than b. Labels
|
||||||
|
// that do not parse fall back to a byte comparison, so the ordering is always total (a mixed
|
||||||
|
// or malformed pair never crashes the boot-time active-version reconcile).
|
||||||
|
func compareDictVersions(a, b string) int {
|
||||||
|
pa, oka := parseDictVersion(a)
|
||||||
|
pb, okb := parseDictVersion(b)
|
||||||
|
if !oka || !okb {
|
||||||
|
return strings.Compare(a, b)
|
||||||
|
}
|
||||||
|
for i := range pa {
|
||||||
|
if pa[i] != pb[i] {
|
||||||
|
if pa[i] < pb[i] {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDictVersion parses a "vMAJOR.MINOR.PATCH" label into its three numeric parts, reporting
|
||||||
|
// whether it matched that shape.
|
||||||
|
func parseDictVersion(v string) ([3]int, bool) {
|
||||||
|
parts := strings.Split(strings.TrimPrefix(v, "v"), ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return [3]int{}, false
|
||||||
|
}
|
||||||
|
var out [3]int
|
||||||
|
for i, p := range parts {
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil {
|
||||||
|
return [3]int{}, false
|
||||||
|
}
|
||||||
|
out[i] = n
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCompareDictVersions(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"v1.3.0", "v1.3.1", -1},
|
||||||
|
{"v1.3.1", "v1.3.0", 1},
|
||||||
|
{"v1.3.1", "v1.3.1", 0},
|
||||||
|
{"v1.3.9", "v1.3.10", -1}, // numeric, not lexical
|
||||||
|
{"v1.10.0", "v1.9.0", 1},
|
||||||
|
{"v2.0.0", "v1.9.9", 1},
|
||||||
|
{"1.3.1", "v1.3.1", 0}, // the leading v is optional
|
||||||
|
// Non-semver falls back to a byte comparison, keeping the ordering total.
|
||||||
|
{"weird", "weird", 0},
|
||||||
|
{"a", "b", -1},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := compareDictVersions(c.a, c.b); sign(got) != c.want {
|
||||||
|
t.Errorf("compareDictVersions(%q, %q) = %d, want sign %d", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(n int) int {
|
||||||
|
switch {
|
||||||
|
case n < 0:
|
||||||
|
return -1
|
||||||
|
case n > 0:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,14 +103,31 @@ func TestDictionaryUpdateFlow(t *testing.T) {
|
|||||||
t.Errorf("installed dawg missing on disk: %v", err)
|
t.Errorf("installed dawg missing on disk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The choice survives a restart: a fresh service over the same DB and registry
|
// The choice survives a restart on the SAME build: an out-of-band admin install newer
|
||||||
// re-adopts the persisted active version.
|
// than the build version is not downgraded (a restart on an older image keeps the hotfix).
|
||||||
restarted := newGameServiceOn(dir, "v1.0.0", reg)
|
restarted := newGameServiceOn(dir, "v1.0.0", reg)
|
||||||
if err := restarted.InitActiveVersion(ctx); err != nil {
|
if err := restarted.InitActiveVersion(ctx); err != nil {
|
||||||
t.Fatalf("restart init: %v", err)
|
t.Fatalf("restart init: %v", err)
|
||||||
}
|
}
|
||||||
if got := restarted.ActiveVersion(); got != "v9.9.9" {
|
if got := restarted.ActiveVersion(); got != "v9.9.9" {
|
||||||
t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got)
|
t.Errorf("restarted active version = %q, want v9.9.9 (a newer out-of-band install is kept)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A redeploy that delivered a build version NEWER than the out-of-band one activates it
|
||||||
|
// for new games without any admin step (the deploy-delivers path — docs/ARCHITECTURE.md §5).
|
||||||
|
if _, err := reg.LoadAvailable(dir, "v10.0.0"); err != nil {
|
||||||
|
t.Fatalf("make v10.0.0 resident: %v", err)
|
||||||
|
}
|
||||||
|
deployed := newGameServiceOn(dir, "v10.0.0", reg)
|
||||||
|
if err := deployed.InitActiveVersion(ctx); err != nil {
|
||||||
|
t.Fatalf("deploy init: %v", err)
|
||||||
|
}
|
||||||
|
if got := deployed.ActiveVersion(); got != "v10.0.0" {
|
||||||
|
t.Errorf("deployed active version = %q, want v10.0.0 (a newer build version wins)", got)
|
||||||
|
}
|
||||||
|
// Restore the out-of-band active version so the assertions below still read v9.9.9.
|
||||||
|
if err := restarted.SetActiveVersion(ctx, "v9.9.9"); err != nil {
|
||||||
|
t.Fatalf("restore active: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New games pin the new version; the in-progress game keeps its own, still resident.
|
// New games pin the new version; the in-progress game keeps its own, still resident.
|
||||||
|
|||||||
+9
-5
@@ -98,7 +98,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
||||||
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
||||||
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
|
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image (build-arg). **Deploy-authoritative**: a redeploy that bumped it **delivers** the version onto the volume (add-only) and **activates** it for new games — old games keep the version they pin, and a newer out-of-band console upload is never downgraded (ARCHITECTURE.md §5). The `.seed_version` marker still guards the flat seed's label (a bump is delivered as a new subdirectory, never a relabel). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
|
||||||
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
||||||
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
||||||
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
||||||
@@ -176,10 +176,14 @@ For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`);
|
|||||||
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
||||||
default — a missing value fails loudly instead of baking a stale tag.
|
default — a missing value fails loudly instead of baking a stale tag.
|
||||||
|
|
||||||
Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the
|
Bumping `DICT_VERSION` and **redeploying** takes a live contour/prod to the new dictionary:
|
||||||
seed-drift guard). A running contour/prod moves to a new release **through the admin console**
|
on boot the backend **delivers** the build's version onto the volume (add-only, into
|
||||||
`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games
|
`BACKEND_DICT_DIR/<version>/` from the unshadowed `BACKEND_DICT_SEED_DIR`) and **activates** it,
|
||||||
keep their pinned version, new games use the new one (ARCHITECTURE.md §5).
|
so new games pin it while in-flight games keep the version they started on. The `.seed_version`
|
||||||
|
marker still guards the flat seed's label (the delivery is a new subdirectory, never a relabel).
|
||||||
|
The admin console `/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm)
|
||||||
|
remains for an **out-of-band** update without a redeploy; a console version newer than the build
|
||||||
|
survives a later restart on an older image (ARCHITECTURE.md §5).
|
||||||
|
|
||||||
## Production rollout
|
## Production rollout
|
||||||
|
|
||||||
|
|||||||
+21
-16
@@ -446,22 +446,27 @@ Key points:
|
|||||||
version while new games use the new one. A restart re-loads every resident
|
version while new games use the new one. A restart re-loads every resident
|
||||||
version via `engine.OpenWithVersions` (the flat seed plus each subdirectory,
|
version via `engine.OpenWithVersions` (the flat seed plus each subdirectory,
|
||||||
skipping the `.staging/` upload area) and restores the active pointer from
|
skipping the `.staging/` upload area) and restores the active pointer from
|
||||||
`dictionary_state`. The volume preserves uploaded versions across redeploys;
|
`dictionary_state`. The volume preserves uploaded versions across redeploys, so
|
||||||
once seeded it is not re-seeded, so after bootstrap dictionary changes go through
|
in-flight games keep replaying on the version they pin. The build's
|
||||||
the console rather than a rebuild. Because the flat DAWGs carry no embedded
|
`BACKEND_DICT_VERSION` is **deploy-authoritative**: on boot `engine.DeliverVersion`
|
||||||
version, `OpenWithVersions` records the version the flat directory was first seeded
|
copies the image's DAWGs — kept unshadowed at `BACKEND_DICT_SEED_DIR`, since the
|
||||||
at in a `.seed_version` marker on the volume and treats that marker as
|
volume mount hides the image's `BACKEND_DICT_DIR` copy — into
|
||||||
**authoritative** (the **seed-drift guard**): on an already-seeded volume a later
|
`BACKEND_DICT_DIR/<version>/` when that version is not already resident (**add-only**:
|
||||||
`BACKEND_DICT_VERSION` is ignored, so a bumped build seed cannot relabel the
|
the flat seed and prior uploads are never touched), and `InitActiveVersion` makes it
|
||||||
already-seeded bytes — which would otherwise silently serve the wrong dictionary
|
the active version. So a redeploy that bumped `DICT_VERSION` moves new games to the
|
||||||
and void games pinned to the prior label. A running contour therefore moves to a
|
new dictionary automatically, while old games stay on theirs — no console step. The
|
||||||
new release **through the console** (the prior version stays resident, so its games
|
one exception: a version installed out-of-band through the console that is **newer**
|
||||||
keep replaying), and `DICT_VERSION` is the seed for a **fresh** volume only:
|
than the build version and still resident is kept, so a restart on an older image never
|
||||||
bumping it on a live contour is a harmless no-op that takes effect on the next
|
downgrades a hotfix (versions compare numerically). Because the flat DAWGs carry no
|
||||||
fresh volume. Set it per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`.
|
embedded version, `OpenWithVersions` records the version the flat directory was first
|
||||||
(The dictionaries ship as a versioned **release artifact** from the
|
seeded at in a `.seed_version` marker and treats it as authoritative for **that flat
|
||||||
`scrabble-dictionary` repo; the build's `DICT_VERSION` selects
|
directory** (the **seed-drift guard**): a later `BACKEND_DICT_VERSION` never relabels
|
||||||
only the seed.)
|
the already-seeded flat bytes — it is delivered as a **new subdirectory** instead — so
|
||||||
|
the guard still prevents mis-serving a game pinned to the flat label. The console
|
||||||
|
remains the way to update a dictionary **without** a redeploy (an out-of-band upload).
|
||||||
|
Set `DICT_VERSION` per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`. (The
|
||||||
|
dictionaries ship as a versioned **release artifact** from the `scrabble-dictionary`
|
||||||
|
repo.)
|
||||||
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
|
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
|
||||||
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
|
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
|
||||||
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
|
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
|
||||||
|
|||||||
Reference in New Issue
Block a user