diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go
index be84e11..1134257 100644
--- a/coordinator/cmd/coordinator/main.go
+++ b/coordinator/cmd/coordinator/main.go
@@ -77,6 +77,7 @@ func run() error {
CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, tx, clk),
FailTask: usecase.NewFailTask(taskRepo, jobRepo, tx, clk),
GetJobStatus: usecase.NewGetJobStatus(jobRepo, taskRepo),
+ CancelJob: usecase.NewCancelJob(jobRepo, taskRepo, tx, clk),
UploadArtifact: usecase.NewUploadArtifact(taskRepo, artifactRepo, blobStore, clk),
DownloadArtifact: usecase.NewDownloadArtifact(artifactRepo, blobStore),
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
diff --git a/coordinator/internal/chunk/tsv.go b/coordinator/internal/chunk/tsv.go
index 6b968eb..f020810 100644
--- a/coordinator/internal/chunk/tsv.go
+++ b/coordinator/internal/chunk/tsv.go
@@ -26,9 +26,19 @@ var ErrNoRows = fmt.Errorf("input has no data rows")
// Only one shard is buffered at a time, so memory is bounded by shard size (a
// worker-sized slice of the data), not by the size of the whole dataset.
func SplitTSV(r io.Reader, rowsPerShard int, emit func(index int, shard io.Reader) error) error {
+ return SplitTSVLimit(r, rowsPerShard, 0, emit)
+}
+
+// SplitTSVLimit behaves like SplitTSV but emits no more than maxRows data rows.
+// A maxRows value of zero means unlimited. This lets an operator make a small,
+// representative pipeline check without materialising a second dataset file.
+func SplitTSVLimit(r io.Reader, rowsPerShard, maxRows int, emit func(index int, shard io.Reader) error) error {
if rowsPerShard <= 0 {
return fmt.Errorf("rowsPerShard must be positive, got %d", rowsPerShard)
}
+ if maxRows < 0 {
+ return fmt.Errorf("maxRows must be non-negative, got %d", maxRows)
+ }
sc := bufio.NewScanner(r)
// Allow long lines: a SMILES row can be far wider than bufio's 64 KB default.
@@ -73,6 +83,9 @@ func SplitTSV(r io.Reader, rowsPerShard int, emit func(index int, shard io.Reade
return err
}
}
+ if maxRows > 0 && index*rowsPerShard+rows == maxRows {
+ break
+ }
}
if err := sc.Err(); err != nil {
return fmt.Errorf("read rows: %w", err)
diff --git a/coordinator/internal/chunk/tsv_test.go b/coordinator/internal/chunk/tsv_test.go
index ba12917..585b35a 100644
--- a/coordinator/internal/chunk/tsv_test.go
+++ b/coordinator/internal/chunk/tsv_test.go
@@ -103,6 +103,22 @@ func TestSplitSingleShardWhenSizeExceedsRows(t *testing.T) {
}
}
+func TestSplitLimitUsesOnlyLeadingDataRows(t *testing.T) {
+ input := "h\nr1\nr2\nr3\nr4\nr5\n"
+ var shards []string
+ err := SplitTSVLimit(strings.NewReader(input), 2, 3, func(_ int, shard io.Reader) error {
+ b, _ := io.ReadAll(shard)
+ shards = append(shards, string(b))
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := strings.Join(shards, ""), "h\nr1\nr2\nh\nr3\n"; got != want {
+ t.Errorf("limited shards = %q, want %q", got, want)
+ }
+}
+
// The scanned bytes are reused by bufio; the shard buffer must copy them, or a
// later row would corrupt an earlier one. This guards that copy.
func TestSplitDoesNotAliasScannerBuffer(t *testing.T) {
diff --git a/coordinator/internal/domain/errors.go b/coordinator/internal/domain/errors.go
index d64b1f6..fca2979 100644
--- a/coordinator/internal/domain/errors.go
+++ b/coordinator/internal/domain/errors.go
@@ -8,13 +8,14 @@ import "errors"
//
// Always compare with errors.Is — outer layers may wrap these with %w.
var (
- ErrJobNotFound = errors.New("job not found")
- ErrTaskNotFound = errors.New("task not found")
- ErrWorkerNotFound = errors.New("worker not found")
- ErrArtifactNotFound = errors.New("artifact not found")
- ErrLeaseConflict = errors.New("task leased to another worker")
- ErrStaleAttempt = errors.New("attempt does not match lease")
- ErrResultConflict = errors.New("different result already recorded")
- ErrInvalidInput = errors.New("invalid input")
- ErrTaskNotLeased = errors.New("task is not currently leased")
+ ErrJobNotFound = errors.New("job not found")
+ ErrTaskNotFound = errors.New("task not found")
+ ErrWorkerNotFound = errors.New("worker not found")
+ ErrArtifactNotFound = errors.New("artifact not found")
+ ErrJobNotCancellable = errors.New("job cannot be cancelled")
+ ErrLeaseConflict = errors.New("task leased to another worker")
+ ErrStaleAttempt = errors.New("attempt does not match lease")
+ ErrResultConflict = errors.New("different result already recorded")
+ ErrInvalidInput = errors.New("invalid input")
+ ErrTaskNotLeased = errors.New("task is not currently leased")
)
diff --git a/coordinator/internal/domain/job.go b/coordinator/internal/domain/job.go
index 0aa4827..7d6be3d 100644
--- a/coordinator/internal/domain/job.go
+++ b/coordinator/internal/domain/job.go
@@ -99,18 +99,21 @@ func NewJobWithTasks(workload, inputURI string, params map[string]any,
// JobProgress is the aggregate view of a job and the state of its tasks.
type JobProgress struct {
- Job Job
- Total int
- Pending int
- Leased int
- Done int
- Failed int
+ Job Job
+ Total int
+ Pending int
+ Leased int
+ Done int
+ Failed int
+ Cancelled int
}
// DeriveStatus computes what the job's status should be from its task counts,
// so the rule lives here rather than in a SQL trigger or a handler.
func (p JobProgress) DeriveStatus() JobStatus {
switch {
+ case p.Job.Status == JobCancelled:
+ return JobCancelled
case p.Total == 0:
return JobPending
case p.Done == p.Total:
diff --git a/coordinator/internal/domain/job_test.go b/coordinator/internal/domain/job_test.go
index 0b58902..54a221d 100644
--- a/coordinator/internal/domain/job_test.go
+++ b/coordinator/internal/domain/job_test.go
@@ -81,6 +81,7 @@ func TestDeriveStatus(t *testing.T) {
{"all done", JobProgress{Total: 3, Done: 3}, JobCompleted},
{"done and failed", JobProgress{Total: 3, Done: 2, Failed: 1}, JobFailed},
{"failed but work remains", JobProgress{Total: 3, Pending: 1, Failed: 2}, JobRunning},
+ {"cancelled job wins over task histogram", JobProgress{Job: Job{Status: JobCancelled}, Total: 3, Done: 1, Cancelled: 2}, JobCancelled},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
diff --git a/coordinator/internal/domain/task.go b/coordinator/internal/domain/task.go
index 306fed5..7864660 100644
--- a/coordinator/internal/domain/task.go
+++ b/coordinator/internal/domain/task.go
@@ -259,6 +259,24 @@ func (t *Task) ExpireLease(now time.Time) {
t.CompletedAt = &now
}
+// Cancel prevents any further worker transition for a task that has not
+// reached a terminal result. A cancelled lease deliberately becomes invalid:
+// a worker still running locally must not upload or complete after its job was
+// stopped by the operator.
+func (t *Task) Cancel(now time.Time) bool {
+ if t.Status == TaskCompleted || t.Status == TaskFailed || t.Status == TaskCancelled {
+ return false
+ }
+ t.Status = TaskCancelled
+ t.LeaseOwner = nil
+ t.LeaseExpiresAt = nil
+ t.ErrorCode = nil
+ t.ErrorMessage = nil
+ t.CompletedAt = &now
+ t.Version++
+ return true
+}
+
// ClaimedTask is the worker-facing projection of a leased task. Input is either
// an external URI or a coordinator-stored shard (InputArtifactID set); the
// transport turns the latter into a coordinator download URL.
diff --git a/coordinator/internal/domain/task_test.go b/coordinator/internal/domain/task_test.go
index 9c2ff98..a4c7862 100644
--- a/coordinator/internal/domain/task_test.go
+++ b/coordinator/internal/domain/task_test.go
@@ -168,6 +168,23 @@ func TestExpireLeaseIgnoresUnleasedTasks(t *testing.T) {
}
}
+func TestCancelInvalidatesLeaseButPreservesTerminalTask(t *testing.T) {
+ task := leasedTask(1, 3)
+ if !task.Cancel(testNow) {
+ t.Fatal("leased task should be cancelled")
+ }
+ if task.Status != TaskCancelled || task.LeaseOwner != nil || task.LeaseExpiresAt != nil {
+ t.Errorf("cancelled task = %+v", task)
+ }
+ if task.Cancel(testLater) {
+ t.Error("cancelled task must not be changed twice")
+ }
+ completed := &Task{Status: TaskCompleted}
+ if completed.Cancel(testNow) {
+ t.Error("completed task must remain terminal")
+ }
+}
+
func TestFirstHeartbeatMovesLeasedToRunning(t *testing.T) {
task := leasedTask(1, 3)
until := testLater.Add(time.Hour)
diff --git a/coordinator/internal/memstore/memstore.go b/coordinator/internal/memstore/memstore.go
index 7916951..44db68e 100644
--- a/coordinator/internal/memstore/memstore.go
+++ b/coordinator/internal/memstore/memstore.go
@@ -148,6 +148,18 @@ func (r *TaskRepo) CountByStatus(ctx context.Context, jobID uuid.UUID) (map[doma
return counts, nil
}
+func (r *TaskRepo) CancelByJob(_ context.Context, jobID uuid.UUID, now time.Time) (int64, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ var cancelled int64
+ for _, task := range r.tasks {
+ if task.JobID == jobID && task.Cancel(now) {
+ cancelled++
+ }
+ }
+ return cancelled, nil
+}
+
func (r *TaskRepo) ExpireLeases(ctx context.Context, now time.Time) (int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
diff --git a/coordinator/internal/storage/postgres/integration_test.go b/coordinator/internal/storage/postgres/integration_test.go
index cb0c610..6b3395a 100644
--- a/coordinator/internal/storage/postgres/integration_test.go
+++ b/coordinator/internal/storage/postgres/integration_test.go
@@ -206,6 +206,27 @@ func TestClaimNextReturnsNilOnEmptyQueue(t *testing.T) {
}
}
+func TestCancelJobCancelsEveryUnfinishedTask(t *testing.T) {
+ pool := testPool(t)
+ ctx := context.Background()
+ job, _ := seedJob(t, pool, 3)
+ clk := fixedClock{now: time.Now().UTC()}
+ uc := usecase.NewCancelJob(NewJobRepo(pool), NewTaskRepo(pool), NewTxManager(pool), clk)
+
+ cancelled, err := uc.Execute(ctx, job.ID)
+ if err != nil || cancelled != 3 {
+ t.Fatalf("cancel = (%d, %v), want (3, nil)", cancelled, err)
+ }
+ stored, err := NewJobRepo(pool).Get(ctx, job.ID)
+ if err != nil || stored.Status != domain.JobCancelled {
+ t.Fatalf("job after cancel = (%+v, %v)", stored, err)
+ }
+ counts, err := NewTaskRepo(pool).CountByStatus(ctx, job.ID)
+ if err != nil || counts[domain.TaskCancelled] != 3 {
+ t.Fatalf("cancelled tasks = %d, err = %v", counts[domain.TaskCancelled], err)
+ }
+}
+
func TestUpdateRejectsStaleVersion(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
diff --git a/coordinator/internal/storage/postgres/task_repo.go b/coordinator/internal/storage/postgres/task_repo.go
index 2a4480f..18b2646 100644
--- a/coordinator/internal/storage/postgres/task_repo.go
+++ b/coordinator/internal/storage/postgres/task_repo.go
@@ -295,6 +295,29 @@ func (r *TaskRepo) CountByStatus(ctx context.Context, jobID uuid.UUID) (map[doma
return counts, rows.Err()
}
+// cancelByJobSQL mirrors domain.Task.Cancel in one set-based update. It runs in
+// the same transaction as the job-status update, so no claimable shard remains
+// after an operator receives a successful cancellation response.
+const cancelByJobSQL = `
+UPDATE tasks
+SET status = 'cancelled'::task_status,
+ lease_owner = NULL,
+ lease_expires_at = NULL,
+ error_code = NULL,
+ error_message = NULL,
+ completed_at = $2,
+ version = version + 1
+WHERE job_id = $1
+ AND status IN ('pending','leased','running')`
+
+func (r *TaskRepo) CancelByJob(ctx context.Context, jobID uuid.UUID, now time.Time) (int64, error) {
+ tag, err := conn(ctx, r.pool).Exec(ctx, cancelByJobSQL, jobID, now)
+ if err != nil {
+ return 0, err
+ }
+ return tag.RowsAffected(), nil
+}
+
// expireLeasesSQL applies the lease-expiry rule set-based, mirroring
// domain.Task.ExpireLease: requeue while attempts remain, otherwise fail.
//
diff --git a/coordinator/internal/transport/http/dto.go b/coordinator/internal/transport/http/dto.go
index c652b25..f944482 100644
--- a/coordinator/internal/transport/http/dto.go
+++ b/coordinator/internal/transport/http/dto.go
@@ -111,13 +111,14 @@ type uploadJobResponse struct {
}
type jobProgressResponse struct {
- ID uuid.UUID `json:"id"`
- Status string `json:"status"`
- Total int `json:"total"`
- Pending int `json:"pending"`
- Leased int `json:"leased"`
- Done int `json:"completed"`
- Failed int `json:"failed"`
+ ID uuid.UUID `json:"id"`
+ Status string `json:"status"`
+ Total int `json:"total"`
+ Pending int `json:"pending"`
+ Leased int `json:"leased"`
+ Done int `json:"completed"`
+ Failed int `json:"failed"`
+ Cancelled int `json:"cancelled"`
}
type uploadArtifactResponse struct {
@@ -153,12 +154,13 @@ func toClaimedTaskResponse(c domain.ClaimedTask) claimedTaskResponse {
func toJobProgressResponse(p domain.JobProgress) jobProgressResponse {
return jobProgressResponse{
- ID: p.Job.ID,
- Status: string(p.DeriveStatus()),
- Total: p.Total,
- Pending: p.Pending,
- Leased: p.Leased,
- Done: p.Done,
- Failed: p.Failed,
+ ID: p.Job.ID,
+ Status: string(p.DeriveStatus()),
+ Total: p.Total,
+ Pending: p.Pending,
+ Leased: p.Leased,
+ Done: p.Done,
+ Failed: p.Failed,
+ Cancelled: p.Cancelled,
}
}
diff --git a/coordinator/internal/transport/http/errors.go b/coordinator/internal/transport/http/errors.go
index 358704e..ae14782 100644
--- a/coordinator/internal/transport/http/errors.go
+++ b/coordinator/internal/transport/http/errors.go
@@ -50,7 +50,8 @@ func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) {
case errors.Is(err, domain.ErrLeaseConflict),
errors.Is(err, domain.ErrStaleAttempt),
errors.Is(err, domain.ErrResultConflict),
- errors.Is(err, domain.ErrTaskNotLeased):
+ errors.Is(err, domain.ErrTaskNotLeased),
+ errors.Is(err, domain.ErrJobNotCancellable):
status = http.StatusConflict
case errors.Is(err, usecase.ErrNotImplemented):
status = http.StatusNotImplemented
diff --git a/coordinator/internal/transport/http/handlers.go b/coordinator/internal/transport/http/handlers.go
index df61461..4bbf5c8 100644
--- a/coordinator/internal/transport/http/handlers.go
+++ b/coordinator/internal/transport/http/handlers.go
@@ -182,7 +182,7 @@ func (s *Server) handleFailure(w http.ResponseWriter, r *http.Request) {
const defaultChunkRows = 1000
// handleUploadDataset accepts a multipart submission — the dataset file plus the
-// workload/parameters/chunk_rows fields — and hands the file, streamed, to the
+// workload/parameters/chunk_rows/max_rows fields — and hands the file, streamed, to the
// chunker. The text fields MUST precede the file part: the file is streamed, not
// buffered, so by the time it arrives the other fields are already parsed.
func (s *Server) handleUploadDataset(w http.ResponseWriter, r *http.Request) {
@@ -197,11 +197,13 @@ func (s *Server) handleUploadDataset(w http.ResponseWriter, r *http.Request) {
workload string
params map[string]any
rows = defaultChunkRows
+ maxRows int
result usecase.SubmitDatasetResult
gotDataset bool
gotWorkload bool
gotParams bool
gotRows bool
+ gotMaxRows bool
)
for {
@@ -249,6 +251,19 @@ func (s *Server) handleUploadDataset(w http.ResponseWriter, r *http.Request) {
}
rows = n
gotRows = true
+ case "max_rows":
+ if gotDataset || gotMaxRows {
+ s.writeError(w, r, domain.ErrInvalidInput)
+ return
+ }
+ b, _ := io.ReadAll(io.LimitReader(part, 32))
+ n, err := strconv.Atoi(strings.TrimSpace(string(b)))
+ if err != nil || n < 1 {
+ s.writeError(w, r, domain.ErrInvalidInput)
+ return
+ }
+ maxRows = n
+ gotMaxRows = true
case "file", "dataset":
if gotDataset || workload == "" {
s.writeError(w, r, domain.ErrInvalidInput)
@@ -262,6 +277,7 @@ func (s *Server) handleUploadDataset(w http.ResponseWriter, r *http.Request) {
Workload: workload,
Parameters: params,
RowsPerShard: rows,
+ MaxRows: maxRows,
Filename: filename,
ContentType: part.Header.Get("Content-Type"),
Body: part,
@@ -379,6 +395,27 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, toJobProgressResponse(progress))
}
+// handleCancelJob stops all non-terminal shards for an operator-requested job.
+// It is available to both the bearer API and the separately authenticated UI.
+func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := s.reqCtx(r)
+ defer cancel()
+ jobID, ok := s.pathUUID(w, r, "job_id")
+ if !ok {
+ return
+ }
+ cancelled, err := s.uc.CancelJob.Execute(ctx, jobID)
+ if err != nil {
+ s.writeError(w, r, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "job_id": jobID,
+ "status": domain.JobCancelled,
+ "cancelled_tasks": cancelled,
+ })
+}
+
// --- helpers ---
func (s *Server) reqCtx(r *http.Request) (context.Context, context.CancelFunc) {
diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go
index 1142836..00e80d8 100644
--- a/coordinator/internal/transport/http/server.go
+++ b/coordinator/internal/transport/http/server.go
@@ -24,6 +24,7 @@ type UseCases struct {
CompleteTask *usecase.CompleteTask
FailTask *usecase.FailTask
GetJobStatus *usecase.GetJobStatus
+ CancelJob *usecase.CancelJob
UploadArtifact *usecase.UploadArtifact
DownloadArtifact *usecase.DownloadArtifact
GetTaskInput *usecase.GetTaskInput
@@ -61,6 +62,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
protected.HandleFunc("POST /jobs", s.handleCreateJob)
protected.HandleFunc("POST /jobs/upload", s.handleUploadDataset)
protected.HandleFunc("GET /jobs/{job_id}", s.handleGetJob)
+ protected.HandleFunc("POST /jobs/{job_id}/cancel", s.handleCancelJob)
protected.HandleFunc("POST /tasks/claim", s.handleClaim)
protected.HandleFunc("GET /tasks/{task_id}/input", s.handleGetTaskInput)
protected.HandleFunc("POST /tasks/{task_id}/heartbeat", s.handleHeartbeat)
@@ -77,6 +79,7 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
ui.HandleFunc("GET /ui/jobs/new", s.handleUINewJob)
ui.HandleFunc("GET /ui/jobs/{job_id}", s.handleUIJob)
ui.HandleFunc("GET /ui/api/jobs/{job_id}", s.handleUIJobJSON)
+ ui.HandleFunc("POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob)
ui.HandleFunc("POST /ui/api/jobs/upload", s.handleUploadDataset)
ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload)
mux.Handle("/ui", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin))
diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go
index 01c299f..261197c 100644
--- a/coordinator/internal/transport/http/server_test.go
+++ b/coordinator/internal/transport/http/server_test.go
@@ -51,6 +51,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, tx, clk),
FailTask: usecase.NewFailTask(tasks, jobs, tx, clk),
GetJobStatus: usecase.NewGetJobStatus(jobs, tasks),
+ CancelJob: usecase.NewCancelJob(jobs, tasks, tx, clk),
UploadArtifact: usecase.NewUploadArtifact(tasks, arts, blobs, clk),
DownloadArtifact: usecase.NewDownloadArtifact(arts, blobs),
GetTaskInput: usecase.NewGetTaskInput(tasks, arts, blobs),
@@ -200,6 +201,39 @@ func TestUIUploadDatasetCreatesJob(t *testing.T) {
}
}
+func TestCancelJobStopsUnfinishedTasks(t *testing.T) {
+ e := newEnv(t, healthy)
+ code, job := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c0","input_sha256":"sha"},{"chunk_index":1,"input_uri":"s3://c1","input_sha256":"sha"}]}`)
+ if code != http.StatusCreated {
+ t.Fatalf("create: %d", code)
+ }
+ jobID := job["id"].(string)
+ if code, body := e.do(t, "POST", "/jobs/"+jobID+"/cancel", ""); code != http.StatusOK || body["cancelled_tasks"].(float64) != 2 {
+ t.Fatalf("cancel = (%d, %v)", code, body)
+ }
+ if code, progress := e.do(t, "GET", "/jobs/"+jobID, ""); code != http.StatusOK || progress["status"] != "cancelled" || progress["cancelled"].(float64) != 2 {
+ t.Fatalf("cancelled job progress = (%d, %v)", code, progress)
+ }
+}
+
+func TestUICancelJobUsesOperatorCredential(t *testing.T) {
+ e := newEnv(t, healthy)
+ code, job := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c0","input_sha256":"sha"}]}`)
+ if code != http.StatusCreated {
+ t.Fatalf("create: %d", code)
+ }
+ req, _ := http.NewRequestWithContext(context.Background(), "POST", e.ts.URL+"/ui/api/jobs/"+job["id"].(string)+"/cancel", nil)
+ req.SetBasicAuth("operator", uiToken)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("UI cancel = %d", resp.StatusCode)
+ }
+}
+
func TestUIJobAndArtifactAreScopedToTheirJob(t *testing.T) {
e := newEnv(t, healthy)
code, job := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`)
@@ -392,6 +426,31 @@ func TestUploadDatasetChunksAndServesInput(t *testing.T) {
}
}
+func TestUploadDatasetLimitsRows(t *testing.T) {
+ e := newEnv(t, healthy)
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ _ = mw.WriteField("workload", "w")
+ _ = mw.WriteField("chunk_rows", "2")
+ _ = mw.WriteField("max_rows", "3")
+ fw, _ := mw.CreateFormFile("file", "chembl.tsv")
+ _, _ = io.Copy(fw, strings.NewReader("id\tsmiles\nA\tCC\nB\tCCC\nC\tCCCC\nD\tCCCCC\n"))
+ _ = mw.Close()
+ req, _ := http.NewRequestWithContext(context.Background(), "POST", e.ts.URL+"/jobs/upload", &buf)
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var result map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&result)
+ if resp.StatusCode != http.StatusCreated || result["task_count"].(float64) != 2 {
+ t.Fatalf("limited upload = (%d, %v)", resp.StatusCode, result)
+ }
+}
+
func TestErrorMappings(t *testing.T) {
e := newEnv(t, healthy)
zero := "00000000-0000-0000-0000-000000000000"
diff --git a/coordinator/internal/transport/http/templates/dashboard.html b/coordinator/internal/transport/http/templates/dashboard.html
index f4769ee..5e24f90 100644
--- a/coordinator/internal/transport/http/templates/dashboard.html
+++ b/coordinator/internal/transport/http/templates/dashboard.html
@@ -14,7 +14,7 @@
Local coordinator
SciMesh operator dashboard
See where a computation is and what should happen next.
Start a checkThis screen currently diagnoses shard jobs.Workers upload partial CSVs to the coordinator. Until a reducer is implemented, those files are not one final scientific result.
1. Upload TSVThe coordinator splits the file into shard tasks.
2. Wait for a workerA worker claims a shard, calculates similarity, and returns a CSV.
3. Inspect artifactsDownload a partial result from the job page.
What can be downloaded now? partial_result files come from individual shards. They are useful for checking the pipeline, but are not a merged final CSV because the reducer is not implemented yet.
Shard tasks
If a task fails, its code and message appear here. Refresh the page to update the detailed rows.
Shard
State
Attempt
Worker / lease
Error
{{range .Tasks}}
#{{.ChunkIndex}}
{{statusLabel .Status}}
{{.Attempt}} / {{.MaxAttempts}}
{{if .LeaseOwner}}{{.LeaseOwner}}{{if .LeaseExpiresAt}} until {{time .LeaseExpiresAt}}{{end}}{{else}}—{{end}}