Skip to content

Commit 6e8b12e

Browse files
refactor: proposal — stash inline Arrow IPC, serve via /arrow-result
DRAFT proposal. Replaces the `arrow_inline` SSE message type from #256 with an out-of-band delivery mechanism. Motivation: remove the SSE event-size cap as a constraint on inline Arrow result size. Architecture ------------ ARROW_STREAM responses are now delivered uniformly: 1. The connector returns the base64 Arrow IPC attachment in `result.attachment` for INLINE responses (unchanged from #256). 2. The analytics route base64-decodes it once, stashes the resulting buffer in `InlineArrowStash` (bounded LRU + TTL), and emits the same `{ type: "arrow", statement_id }` SSE message that EXTERNAL_LINKS already uses — with a synthetic id prefixed `inline-`. 3. The client (unchanged from main) calls `/arrow-result/:jobId` for any `type: "arrow"` message. 4. The route handler checks the stash first; if the id has the `inline-` prefix and is still present, it serves the bytes from memory. Otherwise it falls through to the existing warehouse fetch path for real EXTERNAL_LINKS statement_ids. The `arrow_inline` SSE message type, the client-side base64 decode helper, and the SSE buffer-size bumps from #256 all go away. Reasoning --------- SSE was designed for streams of small control messages. Pushing multi-MB Arrow IPC bytes through it has two unavoidable costs: - single-event memory ceiling on both server and client, - proxy/load-balancer compatibility issues for large events. Bumping the SSE buffer (5b in the design discussion) raises both caps but doesn't fix the architectural mismatch. This PR moves bulk bytes to HTTP, where they belong: - HTTP/2 streaming, gzip, and browser background-fetch all work naturally. - SSE buffer can stay at the conservative 1 MiB default. - The wire protocol unifies INLINE and EXTERNAL_LINKS — the client has a single code path for ARROW_STREAM data. - Inline result size cap goes back up to the Databricks API limit (25 MiB) instead of being constrained by SSE. Tradeoffs --------- - Server holds IPC buffers in memory until the client fetches them (one-shot `take()` removes on read; passive TTL evicts otherwise). For 100 entries × 25 MiB worst case, that's 2.5 GiB peak — but steady state is much smaller because entries are removed as soon as the client fetches. - Single-process only. A multi-server deployment would need a shared store (Redis or equivalent) keyed on the synthetic id. The stash interface is small enough to swap implementations. - One-shot reads mean the client cannot retry a failed fetch. Acceptable for our use case (warehouse query returned data, the hook either succeeds in fetching or surfaces a generic error). Status ------ DRAFT: opened to discuss the architectural direction before landing. #256 ships with the simpler 5b SSE-buffer-bump approach (8 MiB) which is sufficient for the realistic analytics range. This proposal is the cleaner long-term solution if the team wants to invest in the server-side state. Co-authored-by: Isaac Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
1 parent aef9042 commit 6e8b12e

10 files changed

Lines changed: 296 additions & 125 deletions

File tree

packages/appkit-ui/src/js/sse/connect-sse.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@ export async function connectSSE<Payload = unknown>(
1818
lastEventId: initialLastEventId = null,
1919
retryDelay = 2000,
2020
maxRetries = 3,
21-
// 8 MiB — sized to receive inline Arrow IPC attachments from
22-
// ARROW_STREAM analytics responses; matches the server's stream
23-
// `maxEventSize`. Most events are well under 1 MiB in practice.
24-
maxBufferSize = 8 * 1024 * 1024,
21+
maxBufferSize = 1024 * 1024, // 1MB
2522
timeout = 300000, // 5 minutes
2623
onError,
2724
} = options;

packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts

Lines changed: 27 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -30,93 +30,65 @@ describe("useAnalyticsQuery", () => {
3030
lastConnectArgs = null;
3131
});
3232

