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
30 changes: 15 additions & 15 deletions apps/dev-playground/client/src/routes/jobs.route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ export const Route = createFileRoute("/jobs")({
const MAX_STREAM_LOG = 500;

interface Run {
run_id: number;
state?: { life_cycle_state?: string; result_state?: string };
start_time?: number;
end_time?: number;
runId: number;
state?: { lifeCycleState?: string; resultState?: string };
startTime?: number;
endTime?: number;
}

function formatTime(ts?: number) {
Expand Down Expand Up @@ -211,8 +211,8 @@ function JobsRoute() {
</p>
{status.run && (
<p className="text-sm text-muted-foreground mt-1">
Run #{status.run.run_id} &mdash; started{" "}
{formatTime(status.run.start_time)}
Run #{status.run.runId} &mdash; started{" "}
{formatTime(status.run.startTime)}
</p>
)}
</Card>
Expand All @@ -235,27 +235,27 @@ function JobsRoute() {
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.run_id} className="border-b last:border-0">
<td className="py-2 pr-4 font-mono">{run.run_id}</td>
<tr key={run.runId} className="border-b last:border-0">
<td className="py-2 pr-4 font-mono">{run.runId}</td>
<td
className={`py-2 pr-4 ${stateColor(run.state?.life_cycle_state)}`}
className={`py-2 pr-4 ${stateColor(run.state?.lifeCycleState)}`}
>
{run.state?.life_cycle_state ?? "—"}
{run.state?.lifeCycleState ?? "—"}
</td>
<td
className={`py-2 pr-4 ${stateColor(run.state?.result_state)}`}
className={`py-2 pr-4 ${stateColor(run.state?.resultState)}`}
>
{run.state?.result_state ?? "—"}
{run.state?.resultState ?? "—"}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{formatTime(run.start_time)}
{formatTime(run.startTime)}
</td>
<td className="py-2">
{run.state?.life_cycle_state === "RUNNING" && (
{run.state?.lifeCycleState === "RUNNING" && (
<Button
variant="destructive"
size="sm"
onClick={() => cancelRun(run.run_id)}
onClick={() => cancelRun(run.runId)}
>
Cancel
</Button>
Expand Down
12 changes: 6 additions & 6 deletions docs/docs/plugins/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,11 @@ When `taskType` is set, the plugin maps validated parameters to the correct SDK

| Task type | SDK field | Parameter shape |
|-----------|-----------|-----------------|
| `notebook` | `notebook_params` | `Record<string, string>` — values coerced to string |
| `python_wheel` | `python_named_params` | `Record<string, string>` — values coerced to string |
| `python_script` | `python_params` | `{ args: string[] }` — positional args |
| `spark_jar` | `jar_params` | `{ args: string[] }` — positional args |
| `sql` | `sql_params` | `Record<string, string>` — values coerced to string |
| `notebook` | `notebookParams` | `Record<string, string>` — values coerced to string |
| `python_wheel` | `pythonNamedParams` | `Record<string, string>` — values coerced to string |
| `python_script` | `pythonParams` | `{ args: string[] }` — positional args |
| `spark_jar` | `jarParams` | `{ args: string[] }` — positional args |
| `sql` | `sqlParams` | `Record<string, string>` — values coerced to string |
| `dbt` | — | No parameters accepted |

```ts
Expand Down Expand Up @@ -198,7 +198,7 @@ const etl = AppKit.jobs("etl");
// Trigger a run
const result = await etl.runNow({ startDate: "2025-01-01" });
if (result.ok) {
console.log("Run ID:", result.data.run_id);
console.log("Run ID:", result.data.runId);
}

// Trigger and poll until completion
Expand Down
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@databricks/sdk-core",
"@databricks/sdk-experimental",
"@databricks/sdk-genie",
"@databricks/sdk-jobs",
"@databricks/sdk-options",
"@databricks/sdk-scim",
"@databricks/sdk-statementexecution",
Expand Down
1 change: 1 addition & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"@databricks/sdk-core": "0.51.0",
"@databricks/sdk-experimental": "0.17.0",
"@databricks/sdk-genie": "0.54.0",
"@databricks/sdk-jobs": "0.57.0",
"@databricks/sdk-options": "0.51.0",
"@databricks/sdk-scim": "0.51.0",
"@databricks/sdk-statementexecution": "0.52.0",
Expand Down
131 changes: 76 additions & 55 deletions packages/appkit/src/connectors/jobs/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,44 @@ import {
SpanStatusCode,
TelemetryManager,
} from "../../telemetry";
import {
Context,
type jobs,
type WorkspaceClient,
import { isPlainObject } from "../../utils/is-plain-object";
import type {
CancelRunRequest,
GetJobRequest,
GetRunOutputRequest,
GetRunRequest,
ListRunsRequest,
RunNowRequest,
SubmitRunRequest,
WorkspaceClient,
} from "../../workspace-client";
import type { JobsConnectorConfig } from "./types";
import type {
JobBaseRun,
JobDetails,
JobRun,
JobRunNowResponse,
JobRunOutput,
JobsConnectorConfig,
JobSubmitRunResponse,
Numeric,
} from "./types";

const logger = createLogger("connectors:jobs");

/**
* The modular SDK types int64s as `bigint`, which `JSON.stringify` rejects, and
* every jobs response ends up in HTTP JSON, SSE or the cache. `Number()` loses
* precision past 2^53, as the legacy SDK's plain `JSON.parse` did.
*/
function toNumbers<T>(value: T): Numeric<T> {
if (typeof value === "bigint") return Number(value) as Numeric<T>;
if (Array.isArray(value)) return value.map(toNumbers) as Numeric<T>;
if (!isPlainObject(value)) return value as Numeric<T>;
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, toNumbers(v)]),
) as Numeric<T>;
}

export class JobsConnector {
private readonly name = "jobs";
private readonly config: JobsConnectorConfig;
Expand Down Expand Up @@ -51,73 +80,69 @@ export class JobsConnector {

async submitRun(
workspaceClient: WorkspaceClient,
request: jobs.SubmitRun,
request: SubmitRunRequest,
signal?: AbortSignal,
): Promise<jobs.SubmitRunResponse> {
): Promise<JobSubmitRunResponse> {
return this._callApi("submit", async () => {
return workspaceClient.jobs.submit(request, this._createContext(signal));
const waiter = await workspaceClient.jobs.submitRun(request, { signal });
return { runId: Number(waiter.runId) };
});
}

async runNow(
workspaceClient: WorkspaceClient,
request: jobs.RunNow,
request: RunNowRequest,
signal?: AbortSignal,
): Promise<jobs.RunNowResponse> {
): Promise<JobRunNowResponse> {
return this._callApi("runNow", async () => {
return workspaceClient.jobs.runNow(request, this._createContext(signal));
const waiter = await workspaceClient.jobs.runNow(request, { signal });
return { runId: Number(waiter.runId) };
});
}

async getRun(
workspaceClient: WorkspaceClient,
request: jobs.GetRunRequest,
request: GetRunRequest,
signal?: AbortSignal,
): Promise<jobs.Run> {
return this._callApi("getRun", async () => {
return workspaceClient.jobs.getRun(request, this._createContext(signal));
});
): Promise<JobRun> {
return this._callApi("getRun", async () =>
toNumbers(await workspaceClient.jobs.getRun(request, { signal })),
);
}

async getRunOutput(
workspaceClient: WorkspaceClient,
request: jobs.GetRunOutputRequest,
request: GetRunOutputRequest,
signal?: AbortSignal,
): Promise<jobs.RunOutput> {
return this._callApi("getRunOutput", async () => {
return workspaceClient.jobs.getRunOutput(
request,
this._createContext(signal),
);
});
): Promise<JobRunOutput> {
return this._callApi("getRunOutput", async () =>
toNumbers(await workspaceClient.jobs.getRunOutput(request, { signal })),
);
}

async cancelRun(
workspaceClient: WorkspaceClient,
request: jobs.CancelRun,
request: CancelRunRequest,
signal?: AbortSignal,
): Promise<void> {
await this._callApi("cancelRun", async () => {
return workspaceClient.jobs.cancelRun(
request,
this._createContext(signal),
);
await workspaceClient.jobs.cancelRun(request, { signal });
});
}

async listRuns(
workspaceClient: WorkspaceClient,
request: jobs.ListRunsRequest,
request: ListRunsRequest,
signal?: AbortSignal,
): Promise<jobs.BaseRun[]> {
): Promise<JobBaseRun[]> {
return this._callApi("listRuns", async () => {
const runs: jobs.BaseRun[] = [];
const runs: JobBaseRun[] = [];
const limit = Math.max(1, Math.min(request.limit ?? 100, 100));
for await (const run of workspaceClient.jobs.listRuns(
for await (const run of workspaceClient.jobs.listRunsIter(
{ ...request, limit },
this._createContext(signal),
{ signal },
)) {
runs.push(run);
runs.push(toNumbers(run));
if (runs.length >= limit) break;
}
return runs;
Expand All @@ -126,12 +151,12 @@ export class JobsConnector {

async getJob(
workspaceClient: WorkspaceClient,
request: jobs.GetJobRequest,
request: GetJobRequest,
signal?: AbortSignal,
): Promise<jobs.Job> {
return this._callApi("getJob", async () => {
return workspaceClient.jobs.get(request, this._createContext(signal));
});
): Promise<JobDetails> {
return this._callApi("getJob", async () =>
toNumbers(await workspaceClient.jobs.getJob(request, { signal })),
);
}

private async _callApi<T>(
Expand Down Expand Up @@ -164,6 +189,17 @@ export class JobsConnector {
if (error instanceof AppKitError) {
throw error;
}
// The modular SDK's ApiError exposes the HTTP status as
// `httpStatusCode` (-1 when not an HTTP error); Plugin.execute()
// maps on `statusCode`.
if (
error instanceof Error &&
"httpStatusCode" in error &&
typeof error.httpStatusCode === "number" &&
error.httpStatusCode > 0
) {
throw Object.assign(error, { statusCode: error.httpStatusCode });
}
// Preserve SDK ApiError (and any error with a numeric statusCode)
// so Plugin.execute() can map it to the correct HTTP status.
if (
Expand Down Expand Up @@ -198,19 +234,4 @@ export class JobsConnector {
{ name: this.name, includePrefix: true },
);
}

private _createContext(signal?: AbortSignal) {
return new Context({
cancellationToken: {
// Getter — evaluated on every read so SDK code paths that poll
// (rather than subscribe) observe cancellation live.
get isCancellationRequested() {
return signal?.aborted ?? false;
},
onCancellationRequested: (cb: () => void) => {
signal?.addEventListener("abort", cb, { once: true });
},
},
});
}
}
9 changes: 8 additions & 1 deletion packages/appkit/src/connectors/jobs/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
export { JobsConnector } from "./client";
export type { JobsConnectorConfig } from "./types";
export type {
JobBaseRun,
JobDetails,
JobRun,
JobRunNowResponse,
JobRunOutput,
JobsConnectorConfig,
} from "./types";
53 changes: 53 additions & 0 deletions packages/appkit/src/connectors/jobs/tests/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, test, vi } from "vitest";

import { JobsConnector } from "../client";

// The modular Jobs SDK types int64s as `bigint`, which `JSON.stringify` rejects.
// Every connector response ends up in HTTP JSON, SSE or the cache.
describe("JobsConnector", () => {
test("getRun returns a JSON-serializable run with numeric int64s", async () => {
const getRun = vi.fn().mockResolvedValue({
runId: 9007199254740991n,
jobId: 123n,
startTime: 1700000000000n,
state: { lifeCycleState: "TERMINATED" },
tasks: [{ taskKey: "t1", attemptNumber: 0n }],
});
const client = { jobs: { getRun } } as never;

const run = await new JobsConnector({}).getRun(client, { runId: 42n });

expect(getRun).toHaveBeenCalledWith({ runId: 42n }, { signal: undefined });
expect(JSON.parse(JSON.stringify(run))).toEqual({
runId: 9007199254740991,
jobId: 123,
startTime: 1700000000000,
state: { lifeCycleState: "TERMINATED" },
tasks: [{ taskKey: "t1", attemptNumber: 0 }],
});
});

test("runNow returns the waiter's runId as a number", async () => {
const runNow = vi.fn().mockResolvedValue({ runId: 7n });
const client = { jobs: { runNow } } as never;

const result = await new JobsConnector({}).runNow(client, { jobId: 123n });

expect(result).toEqual({ runId: 7 });
});

test("surfaces the modular ApiError's httpStatusCode as statusCode", async () => {
// Shaped like sdk-core's ApiError: the status is a getter, not `statusCode`.
class ModularApiError extends Error {
get httpStatusCode() {
return 404;
}
}
const getRun = vi.fn().mockRejectedValue(new ModularApiError("not found"));
const client = { jobs: { getRun } } as never;

await expect(
new JobsConnector({}).getRun(client, { runId: 1n }),
).rejects.toMatchObject({ statusCode: 404, message: "not found" });
});
});
Loading
Loading