pub struct CronScheduler { /* private fields */ }Expand description
Recurring scheduler glued to a base TimerWheel. After each fire
the scheduler computes the next deadline from the cron schedule
and re-arms.
Implementations§
Source§impl CronScheduler
impl CronScheduler
Sourcepub fn new(schedule: CronSchedule, now_epoch: u64) -> Self
pub fn new(schedule: CronSchedule, now_epoch: u64) -> Self
Examples found in repository?
examples/sample_app.rs (line 248)
241fn cron_risk_snapshot() {
242 use subms_timer_wheel::{CronSchedule, CronScheduler};
243 println!("\n== cron: mark-to-market every 5 minutes ==");
244 let schedule = CronSchedule::parse("*/5 * * * *").expect("valid cron");
245
246 // 2024-01-01 00:00:01 UTC. Next */5 boundary is 00:05:00.
247 let start = 1_704_067_201;
248 let mut scheduler = CronScheduler::new(schedule, start);
249
250 let first = scheduler.next_fire(start).expect("a next fire exists");
251 scheduler.record_fire(first);
252 let second = scheduler.next_fire(first).expect("a next fire exists");
253 println!(" first snapshot at epoch {first}, next at {second}");
254
255 assert_eq!(
256 first, 1_704_067_500,
257 "first fire lands on the 5-minute grid"
258 );
259 assert_eq!(second, first + 300, "re-arms exactly 5 minutes later");
260}More examples
examples/perf_features.rs (line 521)
247fn main() -> io::Result<()> {
248 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
249 .join("..")
250 .join(".subms")
251 .join("features")
252 .join("rust.json");
253 let existing = std::fs::read_to_string(&path).unwrap_or_default();
254 let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
255 // Stamp the box these numbers came from. The bench runs wherever it is
256 // invoked, so an unstamped manifest is indistinguishable from a fleet
257 // capture; the renderer will not publish one it cannot attribute.
258 let (source, instance) = SubMsP99Source::from_env();
259 manifest.set_p99_source(source, instance.as_deref());
260
261 // Diagnostic, not a feature: the base wheel's own tick. A single-level
262 // wheel decrements the rounds counter of every entry in the bucket it
263 // walks, fired or not, so its tick is O(resident/slots) - the cost the
264 // hierarchical feature exists to remove. Printed so the feature curves
265 // below have something to be read against.
266 sweep("base/tick", |n| {
267 drain(BATCH, base_wheel(n), |w| {
268 let _ = w.tick();
269 })
270 });
271
272 // ---------- hierarchical: cascade across three 64-slot wheels ----------
273 #[cfg(feature = "hierarchical")]
274 {
275 use subms_timer_wheel::HierarchicalTimerWheel;
276
277 fn hier(n: usize) -> HierarchicalTimerWheel<u32> {
278 let mut w: HierarchicalTimerWheel<u32> = HierarchicalTimerWheel::new();
279 load(&mut w, n, |w, d| {
280 w.schedule(d as u64, 0);
281 });
282 w
283 }
284
285 // Swept on `tick`, the op the feature transforms. The cascade is the
286 // expensive path and it fires on 1 tick in 64 (level 1) and 1 in 4096
287 // (level 2), so the measured window has to be long enough to contain
288 // both: 4096 timed ticks contains 64 level-1 cascades and one level-2.
289 //
290 // The curve is flat, and that is the correct reading rather than a
291 // hidden cost: a cascade moves the entries in ONE coarse bucket, which
292 // holds the timers due in the next 64 (level 1) or 4096 (level 2)
293 // ticks. With the due rate held fixed that bucket's size is fixed too,
294 // so resident timers further out cost the tick nothing. This is exactly
295 // what the level structure buys - the base wheel's own tick, printed
296 // above, walks resident/slots entries on EVERY tick.
297 let sw = sweep("hierarchical/tick", |n| {
298 drain(BATCH, hier(n), |w| {
299 let _ = w.tick();
300 })
301 });
302
303 // `cancel` is the O(resident) op the feature introduces. It has no
304 // id->slot index (the base wheel's index would need patching on every
305 // cascade) so it sweeps all 192 buckets and every entry in them.
306 // Cancelling a MISS walks all of them and is non-destructive, which is
307 // what makes it safe to repeat against one input.
308 sweep("hierarchical/cancel-miss", |n| {
309 bulk(hier(n), |w| {
310 let _ = w.cancel(u64::MAX);
311 })
312 });
313
314 // PINNED structural on the strength of `cancel`, not of the swept op.
315 // From the source, `HierarchicalTimerWheel::cancel` iterates
316 // LEVELS * SLOTS buckets and every entry in each until it matches, so
317 // it is O(resident) - measured 29x over a 16x sweep, 1.0 ms p99 at
318 // 524288 resident. The base wheel does not have that op shape: it keeps
319 // an id->slot map and cancels in O(bucket). Classifying the feature
320 // hot-path off a flat `tick` would tell a reader every op it introduces
321 // is safe per-operation, and one of them lands on the millisecond line
322 // at half a million timers.
323 let (cat, reason) = classify_feature(
324 &sw,
325 Some(base_p50()),
326 Some(subms::SubMsFeatureCategory::Structural),
327 );
328
329 let mut p99 = BTreeMap::new();
330 p99.insert(
331 "tick".to_string(),
332 drain(1, hier(CANON), |w| {
333 let _ = w.tick();
334 })
335 .p99,
336 );
337 p99.insert(
338 "schedule".to_string(),
339 {
340 let mut scratch: HierarchicalTimerWheel<u32> = HierarchicalTimerWheel::new();
341 let mut w = hier(CANON);
342 keyed(
343 1,
344 |i| {
345 scratch.schedule(resident_delay(i) as u64, 0);
346 },
347 |i| {
348 w.schedule(resident_delay(i) as u64, 0);
349 },
350 )
351 }
352 .p99,
353 );
354 p99.insert(
355 "cancel".to_string(),
356 bulk(hier(CANON), |w| {
357 let _ = w.cancel(u64::MAX);
358 })
359 .p99,
360 );
361 manifest.set_feature("hierarchical", cat, &p99, &reason);
362 }
363
364 // ---------- concurrent: short-mutex wrapper ----------
365 #[cfg(feature = "concurrent")]
366 {
367 use subms_timer_wheel::ConcurrentTimerWheel;
368
369 fn conc(n: usize) -> ConcurrentTimerWheel<u32> {
370 let mut w: ConcurrentTimerWheel<u32> = ConcurrentTimerWheel::new(SLOTS);
371 load(&mut w, n, |w, d| {
372 w.schedule(d, 0);
373 });
374 w
375 }
376
377 // Swept on `schedule` and measured single-threaded. The feature adds a
378 // lock acquire and release to every op; running it contended would
379 // measure the contention instead of the indirection, and the thread
380 // count would then be a second thing varying across the sweep.
381 let sw = sweep("concurrent/schedule", |n| {
382 let scratch: ConcurrentTimerWheel<u32> = ConcurrentTimerWheel::new(SLOTS);
383 let w = conc(n);
384 keyed(
385 BATCH,
386 |i| {
387 scratch.schedule(resident_delay(i), 0);
388 },
389 |i| {
390 w.schedule(resident_delay(i), 0);
391 },
392 )
393 });
394 let (cat, reason) = classify_feature(&sw, Some(base_p50()), None);
395
396 let mut p99 = BTreeMap::new();
397 p99.insert(
398 "schedule".to_string(),
399 {
400 let scratch: ConcurrentTimerWheel<u32> = ConcurrentTimerWheel::new(SLOTS);
401 let w = conc(CANON);
402 keyed(
403 1,
404 |i| {
405 scratch.schedule(resident_delay(i), 0);
406 },
407 |i| {
408 w.schedule(resident_delay(i), 0);
409 },
410 )
411 }
412 .p99,
413 );
414 p99.insert(
415 "tick".to_string(),
416 drain(1, conc(CANON), |w| {
417 let _ = w.tick();
418 })
419 .p99,
420 );
421 manifest.set_feature("concurrent", cat, &p99, &reason);
422 }
423
424 // ---------- deadline-scheduler: absolute deadlines over an injected clock ----------
425 #[cfg(feature = "deadline-scheduler")]
426 {
427 use std::cell::Cell;
428 use std::rc::Rc;
429 use std::time::Duration;
430 use subms_timer_wheel::{Clock, DeadlineScheduler};
431
432 /// Time only moves when the bench moves it. A free-running clock makes
433 /// `poll` tick however many ticks the host happened to take, which is
434 /// neither repeatable nor comparable across sweep points; a frozen one
435 /// makes `poll` a no-op and publishes an empty drain as the cost.
436 struct StepClock {
437 now: Cell<u64>,
438 step: Cell<u64>,
439 }
440 struct Shared(Rc<StepClock>);
441 impl Clock for Shared {
442 fn now_nanos(&self) -> u64 {
443 self.0.now.set(self.0.now.get() + self.0.step.get());
444 self.0.now.get()
445 }
446 }
447
448 fn sched(n: usize) -> (DeadlineScheduler<u32, Shared>, Rc<StepClock>) {
449 let clock = Rc::new(StepClock {
450 now: Cell::new(0),
451 step: Cell::new(0),
452 });
453 let mut s: DeadlineScheduler<u32, Shared> = DeadlineScheduler::new(
454 SLOTS,
455 Shared(Rc::clone(&clock)),
456 Duration::from_nanos(TICK_NS),
457 );
458 load(&mut s, n, |s, d| {
459 s.schedule_at(d as u64 * TICK_NS, 0);
460 });
461 (s, clock)
462 }
463
464 // Swept on `poll`, the op the layer introduces. With the clock stepped
465 // exactly one tick per call, a poll is one wheel tick plus the deadline
466 // arithmetic, so the sweep reads the drain the layer is driving.
467 let sw = sweep("deadline-scheduler/poll", |n| {
468 let (s, clock) = sched(n);
469 clock.step.set(TICK_NS);
470 drain(BATCH, s, |s| {
471 let _ = s.poll();
472 })
473 });
474 let (cat, reason) = classify_feature(&sw, Some(base_p50()), None);
475
476 let mut p99 = BTreeMap::new();
477 p99.insert(
478 "schedule_at".to_string(),
479 {
480 let (mut scratch, _sc) = sched(0);
481 let (mut s, _c) = sched(CANON);
482 keyed(
483 1,
484 |i| {
485 scratch.schedule_at(resident_delay(i) as u64 * TICK_NS, 0);
486 },
487 |i| {
488 s.schedule_at(resident_delay(i) as u64 * TICK_NS, 0);
489 },
490 )
491 }
492 .p99,
493 );
494 p99.insert(
495 "poll".to_string(),
496 {
497 let (s, clock) = sched(CANON);
498 clock.step.set(TICK_NS);
499 drain(1, s, |s| {
500 let _ = s.poll();
501 })
502 }
503 .p99,
504 );
505 manifest.set_feature("deadline-scheduler", cat, &p99, &reason);
506 }
507
508 // ---------- cron: 5-field expression parser + next-fire search ----------
509 #[cfg(feature = "cron")]
510 {
511 use subms_timer_wheel::{CronSchedule, CronScheduler};
512 const EXPR: &str = "*/5 * * * *";
513 const EPOCH0: u64 = 1_704_067_200;
514
515 // Swept on `next_fire`, the op the feature introduces. It searches
516 // forward minute by minute from a rolling epoch and never touches a
517 // wheel, so it is expected to read FLAT against resident timers - that
518 // is the correct result for this feature, not a broken sweep.
519 let sw = sweep("cron/next_fire", |_n| {
520 let mut warm =
521 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
522 let mut warm_epoch = EPOCH0;
523 let mut cs =
524 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
525 let mut epoch = EPOCH0;
526 keyed(
527 BATCH,
528 |_| {
529 if let Some(n) = warm.next_fire(warm_epoch) {
530 warm.record_fire(n);
531 warm_epoch = n;
532 }
533 },
534 |_| {
535 let next = cs.next_fire(epoch);
536 if let Some(n) = next {
537 cs.record_fire(n);
538 epoch = n;
539 }
540 },
541 )
542 });
543 let (cat, reason) = classify_feature(&sw, Some(base_p50()), None);
544
545 let mut p99 = BTreeMap::new();
546 p99.insert(
547 "parse".to_string(),
548 keyed(
549 1,
550 |_| {
551 let _ = CronSchedule::parse(EXPR);
552 },
553 |_| {
554 let _ = CronSchedule::parse(EXPR);
555 },
556 )
557 .p99,
558 );
559 p99.insert(
560 "next_fire".to_string(),
561 {
562 let mut cs =
563 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
564 let mut epoch = EPOCH0;
565 let mut warm =
566 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
567 let mut warm_epoch = EPOCH0;
568 keyed(
569 1,
570 |_| {
571 if let Some(n) = warm.next_fire(warm_epoch) {
572 warm.record_fire(n);
573 warm_epoch = n;
574 }
575 },
576 |_| {
577 let next = cs.next_fire(epoch);
578 if let Some(n) = next {
579 cs.record_fire(n);
580 epoch = n;
581 }
582 },
583 )
584 }
585 .p99,
586 );
587 manifest.set_feature("cron", cat, &p99, &reason);
588 }
589
590 // ---------- metrics: per-instance counters ----------
591 #[cfg(feature = "metrics")]
592 {
593 use subms_timer_wheel::MeteredTimerWheel;
594
595 fn metered(n: usize) -> MeteredTimerWheel<u32> {
596 let mut w: MeteredTimerWheel<u32> = MeteredTimerWheel::new(SLOTS);
597 load(&mut w, n, |w, d| {
598 w.schedule(d, 0);
599 });
600 w
601 }
602
603 // Swept on `schedule`. The counters are the feature and they sit on the
604 // per-op path; sweeping `tick` instead would measure the base wheel's
605 // bucket walk and attribute it to a pair of u64 increments. The tick
606 // number is still recorded below so it is visible.
607 let sw = sweep("metrics/schedule", |n| {
608 let mut scratch: MeteredTimerWheel<u32> = MeteredTimerWheel::new(SLOTS);
609 let mut w = metered(n);
610 keyed(
611 BATCH,
612 |i| {
613 scratch.schedule(resident_delay(i), 0);
614 },
615 |i| {
616 w.schedule(resident_delay(i), 0);
617 },
618 )
619 });
620 // PINNED auxiliary. From the source, `MeteredTimerWheel::schedule` is
621 // one non-atomic increment of an owned u64 field followed by the base
622 // call - no allocation, no branch, no lock. That is well under a
623 // nanosecond against a ~55 ns schedule, and nothing on this host
624 // resolves half a percent: the base op's own p50 spreads 3300-4400 ns
625 // per 64-op sample across runs, and the feature crossed the classifier's
626 // 10% band in both directions on four consecutive runs of unchanged
627 // code. Pinning states that a human read the source instead of
628 // publishing a coin toss as a measurement.
629 let (cat, reason) = classify_feature(
630 &sw,
631 Some(base_p50()),
632 Some(subms::SubMsFeatureCategory::Auxiliary),
633 );
634
635 let mut p99 = BTreeMap::new();
636 p99.insert(
637 "schedule".to_string(),
638 {
639 let mut scratch: MeteredTimerWheel<u32> = MeteredTimerWheel::new(SLOTS);
640 let mut w = metered(CANON);
641 keyed(
642 1,
643 |i| {
644 scratch.schedule(resident_delay(i), 0);
645 },
646 |i| {
647 w.schedule(resident_delay(i), 0);
648 },
649 )
650 }
651 .p99,
652 );
653 p99.insert(
654 "tick".to_string(),
655 drain(1, metered(CANON), |w| {
656 let _ = w.tick();
657 })
658 .p99,
659 );
660 manifest.set_feature("metrics", cat, &p99, &reason);
661 }
662
663 std::fs::create_dir_all(path.parent().unwrap())?;
664 std::fs::write(&path, manifest.to_json())?;
665 io::stdout().write_all(manifest.to_json().as_bytes())?;
666 Ok(())
667}pub fn schedule(&self) -> &CronSchedule
Sourcepub fn next_fire(&self, now_epoch: u64) -> Option<u64>
pub fn next_fire(&self, now_epoch: u64) -> Option<u64>
Epoch-second the schedule will next fire, given the current
epoch second. Returns None if no firing within the schedule’s
look-ahead horizon.
Examples found in repository?
examples/sample_app.rs (line 250)
241fn cron_risk_snapshot() {
242 use subms_timer_wheel::{CronSchedule, CronScheduler};
243 println!("\n== cron: mark-to-market every 5 minutes ==");
244 let schedule = CronSchedule::parse("*/5 * * * *").expect("valid cron");
245
246 // 2024-01-01 00:00:01 UTC. Next */5 boundary is 00:05:00.
247 let start = 1_704_067_201;
248 let mut scheduler = CronScheduler::new(schedule, start);
249
250 let first = scheduler.next_fire(start).expect("a next fire exists");
251 scheduler.record_fire(first);
252 let second = scheduler.next_fire(first).expect("a next fire exists");
253 println!(" first snapshot at epoch {first}, next at {second}");
254
255 assert_eq!(
256 first, 1_704_067_500,
257 "first fire lands on the 5-minute grid"
258 );
259 assert_eq!(second, first + 300, "re-arms exactly 5 minutes later");
260}More examples
examples/perf_features.rs (line 529)
247fn main() -> io::Result<()> {
248 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
249 .join("..")
250 .join(".subms")
251 .join("features")
252 .join("rust.json");
253 let existing = std::fs::read_to_string(&path).unwrap_or_default();
254 let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
255 // Stamp the box these numbers came from. The bench runs wherever it is
256 // invoked, so an unstamped manifest is indistinguishable from a fleet
257 // capture; the renderer will not publish one it cannot attribute.
258 let (source, instance) = SubMsP99Source::from_env();
259 manifest.set_p99_source(source, instance.as_deref());
260
261 // Diagnostic, not a feature: the base wheel's own tick. A single-level
262 // wheel decrements the rounds counter of every entry in the bucket it
263 // walks, fired or not, so its tick is O(resident/slots) - the cost the
264 // hierarchical feature exists to remove. Printed so the feature curves
265 // below have something to be read against.
266 sweep("base/tick", |n| {
267 drain(BATCH, base_wheel(n), |w| {
268 let _ = w.tick();
269 })
270 });
271
272 // ---------- hierarchical: cascade across three 64-slot wheels ----------
273 #[cfg(feature = "hierarchical")]
274 {
275 use subms_timer_wheel::HierarchicalTimerWheel;
276
277 fn hier(n: usize) -> HierarchicalTimerWheel<u32> {
278 let mut w: HierarchicalTimerWheel<u32> = HierarchicalTimerWheel::new();
279 load(&mut w, n, |w, d| {
280 w.schedule(d as u64, 0);
281 });
282 w
283 }
284
285 // Swept on `tick`, the op the feature transforms. The cascade is the
286 // expensive path and it fires on 1 tick in 64 (level 1) and 1 in 4096
287 // (level 2), so the measured window has to be long enough to contain
288 // both: 4096 timed ticks contains 64 level-1 cascades and one level-2.
289 //
290 // The curve is flat, and that is the correct reading rather than a
291 // hidden cost: a cascade moves the entries in ONE coarse bucket, which
292 // holds the timers due in the next 64 (level 1) or 4096 (level 2)
293 // ticks. With the due rate held fixed that bucket's size is fixed too,
294 // so resident timers further out cost the tick nothing. This is exactly
295 // what the level structure buys - the base wheel's own tick, printed
296 // above, walks resident/slots entries on EVERY tick.
297 let sw = sweep("hierarchical/tick", |n| {
298 drain(BATCH, hier(n), |w| {
299 let _ = w.tick();
300 })
301 });
302
303 // `cancel` is the O(resident) op the feature introduces. It has no
304 // id->slot index (the base wheel's index would need patching on every
305 // cascade) so it sweeps all 192 buckets and every entry in them.
306 // Cancelling a MISS walks all of them and is non-destructive, which is
307 // what makes it safe to repeat against one input.
308 sweep("hierarchical/cancel-miss", |n| {
309 bulk(hier(n), |w| {
310 let _ = w.cancel(u64::MAX);
311 })
312 });
313
314 // PINNED structural on the strength of `cancel`, not of the swept op.
315 // From the source, `HierarchicalTimerWheel::cancel` iterates
316 // LEVELS * SLOTS buckets and every entry in each until it matches, so
317 // it is O(resident) - measured 29x over a 16x sweep, 1.0 ms p99 at
318 // 524288 resident. The base wheel does not have that op shape: it keeps
319 // an id->slot map and cancels in O(bucket). Classifying the feature
320 // hot-path off a flat `tick` would tell a reader every op it introduces
321 // is safe per-operation, and one of them lands on the millisecond line
322 // at half a million timers.
323 let (cat, reason) = classify_feature(
324 &sw,
325 Some(base_p50()),
326 Some(subms::SubMsFeatureCategory::Structural),
327 );
328
329 let mut p99 = BTreeMap::new();
330 p99.insert(
331 "tick".to_string(),
332 drain(1, hier(CANON), |w| {
333 let _ = w.tick();
334 })
335 .p99,
336 );
337 p99.insert(
338 "schedule".to_string(),
339 {
340 let mut scratch: HierarchicalTimerWheel<u32> = HierarchicalTimerWheel::new();
341 let mut w = hier(CANON);
342 keyed(
343 1,
344 |i| {
345 scratch.schedule(resident_delay(i) as u64, 0);
346 },
347 |i| {
348 w.schedule(resident_delay(i) as u64, 0);
349 },
350 )
351 }
352 .p99,
353 );
354 p99.insert(
355 "cancel".to_string(),
356 bulk(hier(CANON), |w| {
357 let _ = w.cancel(u64::MAX);
358 })
359 .p99,
360 );
361 manifest.set_feature("hierarchical", cat, &p99, &reason);
362 }
363
364 // ---------- concurrent: short-mutex wrapper ----------
365 #[cfg(feature = "concurrent")]
366 {
367 use subms_timer_wheel::ConcurrentTimerWheel;
368
369 fn conc(n: usize) -> ConcurrentTimerWheel<u32> {
370 let mut w: ConcurrentTimerWheel<u32> = ConcurrentTimerWheel::new(SLOTS);
371 load(&mut w, n, |w, d| {
372 w.schedule(d, 0);
373 });
374 w
375 }
376
377 // Swept on `schedule` and measured single-threaded. The feature adds a
378 // lock acquire and release to every op; running it contended would
379 // measure the contention instead of the indirection, and the thread
380 // count would then be a second thing varying across the sweep.
381 let sw = sweep("concurrent/schedule", |n| {
382 let scratch: ConcurrentTimerWheel<u32> = ConcurrentTimerWheel::new(SLOTS);
383 let w = conc(n);
384 keyed(
385 BATCH,
386 |i| {
387 scratch.schedule(resident_delay(i), 0);
388 },
389 |i| {
390 w.schedule(resident_delay(i), 0);
391 },
392 )
393 });
394 let (cat, reason) = classify_feature(&sw, Some(base_p50()), None);
395
396 let mut p99 = BTreeMap::new();
397 p99.insert(
398 "schedule".to_string(),
399 {
400 let scratch: ConcurrentTimerWheel<u32> = ConcurrentTimerWheel::new(SLOTS);
401 let w = conc(CANON);
402 keyed(
403 1,
404 |i| {
405 scratch.schedule(resident_delay(i), 0);
406 },
407 |i| {
408 w.schedule(resident_delay(i), 0);
409 },
410 )
411 }
412 .p99,
413 );
414 p99.insert(
415 "tick".to_string(),
416 drain(1, conc(CANON), |w| {
417 let _ = w.tick();
418 })
419 .p99,
420 );
421 manifest.set_feature("concurrent", cat, &p99, &reason);
422 }
423
424 // ---------- deadline-scheduler: absolute deadlines over an injected clock ----------
425 #[cfg(feature = "deadline-scheduler")]
426 {
427 use std::cell::Cell;
428 use std::rc::Rc;
429 use std::time::Duration;
430 use subms_timer_wheel::{Clock, DeadlineScheduler};
431
432 /// Time only moves when the bench moves it. A free-running clock makes
433 /// `poll` tick however many ticks the host happened to take, which is
434 /// neither repeatable nor comparable across sweep points; a frozen one
435 /// makes `poll` a no-op and publishes an empty drain as the cost.
436 struct StepClock {
437 now: Cell<u64>,
438 step: Cell<u64>,
439 }
440 struct Shared(Rc<StepClock>);
441 impl Clock for Shared {
442 fn now_nanos(&self) -> u64 {
443 self.0.now.set(self.0.now.get() + self.0.step.get());
444 self.0.now.get()
445 }
446 }
447
448 fn sched(n: usize) -> (DeadlineScheduler<u32, Shared>, Rc<StepClock>) {
449 let clock = Rc::new(StepClock {
450 now: Cell::new(0),
451 step: Cell::new(0),
452 });
453 let mut s: DeadlineScheduler<u32, Shared> = DeadlineScheduler::new(
454 SLOTS,
455 Shared(Rc::clone(&clock)),
456 Duration::from_nanos(TICK_NS),
457 );
458 load(&mut s, n, |s, d| {
459 s.schedule_at(d as u64 * TICK_NS, 0);
460 });
461 (s, clock)
462 }
463
464 // Swept on `poll`, the op the layer introduces. With the clock stepped
465 // exactly one tick per call, a poll is one wheel tick plus the deadline
466 // arithmetic, so the sweep reads the drain the layer is driving.
467 let sw = sweep("deadline-scheduler/poll", |n| {
468 let (s, clock) = sched(n);
469 clock.step.set(TICK_NS);
470 drain(BATCH, s, |s| {
471 let _ = s.poll();
472 })
473 });
474 let (cat, reason) = classify_feature(&sw, Some(base_p50()), None);
475
476 let mut p99 = BTreeMap::new();
477 p99.insert(
478 "schedule_at".to_string(),
479 {
480 let (mut scratch, _sc) = sched(0);
481 let (mut s, _c) = sched(CANON);
482 keyed(
483 1,
484 |i| {
485 scratch.schedule_at(resident_delay(i) as u64 * TICK_NS, 0);
486 },
487 |i| {
488 s.schedule_at(resident_delay(i) as u64 * TICK_NS, 0);
489 },
490 )
491 }
492 .p99,
493 );
494 p99.insert(
495 "poll".to_string(),
496 {
497 let (s, clock) = sched(CANON);
498 clock.step.set(TICK_NS);
499 drain(1, s, |s| {
500 let _ = s.poll();
501 })
502 }
503 .p99,
504 );
505 manifest.set_feature("deadline-scheduler", cat, &p99, &reason);
506 }
507
508 // ---------- cron: 5-field expression parser + next-fire search ----------
509 #[cfg(feature = "cron")]
510 {
511 use subms_timer_wheel::{CronSchedule, CronScheduler};
512 const EXPR: &str = "*/5 * * * *";
513 const EPOCH0: u64 = 1_704_067_200;
514
515 // Swept on `next_fire`, the op the feature introduces. It searches
516 // forward minute by minute from a rolling epoch and never touches a
517 // wheel, so it is expected to read FLAT against resident timers - that
518 // is the correct result for this feature, not a broken sweep.
519 let sw = sweep("cron/next_fire", |_n| {
520 let mut warm =
521 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
522 let mut warm_epoch = EPOCH0;
523 let mut cs =
524 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
525 let mut epoch = EPOCH0;
526 keyed(
527 BATCH,
528 |_| {
529 if let Some(n) = warm.next_fire(warm_epoch) {
530 warm.record_fire(n);
531 warm_epoch = n;
532 }
533 },
534 |_| {
535 let next = cs.next_fire(epoch);
536 if let Some(n) = next {
537 cs.record_fire(n);
538 epoch = n;
539 }
540 },
541 )
542 });
543 let (cat, reason) = classify_feature(&sw, Some(base_p50()), None);
544
545 let mut p99 = BTreeMap::new();
546 p99.insert(
547 "parse".to_string(),
548 keyed(
549 1,
550 |_| {
551 let _ = CronSchedule::parse(EXPR);
552 },
553 |_| {
554 let _ = CronSchedule::parse(EXPR);
555 },
556 )
557 .p99,
558 );
559 p99.insert(
560 "next_fire".to_string(),
561 {
562 let mut cs =
563 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
564 let mut epoch = EPOCH0;
565 let mut warm =
566 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
567 let mut warm_epoch = EPOCH0;
568 keyed(
569 1,
570 |_| {
571 if let Some(n) = warm.next_fire(warm_epoch) {
572 warm.record_fire(n);
573 warm_epoch = n;
574 }
575 },
576 |_| {
577 let next = cs.next_fire(epoch);
578 if let Some(n) = next {
579 cs.record_fire(n);
580 epoch = n;
581 }
582 },
583 )
584 }
585 .p99,
586 );
587 manifest.set_feature("cron", cat, &p99, &reason);
588 }
589
590 // ---------- metrics: per-instance counters ----------
591 #[cfg(feature = "metrics")]
592 {
593 use subms_timer_wheel::MeteredTimerWheel;
594
595 fn metered(n: usize) -> MeteredTimerWheel<u32> {
596 let mut w: MeteredTimerWheel<u32> = MeteredTimerWheel::new(SLOTS);
597 load(&mut w, n, |w, d| {
598 w.schedule(d, 0);
599 });
600 w
601 }
602
603 // Swept on `schedule`. The counters are the feature and they sit on the
604 // per-op path; sweeping `tick` instead would measure the base wheel's
605 // bucket walk and attribute it to a pair of u64 increments. The tick
606 // number is still recorded below so it is visible.
607 let sw = sweep("metrics/schedule", |n| {
608 let mut scratch: MeteredTimerWheel<u32> = MeteredTimerWheel::new(SLOTS);
609 let mut w = metered(n);
610 keyed(
611 BATCH,
612 |i| {
613 scratch.schedule(resident_delay(i), 0);
614 },
615 |i| {
616 w.schedule(resident_delay(i), 0);
617 },
618 )
619 });
620 // PINNED auxiliary. From the source, `MeteredTimerWheel::schedule` is
621 // one non-atomic increment of an owned u64 field followed by the base
622 // call - no allocation, no branch, no lock. That is well under a
623 // nanosecond against a ~55 ns schedule, and nothing on this host
624 // resolves half a percent: the base op's own p50 spreads 3300-4400 ns
625 // per 64-op sample across runs, and the feature crossed the classifier's
626 // 10% band in both directions on four consecutive runs of unchanged
627 // code. Pinning states that a human read the source instead of
628 // publishing a coin toss as a measurement.
629 let (cat, reason) = classify_feature(
630 &sw,
631 Some(base_p50()),
632 Some(subms::SubMsFeatureCategory::Auxiliary),
633 );
634
635 let mut p99 = BTreeMap::new();
636 p99.insert(
637 "schedule".to_string(),
638 {
639 let mut scratch: MeteredTimerWheel<u32> = MeteredTimerWheel::new(SLOTS);
640 let mut w = metered(CANON);
641 keyed(
642 1,
643 |i| {
644 scratch.schedule(resident_delay(i), 0);
645 },
646 |i| {
647 w.schedule(resident_delay(i), 0);
648 },
649 )
650 }
651 .p99,
652 );
653 p99.insert(
654 "tick".to_string(),
655 drain(1, metered(CANON), |w| {
656 let _ = w.tick();
657 })
658 .p99,
659 );
660 manifest.set_feature("metrics", cat, &p99, &reason);
661 }
662
663 std::fs::create_dir_all(path.parent().unwrap())?;
664 std::fs::write(&path, manifest.to_json())?;
665 io::stdout().write_all(manifest.to_json().as_bytes())?;
666 Ok(())
667}Sourcepub fn record_fire(&mut self, epoch: u64)
pub fn record_fire(&mut self, epoch: u64)
Mark a fire at epoch consumed; next call to next_fire will
look past it.
Examples found in repository?
examples/sample_app.rs (line 251)
241fn cron_risk_snapshot() {
242 use subms_timer_wheel::{CronSchedule, CronScheduler};
243 println!("\n== cron: mark-to-market every 5 minutes ==");
244 let schedule = CronSchedule::parse("*/5 * * * *").expect("valid cron");
245
246 // 2024-01-01 00:00:01 UTC. Next */5 boundary is 00:05:00.
247 let start = 1_704_067_201;
248 let mut scheduler = CronScheduler::new(schedule, start);
249
250 let first = scheduler.next_fire(start).expect("a next fire exists");
251 scheduler.record_fire(first);
252 let second = scheduler.next_fire(first).expect("a next fire exists");
253 println!(" first snapshot at epoch {first}, next at {second}");
254
255 assert_eq!(
256 first, 1_704_067_500,
257 "first fire lands on the 5-minute grid"
258 );
259 assert_eq!(second, first + 300, "re-arms exactly 5 minutes later");
260}More examples
examples/perf_features.rs (line 530)
247fn main() -> io::Result<()> {
248 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
249 .join("..")
250 .join(".subms")
251 .join("features")
252 .join("rust.json");
253 let existing = std::fs::read_to_string(&path).unwrap_or_default();
254 let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
255 // Stamp the box these numbers came from. The bench runs wherever it is
256 // invoked, so an unstamped manifest is indistinguishable from a fleet
257 // capture; the renderer will not publish one it cannot attribute.
258 let (source, instance) = SubMsP99Source::from_env();
259 manifest.set_p99_source(source, instance.as_deref());
260
261 // Diagnostic, not a feature: the base wheel's own tick. A single-level
262 // wheel decrements the rounds counter of every entry in the bucket it
263 // walks, fired or not, so its tick is O(resident/slots) - the cost the
264 // hierarchical feature exists to remove. Printed so the feature curves
265 // below have something to be read against.
266 sweep("base/tick", |n| {
267 drain(BATCH, base_wheel(n), |w| {
268 let _ = w.tick();
269 })
270 });
271
272 // ---------- hierarchical: cascade across three 64-slot wheels ----------
273 #[cfg(feature = "hierarchical")]
274 {
275 use subms_timer_wheel::HierarchicalTimerWheel;
276
277 fn hier(n: usize) -> HierarchicalTimerWheel<u32> {
278 let mut w: HierarchicalTimerWheel<u32> = HierarchicalTimerWheel::new();
279 load(&mut w, n, |w, d| {
280 w.schedule(d as u64, 0);
281 });
282 w
283 }
284
285 // Swept on `tick`, the op the feature transforms. The cascade is the
286 // expensive path and it fires on 1 tick in 64 (level 1) and 1 in 4096
287 // (level 2), so the measured window has to be long enough to contain
288 // both: 4096 timed ticks contains 64 level-1 cascades and one level-2.
289 //
290 // The curve is flat, and that is the correct reading rather than a
291 // hidden cost: a cascade moves the entries in ONE coarse bucket, which
292 // holds the timers due in the next 64 (level 1) or 4096 (level 2)
293 // ticks. With the due rate held fixed that bucket's size is fixed too,
294 // so resident timers further out cost the tick nothing. This is exactly
295 // what the level structure buys - the base wheel's own tick, printed
296 // above, walks resident/slots entries on EVERY tick.
297 let sw = sweep("hierarchical/tick", |n| {
298 drain(BATCH, hier(n), |w| {
299 let _ = w.tick();
300 })
301 });
302
303 // `cancel` is the O(resident) op the feature introduces. It has no
304 // id->slot index (the base wheel's index would need patching on every
305 // cascade) so it sweeps all 192 buckets and every entry in them.
306 // Cancelling a MISS walks all of them and is non-destructive, which is
307 // what makes it safe to repeat against one input.
308 sweep("hierarchical/cancel-miss", |n| {
309 bulk(hier(n), |w| {
310 let _ = w.cancel(u64::MAX);
311 })
312 });
313
314 // PINNED structural on the strength of `cancel`, not of the swept op.
315 // From the source, `HierarchicalTimerWheel::cancel` iterates
316 // LEVELS * SLOTS buckets and every entry in each until it matches, so
317 // it is O(resident) - measured 29x over a 16x sweep, 1.0 ms p99 at
318 // 524288 resident. The base wheel does not have that op shape: it keeps
319 // an id->slot map and cancels in O(bucket). Classifying the feature
320 // hot-path off a flat `tick` would tell a reader every op it introduces
321 // is safe per-operation, and one of them lands on the millisecond line
322 // at half a million timers.
323 let (cat, reason) = classify_feature(
324 &sw,
325 Some(base_p50()),
326 Some(subms::SubMsFeatureCategory::Structural),
327 );
328
329 let mut p99 = BTreeMap::new();
330 p99.insert(
331 "tick".to_string(),
332 drain(1, hier(CANON), |w| {
333 let _ = w.tick();
334 })
335 .p99,
336 );
337 p99.insert(
338 "schedule".to_string(),
339 {
340 let mut scratch: HierarchicalTimerWheel<u32> = HierarchicalTimerWheel::new();
341 let mut w = hier(CANON);
342 keyed(
343 1,
344 |i| {
345 scratch.schedule(resident_delay(i) as u64, 0);
346 },
347 |i| {
348 w.schedule(resident_delay(i) as u64, 0);
349 },
350 )
351 }
352 .p99,
353 );
354 p99.insert(
355 "cancel".to_string(),
356 bulk(hier(CANON), |w| {
357 let _ = w.cancel(u64::MAX);
358 })
359 .p99,
360 );
361 manifest.set_feature("hierarchical", cat, &p99, &reason);
362 }
363
364 // ---------- concurrent: short-mutex wrapper ----------
365 #[cfg(feature = "concurrent")]
366 {
367 use subms_timer_wheel::ConcurrentTimerWheel;
368
369 fn conc(n: usize) -> ConcurrentTimerWheel<u32> {
370 let mut w: ConcurrentTimerWheel<u32> = ConcurrentTimerWheel::new(SLOTS);
371 load(&mut w, n, |w, d| {
372 w.schedule(d, 0);
373 });
374 w
375 }
376
377 // Swept on `schedule` and measured single-threaded. The feature adds a
378 // lock acquire and release to every op; running it contended would
379 // measure the contention instead of the indirection, and the thread
380 // count would then be a second thing varying across the sweep.
381 let sw = sweep("concurrent/schedule", |n| {
382 let scratch: ConcurrentTimerWheel<u32> = ConcurrentTimerWheel::new(SLOTS);
383 let w = conc(n);
384 keyed(
385 BATCH,
386 |i| {
387 scratch.schedule(resident_delay(i), 0);
388 },
389 |i| {
390 w.schedule(resident_delay(i), 0);
391 },
392 )
393 });
394 let (cat, reason) = classify_feature(&sw, Some(base_p50()), None);
395
396 let mut p99 = BTreeMap::new();
397 p99.insert(
398 "schedule".to_string(),
399 {
400 let scratch: ConcurrentTimerWheel<u32> = ConcurrentTimerWheel::new(SLOTS);
401 let w = conc(CANON);
402 keyed(
403 1,
404 |i| {
405 scratch.schedule(resident_delay(i), 0);
406 },
407 |i| {
408 w.schedule(resident_delay(i), 0);
409 },
410 )
411 }
412 .p99,
413 );
414 p99.insert(
415 "tick".to_string(),
416 drain(1, conc(CANON), |w| {
417 let _ = w.tick();
418 })
419 .p99,
420 );
421 manifest.set_feature("concurrent", cat, &p99, &reason);
422 }
423
424 // ---------- deadline-scheduler: absolute deadlines over an injected clock ----------
425 #[cfg(feature = "deadline-scheduler")]
426 {
427 use std::cell::Cell;
428 use std::rc::Rc;
429 use std::time::Duration;
430 use subms_timer_wheel::{Clock, DeadlineScheduler};
431
432 /// Time only moves when the bench moves it. A free-running clock makes
433 /// `poll` tick however many ticks the host happened to take, which is
434 /// neither repeatable nor comparable across sweep points; a frozen one
435 /// makes `poll` a no-op and publishes an empty drain as the cost.
436 struct StepClock {
437 now: Cell<u64>,
438 step: Cell<u64>,
439 }
440 struct Shared(Rc<StepClock>);
441 impl Clock for Shared {
442 fn now_nanos(&self) -> u64 {
443 self.0.now.set(self.0.now.get() + self.0.step.get());
444 self.0.now.get()
445 }
446 }
447
448 fn sched(n: usize) -> (DeadlineScheduler<u32, Shared>, Rc<StepClock>) {
449 let clock = Rc::new(StepClock {
450 now: Cell::new(0),
451 step: Cell::new(0),
452 });
453 let mut s: DeadlineScheduler<u32, Shared> = DeadlineScheduler::new(
454 SLOTS,
455 Shared(Rc::clone(&clock)),
456 Duration::from_nanos(TICK_NS),
457 );
458 load(&mut s, n, |s, d| {
459 s.schedule_at(d as u64 * TICK_NS, 0);
460 });
461 (s, clock)
462 }
463
464 // Swept on `poll`, the op the layer introduces. With the clock stepped
465 // exactly one tick per call, a poll is one wheel tick plus the deadline
466 // arithmetic, so the sweep reads the drain the layer is driving.
467 let sw = sweep("deadline-scheduler/poll", |n| {
468 let (s, clock) = sched(n);
469 clock.step.set(TICK_NS);
470 drain(BATCH, s, |s| {
471 let _ = s.poll();
472 })
473 });
474 let (cat, reason) = classify_feature(&sw, Some(base_p50()), None);
475
476 let mut p99 = BTreeMap::new();
477 p99.insert(
478 "schedule_at".to_string(),
479 {
480 let (mut scratch, _sc) = sched(0);
481 let (mut s, _c) = sched(CANON);
482 keyed(
483 1,
484 |i| {
485 scratch.schedule_at(resident_delay(i) as u64 * TICK_NS, 0);
486 },
487 |i| {
488 s.schedule_at(resident_delay(i) as u64 * TICK_NS, 0);
489 },
490 )
491 }
492 .p99,
493 );
494 p99.insert(
495 "poll".to_string(),
496 {
497 let (s, clock) = sched(CANON);
498 clock.step.set(TICK_NS);
499 drain(1, s, |s| {
500 let _ = s.poll();
501 })
502 }
503 .p99,
504 );
505 manifest.set_feature("deadline-scheduler", cat, &p99, &reason);
506 }
507
508 // ---------- cron: 5-field expression parser + next-fire search ----------
509 #[cfg(feature = "cron")]
510 {
511 use subms_timer_wheel::{CronSchedule, CronScheduler};
512 const EXPR: &str = "*/5 * * * *";
513 const EPOCH0: u64 = 1_704_067_200;
514
515 // Swept on `next_fire`, the op the feature introduces. It searches
516 // forward minute by minute from a rolling epoch and never touches a
517 // wheel, so it is expected to read FLAT against resident timers - that
518 // is the correct result for this feature, not a broken sweep.
519 let sw = sweep("cron/next_fire", |_n| {
520 let mut warm =
521 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
522 let mut warm_epoch = EPOCH0;
523 let mut cs =
524 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
525 let mut epoch = EPOCH0;
526 keyed(
527 BATCH,
528 |_| {
529 if let Some(n) = warm.next_fire(warm_epoch) {
530 warm.record_fire(n);
531 warm_epoch = n;
532 }
533 },
534 |_| {
535 let next = cs.next_fire(epoch);
536 if let Some(n) = next {
537 cs.record_fire(n);
538 epoch = n;
539 }
540 },
541 )
542 });
543 let (cat, reason) = classify_feature(&sw, Some(base_p50()), None);
544
545 let mut p99 = BTreeMap::new();
546 p99.insert(
547 "parse".to_string(),
548 keyed(
549 1,
550 |_| {
551 let _ = CronSchedule::parse(EXPR);
552 },
553 |_| {
554 let _ = CronSchedule::parse(EXPR);
555 },
556 )
557 .p99,
558 );
559 p99.insert(
560 "next_fire".to_string(),
561 {
562 let mut cs =
563 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
564 let mut epoch = EPOCH0;
565 let mut warm =
566 CronScheduler::new(CronSchedule::parse(EXPR).expect("valid cron expr"), EPOCH0);
567 let mut warm_epoch = EPOCH0;
568 keyed(
569 1,
570 |_| {
571 if let Some(n) = warm.next_fire(warm_epoch) {
572 warm.record_fire(n);
573 warm_epoch = n;
574 }
575 },
576 |_| {
577 let next = cs.next_fire(epoch);
578 if let Some(n) = next {
579 cs.record_fire(n);
580 epoch = n;
581 }
582 },
583 )
584 }
585 .p99,
586 );
587 manifest.set_feature("cron", cat, &p99, &reason);
588 }
589
590 // ---------- metrics: per-instance counters ----------
591 #[cfg(feature = "metrics")]
592 {
593 use subms_timer_wheel::MeteredTimerWheel;
594
595 fn metered(n: usize) -> MeteredTimerWheel<u32> {
596 let mut w: MeteredTimerWheel<u32> = MeteredTimerWheel::new(SLOTS);
597 load(&mut w, n, |w, d| {
598 w.schedule(d, 0);
599 });
600 w
601 }
602
603 // Swept on `schedule`. The counters are the feature and they sit on the
604 // per-op path; sweeping `tick` instead would measure the base wheel's
605 // bucket walk and attribute it to a pair of u64 increments. The tick
606 // number is still recorded below so it is visible.
607 let sw = sweep("metrics/schedule", |n| {
608 let mut scratch: MeteredTimerWheel<u32> = MeteredTimerWheel::new(SLOTS);
609 let mut w = metered(n);
610 keyed(
611 BATCH,
612 |i| {
613 scratch.schedule(resident_delay(i), 0);
614 },
615 |i| {
616 w.schedule(resident_delay(i), 0);
617 },
618 )
619 });
620 // PINNED auxiliary. From the source, `MeteredTimerWheel::schedule` is
621 // one non-atomic increment of an owned u64 field followed by the base
622 // call - no allocation, no branch, no lock. That is well under a
623 // nanosecond against a ~55 ns schedule, and nothing on this host
624 // resolves half a percent: the base op's own p50 spreads 3300-4400 ns
625 // per 64-op sample across runs, and the feature crossed the classifier's
626 // 10% band in both directions on four consecutive runs of unchanged
627 // code. Pinning states that a human read the source instead of
628 // publishing a coin toss as a measurement.
629 let (cat, reason) = classify_feature(
630 &sw,
631 Some(base_p50()),
632 Some(subms::SubMsFeatureCategory::Auxiliary),
633 );
634
635 let mut p99 = BTreeMap::new();
636 p99.insert(
637 "schedule".to_string(),
638 {
639 let mut scratch: MeteredTimerWheel<u32> = MeteredTimerWheel::new(SLOTS);
640 let mut w = metered(CANON);
641 keyed(
642 1,
643 |i| {
644 scratch.schedule(resident_delay(i), 0);
645 },
646 |i| {
647 w.schedule(resident_delay(i), 0);
648 },
649 )
650 }
651 .p99,
652 );
653 p99.insert(
654 "tick".to_string(),
655 drain(1, metered(CANON), |w| {
656 let _ = w.tick();
657 })
658 .p99,
659 );
660 manifest.set_feature("metrics", cat, &p99, &reason);
661 }
662
663 std::fs::create_dir_all(path.parent().unwrap())?;
664 std::fs::write(&path, manifest.to_json())?;
665 io::stdout().write_all(manifest.to_json().as_bytes())?;
666 Ok(())
667}Auto Trait Implementations§
impl Freeze for CronScheduler
impl RefUnwindSafe for CronScheduler
impl Send for CronScheduler
impl Sync for CronScheduler
impl Unpin for CronScheduler
impl UnsafeUnpin for CronScheduler
impl UnwindSafe for CronScheduler
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Mutably borrows from an owned value. Read more