-
Notifications
You must be signed in to change notification settings - Fork 11
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
[WIP] Adventures in the amazing world of specification of dependencies and predictiveness #137
base: v4
Are you sure you want to change the base?
Conversation
@linkdotnet Not all tests are passing. Before going further with how the code should be changed to make them pass, I'd like some feedback from you about the following points:
Note: I haven't wandered in the region of dynamic jobs. Yet. |
I will have a look at this at the end of the week - unfortunately I am a bit swamped with stuff. |
|
||
var instantJobRegistry = provider.GetRequiredService<IInstantJobRegistry>(); | ||
|
||
Action act = () => instantJobRegistry.ForceRunInstantJob<ParameterJob>("something", CancellationToken); |
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 dont think we should throw here in the first place. That is an absolut valid case and the instant job shouldn't be linked to "cron" jobs and their configuration in any way.
We also don't "assume" the parameter for an instant job based on the configuration inside the container. The user explicitly has to pass in the value he or she wants.
I know that doesn't make it easier but maybe shows that JobDefinition is really really a bad container at the moment.
Basically, we have Jobs, Runs, and somewhat a trigger. JobDefinition tries to bundle all those 3 things somewhat together. At least the "Jobs" and "Trigger" part and spawns the "run".
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.
Updated the expectation:
ServiceCollection.AddNCronJob(n =>
{
n.AddJob<ParameterJob>(s => s.WithCronExpression("* * 30 2 *"));
n.AddJob<ParameterJob>(s => s.WithCronExpression("* * 31 2 *"));
});
should no longer be expected to throw.
However, I've added
ServiceCollection.AddNCronJob(n =>
{
n.AddJob<ParameterJob>(s => s.WithCronExpression("* * 30 2 *").WithParameter("13"));
n.AddJob<ParameterJob>(s => s.WithCronExpression("* * 31 2 *"));
});
which I believe should throw (as the behavior of the job may be altered by the parameter)
/// </summary> | ||
/// <remarks> | ||
/// </remarks> | ||
public DependencyBuilder<TPrincipalJob> RunJob(string jobName, object? parameter = null) |
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 see where you are driving at. Imagine the following registration:
builder.Services.AddNCronJob(o =>
{
// Here comes your new function into play
o.AddJob<...>(w => w.WithCronExpression("* * * * *")).ExecuteWhen(s => s.RunJob("Name", null));
o.AddJob<DependentJob>(o => o.WithCronExpression("* * * * *").WithName("Name"));
});
Technically the user registered a job with the given job name. The order inside a container registration shouldn't be important.
And that is where your useNCronJobAsync
proposal from a few days may come in handy.
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.
But this is more of an exotic use case (at least in my mind).
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.
But this is more of an exotic use case (at least in my mind).
I'm not sure. I've tried a spike where all dependencies were required to be registered before jobs leveraging them.
Although that works, that's an awful user experience.
The order inside a container registration shouldn't be important.
I have an idea how to resolve this
@@ -30,7 +45,7 @@ public void Add(JobDefinition jobDefinition) | |||
{ | |||
throw new InvalidOperationException( | |||
$""" | |||
Job registration conflict for type: {jobDefinition.Type.Name} detected. Another job with the same type, parameters, or cron expression already exists. | |||
Job registration conflict for type '{jobDefinition.Type.Name}' detected. Another job with the same type, parameters, or cron expression already exists. |
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.
We might want to special case that part. If the job is typeof(DynamicJobFactory)
aka Lambda expression, then this will be more confusing than helpful
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 know. I'm starting with the easy part. I'll tackle Dynamic jobs once we've straighten the general expectations on typed jobs. (through expected passing/failing tests)
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.
Hmmm I am not sure why this should throw.
In my mental model CRON jobs and instant jobs are two different things. The only thing they share is the execution logic but none of its metadata (well technically some of it like the retrypolicy stuff, dependent jobs and whether or not they can be executed, but nothing related to CRON jobs and its parameter).
Furthermore, users have the ability to add CRON jobs (IRuntimeRegistry.TryRegister
) and parameters (IRuntimeRegistry.UpdateParameter
) or update schedules.
That might lead to a situation where
- The initial Instant job
A
works because there was no parameter specified IRuntimeRegistry.UpdateParameter
is called forA
with a new parameter- Now the instant job fails with the given exception
The user would need to keep track of the parameters and needs to set it temporarily to something else or null
so the instant trigger succeeds.
More and more we might want to check of we need distinct objects like JobDefinition
, JobRun
and Trigger
.
That would solve some of our issues, mainly:
- Dependent jobs can hang either on a Trigger or a JobDefinition (so they get triggered for instant jobs as well). The location where you call
ExecuteWhen
is key. But takes out ambiguity. Maybe we can even have distinct methods for that so it is painfully clear: That should be straightforward as can provide overloads toAddJob
. For example if you "only" call:AddJob().ExecuteWhen
it should always be on the type aka Jobdefinition, but:AddJob(c => c.WithCronExpression())
should sit on the trigger. - Would make it also easier to distinct if we have duplicates. The JobDefinition should always be unique and we just have a bunch of triggers.
Sorry for the wall of text, but seeing that our system is so ambiguous tells me, we need to go in a different direction. Extending the current system may lead to more confusion
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.
A trigger might have a definition like that:
/// <summary>
/// Base interface for job triggers
/// </summary>
internal interface IJobTrigger
{
JobDefinition JobDefinition { get; }
object? Parameter { get; }
JobPriority Priority { get; }
DateTimeOffset? GetNextExecutionTime(TimeProvider timeProvider);
}
/// <summary>
/// Trigger for CRON-based scheduling
/// </summary>
internal sealed class CronTrigger : IJobTrigger
{
private readonly CronExpression cronExpression;
private readonly TimeZoneInfo timeZone;
public CronTrigger(
JobDefinition jobDefinition,
string cronExpression,
TimeZoneInfo? timeZone = null,
object? parameter = null)
{
JobDefinition = jobDefinition;
this.cronExpression = CronExpression.Parse(cronExpression);
this.timeZone = timeZone ?? TimeZoneInfo.Utc;
Parameter = parameter;
Priority = JobPriority.Normal;
}
public JobDefinition JobDefinition { get; }
public object? Parameter { get; }
public JobPriority Priority { get; init; }
public DateTimeOffset? GetNextExecutionTime(TimeProvider timeProvider)
{
var utcNow = timeProvider.GetUtcNow();
return cronExpression.GetNextOccurrence(utcNow, timeZone);
}
}
/// <summary>
/// Trigger for immediate or scheduled one-time execution
/// </summary>
internal sealed class InstantTrigger : IJobTrigger
{
public InstantTrigger(
JobDefinition jobDefinition,
object? parameter = null,
DateTimeOffset? scheduledTime = null)
{
JobDefinition = jobDefinition;
Parameter = parameter;
ScheduledTime = scheduledTime;
Priority = JobPriority.High;
}
public JobDefinition JobDefinition { get; }
public object? Parameter { get; }
public JobPriority Priority { get; init; }
public DateTimeOffset? ScheduledTime { get; }
public DateTimeOffset? GetNextExecutionTime(TimeProvider timeProvider) =>
ScheduledTime ?? timeProvider.GetUtcNow();
}
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.
Also we have this beauty which might get solved:
builder.Services.AddNCronJob(o =>
{
o.AddJob<MyJob>().ExecuteWhen(s => s.RunJob<JobA>());
o.AddJob<MyJob>(s => s.WithCronExpression("* * * * *")).ExecuteWhen(s => s.RunJob<JobB>());
If "* * * * *" is true
- what dependent jobs should be executed? I'd argue JobA
**and JobB
. And this would easy to model with the Trigger
. well "easier".
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.
In my mental model CRON jobs and instant jobs are two different things. The only thing they share is the execution logic but none of its metadata (well technically some of it like the retrypolicy stuff, dependent jobs and whether or not they can be executed, but nothing related to CRON jobs and its parameter).
@linkdotnet Hrmpf. I completely overlooked this paragraph. And some of it actually triggers a lot of questions in my mind.
I've never linked CRON expression with parameters. In my mind, those two things are completely orthogonal (FWIW,
I've got a local branch with some code change slightly tweaking the fluent interface in order to allow a WithParameter().WithName()
chain without a WithCronExpression()
root.)
Reasoning (at least how I see it):
- Parameters are a way to optionally alter the behavior of how a job runs.
- Those parameters can be set through a
AddJob()
call - They can be overridden through an instant job execution
Thoughts? Is there a world where parameters don't depend on Cron expressions?
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.
Going a little down further this path, I "see" actually two different phases/steps (independently of how the API make them happen).
The Definition:
- The root job, its parametrization, the orchestration of dependent steps (with RunJob() allowing the optional override of the parameter). Those steps being either inline jobs or "references" to other root jobs.
The Scheduling:
- A way to trigger root jobs at regular intervals
All layers of new registrations contributing to the Definition should lead to a list of entries and references that are unambiguous. When an ambiguity is detected, the lib complains and the "Definition" should be updated to leverage "Names" to resolve those ambiguities (RunJob() may have to be updated to accept a jobName to make this happen and JobOptionBuilder might also need to expose WithName()
).
Instant Jobs are only a special case of Scheduling
RuntimeRegistry ensures through the TryRegister()
method that the Definition is kept unambiguous.
To sum up:
- Easy job setups would still be easy to define.
- Complex orchestrations involving reused jobs in different execution contexts would be possible in a safe way, the registration engine ensuring the absence of ambiguity at runtime
What's your feeling about this?
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've never linked CRON expression with parameters. In my mind, those two things are completely orthogonal (FWIW,
So right now that is the model. Defining a job: AddJob<MyJob>(s => s.WithCronExpression("* * * * *").WithParameter("foo"))
is exactly that.
I've got a local branch with some code change slightly tweaking the fluent interface in order to allow a WithParameter().WithName()
True that - it is currently only possible to register CRON jobs with a name. If we take the current world only, then WithParameter
without a CRON expression doesn't make sense, as you can specify parameters for instant job when calling them.
They can be overridden through an instant job execution
That is an fair assumption - and I like that mental model. The current API might make it a bit awkward. Imagine you register a Job with two CRON Expressions and each of them has different parameters. Now you trigger an instant job without passing in arguments - what will be used?
Going a little down further this path, I "see" actually two different phases/steps
It goes exactly into the direction you mentioned some time ago, where AddNCronJob
is the collection phase and Use
is the configuration/kickstart phase.
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.
That is an fair assumption - and I like that mental model. The current API might make it a bit awkward. Imagine you register a Job with two CRON Expressions and each of them has different parameters. Now you trigger an instant job without passing in arguments - what will be used?
This is actually where an ambiguity would be raised at runtime. In order to resolve it, one would have to identify a specific job through its name to trigger it.
Some flavors of ambiguities can be detected before runtime (during the registration) and would prevent the app from running.
Some others (as the one you just described) can be identified as well (and for instance logged as a warning (eg. "Job X is registered Y times without name. Trying to invoke it through an InstantJob would lead to a runtime exception.") but wouldn't prevent the app from starting as the lib cannot be 100% sure whether or not that InstantJob invocation will happen or not).
True that - it is currently only possible to register CRON jobs with a name. If we take the current world only, then WithParameter without a CRON expression doesn't make sense, as you can specify parameters for instant job when calling them.
There's a case when this could make sense even in the current world.
n.AddJob<Reporting>(s => s..WithParameter("using standard channels"));
n.AddJob<TaskOne>(s => s.WithCronExpression(Monthly)))
.ExecuteWhen(s => s.RunJob<Reporting>());
n.AddJob<TaskTwo>(s => s.WithCronExpression(Daily)))
.ExecuteWhen(s => s.RunJob<Reporting>());
n.AddJob<TaskThree>(s => s.WithCronExpression(Daily)))
.ExecuteWhen(s => s.RunJob<Reporting>());
And then from an InstantJob .ForceRunInstantJob<TaskOne>("using emergency channels");
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.
By any chance, do you know how Quartz
/Hangfire
handles such situations?
This is actually where an ambiguity would be raised at runtime. In order to resolve it, one would have to identify a specific job through its name to trigger it.
I fear that if we do this, we declassify Instant jobs to the extent that you always have to take care of naming, just because you want to trigger a job with different names. So you have to register the job twice at least - once for instant jobs, once for CRON (which could be also a route - offering a AddInstantJob
method that operates on its own term).
The funny part is that I use this behavior in my own projects with NCronJob. I do have a Cron Job with Parameter "A" but the instant job triggers with Parameter "B" and I don't wanna think too much about this while building up the container.
0ccc4ed
to
ea1dd7c
Compare
7ba7dd3
to
49b209b
Compare
Codecov ReportAttention: Patch coverage is
|
Pull request description
@linkdotnet Early work in the land of #108
Not a real PR as of now, rather a test bed for expectations (and tests that we'd like to see passing).
PR meta checklist
main
branch for codeCode PR specific checklist