Row Jobs
A row job is an async job started for one table row, from the row itself or from its expanded detail panel. The
browser side is JuneauViews.jobs in juneau-views.js, with helpers.jobButton as a ready-made button and the
console's job-log region as a declarative form. The server side is
JobKeyLedger and RowJobSubmit (package org.apache.juneau.console.views), which bind each submit key to the
row it was issued for. The job itself is an ordinary async job.
Running a job
const handle = JuneauViews.jobs.run({
el: button, // inside the row or its detail panel
endpoint: "/ops/species/{id}/restock",
action: "restock-species",
envelope: "action",
keyUrl: "/ops/species/{id}/key",
fields: { count: 3 },
trigger: button,
hooks: {
onProgress: line => log.append(line),
onEnd: ({ state, result, error }) => console.info(state, result, error)
}
});
await handle.done;
| Option | Meaning |
|---|---|
el | Required. An element inside the row, or inside the row's detail panel. It names the row, its table and its CSRF token. |
endpoint | Required. A same-origin path template. {id} is replaced with the row id, URL-encoded; any other {token} is refused. |
method | POST (default) or PUT. |
action | The action id. Required with envelope: "action"; also names the job on the bus and in the row marker. When the view declares a row action with this id, only its label is used. |
envelope | "flat" (default) sends {action, ...fields, idempotencyKey}; "action" sends {action, targetId, idempotencyKey, fields}. |
idempotencyKey | A key to send. Otherwise give keyUrl. |
keyUrl | A same-origin key-endpoint template ({id} only). The key is fetched and checked before the submit. |
fields | The values to send, already resolved. In the flat envelope action, targetId and idempotencyKey are reserved, and a null value is dropped. |
trigger | An element disabled from the call until the job ends. |
paint | "row" (default) paints progress and the outcome in the row's actions cell; "none" paints nothing and leaves it to the hooks. |
hooks | onStart(ref), onProgress(line), onResult(actionResult), onEnd({state, result, error}). A hook that throws is logged (E-JS-113) and the job carries on. |
signal | An AbortSignal. Aborting stops the request or closes the stream; it doesn't cancel a job the server has already started. |
run returns a frozen handle: done (a Promise of {state, result, error}), cancel() (posts to the job's
cancelUrl; the outcome still arrives as the result) and job (the job ref once it has started, else null).
state is succeeded, failed or cancelled from the job's outcome; refused when the submit or the result was a
refusal; or error for a transport failure, an untrusted stream URL or a browser without EventSource. A refusal
before anything is sent carries error.reason, from the same vocabulary as row-action refusals: row-job-running,
no-row-id, missing-token, unresolved-endpoint, key-refused and the endpoint refusals.
The submit's answer is settled like this:
| Response | Result |
|---|---|
202 and an AsyncJobRef | The stream opens from the start of the job's log (lastEventId=0), tolerating the browser's own reconnects. Its streamUrl and cancelUrl must be same-origin. |
2xx and an ActionResult | A synchronous result: onResult, then onEnd. No stream. |
4xx/5xx and an ActionResult (such as a 409 refusal) | onResult with the refusal or failure, then onEnd with refused or failed. |
| Anything else, or no answer | onEnd with error. |
The row
run finds the row from el: the detail panel's parent row when el is inside a panel, else the closest
tr[data-juneau-row-id] of a view table. The row id comes from that stamp only, never from a field or from row data,
and a panel and row that disagree, or a blank id, refuse no-row-id with nothing sent. A call outside any row is
allowed only without a row-bound key (not envelope: "action" with a keyUrl).
One job per row. A second run for a row whose job is still pending or running is refused with
row-job-running; helpers.jobButton and the job-log region share that rule, so they can't both run on one row.
While the job runs the row carries data-juneau-job and its row-action triggers are disabled. A table redraw
replaces the row's <tr>, so the marker is put back on the new row after every draw, and taken off whichever <tr>
is drawn when the job ends. The same applies to row-action jobs. An ordinary row action (rowActions.run, the row menu, a cell pill or a detail button) activated on a row whose job is pending or running is refused the same way: it shows the row-job-running refusal in the row's actions cell and sends nothing, and it runs normally once the job ends. While an expanded panel hosts the row's job, the
table's poll waits; see Regions.
Keys
JuneauViews.jobs.fetchKey({el, keyUrl, action}) posts {} to the key URL with the same CSRF header and credentials
as a write, and resolves to the key. It accepts only an answer of the form:
{ "contractVersion": "1", "action": "restock-species", "targetId": "DOG", "idempotencyKey": "<64 hex chars>" }
where action and targetId are the ones asked for. Anything else is refused with E-JS-109. The check catches a
misconfigured key endpoint; the server's verify is the security boundary.
A key lives until the server answers. It is fetched when first needed, reused after a failure with no answer (so a retry dedups on the server), and dropped on any answer, including a refusal. The next run gets a fresh key. A key-refusal is not retried with a fresh key automatically, since that could start a second job.
JuneauViews.jobs.active(el, action) returns the ref of the row's running job for action, or null.
JuneauViews.jobs.pending(el, action) returns, for a job still being keyed or submitted, a Promise of its ref once
it starts (or null if it ends first). A view that is rebuilt, such as a detail panel collapsed and expanded again,
uses these to re-attach instead of starting a second job.
Cancelling
JuneauViews.jobs.cancel(el, action) cancels the running job on el's row (a panel element maps to its row), and
returns a Promise<boolean>. It is true once the cancel request was answered ok, and false when it was refused or
failed. action filters as in active; null means "this row's job, whatever its action".
- It takes no URL. The request goes to the
cancelUrlthe server returned when the job started, with the row's CSRF token, and only if that URL is same-origin. Rewriting the ref thatactiveorhandle.jobreturns does not redirect it. falsewith nothing sent when the element is not in a row, the row has no job or the action doesn't match, the job is still pending (it has no job id yet), the CSRF token is blank, or the cancel URL is missing or untrusted.- Concurrent cancels share one request. The card's Cancel button, a re-attached panel, a progress banner's Cancel
and
handle.cancel()all send through the same path. An accepted cancel is not cached, so a later call sends again; the server answers a repeat idempotently. - The outcome arrives on the stream, as
cancelled,cancelled-after-effector, if the job finished first,succeeded. The row stays busy (marker,row-job-runningrefusals, poll hold) until that result. - Painting.
handle.cancel()andjobs.cancelpaint a failed cancel into the row's actions cell only for apaint: "row"job; apaint: "none"job reports failure itself.
A job button
JuneauViews.regions.register("regroom", function (ctx, container) {
container.appendChild(ctx.helpers.jobButton({
label: "Groom again",
endpoint: "/ops/groom-runs/{id}/regroom",
action: "regroom",
envelope: "action",
keyUrl: "/ops/groom-runs/{id}/key"
}));
});
helpers.jobButton(spec) builds a helpers.button from label and appearance (and tone, icon). A click calls
JuneauViews.jobs.run with the rest of spec, and the button as both el and trigger. spec.fields may be an
object or a function read at click. JuneauViews.jobs is looked up at click, so script order doesn't matter; when it
is missing the click logs E-JS-111 and sends nothing.
The server: JobKeyLedger and RowJobSubmit
A row job needs two endpoints: a key endpoint, and the submit endpoint that verifies the key.
private final JobKeyLedger jobKeys = JobKeyLedger.create();
@RestPost(path="/species/{id}/key")
public Map<String,Object> restockKey(@Path("id") String id, RestRequest req) {
var s = species(id); // 404 for a row the caller can't see
return JobKeyLedger.response(jobKeys.issue("restock-species", s.name(), actor(req)));
}
@RestPost(path="/species/{id}/restock")
public Object restock(@Path("id") String id, @Content JsonMap body, RestRequest req, RestResponse res) {
var s = species(id);
var submit = RowJobSubmit.parse(body);
if (! submit.targetId().equals(s.name()))
return refused(res, RowJobSubmit.bindingRefusal());
synchronized (jobKeys) { // verify reads the claim; claim records it
var grant = submit.verify(jobKeys, "restock-species", actor(req));
if (grant.isEmpty())
return refused(res, RowJobSubmit.bindingRefusal()); // 409, RowJobSubmit.REFUSAL_STATUS
var key = grant.get().key();
var claimed = grant.get().jobId();
if (claimed.isPresent())
return replay(res, claimed.get()); // the recorded job, or 409 SPENT_CODE
var count = count(submit.fields()); // validated after the replay check
var job = registry.tryCreate(key).orElse(null);
if (job == null)
return limitReached(res); // 429 app:job-limit
jobKeys.claim(key, job.id());
pool.execute(() -> run(job, s, count));
res.setStatus(202);
return AsyncJobRef.of(job);
}
}
The verify goes inside the lock, and the lock is the ledger's own monitor (its methods are synchronized). Two
concurrent first submits of one key both pass a verify done outside the lock with no jobId; tryCreate then hands
both the same job, and both would start it. Verifying inside the lock means the second sees the first's claim and
replays it.
JobKeyLedger is an in-memory record of issued keys, bounded and swept on access, in the same single-process
spirit as AsyncJobRegistry.
issue(action, targetId[, principal])mints a 256-bitIdempotencyKeybound to the action, the row id and, when given, the caller.response(key)is the key endpoint's body.verify(value, action, targetId[, principal])returns aGrantonly for a known, unexpired key issued for exactly that binding, compared in constant time. An unknown, expired, evicted or mismatched key all return empty and get the same refusal,write-guard:idempotency-binding-mismatch, so the answer never says that a key exists for some other row.claim(key, jobId)marks the key spent once its job is created; it is idempotent for the same job. A later verify returns the grant with thatjobId, and the endpoint answers with the job's ref while the registry still holds it, or with409write-guard:idempotency-key-spent(SPENT_CODE) after the registry has reaped it. A spent key never starts a second job.- Keys expire after
ttl(default 30 minutes). Each principal holds at mostmaxPerPrincipal(default 64) unspent keys per action, so a client minting in a loop only evicts its own oldest keys;maxEntries(default 10,000) is a global backstop. Without authentication every user shares one principal. clock(Clock)is for tests.
RowJobSubmit is the action envelope. parse(body) reads {action, targetId, idempotencyKey, fields} (a missing
fields is {}) and answers 400 for a blank action, targetId or key, or a fields that isn't an object.
verify(ledger, expectedAction[, principal]) checks the action, then the ledger. bindingRefusal() is the standard
refusal for a failed verify, sent with REFUSAL_STATUS (409). A flat-envelope row endpoint calls
ledger.verify(key, action, pathId) directly.
Rules for the endpoints:
- Resolve the caller the same way on both (for example
ConsoleWrites.actor(req)), because the key is bound to it. - The key endpoint applies the same authorization as the action. A key alone doesn't let anyone act (CSRF and the
session still apply), but keys for rows the caller can't act on leak a capability, and an early refusal gives a
clearer message. Answer
404for a row the caller can't see. It is aPOSTbehind CSRF, because minting writes ledger state. - Act on the verified
targetId. When the row id is in the path, check that it equalstargetId. Field values, including hidden and row-sourced presets, are request data the user can edit; never authorize on them or take the row from them. - Keep row endpoints ledger-only. An endpoint that also accepted a self-targeted key (
targetIdequal to the key) would be only as strong as that weaker mode. Serve dialogs and row jobs from separate endpoints.
Errors
| Code | When |
|---|---|
| E-JS-109 | fetchKey: the key endpoint refused, or its answer's contractVersion, action, targetId or key doesn't match. |
| E-JS-111 | helpers.jobButton was clicked without JuneauViews.jobs. |
| E-JS-113 | A hook passed to run threw. The job carries on. |