v0.276.0: a restore and a drive move keep the app's records (R-697, R-700)
gates / gates (push) Successful in 26s

A drive move persisted through the restore's fresh app.yaml write and dropped the pin: the syncer
then copied the catalog verbatim and the next start jumped the app past its ladder (R-700).
persistDriveFlip now changes HDD_PATH and nothing else. The restore's write carries the life
records (conversion copies, desired_state, update history) from the app.yaml it replaces, and a
second conversion no longer overwrites the first kept copy's record (R-697).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-27 17:59:04 +02:00
parent 54bb4da343
commit 820e8efde1
7 changed files with 331 additions and 42 deletions
+79 -30
View File
@@ -590,11 +590,19 @@ func diffSnapshots(before, after []string) string {
// ── keeping, then releasing, the old datadir's copy (B5) ────────────────────────────────────────
func (m *Manager) recordConversionCopy(name, dir string, cc *ConversionCopy) {
set := func(cfg *AppConfig) {
// R-697 (v0.276.0): a newer conversion never overwrites an older kept copy's record — the older one
// moves to the earlier list and is released by the same rule.
if cc != nil && cfg.ConversionCopy != nil && cfg.ConversionCopy.Copy != cc.Copy && !hasCopy(cfg.EarlierConversionCopies, cfg.ConversionCopy.Copy) {
cfg.EarlierConversionCopies = append(cfg.EarlierConversionCopies, *cfg.ConversionCopy)
}
cfg.ConversionCopy = cc
}
cfg := LoadAppConfig(dir)
if cfg == nil || (cc == nil && cfg.ConversionCopy == nil) {
return
}
cfg.ConversionCopy = cc
set(cfg)
meta := LoadMetadata(dir)
if err := SaveAppConfig(dir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
m.logger.Printf("[ERROR] [stacks] update %s: recording conversion_copy failed: %v", name, err)
@@ -602,11 +610,38 @@ func (m *Manager) recordConversionCopy(name, dir string, cc *ConversionCopy) {
}
m.mu.Lock()
if st, ok := m.stacks[name]; ok && st.AppConfig != nil {
st.AppConfig.ConversionCopy = cc
st.AppConfig.ConversionCopy = cfg.ConversionCopy
st.AppConfig.EarlierConversionCopies = cfg.EarlierConversionCopies
}
m.mu.Unlock()
}
func hasCopy(list []ConversionCopy, copyVol string) bool {
for _, c := range list {
if c.Copy == copyVol {
return true
}
}
return false
}
// forgetEarlierConversionCopy drops one released copy from the earlier list.
func (m *Manager) forgetEarlierConversionCopy(name, dir, copyVol string) {
m.mutateAppConfig(name, dir, "earlier_conversion_copies", func(cfg *AppConfig) bool {
var keep []ConversionCopy
for _, c := range cfg.EarlierConversionCopies {
if c.Copy != copyVol {
keep = append(keep, c)
}
}
if len(keep) == len(cfg.EarlierConversionCopies) {
return false
}
cfg.EarlierConversionCopies = keep
return true
})
}
// ReleaseConversionCopies removes each kept pre-conversion datadir copy whose app now has a backup
// PROVEN after the conversion (any tier — the backup side returns only proven copies). Returns the
// names released. Run periodically from main.go (TestConvert_ReleaseIsWiredAtStartup).
@@ -617,43 +652,57 @@ func (m *Manager) ReleaseConversionCopies(ctx context.Context) []string {
}
var released []string
for _, st := range m.GetStacks() {
if st.AppConfig == nil || st.AppConfig.ConversionCopy == nil {
if st.AppConfig == nil {
continue
}
cc := st.AppConfig.ConversionCopy
at, err := time.Parse(time.RFC3339, cc.At)
if err != nil {
m.logger.Printf("[WARN] [stacks] %s: conversion_copy has an unreadable time %q — the copy %s is kept", st.Name, cc.At, cc.Copy)
continue
}
rp, ok, _ := g.RestorePoints(ctx, st.Name, func(p UpdateRestorePoint) bool { return p.ProvenAt.After(at) })
if !ok {
continue
}
// v0.275.0 (A4): AND a dump the converted engine wrote. Without the stamps (older guards, or a unit
// whose data is unstamped) the copy is KEPT — logged, retried at the next pass.
src, hasStamps := g.(DumpStampSource)
var dump DataDumpStamp
if hasStamps {
dump, ok = convertedDumpAt(src.DumpStamps(st.Name), cc, at)
}
if !hasStamps || !ok {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] %s: the pre-conversion copy %s is KEPT — a copy proven at %s exists, but no database dump written after %s by PostgreSQL %d is recorded yet", st.Name, cc.Copy, rp.ProvenAt.UTC().Format(time.RFC3339), cc.At, cc.To)
// v0.276.0 (R-697): the current record and every earlier one a later conversion superseded.
for _, e := range st.AppConfig.EarlierConversionCopies {
e := e
if m.releaseOneConversionCopy(ctx, g, st, &e) {
m.forgetEarlierConversionCopy(st.Name, filepath.Dir(st.ComposePath), e.Copy)
released = append(released, st.Name)
}
continue
}
if err := m.copier().Remove(cc.Copy); err != nil {
m.logger.Printf("[WARN] [stacks] %s: could not remove the pre-conversion copy %s: %v — kept, tried again later", st.Name, cc.Copy, err)
continue
if cc := st.AppConfig.ConversionCopy; cc != nil && m.releaseOneConversionCopy(ctx, g, st, cc) {
m.recordConversionCopy(st.Name, filepath.Dir(st.ComposePath), nil)
released = append(released, st.Name)
}
m.recordConversionCopy(st.Name, filepath.Dir(st.ComposePath), nil)
m.logger.Printf("[INFO] [stacks] %s: REMOVED the pre-conversion datadir copy %s (PostgreSQL %d) — the converted app has a backup proven on %d: %s at %s, its dump %s written %s by %v", st.Name, cc.Copy, cc.From, cc.To, updateTierName(rp.Tier), rp.ProvenAt.UTC().Format(time.RFC3339), dump.File, dump.At.UTC().Format(time.RFC3339), dump.Images)
released = append(released, st.Name)
}
return released
}
// releaseOneConversionCopy removes one kept copy when its release conditions hold; true = removed.
func (m *Manager) releaseOneConversionCopy(ctx context.Context, g UpdateGuards, st Stack, cc *ConversionCopy) bool {
at, err := time.Parse(time.RFC3339, cc.At)
if err != nil {
m.logger.Printf("[WARN] [stacks] %s: conversion_copy has an unreadable time %q — the copy %s is kept", st.Name, cc.At, cc.Copy)
return false
}
rp, ok, _ := g.RestorePoints(ctx, st.Name, func(p UpdateRestorePoint) bool { return p.ProvenAt.After(at) })
if !ok {
return false
}
// v0.275.0 (A4): AND a dump the converted engine wrote. Without the stamps (older guards, or a unit
// whose data is unstamped) the copy is KEPT — logged, retried at the next pass.
src, hasStamps := g.(DumpStampSource)
var dump DataDumpStamp
if hasStamps {
dump, ok = convertedDumpAt(src.DumpStamps(st.Name), cc, at)
}
if !hasStamps || !ok {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] %s: the pre-conversion copy %s is KEPT — a copy proven at %s exists, but no database dump written after %s by PostgreSQL %d is recorded yet", st.Name, cc.Copy, rp.ProvenAt.UTC().Format(time.RFC3339), cc.At, cc.To)
}
return false
}
if err := m.copier().Remove(cc.Copy); err != nil {
m.logger.Printf("[WARN] [stacks] %s: could not remove the pre-conversion copy %s: %v — kept, tried again later", st.Name, cc.Copy, err)
return false
}
m.logger.Printf("[INFO] [stacks] %s: REMOVED the pre-conversion datadir copy %s (PostgreSQL %d) — the converted app has a backup proven on %d: %s at %s, its dump %s written %s by %v", st.Name, cc.Copy, cc.From, cc.To, updateTierName(rp.Tier), rp.ProvenAt.UTC().Format(time.RFC3339), dump.File, dump.At.UTC().Format(time.RFC3339), dump.Images)
return true
}
// ── production boundary ─────────────────────────────────────────────────────────────────────────
type dockerPGConverter struct{ m *Manager }