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):
| Operation | Path | Behavior |
|---|---|---|
GET | /juneau-jobs/{jobId}/stream | Streams 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}/cancel | Cancels 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:
- The
Last-Event-IDheader, if it is non-empty after trimming spaces and tabs. A browser'sEventSourcesends it by itself when it reconnects a dropped stream, to the same URL. - Otherwise the
lastEventIdquery parameter, if it is non-empty after trimming.?lastEventId=0asks for the whole history; the Job Log card always connects this way. - 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
SecureRandomvalue, hex-encoded. A browserEventSourcecannot 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_JOBSis 8, the hard per-job timeoutAsyncJobRegistry.HARD_TIMEOUTis 120 seconds (configurable through theAsyncJobRegistry(Duration)constructor), each job caps its streamed output atAsyncJob.maxOutputBytes()and its concurrent subscribers atAsyncJob.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; callAsyncJob.markEffectStarted()when the irreversible part begins so the outcome is reported truthfully.
See Row Actions and Bulk Mutation for ActionResult and IdempotencyKey.