v0.153.0 — R-47: the DB replay no longer races the app, on BOTH restore paths
Closes R-47. No new agent coupling — MinAgent stays 0.90.0. The replay needs a running DB container, so both restore paths started the WHOLE stack first, giving the application a window to rebuild the very schema objects the dump was about to create. Measured live on 2026-07-19 (H4, DIAG-immich-restore-round2): immich-server rebuilt clip_index two seconds before the dump's CREATE INDEX, the replay aborted "already exists" under ON_ERROR_STOP=1, and immich reported schema drift. The data survived only because pg_dump emits COPY before CREATE INDEX. Both paths now open a DB-ONLY window: only the stack's database service(s) come up, the dump is replayed with the app still down, and the full start runs only after the replay exits 0. Fail-closed: a dump with no identifiable DB service refuses BEFORE the first mutation. Every exit from the window still does a best-effort full start, so a failed restore never leaves a box with a database and no application. New: appbackup.DBServiceNames (yaml.v3 services-map parse — never a line scan; immich's top-level volume keys are the decoy) sharing dbTypeForImage with DiscoverDatabases; stacks.Manager.StartStackServices (refuses an empty list — argument-less `up -d` is a full start); RedeployFromEnv split into PersistUnitRedeployConfig + its unchanged tail. StackDataProvider's RecreateStackFromUnit becomes RecreateStackDefinitionFromUnit — the hidden `up -d` inside the old name is what carried the defect on the local path. 19 new tests (ordering plus state-at-replay-time, zero-mutation fail-closed effects, replay-failure bring-up, parser decoys, empty-list refusal); three companion red-proofs run and reverted. 23/23 packages green. Not yet live-validated: STOP-1 supervised reconstitute, golden 0.153.0.
This commit is contained in:
@@ -767,6 +767,44 @@ func (m *Manager) StartStack(name string) error {
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
// StartStackServices brings up ONLY the named compose services (`docker compose up -d <svc>...`),
|
||||
// leaving the rest of the stack down. It exists for R-47: a database dump must be replayed into a
|
||||
// running DB container while the application that owns the schema is still stopped, otherwise the
|
||||
// app's own schema management races the replay (proven live — H4,
|
||||
// DIAG-immich-restore-round2-2026-07-19). Every catalog template's dependency direction is app→db,
|
||||
// so naming the DB service starts the DB and nothing else.
|
||||
//
|
||||
// An EMPTY service list is refused rather than passed through: `up -d` with no arguments is a FULL
|
||||
// start, which is precisely the behaviour this function exists to avoid — a silent fall-through
|
||||
// would reintroduce the race at the one call site that most needs it not to.
|
||||
//
|
||||
// Deliberately no logPostStartStatus: the app containers are absent ON PURPOSE here, and it would
|
||||
// WARN about every one of them. The full StartStack that always follows logs the real post-start
|
||||
// state.
|
||||
func (m *Manager) StartStackServices(name string, services []string) error {
|
||||
if len(services) == 0 {
|
||||
return fmt.Errorf("starting services of stack %s: empty service list", name)
|
||||
}
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] [stacks] Starting stack %s services only: %v", name, services)
|
||||
start := time.Now()
|
||||
|
||||
dir := filepath.Dir(stack.ComposePath)
|
||||
env := m.stackEnv(dir)
|
||||
|
||||
if _, err := m.composeExecCustomEnv(dir, env, append([]string{"up", "-d"}, services...)...); err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] Stack %s service start failed after %.1fs: %v", name, time.Since(start).Seconds(), err)
|
||||
return fmt.Errorf("starting services %v of stack %s: %w", services, name, err)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] [stacks] Stack %s services %v started (took %.1fs)", name, services, time.Since(start).Seconds())
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
func (m *Manager) StopStack(name string) error {
|
||||
if m.cfg.IsProtectedStack(name) {
|
||||
return fmt.Errorf("stack %q is protected and cannot be stopped", name)
|
||||
|
||||
Reference in New Issue
Block a user