diff --git a/examples/async_date_timerfd/README.md b/examples/async_date_timerfd/README.md new file mode 100644 index 0000000..ef43851 --- /dev/null +++ b/examples/async_date_timerfd/README.md @@ -0,0 +1,87 @@ +# async_date_timerfd — cached `Date:` header, refreshed by a per-worker timerfd + +The architecturally-pure way to send the RFC 9110-mandated `Date` header at +hundreds of thousands of req/s: format it **once per second**, not per request +— and do the refresh **inside each worker's own epoll loop** (the nginx +model), so there is no extra thread, no shared state, no lock, and the request +hot path does *zero* time work: it appends 37 cached bytes. + +Time is treated as an **event, not a sleeping thread**: a 1 s periodic +`timerfd` is registered in the worker's reactor as a clientless watch, and its +expiry arrives as ordinary fd readability between requests. + +## Requirements + +The moving parts live in V's stdlib as of these PRs (until they merge, build V +from the corresponding branches): + +| vlang/v PR | provides | +|---|---| +| [#27639](https://github.com/vlang/v/pull/27639) | `time.write_http_header` / `time.update_http_header` (in-place, incremental) | +| [#27641](https://github.com/vlang/v/pull/27641) | `time.unix_now()` — wall-clock second in ~2 ns | +| [#27642](https://github.com/vlang/v/pull/27642) | `C.timerfd_*` declarations via `import time` | + +## How it works + +```mermaid +flowchart TD + subgraph worker["one worker thread (per core) — nothing shared, no locks"] + start(["on_worker_start"]) --> mk["make_state()
DateCache: fixed [37]u8 seeded from
'Date: Xxx, 00 Xxx 0000 00:00:00 GMT\r\n'"] + mk --> seed["rebuild() — first call:
dc.last == 0 → full format"] + seed --> arm["C.timerfd_create(CLOCK_MONOTONIC)
arm_periodic(1000 ms)
(kernel re-arms: cadence never drifts)"] + arm --> watch["ac.watch(tfd, .readable, date_tick)"] + watch --> loop{{"epoll_wait
one loop, two event kinds"}} + + loop -->|"client fd readable"| req["handle(req, fd, mut out, state):
out << head
push_many(cached 37 bytes)
out << tail
zero time work"] + req --> loop + + loop -->|"tfd readable — the 1 s tick"| drain["date_tick():
read(tfd, &expirations, 8)
drains the count, re-levels the fd"] + drain --> upd["rebuild():
time.unix_now() (~2 ns)
time.update_http_header (~2 ns):
same minute → 2 digit stores
minute/hour rollover → +2 each
day rollover → full reformat (1×/day)"] + upd --> rearm["ac.watch(tfd, ...) again
return .suspend"] + rearm --> loop + end +``` + +The cached line, byte by byte — every refresh rewrites only the digits whose +bucket rolled over: + +```text +D a t e : W e d , 2 1 O c t 2 0 1 5 0 7 : 2 8 : 0 0 G M T \r \n +0 6 23 26 29 37 +'Date: ' └────────── IMF-fixdate (time.http_header_len = 29) ──────────┘CRLF +``` + +## Why it is fast + +| operation | cost | +|---|---| +| request hot path | 3 buffer appends — no clock, no formatting, no allocation | +| per-second refresh (same minute) | `unix_now()` ~2 ns + 2 byte stores (~2 ns) | +| day rollover | one full 29-byte reformat — once per day | +| the rebuild this replaced | ~1.25 µs: dynamic-array clear+appends, `time.utc()` calendar math, 2 hidden substr allocations | + +The kernel re-arms the periodic timer, so the 1 Hz cadence does not drift with +processing time, and `read()` returns the number of expirations — missed ticks +are *counted*, never silently lost. Because refresh and request handling run +on the same thread, the `[37]u8` cache needs no synchronization at all — +contrast with `examples/date_header`, where a dedicated sleeper thread writes +a cache that worker threads read (double buffering + atomic flip required), +and with `examples/efficient_date`, which refreshes lazily on the first +request of each second (correct, but the clock read lands on a request). + +## Run + +```sh +v run examples/async_date_timerfd/ +curl -i http://localhost:8097/ # note the Date header +sleep 3 +curl -i http://localhost:8097/ # Date advanced — with zero load +``` + +## Tests + +`main_test.v` covers this example's wiring: the template/`time.http_header_len` +relationship, the refresh-current-second contract (against +`http_header_string()` as oracle) and the handler's framing. The incremental +update logic itself is oracle-tested in vlib across every rollover +(second/minute/hour/day, forward and backward jumps). diff --git a/examples/async_date_timerfd/main.v b/examples/async_date_timerfd/main.v index 7f39dc6..1895bb3 100644 --- a/examples/async_date_timerfd/main.v +++ b/examples/async_date_timerfd/main.v @@ -10,10 +10,20 @@ module main // touching the cache runs on the one thread that owns it). The request handler // then does ZERO time work on the hot path: it just appends the cached bytes. // +// The moving parts are stdlib since vlang/v#27639 / #27641 / #27642: +// - `time.update_http_header` refreshes the line IN PLACE, rewriting only the +// digits whose bucket rolled over (~2 ns/tick; calendar math only on day +// rollover) — vs ~1.25 µs for the old dynamic-array rebuild it replaced. +// - `time.unix_now()` reads the wall-clock second (vDSO, ~2 ns) without +// constructing a `Time`. +// - the timerfd declarations (`C.timerfd_create`/`C.timerfd_settime`/ +// `C.itimerspec`) ship with `import time` — no local `fn C.` declarations, +// no hand-built itimerspec in a raw [4]i64. +// // Contrast with examples/efficient_date (lazy per-request refresh: correct and -// simple, but calls time.utc() on the request that crosses each second). Here the -// clock advances even with NO traffic — the timerfd wakes the idle loop once a -// second — so the Date is fresh independent of request rate. +// simple, but pays the clock read on the request that crosses each second). Here +// the clock advances even with NO traffic — the timerfd wakes the idle loop once +// a second — so the Date is fresh independent of request rate. // // Run: v run examples/async_date_timerfd/ // Try: curl -i http://localhost:8097/ # note Date; re-run after a few @@ -22,43 +32,25 @@ import http_server import http_server.core import time -#include -#include - -fn C.timerfd_create(clockid int, flags int) int -fn C.timerfd_settime(fd int, flags int, new_value voidptr, old_value voidptr) int fn C.read(fd int, buf voidptr, count usize) int -fn C.time(t voidptr) i64 -// 'Date: ' (6) + IMF-fixdate (29, RFC 9110 §5.6.7) + CRLF (2) = 37 bytes. +// 'Date: ' (6) + IMF-fixdate (time.http_header_len = 29) + CRLF (2) = 37. +// (A literal, not `6 + time.http_header_len + 2`: a fixed-array size built +// from another module's const trips the checker in _test.v builds; the +// relationship is pinned by a test instead.) +const date_prefix_len = 6 const date_line_len = 37 -// Every static byte gets its place ONCE; rebuilds only overwrite digits. +// Static bytes get their place ONCE; every refresh only rewrites date digits. const line_template = 'Date: Xxx, 00 Xxx 0000 00:00:00 GMT\r\n' -// day_of_week() is 1..7 = Mon..Sun. -const wkday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] -const month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', - 'Dec'] - // DateCache is one worker's pre-formatted `Date: ...\r\n` line in a FIXED -// array — no heap, no growth, cache-line friendly. Only ever touched by that -// worker's thread (make_state + the timerfd continuation), so no lock. -// -// The IMF-fixdate is FIXED-WIDTH, so a 1 Hz refresh almost never changes more -// than the two seconds digits. rebuild_at exploits that: it re-encodes only -// the buckets that rolled over — -// same minute -> 2 byte stores (seconds) -// same hour -> 4 stores same day -> 6 stores -// day rollover -> full reformat (calendar math), once per day -// and reads the clock with C.time(0) (vDSO, ~2.5 ns) instead of time.utc() -// (~1.2 us of clock + calendar conversion, plus two hidden substr allocations -// inside push_to_http_header's weekday_str()/smonth()). Measured with the old -// dynamic-array + push_to_http_header rebuild: 1.25 us -> 9.2 ns, ~135x. +// array — no heap, no growth. Only ever touched by that worker's thread +// (make_state + the timerfd continuation), so no lock. struct DateCache { mut: line [date_line_len]u8 - last i64 // unix second currently encoded in line (0 = never formatted) + last i64 // unix second currently encoded (0 = never: first refresh writes all fields) } fn make_state() voidptr { @@ -67,63 +59,31 @@ fn make_state() voidptr { return dc } -// put2 writes v (0..99) as two ASCII digits at line[o] — two byte stores. -@[direct_array_access; inline] -fn (mut dc DateCache) put2(o int, v int) { - dc.line[o] = u8(`0` + v / 10) - dc.line[o + 1] = u8(`0` + v % 10) -} - -// rebuild_at re-encodes `now` into the line, touching only what changed. -// Pure over (dc.last, now) — the tests drive it through every rollover. -@[direct_array_access] -fn (mut dc DateCache) rebuild_at(now i64) { - if now == dc.last { - return - } - tod := int(now % 86400) - if dc.last != 0 && now / 86400 == dc.last / 86400 { - dc.put2(29, tod % 60) - if now / 60 != dc.last / 60 { - dc.put2(26, (tod / 60) % 60) - if now / 3600 != dc.last / 3600 { - dc.put2(23, tod / 3600) - } - } - } else { - // Day rollover (or first call): full reformat — the only place that - // pays calendar math, once per day. - t := time.unix(now) - w := wkday_names[t.day_of_week() - 1] - m := month_names[t.month - 1] - dc.line[6] = w[0] - dc.line[7] = w[1] - dc.line[8] = w[2] - dc.put2(11, t.day) - dc.line[14] = m[0] - dc.line[15] = m[1] - dc.line[16] = m[2] - dc.put2(18, t.year / 100) - dc.put2(20, t.year % 100) - dc.put2(23, t.hour) - dc.put2(26, t.minute) - dc.put2(29, t.second) +// rebuild refreshes the cached line for the current second: one ~2 ns clock +// read + an in-place update of only the digits that changed. +fn rebuild(mut dc DateCache) { + now := time.unix_now() + unsafe { + // 31 writable bytes >= time.http_header_len: cannot fail. + time.update_http_header(&dc.line[date_prefix_len], date_line_len - date_prefix_len, + dc.last, now) or {} } dc.last = now } -// rebuild refreshes the cached line for the current second. -fn rebuild(mut dc DateCache) { - dc.rebuild_at(i64(C.time(0))) -} - +// arm_periodic starts the kernel-paced tick: first expiry after `ms`, then +// every `ms` — re-armed by the KERNEL, so the cadence never drifts with the +// continuation's processing time. fn arm_periodic(tfd int, ms int) { - mut spec := [4]i64{} // { it_interval{s,ns}, it_value{s,ns} } — both set = periodic - spec[0] = i64(ms / 1000) - spec[1] = i64(ms % 1000) * 1_000_000 - spec[2] = spec[0] - spec[3] = spec[1] - C.timerfd_settime(tfd, 0, voidptr(&spec[0]), unsafe { nil }) + interval := C.timespec{ + tv_sec: ms / 1000 + tv_nsec: i64(ms % 1000) * 1_000_000 + } + spec := C.itimerspec{ + it_value: interval + it_interval: interval + } + C.timerfd_settime(tfd, 0, &spec, unsafe { nil }) } // on_start runs once per worker (client_fd = -1): build the cache now so the very @@ -140,8 +100,8 @@ fn on_start(mut ac core.AsyncCtx) { // and re-arm. Returns .suspend (keep the background watch alive). It never writes // to `out` (there is no client) and lives for the worker's whole lifetime. fn date_tick(mut out []u8, mut ac core.AsyncCtx) core.AsyncStep { - mut tmp := [8]u8{} - C.read(ac.ready_fd(), &tmp[0], 8) // drain the expiration count to re-level the fd + mut expirations := u64(0) + C.read(ac.ready_fd(), &expirations, 8) // drain the count to re-level the fd mut dc := unsafe { &DateCache(ac.state) } rebuild(mut dc) ac.watch(ac.ready_fd(), .readable, date_tick, unsafe { nil }) diff --git a/examples/async_date_timerfd/main_test.v b/examples/async_date_timerfd/main_test.v index bdcb28e..d977b61 100644 --- a/examples/async_date_timerfd/main_test.v +++ b/examples/async_date_timerfd/main_test.v @@ -2,60 +2,40 @@ module main import time -// SOLUTION: rebuild_at is pure over (dc.last, now) — drive it through every -// bucket rollover and compare byte-for-byte against the vlib formatter as the -// ORACLE. The incremental path only ever touches the digits that changed, so -// the oracle equality is exactly the property that matters. - -// expected builds the reference line via vlib (test scaffolding). -fn expected(u i64) string { - return 'Date: ' + time.unix(u).http_header_string() + '\r\n' -} - -fn cache() &DateCache { - return unsafe { &DateCache(make_state()) } -} +// SOLUTION: the incremental-update logic itself lives in vlib now +// (time.update_http_header, oracle-tested there across every rollover) — +// these tests cover THIS example's wiring: the template seeding, the +// refresh-current-second contract, and the handler's framing. fn line_of(dc &DateCache) string { return unsafe { tos(&dc.line[0], date_line_len) }.clone() } -fn test_first_format_and_idempotence() { - mut dc := cache() - dc.rebuild_at(1735689600) // 2025-01-01 00:00:00 UTC (full format path) - assert line_of(dc) == expected(1735689600) - dc.rebuild_at(1735689600) // same second: no-op, line unchanged - assert line_of(dc) == expected(1735689600) +fn expected(u i64) string { + return 'Date: ' + time.unix(u).http_header_string() + '\r\n' +} + +fn test_line_len_matches_vlib() { + assert date_line_len == date_prefix_len + time.http_header_len + 2 } -fn test_all_rollovers_match_oracle() { - mut dc := cache() - seq := [ - i64(1735689600), // seed (full format) - 1735689601, // +1 s: seconds digits only - 1735689659, // :59 - 1735689660, // minute rollover - 1735693199, // 00:59:59 - 1735693200, // hour rollover - 1735775999, // 23:59:59 - 1735776000, // day rollover (full reformat) - 1740787199, // jump forward weeks (leap-February territory) - 999999999, // jump BACKWARD across years — must fully reformat - 1000000000, // and advance again - ] - for u in seq { - dc.rebuild_at(u) - assert line_of(dc) == expected(u), 'mismatch at unix=${u}' - } +fn test_rebuild_encodes_the_current_second() { + mut dc := unsafe { &DateCache(make_state()) } + rebuild(mut dc) + assert dc.last > 0 + assert line_of(dc) == expected(dc.last) + rebuild(mut dc) // same second: no-op; a second later: seconds digits only + assert line_of(dc) == expected(dc.last) } -fn test_every_second_across_midnight() { - // Brute-force a window over a day boundary: every second must match the - // oracle (catches any missed bucket update in the incremental path). - mut dc := cache() - start := i64(1735775990) // 2025-01-01 23:59:50 UTC - for u in start .. start + 30 { - dc.rebuild_at(u) - assert line_of(dc) == expected(u), 'mismatch at unix=${u}' - } +fn test_handle_serves_the_cached_date() ! { + state := make_state() + mut dc := unsafe { &DateCache(state) } + rebuild(mut dc) + mut out := []u8{} + handle('GET / HTTP/1.1\r\nHost: x\r\n\r\n'.bytes(), -1, mut out, state)! + s := out.bytestr() + assert s.starts_with('HTTP/1.1 200 OK\r\nDate: ') + assert s.contains(time.unix(dc.last).http_header_string()) + assert s.ends_with('Connection: keep-alive\r\n\r\nok') }