fix(engine): make .seed_version marker authoritative (no boot refusal)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user