Repository navigation
fix(appkit): fall back to inline results when a Cloud Fetch download fails - #723
Open
pffigueiredo wants to merge 14 commits into
Open
pffigueiredo wants to merge 14 commits into
pffigueiredo wants to merge 14 commits into
Conversation
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>
Contributor
📦 Bundle size reportCompared against
|
| dist | raw | gzip |
|---|---|---|
| JS (runtime) | 1.3 MB (+14 KB) | 467 KB (+4.9 KB) |
| Type declarations | 468 KB (-41 B) | 169 KB (-20 B) |
| Source maps | 2.6 MB (+26 KB) | 871 KB (+8.0 KB) |
| Other | 11 KB | 3.7 KB |
| Total | 4.3 MB (+40 KB) | 1.5 MB (+13 KB) |
Per-entry composition (own code — deps external (as shipped))
| Entry | Initial (gz) | Lazy (gz) | Total (gz) | node_modules (min) | Own code (min) |
|---|---|---|---|---|---|
. |
110 KB (+2.4 KB) | 2.5 KB (+1 B) | 112 KB (+2.4 KB) | external | 354 KB (+6.3 KB) |
./beta |
105 KB (+1.9 KB) | 485 B | 106 KB (+1.9 KB) | external | 316 KB (+4.7 KB) |
./testing |
46 KB | 33 KB (-4 B) | 79 KB (-4 B) | external | 229 KB |
./tsdown |
520 B | 0 B | 520 B | external | 813 B |
./type-generator |
26 KB | 0 B | 26 KB | external | 74 KB |
Chunks:
| Entry | Chunk | Load | Size (gz) |
|---|---|---|---|
. |
index.js |
initial | 105 KB |
. |
utils.js |
initial | 4.6 KB |
. |
remote-tunnel-manager.js |
lazy | 2.5 KB |
./beta |
beta.js |
initial | 85 KB |
./beta |
modular.js |
initial | 6.0 KB |
./beta |
stream-manager.js |
initial | 5.9 KB |
./beta |
service-context.js |
initial | 4.3 KB |
./beta |
databricks.js |
initial | 3.3 KB |
./beta |
client.js |
initial | 649 B |
./beta |
index.js |
initial | 20 B |
./beta |
supervisor-api.js |
lazy | 193 B |
./beta |
databricks.js |
lazy | 177 B |
./beta |
index.js |
lazy | 115 B |
./testing |
manifest.js |
initial | 33 KB |
./testing |
index.js |
initial | 10 KB |
./testing |
wide-event-emitter.js |
initial | 2.9 KB |
./testing |
index.js |
lazy | 29 KB |
./testing |
remote-tunnel-manager.js |
lazy | 2.5 KB |
./testing |
utils.js |
lazy | 1.8 KB |
./tsdown |
index.js |
initial | 520 B |
./type-generator |
index.js |
initial | 26 KB |
@databricks/appkit-ui
npm tarball (packed): 397 KB — gzipped download (dist + bin; excludes release-only docs/NOTICE).
| dist | raw | gzip |
|---|---|---|
| JS (runtime) | 429 KB | 147 KB |
| Type declarations | 267 KB | 98 KB |
| Source maps | 848 KB | 285 KB |
| CSS | 16 KB | 3.2 KB |
| Total | 1.5 MB | 533 KB |
Per-entry composition (consumer bundle — deps bundled, peerDeps external)
| Entry | Initial (gz) | Lazy (gz) | Total (gz) | node_modules (min) | Own code (min) |
|---|---|---|---|---|---|
./js |
5.3 KB | 49 KB | 55 KB | 208 KB | 14 KB |
./js/beta |
2.1 KB | 0 B | 2.1 KB | 0 B | 5.0 KB |
./react |
440 KB | 49 KB | 489 KB | 1.4 MB | 177 KB |
./react/beta |
4.9 KB | 0 B | 4.9 KB | 0 B | 12 KB |
Chunks:
| Entry | Chunk | Load | Size (gz) |
|---|---|---|---|
./js |
index.js |
initial | 5.2 KB |
./js |
chunk |
initial | 120 B |
./js |
apache-arrow |
lazy | 49 KB |
./js/beta |
beta.js |
initial | 2.1 KB |
./react |
index.js |
initial | 438 KB |
./react |
tslib |
initial | 2.1 KB |
./react |
apache-arrow |
lazy | 49 KB |
./react/beta |
beta.js |
initial | 4.9 KB |
Contributor
🤖 AppKit PR bot🔬 Run evalsStart an eval for this PR from the evals-monitor app: Go to Evals Monitor → 📦 Try this PR's app templateScaffolds a new app from this PR's SDK build. Run it in any folder (requires the GitHub CLI — gh run download 42998813156 -R databricks/appkit -n appkit-template-0.92.0-pr.e8d277a-fix-la-2169-cloud-fetch-fallback-723 -D appkit-pr-723 \
&& unzip -o "appkit-pr-723/appkit-template-0.92.0-pr.e8d277a-fix-la-2169-cloud-fetch-fallback-723.zip" -d "appkit-pr-723" \
&& databricks apps init --template "appkit-pr-723"The template pins |
…odec table Each scalar type's JSON_ARRAY parser now sits next to its formatter in json-array-cells.ts. The formatters move there unchanged from result-delivery.ts. Cells that don't fit their type now throw instead of passing through altered: a DECIMAL with more fraction digits than its scale (was silently cut), a TIMESTAMP without an offset, and a TIMESTAMP_NTZ with one. The rebuilt stream names its columns like the native Cloud Fetch stream, duplicates included. The browser skips relabeling when names repeat, so col_N names reached the user. buildArrowIPCFromRows takes the SDK's ColumnInfo. tests/fixtures/typed-values.json is a captured warehouse response, with the JSON_ARRAY text and the native Arrow stream, that pins the formats. Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
…169-cloud-fetch-fallback Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
The template lockfiles from the v0.91.2 sync pin versions that Databricks' npm proxies refuse with 403: import-in-the-middle 3.6.0, semantic-conventions 1.45.0, ansi-regex 6.6.0, and the OpenTelemetry 0.223.1 / 2.12.1 patch release. Every Template Deploy-Shape and PR Template Artifact run failed on install. Pin import-in-the-middle to 3.5.3 and semantic-conventions to 1.44.0 under the bunyan and pino instrumentations, which need ^1.41.1. Re-resolve the other blocked npm lock entries, which lands on the allowed 0.223.0 / 2.12.0 and ansi-regex 6.5.0. Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
…e.yaml tools/check-template-deps.ts requires identical override keys in both files, and tools/check-template-artifact.ts expects string specifiers. Replace the scoped semantic-conventions pins with one flat 1.44.0 pin, and pin import-in-the-middle 3.5.3 for pnpm too. Both lockfiles stay clear of the versions the npm security policy blocks. Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
…dant Null branch Shorten docblocks to their contracts, drop a JSDoc that repeated the TTL note, and fix the TTL comment that still said only refusals are remembered. vectorFromArray takes the rebuilt type without a cast, and an all-null VOID column needs no special case. Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
…parsing Review follow-ups for LA-2169: - With the links-denied hint, an inline failure that isn't about size is rethrown at once instead of running the query again over Cloud Fetch. - EXTERNAL_LINKS_DENIED only when storage refused or never answered; any other failure of the full-header-wait retry keeps its own error. A download that never answered is remembered as links-denied even when the retry then fails another way. - The client message is cloud-neutral, and each switch to inline is logged. - Cells parse strictly: decimal or scientific floats, base64 binary, real calendar dates, and decimals within their precision. - Unnamed rebuilt columns are column_N, matching the X-Appkit-Arrow-Columns header. - downloadFailureKind() sits next to the error it reads, one first-chunk helper replaces two copies, the capability memo keeps its expiry in the same map, and `patient` is now `fullHeaderWait`. - The typed-values fixture is re-captured with nulls in every column. Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
- Codec parsers name the Arrow class their table key guarantees (Int, Decimal, Timestamp) instead of casting to ad-hoc shapes, and the rebuild uses DataType.isDecimal. - A UsableLink guard replaces `externalLink as string`, and totalByteCount is converted with Number() instead of asserted. - The 4xx stop rule has a name, isFinalStatus. - Tests use real Response objects, instanceof checks, and `?? []` instead of casts that hid null, `as never`, and `as unknown as Response`. Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
Take main's template files. #726 re-resolved both template lockfiles under a 7-day cooldown, which keeps the npm versions the security policy blocks out, so this branch's template pins and lockfile changes are no longer needed. Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
…d of regexes The Databricks Python connector reads the same INLINE + JSON_ARRAY cells with int(), float(), Decimal(), date.fromisoformat() and dateutil, with no format regexes. Do the same, and keep only the checks where JavaScript is looser than those parsers: - Date.parse rolls 2026-02-31 over to March, so the day must survive a round trip. - Buffer skips characters that aren't base64, so the bytes must re-encode to the cell. - A zoned TIMESTAMP without an offset would be read in the server's zone, so a cell must stop parsing once another Z is appended. DOUBLE goes back to Number(), so "0x1A" and " 1 " are accepted; the warehouse writes neither. DECIMAL accepts fewer fraction digits than its scale, as quantize() does. Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
…lback An INLINE result under 25 MiB can still arrive in several chunks (6 MB came back as 2 on a real warehouse), and one over the limit fails with BAD_REQUEST "Inline byte limit exceeded". The fallback read a second chunk as "too large", so a firewalled result between about 6 MB and 25 MiB was denied, and an oversized one surfaced the raw error. - The connector reads every inline chunk and rebuilds the Arrow itself (`asArrow`), returning an attachment like INLINE + ARROW_STREAM. Delivery only recognises the size-limit error. - A delivery mode is reported once its first byte arrives, in one place, and the INLINE probe runs only for an "inline" or unknown hint, so the fallback passes options through. - ArrowCapabilityMemo keeps the links-denied expiry next to ArrowCapability. The route caches every INLINE request, keyed by its format parameters. - Only the first chunk gets the short header wait and stops on a timeout. A 400 re-mints the link once, like a 403. Rows are appended in a loop, which avoids a stack overflow. - The retry handle, deliverArrowBytes and the failure kind type are internal, and MAX_INLINE_ATTACHMENT_BYTES lives in defaults.ts. - Tests count storage GETs on the client, assert the failure kind rather than error context, and drop a constant-only test. Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
…wered
Node's fetch stops connecting after 10 s with UND_ERR_CONNECT_TIMEOUT. On a blocked host that
raced the 10 s header wait on the first pass, so the same failure read as "unanswered" or "other"
depending on which timer fired first. On the full-header-wait retry it always lost to the 30 s
wait, was retried as a network error for about 34 s, and surfaced as "Query execution failed"
instead of EXTERNAL_LINKS_DENIED.
A connect timeout now counts as no answer: the first chunk stops at once, a too-large result fails
in about 20 s with the storage message, and later chunks still retry. Download errors now include
their cause ("fetch failed: Connect Timeout Error ...") instead of a bare "fetch failed".
Signed-off-by: Pedro Figueiredo <pedro.figueiredo@databricks.com>
MarioCadenas
self-requested a review
November 11, 2026 10:06
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. One example is a storage firewall that doesn't allow the serverless network the app runs on (Jira LA-2169). On pods with internet off, a blocked host can hang instead, until the route's 120 s timer returns a 503 that blames the warehouse. With this PR, the chart renders in both cases.
What happens now
A Cloud Fetch download that fails before its first byte falls back to an inline
JSON_ARRAYrun, rebuilt as Arrow. Each warehouse remembers what worked.flowchart TD Q["Arrow chart query"] --> M{"Warehouse memo"} M -- "links-denied" --> I1["INLINE + JSON_ARRAY, rebuilt as Arrow"] I1 -- "fits" --> OK1["Serve"] I1 -- "over 25 MiB" --> CF M -- "inline / unknown" --> P["INLINE + ARROW_STREAM probe"] P -- "accepted (Reyden)" --> OK2["Serve, remember inline"] P -- "rejected" --> CF M -- "external" --> CF["EXTERNAL_LINKS: download the first chunk"] CF -- "first byte" --> OK3["Serve, remember external"] CF -- "fails before the first byte" --> F{"Fits inline?"} F -- "yes" --> I2["INLINE + JSON_ARRAY, rebuilt as Arrow"] I2 -- "fits" --> OK4["Serve"] I2 -- "over 25 MiB" --> R F -- "no" --> R{"Refused?"} R -- "yes" --> D["EXTERNAL_LINKS_DENIED"] R -- "no" --> RT["Cloud Fetch once more, full header wait"] RT -- "first byte" --> OK5["Serve"] RT -- "refused or no answer" --> D RT -- "other failure" --> E["That failure's own error"]"links-denied"straight away."links-denied"lasts 10 minutes, then reads as"external", so Cloud Fetch is tried again.The download
Only the first chunk gets the short wait, because only a failure before the first byte can still fall back. Every failure carries
EXTERNAL_LINK_DOWNLOAD_FAILED.Where it lives
attachment, like anINLINE+ARROW_STREAMresult. So the route and@databricks/appkit-uidon't change.json-array-cells.tsreads every cell. It uses the platform's parsers, like the Databricks Python connector. A cell that doesn't fit its column type throwsARROW_REBUILD_FAILED.INLINErequest, keyed by its format parameters.Rebuilt Arrow vs. native
This was checked against a captured warehouse response (
tests/fixtures/typed-values.json: 17 columns, nulls in every column). The rebuild matches native Arrow in 14 columns, names and nulls included. The other 3 are known limits:Tradeoffs
Scope
EXTERNAL_LINKS_DENIEDand a message that names the storage download.Verification
This ran 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.EXTERNAL_LINKS_DENIEDand the storage message. Node's own connect timeout counts as no answer, so the full-wait retry doesn't fall back to three network retries.vitest run packages/appkitpasses 4372, with 1 skipped. Typecheck,pnpm checkand knip are clean.On the App Builder
I ran the builder's own evals, headless, on a local App Builder stack where the workspace storage host resolves to an unroutable address. That's what a pod with internet off and no SEG sees: Cloud Fetch downloads never answer. The prompt asked for a chart of
samples.nyctaxi.tripsdaily trips and average fare, fetched with format"arrow_stream". The builder's template pins AppKit 0.90.0, so the patched runs used 0.90.0 plus this PR's source changes, with the same dependencies; this PR's tests pass on that base too.arrow_streamThe builder preview at the end of each run:
The 0.90.0 run logged
Failed to download chunk 0 after 3 attempts: fetch failedsix times. A patched run's dev server logged:In the slowest patched run, the agent's first screenshot landed during the 10 s header wait and showed the loading skeleton. Its next screenshot showed the chart. A date-range dashboard prompt was also run on both versions, but the agent fetched it with
useAnalyticsQueryas JSON, so it never touched Cloud Fetch.