Eight closed tasks for epochs and compaction, then ten for the scheduler, ToolSummary, and tiers. Given tests first. Gate 1456 → 1507 → 1572. Co-Authored-By: Grok 4.6 <noreply@x.ai>
193 lines
7.1 KiB
Markdown
193 lines
7.1 KiB
Markdown
# M5d-2 task 04: five-field UTC cron matcher
|
||
|
||
**Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short`
|
||
is empty; otherwise stop. Never switch branch.
|
||
**Commit subject:** `loopd: five-field UTC cron matcher`
|
||
**Log row:** the Task cell is `M5d2/04-loopd-cron`.
|
||
|
||
## Goal
|
||
|
||
`loopd::cron::due(schedule, Timestamp) -> Result<bool, CronError>` (spec 4.2). Five fields:
|
||
minute hour day-of-month month day-of-week. Each field is `*`, a single number, `n-m`, or
|
||
`*/n`. Day-of-week `0` is Sunday. No cron crate. DOM and DOW are AND (README known limits).
|
||
|
||
Nothing loads job files yet.
|
||
|
||
## Every place that changes
|
||
|
||
| Place | What happens there |
|
||
|---|---|
|
||
| `crates/loopd/src/cron.rs` | new: `due`, `CronError` |
|
||
| `crates/loopd/src/lib.rs` | `pub mod cron;` |
|
||
| `crates/loopd/tests/cron.rs` | new, given |
|
||
| everything else | **no change** |
|
||
|
||
## Files
|
||
|
||
- Copy, from `docs/plans/M5d2/files/` (the same path under it): the new test
|
||
`crates/loopd/tests/cron.rs`.
|
||
- Create: `crates/loopd/src/cron.rs` (skeleton below).
|
||
- Modify: `crates/loopd/src/lib.rs`, `docs/implementer-log.md`.
|
||
- Do not touch: any other file.
|
||
|
||
## Facts you need
|
||
|
||
- **Civil fields from RFC 3339.** `Timestamp::to_rfc3339` is `YYYY-MM-DDTHH:MM:SS.mmmZ`.
|
||
Minute = `rfc[14..16]`, hour = `rfc[11..13]`, day = `rfc[8..10]`, month = `rfc[5..7]`,
|
||
parsed as `u32`. If a slice is missing, `CronError::Fields` naming the schedule.
|
||
- **Weekday.** `0` is Sunday. `(unix_millis() / 1000 / 86400 + 4) % 7` as `u32` (`1970-01-01`
|
||
was Thursday, so `+ 4`). `unix_millis` is `pub` on `Timestamp`.
|
||
- **Ranges.** Inclusive. `*/n` matches when `value % n == 0` (multiples from 0). `n == 0` in
|
||
a step is `Fields`.
|
||
- **Bounds.** Minute 0–59, hour 0–23, DOM 1–31, month 1–12, DOW 0–6. A number outside its
|
||
field's range is `Fields`. Four or six fields, `@daily`, and any other token are `Fields`.
|
||
- **`Display`.** Names the schedule, e.g. contains `x x x x x` when that was the input. Do not
|
||
add a runbook pointer; this error is used by job loading in task 05.
|
||
- **Split.** `split_whitespace`. Five pieces or `Fields`.
|
||
- rustfmt decides `mod` order in `lib.rs`. After `pub mod compact;` alphabetically `cron`
|
||
comes after `config` and before `http`. Insert where rustfmt will put it; run `cargo fmt`
|
||
before the gate.
|
||
- The items the edits use. This table is complete.
|
||
|
||
| Item | What it does |
|
||
|---|---|
|
||
| `Timestamp::to_rfc3339` | civil fields |
|
||
| `Timestamp::unix_millis` | weekday |
|
||
| `str::parse::<u32>` | numbers |
|
||
|
||
## Skeleton
|
||
|
||
`crates/loopd/src/cron.rs`:
|
||
|
||
```rust
|
||
//! Five-field UTC crontab matcher (M5d spec, section 4.2). No cron crate.
|
||
|
||
use bxcore::Timestamp;
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum CronError {
|
||
Fields(String),
|
||
}
|
||
|
||
impl std::fmt::Display for CronError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
CronError::Fields(schedule) => {
|
||
write!(f, "cron schedule {schedule:?} is not five UTC fields")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for CronError {}
|
||
|
||
/// Whether `schedule` matches `now` in UTC.
|
||
pub fn due(schedule: &str, now: Timestamp) -> Result<bool, CronError> {
|
||
let fields: Vec<&str> = schedule.split_whitespace().collect();
|
||
if fields.len() != 5 {
|
||
return Err(CronError::Fields(schedule.to_string()));
|
||
}
|
||
let rfc = now.to_rfc3339();
|
||
let minute = number(&rfc, 14, 16, schedule)?;
|
||
let hour = number(&rfc, 11, 13, schedule)?;
|
||
let day = number(&rfc, 8, 10, schedule)?;
|
||
let month = number(&rfc, 5, 7, schedule)?;
|
||
let dow = {
|
||
let days = now.unix_millis() / 1000 / 86_400;
|
||
((days + 4) % 7) as u32
|
||
};
|
||
Ok(match_field(fields[0], minute, 0, 59, schedule)?
|
||
&& match_field(fields[1], hour, 0, 23, schedule)?
|
||
&& match_field(fields[2], day, 1, 31, schedule)?
|
||
&& match_field(fields[3], month, 1, 12, schedule)?
|
||
&& match_field(fields[4], dow, 0, 6, schedule)?)
|
||
}
|
||
|
||
fn number(rfc: &str, start: usize, end: usize, schedule: &str) -> Result<u32, CronError> {
|
||
let piece = rfc
|
||
.get(start..end)
|
||
.ok_or_else(|| CronError::Fields(schedule.to_string()))?;
|
||
piece
|
||
.parse()
|
||
.map_err(|_| CronError::Fields(schedule.to_string()))
|
||
}
|
||
|
||
fn match_field(
|
||
field: &str,
|
||
value: u32,
|
||
min: u32,
|
||
max: u32,
|
||
schedule: &str,
|
||
) -> Result<bool, CronError> {
|
||
let err = || CronError::Fields(schedule.to_string());
|
||
if field == "*" {
|
||
return Ok(true);
|
||
}
|
||
if let Some(step) = field.strip_prefix("*/") {
|
||
let n: u32 = step.parse().map_err(|_| err())?;
|
||
if n == 0 {
|
||
return Err(err());
|
||
}
|
||
return Ok(value.is_multiple_of(n));
|
||
}
|
||
if let Some((a, b)) = field.split_once('-') {
|
||
let lo: u32 = a.parse().map_err(|_| err())?;
|
||
let hi: u32 = b.parse().map_err(|_| err())?;
|
||
if lo < min || hi > max || lo > hi {
|
||
return Err(err());
|
||
}
|
||
return Ok(value >= lo && value <= hi);
|
||
}
|
||
let n: u32 = field.parse().map_err(|_| err())?;
|
||
if n < min || n > max {
|
||
return Err(err());
|
||
}
|
||
Ok(n == value)
|
||
}
|
||
```
|
||
|
||
`u32::is_multiple_of` is stable in 1.98. Do not use `%` with a comment that `n` was checked;
|
||
the skeleton already returns `Fields` for `n == 0`.
|
||
|
||
If `is_multiple_of` is not in scope on `u32` in this toolchain, use `value % n == 0` after the
|
||
`n == 0` check, and stop only if both fail to compile.
|
||
|
||
## Steps
|
||
|
||
- [ ] **1. Copy the given test.**
|
||
|
||
```sh
|
||
cp docs/plans/M5d2/files/crates/loopd/tests/cron.rs crates/loopd/tests/
|
||
```
|
||
|
||
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms: test
|
||
target `cron` does not compile (`E0432` unresolved import `loopd::cron`). Every other
|
||
target compiles.
|
||
- [ ] **3. The module.** Write `cron.rs` from the skeleton. Add `pub mod cron;` to `lib.rs`
|
||
where rustfmt will put it. `cargo check -p loopd` prints no error and no warning.
|
||
- [ ] **4. The tests.** `cargo test -p loopd --test cron`: every test passes, 10 tests.
|
||
- [ ] **5. Check.** `git diff --stat` lists `crates/loopd/src/cron.rs` and
|
||
`crates/loopd/src/lib.rs`.
|
||
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
|
||
`gate: ok`. Test count **1535** (1525 before, plus 10).
|
||
- [ ] **7. Log and commit.** Task cell `M5d2/04-loopd-cron`. Stage with
|
||
`git add crates/loopd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says.
|
||
|
||
## Done when
|
||
|
||
- `make gate` prints `gate: ok` with 1535 tests.
|
||
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/cron.rs`, `crates/loopd/src/lib.rs`,
|
||
`crates/loopd/tests/cron.rs` and the log.
|
||
|
||
## Stop and report if
|
||
|
||
- The branch check fails; step 2 differs; `cargo check` warns; a test fails; the gate count is
|
||
not 1535; you add a dependency; you treat DOM and DOW as OR; you accept `@daily` or a sixth
|
||
field; `cron.rs` exceeds 500 lines.
|
||
|
||
## What the given tests pin
|
||
|
||
`0 7 * * *` matches that minute only; `* * * * *` matches; `*/5` is multiples from 0; `0-10`
|
||
is inclusive; Sunday is 0 on 2026-10-04; the 1st of the month; four or six fields error;
|
||
minute 60, hour 24, DOW 7 error; `@daily` errors; `Display` names the schedule.
|