Skip to content

jobs: add support for one-shot and scheduled jobs - #14155

Draft
glours wants to merge 17 commits into
docker:mainfrom
glours:jobs-engine-api
Draft

glours wants to merge 17 commits into
docker:mainfrom
glours:jobs-engine-api

Conversation

@glours

@glours glours commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

What I did
Added support for the jobs: top-level element from the Compose Specification: a job is a one-shot unit of work, distinct from a long-running servic, that fires either manually or on a schedule.

  • docker compose up registers a project's scheduled jobs with the
    engine, so they fire on the daemon's own clock from then on,
    independently of the Compose CLI staying attached. Manual jobs are
    registered but never fired by up.
  • docker compose run <job> (the same command already used for
    services) triggers a manual job: it starts the job's declared
    dependencies first, streams its output, and exits with the job's
    own exit code, exactly like a one-off service run.
  • docker compose down deregisters the project's jobs from the
    engine.
  • Re-running up on an unchanged project is a safe no-op. If a job's
    definition changed since it was registered, up (or run) reports
    a clear error asking the user to down the project first rather
    than silently applying the change.

This is an early, working implementation: it targets an experimental engine capability (gated behind a feature flag) that is not yet part of a released Docker Engine, so jobs are not usable against a standard engine today.

Related issue

Based on #14093

(not mandatory) A picture of a cute animal, if possible in relation to what you did
image

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 No issues found — LGTM! View logs.

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟡 NEEDS ATTENTION

Four medium-severity findings in the new jobs orchestration code: two nil pointer dereference risks (unguarded proto pointer fields after Wait and in createJobRun return paths), one log-streaming race where the container may not yet exist when the goroutine starts, and one typed-error loss on the RunJob CLI exit path.

Comment thread pkg/compose/jobs.go Outdated
logsDone := make(chan struct{})
go func() {
defer close(logsDone)
if err := s.streamJobLogs(ctx, created.ContainerID, svc.Tty); err != nil && ctx.Err() == nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] Log-streaming goroutine starts immediately with a potentially empty ContainerID

The goroutine at line 440 calls s.streamJobLogs(ctx, created.ContainerID, ...) before the container is guaranteed to exist. The RunReply and RunRequest docs state:

ContainerID is set once the container is created.

This means the engine may return a Run record (via CreateAndRun or Run) before the container is created, leaving ContainerID as an empty string at the time streamJobLogs is called. ContainerLogs("") will immediately fail with a "no such container" error. The error is swallowed at logrus.Debugf level and logsDone is closed — the user sees no output from the job even though it runs to completion.

To fix, either:

  1. Poll InspectRun until ContainerID is non-empty before starting the goroutine, or
  2. Issue a Wait call with WaitConditionRunning first so the container is guaranteed to exist before streaming.
Confidence Score
🟡 moderate 57/100

Comment thread pkg/compose/jobs.go
return 0, err
}

run := waited.Run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] Nil pointer dereference: waited.Run is not guarded after jc.Wait() succeeds

After jc.Wait() returns with a nil error (post MapError), run := waited.Run is assigned and immediately dereferenced in switch run.State. The proto-generated WaitReply.Run is a *Run pointer with omitempty — if the engine returns a successful reply with no run field set (e.g., a partial or malformed response), waited.Run is nil, and run.State panics.

MapError only maps gRPC status codes; it does not validate that the response payload is well-formed.

Suggested fix — add a nil guard before the switch:

run := waited.Run
if run == nil {
    return 1, fmt.Errorf("job %q: Wait returned no run", name)
}
switch run.State {
Confidence Score
🟡 moderate 67/100

Comment thread pkg/compose/jobs.go
if err := mapAlreadyExists(err, name, "run"); err != nil {
return nil, err
}
return reply.Run, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] createJobRun can return a nil *Run without an error, causing a nil dereference in the caller

Both return paths in createJobRun return reply.Run directly:

// manual job (line 492)
return reply.Run, nil

// scheduled job (line 503)
return reply.Run, nil

