Skip to content

Add aws_s3_parquet_stream output for memory-efficient Parquet streaming to S3 - #661

Open
triddell wants to merge 29 commits into
warpstreamlabs:mainfrom
triddell:feature/streaming-s3-parquet-output
Open

Add aws_s3_parquet_stream output for memory-efficient Parquet streaming to S3#661
triddell wants to merge 29 commits into
warpstreamlabs:mainfrom
triddell:feature/streaming-s3-parquet-output

Conversation

@triddell

@triddell triddell commented Jan 20, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR adds a new aws_s3_parquet_stream output plugin that streams Parquet files directly to S3 using multipart uploads, addressing memory limitations and partition routing issues in the current batch approach.

Resolves: #660
Related: #659 (partition routing bug)

Motivation

Problem 1: High Memory Usage

The current approach (aws_s3 + parquet_encode processor) buffers entire Parquet files in memory before uploading to S3:

# Current approach - high memory usage
output:
  aws_s3:
    batching:
      count: 100000
      processors:
        - parquet_encode:
            schema: [...]

Memory usage for 100K events (minimal schema):

  • Current approach: ~2.2 GB
  • This PR: ~270 MB
  • Reduction: 88%

For production workloads with complex schemas (OCSF) or larger datasets, memory can exceed 10+ GB, causing OOM errors in constrained environments.

Problem 2: Partition Routing

As documented in #659, the batch approach evaluates path expressions once per batch, causing incorrect partition routing:

path: 'data/account=${! meta("account") }/${! uuid_v4() }.parquet'

With 100K events across 2 accounts:

  • Expected: 2 files (one per account)
  • Actual: 1 file with all 100K events in wrong partition

This breaks Iceberg/Hive/Delta table partitioning and causes silent data corruption.

Solution

This PR introduces aws_s3_parquet_stream with:

  1. Incremental row group streaming - Uploads Parquet row groups to S3 as they're generated
  2. Per-message partition routing - Routes each message to correct writer based on partition_by expressions
  3. S3 multipart uploads - Streams data without buffering complete files
  4. Thread-safe writer pooling - Manages multiple concurrent partition writers

Architecture

Input Messages
    ↓
Partition Router (evaluates partition_by per message)
    ↓
Writer Pool (one writer per partition key)
    ├─ Row Buffer (configurable size, default 10K rows)
    ├─ Row Group Encoder (Parquet columnar encoding)
    ├─ Compression (Snappy/Zstd/Gzip/Brotli/LZ4)
    └─ Upload Buffer (accumulates until 5MB for S3 part)
    ↓
S3 Multipart Upload (streaming)

Memory profile per writer: ~30-60 MB (independent of dataset size)

Key Features

1. Partition Routing with partition_by

output:
  aws_s3_parquet_stream:
    bucket: my-bucket
    path: 'events/date=${! meta("date") }/account=${! meta("account") }/${! uuid_v4() }.parquet'

    # Messages with same partition values → same file
    partition_by:
      - '${! meta("date") }'
      - '${! meta("account") }'

    schema_file: ./schemas/events.yml
    row_group_size: 10000
  • Each message evaluated against partition_by expressions
  • Messages routed to correct writer based on partition key
  • Path evaluated once per partition (UUID works correctly)
  • Multiple concurrent writers for different partitions

2. Schema Loading

Supports both inline and external schema definitions:

# Inline schema
schema:
  - name: id
    type: INT64
  - name: message
    type: UTF8

# External schema file
schema_file: ./schemas/ocsf_network_activity.yml

3. Compression Support

All Parquet compression types supported:

  • uncompressed
  • snappy (default)
  • gzip
  • zstd
  • brotli
  • lz4raw

4. Configurable Row Groups

row_group_size: 5000  # Smaller = less memory, more row groups

Implementation Details

