This is an automated email from the ASF dual-hosted git repository.

jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 0c6c9c29f2 [#12521] feat(job): Support filtering and sorting jobs by 
time in listJobs (#12522)
0c6c9c29f2 is described below

commit 0c6c9c29f2fa16baba0c98c8463d0d83fcbf35e4
Author: Jerry Shao <[email protected]>
AuthorDate: Thu Aug 20 22:24:33 2026 +0800

    [#12521] feat(job): Support filtering and sorting jobs by time in listJobs 
(#12522)
    
    ### What changes were proposed in this pull request?
    
    Add `queuedAfter`/`startedAfter`/`finishedAfter`/`sortBy`/`sortOrder`
    query parameters to the list jobs REST API (`GET
    /metalakes/{metalake}/jobs/runs`):
    
    - `queuedAfter`/`startedAfter`/`finishedAfter` — ISO-8601 instant
    strings, inclusive lower bounds, AND-combined; a job missing the
    relevant timestamp is excluded by that filter.
    - `sortBy` (`queuedAt`|`startedAt`|`finishedAt`, default `queuedAt`) /
    `sortOrder` (`asc`|`desc`, default `desc`) — jobs missing the sort field
    always sort last, regardless of direction.
    - Implemented entirely in `JobOperations` (server REST layer):
    filtering/sorting applied in-memory after the existing
    `jobOperationDispatcher.listJobs(...)` fetch and authorization filter —
    no dispatcher/manager/storage/SQL changes.
    - All query params are validated up front, before the dispatcher fetch,
    so invalid input fails fast with 400; `sortBy`/`sortOrder` are matched
    exactly against the OpenAPI-documented casing.
    - OpenAPI spec (`docs/open-api/jobs.yaml`) updated with the five new
    parameters.
    
    ### Why are the changes needed?
    
    Callers currently have to fetch the entire job list and filter/sort
    client-side to answer time-scoped questions like "what ran in the last
    24 hours," which doesn't scale as job history grows.
    
    Fix: #12521
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes: new
    `queuedAfter`/`startedAfter`/`finishedAfter`/`sortBy`/`sortOrder` query
    parameters on the `GET /metalakes/{metalake}/jobs/runs` REST endpoint.
    No changes to existing fields or client APIs.
    
    ### How was this patch tested?
    
    Unit tests in `TestJobOperations` covering: each filter individually and
    AND-combined, inclusive boundary matching, sort by each field in both
    directions with null-handling (unset timestamps sort last),
    `sortBy`/`sortOrder` validation (invalid values, wrong casing), invalid
    ISO-8601 timestamps, and `jobTemplateName` combined with time
    filters/sort together.
    
    *Note: this branch is stacked on `feat/job-queued-started-at` (#12509),
    which is still open — the diff below will include those commits until
    #12509 merges into main.*
    
    ---------
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 .../apache/gravitino/client/TestSupportsJobs.java  |  23 +-
 .../gravitino/dto/responses/JobListResponse.java   |  24 +-
 docs/open-api/jobs.yaml                            |  79 ++++-
 .../gravitino/server/web/rest/JobOperations.java   | 170 ++++++++++-
 .../server/web/rest/TestJobOperations.java         | 339 ++++++++++++++++++++-
 5 files changed, 610 insertions(+), 25 deletions(-)

diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportsJobs.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportsJobs.java
index 4fa6469fa1..f811728cb0 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportsJobs.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportsJobs.java
@@ -23,7 +23,9 @@ import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import java.time.Instant;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 import org.apache.gravitino.dto.AuditDTO;
 import org.apache.gravitino.dto.job.JobDTO;
@@ -243,7 +245,7 @@ public class TestSupportsJobs extends TestBase {
     List<JobDTO> jobs =
         Lists.newArrayList(newJobDTO(jobId1, jobTemplateName), 
newJobDTO(jobId2, jobTemplateName));
 
-    JobListResponse resp = new JobListResponse(jobs);
+    JobListResponse resp = new JobListResponse(jobs, ImmutableMap.of());
 
     buildMockResource(Method.GET, jobRunsPath(), null, resp, HttpStatus.SC_OK);
 
@@ -274,6 +276,25 @@ public class TestSupportsJobs extends TestBase {
     compare(jobs.get(1), jobsByTemplate.get(1));
   }
 
+  @Test
+  public void testListJobsAgainstServerWithoutStatusCounts() throws 
JsonProcessingException {
+    // Simulate an older server whose response predates the statusCounts field 
entirely (the key
+    // is absent, not just null) - a new client must still be able to parse it 
without
+    // JobListResponse.validate() failing.
+    String jobTemplateName = "shell-job-template";
+    JobDTO job = newJobDTO("job-1", jobTemplateName);
+
+    Map<String, Object> legacyRespBody = new LinkedHashMap<>();
+    legacyRespBody.put("code", 0);
+    legacyRespBody.put("jobs", Lists.newArrayList(job));
+
+    buildMockResource(Method.GET, jobRunsPath(), null, legacyRespBody, 
HttpStatus.SC_OK);
+
+    List<JobHandle> actualJobs = metalake.listJobs();
+    Assertions.assertEquals(1, actualJobs.size());
+    compare(job, actualJobs.get(0));
+  }
+
   @Test
   public void testGetJob() throws JsonProcessingException {
     String jobId = "job-1";
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/responses/JobListResponse.java 
b/common/src/main/java/org/apache/gravitino/dto/responses/JobListResponse.java
index 50c4c422b5..af1902083f 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/responses/JobListResponse.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/responses/JobListResponse.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.dto.responses;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.google.common.base.Preconditions;
 import java.util.List;
+import java.util.Map;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
 import org.apache.gravitino.dto.job.JobDTO;
@@ -33,19 +34,36 @@ public class JobListResponse extends BaseResponse {
   @JsonProperty("jobs")
   private final List<JobDTO> jobs;
 
+  @JsonProperty("statusCounts")
+  private final Map<String, Long> statusCounts;
+
   /**
-   * Creates a new JobListResponse with the specified list of jobs.
+   * Creates a new JobListResponse with the specified list of jobs and no 
per-status counts.
    *
    * @param jobs The list of jobs to include in the response.
    */
   public JobListResponse(List<JobDTO> jobs) {
+    this(jobs, null);
+  }
+
+  /**
+   * Creates a new JobListResponse with the specified list of jobs and 
per-status counts.
+   *
+   * @param jobs The list of jobs to include in the response.
+   * @param statusCounts The number of jobs in {@code jobs}, keyed by 
lower-case status name (e.g.
+   *     "queued", "started"), with every {@link 
org.apache.gravitino.job.JobHandle.Status} value
+   *     present even when its count is zero. May be {@code null} when 
deserialized from an older
+   *     server that predates this field.
+   */
+  public JobListResponse(List<JobDTO> jobs, Map<String, Long> statusCounts) {
     super(0);
     this.jobs = jobs;
+    this.statusCounts = statusCounts;
   }
 
   /** Default constructor for Jackson deserialization. */
   private JobListResponse() {
-    this(null);
+    this(null, null);
   }
 
   @Override
@@ -54,5 +72,7 @@ public class JobListResponse extends BaseResponse {
 
     Preconditions.checkArgument(jobs != null, "\"jobs\" must not be null");
     jobs.forEach(JobDTO::validate);
+    // statusCounts is intentionally not required: an older server that 
predates this field
+    // won't include it, and a new client must still be able to talk to it.
   }
 }
diff --git a/docs/open-api/jobs.yaml b/docs/open-api/jobs.yaml
index 5237d5e958..ab4f692f0b 100644
--- a/docs/open-api/jobs.yaml
+++ b/docs/open-api/jobs.yaml
@@ -195,6 +195,11 @@ paths:
       operationId: listJobs
       parameters:
         - $ref: "#/components/parameters/jobTemplateName"
+        - $ref: "#/components/parameters/queuedAfter"
+        - $ref: "#/components/parameters/startedAfter"
+        - $ref: "#/components/parameters/finishedAfter"
+        - $ref: "#/components/parameters/sortBy"
+        - $ref: "#/components/parameters/sortOrder"
       responses:
         "200":
           description: Returns the list of job objects
@@ -352,6 +357,58 @@ components:
       required: true
       schema:
         type: string
+    queuedAfter:
+      name: queuedAfter
+      in: query
+      description: >-
+        Only return jobs queued at or after this ISO-8601 instant (e.g. 
2026-08-18T00:00:00Z)
+      required: false
+      schema:
+        type: string
+        format: date-time
+    startedAfter:
+      name: startedAfter
+      in: query
+      description: >-
+        Only return jobs started at or after this ISO-8601 instant (e.g. 
2026-08-18T00:00:00Z).
+        Jobs that have not started yet are excluded
+      required: false
+      schema:
+        type: string
+        format: date-time
+    finishedAfter:
+      name: finishedAfter
+      in: query
+      description: >-
+        Only return jobs finished at or after this ISO-8601 instant (e.g. 
2026-08-18T00:00:00Z).
+        Jobs that have not finished yet are excluded
+      required: false
+      schema:
+        type: string
+        format: date-time
+    sortBy:
+      name: sortBy
+      in: query
+      description: The field to sort the returned jobs by
+      required: false
+      schema:
+        type: string
+        enum:
+          - queuedAt
+          - startedAt
+          - finishedAt
+        default: queuedAt
+    sortOrder:
+      name: sortOrder
+      in: query
+      description: The sort order for the returned jobs
+      required: false
+      schema:
+        type: string
+        enum:
+          - asc
+          - desc
+        default: desc
 
   schemas:
 
@@ -512,7 +569,7 @@ components:
             - "failed"
             - "succeeded"
             - "cancelling"
-            - "canceled"
+            - "cancelled"
         audit:
           $ref: "./openapi.yaml#/components/schemas/Audit"
         queuedAt:
@@ -773,6 +830,16 @@ components:
           description: A list of job objects
           items:
             $ref: "#/components/schemas/Job"
+        statusCounts:
+          type: object
+          description: >-
+            The number of jobs in "jobs", keyed by lower-case status name. 
Every status is
+            present, even at zero. Counted after 
queuedAfter/startedAfter/finishedAfter filtering,
+            so a time filter can make some statuses structurally zero - e.g. 
startedAfter always
+            yields queued: 0, since a queued job has no startedAt yet.
+          additionalProperties:
+            type: integer
+            format: int64
 
     JobResponse:
       type: object
@@ -952,7 +1019,15 @@ components:
               "startedAt": "2025-08-12T02:14:35.442190Z",
               "finishedAt": "2025-08-12T02:15:47.891023Z"
             }
-          ]
+          ],
+          "statusCounts": {
+            "queued": 0,
+            "started": 0,
+            "failed": 1,
+            "succeeded": 1,
+            "cancelling": 0,
+            "cancelled": 0
+          }
         }
 
     JobResponse:
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java 
b/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java
index 74d1e0e5fd..55f8b578e8 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java
@@ -21,12 +21,19 @@ package org.apache.gravitino.server.web.rest;
 import com.codahale.metrics.annotation.ResponseMetered;
 import com.codahale.metrics.annotation.Timed;
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeParseException;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 import javax.inject.Inject;
 import javax.servlet.http.HttpServletRequest;
@@ -59,6 +66,7 @@ import 
org.apache.gravitino.dto.responses.JobTemplateListResponse;
 import org.apache.gravitino.dto.responses.JobTemplateResponse;
 import org.apache.gravitino.dto.responses.NameListResponse;
 import org.apache.gravitino.dto.util.DTOConverters;
+import org.apache.gravitino.job.JobHandle;
 import org.apache.gravitino.job.JobOperationDispatcher;
 import org.apache.gravitino.job.JobTemplateChange;
 import org.apache.gravitino.meta.AuditInfo;
@@ -285,13 +293,36 @@ public class JobOperations {
   public Response listJobs(
       @PathParam("metalake") @AuthorizationMetadata(type = 
Entity.EntityType.METALAKE)
           String metalake,
-      @QueryParam("jobTemplateName") String jobTemplateName) {
+      @QueryParam("jobTemplateName") String jobTemplateName,
+      @QueryParam("queuedAfter") String queuedAfter,
+      @QueryParam("startedAfter") String startedAfter,
+      @QueryParam("finishedAfter") String finishedAfter,
+      @QueryParam("sortBy") @DefaultValue("queuedAt") String sortBy,
+      @QueryParam("sortOrder") @DefaultValue("desc") String sortOrder) {
     LOG.info(
-        "Received request to list jobs in metalake {}{}",
+        "Received request to list jobs in metalake {}{}, queuedAfter: {}, 
startedAfter: {},"
+            + " finishedAfter: {}, sortBy: {}, sortOrder: {}",
         metalake,
-        jobTemplateName != null ? " for job template " + jobTemplateName : "");
+        jobTemplateName != null ? " for job template " + jobTemplateName : "",
+        queuedAfter,
+        startedAfter,
+        finishedAfter,
+        sortBy,
+        sortOrder);
 
     try {
+      // Parse/validate query params up front so a bad request fails fast, 
before paying for the
+      // dispatcher fetch and authorization filtering below. @DefaultValue 
only applies when a
+      // param is absent, not when a client sends it empty (e.g. 
"?sortBy=&sortOrder="), so blank
+      // values are treated as "not set" here too.
+      Instant queuedAfterInstant = parseInstant("queuedAfter", queuedAfter);
+      Instant startedAfterInstant = parseInstant("startedAfter", startedAfter);
+      Instant finishedAfterInstant = parseInstant("finishedAfter", 
finishedAfter);
+      Comparator<JobEntity> comparator =
+          buildJobComparator(
+              Strings.isNullOrEmpty(sortBy) ? "queuedAt" : sortBy,
+              Strings.isNullOrEmpty(sortOrder) ? "desc" : sortOrder);
+
       return Utils.doAs(
           httpRequest,
           () -> {
@@ -305,10 +336,18 @@ public class JobOperations {
                             .listJobs(metalake, 
Optional.ofNullable(jobTemplateName))
                             .toArray(new JobEntity[0]),
                         jobEntity -> NameIdentifierUtil.ofJob(metalake, 
jobEntity.name())));
-            List<JobDTO> jobDTOs = toJobDTOs(jobEntities);
-
-            LOG.info("Listed {} jobs in metalake {}", jobEntities.size(), 
metalake);
-            return Utils.ok(new JobListResponse(jobDTOs));
+            List<JobEntity> filteredAndSortedJobs =
+                filterAndSortJobs(
+                    jobEntities,
+                    queuedAfterInstant,
+                    startedAfterInstant,
+                    finishedAfterInstant,
+                    comparator);
+            List<JobDTO> jobDTOs = toJobDTOs(filteredAndSortedJobs);
+            Map<String, Long> statusCounts = 
countJobsByStatus(filteredAndSortedJobs);
+
+            LOG.info("Listed {} jobs in metalake {}", jobDTOs.size(), 
metalake);
+            return Utils.ok(new JobListResponse(jobDTOs, statusCounts));
           });
 
     } catch (Exception e) {
@@ -485,4 +524,121 @@ public class JobOperations {
   private static List<JobDTO> toJobDTOs(List<JobEntity> jobEntities) {
     return 
jobEntities.stream().map(JobOperations::toDTO).collect(Collectors.toList());
   }
+
+  @VisibleForTesting
+  static Map<String, Long> countJobsByStatus(List<JobEntity> jobEntities) {
+    // Every status is present, even at zero, so callers get a stable set of 
keys to render
+    // (e.g. a status histogram) without having to special-case missing 
entries.
+    Map<String, Long> statusCounts = new LinkedHashMap<>();
+    for (JobHandle.Status status : JobHandle.Status.values()) {
+      statusCounts.put(status.name().toLowerCase(Locale.ROOT), 0L);
+    }
+    for (JobEntity jobEntity : jobEntities) {
+      statusCounts.merge(jobEntity.status().name().toLowerCase(Locale.ROOT), 
1L, Long::sum);
+    }
+    return statusCounts;
+  }
+
+  @VisibleForTesting
+  static List<JobEntity> filterAndSortJobs(
+      List<JobEntity> jobEntities,
+      Instant queuedAfter,
+      Instant startedAfter,
+      Instant finishedAfter,
+      Comparator<JobEntity> comparator) {
+    return jobEntities.stream()
+        .filter(
+            jobEntity -> matchesTimeFilters(jobEntity, queuedAfter, 
startedAfter, finishedAfter))
+        .sorted(comparator)
+        .collect(Collectors.toList());
+  }
+
+  @VisibleForTesting
+  static Instant parseInstant(String paramName, String value) {
+    if (Strings.isNullOrEmpty(value)) {
+      return null;
+    }
+    try {
+      // OffsetDateTime.parse (RFC 3339, matching the OpenAPI `format: 
date-time`) rather than
+      // Instant.parse (strict ISO_INSTANT, `Z`/zero-offset only): 
Instant.parse of a numeric
+      // offset like "+08:00" throws on JDK 8/11 and only started accepting it 
on JDK 12+
+      // (JDK-8166138), so the same request would 400 or succeed depending on 
the server's JDK.
+      return OffsetDateTime.parse(value).toInstant();
+    } catch (DateTimeParseException e) {
+      throw new IllegalArgumentException(
+          "Invalid "
+              + paramName
+              + " value: "
+              + value
+              + ", must be an ISO-8601 instant, e.g. 2026-08-18T00:00:00Z",
+          e);
+    }
+  }
+
+  // queuedAfter/startedAfter/finishedAfter are inclusive lower bounds (>=), 
AND-combined. A job
+  // missing the relevant timestamp (e.g. not started yet) never matches a 
startedAfter/
+  // finishedAfter filter.
+  private static boolean matchesTimeFilters(
+      JobEntity jobEntity, Instant queuedAfter, Instant startedAfter, Instant 
finishedAfter) {
+    return matchesTimeFilter(jobEntity.auditInfo().createTime(), queuedAfter)
+        && matchesTimeFilter(jobEntity.startedAtAsInstant(), startedAfter)
+        && matchesTimeFilter(jobEntity.finishedAtAsInstant(), finishedAfter);
+  }
+
+  private static boolean matchesTimeFilter(Instant actual, Instant after) {
+    if (after == null) {
+      return true;
+    }
+    return actual != null && !actual.isBefore(after);
+  }
+
+  // sortBy/sortOrder are matched exactly against the OpenAPI-documented enum 
values (queuedAt,
+  // startedAt, finishedAt, asc, desc) rather than case-insensitively, so 
accepted input always
+  // matches what the spec advertises.
+  @VisibleForTesting
+  static Comparator<JobEntity> buildJobComparator(String sortBy, String 
sortOrder) {
+    Function<JobEntity, Instant> keyExtractor;
+    switch (sortBy) {
+      case "queuedAt":
+        keyExtractor = jobEntity -> jobEntity.auditInfo().createTime();
+        break;
+      case "startedAt":
+        keyExtractor = JobEntity::startedAtAsInstant;
+        break;
+      case "finishedAt":
+        keyExtractor = JobEntity::finishedAtAsInstant;
+        break;
+      default:
+        throw new IllegalArgumentException(
+            "Invalid sortBy value: " + sortBy + ", must be one of queuedAt, 
startedAt, finishedAt");
+    }
+
+    boolean ascending;
+    switch (sortOrder) {
+      case "asc":
+        ascending = true;
+        break;
+      case "desc":
+        ascending = false;
+        break;
+      default:
+        throw new IllegalArgumentException(
+            "Invalid sortOrder value: " + sortOrder + ", must be one of asc, 
desc");
+    }
+
+    // Jobs missing the sort key (e.g. not started/finished yet) always sort 
last, regardless of
+    // sortOrder - flipping their position based on direction would be 
confusing.
+    return (a, b) -> {
+      Instant ta = keyExtractor.apply(a);
+      Instant tb = keyExtractor.apply(b);
+      if (ta == null && tb == null) {
+        return 0;
+      } else if (ta == null) {
+        return 1;
+      } else if (tb == null) {
+        return -1;
+      }
+      return ascending ? ta.compareTo(tb) : tb.compareTo(ta);
+    };
+  }
 }
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
index cdd53e6cd8..130fae9434 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
@@ -32,6 +32,9 @@ import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import java.io.IOException;
 import java.time.Instant;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Random;
@@ -640,11 +643,14 @@ public class TestJobOperations extends JerseyTest {
   @Test
   public void testListJobs() {
     String templateName = "shell_template_1";
-    JobEntity job1 = newJobEntity(templateName, JobHandle.Status.QUEUED);
+    // Fixed, strictly-increasing queuedAt values rather than back-to-back 
Instant.now() calls -
+    // the latter can collide (millisecond clock resolution on some 
JDKs/OSes), which would make
+    // the desc-sort assertions below flaky.
+    JobEntity job1 = newJobEntityWithQueuedAt(templateName, 
JobHandle.Status.QUEUED, 1000L, 0L, 0L);
     JobEntity job2 =
-        newJobEntity(templateName, JobHandle.Status.STARTED, 
Instant.now().toEpochMilli(), 0L);
+        newJobEntityWithQueuedAt(templateName, JobHandle.Status.STARTED, 
2000L, 2500L, 0L);
     JobEntity job3 =
-        newJobEntity("spark_template_1", JobHandle.Status.SUCCEEDED, 
Instant.now().toEpochMilli());
+        newJobEntityWithQueuedAt("spark_template_1", 
JobHandle.Status.SUCCEEDED, 3000L, 0L, 3500L);
 
     when(jobOperationDispatcher.listJobs(metalake, Optional.empty()))
         .thenReturn(Lists.newArrayList(job1, job2, job3));
@@ -661,28 +667,41 @@ public class TestJobOperations extends JerseyTest {
     JobListResponse jobListResponse = resp.readEntity(JobListResponse.class);
     Assertions.assertEquals(0, jobListResponse.getCode());
 
+    // statusCounts reflects the returned jobs: one QUEUED, one STARTED, one 
SUCCEEDED, and every
+    // other status present at zero.
+    Map<String, Long> expectedStatusCounts = new HashMap<>();
+    expectedStatusCounts.put("queued", 1L);
+    expectedStatusCounts.put("started", 1L);
+    expectedStatusCounts.put("failed", 0L);
+    expectedStatusCounts.put("succeeded", 1L);
+    expectedStatusCounts.put("cancelling", 0L);
+    expectedStatusCounts.put("cancelled", 0L);
+    Assertions.assertEquals(expectedStatusCounts, 
jobListResponse.getStatusCounts());
+
+    // Default sort is queuedAt desc (newest first); job1/job2/job3 have 
strictly increasing
+    // queuedAt (1000L < 2000L < 3000L), so the response reverses that order.
     Assertions.assertEquals(3, jobListResponse.getJobs().size());
-    Assertions.assertEquals(JobOperations.toDTO(job1), 
jobListResponse.getJobs().get(0));
+    Assertions.assertEquals(JobOperations.toDTO(job3), 
jobListResponse.getJobs().get(0));
     Assertions.assertEquals(JobOperations.toDTO(job2), 
jobListResponse.getJobs().get(1));
-    Assertions.assertEquals(JobOperations.toDTO(job3), 
jobListResponse.getJobs().get(2));
+    Assertions.assertEquals(JobOperations.toDTO(job1), 
jobListResponse.getJobs().get(2));
 
-    // Not-yet-finished jobs round-trip finishedAt as null over the wire.
-    Assertions.assertNull(jobListResponse.getJobs().get(0).finishedAt());
-    Assertions.assertNull(jobListResponse.getJobs().get(1).finishedAt());
     // A finished job round-trips its finishedAt as an Instant over the wire.
     Assertions.assertEquals(
-        Instant.ofEpochMilli(job3.finishedAt()), 
jobListResponse.getJobs().get(2).finishedAt());
+        Instant.ofEpochMilli(job3.finishedAt()), 
jobListResponse.getJobs().get(0).finishedAt());
+    // Not-yet-finished jobs round-trip finishedAt as null over the wire.
+    Assertions.assertNull(jobListResponse.getJobs().get(1).finishedAt());
+    Assertions.assertNull(jobListResponse.getJobs().get(2).finishedAt());
 
     // queuedAt is always present, regardless of status.
     Assertions.assertNotNull(jobListResponse.getJobs().get(0).queuedAt());
     Assertions.assertNotNull(jobListResponse.getJobs().get(1).queuedAt());
     Assertions.assertNotNull(jobListResponse.getJobs().get(2).queuedAt());
 
-    // A not-yet-started job round-trips startedAt as null over the wire.
-    Assertions.assertNull(jobListResponse.getJobs().get(0).startedAt());
     // A started job round-trips its startedAt as an Instant over the wire.
     Assertions.assertEquals(
         Instant.ofEpochMilli(job2.startedAt()), 
jobListResponse.getJobs().get(1).startedAt());
+    // A not-yet-started job round-trips startedAt as null over the wire.
+    Assertions.assertNull(jobListResponse.getJobs().get(2).startedAt());
 
     // Test list jobs by template name
     when(jobOperationDispatcher.listJobs(metalake, Optional.of(templateName)))
@@ -701,8 +720,9 @@ public class TestJobOperations extends JerseyTest {
     JobListResponse jobListResponse1 = resp1.readEntity(JobListResponse.class);
     Assertions.assertEquals(0, jobListResponse1.getCode());
     Assertions.assertEquals(2, jobListResponse1.getJobs().size());
-    Assertions.assertEquals(JobOperations.toDTO(job1), 
jobListResponse1.getJobs().get(0));
-    Assertions.assertEquals(JobOperations.toDTO(job2), 
jobListResponse1.getJobs().get(1));
+    // Default sort is queuedAt desc: job2 (queued after job1) comes first.
+    Assertions.assertEquals(JobOperations.toDTO(job2), 
jobListResponse1.getJobs().get(0));
+    Assertions.assertEquals(JobOperations.toDTO(job1), 
jobListResponse1.getJobs().get(1));
 
     // Test throw NoSuchMetalakeException
     doThrow(new NoSuchMetalakeException("mock error"))
@@ -772,6 +792,276 @@ public class TestJobOperations extends JerseyTest {
     Assertions.assertEquals(RuntimeException.class.getSimpleName(), 
errorResp4.getType());
   }
 
+  @Test
+  public void testListJobsWithTimeFiltersAndSort() {
+    String templateName = "shell_template_1";
+    // job1: queued only. job2: queued+started. job3: queued+started+finished, 
queued earliest.
+    JobEntity job1 = newJobEntityWithQueuedAt(templateName, 
JobHandle.Status.QUEUED, 3000L, 0L, 0L);
+    JobEntity job2 =
+        newJobEntityWithQueuedAt(templateName, JobHandle.Status.STARTED, 
2000L, 2500L, 0L);
+    JobEntity job3 =
+        newJobEntityWithQueuedAt(templateName, JobHandle.Status.SUCCEEDED, 
1000L, 1500L, 1800L);
+
+    when(jobOperationDispatcher.listJobs(metalake, Optional.empty()))
+        .thenReturn(Lists.newArrayList(job1, job2, job3));
+
+    // startedAfter excludes job1 (never started); default sort is queuedAt 
desc.
+    Response resp =
+        target(jobRunPath())
+            .queryParam("startedAfter", Instant.ofEpochMilli(1000L).toString())
+            .request(APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp.getStatus());
+    JobListResponse jobListResponse = resp.readEntity(JobListResponse.class);
+    Assertions.assertEquals(2, jobListResponse.getJobs().size());
+    Assertions.assertEquals(JobOperations.toDTO(job2), 
jobListResponse.getJobs().get(0));
+    Assertions.assertEquals(JobOperations.toDTO(job3), 
jobListResponse.getJobs().get(1));
+
+    // statusCounts is scoped to the filtered set: job1 (QUEUED) is excluded 
by startedAfter,
+    // so its status contributes zero here, even though the job itself exists.
+    Map<String, Long> expectedFilteredStatusCounts = new HashMap<>();
+    expectedFilteredStatusCounts.put("queued", 0L);
+    expectedFilteredStatusCounts.put("started", 1L);
+    expectedFilteredStatusCounts.put("failed", 0L);
+    expectedFilteredStatusCounts.put("succeeded", 1L);
+    expectedFilteredStatusCounts.put("cancelling", 0L);
+    expectedFilteredStatusCounts.put("cancelled", 0L);
+    Assertions.assertEquals(expectedFilteredStatusCounts, 
jobListResponse.getStatusCounts());
+
+    // sortBy=startedAt, sortOrder=asc: job3 (1500) < job2 (2500) < job1 
(null, sorts last).
+    Response resp2 =
+        target(jobRunPath())
+            .queryParam("sortBy", "startedAt")
+            .queryParam("sortOrder", "asc")
+            .request(APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp2.getStatus());
+    JobListResponse jobListResponse2 = resp2.readEntity(JobListResponse.class);
+    Assertions.assertEquals(3, jobListResponse2.getJobs().size());
+    Assertions.assertEquals(JobOperations.toDTO(job3), 
jobListResponse2.getJobs().get(0));
+    Assertions.assertEquals(JobOperations.toDTO(job2), 
jobListResponse2.getJobs().get(1));
+    Assertions.assertEquals(JobOperations.toDTO(job1), 
jobListResponse2.getJobs().get(2));
+
+    // Invalid sortBy is rejected with 400.
+    Response resp3 =
+        target(jobRunPath())
+            .queryParam("sortBy", "bogus")
+            .request(APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
resp3.getStatus());
+    ErrorResponse errorResp3 = resp3.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.ILLEGAL_ARGUMENTS_CODE, 
errorResp3.getCode());
+    Assertions.assertEquals(IllegalArgumentException.class.getSimpleName(), 
errorResp3.getType());
+
+    // Invalid sortOrder is rejected with 400.
+    Response resp4 =
+        target(jobRunPath())
+            .queryParam("sortOrder", "sideways")
+            .request(APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
resp4.getStatus());
+    ErrorResponse errorResp4 = resp4.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.ILLEGAL_ARGUMENTS_CODE, 
errorResp4.getCode());
+    Assertions.assertEquals(IllegalArgumentException.class.getSimpleName(), 
errorResp4.getType());
+
+    // Non-ISO-8601 time value is rejected with 400.
+    Response resp5 =
+        target(jobRunPath())
+            .queryParam("queuedAfter", "not-a-timestamp")
+            .request(APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
resp5.getStatus());
+    ErrorResponse errorResp5 = resp5.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.ILLEGAL_ARGUMENTS_CODE, 
errorResp5.getCode());
+    Assertions.assertEquals(IllegalArgumentException.class.getSimpleName(), 
errorResp5.getType());
+
+    // Blank query params (e.g. "?sortBy=&sortOrder=&queuedAfter="), as 
templated/generated
+    // clients sometimes send, fall back to "not set" rather than 400ing: 
@DefaultValue only
+    // applies when a param is absent, not when it's present-but-empty.
+    Response resp6 =
+        target(jobRunPath())
+            .queryParam("sortBy", "")
+            .queryParam("sortOrder", "")
+            .queryParam("queuedAfter", "")
+            .request(APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp6.getStatus());
+    JobListResponse jobListResponse6 = resp6.readEntity(JobListResponse.class);
+    Assertions.assertEquals(3, jobListResponse6.getJobs().size());
+    Assertions.assertEquals(JobOperations.toDTO(job1), 
jobListResponse6.getJobs().get(0));
+    Assertions.assertEquals(JobOperations.toDTO(job2), 
jobListResponse6.getJobs().get(1));
+    Assertions.assertEquals(JobOperations.toDTO(job3), 
jobListResponse6.getJobs().get(2));
+  }
+
+  @Test
+  public void testListJobsCombinesTemplateFilterWithTimeFiltersAndSort() {
+    String templateName = "shell_template_1";
+    JobEntity job1 = newJobEntityWithQueuedAt(templateName, 
JobHandle.Status.QUEUED, 3000L, 0L, 0L);
+    JobEntity job2 =
+        newJobEntityWithQueuedAt(templateName, JobHandle.Status.STARTED, 
2000L, 2500L, 0L);
+    JobEntity job3 =
+        newJobEntityWithQueuedAt(templateName, JobHandle.Status.SUCCEEDED, 
1000L, 1500L, 1800L);
+
+    // The dispatcher already narrows to this template's jobs; jobs from other 
templates (e.g. a
+    // spark_template_1 job) are never in this list, so they can't leak in via 
the time filter.
+    when(jobOperationDispatcher.listJobs(metalake, Optional.of(templateName)))
+        .thenReturn(Lists.newArrayList(job1, job2, job3));
+
+    Response resp =
+        target(jobRunPath())
+            .queryParam("jobTemplateName", templateName)
+            .queryParam("startedAfter", Instant.ofEpochMilli(1000L).toString())
+            .queryParam("sortBy", "startedAt")
+            .queryParam("sortOrder", "asc")
+            .request(APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
resp.getStatus());
+    JobListResponse jobListResponse = resp.readEntity(JobListResponse.class);
+    // job1 is excluded (never started); job3 (startedAt=1500) sorts before 
job2 (startedAt=2500).
+    Assertions.assertEquals(2, jobListResponse.getJobs().size());
+    Assertions.assertEquals(JobOperations.toDTO(job3), 
jobListResponse.getJobs().get(0));
+    Assertions.assertEquals(JobOperations.toDTO(job2), 
jobListResponse.getJobs().get(1));
+  }
+
+  @Test
+  public void testFilterAndSortJobs() {
+    String templateName = "shell_template_1";
+    JobEntity job1 = newJobEntityWithQueuedAt(templateName, 
JobHandle.Status.QUEUED, 1000L, 0L, 0L);
+    JobEntity job2 =
+        newJobEntityWithQueuedAt(templateName, JobHandle.Status.STARTED, 
2000L, 3000L, 0L);
+    JobEntity job3 =
+        newJobEntityWithQueuedAt(templateName, JobHandle.Status.SUCCEEDED, 
3000L, 4000L, 5000L);
+    List<JobEntity> jobs = Lists.newArrayList(job1, job2, job3);
+    Comparator<JobEntity> queuedAtAsc = 
JobOperations.buildJobComparator("queuedAt", "asc");
+
+    // queuedAfter filters out job1.
+    List<JobEntity> filteredByQueuedAfter =
+        JobOperations.filterAndSortJobs(jobs, instant(1500L), null, null, 
queuedAtAsc);
+    Assertions.assertEquals(Lists.newArrayList(job2, job3), 
filteredByQueuedAfter);
+
+    // startedAfter excludes not-yet-started job1 and jobs started before the 
bound.
+    List<JobEntity> filteredByStartedAfter =
+        JobOperations.filterAndSortJobs(jobs, null, instant(3500L), null, 
queuedAtAsc);
+    Assertions.assertEquals(Lists.newArrayList(job3), filteredByStartedAfter);
+
+    // finishedAfter excludes not-yet-finished jobs.
+    List<JobEntity> filteredByFinishedAfter =
+        JobOperations.filterAndSortJobs(jobs, null, null, instant(1L), 
queuedAtAsc);
+    Assertions.assertEquals(Lists.newArrayList(job3), filteredByFinishedAfter);
+
+    // Filters are AND-combined.
+    List<JobEntity> filteredByAll =
+        JobOperations.filterAndSortJobs(
+            jobs, instant(1500L), instant(3500L), instant(1L), queuedAtAsc);
+    Assertions.assertEquals(Lists.newArrayList(job3), filteredByAll);
+
+    // Boundary is inclusive (>=).
+    List<JobEntity> filteredInclusive =
+        JobOperations.filterAndSortJobs(jobs, instant(2000L), null, null, 
queuedAtAsc);
+    Assertions.assertEquals(Lists.newArrayList(job2, job3), filteredInclusive);
+
+    // sortBy=queuedAt desc.
+    List<JobEntity> sortedByQueuedDesc =
+        JobOperations.filterAndSortJobs(
+            jobs, null, null, null, 
JobOperations.buildJobComparator("queuedAt", "desc"));
+    Assertions.assertEquals(Lists.newArrayList(job3, job2, job1), 
sortedByQueuedDesc);
+
+    // sortBy=startedAt asc, jobs without startedAt sort last regardless of 
direction.
+    List<JobEntity> sortedByStartedAsc =
+        JobOperations.filterAndSortJobs(
+            jobs, null, null, null, 
JobOperations.buildJobComparator("startedAt", "asc"));
+    Assertions.assertEquals(Lists.newArrayList(job2, job3, job1), 
sortedByStartedAsc);
+
+    List<JobEntity> sortedByStartedDesc =
+        JobOperations.filterAndSortJobs(
+            jobs, null, null, null, 
JobOperations.buildJobComparator("startedAt", "desc"));
+    Assertions.assertEquals(Lists.newArrayList(job3, job2, job1), 
sortedByStartedDesc);
+  }
+
+  @Test
+  public void testCountJobsByStatus() {
+    String templateName = "shell_template_1";
+    JobEntity queuedJob1 = newJobEntity(templateName, JobHandle.Status.QUEUED);
+    JobEntity queuedJob2 = newJobEntity(templateName, JobHandle.Status.QUEUED);
+    JobEntity startedJob =
+        newJobEntity(templateName, JobHandle.Status.STARTED, 
Instant.now().toEpochMilli(), 0L);
+    JobEntity succeededJob =
+        newJobEntity(templateName, JobHandle.Status.SUCCEEDED, 
Instant.now().toEpochMilli());
+
+    Map<String, Long> counts =
+        JobOperations.countJobsByStatus(
+            Lists.newArrayList(queuedJob1, queuedJob2, startedJob, 
succeededJob));
+
+    // Every JobHandle.Status is present, even at zero.
+    Map<String, Long> expected = new HashMap<>();
+    expected.put("queued", 2L);
+    expected.put("started", 1L);
+    expected.put("failed", 0L);
+    expected.put("succeeded", 1L);
+    expected.put("cancelling", 0L);
+    expected.put("cancelled", 0L);
+    Assertions.assertEquals(expected, counts);
+
+    // An empty job list still reports every status at zero.
+    Map<String, Long> emptyCounts = 
JobOperations.countJobsByStatus(Lists.newArrayList());
+    Assertions.assertEquals(6, emptyCounts.size());
+    emptyCounts.values().forEach(count -> Assertions.assertEquals(0L, count));
+  }
+
+  @Test
+  public void testParseInstant() {
+    Assertions.assertNull(JobOperations.parseInstant("queuedAfter", null));
+    Assertions.assertNull(JobOperations.parseInstant("queuedAfter", ""));
+
+    Instant expected = Instant.ofEpochMilli(1500L);
+    Assertions.assertEquals(
+        expected, JobOperations.parseInstant("queuedAfter", 
expected.toString()));
+
+    // RFC 3339 numeric offsets (as advertised by the OpenAPI `format: 
date-time`) are accepted,
+    // not just the strict ISO_INSTANT `Z` form.
+    Assertions.assertEquals(
+        Instant.parse("2026-08-17T16:00:00Z"),
+        JobOperations.parseInstant("queuedAfter", 
"2026-08-18T00:00:00+08:00"));
+
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> JobOperations.parseInstant("queuedAfter", "not-a-timestamp"));
+  }
+
+  @Test
+  public void testBuildJobComparatorValidation() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
JobOperations.buildJobComparator("bogus", "asc"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> JobOperations.buildJobComparator("queuedAt", "bogus"));
+
+    // sortBy/sortOrder are matched exactly against the OpenAPI-documented 
casing, not
+    // case-insensitively.
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
JobOperations.buildJobComparator("queuedat", "asc"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
JobOperations.buildJobComparator("queuedAt", "ASC"));
+  }
+
+  private static Instant instant(long epochMilli) {
+    return Instant.ofEpochMilli(epochMilli);
+  }
+
   @Test
   public void testRunJob() {
     String templateName = "shell_template_1";
@@ -967,4 +1257,27 @@ public class TestJobOperations extends JerseyTest {
         .withFinishedAt(finishedAt)
         .build();
   }
+
+  private JobEntity newJobEntityWithQueuedAt(
+      String templateName,
+      JobHandle.Status status,
+      long queuedAtEpochMilli,
+      Long startedAt,
+      Long finishedAt) {
+    Random rand = new Random();
+    return JobEntity.builder()
+        .withId(rand.nextLong())
+        .withJobExecutionId(rand.nextLong() + "")
+        .withNamespace(NamespaceUtil.ofJob(metalake))
+        .withJobTemplateName(templateName)
+        .withStatus(status)
+        .withAuditInfo(
+            AuditInfo.builder()
+                .withCreator("test")
+                .withCreateTime(Instant.ofEpochMilli(queuedAtEpochMilli))
+                .build())
+        .withStartedAt(startedAt)
+        .withFinishedAt(finishedAt)
+        .build();
+  }
 }

Reply via email to