CreateAndRunReply.Run and RunReply.Run are both proto *Run pointer fields with omitempty, so they can be nil in a non-error reply. If createJobRun returns (nil, nil), the caller (RunJob) immediately dereferences created.ContainerID, created.JobID, and created.ID — all three are nil pointer dereferences.

Suggested fix — add a nil guard in createJobRun before returning:

if reply.Run == nil {
    return nil, fmt.Errorf("job %q: engine returned no run", name)
}
return reply.Run, nil

Apply the same guard to the RunRequest path as well.

Confidence Score
🟡 moderate 67/100

Comment thread cmd/compose/run.go Outdated
if err != nil {
errMsg = err.Error()
}
return cli.StatusError{StatusCode: exitCode, Status: errMsg}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] Non-zero exit code from RunJob with a non-nil error silently discards the typed error

When backend.RunJob returns (exitCode != 0, err != nil), the typed err is flattened to a plain string in cli.StatusError.Status:

errMsg = err.Error()
return cli.StatusError{StatusCode: exitCode, Status: errMsg}

Any errors.Is/errors.As inspection downstream is broken — callers cannot detect errdefs.ErrNotFound, errdefs.ErrNotImplemented, or other typed errors that RunJob can return (via jobsv0.MapError). For example, if the engine is unreachable, the typed "not implemented" error is lost and the caller sees only a string.

The RunOneOffContainer path below uses the same pattern (pre-existing code, out of scope), but the RunJob path is new in this PR.

Consider adding the typed error to the struct or returning it directly when no meaningful exit code is available:

return cli.StatusError{StatusCode: exitCode, Status: err.Error(), Err: err}

(if cli.StatusError has an Err field — otherwise return the typed error directly when exitCode == 0 and wrap with the exit code otherwise).

Confidence Score
🟡 moderate 70/100

ndeloof and others added 17 commits September 23, 2026 15:33
…go#866)

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
compose does not run jobs yet. Declared jobs are ignored with a
warning naming them — except on up when the project carries active
(profile-enabled) scheduled jobs: silently not scheduling them would
break the user's expectations, unlike manual jobs which simply wait
for an explicit trigger, so up fails with "scheduled jobs are not
supported in this version".

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
A job is a ContainerSpec plus a WorkloadSpec — the same layers a
service is made of — so `compose run <job>` materializes it as a
service for the one-off machinery: WithSelectedJob activates the job's
profile and narrows the project to its dependencies, then the job
joins Services under its own name. Dependencies declared by the job
start exactly as they would for a service, and the exit code flows
back as usual.

Only manual-trigger jobs are runnable: a schedule-only job is refused
with an explicit error. The up-side warning moves next to the
scheduled-jobs rejection and now points at `docker compose run` as the
way to trigger manual jobs, instead of calling them ignored.

The one-off project loading needs a second, unselected pass when the
target is not a service: the service selector cannot resolve a job.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Replace the "warn and reject" stub from the previous commits with real
calls into the engine's jobs gRPC extension, via the official
moby/extensions client: `up` registers scheduled jobs idempotently,
`run <job>` triggers manual jobs with CreateAndRun+Wait, streaming
their logs and propagating the exit code, and `down` deregisters a
project's jobs.

Also fixes a pre-existing bug in container label construction that
produced a non-deterministic depends_on label, which this feature's
spec-hash idempotency check turned into spurious "job has changed"
errors on an unmodified re-up.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
The pinned ndeloof/compose-go fork fell behind upstream/main and broke
the build after rebasing: it predates the unsupported-attributes
warning and WithoutUnresolvedOptionalDependencies added there since.

compose-go's main has since merged jobs support too, unreleased, so
depend on that commit directly instead of a divergent fork. Pinned to
11feead, just before a later, unrelated commit changes type=image
volume source resolution.