Files Added

  1. output_aws_s3_parquet_stream.go (543 lines)

    • Main output plugin implementation
    • Partition routing logic
    • Writer pool management
    • Configuration parsing
  2. parquet_streaming_writer.go (623 lines)

    • Streaming Parquet writer with S3 multipart uploads
    • Row group buffering and encoding
    • Parquet footer generation
    • Thread-safe operations
  3. output_aws_s3_parquet_stream_test.go (327 lines)

    • Unit tests for configuration parsing
    • Schema generation tests
    • Path interpolation tests
    • Writer pooling tests
  4. parquet_streaming_writer_test.go (216 lines)

    • Unit tests for streaming writer
    • Row group encoding tests
    • S3 multipart upload tests
  5. output_aws_s3_parquet_stream_localstack_test.go (266 lines)

    • Integration tests with LocalStack (mock S3)
    • End-to-end write verification
    • Partition routing validation
    • Parquet file validation

Files Modified

  1. internal/impl/parquet/convert.go (+17 lines)

    • Added json.Number type support for int32/int64 conversion
    • Required for JSON parsers that return json.Number
  2. internal/impl/parquet/schema.go (+26 lines)

    • Exported SchemaOpts struct for use by output plugins
    • Maintains backward compatibility with internal usage

Testing

Unit Tests

  • 22 tests covering configuration, schema generation, path interpolation, Parquet serialization, and S3 multipart upload lifecycle
  • Includes mock-based S3 client testing via S3API interface
  • All passing ✅

Integration Tests

  • LocalStack-based tests (no real AWS required)
  • Tests basic write operations with row group verification
  • Tests partition_by with multiple partitions
  • Validates Parquet file structure and correctness

Manual Testing

Extensive testing documented in project with:

  • 100K+ event datasets
  • Multiple partition configurations
  • Memory profiling and benchmarking
  • Comparison with batch approach

Performance

Test: 100K events with minimal schema

Metric Batch Approach Streaming (This PR) Improvement
Memory 2.2 GB 270 MB 88% reduction
Time 4.0s 3.4s 15% faster
Partitioning ❌ Broken ✅ Correct Fixed

Test: Multiple partitions (2 dates × 2 accounts)

Metric Batch Approach Streaming (This PR)
Files created 1 (wrong) 4 (correct)
Data integrity ❌ Corrupt ✅ Valid

Backwards Compatibility

  • New output plugin, no changes to existing outputs
  • Only minor additions to shared parquet package
  • No breaking changes to existing functionality

Configuration Example

output:
  aws_s3_parquet_stream:
    bucket: data-lake
    region: us-west-2
    path: 'events/date=${! meta("date") }/tenant=${! meta("tenant") }/${! uuid_v4() }.parquet'

    partition_by:
      - '${! meta("date") }'
      - '${! meta("tenant") }'

    schema_file: ./schemas/ocsf_minimal.yml
    compression: snappy
    row_group_size: 10000

    batching:
      count: 10000
      period: 10s

Use Cases

This output is particularly valuable for:

  • High-volume event streaming to S3 data lakes
  • OCSF or complex schemas requiring large memory
  • Iceberg/Hive/Delta tables with dynamic partitioning
  • Memory-constrained environments (containers, Lambda, ECS)
  • Production workloads requiring data integrity guarantees

Adds a new output plugin that writes Parquet files to S3 using multipart
uploads with incremental row group streaming, reducing memory usage by 88%
compared to the batch approach.

Key features:
- Streams row groups directly to S3 (no full file buffering)
- partition_by parameter for correct partition routing
- schema_file parameter for external schema definitions
- Supports all Parquet compression types (snappy, zstd, gzip, etc.)
- Added S3API interface to enable mock-based testing
- Updated StreamingParquetWriter to use S3API instead of concrete *s3.Client
- Added 8 new unit tests covering S3 multipart upload lifecycle:
  - Initialization and double-init protection
  - Write lifecycle (before init, after close)
  - Part upload tracking
  - CompleteMultipartUpload on Close
  - Multiple parts with correct numbering
  - AbortMultipartUpload on errors
Fixes "insufficient definition levels" errors by:
1. Using consistent schema/message type generation (both with struct tags + pointers)
2. Preserving actual column metadata from temp files instead of approximating
@triddell

Copy link
Copy Markdown
Contributor Author

Fixed Nested Optional Structs

Found and fixed the "insufficient definition levels" bug for nested optional STRUCT fields.

Root causes:

  1. Schema and message types had inconsistent optional field representations (struct tags vs pointers)
  2. Footer generation approximated column metadata instead of preserving it from temp files

Fix:

  • Unified type generation with consistent representation
  • Extract and preserve actual column metadata from each temp file's footer

