Skip to content

SNOW-2499292 Add serializable Arrow batches for distributed fetch - #1833

Draft
sfc-gh-pfus wants to merge 2 commits into
masterfrom
pfus-08-06-snow-2499292_add_serializable_arrow_batches_for_distributed_fetch
Draft

sfc-gh-pfus wants to merge 2 commits into
masterfrom
pfus-08-06-snow-2499292_add_serializable_arrow_batches_for_distributed_fetch

Conversation

@sfc-gh-pfus

@sfc-gh-pfus sfc-gh-pfus commented Aug 6, 2026 •

Copy link
Copy Markdown
Collaborator

TL;DR

Adds SerializableArrowBatch with ArrowBatch.ToSerializable and SerializableArrowBatch.ToArrowBatch to enable distributed fetch of Arrow result batches across machines with no Snowflake connection.

What changed?

  • Introduced arrowbatches.SerializableArrowBatch, a portable JSON-serializable descriptor that captures everything needed to fetch a result chunk: the presigned URL, SSE-C headers, inline IPC bytes (for the first batch), row count, column metadata, timezone, and conversion options.
  • Added ArrowBatch.ToSerializable() to convert a live batch handle into a SerializableArrowBatch before fetching.
  • Added SerializableArrowBatch.ToArrowBatch(opts ...Option) to reconstruct a fully functional ArrowBatch on a worker node using only an injected *http.Client — no Snowflake session required.
  • Extracted chunkDownloadHeaders in chunk_downloader.go as a shared helper so the same SSE-C/GCS/Azure header logic is used by both the live download path and the serializable descriptor construction.
  • Populated BatchRaw.Descriptor (a new ChunkDescriptor field on ia.BatchRaw) in rows.go for both remote chunks (URL + headers) and the inline first batch (base64-decoded RowSetBase64).
  • Added internal/arrow/download.go with OpenChunkStream, DecodeIPCRecords, and DownloadBatchRecords — low-level primitives that perform HTTP download and IPC decoding without any driver state, used by the reconstructed batch's Download closure.
  • Added JSON struct tags to ConversionOptions fields so they survive serialization as part of SerializableArrowBatch.
  • Added a cmd/arrow/serializablebatches example demonstrating the full producer → serialize → worker → fetch flow.
  • Updated doc.go with documentation covering the distributed fetch pattern, security considerations (presigned URLs and SSE-C keys are sensitive), and expiry caveats.

How to test?

Unit tests in arrowbatches/serializable_test.go cover:

  • Inline batch round-trip (no HTTP client needed).
  • Remote batch download with plain and gzip-compressed payloads, including SSE-C header forwarding.
  • Error cases: missing HTTP client for remote batches, expired/403 URLs, unsupported formats.
  • Full JSON marshal/unmarshal round-trip including nested RowTypes and all ConversionOptions flags.
  • loadLocation behavior for fixed-offset, Local, named IANA, and empty timezone names.
  • Verification that ConversionOptions (e.g. HigherPrecision) carried in the serialized form are actually applied during fetch, not just round-tripped as data.

Integration tests in arrowbatches/serialization_integration_test.go (require a live account) cover:

  • A 100,000-row result serialized, JSON round-tripped, and re-fetched with a connection-less HTTP client, with value and row-count integrity checks.
  • Structured/nested column types surviving the serialize → reconstruct → fetch path.

Why make this change?

Arrow batches returned by GetArrowBatches are live handles tied to the Snowflake connection that produced them. Distributed compute frameworks (Spark, Hadoop, etc.) need to partition a large result on a coordinator node and download chunks in parallel on worker nodes that have no Snowflake session. SerializableArrowBatch provides the seam for this: the coordinator serializes batch descriptors (which embed presigned URLs and decryption keys), ships them to workers, and each worker fetches its chunk independently using only a standard HTTP client.

@sfc-gh-pfus
sfc-gh-pfus force-pushed the pfus-08-06-snow-2499292_add_serializable_arrow_batches_for_distributed_fetch branch from eb4b175 to 8cdcbee Compare August 6, 2026 07:27
@sfc-gh-pfus
sfc-gh-pfus marked this pull request as ready for review August 6, 2026 07:30
@sfc-gh-pfus
sfc-gh-pfus requested a review from a team as a code owner August 6, 2026 07:30
@sfc-gh-pfus
sfc-gh-pfus force-pushed the pfus-08-06-snow-2499292_add_serializable_arrow_batches_for_distributed_fetch branch 2 times, most recently from e006472 to 78d6c6d Compare August 6, 2026 08:31

@sfc-gh-boler sfc-gh-boler left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a first pass and some high level commentary. This is a big enough change that it deserves a technical design doc and a PRD.