Adapts to type changes bundled with jobs support: TriggerConfig.Manual
is now *bool, and DependsOn/Networks/Image moved into
ContainerSpec/WorkloadSpec.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
job.Triggers != nil && job.Triggers.Manual != nil && !*job.Triggers.Manual
was copy-pasted at four independent sites across cmd/compose and
pkg/compose, each with its own slightly different error message. Extract
ManualTriggerDisabled/ManualTriggerDisabledErr (opt-out via manual: false)
and HasSchedule as the single source of truth, reused by
materializeManualJob/materializeJobClosure, manualJobNames, and RunJob.
Also fold the "job has changed" AlreadyExists message into jobChangedErr,
simplify sortedJobNames to build on the existing sortedMapKeys, and reuse
prepareBuildOptions in RunJob instead of re-deriving the same scoped build
options inline.

manualJobNames also switches from AllJobs (which includes profile-disabled
jobs) to Jobs, and now excludes scheduled jobs explicitly: a job with a
schedule is registered with the engine by up, not "ignored" the way a
manual-only job is.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
Auto-merged in from a parallel rebase, this block reloaded the project
unselected on "no such service" and returned early on success — skipping
the existing, more complete fallback right below it (which also calls
materializeManualJob and validates the target is actually a declared
job). A job target's own env_file would then resolve against the full,
unnarrowed project instead of the job's dependency-scoped one, exactly
what this function's env-resolution ordering exists to avoid.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
create wrapped its own RunE to translate a job-name selection failure
into a clear "job can only be triggered with docker compose run"
message, including a JSON-mode special case — the same translation
projectOrName already centralizes for its own callers (start, stop,
down, ...). up shares WithServices but had no such wrap, so `docker
compose up <job>` still leaked the raw compose-go selection error.

Move the translation into WithServices itself, covering both callers
uniformly (and any future one). This also drops create's now-redundant
JSON-mode special case: WithServices already runs through Adapt, which
already wraps every error in makeJSONError when display.Mode is JSON.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
jobTrigger's switch checked Manual before Schedule, so a job declaring
both triggers.manual: true and a schedule silently lost the schedule:
registerScheduledJobs still selects the job (HasSchedule doesn't look at
Manual), but jobTrigger built a Manual-only Trigger for it — the cron
never reaches the engine, with no error and no warning anywhere (up's
own warnIgnoredJobs also skips it, since HasSchedule is true).

Reject the combination explicitly instead, mirroring the existing
len(Schedule) > 1 case.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
RunJob never called resolveRunServiceReferences, unlike prepareRun's
equivalent service-run path. A job declaring volumes_from, or a
service:-scoped network_mode/ipc/pid, had that raw compose service name
sent straight to the engine instead of a resolved container ID — the
daemon has no notion of compose service names and would reject it (or
silently misbehave), the same class of bug pre_start's own hook
resolution exists to avoid.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
…del prep as a manual run

A manually-run job gets use_api_socket/build/models: support for free,
because materializeManualJob puts it into project.Services before
RunJob's normalization passes see it. registerScheduledJobs never did
this — the job never joined project.Services, by design, to avoid it
being picked up by the real service-reconciliation loop.

The most visible consequence: a scheduled job declaring only `build:`
(no `image:`) registered successfully at `up` and then failed on every
subsequent schedule fire, because its image was never built — up's own
auto-build, and `docker compose build`, both only ever scope to
project.ServiceNames(), which never includes jobs.

scopedProjectForJob materializes the job into a throwaway copy of the
project (a fresh Services map, nothing else touched) so
registerScheduledJobs can run it through useAPISocket/ensureImagesExists/
ensureModels exactly like RunJob does, then discards the copy — the job
never reaches the shared project the reconciliation loop iterates over.
Registration is also switched to run one goroutine per job via errgroup,
since each now does its own image pull/build round trip.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
`up --no-start` called backend.Create directly instead of backend.Up,
hand-replaying two of Up's three steps (create, then registration) and
skipping the third (start) — a pre-existing shortcut, not something
this branch introduced. It silently missed scheduled-job registration
entirely, since that only happens inside Up.