Tests verify column metadata extraction from temp files and proper definition/repetition level encoding in streaming parquet output.
- Fix documentation typo: timestamp().format() → now().ts_format()
- Replace fmt.Errorf with errors.New for static error strings
- Update AWS SDK endpoint resolver to use BaseEndpoint
- Update testify assertions to use specific assertion methods
- Remove unused test struct fields
- Regenerate documentation
@triddell

Copy link
Copy Markdown
Contributor Author

CI Fixes

Fixed all linting errors and CI check failures:

  • Fixed documentation syntax errors (MDX parsing issues with < and > characters)
  • Replaced fmt.Errorf with errors.New for static error strings
  • Updated deprecated AWS SDK endpoint resolver to use BaseEndpoint
  • Fixed testify verbose assertions
  • Removed unused test struct fields
  • Applied gofmt formatting

All tests and lint checks now pass. I will review the generated documentation output more closely for any additional improvements needed.

Preserve ColumnIndex and OffsetIndex from temporary parquet files to enable
query engine optimization through page-level pruning and predicate pushdown.

- Extract and store page index data during row group flush
- Write in correct Parquet format: all ColumnIndex, then all OffsetIndex
- Track filePosition separately from uploadSize for accurate offset calculation
- Add comprehensive unit tests for page index preservation
@triddell

Copy link
Copy Markdown
Contributor Author

Page Index Preservation Fix

Issue

Further in-depth testing revealed that while page index metadata pointers (ColumnIndexOffset, OffsetIndexOffset, etc.) were being copied from temporary files, the actual ColumnIndex and OffsetIndex binary data was not preserved. This prevented query engines from utilizing page-level pruning.

Solution

  • Extract and store page index data during row group flush
  • Write in correct Parquet format: all ColumnIndex, then all OffsetIndex
  • Track filePosition separately from uploadSize for accurate offsets
  • Add 4 unit tests verifying preservation, ordering, and offset calculation

This commit addresses three critical issues with the parquet streaming writer and schema generation that were causing file corruption and compatibility issues:

1. Clear page index and bloom filter offsets in streaming writer
   The streaming writer doesn't include page indexes or bloom filters, so these offsets are invalid. If left set, parquet-go will panic with "slice bounds out of range" errors when reading the files.
   - Clear MetaData.IndexPageOffset and BloomFilterOffset
   - Clear ColumnChunk-level OffsetIndex and ColumnIndex offsets

2. Preserve LogicalType annotations for all field types
   Critical for systems like Apache Iceberg that validate types and rely on modern LogicalType annotations (STRING, INT, TIMESTAMP,
   LIST, MAP, etc.)

3. Fix optional LIST type handling in schema generation
   Don't wrap slice types in pointers when optional. The parquet-go library requires "list" tag on slice types, not pointer types.
   LIST types already handle nullability through 3-level encoding.
@triddell

Copy link
Copy Markdown
Contributor Author

Critical Bug Fixes

This update addresses three critical issues with the parquet streaming writer:

1. Page Index & Bloom Filter Offset Handling ⚠️ CRITICAL

Problem: The streaming writer was leaving page index and bloom filter offsets set in the column metadata, even though these structures aren't included in the streamed output. This caused parquet-go to panic with "slice bounds out of range" errors when reading the files.

Fix: Explicitly clear all page index and bloom filter offsets at both the ColumnMetaData and ColumnChunk levels:

  • MetaData.IndexPageOffset = 0
  • MetaData.BloomFilterOffset = 0
  • OffsetIndexOffset = 0 / OffsetIndexLength = 0
  • ColumnIndexOffset = 0 / ColumnIndexLength = 0

Validation: ✅ Tested with production file (51,898 rows, 3 row groups, 168 column chunks) - all offsets correctly cleared, file reads without errors

2. LogicalType Preservation for Iceberg Compatibility

Problem: LogicalType annotations weren't being preserved for all field types, breaking compatibility with systems like Apache Iceberg that validate types and rely on modern LogicalType annotations.

Fix: Preserve LogicalType from the field for ALL types (leaf and group) in the appendFieldToThrift() function to ensure proper type validation by downstream systems (Iceberg, Trino, etc.)

