Skip to content

Commit 07d25ac

Browse files
committed
Improve list-jobs output
1 parent 9049bd9 commit 07d25ac

7 files changed

Lines changed: 154 additions & 26 deletions

File tree

cmd/partforge/main.go

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2191,13 +2191,17 @@ func runListJobs(ctx context.Context, args []string) error {
21912191
}
21922192

21932193
func buildListJobsOutput(jobs []state.Job) listJobsOutput {
2194-
out := listJobsOutput{Jobs: make([]string, 0, len(jobs))}
2194+
out := listJobsOutput{
2195+
Jobs: make([]string, 0, len(jobs)),
2196+
Details: make([]listJobDetail, 0, len(jobs)),
2197+
}
21952198
jobNames := map[string]string{}
21962199
for _, job := range jobs {
21972200
out.Jobs = append(out.Jobs, job.JobID)
21982201
if job.Name != "" {
21992202
jobNames[job.JobID] = job.Name
22002203
}
2204+
out.Details = append(out.Details, buildListJobDetail(job))
22012205
}
22022206
if len(jobNames) > 0 {
22032207
out.JobNames = jobNames
@@ -2206,13 +2210,58 @@ func buildListJobsOutput(jobs []state.Job) listJobsOutput {
22062210
}
22072211

22082212
func printJobs(out *os.File, jobs []state.Job) {
2213+
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
2214+
fmt.Fprintln(tw, "JOB_ID\tSTATUS\tPARTS\tREWRITE\tIMPORT\tSUBMITTED_AT\tUPDATED_AT\tNAME\tCOUNTS")
22092215
for _, job := range jobs {
2210-
if job.Name == "" {
2211-
fmt.Fprintln(out, job.JobID)
2216+
detail := buildListJobDetail(job)
2217+
fmt.Fprintf(
2218+
tw,
2219+
"%s\t%s\t%d\t%s\t%s\t%s\t%s\t%s\t%s\n",
2220+
detail.JobID,
2221+
detail.Status,
2222+
detail.PartsTotal,
2223+
formatListJobProgress(detail.RewriteCompleted, detail.PartsTotal),
2224+
formatListJobProgress(detail.ImportCompleted, detail.PartsTotal),
2225+
detail.SubmittedAt,
2226+
detail.UpdatedAt,
2227+
detail.Name,
2228+
formatStatusCounts(detail.StatusCounts),
2229+
)
2230+
}
2231+
_ = tw.Flush()
2232+
}
2233+
2234+
func buildListJobDetail(job state.Job) listJobDetail {
2235+
rewriteCompleted := job.Counts[state.StatusCompactReady] + job.Counts[state.StatusCompacting] + job.Counts[state.StatusSuperseded] + job.Counts[state.StatusFinished] + job.Counts[state.StatusImporting] + job.Counts[state.StatusImported]
2236+
importCompleted := job.Counts[state.StatusImported]
2237+
return listJobDetail{
2238+
JobID: job.JobID,
2239+
Name: job.Name,
2240+
Status: overallStatus(job.Total, job.Counts),
2241+
PartsTotal: job.Total,
2242+
RewriteCompleted: rewriteCompleted,
2243+
RewritePercent: percent(rewriteCompleted, job.Total),
2244+
ImportCompleted: importCompleted,
2245+
ImportPercent: percent(importCompleted, job.Total),
2246+
SubmittedAt: job.SubmittedAt,
2247+
UpdatedAt: job.UpdatedAt,
2248+
StatusCounts: listJobStatusCounts(job.Counts),
2249+
}
2250+
}
2251+
2252+
func listJobStatusCounts(counts map[state.Status]int) []statusCount {
2253+
out := make([]statusCount, 0, len(statusOrder()))
2254+
for _, status := range statusOrder() {
2255+
if counts[status] == 0 {
22122256
continue
22132257
}
2214-
fmt.Fprintf(out, "%s\t%s\n", job.JobID, job.Name)
2258+
out = append(out, statusCount{Status: status, Count: counts[status]})
22152259
}
2260+
return out
2261+
}
2262+
2263+
func formatListJobProgress(done, total int) string {
2264+
return fmt.Sprintf("%d/%d %.1f%%", done, total, percent(done, total))
22162265
}
22172266

22182267
func runJobStatus(ctx context.Context, args []string) error {
@@ -3439,6 +3488,21 @@ type jobStatusOutput struct {
34393488
type listJobsOutput struct {
34403489
Jobs []string `json:"jobs"`
34413490
JobNames map[string]string `json:"job_names,omitempty"`
3491+
Details []listJobDetail `json:"job_details"`
3492+
}
3493+
3494+
type listJobDetail struct {
3495+
JobID string `json:"job_id"`
3496+
Name string `json:"name,omitempty"`
3497+
Status string `json:"status"`
3498+
PartsTotal int `json:"parts_total"`
3499+
RewriteCompleted int `json:"rewrite_completed"`
3500+
RewritePercent float64 `json:"rewrite_percent"`
3501+
ImportCompleted int `json:"import_completed"`
3502+
ImportPercent float64 `json:"import_percent"`
3503+
SubmittedAt string `json:"submitted_at,omitempty"`
3504+
UpdatedAt string `json:"updated_at,omitempty"`
3505+
StatusCounts []statusCount `json:"status_counts,omitempty"`
34423506
}
34433507

34443508
type retryFailedOutput struct {

cmd/partforge/main_test.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1449,20 +1449,49 @@ func TestPrintJobSummaryHumanizesBytes(t *testing.T) {
14491449
func TestPrintJobsIncludesNames(t *testing.T) {
14501450
got := captureFileOutput(t, func(out *os.File) {
14511451
printJobs(out, []state.Job{
1452-
{JobID: "job-a", Name: "Backfill A"},
1453-
{JobID: "job-b"},
1452+
{
1453+
JobID: "job-a",
1454+
Name: "Backfill A",
1455+
Total: 3,
1456+
Counts: map[state.Status]int{state.StatusReady: 2, state.StatusFinished: 1},
1457+
SubmittedAt: "2026-06-24T00:00:00.000000000Z",
1458+
UpdatedAt: "2026-06-24T01:00:00.000000000Z",
1459+
},
1460+
{JobID: "job-b", Total: 1, Counts: map[state.Status]int{state.StatusFailed: 1}},
14541461
})
14551462
})
14561463

1457-
if got != "job-a\tBackfill A\njob-b\n" {
1458-
t.Fatalf("printJobs output = %q", got)
1464+
for _, want := range []string{
1465+
"JOB_ID",
1466+
"STATUS",
1467+
"PARTS",
1468+
"SUBMITTED_AT",
1469+
"UPDATED_AT",
1470+
"COUNTS",
1471+
"job-a",
1472+
"Backfill A",
1473+
"READY",
1474+
"READY=2, FINISHED=1",
1475+
"job-b",
1476+
"FAILED",
1477+
} {
1478+
if !strings.Contains(got, want) {
1479+
t.Fatalf("printJobs output missing %q:\n%s", want, got)
1480+
}
14591481
}
14601482
}
14611483

14621484
func TestBuildListJobsOutputPreservesJobIDList(t *testing.T) {
14631485
got := buildListJobsOutput([]state.Job{
1464-
{JobID: "job-a", Name: "Backfill A"},
1465-
{JobID: "job-b"},
1486+
{
1487+
JobID: "job-a",
1488+
Name: "Backfill A",
1489+
Total: 3,
1490+
Counts: map[state.Status]int{state.StatusFinished: 2, state.StatusImported: 1},
1491+
SubmittedAt: "2026-06-24T00:00:00.000000000Z",
1492+
UpdatedAt: "2026-06-24T01:00:00.000000000Z",
1493+
},
1494+
{JobID: "job-b", Total: 1, Counts: map[state.Status]int{state.StatusReady: 1}},
14661495
})
14671496

14681497
if strings.Join(got.Jobs, ",") != "job-a,job-b" {
@@ -1471,6 +1500,15 @@ func TestBuildListJobsOutputPreservesJobIDList(t *testing.T) {
14711500
if len(got.JobNames) != 1 || got.JobNames["job-a"] != "Backfill A" {
14721501
t.Fatalf("job names = %+v", got.JobNames)
14731502
}
1503+
if len(got.Details) != 2 {
1504+
t.Fatalf("job details = %+v", got.Details)
1505+
}
1506+
if got.Details[0].Status != "READY_FOR_IMPORT" || got.Details[0].PartsTotal != 3 || got.Details[0].RewriteCompleted != 3 || got.Details[0].ImportCompleted != 1 || got.Details[0].SubmittedAt == "" || got.Details[0].UpdatedAt == "" {
1507+
t.Fatalf("job-a detail = %+v", got.Details[0])
1508+
}
1509+
if got.Details[1].Status != "READY" || got.Details[1].PartsTotal != 1 {
1510+
t.Fatalf("job-b detail = %+v", got.Details[1])
1511+
}
14741512
}
14751513

14761514
func TestPrintJobSummaryIncludesInProgressStages(t *testing.T) {

docs/dynamodb.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
PartForge tracks every part of every job as a row in a single DynamoDB table. This is the source of truth for the pipeline: workers claim work with conditional updates, transition parts through their lifecycle, and record progress and compaction lineage here. Because all state lives in DynamoDB, jobs are resumable and can be driven by many workers at once.
44

55
The table holds no bulk data — only per-part metadata and pointers to the S3 artifacts. Part data itself lives in S3.
6-
Rows may include `job_name` when `upload-freeze -job-name` is used; `list-jobs` displays it with the job ID.
6+
Rows may include `job_name` when `upload-freeze -job-name` is used; `list-jobs` displays it with job status, counts, and timestamps.
77

88
## Schema
99

docs/operations.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ Workers also write a per-part progress heartbeat to DynamoDB every `15s` (`-stat
3434
## Inspecting jobs
3535

3636
```sh
37-
partforge list-jobs # job IDs and optional names in the state table (uses the gsi1 index)
37+
partforge list-jobs # jobs with status, part counts, submitted/updated timestamps, optional names (uses the gsi1 index)
3838
partforge job-status -job-id=job-123
3939
```
4040

41-
Both accept `-json`; `list-jobs -json` keeps `jobs` as job IDs and adds `job_names` when names are set. `job-status -parts` adds per-row detail (persisted rewrite counters, compact-ready age, destination partitions, active part stats, `FAILED_MERGES`); `job-status -details` adds each part's current stage and per-stage timings. The physical part counters (`input_clickhouse_parts`, `current_output_clickhouse_parts`) refer to ClickHouse parts, not state rows.
41+
Both accept `-json`; `list-jobs -json` keeps `jobs` as job IDs, adds `job_names` when names are set, and includes `job_details` for status/progress/timestamps. `job-status -parts` adds per-row detail (persisted rewrite counters, compact-ready age, destination partitions, active part stats, `FAILED_MERGES`); `job-status -details` adds each part's current stage and per-stage timings. The physical part counters (`input_clickhouse_parts`, `current_output_clickhouse_parts`) refer to ClickHouse parts, not state rows.
4242

4343
## Admin and recovery commands
4444

e2e/run.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ job_list="$(
186186
list-jobs \
187187
-dynamodb-endpoint=http://localstack:4566
188188
)"
189-
if ! grep -F $'e2e-job\tE2E import' <<<"$job_list" >/dev/null; then
189+
if ! grep -F "E2E import" <<<"$job_list" >/dev/null; then
190190
echo "list-jobs did not include job name; output:" >&2
191191
echo "$job_list" >&2
192192
exit 1

internal/state/dynamo.go

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,12 @@ type Part struct {
125125
}
126126

127127
type Job struct {
128-
JobID string `json:"job_id"`
129-
Name string `json:"name,omitempty"`
128+
JobID string `json:"job_id"`
129+
Name string `json:"name,omitempty"`
130+
Total int `json:"total"`
131+
Counts map[Status]int `json:"counts,omitempty"`
132+
SubmittedAt string `json:"submitted_at,omitempty"`
133+
UpdatedAt string `json:"updated_at,omitempty"`
130134
}
131135

132136
type QueryProgress struct {
@@ -1427,11 +1431,13 @@ func (s *Store) ListJobsByStatus(ctx context.Context, statuses ...Status) ([]Job
14271431
TableName: aws.String(s.table),
14281432
IndexName: aws.String(readyIndexName),
14291433
KeyConditionExpression: aws.String("#gsi1pk = :status"),
1430-
ProjectionExpression: aws.String("#job_id, #job_name"),
1434+
ProjectionExpression: aws.String("#job_id, #job_name, #created_at, #updated_at"),
14311435
ExpressionAttributeNames: map[string]string{
1432-
"#gsi1pk": "gsi1pk",
1433-
"#job_id": "job_id",
1434-
"#job_name": "job_name",
1436+
"#gsi1pk": "gsi1pk",
1437+
"#job_id": "job_id",
1438+
"#job_name": "job_name",
1439+
"#created_at": "created_at",
1440+
"#updated_at": "updated_at",
14351441
},
14361442
ExpressionAttributeValues: map[string]types.AttributeValue{
14371443
":status": stringAttr(statusKey(status)),
@@ -1460,19 +1466,33 @@ func (s *Store) ListJobsByStatus(ctx context.Context, statuses ...Status) ([]Job
14601466
if err != nil {
14611467
return nil, err
14621468
}
1469+
createdAt, err := optionalStringAttr(item, "created_at")
1470+
if err != nil {
1471+
return nil, err
1472+
}
1473+
updatedAt, err := optionalStringAttr(item, "updated_at")
1474+
if err != nil {
1475+
return nil, err
1476+
}
14631477
existing := jobsByID[value.Value]
14641478
if existing.JobID == "" {
1465-
jobsByID[value.Value] = Job{JobID: value.Value, Name: name}
1466-
continue
1479+
existing = Job{JobID: value.Value, Name: name, Counts: map[Status]int{}}
14671480
}
14681481
if existing.Name == "" && name != "" {
14691482
existing.Name = name
1470-
jobsByID[value.Value] = existing
1471-
continue
14721483
}
14731484
if existing.Name != "" && name != "" && existing.Name != name {
14741485
return nil, fmt.Errorf("job %s has conflicting job_name values %q and %q", value.Value, existing.Name, name)
14751486
}
1487+
existing.Total++
1488+
existing.Counts[status]++
1489+
if createdAt != "" && (existing.SubmittedAt == "" || createdAt < existing.SubmittedAt) {
1490+
existing.SubmittedAt = createdAt
1491+
}
1492+
if updatedAt != "" && updatedAt > existing.UpdatedAt {
1493+
existing.UpdatedAt = updatedAt
1494+
}
1495+
jobsByID[value.Value] = existing
14761496
}
14771497
}
14781498
}

internal/state/dynamo_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,9 @@ func TestListJobsByStatusReturnsNames(t *testing.T) {
162162
w.Header().Set("Content-Type", "application/x-amz-json-1.0")
163163
switch {
164164
case strings.Contains(string(body), statusKey(StatusReady)):
165-
_, _ = io.WriteString(w, `{"Items":[{"job_id":{"S":"job-b"},"job_name":{"S":"Backfill B"}},{"job_id":{"S":"job-a"}}]}`)
165+
_, _ = io.WriteString(w, `{"Items":[{"job_id":{"S":"job-b"},"job_name":{"S":"Backfill B"},"created_at":{"S":"2026-06-24T00:05:00.000000000Z"},"updated_at":{"S":"2026-06-24T00:10:00.000000000Z"}},{"job_id":{"S":"job-a"},"created_at":{"S":"2026-06-24T00:02:00.000000000Z"},"updated_at":{"S":"2026-06-24T00:03:00.000000000Z"}}]}`)
166166
case strings.Contains(string(body), statusKey(StatusFailed)):
167-
_, _ = io.WriteString(w, `{"Items":[{"job_id":{"S":"job-a"},"job_name":{"S":"Backfill A"}}]}`)
167+
_, _ = io.WriteString(w, `{"Items":[{"job_id":{"S":"job-a"},"job_name":{"S":"Backfill A"},"created_at":{"S":"2026-06-24T00:01:00.000000000Z"},"updated_at":{"S":"2026-06-24T00:20:00.000000000Z"}}]}`)
168168
default:
169169
t.Errorf("request body missing expected status: %s", string(body))
170170
_, _ = io.WriteString(w, `{"Items":[]}`)
@@ -188,6 +188,12 @@ func TestListJobsByStatusReturnsNames(t *testing.T) {
188188
if len(got) != 2 || got[0].JobID != "job-a" || got[0].Name != "Backfill A" || got[1].JobID != "job-b" || got[1].Name != "Backfill B" {
189189
t.Fatalf("jobs = %+v, want sorted jobs with names", got)
190190
}
191+
if got[0].Total != 2 || got[0].Counts[StatusReady] != 1 || got[0].Counts[StatusFailed] != 1 || got[0].SubmittedAt != "2026-06-24T00:01:00.000000000Z" || got[0].UpdatedAt != "2026-06-24T00:20:00.000000000Z" {
192+
t.Fatalf("job-a summary = %+v", got[0])
193+
}
194+
if got[1].Total != 1 || got[1].Counts[StatusReady] != 1 || got[1].SubmittedAt != "2026-06-24T00:05:00.000000000Z" || got[1].UpdatedAt != "2026-06-24T00:10:00.000000000Z" {
195+
t.Fatalf("job-b summary = %+v", got[1])
196+
}
191197
}
192198

193199
func TestSelectCompactBatchPartsAllowsSingleMultiPartArtifact(t *testing.T) {

0 commit comments

Comments
 (0)