Skip to main content

Async Jobs

A row action normally returns a terminal ActionResult. When the write is asynchronous, the same POST instead returns an AsyncJobRef, and the runtime streams the job's progress to the browser over Server-Sent Events. Nothing about the RowAction declaration changes; the runtime switches to the streaming path purely because the 2xx body contains a streamUrl.

Wiring it up​

Compose AsyncJobsMixin and supply a registry:

@Rest(path="/console", mixins={ViewsMixin.class, AsyncJobsMixin.class})
public class ConsoleResource extends BasicRestServlet implements AsyncJobsMixin {

private final AsyncJobRegistry jobs = new AsyncJobRegistry();

@Override
public AsyncJobRegistry asyncJobRegistry() { return jobs; }
}

The mixin serves two operations under AsyncJobsMixin.JOBS_PREFIX (/juneau-jobs):

OperationPathBehavior
GET/juneau-jobs/{jobId}/streamStreams progress events, each with an SSE id, then sends one result event carrying the terminal ActionResult, and closes. A fresh attach is live-only; a Last-Event-ID header or lastEventId query parameter resumes from a position (see Resuming a stream). Unknown id is 404; a bad resume value is 400; a job at its subscriber cap is 429.
POST/juneau-jobs/{jobId}/cancelCancels the job.

Starting a job from an action​

@RestPost(path="/incidents/{id}/reindex")
public AsyncJobRef reindex(@Path("id") String id) {
var job = jobs.create(); // or jobs.create(IdempotencyKey) to de-duplicate
executor.submit(() -> {
job.progress("Starting...\n");
// ... do the work ...
job.complete(ActionResult.ok());
});
return AsyncJobRef.of(job);
}

The table-row action banner attaches live-only, so it shows "Working…" until the next progress line or the result. A line written straight after create(), like the "Starting..." above, is usually written before the browser attaches and is then never shown.

AsyncJobRef carries jobId, streamUrl and cancelUrl. The URLs are servlet:-relative and @Uri-annotated so every response serializer resolves them against the current request.

AsyncJobRegistry.create() throws IllegalStateException when the concurrency cap is reached; tryCreate() returns an empty Optional instead. create(IdempotencyKey) and tryCreate(IdempotencyKey) return the existing job for a key that is already registered, including a finished one within its retention window, so a double-click or retry collapses to one job.

Resuming a stream​

The progress event built from the job's i-th buffered progress line is always sent with SSE id i+1; the result event has no id. The job keeps every progress line for its whole life, so an id always names the same line.

The stream picks its start position from a resume value, in this order:

  1. The Last-Event-ID header, if it is non-empty after trimming spaces and tabs. A browser's EventSource sends it by itself when it reconnects a dropped stream, to the same URL.
  2. Otherwise the lastEventId query parameter, if it is non-empty after trimming. ?lastEventId=0 asks for the whole history; the Job Log card always connects this way.
  3. Otherwise there is no value, and the attach is live-only: only progress written after the client attached is sent.

A value k means "the events with ids 1..k were delivered", so the stream continues with id k+1. The value must be 1 to 10 ASCII digits (so +1, -1, 1.0 or 0x2 are refused). Any k from 0 up to the number of progress lines written so far is valid; k equal to that number resumes with nothing to replay. Only a malformed value or a k past that number is refused, with 400 and no body. The checks run in the order 404 (unknown job), then 400 (bad resume value), then 429 (subscriber cap), so a bad value cannot reveal whether a job exists and never holds a subscriber slot.

A job that has already ended still sends its result on every attach: a fresh attach gets only the result, and a resume gets the remaining progress and then the result.

Security properties​

  • The job id is the access control. It is a 256-bit SecureRandom value, hex-encoded. A browser EventSource cannot set a CSRF header, which is why the id must be unguessable rather than CSRF-gated. An unknown id is indistinguishable from any other miss, so the id space cannot be enumerated. Treat the stream URL as a secret.
  • The caps are enforced on the server. AsyncJobRegistry.MAX_CONCURRENT_JOBS is 8, the hard per-job timeout AsyncJobRegistry.HARD_TIMEOUT is 120 seconds (configurable through the AsyncJobRegistry(Duration) constructor), each job caps its streamed output at AsyncJob.maxOutputBytes() and its concurrent subscribers at AsyncJob.maxSubscribers(). A job over its output cap is terminated with a failure.
  • Single process. Job state lives in the process heap. A restart drops every in-flight job, exactly as it invalidates every CSRF token.
  • A job whose timeout fires or that is cancelled after it had an effect reports cancelled-after-effect; call AsyncJob.markEffectStarted() when the irreversible part begins so the outcome is reported truthfully.

See Row Actions and Bulk Mutation for ActionResult and IdempotencyKey.