Validation: ✅ 84% of fields in test file have LogicalType annotations preserved (STRING, INT, TIMESTAMP, etc.)

3. Optional LIST Type Handling

Problem: Optional LIST types were being wrapped in pointers, but parquet-go requires the "list" tag on slice types, not pointer types. LIST types already handle nullability through 3-level encoding per the Parquet spec.

Fix: Modified wrapType() in schema.go to skip pointer wrapping for slices when optional.

Validation: ✅ All schema generation tests pass with updated behavior

@triddell

triddell commented Mar 11, 2026

Copy link
Copy Markdown
Contributor Author

Config naming consistency

  • Renamed default_compressioncompression
  • Renamed default_encodingencoding
  • Aligns with aws_s3_stream sibling output (uses content_type, not default_content_type)
  • Updated all tests and docs

Memory fix

  • Copy row group data instead of slicing to prevent retaining 100+ MB temp file buffers
  • Slicing created references that kept entire fullFileData allocation in memory

Parquet schema modernization

  • Changed interface{}any in schema.go (golangci-lint requirement)
  • Updated schema_test.go expectations for optional LIST types
  • Optional LIST types should NOT be wrapped in pointers ([]string not *[]string)
  • Parquet-go requires "list" tag on slice types, not pointer types
  • LIST types handle nullability through 3-level encoding

Production validation

  • Running this branch with a healthy workload 24/7
  • Working as designed with no issues

Expose column_index_enabled, column_index_size_limit, and data_page_statistics config fields to give users control over Parquet metadata generation for query optimization
@triddell

Copy link
Copy Markdown
Contributor Author

Docs Check Failing Despite Running make docs

I'm seeing both the CGO and Native test jobs fail on the docs check:

Documentation for 'aws_s3_parquet_stream' has changed, updating: website/docs/components/outputs/aws_s3_parquet_stream.md
Stale docs detected. This can be fixed with 'make docs'.

However, I ran make docs locally before committing and the generated website/docs/components/outputs/aws_s3_parquet_stream.md was included in my commit.

It appears that make docs is generating different output in CI compared to my local environment. This could be due to:

  • Different Go versions
  • Different build tags or environment variables
  • Platform differences (I'm on macOS, CI is Linux)

The build-docsite job passes successfully, and golangci-lint also passes. Only the docs check in the test jobs is failing.

Could you advise on how to proceed? Is there a specific environment setup or additional steps needed to ensure docs are generated consistently with CI?

@jem-davies jem-davies self-assigned this Apr 23, 2026
@jem-davies

Copy link
Copy Markdown
Collaborator

Docs Check Failing Despite Running make docs

I'm seeing both the CGO and Native test jobs fail on the docs check:

Documentation for 'aws_s3_parquet_stream' has changed, updating: website/docs/components/outputs/aws_s3_parquet_stream.md Stale docs detected. This can be fixed with 'make docs'.

Did a merge commit and a make docs and seems to be working now - taking a look at this PR 👀

Comment thread internal/impl/aws/output_aws_s3_parquet_stream.go Outdated
Comment thread internal/impl/aws/output_aws_s3_parquet_stream.go Outdated
}),
).Description("Parquet schema. Mutually exclusive with schema_file.").Optional(),
service.NewStringField(spsoFieldSchemaFile).
Description("Path to a YAML file containing a Parquet schema definition. The file should contain a parquet_encode processor resource with a schema section. Mutually exclusive with schema.").

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.

I think that this is somewhat confusing - I think that it would be better to only add schemas via the schema field - and later we could add a PR that would enable loading schemas via a file to the relevant components, (i.e. this one, parquet_encode & parquet_decode).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

schema_example.yml

Re: schema_file Support

I understand the desire for consistency, but there's a strong practical need for schema_file in production use cases.

Real-world schema complexity:

Production schemas (OCSF, security events, etc.) are typically 200-300+ lines with deeply nested structures. Here's an example of a real OCSF Network Activity schema we use in production: [link to schema_example.yml - 279 lines, 4+ levels of nested STRUCTs]

Embedding schemas this large inline in pipeline configs is impractical because:

  • Maintainability: 279 lines of schema embedded in config obscures the actual pipeline logic
  • Reusability: Multiple pipelines (dev/staging/prod environments) share the same schema definition
  • Version control: Teams need to track schema evolution separately from pipeline config changes
  • Validation: External schema files can be validated/tested independently

