Skip to content

fix(appkit): fall back to inline results when a Cloud Fetch download is refused - #722

Closed
pffigueiredo wants to merge 3 commits into
databricks:mainfrom
pffigueiredo:la-2169-cloud-fetch-fallback
Closed

pffigueiredo wants to merge 3 commits into
databricks:mainfrom
pffigueiredo:la-2169-cloud-fetch-fallback

Conversation

@pffigueiredo

Copy link
Copy Markdown
Contributor

Why

A chart on the Arrow path fails with HTTP 500 "Query execution failed" when workspace storage refuses the app server's Cloud Fetch download. This happens, for example, when the storage account's firewall doesn't allow the serverless network the app runs on (Jira LA-2169). AppKit makes three attempts and has no fallback. On pods with internet off, a blocked host can also hang until the route's 120 s timer returns a 503 that blames the warehouse. This PR makes the chart render in both cases. Healthy workspaces get the same bytes and the same executions as before.

What changed

  • The chunk downloader sends each link's httpHeaders. It waits at most 10 s for response headers and keeps the per-read idle timeout for the body. On a 403 it re-mints the link once with no wait. On any other 4xx except 408 and 429 it stops. Every failure carries EXTERNAL_LINK_DOWNLOAD_FAILED.
  • deliverArrowBytesWithFallback wraps deliverArrowBytes. If the download fails before the first byte, the wrapper runs the query as INLINE + JSON_ARRAY and rebuilds the same Arrow IPC stream from the raw rows and parseDatabricksType. The route and @databricks/appkit-ui don't change.
  • A refusal (400 or 403 after the re-mint), or a download that never answers, records "links-denied" for the warehouse for 10 minutes, so later queries skip the doomed download. A missing answer is recorded only after the inline result has been served.

Scope

  • Covers refused, reset, stalled, and unanswered downloads that fail before any byte reaches the browser.
  • Doesn't cover a later chunk that fails after bytes were sent. Results larger than the 25 MiB inline limit still fail under a refusal, but with EXTERNAL_LINKS_DENIED and a message that names the storage download.
  • Nested, interval, and geospatial columns stay strings in the rebuild, as they arrive on the JSON wire. Timestamps keep millisecond precision. Rebuilt TIMESTAMP columns are labeled UTC instead of the warehouse session time zone. The instants are identical, and @databricks/appkit-ui doesn't read the label.

Tradeoffs

  • A download that never answers is remembered as denied, because blocked hosts on internet-off pods hang instead of refusing. App Builder pods have no SEG, so Netvisor doesn't route their storage traffic through the node proxy. The cost: a slow but healthy store can be skipped for up to 10 minutes. Small results still render from the inline run, and a result too large for inline still goes back to Cloud Fetch. A reset or a body failure after the headers isn't remembered.
  • If a result is too large for inline after an inconclusive failure, Cloud Fetch gets one more pass with the normal per-attempt timeout, so a slow but healthy large result still renders.

Blast radius

The change is limited to the ARROW_STREAM route of the analytics plugin and the chunk downloader. The JSON path, metric views, and the browser package don't change. Every new branch runs only after a download has failed. Before this PR, that path returned a 500.

Verification

  • The tests land first, in a separate commit: 56 fail on that commit and all 224 pass with the fix. They cover the downloader, the delivery fallback, the rebuild's type and value parity with native Arrow, and the route over a real HTTP storage host.
  • vitest run packages/appkit: 4301 passed, 1 skipped. pnpm --filter @databricks/appkit typecheck and pnpm check are clean.
  • End to end on a real SQL warehouse, using an app rendered from template/ and viewed in headless Chrome. A preload answered the storage downloads with 403, 400, or no response. With 0.89.0, the two format="arrow_stream" charts showed "Error loading chart: Query execution failed", and a hung download returned 503 after 120 s. With this branch, every chart rendered: 2.8 s on a 403 and 12.9 s on a hang, with 7 ms on the next load in both cases. Healthy storage still used Cloud Fetch. Across 17 columns of real data, the rebuild matched native Arrow in 14. The other 3 are the documented limits above.

Charts on the Arrow path fail with HTTP 500 when workspace storage refuses
the app server's Cloud Fetch download, and hang until the route's 120 s
first-byte timer fires when storage never answers (LA-2169).

Add tests for the behavior the fix needs, all failing against v0.91.0:

- Processor: link headers are sent, a 400 is not retried, a 403 re-mints
  the link once, a wait for headers is bounded and counts as a denial,
  every download failure carries EXTERNAL_LINK_DOWNLOAD_FAILED.
- Delivery: a failed first chunk is replaced by an inline JSON_ARRAY run
  rebuilt as Arrow, and a result too large for inline is never stranded.
- Route: over real HTTP against a loopback storage host, charts render,
  a definite refusal is remembered for ten minutes, and errors say why.
- Rebuild: Arrow rebuilt from positional rows matches the native schema
  and values for every scalar type, including nulls.

Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
…is refused

A chart on the Arrow path asks for ARROW_STREAM, which a standard SQL
warehouse serves only as EXTERNAL_LINKS. When workspace storage refused the
app server's download, the route made three attempts and returned HTTP 500
with "Query execution failed". When storage never answered, the route's 120 s
first-byte timer returned a 503 that blamed the warehouse (LA-2169).

Processor
- Send the link's httpHeaders with every GET, and the new link's headers
  after a re-mint.
- Wait at most responseTimeoutMs (10 s) for response headers only. A timeout
  is not retried. Body reads keep the per-read idle timeout, so a healthy
  chunk may stream for longer.
- A 403 re-mints the link once and retries at once. Any other 4xx except 408
  and 429 stops. Network errors and 5xx keep the 1 s and 2 s backoff.
- Every download failure, before or after the headers, carries
  errorCode EXTERNAL_LINK_DOWNLOAD_FAILED and the last HTTP status.

Delivery
- deliverArrowBytesWithFallback pulls the first chunk before the route sends
  headers. On a failure before the first byte it runs the query as
  INLINE + JSON_ARRAY and rebuilds the Arrow IPC stream from the positional
  rows and manifest columns, typed by parseDatabricksType.
- Only a 400 or 403 after the re-mint is remembered as "links-denied", for
  ten minutes. A timeout, network error, or body failure falls back once and
  is forgotten.
- A result the inline run cannot hold (manifest byte count over 25 MiB, or a
  multi-chunk inline response) is never served truncated. After an
  inconclusive failure Cloud Fetch gets one patient pass. After a refusal the
  error is EXTERNAL_LINKS_DENIED and says why.
- Any other inline failure is rethrown unchanged. A rebuild failure is
  ARROW_REBUILD_FAILED.

Connector
- executeStatement accepts rawRows to keep dataArray and carry the manifest
  columns on the result. EXTERNAL_LINKS results now carry totalByteCount.

The inline fallback run is cached like the inline Arrow attempt, under its
own key. @databricks/appkit-ui is unchanged.

Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
Builder pods run with internet off and no SEG, so a download straight to cloud
storage hangs instead of refusing. Remember that as links-denied once the inline
fallback has served the result, so later charts skip the wait.

Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
@pffigueiredo
pffigueiredo requested a review from a team as a code owner November 9, 2026 17:55
@pffigueiredo

Copy link
Copy Markdown
Contributor Author

Moved to #723, an in-repo branch, so CI can run: fork PRs don't get the OIDC token the JFrog npm setup needs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant