code wiki / _hdl_build / nx_crawl_pace.nx

nx_crawl_pace.nx source

↩ module page · 440 lines · 27045 B

1// nx_crawl_pace.nx -- SOTA polite crawler pacing (operator 2026-07-05: "pacing is a great callout on all the 2// systems... get to state of the art"). Per-host adaptive rate limiting: a min interval between hits to the 3// same host, HONOR Retry-After on 429/503, EXPONENTIAL backoff on repeated throttles (R9 2026-08-24: a success 4// HALVES the backoff instead of erasing it -- pace_decay -- so one lucky 200 cannot re-trip a host's limit), and 5// robots crawl-delay override. State persists across invocations (each nx_url_index run is a fresh process) in 6// a fixed hash-table file, so serial crawls stay polite and don't get IP-banned (a ban = LOST reach, not just 7// rudeness -- reddit 429'd us on rapid feed hits). A shared organ any fetcher can call: pace_before(host) 8// before a request, pace_after(host,status,retry_after) after. license_tier: ORIGINAL 9import "nx_syscalls.nx" 10const PACE_MAGIC_5381: i64 = 5381 11const PACE_MAGIC_1024: i64 = 1024 12const PACE_MAGIC_1000000: i64 = 1000000 13 14// --- policy config (v1 surface; a later rung reads svc-config per Cardinal 11) --- 15const PACE_BASE_MS: i64 = 1000 // polite default: >= 1s between requests to the same host 16const PACE_MAX_BACKOFF_MS: i64 = 300000 // cap any single backoff at 5 min 17const PACE_WAIT_CAP_MS: i64 = 60000 // never block ONE fetch > 60s (caller may defer beyond) 18const PACE_SLOTS: i64 = 4096 19const PACE_REC: i64 = 40 // host_hash(8) | next_allowed_ms(8) | consec_throttle(8) | crawl_delay_ms(8) | flags(8) 20const PACE_TBL_BYTES: i64 = 163840 // PACE_SLOTS * PACE_REC 21const PACE_REC_V1: i64 = 32 // the pre-R12 record; on-disk tables in that layout are MIGRATED, never discarded 22const PACE_TBL_BYTES_V1: i64 = 131072 23const PACE_F_OKSEEN: i64 = 1 // flags bit 0: this host has returned a 2xx at least once 24// Byte offsets of each field WITHIN one PACE_REC, derived from the layout PACE_REC already declares 25// above (host_hash | next_allowed_ms | consec_throttle | crawl_delay_ms | flags), every field an i64. 26// The pre-existing accessors below still spell these as raw 8/16/24/32; these names exist so new code 27// stops adding to that pile, and so a reader of the R14 rule can see WHICH field it reads. 28const PACE_OFF_HASH: i64 = 0 29const PACE_OFF_NEXT_ALLOWED: i64 = 8 30const PACE_OFF_CONSEC: i64 = 16 31const PACE_OFF_CRAWL_DELAY: i64 = 24 32const PACE_OFF_FLAGS: i64 = 32 33const PACE_TBL_MODE: i64 = 0x1A4 // 0o644 file MODE for sys_openat_wr (flags O_CREAT|O_WRONLY|O_TRUNC 34 // are hardcoded inside it). Passing flags here made the file 0o1101 35 // (no owner-write) -> 2nd open EACCES; a real mode fixes re-writes. 36 37// --- little-endian i64 load/store into the table buffer --- 38func pace_ld(tbl: *u8, off: i64) -> i64 { var v: i64=0; var i: i64=0; while i<8 { v = v | ((tbl[off+i] as i64) << (i*8)); i=i+1 } return v } 39func pace_st(tbl: *u8, off: i64, val: i64) -> i64 { var i: i64=0; while i<8 { tbl[off+i] = ((val >> (i*8)) & 0xff) as u8; i=i+1 } return 0 } 40 41// stable positive host hash (djb2, masked) 42func pace_hash(h: *u8, n: i64) -> i64 { 43 var x: i64 = PACE_MAGIC_5381 44 var i: i64 = 0 45 while i < n { x = (((x << 5) + x) + (h[i] as i64)) & 0x7fffffffffffffff; i = i + 1 } 46 if x == 0 { x = 1 } // 0 reserved for "empty slot" 47 return x 48} 49 50// --- persistence: read-modify-write a fixed table file (serial-crawler safe) --- 51// R13b (2026-08-25): fill a CALLER-OWNED buffer. pace_load_tbl() below is exactly this plus the mmap, so 52// there is ONE loader and the two cannot drift. A caller that reloads on a loop (the crawler reloads once 53// per BATCH) then owns exactly one buffer for the whole run instead of one per reload. 54// ZEROING IS LOAD-BEARING AND IS THE WHOLE REASON THIS IS NOT A FREE SPLIT: sys_mmap hands back zero-filled 55// pages, so the original could rely on dst starting clean, but a REUSED buffer does not. Two paths below 56// leave bytes unwritten -- the no-file-at-all path writes nothing, and the v1 migration writes only 57// PACE_REC_V1 of each PACE_REC record -- so without this a reload would inherit the PREVIOUS load's bytes 58// in exactly the fields (flags, and every slot the new table dropped) that decide a deferral. 59// The v2 path overwrites all PACE_TBL_BYTES and so does not need it; it is unconditional anyway because a 60// loader whose postcondition is "dst is fully defined" cannot be misused, and the cost is one memset per 61// BATCH, not per row. 62func pace_load_tbl_into(dst: *u8) -> i64 { 63 var z: i64 = 0 64 while z < PACE_TBL_BYTES { dst[z] = 0 as u8; z = z + 1 } 65 // NEVER hand back the file's own mapping (which is what the caller would get from sys_read_file): 66 // pace_save_tbl's O_TRUNC would then wipe the buffer mid-write. Copy in, decouple. 67 let lp: *i64 = sys_mmap(16) as *i64 68 // v2 first. Its presence means the migration already happened; v1 is then only history. 69 let src2: *u8 = sys_read_file("knowledge/status/pace_crawl2.tbl" as *u8, lp) 70 if (src2 as i64) != 0 { if lp[0] >= PACE_TBL_BYTES { 71 var j: i64 = 0 72 while j < PACE_TBL_BYTES { dst[j] = src2[j]; j = j + 1 } 73 return 0 74 } } 75 // No v2 yet -> seed from the v1 (32-byte) table exactly once. v1 is left ON DISK untouched, so a 76 // still-running old binary keeps working against it and cannot reach anything we own (rule 13: 77 // additive only -- the migration reads history, it never deletes it). 78 let src: *u8 = sys_read_file("knowledge/status/pace_crawl.tbl" as *u8, lp) 79 if (src as i64) != 0 { if lp[0] >= PACE_TBL_BYTES { 80 var i: i64 = 0 81 while i < PACE_TBL_BYTES { dst[i] = src[i]; i = i + 1 } 82 } else { if lp[0] >= PACE_TBL_BYTES_V1 { 83 // R12 MIGRATION: the on-disk table is the 32-byte layout. Re-lay it into 40-byte records 84 // rather than ignoring it. Discarding it would silently drop every in-flight backoff and 85 // every adopted Crawl-delay -- i.e. a politeness change that begins by being impolite. 86 // flags starts 0 (ok_seen unknown), which is the SAFE direction: an unproven host's 403 is 87 // read as a refusal, never as a throttle. 88 var s: i64 = 0 89 while s < PACE_SLOTS { 90 var f: i64 = 0 91 while f < PACE_REC_V1 { dst[s * PACE_REC + f] = src[s * PACE_REC_V1 + f]; f = f + 1 } 92 s = s + 1 93 } 94 } } } 95 return 0 96} 97// The original 0-arg loader, byte-for-byte in meaning: allocate, then delegate. Every existing caller and 98// every gate tooth is unchanged, and there is still exactly ONE piece of load logic in the estate. 99func pace_load_tbl() -> *u8 { 100 let dst: *u8 = sys_mmap(PACE_TBL_BYTES) // anon, zero-filled 101 pace_load_tbl_into(dst) 102 return dst 103} 104// R16 (2026-09-01, /compare/mediaingest, debt 1788270007): stderr emitters so a failed PERSIST can 105// announce itself. Lengths are DERIVED by scanning to NUL, never hand-counted beside the literal -- a 106// hand-counted length is a second copy of the string's shape and the two drift silently. 107// stderr and not stdout on purpose: callers on this path emit machine-read output on fd 1, and a 108// diagnostic written there would corrupt a parser that is not the subject of this change. 109func pace_ew(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(2, s, n); return 0 } 110func pace_en(v: i64) -> i64 { 111 let t: *u8 = sys_mmap(32) 112 var m: i64 = v 113 if m < 0 { pace_ew("-" as *u8); m = 0 - m } 114 var k: i64 = 0 115 if m == 0 { t[0] = 48 as u8; k = 1 } 116 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 117 let b: *u8 = sys_mmap(32) 118 var j: i64 = 0 119 while j < k { b[j] = t[k - 1 - j]; j = j + 1 } 120 sys_write(2, b, k) 121 return 0 122} 123 124// PERSIST THE TABLE. THE RETURN CONTRACT IS UNCHANGED (0 ok, -1 fail) so no caller's behaviour moves. 125// 126// WHY THE ANNOUNCE EXISTS. Both callers of this function DISCARD its return value: pace_after_tbl ends 127// `pace_save_tbl(tbl); return delay` and pace_set_crawl_delay ends `pace_save_tbl(tbl); return 0` -- 128// success, unconditionally. So before this change a failed save was INDISTINGUISHABLE FROM A SUCCESSFUL 129// ONE at every call site, and nx_crawl_pace could not tell RECORDED from FAILED-TO-RECORD. That is the 130// same fail-open shape R15 closed on the SLOT path, arriving through the SAVE path: a host whose backoff 131// never persisted reads exactly like a host that was never throttled, so the crawler re-hits it at base 132// delay -- the operator-facing symptom this whole domain was admitted for. 133// 134// MEASURED 2026-09-01 by nx_pacetbl_probe (which scans all PACE_SLOTS records directly instead of going 135// through pace_slot, so it separates write-did-not-land from read-cannot-find-it): nx_crawl_pace_gate's 136// two fixture hosts are ABSENT FROM EVERY SLOT after their writes, on a table that is FULL with empty=0. 137// The write genuinely does not land. WHICH failure it is -- open refused, or a short write -- was 138// unobservable from outside, and five candidate mechanisms were refuted by control before that could 139// even be established. 140// 141// This does not FIX the failure; it makes the failure NAME ITSELF, so the next run tells the reader 142// which syscall refused and with what, instead of costing them the same investigation again. A fix that 143// guessed between the remaining candidates would be a guess shipped as a repair. 144func pace_save_tbl(tbl: *u8) -> i64 { 145 let fd: i64 = sys_openat_wr("knowledge/status/pace_crawl2.tbl" as *u8, PACE_TBL_MODE) 146 if fd < 0 { 147 pace_ew("PACE-PERSIST-FAILED stage=open rc=" as *u8); pace_en(fd) 148 pace_ew(" path=knowledge/status/pace_crawl2.tbl bytes_owed=" as *u8); pace_en(PACE_TBL_BYTES) 149 pace_ew(" -- the caller has ALREADY been told this succeeded; its backoff is NOT recorded\n" as *u8) 150 return 0 - 1 151 } 152 var w: i64 = 0 // loop: a single write() need not flush 128KB in one go 153 while w < PACE_TBL_BYTES { 154 let k: i64 = sys_write(fd, (tbl as i64 + w) as *u8, PACE_TBL_BYTES - w) 155 if k <= 0 { 156 pace_ew("PACE-PERSIST-FAILED stage=write rc=" as *u8); pace_en(k) 157 pace_ew(" wrote=" as *u8); pace_en(w) 158 pace_ew(" of=" as *u8); pace_en(PACE_TBL_BYTES) 159 // A short write here leaves the table TRUNCATED on disk, which is strictly worse than not 160 // having written at all: the next load reads fewer than PACE_TBL_BYTES and silently starts 161 // from a zeroed table, dropping every in-flight backoff in the estate. Say so. 162 pace_ew(" -- TABLE IS NOW TRUNCATED ON DISK; the next load will read a partial file\n" as *u8) 163 sys_close(fd) 164 return 0 - 1 165 } 166 w = w + k 167 } 168 sys_close(fd) 169 return 0 170} 171// find the slot for hh (existing match or first empty), linear probe. -1 if full. 172func pace_slot(tbl: *u8, hh: i64) -> i64 { 173 var s: i64 = hh % PACE_SLOTS 174 var tries: i64 = 0 175 while tries < PACE_SLOTS { 176 let stored: i64 = pace_ld(tbl, s * PACE_REC) 177 if stored == 0 { return s } 178 if stored == hh { return s } 179 s = (s + 1) % PACE_SLOTS 180 tries = tries + 1 181 } 182 return 0 - 1 183} 184 185// R15 (2026-08-30, /compare/mediaingest R0): the WRITE-side slot finder. A READ that misses a full table is 186// correctly "no state" -- an absent host is not throttled -- so pace_slot returns -1 for reads. But a WRITE that 187// returns -1 SILENTLY DROPS the record: the pacer fails OPEN exactly when it is busiest. MEASURED 2026-08-30: the 188// nishihost pace table had reached 4096/4096 REAL hosts (4066 distinct hashes, 3815 plausible next_allowed epochs), 189// so pace_slot returned -1 and every NEW host got no pacing at all. An open-addressed table with no eviction cannot 190// grow, so a full table MUST evict. The victim is the slot whose next_allowed_ms is furthest in the PAST: that 191// host's backoff has most-expired, so it is the one least likely to still need pacing, and re-inserting it later 192// costs at most one un-paced request. This never returns -1 -- a write always lands -- and on a NON-full table it 193// is byte-identical to pace_slot (returns the found/empty slot), so the crawler's healthy table is unaffected. 194func pace_slot_or_evict(tbl: *u8, hh: i64) -> i64 { 195 let s: i64 = pace_slot(tbl, hh) 196 if s >= 0 { return s } 197 var victim: i64 = 0 198 var best: i64 = pace_ld(tbl, PACE_OFF_NEXT_ALLOWED) 199 var i: i64 = 1 200 while i < PACE_SLOTS { 201 let na: i64 = pace_ld(tbl, i * PACE_REC + PACE_OFF_NEXT_ALLOWED) 202 if na < best { best = na; victim = i } 203 i = i + 1 204 } 205 // clear the victim's whole record so the incoming host starts clean (no inherited backoff, crawl_delay or flags) 206 var f: i64 = 0 207 while f < PACE_REC { tbl[victim * PACE_REC + f] = 0 as u8; f = f + 1 } 208 return victim 209} 210 211// --- PURE POLICY (deterministic, gate-tested): post-fetch delay before the host may be hit again --- 212// status 429/503 -> honor Retry-After (s), else exponential base*2^consec (capped); success -> reset to base. 213func pace_delay_after(base_ms: i64, crawl_delay_ms: i64, consec_in: i64, 214 status: i64, retry_after_s: i64, out_consec: *i64) -> i64 { 215 return pace_delay_after2(base_ms, crawl_delay_ms, consec_in, status, retry_after_s, 0, out_consec) 216} 217// R12: the same policy, plus ok_seen (1 = this host has already served us a 2xx). The 6-arg form 218// above is preserved EXACTLY (ok_seen=0) so every existing caller and gate tooth is byte-identical. 219func pace_delay_after2(base_ms: i64, crawl_delay_ms: i64, consec_in: i64, 220 status: i64, retry_after_s: i64, ok_seen: i64, out_consec: *i64) -> i64 { 221 var eff: i64 = base_ms 222 if crawl_delay_ms > eff { eff = crawl_delay_ms } 223 var throttle: i64 = 0 224 if status == 429 { throttle = 1 } 225 if status == 503 { throttle = 1 } 226 // A 403 from a host that has ALREADY served us is a rate limit wearing a 403 (measured on 227 // loc.gov: 2 pages served, then 403 on every one after, six observations agreeing). A 403 from a 228 // host that never served us is a real refusal, and backing off cannot fix a refusal. 229 if status == 403 { if ok_seen == 1 { throttle = 1 } } 230 if throttle == 1 { 231 out_consec[0] = consec_in + 1 232 var delay: i64 = 0 233 if retry_after_s > 0 { delay = retry_after_s * 1000 } 234 else { 235 var mult: i64 = 1; var k: i64 = 0 236 while k < consec_in { mult = mult * 2; if mult >= PACE_MAGIC_1024 { mult = PACE_MAGIC_1024; k = consec_in } else { k = k + 1 } } 237 delay = eff * mult 238 } 239 if delay < eff { delay = eff } 240 if delay > PACE_MAX_BACKOFF_MS { delay = PACE_MAX_BACKOFF_MS } 241 return delay 242 } 243 // R9 (2026-08-24, /compare/webscraping contract pace_decay): a success DECAYS the backoff instead of erasing it. 244 // One 200 after a 429 used to reset to base, so the very next burst re-tripped the limit; now each success takes 245 // one halving off the backoff (consec - 1) and a host earns its way back to base over as many successes as it 246 // took throttles. A host that was never throttled is byte-identical to before (consec 0 -> base). 247 let nc: i64 = pace_decay(consec_in) 248 out_consec[0] = nc 249 if nc == 0 { return eff } 250 var mult2: i64 = 1 251 var k2: i64 = 0 252 while k2 < nc { mult2 = mult2 * 2; if mult2 >= PACE_MAGIC_1024 { mult2 = PACE_MAGIC_1024; k2 = nc } else { k2 = k2 + 1 } } 253 var d2: i64 = eff * mult2 254 if d2 > PACE_MAX_BACKOFF_MS { d2 = PACE_MAX_BACKOFF_MS } 255 return d2 256} 257// R9: the decay step, pure and gate-tested -- one success takes one halving off the backoff, never below zero 258func pace_decay(consec_in: i64) -> i64 { if consec_in <= 0 { return 0 } return consec_in - 1 } 259// forget a host's throttle history and next-allowed stamp (gate self-isolation; an operator reset after a fixed 260// client bug). The robots crawl-delay is KEPT: it is the host's number, not ours to forget. 261func pace_reset_host(host: *u8, host_len: i64) -> i64 { 262 let tbl: *u8 = pace_load_tbl() 263 let hh: i64 = pace_hash(host, host_len) 264 let s: i64 = pace_slot(tbl, hh) 265 if s < 0 { return 0 - 1 } 266 let off: i64 = s * PACE_REC 267 if pace_ld(tbl, off) != hh { return 0 } 268 pace_st(tbl, off + 8, 0) 269 pace_st(tbl, off + 16, 0) 270 pace_st(tbl, off + 32, 0) 271 pace_save_tbl(tbl) 272 return 1 273} 274// wait (ms) required before hitting the host now, given its next-allowed time. capped. 275func pace_wait_ms(now_ms: i64, next_allowed_ms: i64) -> i64 { 276 var w: i64 = next_allowed_ms - now_ms 277 if w <= 0 { return 0 } 278 if w > PACE_WAIT_CAP_MS { return PACE_WAIT_CAP_MS } 279 return w 280} 281 282// --- top-level: call BEFORE a request to a host (loads state, sleeps the polite interval) --- 283func pace_before(host: *u8, host_len: i64) -> i64 { 284 return pace_before_tbl(pace_load_tbl(), host, host_len) 285} 286// R15 (2026-08-30, /compare/mediaingest R0): the same wait against an ALREADY-LOADED table. The 0-arg form above 287// mmaps 160 KB per call and never unmaps it (R13b measured that for the crawler); the capture path calls the pacer 288// once per FETCH, and a 1000-segment HLS download through the 0-arg form would leak ~320 MB of touched anon pages. 289// nx_paced_fetch owns ONE process-lifetime buffer and reloads it in place before each decision instead. The two 290// forms cannot disagree: the 0-arg form loads and delegates, so there is still exactly one wait computation. 291func pace_before_tbl(tbl: *u8, host: *u8, host_len: i64) -> i64 { 292 if (tbl as i64) == 0 { return 0 } 293 let hh: i64 = pace_hash(host, host_len) 294 let s: i64 = pace_slot(tbl, hh) 295 if s < 0 { return 0 } 296 let off: i64 = s * PACE_REC 297 if pace_ld(tbl, off) != hh { return 0 } // no prior state -> no wait 298 let w: i64 = pace_wait_ms(sys_now_realtime_ms(), pace_ld(tbl, off + 8)) 299 if w > 0 { sys_sleep_ms(w) } 300 return w 301} 302// --- THE DEFERRAL PREDICATE THIS FILE'S OWN CONTRACT HAS ALWAYS PROMISED AND NO CALLER EVER HAD --- 303// PACE_WAIT_CAP_MS above reads "never block ONE fetch > 60s (caller may defer beyond)" -- but there has 304// never been a verb a caller could ASK, so the only production caller sleeps instead, and it sleeps IN 305// THE PARENT INSIDE ITS FORK LOOP. MEASURED 2026-08-25: books.google.com fetched at ~62s intervals, 41 306// consecutive times, while an 8-way pool sat idle behind it; crawl throughput fell 280.7 -> 72.3 docs/hr. 307// 308// IT ALSO BREAKS A SELF-SUSTAINING LOCK. pace_delay_after2 can set a 300s backoff, but pace_wait_ms CAPS 309// THE WAIT AT 60s -- so the crawler re-hits the host after 60s, earns another 429, and consec can never 310// decay (pace_decay requires a success). The backoff is computed and then never actually served. 311// DEFERRING IS WHAT SERVES IT. 312// 313// Returns 1 = DEFER this host now (leave the url PENDING -- it costs nothing and returns next run) 314// 0 = proceed (ready now, or only an ordinary politeness gap worth sleeping through) 315// 316// THE DISCRIMINATOR IS consec_throttle, NOT A DURATION, and that choice is load-bearing: an ordinary 317// politeness gap and a throttle backoff can be the SAME number of milliseconds while meaning completely 318// different things. A caller that deferred on duration would also defer normal politeness and fetch one 319// url per host per run -- slower than the defect it was written to fix. consec > 0 is the only signal 320// that means "this host has actively refused us", which is the one case where waiting is wasted. 321// R13b (2026-08-25): THE SAME PREDICATE AGAINST AN ALREADY-LOADED TABLE -- this is the form the crawler 322// calls, and the split is not cosmetic. pace_load_tbl() mmaps 160 KB, reads the 160 KB table file and 323// copies it byte-by-byte, and the buffer is NEVER unmapped. The crawler asks this question once per 324// CANDIDATE ROW at batch selection, and its pull window is WC_MAXPEND = 2048 rows, so the 0-arg form 325// would cost up to 2048 x (160 KB anon mmap + 160 KB read + 163,840-iteration copy) = ~335 MB of leaked 326// anon VMA and ~335 MB of read I/O in the crawler PARENT, every run, every 20 minutes. 327// NEVER ALLOCATE IN A HOT LOOP -- PASS THE BUFFER IN. The caller reloads once per BATCH, which is also 328// exactly the freshness granularity that matters: pace_after rewrites the table during the reap of the 329// previous batch, so a per-batch reload sees every update a per-row reload would have seen. 330func pace_should_defer_tbl(tbl: *u8, host: *u8, host_len: i64) -> i64 { 331 if (tbl as i64) == 0 { return 0 } 332 let hh: i64 = pace_hash(host, host_len) 333 let s: i64 = pace_slot(tbl, hh) 334 if s < 0 { return 0 } 335 let off: i64 = s * PACE_REC 336 if pace_ld(tbl, off) != hh { return 0 } // no prior state 337 // ---- R14 (2026-08-25): DEFER A HOST THAT PUBLISHED A CRAWL-DELAY LONGER THAN ONE FETCH CAN WAIT. 338 // WHY THIS IS A CORRECTNESS FIX AND NOT A TUNING KNOB: pace_before honours an interval by SLEEPING, 339 // and pace_wait_ms caps that sleep at PACE_WAIT_CAP_MS. So for any published Crawl-delay ABOVE that 340 // cap the sleep is truncated and we hit the host EARLY -- a host asking 120 s was fetched at 60 s. 341 // The crawler's old answer was worse still: nx_web_crawl_step clamped the published number DOWN to 342 // 30 s before it ever reached this table, so the host was fetched at 30 s. Both are the same defect, 343 // which is that a delay we cannot SLEEP through was converted into a delay we ignored. 344 // The threshold is DERIVED, not chosen: it is exactly the largest interval pace_before is capable of 345 // honouring in line. At or below it, sleeping is correct and nothing changes. Above it, sleeping 346 // CANNOT be correct, and the only compliant move left is to not fetch now. 347 // WHY IT DOES NOT RE-INTRODUCE THE DURATION-DEFERRAL TRAP the 2-arg contract above warns about: the 348 // discriminator is still not a raw wait. It is "this host PUBLISHED a delay we are structurally 349 // unable to sleep through", which is false for every host that never published one -- ordinary 350 // politeness (PACE_BASE_MS = 1 s) can never reach it, so a normal host is byte-identical to before. 351 // COST OF DEFERRING: none. The frontier row is persisted and simply returns on a later step, by 352 // which time the interval HAS elapsed and this test yields 0. The host is crawled at its own pace 353 // rather than not at all. 354 if pace_ld(tbl, off + PACE_OFF_CRAWL_DELAY) > PACE_WAIT_CAP_MS { 355 if pace_wait_ms(sys_now_realtime_ms(), pace_ld(tbl, off + PACE_OFF_NEXT_ALLOWED)) > 0 { return 1 } 356 } 357 if pace_ld(tbl, off + 16) <= 0 { return 0 } // not throttled 358 if pace_wait_ms(sys_now_realtime_ms(), pace_ld(tbl, off + 8)) <= 0 { return 0 } // backoff served 359 return 1 360} 361// The 2-arg form the contract above describes, preserved EXACTLY for any caller that asks the question 362// once and does not have a table in hand. It loads and delegates, so the two forms cannot disagree about 363// what "defer" means -- there is one predicate, and the gate pins them equal on the same host. 364func pace_should_defer(host: *u8, host_len: i64) -> i64 { 365 return pace_should_defer_tbl(pace_load_tbl(), host, host_len) 366} 367 368// call AFTER the response: update next-allowed + backoff. returns the delay applied (ms). 369func pace_after(host: *u8, host_len: i64, status: i64, retry_after_s: i64) -> i64 { 370 return pace_after_tbl(pace_load_tbl(), host, host_len, status, retry_after_s) 371} 372// R15 (2026-08-30): the same update against an ALREADY-LOADED table -- see pace_before_tbl. It still SAVES the 373// table (persistence is the contract), so a caller's buffer is only a read cache: the disk is the truth. 374func pace_after_tbl(tbl: *u8, host: *u8, host_len: i64, status: i64, retry_after_s: i64) -> i64 { 375 if (tbl as i64) == 0 { return 0 - 1 } 376 let hh: i64 = pace_hash(host, host_len) 377 let s: i64 = pace_slot_or_evict(tbl, hh) // R15: a WRITE always lands; a full table evicts the oldest host rather than failing open 378 if s < 0 { return 0 - 1 } 379 let off: i64 = s * PACE_REC 380 var consec_in: i64 = 0 381 var crawl_delay: i64 = 0 382 var flags: i64 = 0 383 if pace_ld(tbl, off) == hh { 384 consec_in = pace_ld(tbl, off + 16) 385 crawl_delay = pace_ld(tbl, off + 24) 386 flags = pace_ld(tbl, off + 32) 387 } 388 var ok_seen: i64 = 0 389 if (flags & PACE_F_OKSEEN) != 0 { ok_seen = 1 } 390 let oc: *i64 = sys_mmap(16) as *i64 391 let delay: i64 = pace_delay_after2(PACE_BASE_MS, crawl_delay, consec_in, status, retry_after_s, ok_seen, oc) 392 // Record the 2xx AFTER the decision, never before: setting the bit first would make the very 393 // first 2xx-then-403 pair look like it already had history, and the discrimination would be a 394 // tautology that fires on every 403. 395 if status >= 200 { if status < 300 { flags = flags | PACE_F_OKSEEN } } 396 pace_st(tbl, off, hh) 397 pace_st(tbl, off + 8, sys_now_realtime_ms() + delay) 398 pace_st(tbl, off + 16, oc[0]) 399 pace_st(tbl, off + 24, crawl_delay) 400 pace_st(tbl, off + 32, flags) 401 pace_save_tbl(tbl) 402 return delay 403} 404// robots.txt crawl-delay for a host (ms): recorded so pace_delay_after uses max(base, crawl_delay). 405func pace_set_crawl_delay(host: *u8, host_len: i64, cd_ms: i64) -> i64 { 406 let tbl: *u8 = pace_load_tbl() 407 let hh: i64 = pace_hash(host, host_len) 408 let s: i64 = pace_slot_or_evict(tbl, hh) // R15: recording a host's crawl-delay is a WRITE -- it must land even on a full table 409 if s < 0 { return 0 - 1 } 410 let off: i64 = s * PACE_REC 411 pace_st(tbl, off, hh) 412 if pace_ld(tbl, off + 8) == 0 { pace_st(tbl, off + 8, 0) } 413 pace_st(tbl, off + 24, cd_ms) 414 pace_save_tbl(tbl) 415 return 0 416} 417// parse "Retry-After: <seconds>" from response headers (integer-seconds form). 0 if absent/date-form. 418func pace_retry_after(resp: *u8, n: i64) -> i64 { 419 let key: *u8 = "retry-after:" as *u8 420 var i: i64 = 0 421 while i + 12 < n { 422 var m: i64 = 1; var j: i64 = 0 423 while j < 12 { let c: i64 = resp[i+j] as i64; var lc: i64 = c; if c >= 65 { if c <= 90 { lc = c + 32 } } if lc != (key[j] as i64) { m = 0; j = 12 } else { j = j + 1 } } 424 if m == 1 { 425 var p: i64 = i + 12 426 while p < n { let c: i64 = resp[p] as i64; if c == 32 { p = p + 1 } else { if c == 9 { p = p + 1 } else { p = n + PACE_MAGIC_1000000 } } } 427 var q: i64 = i + 12 428 var st: i64 = 0 429 while st == 0 { if q >= n { st = 1 } else { let c: i64 = resp[q] as i64; if c == 32 { q = q + 1 } else { if c == 9 { q = q + 1 } else { st = 1 } } } } 430 var v: i64 = 0; var got: i64 = 0 431 while q < n { let d: i64 = resp[q] as i64; if d >= 48 { if d <= 57 { v = v*10 + (d - 48); got = 1; q = q + 1 } else { q = n } } else { q = n } } 432 if got == 1 { return v } 433 return 0 434 } 435 i = i + 1 436 } 437 return 0 438} 439 440func main() -> i64 { return 0 }