From 95f5703372027b1cdb2565aa55c9185e11ee65f6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 20 Jun 2026 20:06:57 +0200 Subject: [PATCH] fix(engine): make .seed_version marker authoritative (no boot refusal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PRERELEASE.md | 2 +- backend/README.md | 9 ++-- backend/internal/engine/registry.go | 26 ++++++------ backend/internal/engine/reload_test.go | 58 ++++++++++++++++++++++---- backend/internal/engine/seedmarker.go | 57 ++++++++++++------------- deploy/.env.example | 6 +-- deploy/README.md | 2 +- deploy/docker-compose.yml | 6 +-- docs/ARCHITECTURE.md | 22 +++++----- 9 files changed, 119 insertions(+), 69 deletions(-) diff --git a/PRERELEASE.md b/PRERELEASE.md index 9dd46c8..412064d 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -38,7 +38,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** | | FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** | | SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** | -| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in a `.seed_version` marker and refuses to boot when a bumped build seed would relabel a live volume (silently serving the wrong dictionary + voiding games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** | +| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/backend/README.md b/backend/README.md index 8643d00..fb0a3d5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -190,7 +190,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol | `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). | | `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. | | `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. | -| `BACKEND_DICT_VERSION` | `v1` | Seed dictionary version new games pin (the flat dir's label). Recorded in a `.seed_version` marker on first boot; the backend refuses to start if a later value drifts from it — the seed-drift guard (ARCHITECTURE.md §5). | +| `BACKEND_DICT_VERSION` | `v1` | Version label for the flat dictionary dir. Recorded in a `.seed_version` marker on first boot and authoritative after: on a seeded volume a changed value is ignored (it seeds only a fresh volume) — the seed-drift guard (ARCHITECTURE.md §5). | | `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. | | `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. | | `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. | @@ -255,9 +255,10 @@ repo (one semver per set); the engine loads them by `(variant, dict_version)` fr labelled `BACKEND_DICT_VERSION`; uploaded versions live in `/` subdirectories the admin console writes and a restart re-loads. Because the DAWGs carry no embedded version, the first boot records the seed in a `.seed_version` -marker and a later boot **refuses to start** if `BACKEND_DICT_VERSION` no longer -matches it (the seed-drift guard), so a live contour's dictionary is changed through -the console, never by bumping the build seed (ARCHITECTURE.md §5). +marker that is authoritative after: on a seeded volume a changed `BACKEND_DICT_VERSION` +is ignored (it seeds only a fresh volume) — the seed-drift guard — so a live contour's +dictionary is changed through the console, never by bumping the build seed +(ARCHITECTURE.md §5). ## Tests diff --git a/backend/internal/engine/registry.go b/backend/internal/engine/registry.go index f153471..d9ff910 100644 --- a/backend/internal/engine/registry.go +++ b/backend/internal/engine/registry.go @@ -70,20 +70,22 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) { // 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// -// at runtime is resident again after a restart. A subdirectory named like the -// boot version is skipped (the flat dir already is the boot version). It records -// and enforces a seed-version marker on the flat dir (see checkSeedMarker), failing -// when the build seed was bumped on an already-seeded volume. A partially loaded +// 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) { - r, err := Open(dir, bootVersion) + // 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 } - // Guard the seed-drift footgun before scanning for additional versions: refuse - // to boot when the flat dir was seeded under a different version than bootVersion. - if err := checkSeedMarker(dir, bootVersion); err != nil { - _ = r.Close() + r, err := Open(dir, seed) + if err != nil { return nil, err } entries, err := os.ReadDir(dir) @@ -92,9 +94,9 @@ func OpenWithVersions(dir, bootVersion string) (*Registry, error) { return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err) } for _, e := range entries { - // 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(), ".") { + // 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 { diff --git a/backend/internal/engine/reload_test.go b/backend/internal/engine/reload_test.go index 8d2426f..d7a60f2 100644 --- a/backend/internal/engine/reload_test.go +++ b/backend/internal/engine/reload_test.go @@ -146,23 +146,67 @@ func TestOpenWithVersionsRecordsSeedMarker(t *testing.T) { _ = reg2.Close() } -// TestOpenWithVersionsRejectsSeedDrift verifies the guard refuses to boot a directory -// seeded as one version when BACKEND_DICT_VERSION (bootVersion) names another — the -// seed-drift footgun a bumped build seed on a live volume would cause. -func TestOpenWithVersionsRejectsSeedDrift(t *testing.T) { +// TestOpenWithVersionsMarkerWinsOverBoot verifies the recorded .seed_version marker +// is authoritative: once a directory is seeded, a different bootVersion +// (BACKEND_DICT_VERSION) is ignored — the flat dir keeps its recorded label — so a +// bumped build seed on a live volume cannot relabel the already-seeded bytes. +func TestOpenWithVersionsMarkerWinsOverBoot(t *testing.T) { dir := t.TempDir() for _, v := range Variants() { copyDawg(t, testDictDir(), dir, v) } - reg, err := OpenWithVersions(dir, "v1") + reg, err := OpenWithVersions(dir, "v1") // seeds the marker = v1 if err != nil { t.Fatalf("seed open: %v", err) } _ = reg.Close() - if _, err := OpenWithVersions(dir, "v2"); err == nil { - t.Fatal("open after seed bump: want error, got nil") + // Reboot with a bumped boot version: the marker (v1) wins, no error, v2 ignored. + reg2, err := OpenWithVersions(dir, "v2") + if err != nil { + t.Fatalf("reboot with bumped boot version: %v", err) + } + defer func() { _ = reg2.Close() }() + if got := reg2.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" { + t.Errorf("versions = %v, want [v1] (marker wins, v2 ignored)", got) + } + if _, err := reg2.Solver(VariantEnglish, "v2"); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("v2 must not be resident: got %v", err) + } + data, _ := os.ReadFile(filepath.Join(dir, seedMarkerFile)) + if got := strings.TrimSpace(string(data)); got != "v1" { + t.Errorf("marker = %q, want v1 (unchanged)", got) + } +} + +// TestOpenWithVersionsBumpedBootKeepsSubdir mirrors the live-contour case: a volume +// seeded as v1 with a v2 subdirectory (uploaded via the console), booted with a bumped +// build seed bootVersion=v2. The marker (v1) wins for the flat dir, and the v2 +// subdirectory is still loaded — not skipped as "the boot version" — so both versions +// stay resident. (Skipping it would silently leave only the flat v1 bytes under v2.) +func TestOpenWithVersionsBumpedBootKeepsSubdir(t *testing.T) { + dir := t.TempDir() + for _, v := range Variants() { + copyDawg(t, testDictDir(), dir, v) + } + reg0, err := OpenWithVersions(dir, "v1") // seed marker = v1 + if err != nil { + t.Fatalf("seed: %v", err) + } + _ = reg0.Close() + copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // console upload + + reg, err := OpenWithVersions(dir, "v2") // bumped build seed + if err != nil { + t.Fatalf("boot v2: %v", err) + } + defer func() { _ = reg.Close() }() + if _, err := reg.Solver(VariantEnglish, "v1"); err != nil { + t.Errorf("flat v1 must stay resident: %v", err) + } + if _, err := reg.Solver(VariantEnglish, "v2"); err != nil { + t.Errorf("v2 subdir must be resident (not skipped): %v", err) } } diff --git a/backend/internal/engine/seedmarker.go b/backend/internal/engine/seedmarker.go index 5425e31..50514c0 100644 --- a/backend/internal/engine/seedmarker.go +++ b/backend/internal/engine/seedmarker.go @@ -13,40 +13,41 @@ import ( // version scan skips it (like the .staging upload area). const seedMarkerFile = ".seed_version" -// checkSeedMarker reconciles the flat dictionary directory's recorded seed version -// with bootVersion, guarding the seed-drift footgun. +// resolveSeedVersion returns the version label the flat dictionary directory is +// addressed by, recording it on first use. // // The contour's dictionary lives on a named volume seeded from the image once and -// never re-seeded (deploy/docker-compose.yml). The flat directory's DAWG files carry -// no embedded version, so their version is only the label BACKEND_DICT_VERSION gives -// them. Bumping the build seed on a live volume would therefore relabel the already -// seeded bytes: games that pinned the old label become unreplayable (voided) and new -// games would silently use the wrong dictionary. checkSeedMarker records the seed -// version on a fresh directory and, on every later boot, returns an error when -// bootVersion no longer matches the recorded seed — so an operator changes a live -// dictionary by uploading the new release through the admin console, or wipes the -// volume to re-seed, never by bumping the build seed (docs/ARCHITECTURE.md §5). +// never re-seeded (deploy/docker-compose.yml). The flat DAWGs carry no embedded +// version, so the version a volume was first seeded as is recorded in a +// .seed_version marker and is **authoritative** from then on: // -// A directory that cannot be written makes the first record fail; that already breaks +// - fresh directory (no marker): record bootVersion (the build's +// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume; +// - 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 +// effect on a future fresh volume) instead of relabelling the already-seeded bytes — +// which would void games pinned to the prior label and mis-serve new ones. New games +// still pin the active version (DB-persisted, set by the admin console), which is the +// real way a running contour moves to a new release. +// +// 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 // returned rather than swallowed, matching the package's fail-loud dictionary setup. -func checkSeedMarker(dir, bootVersion string) error { +func resolveSeedVersion(dir, bootVersion string) (string, error) { path := filepath.Join(dir, seedMarkerFile) data, err := os.ReadFile(path) - switch { - case err == nil: - if recorded := strings.TrimSpace(string(data)); recorded != bootVersion { - return fmt.Errorf("engine: dictionary volume was seeded as %q but BACKEND_DICT_VERSION is %q; "+ - "change a live dictionary by uploading the release through the admin console, or wipe the "+ - "volume to re-seed — do not bump the build seed (docs/ARCHITECTURE.md §5)", recorded, bootVersion) - } - return nil - case errors.Is(err, os.ErrNotExist): - if err := os.WriteFile(path, []byte(bootVersion+"\n"), 0o644); err != nil { - return fmt.Errorf("engine: record dictionary seed marker %s: %w", path, err) - } - return nil - default: - return fmt.Errorf("engine: read dictionary seed marker %s: %w", path, err) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("engine: read dictionary seed marker %s: %w", path, err) } + if err == nil { + if recorded := strings.TrimSpace(string(data)); recorded != "" { + return recorded, nil + } + // An empty/corrupt marker falls through and is rewritten from bootVersion. + } + if werr := os.WriteFile(path, []byte(bootVersion+"\n"), 0o644); werr != nil { + return "", fmt.Errorf("engine: record dictionary seed marker %s: %w", path, werr) + } + return bootVersion, nil } diff --git a/deploy/.env.example b/deploy/.env.example index ae4fdcc..beb1530 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -13,9 +13,9 @@ POSTGRES_PASSWORD=change-me # required # scrabble-dictionary release tag baked into the image as the SEED dictionary for a # FRESH volume (image build-arg; also labels the resident seed version). After first # boot the dawg-data volume preserves versions uploaded through the admin console and -# the active version lives in the DB. On a live volume the backend refuses to start if -# this no longer matches the recorded seed (the seed-drift guard) — change a running -# contour's dictionary through /_gm/dictionary, not by bumping this (ARCHITECTURE.md §5). +# the active version lives in the DB. On a live volume a changed value is ignored (the +# recorded .seed_version marker wins — the seed-drift guard); change a running +# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). DICT_VERSION=v1.2.1 # --- Logging ---------------------------------------------------------------- diff --git a/deploy/README.md b/deploy/README.md index b38b41d..0932aa4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -71,7 +71,7 @@ compose, but the connector **fails at boot** when it is empty. | --- | --- | --- | --- | | `POSTGRES_DB` | variable | `scrabble` | Database name. | | `POSTGRES_USER` | variable | `scrabble` | Database user. | -| `DICT_VERSION` | variable | `v1.2.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; the backend refuses to boot if it drifts from the volume's recorded `.seed_version` (the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. | +| `DICT_VERSION` | variable | `v1.2.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). Set per contour as `TEST_`/`PROD_DICT_VERSION`. | | `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / connector (`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. | | `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. | diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index db2195f..94a6ed0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -97,9 +97,9 @@ services: # inherits). The admin console writes new version subdirectories here, and the # volume preserves them — and the versions in-progress games pin — across # redeploys. Once seeded the volume is not re-seeded: DICT_VERSION is the seed for - # a FRESH volume only. On a live volume the backend refuses to start if DICT_VERSION - # no longer matches the recorded seed (the seed-drift guard), so a running contour's - # dictionary is changed through the admin console, never by bumping the seed + # a FRESH volume only. On a live volume a changed DICT_VERSION is ignored (the + # recorded .seed_version marker wins — the seed-drift guard), so a running contour's + # dictionary is changed through the admin console, not by bumping the seed # (docs/ARCHITECTURE.md §5). volumes: - dawg-data:/opt/dawg diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58e1251..4513702 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -274,16 +274,18 @@ Key points: `dictionary_state`. The volume preserves uploaded versions across redeploys; once seeded it is not re-seeded, so after bootstrap dictionary changes go through the console rather than a rebuild. Because the flat DAWGs carry no embedded - version, `OpenWithVersions` records the version the flat directory was first - opened at in a `.seed_version` marker on the volume and **refuses to boot** when a - later `BACKEND_DICT_VERSION` disagrees — the **seed-drift guard**: it stops a - bumped build seed on a live volume from relabelling the already-seeded bytes, - which would silently serve the wrong dictionary and void games pinned to the prior - label. A running contour therefore moves to a new release **through the console** - (the prior version stays resident, so its games keep replaying); `DICT_VERSION` is - the seed for a **fresh** volume only, set per contour from the deploy's - `TEST_`/`PROD_DICT_VERSION`. (The dictionaries ship as a versioned **release - artifact** from the `scrabble-dictionary` repo; the build's `DICT_VERSION` selects + version, `OpenWithVersions` records the version the flat directory was first seeded + at in a `.seed_version` marker on the volume and treats that marker as + **authoritative** (the **seed-drift guard**): on an already-seeded volume a later + `BACKEND_DICT_VERSION` is ignored, so a bumped build seed cannot relabel the + already-seeded bytes — which would otherwise silently serve the wrong dictionary + and void games pinned to the prior label. A running contour therefore moves to a + new release **through the console** (the prior version stays resident, so its games + keep replaying), and `DICT_VERSION` is the seed for a **fresh** volume only: + bumping it on a live contour is a harmless no-op that takes effect on the next + fresh volume. Set it per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`. + (The dictionaries ship as a versioned **release artifact** from the + `scrabble-dictionary` repo; the build's `DICT_VERSION` selects only the seed.) - Move generation/validation/scoring use `Solver.GenerateMoves` (ranked), `Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses