robfig-cron-rs
Live project site → cron-rs-site.vercel.app
Side-by-side Go↔Rust diffs, benchmarks, the 4-hour soak results, and the
bug writeups, rendered in the browser.
A faithful Rust port of robfig/cron (v3), the
de facto Go cron library (14.2k stars, 13k+ dependents).
This is a Track E port-mortem exercise: Go -> Rust, motivated by the
GC-pause-vs-tail-latency problem statement. The cron scheduler's core loop
(sleep -> wake -> run job) is directly vulnerable to GC-induced jitter, making
it an ideal candidate for measuring the latency improvements a no-GC runtime
delivers.
What's ported
| Go source file | Rust module | LOC (Go) | LOC (Rust) |
|---|
parser.go | src/parser.rs | 327 | 461 |
spec.go | src/spec.rs | 127 | 261 |
constantdelay.go | src/constant_delay.rs | 16 | 38 |
logger.go | src/logger.rs | 62 | 89 |
option.go | src/option.rs | 30 | 93 |
chain.go | src/chain.rs | 68 | 170 |
cron.go | src/cron.rs | 242 | 332 |
| Total | | 872 | 1444 |
Test parity: 52/52 tests pass (100%). See DECISIONS.md for the full
parity table, the rationale behind the three deliberately non-ported Go
tests, and the writeup of one latent bug found in the upstream Go repo
(issue #543) and fixed in our port. The original Go test files are vendored under tests/original/ and
their SHA-256 kickoff hashes are recorded in .port-mortem.toml -- they
remain unmodified.
Quickstart (judges' path)
The fastest way to verify everything yourself, with zero toolchain
installation:
docker build -t robfig-cron-rs .
docker run --rm -it robfig-cron-rs # interactive TUI dashboard
That lands you in cron-rs-cli, a single-screen dashboard with:
- inline display of the canonical numbers (
unsafe: 0, tests: 52/52,
fuzz: 204k/0 div)
t runs the integrated test suite live and shows the summary
i lets you enter any cron expression + RFC3339 "from" time and see the
Rust port's Next() side-by-side with the Go original (if the Go oracle
is bundled in the container) and a MATCH/DIVERGENCE verdict
f runs a 10-second mini fuzz in-process and shows the divergence count
b runs the four-metric benchmark suite and surfaces the results inline
q quits
Other entry points from the same image (override the entrypoint):
docker run --rm robfig-cron-rs cargo test --test port # 52 integration tests
docker run --rm robfig-cron-rs ./bench_harness # benchmarks -> stdout + JSON
docker run --rm robfig-cron-rs ./fuzz_harness 60 # 60s differential fuzz
docker run --rm robfig-cron-rs ./soak_harness --duration 14400 --period-ms 50 # 4h soak
Build (from source, no Docker)
If you have Rust 1.88+ and Go 1.22+ installed, you can build and run
everything directly without Docker:
# Build all binaries (TUI + bench + soak + fuzz)
cargo build --release --features "cli,bench"
# Interactive TUI dashboard
cargo run --release --features cli --bin cron-rs-cli
# Run the 52 integration tests
cargo test --test port
# Benchmarks (Rust vs Go side-by-side)
( cd bench-helper && go build -o bench-helper . )
cargo run --release --features bench --bin bench_harness
# 60s differential fuzz
( cd fuzz-helper && go build -o go-fuzz-helper . )
git clone --depth=1 --branch=v3.0.1 https://github.com/robfig/cron ../cron
cargo run --release --features fuzz --bin fuzz_harness -- 60
# 4-hour soak
./soak.sh
# or:
cargo run --release --features bench --bin soak_harness -- --duration 14400 --period-ms 50
Viewing logs and results
All output files live inside bench/ and fuzz/. With Docker:
# Fuzz log (204,425 cases, zero divergences)
docker run --rm robfig-cron-rs cat fuzz/log.txt
# Benchmark results (Rust vs Go side-by-side JSON)
docker run --rm robfig-cron-rs cat bench/results.json
# Soak summary (4h, 64.5M ticks, p99 lateness)
docker run --rm robfig-cron-rs cat bench/soak-results.json
# Soak per-minute CSV (t_s, ticks, p50, p99, max, rss_kb)
docker run --rm robfig-cron-rs cat bench/soak-results.log
Without Docker, the files are in the repo after running the harnesses:
cat fuzz/log.txt # 60s fuzz log
cat bench/results.json # benchmark results
cat bench/soak-results.json # 4h soak summary
head bench/soak-results.log # soak per-minute CSV
tail bench/soak-results.log # last few minutes of the soak
Test
cargo test # all 52 tests
cargo test --test port # integration tests only (52 tests in tests/port/)
cargo clippy --all-targets --all-features # lint (clean)
cargo fmt --check # format check (clean)
Usage (as a library)
use robfig_cron_rs::chain::Chain;
use robfig_cron_rs::cron::Cron;
use robfig_cron_rs::logger;
use robfig_cron_rs::parser::{ParseOption, Parser};
use robfig_cron_rs::Job;
struct MyJob;
impl Job for MyJob {
fn run(&self) { println!("tick!"); }
// clone_box and as_any are required by the trait but the cron
// core uses an ArcJob adapter so they are never called on user jobs.
fn clone_box(&self) -> Box<dyn Job> { Box::new(MyJob) }
fn as_any(&self) -> &dyn std::any::Any { self }
}
fn main() {
let parser = Parser::new(
ParseOption::SECOND | ParseOption::MINUTE | ParseOption::HOUR
| ParseOption::DOM | ParseOption::MONTH | ParseOption::DOW_OPTIONAL
| ParseOption::DESCRIPTOR,
);
let cron = Cron::new(parser, chrono_tz::UTC, Chain::new(vec![]), logger::default_logger());
cron.add_func("* * * * * ?", Box::new(MyJob)).unwrap();
cron.start();
// ... run for a while ...
cron.stop();
}
Interactive TUI (cron-rs-cli)
The dashboard binary lives at src/bin/cli.rs and is gated behind the cli
Cargo feature (which pins ratatui + crossterm + rand as opt-in
dependencies, so the library build stays lean).
cargo run --release --features cli --bin cron-rs-cli
Keys:
| Key | Action |
|---|
t | run cargo test --test port and stream the summary |
i | focus the sandbox; Tab toggles between the expression and the RFC3339 "from" field; Enter computes Next() |
f | run a 10-second in-process fuzz session (uses the bundled Go oracle if available) |
b | run the four-metric benchmark and display the JSON |
q | quit |
Migration from Go
See DECISIONS.md for the 12 architectural divergences between the Go and
Rust implementations. Key API mappings:
| Go | Rust |
|---|
cron.New(opts...) | CronBuilder::new().with_...().build() |
c.AddFunc(spec, fn) | cron.add_func(spec, Box::new(job)) |
c.AddJob(spec, job) | cron.add_job(spec, job) |
c.Schedule(sched, job) | cron.schedule(sched, job) |
c.Start() | cron.start() |
c.Stop() context.Context | cron.stop() -> oneshot::Receiver<()> |
c.Entries() | cron.entries() |
c.Remove(id) | cron.remove(id) |
cron.Every(d) | ConstantDelaySchedule::new(d) |
cron.Recover(logger) | chain::recover(logger) |
cron.SkipIfStillRunning(logger) | chain::skip_if_still_running(logger) |
cron.DelayIfStillRunning(logger) | chain::delay_if_still_running(logger) |
Fuzz & Benchmarks
Differential fuzz (vs Go original):
The Go oracle source lives inside this repo at fuzz-helper/ (so the
submission is self-contained), but is fuzz infrastructure, not part of the
Rust port itself. To build it on the host:
# 1. Clone the original Go repo as a sibling of this port:
git clone --depth=1 --branch=v3.0.1 https://github.com/robfig/cron ../cron
# 2. Build the Go fuzz oracle:
( cd fuzz-helper && go build -o go-fuzz-helper . )
# 3. Run the differential fuzz harness (default 60s):
cargo run --release --features fuzz --bin fuzz_harness -- 60
fuzz/log.txt contains the 60-second run (204,425 cases, zero
unintended divergences; 30,663 intentional StepAboveMax rejections
from the issue #543 fix). fuzz/divergences.txt is only written if
unintended divergences are found. See DECISIONS.md for the
architectural rationale.
Go Helpers: fuzz-helper vs bench-helper
Both fuzz-helper/ and bench-helper/ are small Go programs that live
inside this repo but are infrastructure, not part of the Rust port.
They reference the original robfig/cron Go source via a replace
directive in their respective go.mod files (pointing to ../../cron).
Neither modifies the original repo. They serve different purposes:
| Helper | Directory | What it does | Output | Used by |
|---|
| fuzz-helper | fuzz-helper/ | Reads expr|RFC3339 lines from stdin, parses each with Go's cron parser, computes Next(), prints the result on stdout. | One result line per input line on stdout | fuzz/harness.rs (differential fuzz: pipes random cron expressions in, compares Go's Next() vs Rust's Next()) |
| bench-helper | bench-helper/ | Runs the same four benchmark metrics against Go's cron library (p99 tick, RSS, cold start, throughput) and prints a JSON object on stdout. | A single JSON object on stdout | bench/bench.rs (calls bench-helper as a subprocess, parses JSON, merges into side-by-side bench/results.json) |
Think of it this way: fuzz-helper answers "are the outputs the same?"
(correctness), while bench-helper answers "how fast is each one?"
(performance). Both are required by the hackathon rubric -- the Differential
Fuzz Survivor bonus needs the former, and the Benchmark deliverable
("original vs port, on a shared workload, with methodology") needs the
latter.
Build both before running the full verification suite:
git clone --depth=1 --branch=v3.0.1 https://github.com/robfig/cron ../cron
( cd fuzz-helper && go build -o go-fuzz-helper . )
( cd bench-helper && go build -o bench-helper . )
Benchmarks:
cargo run --release --features bench --bin bench_harness
See bench/methodology.md for measurement setup and bench/results.json
for recorded results. After the methodology fixes in this pass:
- tick lateness p50 ~1.25 ms / p99 ~1.7 ms (real lateness, not the
old catch-up spin artifact)
- parse throughput ~2.5 M ops/s (Rust now meets or exceeds Go after
dropping per-parse HashMap rebuilds and
Vec<String> churn)
- Next() throughput ~520k ops/s (chrono
DateTime<Tz> arithmetic is
heavier than Go time.Time; the risky spec.rs naive-arithmetic
rewrite is left for a follow-up)
- peak RSS ~4.5 MB, cold start ~9 µs
Soak test (Track E sustained-load proof)
The 25-second microbenchmark cannot show the Track E thesis (Rust's p99
stays predictable under sustained load, no GC stop-the-world pauses).
The soak harness can:
# 4-hour soak, 50ms period, progressive load (+20 jobs every 10 min)
./soak.sh
# or directly:
cargo run --release --features bench --bin soak_harness -- \
--duration 14400 --period-ms 50 --log-interval 60
Flags: --duration <s>, --period-ms <ms>, --entries N (initial jobs),
--ramp-entries N + --ramp-interval <s> (progressive load),
--log-interval <s>, --out PATH.
Output: bench/soak-results.json (summary) + bench/soak-results.log
(per-minute CSV: t_s, ticks, p50, p99, max, rss_kb).
Last 4h run: 64.5 M ticks across 461 progressively-added jobs, p99
lateness 2.27 ms — flat over the entire run, no GC spikes. RSS
climbed to ~500 MB; that is the harness's 64M-sample buffer
(8 B each), not a cron leak. A bounded-ring-buffer follow-up would
flatten this. See bench/methodology.md for the full writeup.
Docker
docker build -t robfig-cron-rs .
docker run --rm -it robfig-cron-rs # TUI dashboard
docker run --rm robfig-cron-rs cargo test --test port # integration tests
docker run --rm robfig-cron-rs ./bench_harness # benchmarks (Rust + Go side-by-side)
docker run --rm robfig-cron-rs ./fuzz_harness 60 # 60s differential fuzz
docker run --rm robfig-cron-rs ./soak_harness --duration 14400 --period-ms 50 # 4h soak
The image is multi-stage: Rust binaries and the Go fuzz oracle are both
prebuilt so the runtime image carries no toolchains (~80 MB total). The
original cron Go source is cloned at a pinned tag inside the Go build
stage; it is never modified.
License
MIT, same as the original robfig/cron.