nx_ts_slot_lib.nx source
↩ module page · 215 lines · 9468 B
1// nx_ts_slot_lib.nx -- TS3: THE SLOT-FLIP DECISION CORE (importable, no main).
2//
3// /compare/trafficsafety rung TS3. Two backend SLOTS on two ports behind one small front door that
4// owns the upstream choice and is independent of both. Accept rule taken VERBATIM from
5// trafficsafety.plan: "a flip changes which slot answers with zero failed requests across the flip,
6// a slot that fails its warm-up probe is NEVER flipped to, and the front door's own uptime is
7// unbroken across the whole exercise."
8//
9// IT DOES NOT TOUCH sites.elf OR nx_proxy_route, AND THAT IS A DESIGN DECISION, NOT AN OMISSION.
10// The live front door is what all 69 route rows cross; editing it to gain a deploy-safety property is
11// the one change whose blast radius is every request in the estate. So this ships as a STANDALONE
12// router that fronts one non-critical backend. The pattern is identical and provable; adopting it for
13// the estate's real front door is an operator decision that should be taken against this evidence,
14// not smuggled in beneath it.
15//
16// THE FRONT DOOR IS THE ONLY THING THAT MUST NEVER RESTART, so it is also the only thing that must be
17// replaceable without a gap -- and that is precisely why TS3 depends on TS1. This router takes its
18// own listener from ts_handoff_nodrop, so the component whose restart would be an outage is itself
19// hot-replaceable. A front door that cannot be replaced hitlessly is refused rather than deployed;
20// this one closes that dependency in its own first line of work rather than deferring it.
21//
22// THE UPSTREAM CHOICE IS DATA, NOT A RECOMPILED BRANCH. The active slot lives in a conf file read
23// through rm_conf -- the estate's line-anchored reader, not a second one -- and it is re-read PER
24// CONNECTION. So a flip takes effect on the very next request with no signal, no restart and no
25// reload race, and the router holds no cached routing state that could disagree with the file.
26//
27// A FLIP IS GUARDED BY A WARM-UP PROBE AND THE GUARD FAILS TOWARD DOING NOTHING. ts_slot_flip probes
28// the CANDIDATE before it writes, and on a cold candidate it leaves the active slot exactly where it
29// was and returns a NAMED refusal. A heuristic that gates a destructive action must be wrong in the
30// direction of doing nothing, and flipping traffic onto a dead slot is the destructive action here.
31// SCOPE OF THE PROBE, STATED RATHER THAN IMPLIED: it establishes that the candidate ACCEPTS A
32// CONNECTION AND ANSWERS BYTES. That is liveness, not health. The estate already owns the deeper
33// instrument -- nx_http_probe_lib's hp_run, with thirteen distinct exit codes so a compound health
34// assertion cannot fail without naming WHICH half failed -- and the honest upgrade path is to compose
35// that, never to re-implement a second probe here.
36// license_tier: ORIGINAL No hw writes (Rule 26).
37
38import "nx_syscalls.nx"
39import "nx_http_server.nx" // ts_handoff_nodrop -- TS1, so the front door is itself replaceable
40import "nx_ts_drain_lib.nx" // ts_drain_on_term / ts_drain_wait -- TS2, so it drains rather than cuts
41import "nx_resmon_lib.nx" // rm_conf: the estate's line-anchored conf reader -- NOT a second one
42
43const TSR_SA_BYTES: i64 = 16
44// sockaddr_in FIELD OFFSETS, named so a reader can check them against the platform layout instead of
45// meeting bare indices at the call site: sin_family at 0, sin_port at 2 (big-endian), sin_addr at 4.
46const TSR_SA_PORT_OFF: i64 = 2
47const TSR_SA_ADDR_OFF: i64 = 4
48const TSR_ADDR_BYTES: i64 = 4
49const TSR_BYTE_RADIX: i64 = 256
50const TSR_LOOPBACK_A: i64 = 127
51const TSR_LOOPBACK_D: i64 = 1
52const TSR_BACKLOG: i64 = 16
53const TSR_OUT_BYTES: i64 = 64
54const TSR_NUM_SCRATCH: i64 = 24
55const TSR_ASCII_0: i64 = 48
56const TSR_ASCII_9: i64 = 57
57const TSR_B10: i64 = 10
58const TSR_BUF_CAP: i64 = 65536
59const TSR_PROBE_BYTE: i64 = 80
60
61// Slot identity. Two slots is the whole point -- blue and green -- and the count is named so a reader
62// does not meet a bare 2 at a call site.
63const TSR_SLOT_A: i64 = 0
64const TSR_SLOT_B: i64 = 1
65const TSR_SLOT_N: i64 = 2
66
67// ts_slot_flip verdicts -- each NAMES why, because a refusal that cannot say which rule fired sends
68// the reader at the wrong cause.
69const TSR_FLIP_OK: i64 = 0
70const TSR_FLIP_NOOP: i64 = 1
71const TSR_FLIP_COLD: i64 = 2
72const TSR_FLIP_BAD_SLOT: i64 = 3
73const TSR_FLIP_WRITE_ERR: i64 = 4
74
75const TSR_KEY_ACTIVE: *u8 = "active_slot" as *u8
76const TSR_ARGC_MIN: i64 = 5
77const TSR_A_FRONT: i64 = 1
78const TSR_A_SLOTA: i64 = 2
79const TSR_A_SLOTB: i64 = 3
80const TSR_A_STATE: i64 = 4
81const TSR_A_SOCK: i64 = 5
82
83func tsr_atoi(s: *u8) -> i64 {
84 var v: i64 = 0
85 var i: i64 = 0
86 while s[i] != (0 as u8) {
87 if s[i] >= (TSR_ASCII_0 as u8) { if s[i] <= (TSR_ASCII_9 as u8) { v = v * TSR_B10 + ((s[i] as i64) - TSR_ASCII_0) } }
88 i = i + 1
89 }
90 return v
91}
92
93func tsr_itoa(v: i64, out: *u8) -> i64 {
94 if v == 0 { out[0] = TSR_ASCII_0 as u8; out[1] = 0 as u8; return 1 }
95 var m: i64 = v
96 let t: *u8 = sys_mmap(TSR_NUM_SCRATCH)
97 var k: i64 = 0
98 while m > 0 { t[k] = ((TSR_ASCII_0 + (m - (m / TSR_B10) * TSR_B10)) as u8); m = m / TSR_B10; k = k + 1 }
99 var j: i64 = 0
100 while j < k { out[j] = t[k - 1 - j]; j = j + 1 }
101 out[k] = 0 as u8
102 return k
103}
104
105func tsr_sa(sa: *u8, port: i64) -> i64 {
106 var i: i64 = 0
107 while i < TSR_SA_BYTES { sa[i] = 0; i = i + 1 }
108 sa[0] = AF_INET
109 // The zero-fill above already cleared every other octet, so only the non-zero lanes are written.
110 sa[TSR_SA_PORT_OFF] = port / TSR_BYTE_RADIX
111 sa[TSR_SA_PORT_OFF + 1] = port % TSR_BYTE_RADIX
112 sa[TSR_SA_ADDR_OFF] = TSR_LOOPBACK_A
113 sa[TSR_SA_ADDR_OFF + TSR_ADDR_BYTES - 1] = TSR_LOOPBACK_D
114 return 0
115}
116
117func tsr_dial(port: i64) -> i64 {
118 let sa: *u8 = sys_mmap(TSR_SA_BYTES)
119 tsr_sa(sa, port)
120 let fd: i64 = sys_socket(AF_INET, SOCK_STREAM, 0)
121 if fd < 0 { return 0 - 1 }
122 sys_set_socket_timeout(fd, ACCEPT_TMO_S)
123 if sys_connect(fd, sa, TSR_SA_BYTES) < 0 { sys_close(fd); return 0 - 1 }
124 return fd
125}
126
127// THE ACTIVE SLOT IS READ FROM DATA, EVERY TIME. An absent or unparsable state file yields slot A,
128// which is the fail-safe direction for a front door: serve from the known-first slot rather than
129// refuse every request because a config read went wrong.
130func ts_slot_active(state_path: *u8) -> i64 {
131 let nl: *i64 = (sys_mmap(TSR_OUT_BYTES)) as *i64
132 nl[0] = 0
133 let buf: *u8 = sys_read_file(state_path, nl)
134 if buf == (0 as *u8) { return TSR_SLOT_A }
135 if nl[0] <= 0 { return TSR_SLOT_A }
136 let v: i64 = rm_conf(buf, nl[0], TSR_KEY_ACTIVE, TSR_SLOT_A)
137 if v < TSR_SLOT_A { return TSR_SLOT_A }
138 if v >= TSR_SLOT_N { return TSR_SLOT_A }
139 return v
140}
141
142// THE WARM-UP PROBE. Liveness, not health -- see the scope note in the header. It connects and
143// requires the candidate to answer bytes, so a port that is bound but wedged before its first write
144// does NOT read as warm.
145func ts_slot_probe(port: i64) -> i64 {
146 let fd: i64 = tsr_dial(port)
147 if fd < 0 { return 0 }
148 let b: *u8 = sys_mmap(TSR_BUF_CAP)
149 b[0] = TSR_PROBE_BYTE
150 if sys_write(fd, b, 1) != 1 { sys_close(fd); return 0 }
151 let r: i64 = sys_read(fd, b, TSR_BUF_CAP)
152 sys_close(fd)
153 if r > 0 { return 1 }
154 return 0
155}
156
157// THE FLIP. Probe the CANDIDATE first and refuse by name on a cold one, leaving the active slot
158// exactly where it was -- wrong in the direction of doing nothing. Writes the state file, which the
159// router re-reads per connection, so the change takes effect on the next request with no restart.
160func ts_slot_flip(state_path: *u8, ports: *i64, want: i64) -> i64 {
161 if want < TSR_SLOT_A { return TSR_FLIP_BAD_SLOT }
162 if want >= TSR_SLOT_N { return TSR_FLIP_BAD_SLOT }
163 if ts_slot_active(state_path) == want { return TSR_FLIP_NOOP }
164 if ts_slot_probe(ports[want]) == 0 { return TSR_FLIP_COLD }
165 let buf: *u8 = sys_mmap(TSR_NUM_SCRATCH * TSR_SLOT_N)
166 var o: i64 = 0
167 var i: i64 = 0
168 while TSR_KEY_ACTIVE[i] != (0 as u8) { buf[o] = TSR_KEY_ACTIVE[i]; o = o + 1; i = i + 1 }
169 buf[o] = 32 as u8
170 o = o + 1
171 let nb: *u8 = sys_mmap(TSR_NUM_SCRATCH)
172 let nlen: i64 = tsr_itoa(want, nb)
173 var j: i64 = 0
174 while j < nlen { buf[o] = nb[j]; o = o + 1; j = j + 1 }
175 buf[o] = 10 as u8
176 o = o + 1
177 let fd: i64 = sys_openat_wr(state_path, MODE_0644)
178 if fd < 0 { return TSR_FLIP_WRITE_ERR }
179 let w: i64 = sys_write(fd, buf, o)
180 sys_fsync(fd)
181 sys_close(fd)
182 if w != o { return TSR_FLIP_WRITE_ERR }
183 return TSR_FLIP_OK
184}
185
186// Forward one client exchange to the currently-active slot. Returns 1 when the client got bytes.
187func tsr_forward(cfd: i64, upstream_port: i64, buf: *u8) -> i64 {
188 let n: i64 = sys_read(cfd, buf, TSR_BUF_CAP)
189 if n <= 0 { return 0 }
190 let ufd: i64 = tsr_dial(upstream_port)
191 if ufd < 0 { return 0 }
192 if sys_write(ufd, buf, n) != n { sys_close(ufd); return 0 }
193 let r: i64 = sys_read(ufd, buf, TSR_BUF_CAP)
194 sys_close(ufd)
195 if r <= 0 { return 0 }
196 var off: i64 = 0
197 while off < r {
198 let w: i64 = sys_write(cfd, ((buf as i64) + off) as *u8, r - off)
199 if w <= 0 { return 0 }
200 off = off + w
201 }
202 return 1
203}
204
205func tsr_puts(s: *u8) -> i64 {
206 var n: i64 = 0
207 while s[n] != (0 as u8) { n = n + 1 }
208 return sys_write(1, s, n)
209}
210func tsr_putn(v: i64) -> i64 {
211 let b: *u8 = sys_mmap(TSR_NUM_SCRATCH)
212 let n: i64 = tsr_itoa(v, b)
213 return sys_write(1, b, n)
214}
215