Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions examples/async_date_timerfd/README.md
Original file line number Diff line number Diff line change
@@ -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()<br/>DateCache: fixed [37]u8 seeded from<br/>'Date: Xxx, 00 Xxx 0000 00:00:00 GMT\r\n'"]
mk --> seed["rebuild() — first call:<br/>dc.last == 0 → full format"]
seed --> arm["C.timerfd_create(CLOCK_MONOTONIC)<br/>arm_periodic(1000 ms)<br/>(kernel re-arms: cadence never drifts)"]
arm --> watch["ac.watch(tfd, .readable, date_tick)"]
watch --> loop{{"epoll_wait<br/>one loop, two event kinds"}}

loop -->|"client fd readable"| req["handle(req, fd, mut out, state):<br/>out << head<br/>push_many(cached 37 bytes)<br/>out << tail<br/><b>zero time work</b>"]
req --> loop

loop -->|"tfd readable — the 1 s tick"| drain["date_tick():<br/>read(tfd, &expirations, 8)<br/>drains the count, re-levels the fd"]
drain --> upd["rebuild():<br/>time.unix_now() (~2 ns)<br/>time.update_http_header (~2 ns):<br/>same minute → 2 digit stores<br/>minute/hour rollover → +2 each<br/>day rollover → full reformat (1×/day)"]
upd --> rearm["ac.watch(tfd, ...) again<br/>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).
128 changes: 44 additions & 84 deletions examples/async_date_timerfd/main.v
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,43 +32,25 @@ import http_server
import http_server.core
import time

#include <sys/timerfd.h>
#include <unistd.h>

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 {
Expand All @@ -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
Expand All @@ -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 })
Expand Down
74 changes: 27 additions & 47 deletions examples/async_date_timerfd/main_test.v
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Loading