33-
test("decodes arrow_inline base64 attachment via ArrowClient.processArrowBuffer", async () => {
34-
const fakeTable = { numRows: 1, schema: { fields: [] } };
33+
test("fetches Arrow IPC via /arrow-result for type:arrow (covers both inline-stash and external-link paths)", async () => {
34+
const fakeTable = { numRows: 0, schema: { fields: [] } };
35+
mockFetchArrow.mockResolvedValueOnce(new Uint8Array([1, 2, 3]));
3536
mockProcessArrowBuffer.mockResolvedValueOnce(fakeTable);
3637

37-
// 'AQID' decodes to bytes [1, 2, 3].
38-
const base64 = "AQID";
39-
4038
const { result } = renderHook(() =>
4139
useAnalyticsQuery("q", null, { format: "ARROW_STREAM" }),
4240
);
4341

44-
// Drive the SSE onMessage handler with an arrow_inline payload.
42+
// Server emits the same {type:"arrow", statement_id} shape regardless of
43+
// whether the bytes came from the warehouse (EXTERNAL_LINKS) or were
44+
// stashed locally (INLINE).
4545
await lastConnectArgs.onMessage({
46-
data: JSON.stringify({ type: "arrow_inline", attachment: base64 }),
46+
data: JSON.stringify({ type: "arrow", statement_id: "inline-abc" }),
4747
});
4848

4949
await waitFor(() => {
5050
expect(result.current.data).toBe(fakeTable);
5151
});
52-
53-
expect(mockProcessArrowBuffer).toHaveBeenCalledTimes(1);
54-
const passedBuffer = mockProcessArrowBuffer.mock.calls[0][0] as Uint8Array;
55-
expect(passedBuffer).toBeInstanceOf(Uint8Array);
56-
expect(Array.from(passedBuffer)).toEqual([1, 2, 3]);
57-
// Inline path must NOT trigger a network fetch.
58-
expect(mockFetchArrow).not.toHaveBeenCalled();
52+
expect(mockFetchArrow).toHaveBeenCalledTimes(1);
53+
expect(mockFetchArrow.mock.calls[0][0]).toBe(
54+
"/api/analytics/arrow-result/inline-abc",
55+
);
5956
});
6057

61-
test("surfaces an error when arrow_inline decode fails", async () => {
62-
mockProcessArrowBuffer.mockRejectedValueOnce(new Error("bad ipc"));
63-
58+
test("still handles type:result rows for JSON_ARRAY", async () => {
6459
const { result } = renderHook(() =>
65-
useAnalyticsQuery("q", null, { format: "ARROW_STREAM" }),
60+
useAnalyticsQuery("q", null, { format: "JSON_ARRAY" }),
6661
);
6762

6863
await lastConnectArgs.onMessage({
69-
data: JSON.stringify({ type: "arrow_inline", attachment: "AQID" }),
64+
data: JSON.stringify({
65+
type: "result",
66+
data: [{ id: 1 }, { id: 2 }],
67+
}),
7068
});
7169

7270
await waitFor(() => {
73-
expect(result.current.error).toBe(
74-
"Unable to load data, please try again",
75-
);
71+
expect(result.current.data).toEqual([{ id: 1 }, { id: 2 }]);
7672
});
77-
expect(result.current.loading).toBe(false);
73+
expect(mockProcessArrowBuffer).not.toHaveBeenCalled();
7874
});
7975

80-
test("rejects arrow_inline with missing/empty/non-string attachment without crashing atob", async () => {
81-
const cases: Array<unknown> = [undefined, null, "", 123, { foo: "bar" }];
82-
83-
for (const attachment of cases) {
84-
mockProcessArrowBuffer.mockClear();
85-
const { result, unmount } = renderHook(() =>
86-
useAnalyticsQuery("q", null, { format: "ARROW_STREAM" }),
87-
);
88-
89-
await lastConnectArgs.onMessage({
90-
data: JSON.stringify({ type: "arrow_inline", attachment }),
91-
});
92-
93-
await waitFor(() => {
94-
expect(result.current.error).toBe(
95-
"Unable to load data, please try again",
96-
);
97-
});
98-
// Critically: must NOT call processArrowBuffer (or atob) on the bad input.
99-
expect(mockProcessArrowBuffer).not.toHaveBeenCalled();
100-
101-
unmount();
102-
}
103-
});
76+
test("surfaces an error when /arrow-result fetch fails", async () => {
77+
mockFetchArrow.mockRejectedValueOnce(new Error("HTTP 404"));
10478

105-
test("still handles type:result rows for JSON_ARRAY", async () => {
10679
const { result } = renderHook(() =>
107-
useAnalyticsQuery("q", null, { format: "JSON_ARRAY" }),
80+
useAnalyticsQuery("q", null, { format: "ARROW_STREAM" }),
10881
);
10982

11083
await lastConnectArgs.onMessage({
111-
data: JSON.stringify({
112-
type: "result",
113-
data: [{ id: 1 }, { id: 2 }],
114-
}),
84+
data: JSON.stringify({ type: "arrow", statement_id: "inline-stale" }),
11585
});
11686

11787
await waitFor(() => {
118-
expect(result.current.data).toEqual([{ id: 1 }, { id: 2 }]);
88+
expect(result.current.error).toBe(
89+
"Unable to load data, please try again",
90+
);
11991
});
120-
expect(mockProcessArrowBuffer).not.toHaveBeenCalled();
92+
expect(result.current.loading).toBe(false);
12193
});
12294
});

packages/appkit-ui/src/react/hooks/use-analytics-query.ts

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,6 @@ function getArrowStreamUrl(id: string) {
2222
return `/api/analytics/arrow-result/${id}`;
2323
}
2424

25-
/** Decode a base64 string into a Uint8Array suitable for Arrow IPC parsing. */
26-
function decodeBase64(b64: string): Uint8Array {
27-
const binary = atob(b64);
28-
const bytes = new Uint8Array(binary.length);
29-
for (let i = 0; i < binary.length; i++) {
30-
bytes[i] = binary.charCodeAt(i);
31-
}
32-
return bytes;
33-
}
34-
3525
/**
3626
* Subscribe to an analytics query over SSE and returns its latest result.
3727
* Integration hook between client and analytics plugin.
@@ -139,7 +129,9 @@ export function useAnalyticsQuery<
139129
return;
140130
}
141131

142-
// success - Arrow format (external links: fetch from server)
132+
// success - Arrow format. The server delivers Arrow IPC bytes via
133+
// /arrow-result/:jobId for both INLINE (stashed server-side) and
134+
// EXTERNAL_LINKS (forwarded from the warehouse) responses.
143135
if (parsed.type === "arrow") {
144136
try {
145137
const arrowData = await ArrowClient.fetchArrow(
@@ -161,36 +153,6 @@ export function useAnalyticsQuery<
161153
}
162154
}
163155

164-
// success - Arrow format (inline: decode base64 IPC payload locally)
165-
if (parsed.type === "arrow_inline") {
166-
if (
167-
typeof parsed.attachment !== "string" ||
168-
parsed.attachment.length === 0
169-
) {
170-
console.error(
171-
"[useAnalyticsQuery] arrow_inline message missing attachment",
172-
);
173-
setLoading(false);
174-
setError("Unable to load data, please try again");
175-
return;
176-
}
177-
try {
178-
const buffer = decodeBase64(parsed.attachment);
179-
const table = await ArrowClient.processArrowBuffer(buffer);
180-
setLoading(false);
181-
setData(table as ResultType);
182-
return;
183-
} catch (error) {
184-
console.error(
185-
"[useAnalyticsQuery] Failed to decode inline Arrow data",
186-
error,
187-
);
188-
setLoading(false);
189-
setError("Unable to load data, please try again");
190-
return;
191-
}
192-
}
193-
194156
// error
195157
if (parsed.type === "error" || parsed.error || parsed.code) {
196158
const errorMsg =

packages/appkit/src/connectors/sql-warehouse/client.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ import { executeStatementDefaults } from "./defaults";
2727
const logger = createLogger("connectors:sql-warehouse");
2828

2929
/**
30-
* Maximum size for inline Arrow IPC attachments (8 MiB decoded).
31-
* Aligned with `streamDefaults.maxEventSize` so anything that would exceed
32-
* the SSE event cap fails here with a clear error rather than a confusing
33-
* "Buffer size exceeded" downstream. Larger results should use
34-
* `disposition: "EXTERNAL_LINKS"`, which the analytics fallback handles.
30+
* Maximum size for inline Arrow IPC attachments (25 MiB decoded).
31+
* Matches the Databricks Statement Execution API hard cap on INLINE
32+
* disposition. The bytes are stashed server-side (see InlineArrowStash) and
33+
* served out of band via /arrow-result/:jobId, so the SSE event-size cap
34+
* does not apply here.
3535
*/
36-
const MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024 * 1024;
36+
const MAX_INLINE_ATTACHMENT_BYTES = 25 * 1024 * 1024;
3737

3838
interface SQLWarehouseConfig {
3939
timeout?: number;

packages/appkit/src/connectors/sql-warehouse/tests/client.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,8 @@ describe("SQLWarehouseConnector._transformDataArray", () => {
293293

294294
test("rejects oversized attachments to bound memory", () => {
295295
const connector = createConnector();
296-
// 8 MiB decoded cap → ~12 MiB of base64 chars decodes to >8 MiB.
297-
const oversized = "A".repeat(12 * 1024 * 1024);
296+
// 25 MiB decoded cap → ~36 MiB of base64 chars decodes to >25 MiB.
297+
const oversized = "A".repeat(36 * 1024 * 1024);
298298
const response = {
299299
statement_id: "stmt-oversized",
300300
status: { state: "SUCCEEDED" },

packages/appkit/src/plugins/analytics/analytics.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createLogger } from "../../logging/logger";
1212
import { Plugin, toPlugin } from "../../plugin";
1313
import type { PluginManifest } from "../../registry";
1414
import { queryDefaults } from "./defaults";
15+
import { InlineArrowStash } from "./inline-arrow-stash";
1516
import manifest from "./manifest.json";
1617
import { QueryProcessor } from "./query";
1718
import type {
@@ -33,11 +34,13 @@ export class AnalyticsPlugin extends Plugin {
3334
// analytics services
3435
private SQLClient: SQLWarehouseConnector;
3536
private queryProcessor: QueryProcessor;
37+
private inlineStash: InlineArrowStash;
3638

3739
constructor(config: IAnalyticsConfig) {
3840
super(config);
3941
this.config = config;
4042
this.queryProcessor = new QueryProcessor();
43+
this.inlineStash = new InlineArrowStash();
4144

4245
this.SQLClient = new SQLWarehouseConnector({
4346
timeout: config.timeout,
@@ -76,6 +79,24 @@ export class AnalyticsPlugin extends Plugin {
7679
): Promise<void> {
7780
try {
7881
const { jobId } = req.params;
82+
83+
// Inline path: ARROW_STREAM + INLINE responses are stashed by the query
84+
// route and served from memory rather than fetched from the warehouse.
85+
const stashed = this.inlineStash.take(jobId);
86+
if (stashed) {
87+
res.setHeader("Content-Type", "application/octet-stream");
88+
res.setHeader("Content-Length", stashed.length.toString());
89+
// Don't cache — stash entries are one-shot.
90+
res.setHeader("Cache-Control", "no-store");
91+
logger.debug(
92+
"Serving inline Arrow buffer from stash: %d bytes for jobId=%s",
93+
stashed.length,
94+
jobId,
95+
);
96+
res.send(stashed);
97+
return;
98+
}
99+
79100
const workspaceClient = getWorkspaceClient();
80101

81102
logger.debug("Processing Arrow job request for jobId=%s", jobId);
@@ -247,12 +268,15 @@ export class AnalyticsPlugin extends Plugin {
247268
{ disposition: "INLINE", format: "ARROW_STREAM" },
248269
signal,
249270
);
250-
// INLINE responses with an Arrow IPC attachment are forwarded as base64
251-
// for the client to decode into an Arrow Table. Anything else (rare:
252-
// data_array under ARROW_STREAM, or an empty result) falls back to the
253-
// generic "result" payload.
271+
// INLINE responses carry the Arrow IPC bytes as a base64 `attachment`.
272+
// Stash them server-side and emit the same `{type:"arrow", statement_id}`
273+
// shape that EXTERNAL_LINKS uses, so the client fetches the bytes via
274+
// the existing /arrow-result/:jobId path. This keeps SSE messages
275+
// small and unifies the wire protocol.
254276
if (result?.attachment) {
255-
return { type: "arrow_inline", attachment: result.attachment };
277+
const buffer = Buffer.from(result.attachment, "base64");
278+
const statement_id = this.inlineStash.put(buffer);
279+
return { type: "arrow", statement_id };
256280
}
257281
return { type: "result", ...result };
258282
} catch (err: unknown) {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { randomUUID } from "node:crypto";
2+
3+
/**
4+
* Bounded TTL stash of inline Arrow IPC payloads, keyed by a synthetic
5+
* statement ID prefixed with `inline-`.
6+
*
7+
* The analytics route puts each ARROW_STREAM + INLINE response into the stash
8+
* and emits `{ type: "arrow", statement_id: <inline-...> }` over SSE. The
9+
* client fetches the bytes via the existing `/arrow-result/:jobId` endpoint,
10+
* which checks this stash first and only delegates to the warehouse fetch
11+
* when the ID is not stashed (i.e., a real EXTERNAL_LINKS statement).
12+
*
13+
* Decoupling bulk bytes from the SSE channel keeps `streamDefaults.maxEventSize`
14+
* small (control messages stay small), at the cost of in-process memory for
15+
* the duration of the round trip.
16+
*
17+
* Bounds:
18+
* - per-entry size: enforced upstream by the connector (`MAX_INLINE_ATTACHMENT_BYTES`).
19+
* - max entries: LRU-evict when full.
20+
* - TTL: time after which an unread entry is dropped.
21+
*
22+
* Reads are one-shot — `take()` removes the entry — because each query has
23+
* exactly one consumer. This bounds peak memory in steady state to roughly
24+
* one entry per active analytics query, not `maxEntries × maxBytes`.
25+
*
26+
* Single-process only. A multi-server deployment would need a shared store
27+
* (e.g. Redis) — see PR description for the limitation.
28+
*/
29+
interface InlineArrowStashOptions {
30+
/** Maximum number of pending entries (LRU eviction beyond this). Default 100. */
31+
maxEntries?: number;
32+
/** Time in ms before an unread entry is auto-evicted. Default 60_000 (60s). */
33+
ttlMs?: number;
34+
}
35+
36+
interface StashEntry {
37+
buffer: Buffer;
38+
expiresAt: number;
39+
}
40+
41+
export class InlineArrowStash {
42+
private readonly entries = new Map<string, StashEntry>();
43+
private readonly maxEntries: number;
44+
private readonly ttlMs: number;
45+
46+
constructor(options: InlineArrowStashOptions = {}) {
47+
this.maxEntries = options.maxEntries ?? 100;
48+
this.ttlMs = options.ttlMs ?? 60_000;
49+
}
50+
51+
/** Stash an Arrow IPC buffer and return the synthetic statement_id. */
52+
put(buffer: Buffer): string {
53+
this._evictExpired();
54+
while (this.entries.size >= this.maxEntries) {
55+
// LRU: oldest insertion order — Map iterates in insertion order.
56+
const oldestKey = this.entries.keys().next().value;
57+
if (oldestKey === undefined) break;
58+
this.entries.delete(oldestKey);
59+
}
60+
const id = `inline-${randomUUID()}`;
61+
this.entries.set(id, {
62+
buffer,
63+
expiresAt: Date.now() + this.ttlMs,
64+
});
65+
return id;
66+
}
67+
68+
/**
69+
* Retrieve and remove a stashed buffer. Returns `null` if the id is not in
70+
* the stash, expired, or not prefixed `inline-` (in which case the caller
71+
* should treat it as a real warehouse statement_id).
72+
*/
73+
take(id: string): Buffer | null {
74+
if (!id.startsWith("inline-")) return null;
75+
const entry = this.entries.get(id);
76+
if (!entry) return null;
77+
this.entries.delete(id);
78+
if (entry.expiresAt < Date.now()) return null;
79+
return entry.buffer;
80+
}
81+
82+
/** Drop expired entries without consuming them. */
83+
private _evictExpired(): void {
84+
const now = Date.now();
85+
for (const [id, entry] of this.entries) {
86+
if (entry.expiresAt < now) {
87+
this.entries.delete(id);
88+
}
89+
}
90+
}
91+
92+
/** For tests/observability. */
93+
size(): number {
94+
return this.entries.size;
95+
}
96+
97+
/** For tests. */
98+
clear(): void {
99+
this.entries.clear();
100+
}
101+
}

0 commit comments

Comments
 (0)