Repository navigation
Add the database-backed job runner - #2748
Merged
Merged
Conversation
This was referenced Nov 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2698
Adds durable queued, scheduled and recurring jobs on one database, with no other infrastructure and safe across instances.
cms_job,cms_job_attemptandcms_recurring_jobcome with a migration per provider. The SDK getsIJob<TPayload>,JobContext,IJobScheduler,IJobRunnerandIJobDeadLetters.FluentCMS.Core/Jobshas the hosted runner: workers per queue, claims throughIJobClaimDialect, lease heartbeat, retries with backoff and jitter, dead letter with a sweep for a worker that died on its last attempt, graceful shutdown, and recurring jobs through Cronos.This also fixes two bugs in the SQLite claim from #2737:
Dependencies:
Cronos0.13.0 (new, MIT, approved in principle in Q9), referenced byFluentCMS.Corefor cron parsing. No new test package:tests/FluentCMS.Data.Tests/Jobs/ManualTimeProvider.csis written here rather than addingMicrosoft.Extensions.TimeProvider.Testing.Acceptance
Under the scope change on #2711 (Q19), items that name PostgreSQL and SQL Server apply to SQLite until #2711.
IJobDeadLetters.TimeProvidercontrolling time in tests.Verification
On Windows (arm64), at this commit:
dotnet build -c Release -warnaserror,dotnet test -c Release(1893 tests, all passed),dotnet pack -c Release(18 packages and their symbol packages). CI covers Linux.Before the last commit, the job tests (85) passed 6 rounds of 4 copies at once, which is 24 runs under load, and 5 more one at a time.
JobClaimTestspassed the 5 single runs and was not in the 4-copy rounds.Failure injection:
JobFaultsfails one instance's commands while others reach the database, so the paths throughHeartbeatFailed,SettleFailed,WorkerFailed,MaintenanceFailedandRecurringFireFailednow have tests. To check that the tests need the fixes, I undid four of them in turn. Each time the tests meant to catch it failed:AFailedClaimDoesNotUseUpAnAttempt(both cases), which proves the attempt was burnedAttempts == job.AttemptinHeldALeaseIsFencedByTheAttemptAsWellAsTheOwnerWhenALeaseLapsesMidJobTheTwoRunnersNeverOverlap; that run hit my time limit before finishing, so others may fail tooABriefOutageDoesNotLoseASuccess(both cases),ABriefOutageDoesNotLoseAFailureTheClaimSeeksItsIndexWithoutASortrunsEXPLAIN QUERY PLANon the statement the SQLite dialect really sends.Not verified: anything on a PostgreSQL or SQL Server server. Those migrations and dialects are checked offline only (no pending model changes, column types, index shape).
Limits
JobContext.CancellationTokenstops before another can claim it. Two things can still overlap: a job that ignores its token, and a process paused long enough that its heartbeat does not run at all. Whatever such a worker writes on waking matches no row. A worker that dies mid-job also leaves work to be repeated. Jobs should be safe to run again.POST failed with *** ***.What later issues must do
author_id,created_byandupdated_byfromcontext.AuditPrincipal.Id, notcontext.Principal.Id. Otherwise a scheduled publish recordsGuid.Emptyas its author. Authorization keeps readingPrincipal. A job runs withPrincipal.SystemandOrigin.Job, whichSystemOperation.Allowsaccepts;AJobRunsAsTheSystemPrincipalWithOriginJobasserts both.NullJobEventSinkby registering anIJobEventSink, and moveJobFailedEventandJobDeadLetteredEventto the SDK if handlers there need them. They are internal for now. A sink that throws is logged and does not affect the job.cms.Jobs.AddJob<TJob, TPayload>("stable-name"); that name is whatcms_jobstores, so it must not change. Add schedules withIJobScheduler.AddRecurringAsync, which is safe at every startup on every instance. Nothing calls it for you yet, so these issues need a startup step. Make jobs safe to run again. A payload that should record who asked implementsIJobActorPayload.IJobSchedulerwrites on its own context, so a job can start before the caller's transaction commits, and survives a rollback. If that matters, enqueue after the commit./jobsendpoints:IJobDeadLettersis the service to call, andjobs.manageis theirs to enforce; the service checks no permission. There is no API yet to list live jobs or to change or remove a schedule, and nothing setscms_recurring_job.enabledto false.JobsBuilderis in core, and plugins reference only the SDK, so plugin jobs need a registration path.JobRunnerOptionsis set in code throughcms.Jobs.Options. It is not bound fromappsettings.JobStoreand migrations on real servers, and move the two-runner test onto the provider matrix.ix_cms_job_claimunderUPDLOCK, READPASTwithout a sort, so that concurrent claimers do not lock every due row. It depends on the filtered index matching the literal status filter.ISJSONrefuses scalar payloads on SQL Server. With no type constraint,ISJSONaccepts only an object or array, per its documentation. So the check oncms_job.payloadfails the enqueue of anIJob<int>orIJob<string>there, while SQLite and PostgreSQL accept it.ISJSON(x, VALUE)would allow scalars but needs SQL Server 2022, and the plan covers 2019. Validate and complete the PostgreSQL and SQL Server providers #2711 decides between a wrapper object and a rule that payloads are objects.Interpretations beyond the specification
Reported as a mismatch with the spec:
System"unless the payload carries an actor". Taken literally, the job would run as that user, and either be denied everything (a principal with no claims) or borrow that user's real roles onceIPrincipalRolesreads them from the database, chosen by whatever wrote the payload. So the principal staysPrincipal.System, and the actor goes to a newOperationContext.Actor, which authorization never reads.OperationContext.AuditPrincipalisActor ?? Principal. The actor is built from kind, id and name only, so a payload cannot bring claims.System,Anonymousor an empty id fails the attempt withFCMS2507. This adds two members toOperationContext. The spec may want rewording.cms_recurring_jobhas atypecolumn that the spec's column list lacks. A schedule has to say which job it fires, andnamealone would allow only one schedule per job type.AddRecurring(name, cron)isAddRecurringAsync<TJob, TPayload>(name, cron, payload, queue), because it writes to the database and needs the job and its input.Choices the spec leaves open:
IJobDeadLettersis new in the SDK, because the acceptance list asks for a service API and the spec names none.cms.Jobs.AddJobwith an explicit stable name.Enqueuerefuses an unregistered job (FCMS2504).cms_job_attemptis keyed on them. A retry from dead letter setsmax_attemptsto the attempts used plusJobRunnerOptions.DefaultMaxAttempts, because the table does not keep a job's original budget. A release at shutdown raises it by one, so a shutdown does not use up an attempt.ALeaseIsFencedByTheAttemptAsWellAsTheOwnerneeds that clause.pending,running,succeeded,dead_lettered. Attempt outcomes:succeeded,failed,lease_expired,released. A crashed attempt's row stays open until the next claim or sweep closes it, which also raisesJobFailedEvent.pendingjob with no attempts left, which the runner never writes but a claim would never take.last_errorandcms_job_attempt.errorhold exception types and messages, no stack trace, capped at 2000 characters. Every string, number and property name of the payload is replaced with***in the messages, in logs too. A value of 4 characters or more is replaced anywhere; a shorter one only as a whole word, becausepin 123has to go but12must not eat123. A payload that fails to deserialize records only the target type and the exception's type.next_run_at, so a restart does not skip an occurrence. A changed cron starts from now.ix_cms_job_claimis(queue, priority DESC, run_at, id)filtered to live jobs, which needs the claim's status filter as literals on all three dialects. Without the filter, the index would walk finished rows first, since they have the earliestrun_at.default, 5 attempts, 5s poll, 5m lease, 1m heartbeat, 15s maintenance, 30s to 1h backoff with up to 20% jitter, 20s shutdown grace plus 5s to write releases. Every host usingAddFluentCmsnow runs the runner; a queue with 0 workers takes nothing.site_idhas no foreign key, likecms_document.SystemDbContextwithExecuteUpdate, which keeps ids as EF stores them and avoids theUPDATE ... RETURNINGthat fails under load on SQLite.FCMS2501toFCMS2507are a new group for jobs.