Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions acceptance/experimental/air/list/output.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@

=== list (text)
>>> [CLI] experimental air list
Run ID Experiment Status Started Duration MLflow User Accelerators
[NUMID] qwen-train ● SUCCESS [TIMESTAMP] 12s …/runs/run1 [USERNAME] 8x H100
Run ID Experiment Status Started Duration MLflow User Accelerators
[NUMID] qwen-train ● SUCCESS [TIMESTAMP] 12s qwen-train-001 [USERNAME] 8x H100

=== list (json)
>>> [CLI] experimental air list -o json
Expand All @@ -25,8 +25,8 @@

=== list --all-status (text, via AiTrainingService index)
>>> [CLI] experimental air list --all-status
Run ID Experiment Status Started Duration MLflow User Accelerators
[NUMID] qwen-train ● SUCCESS [TIMESTAMP] 12s …/runs/run1 [USERNAME] 8x H100
Run ID Experiment Status Started Duration MLflow User Accelerators
[NUMID] qwen-train ● SUCCESS [TIMESTAMP] 12s qwen-train-001 [USERNAME] 8x H100

=== list --all-status (json)
>>> [CLI] experimental air list --all-status -o json
Expand Down
13 changes: 13 additions & 0 deletions acceptance/experimental/air/list/test.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ Response.Body = '''
{"training_workflows": [{"job_run_id": "334747067049496", "submit_time": "2024-06-05T17:32:39Z"}]}
'''

# MLflow run names (for the MLflow column label) are fetched per AIR run (text mode).
[[Server]]
Pattern = "GET /api/2.0/mlflow/runs/get"
Response.Body = '''
{
"run": {
"info": {
"run_name": "qwen-train-001"
}
}
}
'''

# runs/get hydrates one index id into the same shape as a runs/list element.
[[Server]]
Pattern = "GET /api/2.2/jobs/runs/get"
Expand Down
63 changes: 43 additions & 20 deletions experimental/air/cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,13 @@ type listRow struct {

// Experiment, Duration, MLflowURL and Accelerators are table-only columns,
// omitted from JSON to match `air list --json`.
Experiment string `json:"-"`
Duration string `json:"-"`
MLflowURL string `json:"-"`
Accelerators string `json:"-"`
Experiment string `json:"-"`
Duration string `json:"-"`
MLflowURL string `json:"-"`
MLflowLabel string `json:"-"`
RunURL string `json:"-"`
ExperimentURL string `json:"-"`
Accelerators string `json:"-"`
}

// listedRun pairs a row with its task run id, so the MLflow link can be fetched
Expand All @@ -68,6 +71,7 @@ type listQuery struct {
filters listFilters
fetchMLflow bool
limit int
workspaceID int64
}

func newListCommand() *cobra.Command {
Expand Down Expand Up @@ -118,6 +122,15 @@ func newListCommand() *cobra.Command {
userFilter = currentUser
}

// Fetch workspace ID once for dashboard links; proceed with 0 on error.
var workspaceID int64
wsID, err := w.CurrentWorkspaceID(ctx)
if err != nil {
log.Debugf(ctx, "air list: could not fetch workspace ID for dashboard links: %v", err)
} else {
workspaceID = wsID
}

fetcher := newRunFetcher(ctx, w, listQuery{
activeOnly: !allStatus,
allUsers: allUsers,
Expand All @@ -126,6 +139,7 @@ func newListCommand() *cobra.Command {
filters: f,
fetchMLflow: root.OutputType(cmd) == flags.OutputText,
limit: limit,
workspaceID: workspaceID,
})

// JSON prints the newest `limit` runs once. Text renders the table:
Expand Down Expand Up @@ -165,6 +179,7 @@ type runFetcher struct {
w *databricks.WorkspaceClient
fetchMLflow bool
strategy listStrategy
workspaceID int64

exhausted bool
}
Expand All @@ -175,6 +190,7 @@ func newRunFetcher(ctx context.Context, w *databricks.WorkspaceClient, q listQue
w: w,
fetchMLflow: q.fetchMLflow,
strategy: newListStrategy(ctx, w, q),
workspaceID: q.workspaceID,
}
}

Expand Down Expand Up @@ -209,7 +225,7 @@ func (f *runFetcher) next(want int) ([]listRow, error) {
// MLflow links appear only in the text table, so the per-run get-output
// lookups are skipped for JSON output (which omits the column anyway).
if f.fetchMLflow {
setMLflowLinks(f.ctx, f.w, entries)
setMLflowLinks(f.ctx, f.w, f.w.Config.Host, entries)
}

rows := make([]listRow, len(entries))
Expand All @@ -223,11 +239,13 @@ func (f *runFetcher) next(want int) ([]listRow, error) {
// and filters. It buffers a page's leftover runs so successive next() calls
// resume where the last stopped.
type jobsScanStrategy struct {
ctx context.Context
w *databricks.WorkspaceClient
iter listing.Iterator[jobs.BaseRun]
userFilter string
filters listFilters
ctx context.Context
w *databricks.WorkspaceClient
iter listing.Iterator[jobs.BaseRun]
userFilter string
filters listFilters
host string
workspaceID int64

scanned int
}
Expand All @@ -240,11 +258,13 @@ func newJobsScanStrategy(ctx context.Context, w *databricks.WorkspaceClient, q l
ActiveOnly: q.activeOnly,
}
return &jobsScanStrategy{
ctx: ctx,
w: w,
iter: w.Jobs.ListRuns(ctx, req),
userFilter: q.userFilter,
filters: q.filters,
ctx: ctx,
w: w,
iter: w.Jobs.ListRuns(ctx, req),
userFilter: q.userFilter,
filters: q.filters,
host: w.Config.Host,
workspaceID: q.workspaceID,
}
}

Expand All @@ -267,7 +287,7 @@ func (s *jobsScanStrategy) next(want int) ([]listedRun, error) {
if !s.filters.matches(run) {
continue
}
entries = append(entries, listedRun{row: buildListRow(run), taskRunID: taskRunID(run)})
entries = append(entries, listedRun{row: buildListRow(run, s.host, s.workspaceID), taskRunID: taskRunID(run)})
}
return entries, nil
}
Expand All @@ -288,15 +308,18 @@ func warnIfTruncated(ctx context.Context, f *runFetcher) {
}
}

