You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@asterixdb.apache.org by mb...@apache.org on 2026/09/15 16:22:11 UTC
(asterixdb) branch master updated: [ASTERIXDB-3593][API] Report the request, not its first job
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/asterixdb.git
The following commit(s) were added to refs/heads/master by this push:
new 90b64cc93e [ASTERIXDB-3593][API] Report the request, not its first job
90b64cc93e is described below
commit 90b64cc93ed272b523d9b1bfad8f7cd54a059732
Author: Murtadha Hubail <mu...@couchbase.com>
AuthorDate: Wed Sep 9 14:39:11 2026 +0300
[ASTERIXDB-3593][API] Report the request, not its first job
- user model changes: no
- storage format changes: no
- interface changes: yes
Details: The flat job fields of a request record described its first
job, so a request whose second statement failed reported the first
statement's TERMINATED status and no error at all, and an alert reading
jobStatus or error missed it. The value was wrong where it was not
absent, which no reader could tell.
They describe the request itself now:
- jobCreateTime and jobStartTime are the first job's, and jobEndTime the
last job's, once every job of the request has finished
- jobQueueTime is the total over the jobs, which do not wait at the same
time, the statements running one after another
- jobRequiredCPUs and jobRequiredMemory are the peak over the jobs, for
the same reason, rather than their sum
- jobStatus is the failure if any job failed, and otherwise the least
finished of them; error is that failing job's
- jobId is null where the request carries several statements, no one of
whose jobs identifies it - from the moment it is tracked, so that the
id a reader has does not change as the next statement starts. That is
what a request that created no job has always reported, and what the
API documents
- plan is reported by the same rule, being the plan of the one job that
is the request: several statements' plans belong to their statements
and are reported on their jobs
The jobs array is written for every request now, one job or several, so
that a reader has one place that is right whatever the request carried,
and each entry says which statement submitted it. Statements that submit
no job leave a gap, so a job's position in the array does not identify
its statement, and the position is reported as the response reports it.
A request of one statement reports what it always did: its only job is
the roll-up of itself.
Ext-ref: MB-62708
Co-Authored-By: Claude Opus 5 (1M context) <no...@anthropic.com>
Change-Id: I63bbf8898013ae52b530d201a107bc56f2c4fe87
Reviewed-on: https://asterix-gerrit.ics.uci.edu/c/asterixdb/+/21726
Reviewed-by: Ali Alsuliman <al...@gmail.com>
Integration-Tests: Jenkins <je...@fulliautomatix.ics.uci.edu>
Tested-by: Janhavi Tripurwar <ja...@apache.org>
---
.../apache/asterix/translator/ClientRequest.java | 242 ++++++++++++++++++---
.../asterix/app/translator/QueryTranslator.java | 6 +
.../asterix/test/translator/ClientRequestTest.java | 179 +++++++++++++--
3 files changed, 382 insertions(+), 45 deletions(-)
diff --git a/asterixdb/asterix-algebra/src/main/java/org/apache/asterix/translator/ClientRequest.java b/asterixdb/asterix-algebra/src/main/java/org/apache/asterix/translator/ClientRequest.java
index fc09bf0268..4f75a1e0de 100644
--- a/asterixdb/asterix-algebra/src/main/java/org/apache/asterix/translator/ClientRequest.java
+++ b/asterixdb/asterix-algebra/src/main/java/org/apache/asterix/translator/ClientRequest.java
@@ -36,6 +36,7 @@ import org.apache.hyracks.api.job.resource.IReadOnlyClusterCapacity;
import org.apache.hyracks.api.util.ExceptionUtils;
import org.apache.hyracks.util.LogRedactionUtil;
import org.apache.hyracks.util.StorageUtil;
+import org.apache.hyracks.util.annotations.AiProvenance;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -61,6 +62,8 @@ public class ClientRequest extends BaseClientRequest {
private final List<RequestJob> jobs = new ArrayList<>();
private final Object jobsLock = new Object();
private volatile long compileTimeNanos;
+ private volatile int statementPosition;
+ private volatile boolean multiStatement;
public ClientRequest(ICommonRequestParameters requestParameters) {
super(requestParameters.getRequestReference());
@@ -103,7 +106,9 @@ public class ClientRequest extends BaseClientRequest {
synchronized (jobsLock) {
// registers the job as well, so that a handle of it is authorised whether or not the creation
// notification has been delivered yet
- jobStateOf(jobId).compileTimeNanos = compileTimeNanos;
+ RequestJob job = jobOf(jobId);
+ job.state.compileTimeNanos = compileTimeNanos;
+ job.statementPosition = statementPosition;
}
setRunning();
return true;
@@ -232,6 +237,29 @@ public class ClientRequest extends BaseClientRequest {
this.compileTimeNanos = compileTimeNanos;
}
+ /**
+ * Staged and copied onto the job by {@link #addJob}, as the compile time is: it is set as each statement
+ * starts, so the value last staged is the submitting statement's.
+ *
+ * @param statementPosition the statement's position among the client's, counting from 1; 0 for a statement
+ * of the request's own, which is not the client's to see
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.GENERATED)
+ public void setStatementPosition(int statementPosition) {
+ this.statementPosition = statementPosition;
+ }
+
+ /**
+ * Recorded before the first statement runs, so that what the request reports as its own does not depend
+ * on how many jobs it happens to have created by the time it is read. See {@link #requestJob}.
+ *
+ * @param multiStatement whether the request carries more than one statement of the client's own
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.GENERATED)
+ public void setMultiStatement(boolean multiStatement) {
+ this.multiStatement = multiStatement;
+ }
+
@Override
public ObjectNode asJson() {
ObjectNode json = super.asJson();
@@ -248,11 +276,9 @@ public class ClientRequest extends BaseClientRequest {
putJobDetails(json, redact);
json.put("statement", redact ? LogRedactionUtil.statement(statement) : statement);
json.put("clientContextID", clientContextId);
- // the flat plan describes the first job, as the flat job fields do
- String plan;
- synchronized (jobsLock) {
- plan = jobs.isEmpty() ? null : jobs.getFirst().state.plan;
- }
+ // the plan describes the request, so it is reported here only where the request has one of its own,
+ // by the same rule as the job id it belongs to; a statement's plan is reported on that statement's job
+ String plan = requestPlan();
if (plan != null) {
json.put("plan", redact ? LogRedactionUtil.userData(plan) : plan);
}
@@ -298,12 +324,18 @@ public class ClientRequest extends BaseClientRequest {
// must be called while holding jobsLock
private JobState jobStateOf(JobId jobId) {
+ return jobOf(jobId).state;
+ }
+
+ /** @return this request's job of the given id, adopting it if it is not known here yet */
+ // must be called while holding jobsLock
+ private RequestJob jobOf(JobId jobId) {
RequestJob job = findJob(jobId);
if (job == null) {
job = new RequestJob(jobId);
jobs.add(job);
}
- return job.state;
+ return job;
}
// must be called while holding jobsLock
@@ -316,27 +348,31 @@ public class ClientRequest extends BaseClientRequest {
return null;
}
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.REFACTORED)
private void putJobDetails(ObjectNode json, boolean redact) {
try {
List<RequestJob> requestJobs;
synchronized (jobsLock) {
requestJobs = List.copyOf(jobs);
}
- // The flat fields describe the first job, so that they - and the flat plan with them - stop
- // changing as the request moves from one statement to the next. What every statement did is in
- // the jobs array, which is where a later statement's status and error are read.
- RequestJob firstJob = requestJobs.isEmpty() ? null : requestJobs.get(0);
- json.put("jobId", firstJob != null ? firstJob.jobId.toString() : null);
- putJobState(json, firstJob != null ? firstJob.state : EMPTY_JOB_STATE, redact);
- if (requestJobs.size() > 1) {
- ArrayNode jobsJson = json.putArray("jobs");
- for (RequestJob job : requestJobs) {
- ObjectNode jobJson = jobsJson.addObject();
- jobJson.put("jobId", job.jobId.toString());
- putJobState(jobJson, job.state, redact);
- if (job.state.plan != null) {
- jobJson.put("plan", redact ? LogRedactionUtil.userData(job.state.plan) : job.state.plan);
- }
+ // The flat fields describe the request as a whole: one job reports itself, as it always has, and
+ // several roll up - see rollUp. What each job did on its own is in the jobs array.
+ RequestJob requestJob = requestJob(requestJobs);
+ json.put("jobId", requestJob != null ? requestJob.jobId.toString() : null);
+ putJobState(json, rollUp(requestJobs), queueTimeMillis(requestJobs), redact);
+ // written for every request, one job or not, so that a reader has one place that is always right
+ ArrayNode jobsJson = json.putArray("jobs");
+ for (RequestJob job : requestJobs) {
+ ObjectNode jobJson = jobsJson.addObject();
+ // the statement that submitted the job, named as the response names it. Not every statement
+ // submits a job, so a job's position in this array does not identify the statement.
+ if (job.statementPosition > 0) {
+ jobJson.put("statement", job.statementPosition);
+ }
+ jobJson.put("jobId", job.jobId.toString());
+ putJobState(jobJson, job.state, queueTimeMillis(job.state), redact);
+ if (job.state.plan != null) {
+ jobJson.put("plan", redact ? LogRedactionUtil.userData(job.state.plan) : job.state.plan);
}
}
} catch (Throwable th) {
@@ -344,20 +380,132 @@ public class ClientRequest extends BaseClientRequest {
}
}
- private static void putJobState(ObjectNode json, JobState state, boolean redact) {
+ /**
+ * The job that is this request, and whose id and plan the request reports as its own: the single job
+ * that a request of a single statement submits. A request of several statements has no such job - no one
+ * of its jobs is the request - and reports none for its whole life, rather than reporting its first
+ * job's id until a second statement submits a job and dropping it then. Null is what a request that
+ * created no job has always reported, and what the API documents.
+ *
+ * @return that job, or null where no one job is the request
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.GENERATED)
+ private RequestJob requestJob(List<RequestJob> requestJobs) {
+ return !multiStatement && requestJobs.size() == 1 ? requestJobs.get(0) : null;
+ }
+
+ /**
+ * What the flat fields report for the request as a whole. A request with a single job reports that job's
+ * state unchanged; several roll up to the first job's creation and start, the last job's end once they
+ * have all finished, the peak of what they required - they run one at a time - and the first error any of
+ * them reported, which for a request that stops at its first failing statement is that statement's.
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.GENERATED)
+ private static JobState rollUp(List<RequestJob> requestJobs) {
+ if (requestJobs.isEmpty()) {
+ return EMPTY_JOB_STATE;
+ }
+ if (requestJobs.size() == 1) {
+ return requestJobs.get(0).state;
+ }
+ JobState rolled = new JobState();
+ boolean allFinished = true;
+ for (RequestJob job : requestJobs) {
+ JobState state = job.state;
+ rolled.createTime = earlier(rolled.createTime, state.createTime);
+ rolled.startTime = earlier(rolled.startTime, state.startTime);
+ rolled.endTime = Math.max(rolled.endTime, state.endTime);
+ rolled.requiredCPUs = Math.max(rolled.requiredCPUs, state.requiredCPUs);
+ rolled.requiredMemoryInBytes = Math.max(rolled.requiredMemoryInBytes, state.requiredMemoryInBytes);
+ allFinished &= isTerminal(state.status);
+ if (rolled.errorMsg == null) {
+ rolled.errorMsg = state.errorMsg;
+ }
+ }
+ if (!allFinished) {
+ // a job of the request is still to end, so the end of the ones that have is not the request's
+ rolled.endTime = 0;
+ }
+ rolled.status = rollUpStatus(requestJobs);
+ return rolled;
+ }
+
+ /**
+ * The status of the request's jobs as one: the failure if any of them failed - the request stops at its
+ * first failing statement - and otherwise the least finished of them, so that a request still doing
+ * something does not report itself terminated. Null, as a single job's unreported status is, where a job
+ * has been submitted and its creation not yet notified.
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.GENERATED)
+ private static JobStatus rollUpStatus(List<RequestJob> requestJobs) {
+ JobStatus failure = null;
+ boolean running = false;
+ boolean pending = false;
+ boolean unreported = false;
+ for (RequestJob job : requestJobs) {
+ JobStatus status = job.state.status;
+ if (status == null) {
+ unreported = true;
+ continue;
+ }
+ switch (status) {
+ case FAILURE:
+ case FAILURE_BEFORE_EXECUTION:
+ if (failure == null) {
+ failure = status;
+ }
+ break;
+ case RUNNING:
+ running = true;
+ break;
+ case PENDING:
+ pending = true;
+ break;
+ default:
+ break;
+ }
+ }
+ if (failure != null) {
+ return failure;
+ } else if (running) {
+ return JobStatus.RUNNING;
+ } else if (pending) {
+ return JobStatus.PENDING;
+ }
+ return unreported ? null : JobStatus.TERMINATED;
+ }
+
+ /** @return the earlier of two times, either of which may be unset */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.GENERATED)
+ private static long earlier(long time, long otherTime) {
+ if (time == 0) {
+ return otherTime;
+ }
+ return otherTime == 0 ? time : Math.min(time, otherTime);
+ }
+
+ /**
+ * @return the plan of the request, where it has one of its own: the plan of the job that is the request,
+ * as the reported id is that job's - see {@link #requestJob}. A request of several statements has
+ * no plan of its own; each of their plans belongs to its statement, and is reported on that
+ * statement's job.
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.GENERATED)
+ private String requestPlan() {
+ RequestJob requestJob;
+ synchronized (jobsLock) {
+ requestJob = requestJob(jobs);
+ }
+ return requestJob != null ? requestJob.state.plan : null;
+ }
+
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.REFACTORED)
+ private static void putJobState(ObjectNode json, JobState state, long queueTimeMillis, boolean redact) {
AMutableDateTime dateTime = new AMutableDateTime(0);
putTime(json, state.createTime, "jobCreateTime", dateTime);
putTime(json, state.startTime, "jobStartTime", dateTime);
putTime(json, state.endTime, "jobEndTime", dateTime);
- long queueTime = 0;
- if (state.createTime > 0) {
- // startTime - createTime, if job has started
- // endTime - createTime, if job has ended but not started (failed while in the queue, cancelled/timeout)
- // currentTime - createTime, if job is still in the queue
- queueTime = (state.startTime > 0 ? state.startTime
- : (state.endTime > 0 ? state.endTime : System.currentTimeMillis())) - state.createTime;
- }
- json.put("jobQueueTime", TimeUnit.MILLISECONDS.toSeconds(queueTime));
+ json.put("jobQueueTime", TimeUnit.MILLISECONDS.toSeconds(queueTimeMillis));
json.put("jobStatus", String.valueOf(state.status));
json.put("jobRequiredCPUs", state.requiredCPUs);
json.put("jobRequiredMemory", state.requiredMemoryInBytes);
@@ -366,6 +514,34 @@ public class ClientRequest extends BaseClientRequest {
}
}
+ /**
+ * @return the time the request spent queued: the sum over its jobs, whose queue intervals do not overlap,
+ * its statements running one at a time
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.GENERATED)
+ private static long queueTimeMillis(List<RequestJob> requestJobs) {
+ long queueTime = 0;
+ for (RequestJob job : requestJobs) {
+ queueTime += queueTimeMillis(job.state);
+ }
+ return queueTime;
+ }
+
+ /**
+ * @return startTime - createTime, if the job has started; endTime - createTime, if it ended without
+ * starting (failed while in the queue, cancelled/timeout); currentTime - createTime, if it is
+ * still in the queue
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.GENERATED)
+ private static long queueTimeMillis(JobState state) {
+ if (state.createTime == 0) {
+ return 0;
+ }
+ long queuedUntil = state.startTime > 0 ? state.startTime
+ : (state.endTime > 0 ? state.endTime : System.currentTimeMillis());
+ return queuedUntil - state.createTime;
+ }
+
private static void putTime(ObjectNode json, long time, String label, AMutableDateTime dateTime) {
if (time > 0) {
dateTime.setValue(time);
@@ -391,6 +567,8 @@ public class ClientRequest extends BaseClientRequest {
private static class RequestJob {
final JobId jobId;
final JobState state = new JobState();
+ /** The position of the statement that submitted the job; 0 where it is not known. */
+ volatile int statementPosition;
RequestJob(JobId jobId) {
this.jobId = jobId;
diff --git a/asterixdb/asterix-app/src/main/java/org/apache/asterix/app/translator/QueryTranslator.java b/asterixdb/asterix-app/src/main/java/org/apache/asterix/app/translator/QueryTranslator.java
index 3627dca8dc..98db7755ab 100644
--- a/asterixdb/asterix-app/src/main/java/org/apache/asterix/app/translator/QueryTranslator.java
+++ b/asterixdb/asterix-app/src/main/java/org/apache/asterix/app/translator/QueryTranslator.java
@@ -422,6 +422,9 @@ public class QueryTranslator extends AbstractLangTranslator implements IStatemen
// the client's statements are numbered from 1; the request's own dataverse declaration is not one
int statementPosition = 0;
final boolean multiStatementRequest = isMultiStatementRequest();
+ // recorded before any job is created, so that the request reports the same job id - its own job's,
+ // or none where it has several statements - from the first time it is read to the last
+ ((ClientRequest) clientRequest).setMultiStatement(multiStatementRequest);
try {
for (Statement stmt : statements) {
// each statement decides for itself whether it can be canceled
@@ -447,6 +450,9 @@ public class QueryTranslator extends AbstractLangTranslator implements IStatemen
// recorded before the statement runs, so that a statement that fails is reported too
StatementInfo statementInfo =
beginStatement(outMetadata, synthetic ? 0 : statementPosition, stmt, multiStatementRequest);
+ // staged for the job this statement submits, so that the request's record says which
+ // statement each of its jobs ran
+ ((ClientRequest) clientRequest).setStatementPosition(synthetic ? 0 : statementPosition);
final Stats statsBeforeStatement = stats.snapshot();
try {
validateStatement(stmt, requestParameters);
diff --git a/asterixdb/asterix-app/src/test/java/org/apache/asterix/test/translator/ClientRequestTest.java b/asterixdb/asterix-app/src/test/java/org/apache/asterix/test/translator/ClientRequestTest.java
index 7357e5f979..0e179f1781 100644
--- a/asterixdb/asterix-app/src/test/java/org/apache/asterix/test/translator/ClientRequestTest.java
+++ b/asterixdb/asterix-app/src/test/java/org/apache/asterix/test/translator/ClientRequestTest.java
@@ -44,6 +44,7 @@ import org.apache.hyracks.api.job.JobId;
import org.apache.hyracks.api.job.JobStatus;
import org.apache.hyracks.api.job.resource.IJobCapacityController;
import org.apache.hyracks.api.job.resource.IReadOnlyClusterCapacity;
+import org.apache.hyracks.util.annotations.AiProvenance;
import org.junit.Test;
import org.mockito.Mockito;
@@ -140,12 +141,67 @@ public class ClientRequestTest {
assertEquals(1_000_000, request.getCompileTimeNanos(JOB_1));
assertEquals(2_000_000, request.getCompileTimeNanos(JOB_2));
assertEquals("a job of another request has none", 0, request.getCompileTimeNanos(JOB_3));
- // the flat plan describes the first job, as the flat job fields do
- assertEquals("plan of statement one", request.asJson().get("plan").asText());
+ // each plan belongs to its statement, so neither of them is the request's
+ assertNull("the request has no plan of its own", request.asJson().get("plan"));
assertEquals("plan of statement one", request.asJson().get("jobs").get(0).get("plan").asText());
assertEquals("plan of statement two", request.asJson().get("jobs").get(1).get("plan").asText());
}
+ /** The plan of the one job of a single statement is the request's own, as its id is. */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.TEST_GENERATED)
+ @Test
+ public void thePlanOfASingleStatementIsTheRequestsOwn() {
+ ClientRequest request = newRequest();
+ request.addJob(JOB_1);
+ request.setPlan(JOB_1, "plan of the only statement");
+
+ assertEquals("plan of the only statement", request.asJson().get("plan").asText());
+ }
+
+ /**
+ * A request of several statements has no plan of its own, whether or not one of them has yet produced a
+ * plan: the plan is reported on the statement's job, where it does not appear and then vanish as the
+ * next statement compiles its own.
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.TEST_GENERATED)
+ @Test
+ public void aMultiStatementRequestHasNoPlanOfItsOwn() {
+ ClientRequest request = newRequest();
+ request.setMultiStatement(true);
+ request.addJob(JOB_1);
+ request.setPlan(JOB_1, "plan of the first statement");
+
+ JsonNode json = request.asJson();
+ assertNull("the plan belongs to the statement, not to the request", json.get("plan"));
+ assertEquals("plan of the first statement", json.get("jobs").get(0).get("plan").asText());
+ }
+
+ /**
+ * What identifies a request does not change while a reader watches it: a request of several statements
+ * reports no jobId from the moment it is tracked, rather than reporting its first statement's job and
+ * dropping it when the second statement submits one.
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.TEST_GENERATED)
+ @Test
+ public void aMultiStatementRequestReportsNoJobIdWhileItsFirstStatementRuns() {
+ ClientRequest request = newRequest();
+ request.setMultiStatement(true);
+ assertTrue("no job has been created yet", request.asJson().get("jobId").isNull());
+
+ request.setStatementPosition(1);
+ request.addJob(JOB_1);
+ request.jobStarted(JOB_1);
+ JsonNode json = request.asJson();
+ assertTrue("no one job id is the id of a request of several statements", json.get("jobId").isNull());
+ assertEquals("the job the first statement submitted is in the array", JOB_1.toString(),
+ json.get("jobs").get(0).get("jobId").asText());
+
+ request.setStatementPosition(2);
+ request.addJob(JOB_2);
+ assertTrue("and it is still not reported once the second statement runs",
+ request.asJson().get("jobId").isNull());
+ }
+
@Test
public void onlyJobsWithoutAPendingResultAreSwept() {
ClientRequest request = newRequest();
@@ -166,20 +222,25 @@ public class ClientRequestTest {
assertFalse(request.hasPendingResults());
}
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.REFACTORED)
@Test
- public void aRequestWithOneJobReportsItFlat() {
+ public void aRequestWithOneJobReportsItFlatAndInTheArray() {
ClientRequest request = newRequest();
runJob(request, JOB_1, JobStatus.TERMINATED, null);
- assertEquals(JOB_1.toString(), request.asJson().get("jobId").asText());
- assertNull("a single job is reported flat, as it always was", request.asJson().get("jobs"));
+ JsonNode json = request.asJson();
+ assertEquals("the flat fields are the job's, as they always were", JOB_1.toString(),
+ json.get("jobId").asText());
+ assertEquals("every request reports its jobs, so a reader has one place", 1, json.get("jobs").size());
+ assertEquals(JOB_1.toString(), json.get("jobs").get(0).get("jobId").asText());
}
/**
* A request with several jobs reports them in creation order, each entry carrying the fields the one
- * flat job block carried and no others. The flat fields stay on the first job, so a later statement's
- * failure is read from its own entry - the per-job compile time is not reported here, and was not.
+ * flat job block carried and no others - the per-job compile time is not reported here, and was not.
+ * The flat fields describe the request, so a later statement's failure is the request's failure.
*/
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.REFACTORED)
@Test
public void severalJobsAreReportedInOrderWithTheFieldsAJobAlwaysHad() {
ClientRequest request = newRequest();
@@ -200,12 +261,93 @@ public class ClientRequestTest {
failed.add("error");
assertEquals(failed, fieldsOf(json.get("jobs").get(1)));
- assertEquals(JOB_1.toString(), json.get("jobId").asText());
- assertEquals(JobStatus.TERMINATED.name(), json.get("jobStatus").asText());
- assertNull("the first statement did not fail", json.get("error"));
+ assertTrue("no one job id describes a request of several", json.get("jobId").isNull());
+ assertEquals("a request whose statement failed is a failed request", JobStatus.FAILURE.name(),
+ json.get("jobStatus").asText());
+ assertEquals("statement two failed", json.get("error").asText());
assertEquals("statement two failed", json.get("jobs").get(1).get("error").asText());
}
+ /**
+ * The flat times span the request: the first job's creation and start, and the last job's end, so that a
+ * reader is not told the request ended when its first statement did.
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.TEST_GENERATED)
+ @Test
+ public void theFlatTimesSpanEveryJob() throws Exception {
+ ClientRequest request = newRequest();
+ runJob(request, JOB_1, JobStatus.TERMINATED, null);
+ // so that the two jobs cannot fall in the same millisecond, and the times can be told apart
+ Thread.sleep(2);
+ runJob(request, JOB_2, JobStatus.TERMINATED, null);
+
+ JsonNode json = request.asJson();
+ JsonNode firstJob = json.get("jobs").get(0);
+ JsonNode lastJob = json.get("jobs").get(1);
+ assertEquals(firstJob.get("jobCreateTime").asText(), json.get("jobCreateTime").asText());
+ assertEquals(firstJob.get("jobStartTime").asText(), json.get("jobStartTime").asText());
+ assertEquals(lastJob.get("jobEndTime").asText(), json.get("jobEndTime").asText());
+ }
+
+ /** A request one of whose jobs has not ended has not ended either, whatever the others did. */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.TEST_GENERATED)
+ @Test
+ public void theRequestDoesNotEndWhileAJobOfItsHasNot() {
+ ClientRequest request = newRequest();
+ runJob(request, JOB_1, JobStatus.TERMINATED, null);
+ request.addJob(JOB_2);
+ request.jobCreated(JOB_2, capacity(1, 172032L), IJobCapacityController.JobSubmissionStatus.EXECUTE);
+ request.jobStarted(JOB_2);
+
+ JsonNode json = request.asJson();
+ assertEquals(JobStatus.RUNNING.name(), json.get("jobStatus").asText());
+ assertNull("a job of the request is still to end", json.get("jobEndTime"));
+ assertEquals("the job that did end reports its own end", JobStatus.TERMINATED.name(),
+ json.get("jobs").get(0).get("jobStatus").asText());
+ }
+
+ /** The jobs run one at a time, so what the request required is the peak of them, not their sum. */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.TEST_GENERATED)
+ @Test
+ public void theFlatRequirementIsThePeakOfTheJobs() {
+ ClientRequest request = newRequest();
+ runJob(request, JOB_1, capacity(1, 172032L), JobStatus.TERMINATED, null);
+ runJob(request, JOB_2, capacity(4, 688128L), JobStatus.TERMINATED, null);
+
+ JsonNode json = request.asJson();
+ assertEquals(4, json.get("jobRequiredCPUs").asInt());
+ assertEquals(688128L, json.get("jobRequiredMemory").asLong());
+ }
+
+ /**
+ * A job says which statement submitted it, as the response says of a statement's own report. Statements
+ * that submit no job leave a gap, so a job's position in the array does not identify its statement.
+ */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.TEST_GENERATED)
+ @Test
+ public void aJobSaysWhichStatementSubmittedIt() {
+ ClientRequest request = newRequest();
+ request.setStatementPosition(1);
+ request.addJob(JOB_1);
+ // the statements in between submitted no job of their own
+ request.setStatementPosition(4);
+ request.addJob(JOB_2);
+
+ JsonNode jobs = request.asJson().get("jobs");
+ assertEquals(1, jobs.get(0).get("statement").asInt());
+ assertEquals(4, jobs.get(1).get("statement").asInt());
+ }
+
+ /** A job submitted by no statement of the client's - the request's own - says nothing of a statement. */
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.TEST_GENERATED)
+ @Test
+ public void aJobOfNoStatementOfTheClientsSaysNothing() {
+ ClientRequest request = newRequest();
+ request.addJob(JOB_1);
+
+ assertNull(request.asJson().get("jobs").get(0).get("statement"));
+ }
+
private static Set<String> fieldsOf(JsonNode node) {
Set<String> fields = new HashSet<>();
node.fieldNames().forEachRemaining(fields::add);
@@ -214,15 +356,26 @@ public class ClientRequestTest {
/** Takes a job through the lifecycle the cluster controller notifies, so every timing is set. */
private static void runJob(ClientRequest request, JobId jobId, JobStatus status, List<Exception> exceptions) {
- IReadOnlyClusterCapacity capacity = mock(IReadOnlyClusterCapacity.class);
- when(capacity.getAggregatedCores()).thenReturn(1);
- when(capacity.getAggregatedMemoryByteSize()).thenReturn(172032L);
+ runJob(request, jobId, capacity(1, 172032L), status, exceptions);
+ }
+
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.REFACTORED)
+ private static void runJob(ClientRequest request, JobId jobId, IReadOnlyClusterCapacity capacity, JobStatus status,
+ List<Exception> exceptions) {
request.addJob(jobId);
request.jobCreated(jobId, capacity, IJobCapacityController.JobSubmissionStatus.EXECUTE);
request.jobStarted(jobId);
request.jobFinished(jobId, status, exceptions);
}
+ @AiProvenance(agent = AiProvenance.Agent.CLAUDE_OPUS_5, tool = AiProvenance.Tool.CLAUDE_CODE_UI, contributionKind = AiProvenance.ContributionKind.TEST_GENERATED)
+ private static IReadOnlyClusterCapacity capacity(int cores, long memoryByteSize) {
+ IReadOnlyClusterCapacity capacity = mock(IReadOnlyClusterCapacity.class);
+ when(capacity.getAggregatedCores()).thenReturn(cores);
+ when(capacity.getAggregatedMemoryByteSize()).thenReturn(memoryByteSize);
+ return capacity;
+ }
+
private static ClientRequest newRequest() {
return newRequestOn(Thread.currentThread());
}