-
Notifications
You must be signed in to change notification settings - Fork 132
Support schedule jitter #89
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Support schedule jitter #89
Conversation
|
Now that this is out there, I'm thinking that the caller passing in a pre-seeded |
|
Thanks a lot, this is a really great addition to Cronos 😊 And many thanks for the correction of the README.md file! Let's go through the general questions first, and then we will dig into the details:
Yes, this is a big topic related to a few time zones, and Cronos is "lucky" enough to use some of them. The problem is the source of time zone information, which is different on different platforms. Since .NET is working with Window-based source since the very beginning, it does everything right. However, on Linux and macOS IANA's tzdb is used, and the code that reads it is relatively new (appeared in .NET 5.0). And there are problems with reading it and converting it into .NET's internal data structures. NodaTime does everything right, but .NET's own code needs improvements. The corresponding issues are already filed as a GitHub issue, but not implemented yet. I have a list of issues with time zones somewhere, but didn't have time to add more information to those GitHub issues yet.
This is really awesome, thank you again 😍
Not sure about this change, since
The original intention was to add minimal number of characters to convert this into a table and make it easy to add new rows. Since this table is mostly immutable and since the new format have better look when viewing raw file, it may be worth using the new format, leaving this on you.
I think this is fully expected, since
Cronos has a limited scope, so there are no features specific to versions. One day it might be converted to using modern
Yes, this would be a great feature! |
src/Cronos/CronParser.cs
Outdated
|
|
||
| if (Accept(ref pointer, 'H')) | ||
| { | ||
| if (field.CanDefineInterval) flags |= CronExpressionFlag.Interval; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This line is interesting, and should be covered by a dedicated unit test, and the Interval flag relates to Daylight Saving Time transitions, please see https://github.com/HangfireIO/Cronos?tab=readme-ov-file#interval-expression.
The point is that the jitter feature is sensitive to DST transitions, since it can easily schedule occurrences to "dangerous" time like 01:00 and 02:00 AM. Since Cronos handles this automatically, no problems are expected, but we should ensure that both interval-based and non-interval-based expressions work as expected with H characters.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I misunderstood the intent of that flag.
H on its own is a stand-in for a single number and is not an interval expression on its own, but it can be combined with steps. I need to think about this a little more, thanks for pointing it out.
src/Cronos/CronParser.cs
Outdated
| for (var i = low; i <= high; i += step) | ||
| if (jitter.HasValue) | ||
| { | ||
| // we will wrap around the range with modulus, which breaks the calculations when the ranges are reversed |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you explain this, please?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll try 😄 . I spent a while trying to implement this in a way that didn't break existing unit tests. The reversed ranges were particularly problematic.
So this method gets called in these scenarios:
- A low-high range, e.g.
2-4 - A high-low range, implemented as two separate low-high ranges, e.g. hours
22-2become22-23and0-2. - A non-hash step, e.g.
*/8 - A hash with step, e.g.
H/8
Taking them backwards:
A hash with step requires us to go through the entire field of numbers starting at the offset and wrap our way around, e.g. H/8 as an hour field might start us out at 17, causing us to set 0+17, (8+17)%24=1, and (16+17)%24=9. Simple, intuitive.
A non-hash step can start with the field's low value and never have to worry about modulus. To apply one in a unified formula, though, we must know the full range of the field, and in this case, the low and high parameters correctly define the range.
A range, whether high-low or low-high, doesn't have enough information for us to be able to infer the full range of the field to be used as a modulus, it needs to be passed in as another argument to the function, e.g. 22-23 being the low/high does NOT mean that the range for modulus is 1. That issue is easy to overcome. However, the ranges by definition can't have hashes, so attempting to use the modulus is unnecessary anyways.
I kept trying to find ways to unify the formula but ultimately decided that doing that check inside the for loop was probably less performant than only invoking modules when it was actually needed anyway.
And I think I have a bug on fields where the low value is non-zero, e.g. if a day-of-month has an expression of H/15 and our offset is 12: we'd set 1+12=13 and (16+12)%28=0, but 0 is out of range. I will fix that and add a test.
The unified formula probably looks something like this, if we really wanted to use a modulus each time:
for (var i = 0; i < intervalRangeOrFieldRange; i += step)
{
SetBit(ref bits, low + ((i + (jitter ?? 0)) % fieldRange));
}I can keep digging in that direction if you think it's important.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some fresher eyes have allowed me to find a way to avoid the modulus issue entirely 🥳
|
And finally:
👍 , and I appreciate the back-story. I just wanted to call 'em out as preexisting failures.
Makes sense. I saw that GH issue after submitting this PR. I'll revert that specific change.
I'll leave the edits, since they're already done. I appreciate the thorough review. |
…andom" This reverts commit 6566695.
|
(Nearly) all feedback should be implemented now:
The only thing (I think) I didn't address is the comment on nullable reference types. I need a little more guidance understanding what you'd prefer to see there, since it's valid for the caller to use a non-hashed expression. The parsing of each field needs to be passed a reference to a shared, stateful |
|
Sorry for the delayed response, quite a busy week. I will try my best to continue our conversation next week! |
|
Hi @epicoxymoron! Thanks a lot for working on this, I appreciate your time and efforts in making this done. No worries regarding the nullable reference type, it was a misunderstanding from my side, now with a dedicated exception, the intention is perfectly clear. Thank you also for the description regarding wrap-arounds when parsing steps, it looks like I understand the problem now. I think your original implementation was better, but please let me clutter your head with unnecessary details first. The full form of steps is based on range, so When we talk about expressions like Since the [SuppressMessage("Security", "CA5394:Do not use insecure randomness")]
private static unsafe ulong ParseHash(CronField field, ref char* pointer, ref CronExpressionFlag flags, Random? rng)
{
// prior to this point in parsing, it has been valid to not pass in a jitter seed.
if (rng == null) throw new MissingSeedException();
// Prevent against calculating the 31st of February
var maxValueInclusive = field == CronField.DaysOfMonth
? CronField.LastCommonDayOfMonth
: field.Last;
var jitter = rng.Next(field.First, maxValueInclusive + 1);
if (Accept(ref pointer, '/'))
{
if (field.CanDefineInterval) flags |= CronExpressionFlag.Interval;
var range = maxValueInclusive - field.First + 1;
return ParseStep(field, ref pointer, jitter, (jitter + range - 1) % range);
}
return GetBit(jitter);
}In this case, no jitter is required to be passed to |
|
What do you think about such an implementation? I can make the changes on my own, but need to re-consider the tests first, since random numbers in this case are different, because higher bound is different. |
|
At first glance, I'm not so sure that both approaches don't take us to the same place. The idea of treating That said, my current implementation treats it more akin to I think it comes down to just which fits your mental model better and what you'd rather maintain longer-term. I'm happy to leave that decision to you, but I certainly grant your version is less intrusive to the codebase. From a testing standpoint, I tried out a few seeds and tried to stay away from values that would generate either 0, 1, or the last value in the range. IIRC 3 was a decent seed when the range was Whichever way you'd like to go is fine with me. This has been fun. |
Closes #88.
Worth noting: I develop on a Mac, and 12 of the test cases fail on
main(without any changes), mainly around DST handling. I did not fix them. I assume they pass when run on Windows.GetNextOccurrence_HandleDifficultDSTCases_WhenTheClockJumpsForwardOnFridayGetNextOccurrence_HandleDifficultDSTCases_WhenTheClockJumpsBackwardOnFridayGetNextOccurrence_HandleDST_WhenTheClockJumpsForwardAndFromIsAroundDSTGetNextOccurrence_ReturnsAGreaterValue_EvenWhenMillisecondTruncationRuleIsAppliedDueToDSTI cleaned up a few typos around the codebase while I was there, and took a stab at writing the README docs. I'll call out some specific non-jitter related changes that you may want me to revert (I don't mind):
[Flags]attribute onCronFormatsince you can't use both formats at the same time. I assume that was put there intentionally but it didn't make sense to me.I did run the benchmarks locally, and while the hash benchmark is noticeably slower than the others and allocates more, I think it's forgivable for what it provides.
One last thing: I'm fairly new to the .NET ecosystem, having really only developed on .NET 9. If there are conventions I've used that don't work on older versions of .NET that you'd like to support, I'll just need a little coaching on how to adjust.
Feedback welcome. I'm looking forward to seeing this capability integrated into HangFire at some point.