carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
6package activator
7
8import (
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.
29var generationsOn atomic.Bool
30
31// SetGenerations sets the dormant switch for Services built after the call.
32func SetGenerations(on bool) { generationsOn.Store(on) }
33
34// GenerationsEnabled reports the switch as it stands now.
35func 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.
42var 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.
46func 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.
54type 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>.
75const GenSocketDir = provision.SocketDir + "/g"
76
77// maxUnixPath is the longest path a unix socket address holds.
78const 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).
88func 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).
94func 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.
110func 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.
121func 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.
132func 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.
137func 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.
150func 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.
172func 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.
185type 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
194var (
195 _ GenBackend = (*ExecBackend)(nil)
196 _ GenBackend = (*SplitBackend)(nil)
197)
198