Skip to content

Commit a45b2af

Browse files
fix(aitools): non-zero exit when discover-schema has any failed table (#5116)
## Summary `databricks aitools tools discover-schema TABLE...` rendered per-table failures into stdout but always returned `nil` from RunE. Even when every table failed, the process exited `0`, so scripts and CI couldn't detect failure without grepping output for `"Error discovering "` — exactly the kind of `err.Error()` string-matching the repo's style guide forbids. Extract the loop into `runDiscoverSchemas` which returns `(output, anyFailed)`. RunE prints the output and returns `root.ErrAlreadyPrinted` when `anyFailed` is true so cobra produces a non-zero exit without re-printing the per-table errors. Behavior preserved: one bad table still doesn't abort the others. Spotted via multi-model code review on #4917 (GPT 5.4). Pre-existing in #5097, not introduced by #4917 — sending as a separate small fix per the "keep PRs focused" rule. ## Test plan - [ ] `go test ./experimental/aitools/cmd/` (new tests: `TestRunDiscoverSchemasFlagsTableFailureForExitCode`, `TestRunDiscoverSchemasAllSucceedReturnsFalse`). - [ ] Manual: `databricks aitools tools discover-schema doesnt.exist.foo; echo $?` should now print `1`. - [ ] Manual: a successful run still prints `0`. This pull request and its description were written by Isaac.
1 parent 9c0990c commit a45b2af

2 files changed

Lines changed: 107 additions & 34 deletions

File tree

experimental/aitools/cmd/discover_schema.go

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"slices"
1111
"strings"
1212
"sync"
13+
"sync/atomic"
1314
"syscall"
1415

1516
"github.com/databricks/cli/cmd/root"
@@ -161,47 +162,20 @@ CancelExecution before the command exits.`,
161162

162163
gate := newSQLGate(concurrency)
163164

164-
results := make([]string, len(args))
165-
g := new(errgroup.Group)
166-
for i, table := range args {
167-
g.Go(func() error {
168-
result, err := discoverTable(pollCtx, gate, w, warehouseID, table)
169-
if err != nil {
170-
results[i] = fmt.Sprintf("Error discovering %s: %v", table, err)
171-
} else {
172-
results[i] = result
173-
}
174-
// A failure on one table shouldn't abort the others.
175-
return nil
176-
})
177-
}
178-
_ = g.Wait()
165+
output, anyFailed := runDiscoverSchemas(pollCtx, gate, w, warehouseID, args)
179166

180167
if pollCtx.Err() != nil {
181168
cancelDiscoverInFlight(ctx, w.StatementExecution, gate.trackedIDs())
182169
return root.ErrAlreadyPrinted
183170
}
184171

185-
// format output with dividers for multiple tables
186-
var output string
187-
if len(results) == 1 {
188-
output = results[0]
189-
} else {
190-
divider := strings.Repeat("-", 70)
191-
var sb strings.Builder
192-
for i, result := range results {
193-
if i > 0 {
194-
sb.WriteByte('\n')
195-
sb.WriteString(divider)
196-
sb.WriteByte('\n')
197-
}
198-
fmt.Fprintf(&sb, "TABLE: %s\n%s\n", args[i], divider)
199-
sb.WriteString(result)
200-
}
201-
output = sb.String()
202-
}
203-
204172
cmdio.LogString(ctx, output)
173+
if anyFailed {
174+
// Per-table errors are already in `output`; ErrAlreadyPrinted
175+
// gives a non-zero exit without re-printing them so scripts
176+
// and CI can detect failure via the exit code.
177+
return root.ErrAlreadyPrinted
178+
}
205179
return nil
206180
},
207181
}
@@ -211,6 +185,46 @@ CancelExecution before the command exits.`,
211185
return cmd
212186
}
213187

188+
// runDiscoverSchemas discovers schemas for tables concurrently and returns the
189+
// rendered output. The bool is true if any table failed; per-table errors are
190+
// inlined into the output so one bad table doesn't abort the others.
191+
func runDiscoverSchemas(ctx context.Context, gate *sqlGate, w *databricks.WorkspaceClient, warehouseID string, tables []string) (string, bool) {
192+
results := make([]string, len(tables))
193+
var anyFailed atomic.Bool
194+
g := new(errgroup.Group)
195+
for i, table := range tables {
196+
g.Go(func() error {
197+
result, err := discoverTable(ctx, gate, w, warehouseID, table)
198+
if err != nil {
199+
results[i] = fmt.Sprintf("Error discovering %s: %v", table, err)
200+
anyFailed.Store(true)
201+
} else {
202+
results[i] = result
203+
}
204+
// A failure on one table shouldn't abort the others.
205+
return nil
206+
})
207+
}
208+
_ = g.Wait()
209+
210+
if len(tables) == 1 {
211+
return results[0], anyFailed.Load()
212+
}
213+
214+
divider := strings.Repeat("-", 70)
215+
var sb strings.Builder
216+
for i, result := range results {
217+
if i > 0 {
218+
sb.WriteByte('\n')
219+
sb.WriteString(divider)
220+
sb.WriteByte('\n')
221+
}
222+
fmt.Fprintf(&sb, "TABLE: %s\n%s\n", tables[i], divider)
223+
sb.WriteString(result)
224+
}
225+
return sb.String(), anyFailed.Load()
226+
}
227+
214228
// cancelDiscoverInFlight sends CancelExecution for every recorded statement_id.
215229
// Best effort: errors are logged but don't fail the user-visible exit.
216230
// Statements that already finished server-side return an error which we just

