fix(models): reject non-finite and out-of-range durations instead of saturating - #751
Conversation
|
@greptileai review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c190b66e7e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
c190b66 to
415500b
Compare
|
@greptileai review |
🤖 AI code reviewThis PR changes duration handling in aw-models, aw-query, and aw-datastore. It makes seconds_to_nanos return Option<i64> instead of i64, rejecting non-finite and out-of-range values. It replaces the serde remote derive with manual serialize/deserialize functions for DurationSerialization, so deserializing an out-of-range duration becomes a serde error. It updates the legacy importer to skip events with invalid durations, the flood query function to reject invalid pulsetimes, and sum_durations to use checked_add to avoid overflow panics. Tests are added for the new behavior. Safe to merge — no P0/P1 findingsConfidence 5/5 ✅ No thread-worthy findings. Advisory notes follow; they are retained without opening review threads. 3 advisory findings (summary-only, not scored)These P2 guard, heuristic, trade-off, or documentation claims are retained for judgment without opening review threads.
The new deserialize function for DurationSerialization uses How this was verified: Checked the serde with attribute usage and the test cases. The deserialize function correctly parses f64 from JSON numbers.
The test How this was verified: Checked the f64 precision and the test values. The tolerance is appropriate.
In How this was verified: Checked the surrounding code and the PR description. The skip is intentional. Files changed (4) — the diff as I read it
Previous review passes
Reviewed Maintainer commands
|
| // i64::MIN (-2^63) is exactly representable as f64. i64::MAX is not: the nearest f64 is 2^63, | ||
| // which is what a duration of i64::MAX (or close to it) serializes as, so accept it and let the | ||
| // cast map it to i64::MAX. Anything beyond is out of range. NaN fails both comparisons. | ||
| if nanos >= i64::MIN as f64 && nanos <= i64::MAX as f64 { |
There was a problem hiding this comment.
❌ P1 — The range check in seconds_to_nanos accepts 2^63 ns (the f64 nearest to i64::MAX) and casts it to i64::MAX, but the comment and tests claim that 'the next f64 above it is out of range'. However, the check nanos <= i64::MAX as f64 uses i64::MAX as f64 which is 2^63 (since i64::MAX is not representable). This means any f64 in the range (2^63 - 1024, 2^63] is accepted and cast to i64::MAX, but f64 values just below 2^63 that are not exactly representable as i64 (e.g., 2^63 - 1) will be cast to i64::MAX as well, losing precision. More importantly, the test let below = f64::from_bits((i64::MAX as f64).to_bits() - 1); assert_eq!(seconds_to_nanos(below / 1e9), Some(below as i64)); expects below as i64 to be the result, but below is an f64 that is one ulp below 2^63, and casting it to i64 saturates to i64::MAX, so the assertion passes vacuously. The actual bug is that durations between 2^63 - 1024 and 2^63 ns (which are not representable as i64) are silently converted to i64::MAX, which is a different duration. This violates the PR's stated goal of rejecting out-of-range durations instead of saturating. The fix is to reject any value > i64::MAX as f64 (i.e., > 2^63 - 1024) or to use a more precise check that ensures the rounded value is exactly representable as i64.
…saturating seconds_to_nanos cast with `as i64`, which silently saturates to i64::MAX/MIN for durations beyond about 292 years and turns NaN into 0, so e.g. a 1e12 s duration was stored as about 292 years. seconds_to_nanos now returns None for non-finite or out-of-range values, with an explicit range check before the cast. Callers handle it: - deserializing an event duration fails with a serde error; the remote derive is replaced by serialize/deserialize functions, keeping the wire format (a newtype around f64) - the legacy importer skips such events with a warning, like events with unparseable data - flood() rejects such a pulsetime like a negative one - sum_durations returns an error on overflow instead of panicking Follow-up to review of #750.
…ive pulsetimes A duration of i64::MAX ns (or close to it) serializes as 2^63 ns, the nearest f64, which the new range check rejected, so such an event could be written but not read back. Accept exactly 2^63 and map it to i64::MAX, and check that durations near both limits round-trip. flood() accepted negative pulsetimes that round to 0 ns; check the sign of the input again.
8e4f541 to
90fa054
Compare
Follow-up to #750, addressing this review comment: #750 (comment)
seconds_to_nanos(the canonical seconds-to-Durationconversion since #750) cast withas i64. That cast silently saturates toi64::MAX/MINbeyond about ±292 years, and turns NaN into 0. So a duration of1e12s (about 31 700 years) was stored as about 292 years without any error. This isn't a regression: the truncating cast before #750 did the same.Changes
seconds_to_nanosreturnsOption<i64>, withNonefor NaN, ±inf, and results outside the i64 range. The range check runs on the rounded f64 before the cast.i64::MIN(-2^63) is exactly representable and allowed.i64::MAXis not representable: its nearest f64 is 2^63, which is exactly what a duration ofi64::MAXns serializes as. So 2^63 is accepted and maps toi64::MAX, and every representable duration round-trips through its JSON (found by Greptile and Codex in review). Anything above 2^63 is rejected.duration ... s is not finite or out of range) instead of corrupted data. The#[serde(remote)]derive can only convert through an infallibleFrom, soDurationSerializationnow providesserialize/deserializefunctions for#[serde(with = ...)]. They go through a private newtype with the same name, so the wire format is unchanged (a plain number in JSON), and thedefaultfor a missing duration still applies.flood()query function: rejects such a pulsetime, like a negative one. It had its own saturating cast since fix(transform): port aw-core's flood() so both servers agree #748. The sign is checked on the input, so tiny negative values that round to 0 ns are still rejected.sum_durations: useschecked_addand returns a query error on overflow instead of panicking. The overflow limit ischrono::Duration's range (about 292 million years), so it's theoretical, but it's no longer a panic.aw-transform'sheartbeatstill casts its pulsetime withas, which saturates. I left it alone: its signature returnsOption<Event>, and a saturated pulsetime there just means "always merge", not corrupted data.Tests
seconds_to_nanos: normal values are unchanged (including 515.968 from Durations lose a nanosecond on parse, so sum_durations comes out 1 ms low and touching periods don't merge #745, -2.25, -0.0 and 1.5e-9). Also 1e12, -1e12, NaN and ±inf. At the exact boundaries, -2^63 ns converts toi64::MIN, 2^63 ns toi64::MAX, and the next f64s beyond either limit are rejected. 9e9 s (about 285 years) is still accepted.i64::MAXandi64::MINserialize and deserialize again (within a few f64 ulps).1e12,-1e12and1e400durations are errors. Integer and missing durations still work, and 515.968 still round-trips exactly.flood(events, 1000000000000)andflood(events, 0 - 0.0000000001)are rejected like a negative pulsetime.cargo test,cargo clippy -- -D warningsandcargo fmt --checkpass locally. master currently doesn't build (tomlmissing in aw-client-rust since #740), so CI here stays red until #752 is merged. On the pre-#740 base, the whole workspace passes.