// setMLflowLinks fills in each row's MLflow link in parallel, best-effort: a row
// whose IDs can't be resolved keeps its "-" placeholder.
func setMLflowLinks(ctx context.Context, w *databricks.WorkspaceClient, entries []listedRun) {
// setMLflowLinks fills in each row's MLflow link, label, and experiment URL in
// parallel, best-effort: a row whose IDs can't be resolved keeps its "-" placeholder.
func setMLflowLinks(ctx context.Context, w *databricks.WorkspaceClient, host string, entries []listedRun) {
var g errgroup.Group
g.SetLimit(enrichConcurrency)
for i := range entries {
g.Go(func() error {
if ids := mlflowIDsForTask(ctx, w, entries[i].taskRunID); ids != nil {
entries[i].row.MLflowURL = mlflowLogsURL(w.Config.Host, ids)
entries[i].row.MLflowURL = mlflowLogsURL(host, ids)
name := fetchMLflowRunName(ctx, w, ids.RunID)
entries[i].row.MLflowLabel = mlflowRunLabel(name, ids.RunID)
entries[i].row.ExperimentURL = mlflowExperimentURL(host, ids)
}
return nil
})
Expand Down
33 changes: 19 additions & 14 deletions experimental/air/cmd/list_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,33 +28,38 @@ type listCacheKey struct {
// table-only columns, which listRow tags json:"-" and so wouldn't survive a
// direct marshal), the filter inputs, and the submit time.
type cachedRun struct {
RunID string `json:"run_id"`
RunName string `json:"run_name"`
User string `json:"user"`
Status string `json:"status"`
StartedAt *string `json:"started_at"`
IsSweep bool `json:"is_sweep"`
Experiment string `json:"experiment"`
Duration string `json:"duration"`
MLflowURL string `json:"mlflow_url"`
Accelerators string `json:"accelerators"`
Fields filterFields `json:"filter_fields"`
SubmitTimeMs int64 `json:"submit_time_ms"`
RunID string `json:"run_id"`
RunName string `json:"run_name"`
User string `json:"user"`
Status string `json:"status"`
StartedAt *string `json:"started_at"`
IsSweep bool `json:"is_sweep"`
Experiment string `json:"experiment"`
Duration string `json:"duration"`
MLflowURL string `json:"mlflow_url"`
MLflowLabel string `json:"mlflow_label"`
RunURL string `json:"run_url"`
ExperimentURL string `json:"experiment_url"`
Accelerators string `json:"accelerators"`
Fields filterFields `json:"filter_fields"`
SubmitTimeMs int64 `json:"submit_time_ms"`
}

func (c cachedRun) toRow() listRow {
return listRow{
RunID: c.RunID, RunName: c.RunName, User: c.User, Status: c.Status,
StartedAt: c.StartedAt, IsSweep: c.IsSweep, Experiment: c.Experiment,
Duration: c.Duration, MLflowURL: c.MLflowURL, Accelerators: c.Accelerators,
Duration: c.Duration, MLflowURL: c.MLflowURL, MLflowLabel: c.MLflowLabel,
RunURL: c.RunURL, ExperimentURL: c.ExperimentURL, Accelerators: c.Accelerators,
}
}

func cachedRunFromRow(r listRow, fields filterFields, submitTimeMs int64) cachedRun {
return cachedRun{
RunID: r.RunID, RunName: r.RunName, User: r.User, Status: r.Status,
StartedAt: r.StartedAt, IsSweep: r.IsSweep, Experiment: r.Experiment,
Duration: r.Duration, MLflowURL: r.MLflowURL, Accelerators: r.Accelerators,
Duration: r.Duration, MLflowURL: r.MLflowURL, MLflowLabel: r.MLflowLabel,
RunURL: r.RunURL, ExperimentURL: r.ExperimentURL, Accelerators: r.Accelerators,
Fields: fields, SubmitTimeMs: submitTimeMs,
}
}
Expand Down
64 changes: 64 additions & 0 deletions experimental/air/cmd/list_detail.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package aircmd

import (
"bytes"
"context"
"errors"
"fmt"

"github.com/databricks/databricks-sdk-go"
"github.com/databricks/databricks-sdk-go/apierr"
"github.com/databricks/databricks-sdk-go/service/jobs"
)

// runDetailText fetches a run and renders the same styled view as `air get` into
// a string, for the list picker's detail pane.
func runDetailText(ctx context.Context, w *databricks.WorkspaceClient, runID int64) (string, error) {
run, err := w.Jobs.GetRun(ctx, jobs.GetRunRequest{RunId: runID})
if err != nil {
if errors.Is(err, apierr.ErrResourceDoesNotExist) {
return "", fmt.Errorf("run %d not found", runID)
}
return "", fmt.Errorf("failed to fetch run: %w", err)
}

// A missing workspace id only drops the ?o= org hint from the dashboard link.
workspaceID, _ := w.CurrentWorkspaceID(ctx)

data := buildGetData(run)
data.DashboardURL = dashboardURL(w.Config.Host, runID, workspaceID)
ids := mlflowIDs(ctx, w, run)
if ids != nil {
url := mlflowLogsURL(w.Config.Host, ids)
data.MLflowURL = &url
}

var buf bytes.Buffer
renderRunText(ctx, &buf, w, run, &data, ids)
return buf.String(), nil
}

// runLogsSnapshot fetches a one-shot tail of a run's logs into a string, for the
// list picker's detail pane.
func runLogsSnapshot(ctx context.Context, w *databricks.WorkspaceClient, runID int64) (string, error) {
status, err := resolveRunStatus(ctx, w, runID)
if err != nil {
if errors.Is(err, apierr.ErrResourceDoesNotExist) {
return "", fmt.Errorf("run %d not found", runID)
}
return "", fmt.Errorf("failed to fetch run status: %w", err)
}

req := logRequest{
runID: runID,
attempt: -1, // latest attempt
tailLines: 1000, // one-shot tail, not a live follow
staticView: true,
}

var buf bytes.Buffer
if _, err := fetchLogs(ctx, w, &buf, req, status); err != nil {
return "", fmt.Errorf("failed to fetch logs: %w", err)
}
return buf.String(), nil
}
5 changes: 4 additions & 1 deletion experimental/air/cmd/list_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import (

// buildListRow extracts the columns shown for one run. Optional cells fall back
// to "-"; MLflowURL starts as "-" and setMLflowLinks fills it in for text output.
func buildListRow(run *jobs.Run) listRow {
// host and workspaceID are used for building dashboard URLs.
func buildListRow(run *jobs.Run, host string, workspaceID int64) listRow {
experiment := "-"
if e := jobExperiment(run); e != "" {
experiment = e
Expand Down Expand Up @@ -42,6 +43,8 @@ func buildListRow(run *jobs.Run) listRow {
Experiment: experiment,
Duration: duration,
MLflowURL: "-",
MLflowLabel: "-",
RunURL: dashboardURL(host, run.RunId, workspaceID),
Accelerators: accel,
}
}
30 changes: 17 additions & 13 deletions experimental/air/cmd/list_index.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import (
// skip the network. Unlike the Jobs scan it can't lazy-page (it must sort the
// whole id set first), but it still yields in batches so the table paints early.
type indexStrategy struct {
ctx context.Context
w *databricks.WorkspaceClient
activeOnly bool
filters listFilters
limit int
cache *cache.Cache
ctx context.Context
w *databricks.WorkspaceClient
activeOnly bool
filters listFilters
limit int
cache *cache.Cache
host string
workspaceID int64

ids []int64 // newest-first run ids to hydrate, resolved on first next()
pos int
Expand All @@ -30,12 +32,14 @@ type indexStrategy struct {

func newIndexStrategy(ctx context.Context, w *databricks.WorkspaceClient, q listQuery, limit int) *indexStrategy {
return &indexStrategy{
ctx: ctx,
w: w,
activeOnly: q.activeOnly,
filters: q.filters,
limit: limit,
cache: newListCache(ctx),
ctx: ctx,
w: w,
activeOnly: q.activeOnly,
filters: q.filters,
limit: limit,
cache: newListCache(ctx),
host: w.Config.Host,
workspaceID: q.workspaceID,
}
}

Expand Down Expand Up @@ -120,7 +124,7 @@ func (s *indexStrategy) hydrate(ids []int64) ([]listedRun, error) {
if !s.filters.matchesFields(fields) {
continue
}
row := buildListRow(run)
row := buildListRow(run, s.host, s.workspaceID)
rows = append(rows, listedRun{row: row, taskRunID: taskRunID(run)})
if isTerminal(run) {
start, _ := jobTiming(run)
Expand Down
Loading
Loading