package rollout import ( "context" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // executeWorker runs one resolved attempt on a socketless host (spec §6.5, // §7.9) through the activator's same-entry replacement. The host's good // build, by its retained identity, goes with it as the fallback for a failed // adoption in overlap mode; the activator decides whether it applies. // // While a hold-ran marker of the app stands on the host, the replacement // carries no fallback (Astra R3 N5, spec §6.4): a hold build ran there // without passing and may have migrated the database, so a failed // replacement must not start the good build against it. // // An abandoned result (a staging refusal, a busy entry, a re-check that // refused) is deferred by done(), as a refused swap is: while the attempt is // still the host's newest it waits abandonDeferral, and one superseded by a // newer desire does not wait at all. func (s *scheduler) executeWorker(ctx context.Context, rt registry.Route, a activator.Attempt) activator.SwapResult { m := s.m var fallback *release.StagedIdentity acct := release.RouteAccount(rt) if _, marked := holdRanFor(m.d.Book, acct, rt.App, rt.Host); !marked { if g := rt.Good(); g.Version != "" && g.Digest != "" && g.Version != a.Build { fallback = &release.StagedIdentity{Version: g.Version, Digest: g.Digest, Demand: g.Demand, StagedPath: release.VersionPath(m.d.OptDir, acct, g.Version, rt.App)} } } m.emit(rt, EvSwapping, "info", textSwapping(a.Build, a.Cause)) return m.d.Act.ReplaceWorker(ctx, a, fallback, s.newest) } // admitOne is admit for one host, for the recycle hook: the host's pending // attempt, if it is a release-class attempt (an adoption) the queue would // start right now. It honours everything admit does for that host: its // waits, its cohort's canary and settle, a halt, and swap_concurrency. A // restart or config attempt is not taken: the recycle runs on the serving // build, and that attempt follows in its turn. The caller holds the // scheduler's lock. func (q *appQueue) admitOne(host string, now time.Time) (dispatch, bool) { h := q.hosts[host] if h == nil || h.running != nil || q.running >= q.concurrency { return dispatch{}, false } d, ok := q.gate(h, now) if !ok || d.Class != classAdopt { return dispatch{}, false } q.start(h, d) return d, true } // CoalesceRecycle is plan 4's Service.CoalesceRecycle (spec §7.9). The // activator calls it under its own lock (activator.Service.mu), so it calls // nothing in the activator and waits on nothing that could: it takes // Manager.mu (schedulerOf) and then the scheduler's lock, the documented // order, and the attempt it admits runs on its own goroutine. True means an // attempt makes the spawn and the recycle is skipped: one admitted now, or // one already running for the host (ReplaceWorker respawns that child // itself, and its observation reads a crash as the build's). Once Close has // begun it is also true, with nothing admitted. func (m *Manager) CoalesceRecycle(host string) bool { s := m.schedulerOf(host) if s == nil { return false } s.mu.Lock() if h := s.q.hosts[host]; h != nil && h.running != nil { s.mu.Unlock() return true } // Counted before it is admitted, as the scheduler loop counts its // admissions, so Close joins its done() (spec §8.6, Task 11 fix round // 1). joinMu is a leaf, so it may be taken here under activator.Service.mu // and s.mu. Once Close has begun nothing is admitted and the recycle is // skipped: the agent is stopping, and the child goes with it. if !m.enter(true) { s.mu.Unlock() return true } d, ok := s.q.admitOne(host, m.d.Now()) if !ok { m.leave() s.mu.Unlock() return false } s.mu.Unlock() go func() { defer m.leave() s.execute(m.ctx, d) }() return true }