Repository navigation
feat(desktop,trpc): make creating a cloud workspace feel immediate #6566
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { provisionCloudWorkspace } from "@superset/trpc/cloud-workspace-provision"; | ||
| import { Receiver } from "@upstash/qstash"; | ||
| import { z } from "zod"; | ||
|
|
||
| import { env } from "@/env"; | ||
|
|
||
| // Provisioning is a second or two warm, but the first sandboxes after an image | ||
| // rebuild pull the image and take tens of seconds. | ||
| export const maxDuration = 60; | ||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| const receiver = new Receiver({ | ||
| currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, | ||
| nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, | ||
| }); | ||
|
|
||
| const payloadSchema = z | ||
| .object({ | ||
| cloudWorkspaceId: z.string().uuid(), | ||
| /** Absent when the user typed a name, which the row already holds. */ | ||
| namingPrompt: z.string().max(20000).optional(), | ||
| }) | ||
| .strict(); | ||
|
|
||
| /** | ||
| * Provisions the sandbox for a `cloud_workspaces` row that `cloudWorkspace.create` | ||
| * already wrote. The row's status is the only thing the client waits on, so | ||
| * this job is what turns the provisioning screen into a workspace. | ||
| */ | ||
| export async function POST(request: Request): Promise<Response> { | ||
| const body = await request.text(); | ||
| const signature = request.headers.get("upstash-signature"); | ||
| if (!signature) { | ||
| return Response.json({ error: "Missing signature" }, { status: 401 }); | ||
| } | ||
|
|
||
| // `verify` throws rather than returning false on a signature it can't even | ||
| // parse, so a malformed header answered 500 — reported as a server fault, | ||
| // noisy in Sentry, and a different answer than a well-formed wrong | ||
| // signature gets. Both are the same refusal. | ||
| let valid = false; | ||
| try { | ||
| valid = await receiver.verify({ | ||
| body, | ||
| signature, | ||
| url: `${env.NEXT_PUBLIC_API_URL}/api/cloud-workspaces/provision`, | ||
| }); | ||
| } catch { | ||
| valid = false; | ||
| } | ||
| if (!valid) { | ||
| return Response.json({ error: "Invalid signature" }, { status: 401 }); | ||
| } | ||
|
|
||
| const parsed = payloadSchema.safeParse(JSON.parse(body)); | ||
| if (!parsed.success) { | ||
| console.error("[cloud-workspaces/provision] invalid payload", parsed.error); | ||
| return Response.json({ error: "Invalid payload" }, { status: 400 }); | ||
| } | ||
|
|
||
| // A failure here is already recorded on the row and the sandbox already | ||
| // torn down, so answering 200 is right: a redelivery would repeat work that | ||
| // deliberately gave up. QStash's retries are for the deliveries that never | ||
| // reach this line. | ||
| const outcome = await provisionCloudWorkspace(parsed.data); | ||
| return Response.json({ ok: true, outcome }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| export { | ||
| type CloudWorkspaceRow, | ||
| type CloudWorkspacesValue, | ||
| useCloudWorkspaces, | ||
| } from "./useCloudWorkspaces"; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,50 @@ | ||||||||||||||||||||||||||||||||
| import { FEATURE_FLAGS } from "@superset/shared/constants"; | ||||||||||||||||||||||||||||||||
| import type { RouterOutputs } from "@superset/trpc"; | ||||||||||||||||||||||||||||||||
| import { useFeatureFlagEnabled } from "posthog-js/react"; | ||||||||||||||||||||||||||||||||
| import { authClient } from "renderer/lib/auth-client"; | ||||||||||||||||||||||||||||||||
| import { cloudTrpc } from "renderer/lib/cloud-trpc"; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| export type CloudWorkspaceRow = RouterOutputs["cloudWorkspace"]["list"][number]; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||
| * Someone is watching a provisioning row, so poll like it: a warm sandbox is | ||||||||||||||||||||||||||||||||
| * up in about a second, and this poll is now the longest part of the wait | ||||||||||||||||||||||||||||||||
| * between pressing create and the workspace opening. | ||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||
| const PROVISIONING_POLL_MS = 1_000; | ||||||||||||||||||||||||||||||||
| const IDLE_POLL_MS = 30_000; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| export interface CloudWorkspacesValue { | ||||||||||||||||||||||||||||||||
| workspaces: CloudWorkspaceRow[]; | ||||||||||||||||||||||||||||||||
| organizationId: string | null; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||
| * Every cloud workspace in the active organization, including the ones still | ||||||||||||||||||||||||||||||||
| * provisioning and the ones that failed. The row exists from the moment create | ||||||||||||||||||||||||||||||||
| * returns, and both the sidebar and the workspace route render it long before | ||||||||||||||||||||||||||||||||
| * a sandbox is behind it. | ||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||
| * Shared rather than queried per consumer so the polling cadence is one | ||||||||||||||||||||||||||||||||
| * decision: while anything is provisioning there is a screen waiting on the | ||||||||||||||||||||||||||||||||
| * flip to `ready`, and a 30s poll would leave it spinning for half a minute | ||||||||||||||||||||||||||||||||
| * after the sandbox came up. | ||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||
| export function useCloudWorkspaces(): CloudWorkspacesValue { | ||||||||||||||||||||||||||||||||
| const enabled = useFeatureFlagEnabled(FEATURE_FLAGS.CLOUD_WORKSPACES); | ||||||||||||||||||||||||||||||||
| const { data: session } = authClient.useSession(); | ||||||||||||||||||||||||||||||||
| const organizationId = session?.session?.activeOrganizationId ?? null; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const query = cloudTrpc.cloudWorkspace.list.useQuery( | ||||||||||||||||||||||||||||||||
| { organizationId: organizationId ?? "" }, | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| enabled: Boolean(enabled && organizationId), | ||||||||||||||||||||||||||||||||
| refetchInterval: (current) => | ||||||||||||||||||||||||||||||||
| current.state.data?.some((row) => row.status === "provisioning") | ||||||||||||||||||||||||||||||||
| ? PROVISIONING_POLL_MS | ||||||||||||||||||||||||||||||||
| : IDLE_POLL_MS, | ||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||
|
Comment on lines
+42
to
+46
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win Bound the 1-second poll.
Provisioning is documented as about 5 seconds. Fall back to the idle cadence once a row is older than the plausible provisioning window, so a stranded row costs one poll per 30 seconds instead of one per second. ♻️ Proposed fix to bound the fast poll const PROVISIONING_POLL_MS = 1_000;
const IDLE_POLL_MS = 30_000;
+/**
+ * A row can sit in `provisioning` forever when nothing reaps it, and polling a
+ * stranded row every second for the life of the session buys nothing.
+ */
+const PROVISIONING_WATCH_MS = 120_000;
@@
refetchInterval: (current) =>
- current.state.data?.some((row) => row.status === "provisioning")
+ current.state.data?.some(
+ (row) =>
+ row.status === "provisioning" &&
+ Date.now() - new Date(row.createdAt).getTime() <
+ PROVISIONING_WATCH_MS,
+ )
? PROVISIONING_POLL_MS
: IDLE_POLL_MS,📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| return { workspaces: query.data ?? [], organizationId }; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
CloudWorkspacesValuecarries no readiness signal, so an empty list is ambiguous.useCloudWorkspacesreturnsquery.data ?? [], and the query is disabled until the feature flag resolves and the session supplies an organization id. Consumers cannot tell "no cloud workspaces" from "not fetched yet", which forces one consumer into a wrong branch and blocks another from adopting the hook at all.apps/desktop/src/renderer/hooks/useCloudWorkspaces/useCloudWorkspaces.ts#L17-L20: add anisLoadedfield toCloudWorkspacesValueand return it from the hook, derived from the query's enabled and fetched state.apps/desktop/src/renderer/hooks/host-service/useDestroyWorkspace/useDestroyWorkspace.ts#L132-L137: gate the host-cleanup fallback onisLoaded, so a cloud workspace is never destroyed at the local host while the list is still loading.apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/useWorkspaceHostUrl.ts#L53-L59: replace the localcloudWorkspace.listquery with the shared hook and derivecloudPendingfromisLoaded, so the feature-flag gate applies here too.📍 Affects 3 files
apps/desktop/src/renderer/hooks/useCloudWorkspaces/useCloudWorkspaces.ts#L17-L20(this comment)apps/desktop/src/renderer/hooks/host-service/useDestroyWorkspace/useDestroyWorkspace.ts#L132-L137apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/useWorkspaceHostUrl.ts#L53-L59🤖 Prompt for AI Agents