Skip to content

Start, stop and delete jobs

You start a job from an endpoint and you stop or delete it from an operator action. Both are calls on a grain that may live on another silo, possibly one running a different release during a rolling upgrade. This page shows the supported way to make those calls and how to read what comes back.

Use IJobs, not the generic manager methods

Section titled “Use IJobs, not the generic manager methods”

During a rolling upgrade, a shared jobs manager may still run on the older release. Calling its generic Start<TJob, TRequest> or GetJobsOfType<TJob, TRequest> sends those types to that silo, which cannot decode a job or request introduced by the newer release. Use the singleton IJobs from Cratis.Choreography.Jobs for starting, stopping, draining and deleting instead. AddCratisChoreography registers it.

IJobs is a local service, not a grain. It addresses the job grain directly from the caller’s release, and Orleans places that grain on a silo that hosts its type. No generic job or request type passes through the shared manager. The generic manager methods stay available for single-version clusters and carry documentation warnings rather than [Obsolete] build warnings.

A job is addressed by a JobKey of a scope and a namespace, two strings the engine treats as opaque and the storage uses to find the right database (see Storage). A host that serves several tenants typically uses the event store name as the scope and the tenant namespace as the namespace. This excerpt belongs in a host with Orleans and jobs storage configured, and assumes a serializable request and the IRebuildTenantIndex : IJob<RebuildIndexRequest> grain from Planning a job:

public class TenantJobs(IJobs jobs)
{
public Task<Result<JobId, StartJobError>> Start(
string eventStoreName, string tenantNamespace, RebuildIndexRequest request) =>
jobs.Start<IRebuildTenantIndex, RebuildIndexRequest>(
new JobKey(eventStoreName, tenantNamespace), request);
public Task<StopJobResult> Stop(string eventStoreName, string tenantNamespace, JobId jobId) =>
jobs.Stop(new JobKey(eventStoreName, tenantNamespace), jobId);
public Task<DeleteJobResult> Delete(string eventStoreName, string tenantNamespace, JobId jobId) =>
jobs.Delete(new JobKey(eventStoreName, tenantNamespace), jobId);
}

The excerpt uses Cratis.Monads for Result and Cratis.Choreography.Jobs for everything else.

Start returns the job identity only when the job accepts the request. Success means started, not completed. Start(key, jobId, request) lets you supply the identity, and a duplicate start of that identity keeps the job’s usual refusal behavior instead of starting a second plan. On refusal, inspect the StartJobError:

ErrorMeaning
AlreadyBeenPrepared, AlreadyCompletedThe job already exists and was started or has finished
NotAdmittedThe job’s owner refused the start; nothing was written (see Admission)
InvalidStepPlan, CouldNotPrepareJobStepsThe plan could not be saved or a step refused preparation (see Planning)
NoJobStepsToStartThe plan was empty and the job completed
FailedStartingSomeJobStepsThe job is running; steps that failed to start count as failed
AllJobStepsFailedStartingNo step started and the job is not running
UnknownAn unexpected failure or a write that was not durable

Starting also remembers the scope and namespace for recovery without making Start wait; see Recovery for the complete catalog you should register.

Stop and delete read only the stored job type, so an unreadable request from a newer release does not hide which release owns the job. Match their nested result records:

OutcomeStopDelete
Accepted by the jobStopJobResult.Stopped(JobId)DeleteJobResult.Deleted(JobId)
No stored jobStopJobResult.NotFound(JobId)DeleteJobResult.NotFound(JobId)
Type unavailable in this releaseStopJobResult.UnknownJobType(JobId, JobType)DeleteJobResult.UnknownJobType(JobId, JobType)
Refused by the jobStopJobResult.Refused(JobId, StopJobError)DeleteJobResult.Refused(JobId, RemoveJobError)

An unknown type can belong to a newer release or to a removed job. Neither operation resolves a grain or changes state in that case: route the operation to a release that knows the type. Storage and grain-call exceptions propagate instead of being reported as stopped or deleted. These result records stay local and are not serialized across grains.

A stop refuses with StopJobError.JobIsCompleted for a finished job, JobIsNotRunning for one that is not running and CouldNotPersistStop when the stop could not be written, which leaves the job as it was. Delete calls the job’s Remove, which stops its steps first. It refuses a completed job with RemoveJobError.JobIsCompleted; kept terminal records go away through the retention cleanup. When the removal cannot be persisted it refuses with CouldNotPersistRemoval and can be retried. Acceptance does not promise that every step has finished its own stop or removal callback. Third-party storage providers should override IJobStorage.GetJobType with a metadata-only read, because the compatibility fallback reads the full job and can still fail on an unknown request.

Stop is accepted as soon as the job has persisted it, and its steps finish stopping afterwards. When you need to know that nothing of the job runs any more, before you read its steps’ checkpoints, hand its work to someone else or delete it, use StopAndDrain. This excerpt adds it to TenantJobs:

public async Task<bool> IsDrained(string eventStoreName, string tenantNamespace, JobId jobId) =>
await jobs.StopAndDrain(new JobKey(eventStoreName, tenantNamespace), jobId, TimeSpan.Zero) is
DrainJobResult.Drained or DrainJobResult.Completed;
OutcomeMeaning
DrainJobResult.Drained(JobId)The job is stopped and drained
DrainJobResult.TimedOut(JobId)The job is stopped, but not every step drained in time; call again and the next call asks only the steps that have not drained
DrainJobResult.Completed(JobId)The job had already completed or failed and none of its step work runs
DrainJobResult.NotFound(JobId)No stored job
DrainJobResult.UnknownJobType(JobId, JobType)The stored type is unavailable in this release

IJobDrain.StopAndDrain on the job grain is the same operation, and every job implements it. The timeout is capped at half the Orleans response timeout less a margin, because the steps’ own reports queue behind the drain. TimeSpan.Zero polls without waiting, and polling never cuts a step short: a step refuses its callbacks only once its own StepStopTimeout has passed since its stop began. A drained job is a stopped job, so Resume() runs it again and automatic recovery leaves it alone. Asking again after Drained writes nothing and runs no hook. What Drained guarantees, and what it does not, is in Stopping and draining.

A job resumes on its own after a silo restart; see Recovery. To resume one yourself, use the manager for the scope and namespace. IJobsManager.Resume(jobId) returns true when the job was taken forward (resumed, already running or finalized as completed) and false when nothing took it. Treat false as a refusal, because a job nobody took stays exactly as it was.

ResumeIfRunning(jobId) resumes only a job whose stored status is Running. The check runs in the job grain’s own turn, so a Stop or Remove is serialized before or after the whole call and can never land between the check and the resume. For a Running job it behaves exactly like Resume(). For any other status it changes nothing, runs no hook, starts no step and returns ResumeJobSuccess.JobIsNotRunning. A manager that cannot reach the job answers CannotResumeJobError.Unknown. Resume stays unrestricted, so an operator can still resume a stopped job.

var manager = grainFactory.GetJobsManager("store", "tenant");
var result = await manager.ResumeIfRunning(jobId);
result.Match(
success => success == ResumeJobSuccess.JobIsNotRunning ? Skipped(jobId) : Resumed(jobId),
error => Failed(jobId, error));

Skipped, Resumed and Failed stand in for your own handling. To run the failed steps of a finished job again, call RetryFailedSteps(jobId), which a job type must opt into (see Stages, failures and retries).