Why this component specifically needs it:

Unlike parquet_encode (which processes batches), this streaming output:

  • Needs the schema at initialization to create the Parquet writer pool
  • Schema must be stable across multiple files/partitions during the entire output lifecycle
  • Schema loading happens once per output initialization, not per message/batch

Proposed path forward:

I'd like to propose Option A: Keep schema_file in this PR, with a commitment to add it to parquet_encode and parquet_decode in a follow-up PR for consistency. This PR establishes the pattern that others can follow.

Alternatively, I can extract the schema loading logic into internal/impl/parquet/schema_loader.go right now, making it available to all parquet components immediately.

@jem-davies jem-davies May 7, 2026

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.

Sorry I wasn't really clear here.

When I said it is somewhat confusing it was around the implementation of the schema_file field. For instance, I was thinking that because you are required to include it as a processor_resource & parquet_encode when actually a Bento stream config could not contain them, that would be confusing for a reader of config to understand.

I have added comments regarding this here: #661 (review)

Then also the function loadSchemaFromFile() does more yaml marshalling / un-marshalling than necessary & duplication of the schema fields, which again added to poor code readablity.

My suggestion to take out this, was more to reduce the volume of code that is being added in the PR, and potentially add this in a separate feature later - making code review more manageable.

There is a real need for a feature like this, i.e. the ability to have a yaml file that contains some bento stream config that can be referenced by other stream config files.

It seems that we have a few ways to do this but they all fall short of being able to do exactly what we need here:

  • yaml anchors - it is possible to reduce config repetition via yaml anchors, however they cannot cross file boundries.
  • templates - You could use Bento Templates to avoid having a large schema obscure pipeline logic, but you would need to duplicate the schema for each template
  • resources - these are good but require a full & valid component configuration - so it doesn't allow supplying only part of it i.e. schema.

We should be looking to add this functionality in such that it could be used by all components rather than just aws_s3_parquet_stream and the other parquet_encode & parquet_decode I think.

Description("Forces path style URLs for S3 requests.").
Default(false).
Advanced(),
service.NewObjectListField("schema",

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.

Dedupe these from other parquet components?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Re: Schema Field Duplication

Good catch - there is duplication of schema field configuration (~50 lines) between this output and the parquet package.

I can refactor this to extract a shared parquetSchemaFieldConfig() function that both parquet_encode and this output can use. This would:

  • Eliminate duplication
  • Ensure schema field definitions stay consistent
  • Make future schema enhancements easier

Should I include this refactoring in this PR, or would you prefer it as a follow-up?

}

func (s *s3ParquetStreamOutput) Connect(ctx context.Context) error {
s.log.Infof("Streaming Parquet output configured for bucket: %s", s.conf.Bucket)

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.

The bento engine will already produce a log for this so remove this one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Re: Redundant log in Connect method

Good point - removed the redundant log line. The Bento engine already logs output connections, so this was unnecessary noise.

)

