feat(engine,deploy): seed-drift guard + track current dictionary release
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

The dictionary release moved to v1.2.1 while DICT_VERSION stayed pinned at
v1.0.0 in CI and the image/compose seed defaults. Two problems:

1. CI validated against a stale dictionary.
2. The contour seed could be bumped on a live volume, which silently relabels
   the already-seeded bytes — voiding games pinned to the prior label and
   serving the wrong dictionary for new ones. The flat DAWGs carry no embedded
   version, so this drift was undetectable.

Changes:

- Seed-drift guard: OpenWithVersions records the flat dir's version in a
  .seed_version marker on first boot and refuses to start when a later
  BACKEND_DICT_VERSION disagrees. DICT_VERSION is now the seed for a *fresh*
  volume only; a live contour migrates through the admin console (old versions
  stay resident, in-progress games keep replaying).
- Track the current release: CI's DICT_VERSION centralised to one workflow-level
  env (v1.2.1); image/compose/.env seed defaults bumped to v1.2.1. The deploy
  job keeps reading the per-contour vars.TEST_DICT_VERSION.
- Docs: ARCHITECTURE §5 (decision record), backend/deploy READMEs, PRERELEASE
  tracker (DV row).

Verified locally against the v1.2.1 artifact: gofmt, build, vet, unit and
integration (-tags=integration) all green.
This commit is contained in:
Ilia Denisov
2026-06-20 19:26:32 +02:00
parent c739f12d3d
commit a5db10c46e
12 changed files with 164 additions and 24 deletions
+10 -2
View File
@@ -71,13 +71,21 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
// version V, the variants whose committed DAWG it carries. This is the
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
// 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). A partially
// loaded registry is closed before any error is returned.
// 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
// registry is closed before any error is returned.
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
r, err := Open(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()
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
_ = r.Close()
+54
View File
@@ -5,6 +5,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -112,6 +113,59 @@ func TestOpenWithVersionsSkipsDotDirs(t *testing.T) {
}
}
// TestOpenWithVersionsRecordsSeedMarker verifies the first boot records the seed
// version in the flat dir's marker, the marker is not mistaken for a version, and a
// reboot at the same seed version succeeds.
func TestOpenWithVersionsRecordsSeedMarker(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("first open: %v", err)
}
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("versions = %v, want only [v1] (marker not a version)", got)
}
_ = reg.Close()
data, err := os.ReadFile(filepath.Join(dir, seedMarkerFile))
if err != nil {
t.Fatalf("read seed marker: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "v1" {
t.Fatalf("seed marker = %q, want v1", got)
}
reg2, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("reboot at same seed: %v", err)
}
_ = 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) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "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")
}
}
// 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) {
+52
View File
@@ -0,0 +1,52 @@
package engine
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// seedMarkerFile names the file, in the flat dictionary directory, that records the
// version the directory was first seeded as. It is dot-prefixed so OpenWithVersions'
// 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.
//
// 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).
//
// A directory that cannot be written makes the first record fail; that already 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 {
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)
}
}