//
// Call ToSerializable before Fetch: after a successful Fetch the in-memory records are
// released, though the descriptor itself remains valid for re-serialization.
func (rb *ArrowBatch) ToSerializable() (SerializableArrowBatch, error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just "Serialize" or "Marshal" to be consistent with the usual naming scheme.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this is a bit different - this is not already serialised, this function just takes not serialisable struct and turns it to the serialisable struct.

Comment thread arrowbatches/serializable.go Outdated
// The returned batch's Fetch downloads (if remote) and transforms the records exactly
// as a batch obtained directly from GetArrowBatches would.
//
// A remote batch (URL set) requires an *http.Client via WithHTTPClient. Inline batches

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It it requires a param it should be passed directly, not through options.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not required, for inline chunk it is not used at all. But apart from global timeout, http.DefaultClient is good enough. I'm marking it as optional.

// Expiry: presigned URLs are valid only for a server-controlled window (typically a few
// hours). A remote SerializableArrowBatch must be shipped and fetched within it. Inline
// batches (InlineData set) embed their data and never expire.
type SerializableArrowBatch struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd just wall it "Serialized". ArrowBatch suffix is pointless, because it is present in the package name already.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it's not serialised, it's only able to be serialised. I can shorten the name, it may be more go-like, but is arrowbatches.Serializable fine?

RowTypes []query.ExecResponseRowType `json:"rowTypes"`
// TimezoneName is the batch's timezone name (loc.String()).
TimezoneName string `json:"timezoneName,omitempty"`
// TimezoneOffsetSeconds is the batch's timezone offset, used as a fallback when the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point of TZName if we give the offset already?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because Go is not Snowflake :) Snowflake uses offset only for timestamps, but to create a correct timezone in Go you need both. It is needed for LTZ.

TimezoneName string `json:"timezoneName,omitempty"`
// TimezoneOffsetSeconds is the batch's timezone offset, used as a fallback when the
// worker cannot resolve TimezoneName (e.g. fixed-offset zones, or missing tzdata).
TimezoneOffsetSeconds int `json:"timezoneOffsetSeconds,omitempty"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TZs don't have offsets in seconds. That's confusing. Minutes would be fine.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But Go API requires seconds to create a location:

// FixedZone returns a [Location] that always uses
// the given zone name and offset (seconds east of UTC).
func FixedZone(name string, offset int) *Location {

}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("arrow: fetching chunk: %w", err)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this going to print the URL containing keys (presigned)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It shouldn't, it goes through our secret detector.

for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the retry and cancelation story?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: cancellation - this request takes context, so it should honour cancellations as usual.
Re: retry - we have an ugly retry mechanism, but it is in our main package, not available here. I can move it to internal package (http?), but I'd prefer doing this in another PR.

Comment thread rows.go Outdated
if inlineBytes, err := base64.StdEncoding.DecodeString(scd.RowSet.RowSetBase64); err == nil {
batch.Descriptor = ia.ChunkDescriptor{InlineData: inlineBytes}
} else {
logger.WithContext(scd.ctx).Warnf("failed to decode RowSetBase64 for arrow batch serialization: %v", err)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we log an error, but there is no signalling to the caller it seems.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I also think that there's no point in decoding it here, since this field is used only for serialising it later. So I kept base64 instead of byte array.

// field is kept for forward compatibility with the streaming/JSON result path.
const arrowFormatName = "arrow"

// SerializableArrowBatch is a portable, self-contained description of an ArrowBatch.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we even want to expose those fields? They seem internal to Marsha/Unmarshal.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is needed for JSON serialisation.

// hours). A remote SerializableArrowBatch must be shipped and fetched within it. Inline
// batches (InlineData set) embed their data and never expire.
type SerializableArrowBatch struct {
// Format is the chunk payload format; currently always "arrow".

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this mean specifically? In other words, when is this going to change?

If we want to provide a contract between orchestrators and executors (distributed system), we need a version number here.

What about orchestration between different drivers? Is the plan to support it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means, that this should be always Arrow, not JSON.
This is never an external executors, it should be always handled by a drivers. So all these should be an implementation detail.
About other drivers - only Go driver supports arrow batches in this form. We can think about improvements in UD, if needed.

@sfc-gh-pfus
sfc-gh-pfus force-pushed the pfus-08-06-snow-2499292_add_serializable_arrow_batches_for_distributed_fetch branch from 78d6c6d to da861b8 Compare August 7, 2026 06:24

Copy link
Copy Markdown
Collaborator Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

Comment thread arrowbatches/serializable.go Resolved
@sfc-gh-pfus
sfc-gh-pfus force-pushed the pfus-08-06-snow-2499292_add_serializable_arrow_batches_for_distributed_fetch branch from da861b8 to a9ad80d Compare August 25, 2026 09:56
Comment on lines +142 to +147
desc := ia.ChunkDescriptor{
URL: s.URL,
Headers: s.Headers,
InlineDataBase64: s.InlineData,
UncompressedSize: s.UncompressedSize,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MEDIUM · OpenChunkStream follows arbitrary attacker-influenced URL from a deserialized descriptor with no scheme/host allowli · CWE-918

@sfc-gh-pfus
sfc-gh-pfus marked this pull request as draft September 18, 2026 07:31
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.

3 participants