| 1 | // generations.go is the generation identity of zero-downtime deploys |
| 2 | // (docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md §7.1, |
| 3 | // as amended by 2026-10-13-zero-downtime-deploys-rev19-amendment.md): the |
| 4 | // process-wide counter, the generation socket paths, the Gen handle, and the |
| 5 | // dormant switch that keeps all of it off by default. |
| 6 | package activator |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "errors" |
| 11 | "log" |
| 12 | "math" |
| 13 | "os" |
| 14 | "path/filepath" |
| 15 | "strconv" |
| 16 | "sync/atomic" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/cgroup" |
| 19 | "github.com/carlosframework/platform/internal/provision" |
| 20 | "github.com/carlosframework/platform/internal/registry" |
| 21 | ) |
| 22 | |
| 23 | // generationsOn is the dormant switch (spec §15). Off, every spawn binds the |
| 24 | // route's canonical socket exactly as before, and nothing outside this |
| 25 | // package changes behaviour. Plan 5 turns it on from CARLOS_SWAP=overlap by |
| 26 | // calling SetGenerations(true) in internal/host's runAgent BEFORE |
| 27 | // activator.New: a Service reads the switch once, at construction, so one |
| 28 | // process never mixes the two socket schemes. |
| 29 | var generationsOn atomic.Bool |
| 30 | |
| 31 | // SetGenerations sets the dormant switch for Services built after the call. |
| 32 | func SetGenerations(on bool) { generationsOn.Store(on) } |
| 33 | |
| 34 | // GenerationsEnabled reports the switch as it stands now. |
| 35 | func GenerationsEnabled() bool { return generationsOn.Load() } |
| 36 | |
| 37 | // genCounter is the ONE generation counter for the whole process (spec |
| 38 | // §7.1). It is package-level, never per Service and never per entry, and it |
| 39 | // is never reset: an entry deleted at a stop and recreated at the next wake |
| 40 | // cannot reuse a number an earlier entry had, so a cached (route, n) pair in |
| 41 | // the edge (same process) can never match a later generation. |
| 42 | var genCounter atomic.Uint64 |
| 43 | |
| 44 | // nextGen takes the next generation number. The first is 1; 0 means "no |
| 45 | // generation" everywhere a Gen is optional. |
| 46 | func nextGen() uint64 { return genCounter.Add(1) } |
| 47 | |
| 48 | // Gen is one spawn of one instance (spec §3 "Generation"). It is the |
| 49 | // generation HANDLE (spec §7.1): returned by the spawn and held by whoever |
| 50 | // started it. Health checks, settle observation and retirement use the |
| 51 | // handle, never AppSocket. Plan 1 fills N, Socket, Control and Build; |
| 52 | // StagedPath, Digest and Demand stay empty until plans 3 and 4 exec by |
| 53 | // staged path. |
| 54 | type Gen struct { |
| 55 | N uint64 // process-wide, never reused (spec §7.1) |
| 56 | Socket string // generation app socket (spec §7.1, rev 19: the flag-off file name in a directory of its own) |
| 57 | Control string // Socket + ".control" |
| 58 | Build string // version name the generation runs |
| 59 | StagedPath string // immutable staged executable it exec'd (spec §7.5 step 3) |
| 60 | Digest string // sha256 of StagedPath at exec, hex |
| 61 | Demand string // the release's own `swap` demand verified for this artifact: "hold" or "" (spec §4.2, §8.5) |
| 62 | // Tier is the generation's own artifact's tier (zero-downtime spec |
| 63 | // §7.3), captured when an attempt is admitted (plan 3). Zero for a |
| 64 | // live-link wake until a swap captures it from Build. |
| 65 | Tier cgroup.Limit |
| 66 | // Worker marks a worker's generation (zero-downtime spec §7.9): it has |
| 67 | // no generation child, and its termination proof is its host cgroup, |
| 68 | // with or without a process handle (genTerminated). Plan 3 Task 11 |
| 69 | // sets it. |
| 70 | Worker bool |
| 71 | } |
| 72 | |
| 73 | // GenSocketDir is the root of the flat generation directories: |
| 74 | // /run/carlos/g. Generation n's directory is /run/carlos/g/<n>. |
| 75 | const GenSocketDir = provision.SocketDir + "/g" |
| 76 | |
| 77 | // maxUnixPath is the longest path a unix socket address holds. |
| 78 | const maxUnixPath = provision.MaxUnixPath |
| 79 | |
| 80 | // GenSocketPath is flat generation n's socket (zero-downtime spec §7.1 as |
| 81 | // amended by rev 19, rule 1): /run/carlos/g/<n>/<host>.sock. The file name |
| 82 | // is the flag-off one, because an app may work out its host from the file |
| 83 | // name of its --socket argument, and a name the flag-off launch never gives |
| 84 | // it can make it refuse to start (amadan #272, Gate F). Each generation gets |
| 85 | // its own directory instead. The hostname is in the path, so its length is |
| 86 | // bounded by the host's: flatGenFits decides whether a host has generation |
| 87 | // paths at all (rule 4). |
| 88 | func GenSocketPath(host string, n uint64) string { |
| 89 | return genSocketPathIn(GenSocketDir, host, n) |
| 90 | } |
| 91 | |
| 92 | // genSocketPathIn is GenSocketPath under another directory (a test's |
| 93 | // RunDirOverride). |
| 94 | func genSocketPathIn(dir, host string, n uint64) string { |
| 95 | return filepath.Join(dir, strconv.FormatUint(n, 10), host+".sock") |
| 96 | } |
| 97 | |
| 98 | // flatGenFits reports whether host's flat generation sockets fit a unix |
| 99 | // socket address (provision.MaxUnixPath, 107 bytes) for every generation |
| 100 | // number (rev 19, rule 4): the control socket, the longer of the pair, at |
| 101 | // the longest number a generation can have. That admits hosts of up to 59 |
| 102 | // bytes; the flag-off control socket already caps a flat host at 82. It |
| 103 | // depends on the host alone, so a row never changes treatment as |
| 104 | // the process-wide counter grows, and an attempt admitted as an overlap |
| 105 | // never meets a generation that cannot bind. A host that does not fit is |
| 106 | // handled as a foreign row is (genLayout): it keeps its canonical socket. |
| 107 | // |
| 108 | // It is measured against GenSocketDir even under a test's RunDirOverride, |
| 109 | // so a test sees the decision a box makes. |
| 110 | func flatGenFits(host string) bool { |
| 111 | return len(GenSocketPath(host, math.MaxUint64)+".control") <= maxUnixPath |
| 112 | } |
| 113 | |
| 114 | // genLayout is layout l of host's row as generations see it (rev 19, rule |
| 115 | // 4): a flat row whose generation sockets would not fit a unix socket path |
| 116 | // (flatGenFits) reads foreign, so it keeps its canonical socket and an |
| 117 | // overlap on it runs as a hold, exactly as for a foreign row. Every other |
| 118 | // layout is l. Classify and pathsFor are unchanged: the flag-off spawn, the |
| 119 | // layout pass and every path but the generation socket's still read the row |
| 120 | // as flat. |
| 121 | func genLayout(l Layout, host string) Layout { |
| 122 | if l == LayoutFlat && !flatGenFits(host) { |
| 123 | return LayoutForeign |
| 124 | } |
| 125 | return l |
| 126 | } |
| 127 | |
| 128 | // NestedGenSocket is the nested layout's generation socket (rev 19, rule |
| 129 | // 1): /run/carlos/instances/<InstanceID>/g<n>/app.sock, the flag-off file |
| 130 | // name in a directory of the generation's own. The identifier is bounded at |
| 131 | // 40 bytes, so this is at most 93 bytes (101 with ".control") whatever n is. |
| 132 | func NestedGenSocket(host string, n uint64) string { |
| 133 | return nestedGenSocketIn(provision.InstanceRunDir(host), n) |
| 134 | } |
| 135 | |
| 136 | // nestedGenSocketIn is NestedGenSocket under an already-resolved run dir. |
| 137 | func nestedGenSocketIn(runDir string, n uint64) string { |
| 138 | return filepath.Join(runDir, "g"+strconv.FormatUint(n, 10), "app.sock") |
| 139 | } |
| 140 | |
| 141 | // removeGenDir removes generation g's own socket directory once g's |
| 142 | // termination is proven, if it is empty (rev 19, rule 3). It touches only a |
| 143 | // directory named for g.N (flat: <n>, nested: g<n>) that holds g.Socket, so |
| 144 | // a sibling's directory, the run dir and GenSocketDir itself are never |
| 145 | // touched. A generation on its row's canonical socket (generations off, a |
| 146 | // foreign row, a unit tenant) has no directory of its own and is skipped. A |
| 147 | // directory that still holds a file (an app that left its socket behind) |
| 148 | // stays: it is harmless, and the stale-file rule clears that file if the |
| 149 | // number is ever used again. |
| 150 | func removeGenDir(rt registry.Route, g Gen) { |
| 151 | if g.N == 0 || !linksCanonical(rt, g) { |
| 152 | return |
| 153 | } |
| 154 | dir := filepath.Dir(g.Socket) |
| 155 | n := strconv.FormatUint(g.N, 10) |
| 156 | if base := filepath.Base(dir); base != n && base != "g"+n { |
| 157 | return |
| 158 | } |
| 159 | entries, err := os.ReadDir(dir) |
| 160 | if err != nil || len(entries) != 0 { |
| 161 | return |
| 162 | } |
| 163 | if err := os.Remove(dir); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 164 | log.Printf("activator: %s: remove generation %d's socket directory: %v", rt.Host, g.N, err) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // canonicalGen is the generation handle of a spawn that bound the route's |
| 169 | // canonical socket: every spawn with the switch off, a backend with no |
| 170 | // generation support (unit-backed tenants, test doubles), and a foreign |
| 171 | // layout row. |
| 172 | func canonicalGen(rt registry.Route, n uint64, build string) Gen { |
| 173 | g := Gen{N: n, Socket: rt.Socket, Build: build} |
| 174 | if rt.Socket != "" { |
| 175 | g.Control = rt.Socket + ".control" |
| 176 | } |
| 177 | return g |
| 178 | } |
| 179 | |
| 180 | // GenBackend is the optional Backend capability behind generations (spec |
| 181 | // §7.1). ExecBackend and SplitBackend implement it. A backend that does not |
| 182 | // (UnitBackend, every test double that predates this plan) is spawned with |
| 183 | // Start and gets canonicalGen: one generation per entry, on the canonical |
| 184 | // socket. |
| 185 | type GenBackend interface { |
| 186 | // StartGen spawns generation n of rt's instance and returns its handle. |
| 187 | // perGen false must behave exactly as Start; true binds the generation's |
| 188 | // own socket where the layout has one. |
| 189 | StartGen(ctx context.Context, rt registry.Route, n uint64, perGen bool) (Proc, Gen, error) |
| 190 | // NextDueVia is NextDue dialling socket rather than rt.Socket. |
| 191 | NextDueVia(ctx context.Context, rt registry.Route, socket string) (int64, error) |
| 192 | } |
| 193 | |
| 194 | var ( |
| 195 | _ GenBackend = (*ExecBackend)(nil) |
| 196 | _ GenBackend = (*SplitBackend)(nil) |
| 197 | ) |
| 198 | |