experimental/aitools/cmd/discover_schema_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,3 +324,62 @@ func TestDiscoverSchemaInvalidTableNameRejectedBeforeWorkspaceClient(t *testing.
324324
require.Error(t, err)
325325
assert.Contains(t, err.Error(), "expected CATALOG.SCHEMA.TABLE")
326326
}
327+
328+
func TestRunDiscoverSchemasFlagsTableFailureForExitCode(t *testing.T) {
329+
// runDiscoverSchemas must report any per-table failure via the bool
330+
// return so the caller can produce a non-zero exit. Without this signal
331+
// scripts and CI parse stdout to detect failure, which is brittle.
332+
ctx := cmdio.MockDiscard(t.Context())
333+
mockAPI := mocksql.NewMockStatementExecutionInterface(t)
334+
335+
mockAPI.EXPECT().ExecuteStatement(mock.Anything, mock.Anything).Return(&dbsql.StatementResponse{
336+
StatementId: "stmt-bad",
337+
Status: &dbsql.StatementStatus{
338+
State: dbsql.StatementStateFailed,
339+
Error: &dbsql.ServiceError{ErrorCode: "TABLE_OR_VIEW_NOT_FOUND", Message: "no such table"},
340+
},
341+
}, nil).Once()
342+
343+
w := &databricks.WorkspaceClient{StatementExecution: mockAPI}
344+
output, anyFailed := runDiscoverSchemas(ctx, newSQLGate(8), w, "wh-1", []string{"main.public.missing"})
345+
346+
assert.True(t, anyFailed)
347+
assert.Contains(t, output, "Error discovering main.public.missing")
348+
assert.Contains(t, output, "TABLE_OR_VIEW_NOT_FOUND")
349+
}
350+
351+
func TestRunDiscoverSchemasAllSucceedReturnsFalse(t *testing.T) {
352+
ctx := cmdio.MockDiscard(t.Context())
353+
mockAPI := mocksql.NewMockStatementExecutionInterface(t)
354+
355+
mockAPI.EXPECT().ExecuteStatement(mock.Anything, mock.MatchedBy(func(req dbsql.ExecuteStatementRequest) bool {
356+
return strings.HasPrefix(req.Statement, "DESCRIBE TABLE")
357+
})).Return(&dbsql.StatementResponse{
358+
StatementId: "stmt-desc",
359+
Status: &dbsql.StatementStatus{State: dbsql.StatementStateSucceeded},
360+
Result: &dbsql.ResultData{DataArray: [][]string{{"id", "BIGINT", ""}}},
361+
}, nil).Once()
362+
363+
mockAPI.EXPECT().ExecuteStatement(mock.Anything, mock.MatchedBy(func(req dbsql.ExecuteStatementRequest) bool {
364+
return strings.HasPrefix(req.Statement, "SELECT *")
365+
})).Return(&dbsql.StatementResponse{
366+
StatementId: "stmt-sample",
367+
Status: &dbsql.StatementStatus{State: dbsql.StatementStateSucceeded},
368+
}, nil).Once()
369+
370+
mockAPI.EXPECT().ExecuteStatement(mock.Anything, mock.MatchedBy(func(req dbsql.ExecuteStatementRequest) bool {
371+
return strings.Contains(req.Statement, "SUM(CASE WHEN")
372+
})).Return(&dbsql.StatementResponse{
373+
StatementId: "stmt-null",
374+
Status: &dbsql.StatementStatus{State: dbsql.StatementStateSucceeded},
375+
Manifest: &dbsql.ResultManifest{Schema: &dbsql.ResultSchema{Columns: []dbsql.ColumnInfo{{Name: "total_rows"}, {Name: "id_nulls"}}}},
376+
Result: &dbsql.ResultData{DataArray: [][]string{{"7", "0"}}},
377+
}, nil).Once()
378+
379+
w := &databricks.WorkspaceClient{StatementExecution: mockAPI}
380+
output, anyFailed := runDiscoverSchemas(ctx, newSQLGate(8), w, "wh-1", []string{"main.public.orders"})
381+
382+
assert.False(t, anyFailed)
383+
assert.Contains(t, output, "COLUMNS:")
384+
assert.NotContains(t, output, "Error discovering")
385+
}

0 commit comments

Comments
 (0)