// S3API defines the S3 operations needed for multipart uploads
type S3API interface {

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.

Duplicate of s3StreamingAPI in output_s3_stream_writer.go

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Re: Duplicate of s3StreamingAPI in output_s3_stream_writer.go

Good catch! There is indeed duplication here. I have two identical interfaces defined:

  • Line 18 in output_s3_stream_writer.go: s3StreamingAPI (unexported)
  • Line 25 in output_aws_s3_parquet_stream_writer.go: S3API (exported)

Both define the same 4 S3 multipart upload methods.

Proposed Solution

I'll extract this to a shared exported interface that both components can use. This provides:

  • Eliminates duplication - single source of truth
  • Better testability - exported interface can be mocked in tests
  • Future reusability - any other S3 multipart upload components can use it

Implementation:

Create internal/impl/aws/s3_interfaces.go:

package aws

import (
    "context"
    "github.com/aws/aws-sdk-go-v2/service/s3"
)

// S3MultipartAPI defines the S3 operations required for multipart uploads
type S3MultipartAPI interface {
    CreateMultipartUpload(ctx context.Context, input *s3.CreateMultipartUploadInput, opts ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error)
    UploadPart(ctx context.Context, input *s3.UploadPartInput, opts ...func(*s3.Options)) (*s3.UploadPartOutput, error)
    CompleteMultipartUpload(ctx context.Context, input *s3.CompleteMultipartUploadInput, opts ...func(*s3.Options)) (*s3.CompleteMultipartUploadOutput, error)
    AbortMultipartUpload(ctx context.Context, input *s3.AbortMultipartUploadInput, opts ...func(*s3.Options)) (*s3.AbortMultipartUploadOutput, error)
}

Then update both files to use S3MultipartAPI and remove their local interface definitions.

Should I proceed with this refactoring?

@jem-davies

Copy link
Copy Markdown
Collaborator

Questions regarding this:

  • Do we need this after the inclusion of the aws_s3_stream output?

I can see that there is an identical interface included with s3API & s3StreamingAPI...

  • It seems very specific?

The PR description talks about issues relating to the s3 output, are they still relevant now we have the aws_s3_stream output? It is very specific: a particular file format to a particular cloud storage service... Perhaps it better if this was implemented as a plugin - which could be hosted on github for others to use if they wish?

  • Duplication from the parquet package?

Perhaps it would be better to refactor some of the parquet fields such they are deduplicated rather than copied into the aws package?

🤔

@jem-davies jem-davies removed their assignment Apr 24, 2026
@triddell

triddell commented May 1, 2026

Copy link
Copy Markdown
Contributor Author

Re: Do we need this after aws_s3_stream?

While aws_s3_stream provides generic multipart upload streaming, this component addresses Parquet-specific problems that can't be solved by combining aws_s3_stream + parquet_encode:

1. The Partition Routing Bug (#659)

The batch approach evaluates path expressions once per batch, causing data to land in wrong partitions:

# Current approach (aws_s3 + parquet_encode in batch processor)
path: 'data/account=${! meta("account") }/${! uuid_v4() }.parquet'

# With 100K events across 2 accounts:
# Expected: 2 files (one per account)
# Actual: 1 file with all 100K events in WRONG partition

This breaks Iceberg/Hive/Delta table partitioning and causes silent data corruption. aws_s3_stream doesn't solve this because it doesn't understand Parquet row groups or per-message partition routing.

2. Memory Issues Specific to Parquet

Test with 100K events (minimal schema):

  • aws_s3 + parquet_encode: 2.2 GB memory
  • This streaming approach: 270 MB memory
  • 88% reduction

For production OCSF schemas (like the 279-line example above), memory can exceed 10+ GB, causing OOM in containers.

The memory problem is Parquet-specific because:

  • Entire Parquet file (header + all row groups + footer) must be in memory before upload
  • aws_s3_stream can't help because it receives already-encoded Parquet bytes from the batch processor
  • Only row-group-level streaming (inside Parquet encoding) solves this

3. Parquet Internal Structure Management

This component handles Parquet-specific concerns that generic streaming can't address:

  • Row group size tuning (memory vs. file overhead tradeoff)
  • Column statistics and indexes for query performance
  • Parquet footer generation with proper metadata
  • Page index preservation for pruning
  • Schema validation specific to Parquet type system

Why not a plugin?

  • Parquet is the de facto standard for modern data lakes and analytics platforms:
    • Apache Iceberg, Apache Hudi, Delta Lake all use Parquet as their primary format
    • Databricks, Snowflake, BigQuery, Redshift, Athena all natively support/prefer Parquet
    • AWS S3 + Parquet is the foundation for most cloud-based data lake architectures
  • Format-specific components already exist in core (e.g., parquet_encode processor)
  • The memory/partition bugs are critical production issues affecting real workloads
  • Plugin model makes discovery harder for users facing these exact problems
  • This has been running in production 24/7 since March with no issues

This isn't just "S3 + file format" - it's solving fundamental architectural problems with how Parquet streaming works. The combination of aws_s3_stream + parquet_encode can't achieve the same result because the Parquet encoding happens before the streaming, so the entire file is still buffered in memory.

@triddell
triddell requested a review from jem-davies May 1, 2026 19:30
// loadSchemaFromFile loads a Parquet schema from an external YAML file.
// The file should contain a processor_resources section with a parquet_encode processor.
func loadSchemaFromFile(filePath string) (*service.ParsedConfig, error) {
// Read the file

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.

Redundant comment, I suppose it's somewhat subjective but why include a comment "Read the file" for then the subsequent line os.ReadFile()? It is self-explanatory.


// Parse into a ParsedConfig
// We use a dummy ConfigSpec that matches the schema structure
spec := service.NewConfigSpec().Field(

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.

These will have been duplicated 3 times now, from the parquet package & above in this output definition.

We could move these into a function:

func getSchemaFields() *service.ConfigField {
	return service.NewObjectListField("schema",
		service.NewStringField("name").Description("The name of the column."),
		service.NewStringEnumField("type", "BOOLEAN", "INT8", "INT16", "INT32", "INT64", "DECIMAL64", "DECIMAL32", "FLOAT", "DOUBLE", "BYTE_ARRAY", "UTF8", "MAP", "LIST", "STRUCT").
			Description("The type of the column, only applicable for leaf columns with no child fields. STRUCT represents nested objects with defined field schemas. MAP supports only string keys, but can support values of all types. Some logical types can be specified here such as UTF8.").Optional(),
		service.NewIntField("decimal_precision").Description("Precision to use for DECIMAL32/DECIMAL64 type").Default(0),
		service.NewIntField("decimal_scale").Description("Scale to use for DECIMAL32/DECIMAL64 type").Default(0),
		service.NewBoolField("repeated").Description("Whether the field is repeated.").Default(false),
		service.NewBoolField("optional").Description("Whether the field is optional.").Default(false),
		service.NewAnyListField("fields").Description("A list of child fields.").Optional().Example([]any{
			map[string]any{
				"name": "foo",
				"type": "INT64",
			},
			map[string]any{
				"name": "bar",
				"type": "BYTE_ARRAY",
			},
		}),
	).Description("Parquet schema. Mutually exclusive with schema_file.").Optional()
}


// loadSchemaFromFile loads a Parquet schema from an external YAML file.
// The file should contain a processor_resources section with a parquet_encode processor.
func loadSchemaFromFile(filePath string) (*service.ParsedConfig, 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.

This function could be made much simpler:

func loadSchemaFromFile(filePath string) (*service.ParsedConfig, error) {
	data, err := os.ReadFile(filePath)
	if err != nil {
		return nil, err
	}

	spec := service.NewConfigSpec().Fields(getSchemaFields())

	return spec.ParseYAML(string(data), nil)
}

Then the contents of the file could be:

  schema:
    - name: id
      type: INT64
    - name: message
      type: UTF8
    - name: value
      type: DOUBLE
      optional: true

There is no real need to have processor_resources & parquet_encode in there?

triddell and others added 4 commits May 27, 2026 10:28
… API

Updates the S3 Parquet streaming writer to use thrift.New() wrapper
for nullable fields, required by parquet-go v0.29.0. Changes:
- ColumnIndexSizeLimit now takes a function parameter
- Schema element nullable fields (NumChildren, RepetitionType, Type, LogicalType)
  now use thrift.New() instead of raw pointers
…r writer

Each partition_by key gets its own StreamingParquetWriter, and every one of
them pre-allocated a rowBuffer sized to the full row_group_size up front —
even a writer that only ever buffers a handful of rows. That's fine with a
low writer count, but a partition_by with a high-cardinality key (e.g.
account+webacl) can create dozens of concurrent writers per invocation, and
in a short-lived Lambda that memory is paid entirely upfront, all at once,
with no time for anything to settle or get reclaimed before the process
exits.

Cap the initial capacity at initialRowBufferCap (128) regardless of
row_group_size; Go's normal slice growth handles writers that actually need
more. Close() already flushes any partial buffer, so this only changes an
allocation-size detail, not behavior — confirmed via the existing writer
test suite (all passing except one pre-existing, unrelated thrift
type-equality failure).
…l-file offsets

Close() rebased ColumnChunk.MetaData.DataPageOffset/DictionaryPageOffset from temp-buffer-relative to final-file-absolute positions, but the OffsetIndex's PageLocation.Offset values were copied verbatim, so every row group after the first described page positions as if it started at byte 0. Strict readers (arrow-rs's SerializedPageReader) panic with an offset underflow on read.
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.

2 participants