code wiki / _hdl_build / nx_retry_policy_gate.nx

nx_retry_policy_gate.nx source

↩ module page · 202 lines · 11900 B

1// nx_retry_policy_gate.nx -- TEETH FOR THE RETRY PRIMITIVE, written because its jitter was ADVERTISED AND 2// INERT and nothing could see it. 3// 4// THE DEFECT (measured 2026-08-21, vocabulary census): nx_retry_policy_new set `p.jitter_enabled = 1` 5// while nx_retry_compute_wait_seconds performed NO randomization -- its own comment said "v1 uses 6// deterministic mid-jitter (multiply by 1.0)". A caller reading the flag believed the herd was 7// decorrelated while every retry landed on the same instant. It failed in the FLATTERING direction, so 8// nobody investigated, and the organ had NO GATE AT ALL -- the two facts are the same fact. 9// 10// THE TOOTH THAT MATTERS IS T2, AND IT IS BUILT TO FAIL AGAINST THE OLD CODE: two DIFFERENT callers 11// drawing at the SAME instant must get DIFFERENT delays. Under the 1.0-multiplier version they get the 12// same number, so T2 goes RED on the pre-fix source by construction -- that is what makes the GREEN worth 13// anything. Every other tooth here would have passed before the fix. 14// 15// No files, no fixtures, no clock: the subject is pure arithmetic and the seed is explicit, so this gate 16// is idempotent and cannot share a fixture with a production beat. 17// license_tier: ORIGINAL expect_exit: 0 No hw writes (Rule 26). 18import "nx_retry_policy.nx" 19import "nx_gate_verdict.nx" 20 21// The seed sweep. A jitter claim proven on ONE seed is a claim about one number -- a bound must hold over 22// the whole draw space, and a variance claim needs enough draws that agreement is not luck. 23const RG_SEEDS: i64 = 64 24// An attempt whose UNJITTERED delay is comfortably larger than a millisecond, so [d/2, d] has room to be 25// a real interval rather than a rounding artefact. Derived, not picked: at base 250 ms attempt 3 gives 26// 2000 ms, whose half-width is 1000 ms. 27const RG_ATTEMPT: i64 = 3 28const RG_NOW: i64 = 1787000000 29 30func rg_eq(a: i64, b: i64) -> i64 { if a == b { return 1 } return 0 } 31 32func rg_policy(hk: i64, attempt: i64, jit: i64) -> *RetryPolicy { 33 let p: *RetryPolicy = nx_retry_policy_new(hk) 34 p.current_attempt = attempt 35 p.jitter_enabled = jit 36 return p 37} 38 39func main() -> i64 { 40 let ctr: *i64 = gv_ctr() 41 gv_head("nx_retry_policy_gate -- the jitter is PERFORMED, not advertised" as *u8) 42 43 // ---- T1 ANTI-VACUITY, FIRST: the subject must produce a non-trivial delay at all. A gate whose 44 // subject returns 0 everywhere would score 100 percent on every bound below. 45 let base: *RetryPolicy = rg_policy(1, RG_ATTEMPT, 0) 46 let unjittered: i64 = nx_retry_compute_wait_ms(base, 0) 47 gv_puts(" unjittered wait_ms at attempt " as *u8); gv_num(RG_ATTEMPT) 48 gv_puts(" = " as *u8); gv_num(unjittered); gv_puts("\n" as *u8) 49 var t1: i64 = 0 50 if unjittered > 1 { if unjittered <= base.max_wait_seconds * NX_RETRY_MS_PER_SEC { t1 = 1 } } 51 gv_check("T1 ANTI-VACUITY: the unjittered backoff is a real, in-range delay (>1ms and within the cap), so every bound below is measured against something" as *u8, t1, ctr) 52 53 // ---- T2 THE ONE THAT FAILS AGAINST THE 1.0-MULTIPLIER VERSION ---- 54 // Two DIFFERENT callers, SAME instant. This is the entire point of jitter and the exact thing the old 55 // code did not do. Counting DISTINCT values (not merely "a != b") so a two-valued degenerate draw 56 // cannot pass either. 57 var distinct: i64 = 0 58 var i: i64 = 0 59 while i < RG_SEEDS { 60 let pi: *RetryPolicy = rg_policy(i, RG_ATTEMPT, 1) 61 let wi: i64 = nx_retry_compute_wait_ms(pi, nx_retry_seed(pi, RG_NOW)) 62 var seen: i64 = 0 63 var j: i64 = 0 64 while j < i { 65 let pj: *RetryPolicy = rg_policy(j, RG_ATTEMPT, 1) 66 let wj: i64 = nx_retry_compute_wait_ms(pj, nx_retry_seed(pj, RG_NOW)) 67 if wj == wi { seen = 1; j = i } 68 j = j + 1 69 } 70 if seen == 0 { distinct = distinct + 1 } 71 i = i + 1 72 } 73 gv_puts(" distinct delays across " as *u8); gv_num(RG_SEEDS) 74 gv_puts(" callers at ONE instant = " as *u8); gv_num(distinct); gv_puts("\n" as *u8) 75 gv_bite("T2 NO THUNDERING HERD: 64 different callers drawing at the SAME instant get MANY distinct delays -- this is the tooth that goes RED against the multiply-by-1.0 version, where all 64 collapse to one value" as *u8, 76 rg_eq(distinct > RG_SEEDS / 2, 1), rg_eq(distinct, 1), ctr) 77 78 // ---- T3 THE BOUND, OVER THE WHOLE SWEEP, NOT ONE DRAW ---- 79 var inband: i64 = 0 80 var below: i64 = 0 81 var above: i64 = 0 82 i = 0 83 while i < RG_SEEDS { 84 let p3: *RetryPolicy = rg_policy(i, RG_ATTEMPT, 1) 85 let w: i64 = nx_retry_compute_wait_ms(p3, nx_retry_seed(p3, RG_NOW)) 86 if w > unjittered { above = above + 1 } else { 87 if w < unjittered / 2 { below = below + 1 } else { inband = inband + 1 } 88 } 89 i = i + 1 90 } 91 gv_puts(" band check: inband=" as *u8); gv_num(inband) 92 gv_puts(" below_half=" as *u8); gv_num(below) 93 gv_puts(" above_d=" as *u8); gv_num(above) 94 gv_puts(" (partition sums to " as *u8); gv_num(inband + below + above); gv_puts(")\n" as *u8) 95 gv_check("T3 EVERY draw lies in [d/2, d] over the whole 64-seed sweep, and the three-way partition SUMS to the sweep size (a bound proven on one draw is a claim about one number)" as *u8, 96 rg_eq(inband, RG_SEEDS), ctr) 97 98 // ---- T4 neg-control: DISABLING THE FLAG MUST CHANGE THE ANSWER ---- 99 // Without this, a jitter function that ignored jitter_enabled would pass T2 and T3 and the flag would 100 // be decorative in the other direction. 101 var off_same: i64 = 1 102 i = 0 103 while i < RG_SEEDS { 104 let p4: *RetryPolicy = rg_policy(i, RG_ATTEMPT, 0) 105 if nx_retry_compute_wait_ms(p4, nx_retry_seed(p4, RG_NOW)) != unjittered { off_same = 0 } 106 i = i + 1 107 } 108 gv_check("neg-control-T4 jitter_enabled=0 returns the EXACT capped exponential for all 64 seeds -- the flag is what causes the spread, so it is not decorative in either direction" as *u8, 109 off_same, ctr) 110 111 // ---- T5 THE FLAG TELLS THE TRUTH ON A FRESH POLICY ---- 112 // The original defect stated as one predicate: the default says jitter is ON, and the default really 113 // does randomize. Either half alone was true before the fix; the conjunction was not. 114 let fresh: *RetryPolicy = nx_retry_policy_new(7) 115 fresh.current_attempt = RG_ATTEMPT 116 let fa: i64 = nx_retry_compute_wait_ms(fresh, nx_retry_seed(fresh, RG_NOW)) 117 let other: *RetryPolicy = nx_retry_policy_new(7000) 118 other.current_attempt = RG_ATTEMPT 119 let fb: i64 = nx_retry_compute_wait_ms(other, nx_retry_seed(other, RG_NOW)) 120 var t5: i64 = 0 121 if fresh.jitter_enabled == 1 { if fa != fb { t5 = 1 } } 122 gv_bite("T5 THE DEFAULT POLICY'S FLAG MATCHES ITS BEHAVIOUR: jitter_enabled reads 1 AND two default policies really do draw differently (before the fix the flag read 1 and the draws were identical)" as *u8, 123 t5, rg_eq(fa, fb), ctr) 124 125 // ---- T6 THE CAP STILL BINDS AFTER JITTERING ---- 126 var capped_ok: i64 = 1 127 i = 0 128 while i < RG_SEEDS { 129 let p6: *RetryPolicy = rg_policy(i, RG_ATTEMPT + 20, 1) 130 let w6: i64 = nx_retry_compute_wait_ms(p6, nx_retry_seed(p6, RG_NOW)) 131 if w6 > p6.max_wait_seconds * NX_RETRY_MS_PER_SEC { capped_ok = 0 } 132 i = i + 1 133 } 134 gv_check("T6 a jittered draw never exceeds max_wait_seconds even at a deep attempt where the raw exponential would overflow the cap" as *u8, capped_ok, ctr) 135 136 // ---- T7/T8 RETRY-AFTER IS A FLOOR, AND THE FLOOR IS NOT INERT ---- 137 // RFC 9110 says honour the header; a bare scalar synchronises the herd. So the jitter goes ABOVE the 138 // value, never below. T7 proves the direction, T8 proves it actually moves -- without T8 a function 139 // that returned the value verbatim would pass T7 forever, which is the original defect relocated. 140 let ra: i64 = 60 141 var never_early: i64 = 1 142 var moved: i64 = 0 143 i = 0 144 while i < RG_SEEDS { 145 let p7: *RetryPolicy = nx_retry_policy_new(i) 146 nx_retry_apply_retry_after(p7, ra, i) 147 if p7.next_wait_ms < ra * NX_RETRY_MS_PER_SEC { never_early = 0 } 148 if p7.next_wait_ms > ra * NX_RETRY_MS_PER_SEC { moved = moved + 1 } 149 i = i + 1 150 } 151 gv_puts(" retry-after floor=" as *u8); gv_num(ra * NX_RETRY_MS_PER_SEC) 152 gv_puts("ms seeds_above_floor=" as *u8); gv_num(moved); gv_puts("\n" as *u8) 153 gv_check("T7 Retry-After is a FLOOR: no seed ever produces a wait EARLIER than the server asked for (jittering downward here would violate the header)" as *u8, never_early, ctr) 154 gv_bite("T8 and the floor is NOT honoured verbatim: some seeds land strictly above it, so the herd told the same Retry-After is still spread" as *u8, 155 rg_eq(moved > 0, 1), rg_eq(moved, 0), ctr) 156 157 // ---- T9 THE SIZE CONSTANT AGREES WITH THE STRUCT ---- 158 // It did not: the constant read 96 ("12 fields") against 13 real fields, so the last field was written 159 // 8 bytes past the allocation. mmap page-rounding hid it, which is why it survived. Writing a sentinel 160 // to the LAST field and reading it back proves the allocation actually covers the layout in use. 161 let sz: *RetryPolicy = nx_retry_policy_new(11) 162 sz.last_error_class = NX_RETRY_ERROR_TLS_FAIL 163 sz.next_wait_ms = 123456 164 var t9: i64 = 0 165 if NX_RETRY_POLICY_BYTES == NX_RETRY_POLICY_FIELDS * 8 { 166 if sz.last_error_class == NX_RETRY_ERROR_TLS_FAIL { if sz.next_wait_ms == 123456 { t9 = 1 } } 167 } 168 gv_puts(" policy bytes=" as *u8); gv_num(NX_RETRY_POLICY_BYTES) 169 gv_puts(" fields=" as *u8); gv_num(NX_RETRY_POLICY_FIELDS); gv_puts("\n" as *u8) 170 gv_check("T9 NX_RETRY_POLICY_BYTES is DERIVED from the field count and the last two fields round-trip a sentinel -- a hand-counted length beside a struct is a second copy of its shape and the two drift" as *u8, t9, ctr) 171 172 // ---- T10 THE SEED CARRIES PER-CALLER IDENTITY ---- 173 // A seed of `now` alone would move every client together and change nothing; this is the conjunct that 174 // makes T2 possible at all, tested directly so a T2 failure can be localised. 175 let s1: *RetryPolicy = nx_retry_policy_new(100) 176 let s2: *RetryPolicy = nx_retry_policy_new(200) 177 gv_bite("T10 the seed is per-CALLER, not per-instant: two descriptor_hk values give different seeds at the same now_unix" as *u8, 178 rg_eq(nx_retry_seed(s1, RG_NOW) != nx_retry_seed(s2, RG_NOW), 1), 179 rg_eq(nx_retry_seed(s1, RG_NOW), nx_retry_seed(s2, RG_NOW)), ctr) 180 181 // ---- T11 THE DECISION PATHS ARE UNCHANGED BY THIS WORK ---- 182 let d: *RetryPolicy = nx_retry_policy_new(3) 183 var t11: i64 = 0 184 if nx_retry_policy_decide(d, NX_RETRY_ERROR_401_AUTH, RG_NOW) == NX_RETRY_NON_RETRYABLE { 185 let d2: *RetryPolicy = nx_retry_policy_new(3) 186 if nx_retry_policy_decide(d2, NX_RETRY_ERROR_5XX_SERVER, RG_NOW) == NX_RETRY_WAIT_AND_RETRY { 187 if d2.next_wait_ms > 0 { t11 = 1 } 188 } 189 } 190 gv_check("T11 REGRESSION: a non-retryable class still refuses, a retryable class still says WAIT_AND_RETRY, and the decision now populates next_wait_ms rather than leaving the caller with only the coarse seconds view" as *u8, t11, ctr) 191 192 // ---- T12 ONE DRAW FEEDS BOTH VIEWS ---- 193 let d3: *RetryPolicy = nx_retry_policy_new(9) 194 nx_retry_policy_decide(d3, NX_RETRY_ERROR_TIMEOUT, RG_NOW) 195 let expect_s: i64 = (d3.next_wait_ms + NX_RETRY_MS_PER_SEC - 1) / NX_RETRY_MS_PER_SEC 196 gv_check("T12 next_wait_seconds is the ROUNDED-UP view of the very next_wait_ms that was drawn, so the two fields can never disagree about which delay was chosen (a second draw here would be a second ruler)" as *u8, 197 rg_eq(d3.next_wait_seconds, expect_s), ctr) 198 199 let rc: i64 = gv_verdict("RETRY-POLICY-GATE" as *u8, ctr, "the retry primitive's advertised jitter is performed, bounded, and directional" as *u8) 200 sys_exit(rc) 201 return rc 202}