Considered exporting the (now build/socket/model-aware) registration
method on api.Compose instead, so --no-start could call it explicitly
after Create. Rejected: every other method on that interface — which is
documented for third-party programmatic use — maps 1:1 to a `docker
compose <verb>`; a method with no corresponding verb, existing only to
unblock one internal CLI wiring case, would be the one exception to
explain away.

Adding StartOptions.NoStart and checking it inside Up itself fixes the
actual defect instead of one visible symptom of it: the CLI stops
hand-replaying Up's internal sequence, so any future step Up gains
between create and start is covered by construction, not by remembering
to update a second copy of the orchestration. It also follows the
existing convention on the same struct (Attach == nil already gates
Up's foreground session as a mode switch) rather than introducing a new
one.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
TestUpRefusesJob never had its own compose.yaml, so it ran against no
config file at all instead of exercising the job-target error it's
meant to lock in. Caught by actually running the e2e suite rather than
just letting it compile.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
Verified empirically: `docker compose build/pull/push <job>` all leaked
compose-go's raw "no such service" error, unlike create/start/stop/logs/
ps/pause/down (via WithServices/projectOrName) — exec/attach/cp already
had clear enough errors of their own ("service is not running" / "no
container found"), so left untouched; only these three needed the fix.

jobTargetErrOr wraps jobTargetErr's replaced/not-replaced pair into a
plain error, reused by all three instead of repeating the same
if-replaced-return three times.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
`docker compose run backup` on a schedule-only job failed against the
real engine: "create-and-run serves manual jobs only; register schedule
jobs with create". The engine already supports firing an existing job
manually regardless of its trigger, through a dedicated RPC (Run) — what
it refuses is CreateAndRun (register-and-fire atomically) for a job
whose trigger is a schedule, since registering a cron must never imply
an immediate run.

createJobRun now routes accordingly: a manual-trigger job (the opt-out
default included) still goes through CreateAndRun unchanged; a scheduled
job goes through Create (idempotent on SpecHash — a no-op if `up`
already registered the identical spec, or a fresh registration if not)
then Run with Reschedule: false, so the manual fire adds to the cron
cadence instead of replacing its next occurrence. jobTrigger/buildJobSpec
are untouched: the spec sent to the engine still always reflects the
job's actually declared trigger.

Fixing this surfaced a second, latent bug the "already registered by
up" case exercises directly: RunJob's spec for the same job differed
depending on how it was reached. buildJobSpec relied on svc.CustomLabels
for most of the job's standard labels, but the two materialization paths
(RunJob's materializeManualJob vs registerScheduledJobs' own
scopedProjectForJob) populate it differently — buildJobSpec now computes
that label set itself instead. Separately, applyRunOptions unconditionally
sets Tty/StdinOpen/ContainerName from the run invocation's terminal
context, none of which RunJob's own doc comment lists as a supported
override (jobs have no interactive attach, and the engine assigns the
run container's identity itself) — RunJob now restores them to the
job's own declared values. Without both fixes, a schedule job already
registered by `up` spuriously conflicted on SpecHash the moment `run`
tried to fire it.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
RunJob was resetting a job's Tty/StdinOpen to false instead of its
own declared values, so a scheduled job with tty: true spuriously
conflicted with the spec already registered by up. streamJobLogs
always demuxed the log stream, which corrupts output for a job with
tty: true. scopedProjectForJob's per-job project copy left several
service fields (Environment, CustomLabels, Volumes, Configs) aliased
across concurrently-registering jobs, racing whenever another
service uses use_api_socket, models, or an image-type volume.

Also unifies job-to-service materialization behind a single
JobAsService helper and bounds scheduled-job registration to the
configured concurrency limit.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
Run/CreateAndRun/Run replies carry the run as an omitempty proto
pointer, so a successful call can still come back with no run at
all, and ContainerID stays empty until the daemon has actually
created the container.

Wait for the running condition before starting the log stream, skip
streaming when no container ever came up, and fail with a plain
error instead of dereferencing a nil run. Also keep RunJob's
underlying error attached to its StatusError so callers can still
inspect it with errors.Is/As.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants