This is an automated email from the ASF dual-hosted git repository.
damccorm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new 3a0985a07da [Go SDK] Add GroupIntoBatches transform (#19868) (#38220)
3a0985a07da is described below
commit 3a0985a07daa426198ece0811146efc60292f4b5
Author: Florian TREHAUT <[email protected]>
AuthorDate: Tue Aug 4 21:04:11 2026 +0700
[Go SDK] Add GroupIntoBatches transform (#19868) (#38220)
* [Go SDK] Add Coder.IsDeterministic and ShardedKey standard coder
This introduces the supporting infrastructure required by the upcoming
GroupIntoBatches transform (#19868):
- (*coder.Coder).IsDeterministic() reports whether a coder produces
byte-stable output. Primitives (bytes, bool, varint, double, string)
are deterministic; composite coders (KV, CoGBK, Nullable, Iterable,
LP, ShardedKey) are deterministic iff every component is. Custom
user-registered coders are non-deterministic by default and opt in
via the new RegisterDeterministicCoder registration helper.
- beam.Coder.IsDeterministic() forwards to the inner coder's method so
transform authors can gate on determinism without reaching into
internals.
- beam.PCollection.WindowingStrategy() exposes the input's windowing
strategy publicly so transforms honoring allowed lateness (e.g.
GroupIntoBatches) can read it without package-private access.
- typex.ShardedKey[K] is a concrete Go generic struct representing a
sharded user key. The accompanying Kind (coder.ShardedKey) and
beam:coder:sharded_key:v1 URN wiring (graphx marshal/unmarshal, exec
encode/decode) produce the exact wire format documented in
standard_coders.yaml:501-521 — verified byte-identical against the
four published fixtures.
Cross-SDK byte compatibility is required for Dataflow/Flink
interoperability; a single divergent byte would silently corrupt
pipelines. Roundtrip tests cover all four yaml fixtures.
* [Go SDK] Add GroupIntoBatches transform and ShardedKey composite (#19868)
Builds on top of the Coder.IsDeterministic / DeterministicCoder
foundation and introduces the full user-facing surface for batching
PCollection<KV<K,V>> elements by key.
* typex.ShardedKey is added as a new Composite marker type (alongside
KV, CoGBK, WindowedValue, Timers). Its runtime representation is a
two-part FullValue (Elm=key, Elm2=[]byte shardID).
* coder.NewSK builds the associated coder; graphx/coder and exec/coder
wire the beam:coder:sharded_key:v1 URN in both directions. The wire
format is byte-identical to the Java util.ShardedKey.Coder and the
Python sharded_key coder — verified against all four
standard_coders.yaml fixtures (lines 501-521).
* beam.PCollection.WindowingStrategy and beam.Coder.IsDeterministic are
exposed publicly, matching the access pattern already used inside the
beam package (pardo.go, gbk.go).
* transforms/batch introduces GroupIntoBatches, a stateful DoFn that
buffers per-key values in a state.Bag and flushes when BatchSize /
BatchSizeBytes / MaxBufferingDuration / end-of-window + allowed
lateness triggers fire. The transform honors the input's allowed
lateness (Java parity; Python currently ignores it) and panics at
pipeline-build time on invalid params, non-KV inputs, or
non-deterministic key coders.
* CHANGES.md is updated under [2.74.0] - Unreleased.
Scope note: this release ships GroupIntoBatches with string keys and
string values. The underlying ShardedKey infrastructure is fully in
place (type, coder, URN, tests); GroupIntoBatchesWithShardedKey and
arbitrary K/V generics are follow-up work once the Go SDK binds
universal types through state.Bag element coders.
End-to-end Prism integration testing of the stateful DoFn path remains
a follow-up — the pipeline hangs on job completion in the bounded
case, pending investigation of Prism's watermark signalling for
event-time timers set on the GlobalWindow maxTimestamp. All unit tests
(coder roundtrip, Params validation, primitive sizer) pass.
* [Go SDK] Support generic K,V and WithShardedKey in GroupIntoBatches
(#19868)
Extends GroupIntoBatches to arbitrary key/value types and adds
GroupIntoBatchesWithShardedKey, completing the Apache Beam
GroupIntoBatches feature parity with Java/Python (#19868).
Generic K, V support:
- Replaces the string-only DoFn with a typex.T / typex.V universal
pair, resolved by beam.ParDo's type-binding engine at graph
construction. Values flow through a state.Bag[[]byte] encoded via
a cached beam.ElementEncoder/Decoder lazily initialised from
beam.EncodedType{T: valueType} — a single reflect.Type captured at
graph time and serialised across the SDK-worker boundary.
- Separates into two concrete DoFn shapes: the plain
groupIntoBatchesFn (event-time timer only) and
groupIntoBatchesBufferedFn (event-time + processing-time). A single
DoFn with an unused processing-time timer family stalls Prism
waiting for the family's completion signal — splitting the shape
by params.MaxBufferingDuration avoids the stall.
- ProcessingTime timer is only wired when the user requests
buffering, eliminating the Prism stall we hit on the initial
implementation.
WithShardedKey:
- Adds GroupIntoBatchesWithShardedKey(s, params, col) that
round-trips KV<K, V> → KV<[]byte-shardKey, V> → batched →
KV<K, []V>. ShardIDs are 24-byte worker-UUID + atomic-counter
tuples matching Java/Python layouts; downstream workers see
independent state per shard, so a single hot logical key's
processing spreads across workers on distributed runners.
- Output shape: PCollection<KV<K, []V>>, identical to
GroupIntoBatches. The Go SDK's type-binding engine does not
accept custom generic structs as DoFn output types, so we do not
surface ShardedKey<K> to the user. Cross-SDK bytes-compat
ShardedKey coder infrastructure is still wired at the core/typex
+ core/graph/coder level for future bidirectional pipelines.
Testing:
- End-to-end Prism tests for GroupIntoBatches across count, byte and
per-key-isolation triggers, including a non-string value type
(int).
- GroupIntoBatchesWithShardedKey pipeline construction test (Prism
panics on the 3-stage round-trip pipeline with "assignment to nil
map" in aggregateStageKind.buildEventTimeBundle — a runner-side
regression we verify does NOT reproduce on non-Prism runners).
Follow-up items documented in the package godoc.
* [Go SDK] Fix ShardedKey coder serialization for generic closures (#19868)
Go generic functions produce closures with identical compiler-assigned
symbol names across type instantiations — all RegisterShardedKeyType[K]
instantiations generated closures named
"RegisterShardedKeyType[...].func1", causing cross-worker
deserialization to resolve the wrong enc/dec function (last-registered
wins).
Root cause: reflectx.FunctionName calls runtime.FuncForPC which returns
the compiler name; Go does not qualify closure names by type parameter.
Fix: three surgical additions to core SDK infrastructure:
1. reflectx.MakeFuncWithName wraps a Func with a caller-supplied Name()
so the serializer (encodeUserFn → u.Fn.Name()) emits a
type-qualified name like "batch.encShardedKey[string]".
2. runtime.RegisterFunctionWithName registers a function under a custom
name in the resolution cache so the deserializer (decodeUserFn →
ResolveFunction) finds it.
3. coder.RegisterDeterministicCoderWithFuncs accepts pre-wrapped
funcx.Fn values carrying the qualified names, bypassing the
automatic name derivation in NewCustomCoder.
RegisterShardedKeyType[K] now uses these three mechanisms to produce
stable, collision-free names per type parameter.
Additionally completes GroupIntoBatchesWithShardedKey as a fully
generic function that wraps each key with ShardedKey{Key, ShardID}
and routes through GroupIntoBatches. End-to-end Prism test passes.
* Update batch.go
Fix staticcheck finding
* Update doc.go
* Update registry.go
* Update batch.go package comment
---------
Co-authored-by: Jack McCluskey
<[email protected]>
---
CHANGES.md | 10 +
sdks/go/pkg/beam/coder.go | 17 +
sdks/go/pkg/beam/core/graph/coder/coder.go | 116 ++++
sdks/go/pkg/beam/core/graph/coder/coder_test.go | 66 ++
sdks/go/pkg/beam/core/graph/coder/registry.go | 60 +-
.../pkg/beam/core/graph/coder/sharded_key_test.go | 81 +++
sdks/go/pkg/beam/core/runtime/exec/coder.go | 63 ++
sdks/go/pkg/beam/core/runtime/exec/coder_test.go | 84 +++
sdks/go/pkg/beam/core/runtime/graphx/coder.go | 21 +
sdks/go/pkg/beam/core/runtime/symbols.go | 20 +
sdks/go/pkg/beam/core/typex/class.go | 4 +-
sdks/go/pkg/beam/core/typex/fulltype.go | 23 +
sdks/go/pkg/beam/core/typex/special.go | 18 +-
sdks/go/pkg/beam/core/util/reflectx/call.go | 31 +
sdks/go/pkg/beam/pcollection.go | 16 +
sdks/go/pkg/beam/transforms/batch/batch.go | 677 +++++++++++++++++++++
.../pkg/beam/transforms/batch/batch_prism_test.go | 222 +++++++
sdks/go/pkg/beam/transforms/batch/batch_test.go | 47 ++
sdks/go/pkg/beam/transforms/batch/doc.go | 58 ++
sdks/go/pkg/beam/transforms/batch/size.go | 88 +++
sdks/go/pkg/beam/transforms/batch/size_test.go | 91 +++
21 files changed, 1807 insertions(+), 6 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index bda50ac6cd1..9cbbe1c207f 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -70,6 +70,16 @@
## New Features / Improvements
+* X feature added (Java/Python)
([#X](https://github.com/apache/beam/issues/X)).
+* Added `GroupIntoBatches` transform and the standard
+ `beam:coder:sharded_key:v1` coder to the Go SDK, along with
+ `beam.Coder.IsDeterministic`, `beam.PCollection.WindowingStrategy`,
+ and `coder.RegisterDeterministicCoder` for opt-in deterministic
+ custom coders (Go) ([#19868](https://github.com/apache/beam/issues/19868)).
+* TriggerStateMachineRunner changes from BitSetCoder to SentinelBitSetCoder to
+ encode finished bitset. SentinelBitSetCoder and BitSetCoder are state
+ compatible. Both coders can decode encoded bytes from the other coder
+ ([#38139](https://github.com/apache/beam/issues/38139)).
* (Python) Removed the `envoy-data-plane` (and transitive `betterproto`)
dependency; `EnvoyRateLimiter` now uses a small vendored protobuf definition
instead, resolving dependency conflicts for downstream projects
([#37854](https://github.com/apache/beam/issues/37854)).
* (Java) Supported acknowledge mode for JmsIO
([#39253](https://github.com/apache/beam/issues/39253)).
* (Python) Staged files directory is now automatically added to `sys.path` on
the Python SDK worker at startup. This makes Python files provided via the
'--files_to_stage' pipeline option importable in the pipeline code and makes it
easier to initialize Python SDK harness at startup via the `--beam_plugins`
pipeline option. For more information, see the [Staging Individual
Files](https://beam.apache.org/documentation/sdks/python-pipeline-dependencies/#staging-files)
section of the depend [...]
diff --git a/sdks/go/pkg/beam/coder.go b/sdks/go/pkg/beam/coder.go
index b03b739ed7b..c38a8e37ecc 100644
--- a/sdks/go/pkg/beam/coder.go
+++ b/sdks/go/pkg/beam/coder.go
@@ -89,6 +89,21 @@ func (c Coder) String() string {
return c.coder.String()
}
+// IsDeterministic reports whether this coder produces a byte-deterministic
+// encoding: encoding two equal values always yields identical byte
+// sequences.
+//
+// Determinism is required for any coder used as a state key in a stateful
+// DoFn or as the key component of a KV consumed by GroupByKey /
+// GroupIntoBatches. A non-deterministic key coder would silently corrupt
+// state keying, splintering state across apparently-distinct keys.
+func (c Coder) IsDeterministic() bool {
+ if c.coder == nil {
+ return false
+ }
+ return c.coder.IsDeterministic()
+}
+
// NewElementEncoder returns a new encoding function for the given type.
func NewElementEncoder(t reflect.Type) ElementEncoder {
c, err := inferCoder(typex.New(t))
@@ -249,6 +264,8 @@ func inferCoder(t FullType) (*coder.Coder, error) {
// are non-windowed? We either need to know the
windowing strategy or
// we should remove this case.
return &coder.Coder{Kind: coder.WindowedValue, T: t,
Components: c, Window: coder.NewGlobalWindow()}, nil
+ case typex.ShardedKeyType:
+ return &coder.Coder{Kind: coder.ShardedKey, T: t,
Components: c}, nil
default:
panic(fmt.Sprintf("Unexpected composite type: %v", t))
diff --git a/sdks/go/pkg/beam/core/graph/coder/coder.go
b/sdks/go/pkg/beam/core/graph/coder/coder.go
index 28e235860bd..f5f7aa2d757 100644
--- a/sdks/go/pkg/beam/core/graph/coder/coder.go
+++ b/sdks/go/pkg/beam/core/graph/coder/coder.go
@@ -84,6 +84,18 @@ func (c *CustomCoder) String() string {
return fmt.Sprintf("%v[%v;%v]", c.Type, c.Name, c.ID)
}
+// IsDeterministic reports whether this CustomCoder produces a deterministic
+// encoding. A CustomCoder is deterministic iff the user opted in by
+// registering the coder via RegisterDeterministicCoder. Default is false
+// (conservative): a non-deterministic key coder would silently corrupt state
+// keying in stateful DoFns.
+func (c *CustomCoder) IsDeterministic() bool {
+ if c == nil {
+ return false
+ }
+ return isCustomCoderDeterministic(c.Type)
+}
+
// Type signatures of encode/decode for verification.
var (
encodeSig = &funcx.Signature{
@@ -156,6 +168,20 @@ func NewCustomCoder(id string, t reflect.Type, encode,
decode any) (*CustomCoder
return c, nil
}
+// NewCustomCoderWithFuncs creates a CustomCoder from pre-wrapped
+// reflectx.Func values. This allows the caller to control the Name()
+// returned by each function — critical for closures inside Go generic
+// functions where the compiler assigns identical names to different
+// type instantiations.
+func NewCustomCoderWithFuncs(id string, t reflect.Type, enc, dec *funcx.Fn)
*CustomCoder {
+ return &CustomCoder{
+ Name: id,
+ Type: t,
+ Enc: enc,
+ Dec: dec,
+ }
+}
+
// Kind represents the type of coder used.
type Kind string
@@ -195,6 +221,17 @@ const (
//
// TODO(https://github.com/apache/beam/issues/18032): once this JIRA is
done, this coder should become the new thing.
CoGBK Kind = "CoGBK"
+
+ // ShardedKey encodes a user key wrapped with an opaque shard
identifier,
+ // used by GroupIntoBatchesWithShardedKey to distribute a single logical
+ // key's processing across workers. Wire format
+ // (beam:coder:sharded_key:v1):
+ //
+ // ByteArrayCoder.encode(shardId) ++ keyCoder.encode(key)
+ //
+ // matching sdks/java/core ShardedKey and the Python sharded_key
+ // encoding for cross-SDK interoperability.
+ ShardedKey Kind = "SK"
)
// Coder is a description of how to encode and decode values of a given type.
@@ -273,6 +310,62 @@ func (c *Coder) String() string {
return ret
}
+// IsDeterministic reports whether this Coder produces a deterministic
+// byte encoding — i.e. encoding two equal values always yields identical
+// byte sequences.
+//
+// Determinism is a prerequisite for any Coder used as a state key in a
+// stateful DoFn, as the key component of a KV consumed by GroupByKey, or as
+// a grouping key in a CoGroupByKey. A non-deterministic key coder causes
+// state-keyed operations to silently corrupt: two encodings of the same
+// logical key map to distinct physical keys, splintering state across
+// apparently-distinct keys.
+//
+// Built-in coders for primitive types (bytes, bool, varint, double,
+// string) are deterministic. Composite coders (KV, Iterable, Nullable)
+// are deterministic iff every component is. The Map coder is
+// non-deterministic because Go map iteration order is unspecified.
+// Custom user-registered coders are non-deterministic by default; users
+// opt in by registering with RegisterDeterministicCoder.
+func (c *Coder) IsDeterministic() bool {
+ if c == nil {
+ return false
+ }
+ switch c.Kind {
+ case Bytes, Bool, VarInt, Double, String:
+ return true
+ case Custom:
+ return c.Custom.IsDeterministic()
+ case KV, CoGBK, Nullable, Iterable, LP, ShardedKey:
+ for _, comp := range c.Components {
+ if !comp.IsDeterministic() {
+ return false
+ }
+ }
+ return true
+ case WindowedValue, ParamWindowedValue, Window, Timer, PaneInfo, IW:
+ // These coders are structural: they wrap runner/window
bookkeeping that is
+ // not used as a state key. Recurse into the data component
when present so
+ // that a non-deterministic inner coder is still reported.
+ for _, comp := range c.Components {
+ if !comp.IsDeterministic() {
+ return false
+ }
+ }
+ return true
+ case Row:
+ // Schema (row) coding encodes fields in a fixed field-id order
and
+ // produces a stable byte layout; however, row coders may
contain fields
+ // backed by custom coders we cannot introspect here.
Conservative
+ // default: return false and allow users to opt in via
schema-level
+ // determinism guarantees once they're exposed. Structs wanting
+ // deterministic behavior can register a deterministic custom
coder
+ // instead.
+ return false
+ }
+ return false
+}
+
// NewBytes returns a new []byte coder using the built-in scheme. It
// is always nested, for now.
func NewBytes() *Coder {
@@ -428,6 +521,29 @@ func NewCoGBK(components []*Coder) *Coder {
}
}
+// NewSK returns a coder for ShardedKey-typed values. The component
+// keyCoder encodes the user key; the ShardID is encoded as a
+// length-prefixed byte string preceding it (beam:coder:sharded_key:v1).
+//
+// The resulting FullType root is typex.ShardedKeyType with the key's
+// FullType as the single component, following the same Composite
+// pattern as KV.
+func NewSK(keyCoder *Coder) *Coder {
+ if keyCoder == nil {
+ panic("NewSK: keyCoder must not be nil")
+ }
+ return &Coder{
+ Kind: ShardedKey,
+ T: typex.New(typex.ShardedKeyType, keyCoder.T),
+ Components: []*Coder{keyCoder},
+ }
+}
+
+// IsSK returns true iff the coder is for a ShardedKey.
+func IsSK(c *Coder) bool {
+ return c != nil && c.Kind == ShardedKey
+}
+
// SkipW returns the data coder used by a WindowedValue, or returns the coder.
This
// allows code to seamlessly traverse WindowedValues without additional
conditional
// code.
diff --git a/sdks/go/pkg/beam/core/graph/coder/coder_test.go
b/sdks/go/pkg/beam/core/graph/coder/coder_test.go
index 040a0402c85..b60cbd72848 100644
--- a/sdks/go/pkg/beam/core/graph/coder/coder_test.go
+++ b/sdks/go/pkg/beam/core/graph/coder/coder_test.go
@@ -578,6 +578,72 @@ func TestNewNullable(t *testing.T) {
}
}
+func TestCoder_IsDeterministic(t *testing.T) {
+ ints := NewVarInt()
+ bytes := NewBytes()
+ bools := NewBool()
+ doubles := NewDouble()
+ strs := NewString()
+
+ enc := func(string) []byte { return nil }
+ dec := func([]byte) string { return "" }
+
+ nonDetCustom, err := NewCustomCoder("nonDet", reflectx.String, enc, dec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ nonDetC := &Coder{Kind: Custom, Custom: nonDetCustom, T:
typex.New(reflectx.String)}
+
+ // Register a deterministic custom coder for a dedicated type.
+ type detType struct{}
+ detT := reflect.TypeOf((*detType)(nil)).Elem()
+ detEnc := func(detType) []byte { return nil }
+ detDec := func([]byte) detType { return detType{} }
+ RegisterDeterministicCoder(detT, detEnc, detDec)
+ detCustom, err := NewCustomCoder("det", detT, detEnc, detDec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ detC := &Coder{Kind: Custom, Custom: detCustom, T: typex.New(detT)}
+
+ tests := []struct {
+ name string
+ c *Coder
+ want bool
+ }{
+ {"nil", nil, false},
+ {"bytes", bytes, true},
+ {"bool", bools, true},
+ {"varint", ints, true},
+ {"double", doubles, true},
+ {"string", strs, true},
+ {"nonDetCustom", nonDetC, false},
+ {"detCustom", detC, true},
+ {"KV_bytes_varint", NewKV([]*Coder{bytes, ints}), true},
+ {"KV_bytes_nonDet", NewKV([]*Coder{bytes, nonDetC}), false},
+ {"KV_nonDet_bytes", NewKV([]*Coder{nonDetC, bytes}), false},
+ {"iterable_varint", NewI(ints), true},
+ {"iterable_nonDet", NewI(nonDetC), false},
+ {"nullable_string", NewN(strs), true},
+ {"nullable_nonDet", NewN(nonDetC), false},
+ {"CoGBK_bytes_varint", NewCoGBK([]*Coder{bytes, ints}), true},
+ {"CoGBK_nonDet_varint", NewCoGBK([]*Coder{nonDetC, ints}),
false},
+ {"WindowedValue_varint", NewW(ints, NewGlobalWindow()), true},
+ {"WindowedValue_nonDet", NewW(nonDetC, NewGlobalWindow()),
false},
+ {"Row",
NewR(typex.New(reflect.TypeOf((*namedTypeForTest)(nil)))), false},
+ }
+
+ for _, test := range tests {
+ test := test
+ t.Run(test.name, func(t *testing.T) {
+ got := test.c.IsDeterministic()
+ if got != test.want {
+ t.Errorf("IsDeterministic(%v) = %v, want %v",
test.c, got, test.want)
+ }
+ })
+ }
+}
+
func TestNewCoGBK(t *testing.T) {
bytes := NewBytes()
ints := NewVarInt()
diff --git a/sdks/go/pkg/beam/core/graph/coder/registry.go
b/sdks/go/pkg/beam/core/graph/coder/registry.go
index f6677071b86..05d211898df 100644
--- a/sdks/go/pkg/beam/core/graph/coder/registry.go
+++ b/sdks/go/pkg/beam/core/graph/coder/registry.go
@@ -18,12 +18,14 @@ package coder
import (
"reflect"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/funcx"
"github.com/apache/beam/sdks/v2/go/pkg/beam/internal/errors"
)
var (
- coderRegistry = make(map[reflect.Type]func(reflect.Type)
*CustomCoder)
- interfaceOrdering []reflect.Type
+ coderRegistry = make(map[reflect.Type]func(reflect.Type)
*CustomCoder)
+ interfaceOrdering []reflect.Type
+ deterministicRegistry = make(map[reflect.Type]bool)
)
// RegisterCoder registers a user defined coder for a given type, and will
@@ -76,6 +78,60 @@ func RegisterCoder(t reflect.Type, enc, dec any) {
}
}
+// RegisterDeterministicCoderWithFuncs is like RegisterDeterministicCoder
+// but accepts pre-wrapped reflectx.Func values (typically built via
+// reflectx.MakeFuncWithName) so the caller controls the function name
+// used during cross-worker serialization. This is required for
+// closures inside Go generic functions where different type
+// instantiations produce closures with the same compiler name.
+func RegisterDeterministicCoderWithFuncs(t reflect.Type, encFn, decFn
*funcx.Fn) {
+ name := t.String()
+ coderRegistry[t] = func(rt reflect.Type) *CustomCoder {
+ return NewCustomCoderWithFuncs(name, rt, encFn, decFn)
+ }
+ deterministicRegistry[t] = true
+}
+
+// RegisterDeterministicCoder is the deterministic-affirming counterpart to
+// RegisterCoder: it registers the (enc, dec) pair for t AND records that the
+// resulting CustomCoder produces a deterministic encoding. The caller asserts
+// by calling this function that enc produces byte-identical output for any
+// two equal input values of type t.
+//
+// Deterministic coders are required for any type used as a state key in a
+// stateful DoFn, as the key of a KV consumed by GroupByKey / GroupIntoBatches,
+// or as a grouping key for CoGroupByKey.
+//
+// Prefer this over RegisterCoder whenever the encoded type may be used as a
+// key. For types that cannot guarantee determinism (e.g. encodings backed by
+// map[K]V iteration order), use the plain RegisterCoder.
+func RegisterDeterministicCoder(t reflect.Type, enc, dec any) {
+ RegisterCoder(t, enc, dec)
+ deterministicRegistry[t] = true
+}
+
+// isCustomCoderDeterministic returns true iff t has been registered via
+// RegisterDeterministicCoder.
+func isCustomCoderDeterministic(t reflect.Type) bool {
+ if t == nil {
+ return false
+ }
+ if ok, present := deterministicRegistry[t]; present {
+ return ok
+ }
+ // Also match against interface registrations: if the type implements a
+ // registered-deterministic interface, honor that.
+ for rt, det := range deterministicRegistry {
+ if !det {
+ continue
+ }
+ if rt.Kind() == reflect.Interface && t.Implements(rt) {
+ return true
+ }
+ }
+ return false
+}
+
// LookupCustomCoder returns the custom coder for the type if any,
// first checking for a specific matching type, and then iterating
// through registered interface coders in reverse registration order.
diff --git a/sdks/go/pkg/beam/core/graph/coder/sharded_key_test.go
b/sdks/go/pkg/beam/core/graph/coder/sharded_key_test.go
new file mode 100644
index 00000000000..fc9b93b0070
--- /dev/null
+++ b/sdks/go/pkg/beam/core/graph/coder/sharded_key_test.go
@@ -0,0 +1,81 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package coder
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/typex"
+)
+
+func TestNewSK(t *testing.T) {
+ t.Run("nilKeyCoder_panics", func(t *testing.T) {
+ defer func() {
+ if p := recover(); p == nil {
+ t.Fatal("expected panic on nil keyCoder, got
none")
+ }
+ }()
+ NewSK(nil)
+ })
+
+ t.Run("valid_string_key", func(t *testing.T) {
+ sk := NewSK(NewString())
+ if sk.Kind != ShardedKey {
+ t.Fatalf("Kind = %v, want %v", sk.Kind, ShardedKey)
+ }
+ if !IsSK(sk) {
+ t.Fatalf("IsSK(%v) = false, want true", sk)
+ }
+ if len(sk.Components) != 1 {
+ t.Fatalf("Components = %d, want 1", len(sk.Components))
+ }
+ if sk.Components[0].Kind != String {
+ t.Fatalf("Components[0].Kind = %v, want %v",
sk.Components[0].Kind, String)
+ }
+ if sk.T.Type() != typex.ShardedKeyType {
+ t.Fatalf("T.Type() = %v, want %v", sk.T.Type(),
typex.ShardedKeyType)
+ }
+ })
+
+ t.Run("nested_composite_panics", func(t *testing.T) {
+ defer func() {
+ if p := recover(); p == nil {
+ t.Fatal("expected panic on nested composite
key, got none")
+ }
+ }()
+ // KV components inside a ShardedKey key are disallowed by
fulltype.New.
+ NewSK(NewKV([]*Coder{NewString(), NewBytes()}))
+ })
+}
+
+func TestSK_IsDeterministic(t *testing.T) {
+ detSK := NewSK(NewString())
+ if !detSK.IsDeterministic() {
+ t.Errorf("ShardedKey<string>.IsDeterministic() = false, want
true")
+ }
+
+ nonDet, err := NewCustomCoder("nonDet", reflect.TypeOf(""),
+ func(string) []byte { return nil }, func([]byte) string {
return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ nonDetC := &Coder{Kind: Custom, Custom: nonDet, T:
typex.New(reflect.TypeOf(""))}
+ nonDetSK := NewSK(nonDetC)
+ if nonDetSK.IsDeterministic() {
+ t.Errorf("ShardedKey<nonDet>.IsDeterministic() = true, want
false")
+ }
+}
diff --git a/sdks/go/pkg/beam/core/runtime/exec/coder.go
b/sdks/go/pkg/beam/core/runtime/exec/coder.go
index 2c21ebea56b..b6894335538 100644
--- a/sdks/go/pkg/beam/core/runtime/exec/coder.go
+++ b/sdks/go/pkg/beam/core/runtime/exec/coder.go
@@ -166,6 +166,11 @@ func MakeElementEncoder(c *coder.Coder) ElementEncoder {
be: boolEncoder{},
}
+ case coder.ShardedKey:
+ return &shardedKeyEncoder{
+ key: MakeElementEncoder(c.Components[0]),
+ }
+
default:
panic(fmt.Sprintf("Unexpected coder: %v", c))
}
@@ -288,6 +293,11 @@ func MakeElementDecoder(c *coder.Coder) ElementDecoder {
bd: boolDecoder{},
}
+ case coder.ShardedKey:
+ return &shardedKeyDecoder{
+ key: MakeElementDecoder(c.Components[0]),
+ }
+
default:
panic(fmt.Sprintf("Unexpected coder: %v", c))
}
@@ -1356,3 +1366,56 @@ func decodeTimer(dec ElementDecoder, win WindowDecoder,
r io.Reader) (TimerRecv,
return tm, nil
}
+
+// shardedKeyEncoder encodes ShardedKey-typed values in the standard
+// beam:coder:sharded_key:v1 wire format:
+//
+// ByteArrayCoder.encode(ShardID) ++ keyCoder.encode(Key)
+//
+// Runtime values are carried by a FullValue whose Elm holds the user key
+// and whose Elm2 holds the []byte shard identifier — the same two-part
+// convention used by the KV coder. This matches the Java
+// util.ShardedKey.Coder and Python sharded_key encodings exactly; any
+// divergence of a single byte would silently corrupt cross-SDK pipelines.
+type shardedKeyEncoder struct {
+ key ElementEncoder
+}
+
+func (e *shardedKeyEncoder) Encode(val *FullValue, w io.Writer) error {
+ shardID, ok := val.Elm2.([]byte)
+ if !ok {
+ return errors.Errorf(
+ "shardedKeyEncoder: Elm2 must be []byte shardID (got
%T)", val.Elm2)
+ }
+ if err := coder.EncodeBytes(shardID, w); err != nil {
+ return errors.WithContext(err, "shardedKeyEncoder: shardID")
+ }
+ return e.key.Encode(&FullValue{Elm: val.Elm}, w)
+}
+
+// shardedKeyDecoder is the inverse of shardedKeyEncoder. Decoded values
+// are placed in FullValue{Elm: key, Elm2: shardID}.
+type shardedKeyDecoder struct {
+ key ElementDecoder
+}
+
+func (d *shardedKeyDecoder) DecodeTo(r io.Reader, fv *FullValue) error {
+ shardID, err := coder.DecodeBytes(r)
+ if err != nil {
+ return errors.WithContext(err, "shardedKeyDecoder: shardID")
+ }
+ keyFV, err := d.key.Decode(r)
+ if err != nil {
+ return errors.WithContext(err, "shardedKeyDecoder: key")
+ }
+ *fv = FullValue{Elm: keyFV.Elm, Elm2: shardID}
+ return nil
+}
+
+func (d *shardedKeyDecoder) Decode(r io.Reader) (*FullValue, error) {
+ fv := &FullValue{}
+ if err := d.DecodeTo(r, fv); err != nil {
+ return nil, err
+ }
+ return fv, nil
+}
diff --git a/sdks/go/pkg/beam/core/runtime/exec/coder_test.go
b/sdks/go/pkg/beam/core/runtime/exec/coder_test.go
index 75d18e533cf..155fd72776e 100644
--- a/sdks/go/pkg/beam/core/runtime/exec/coder_test.go
+++ b/sdks/go/pkg/beam/core/runtime/exec/coder_test.go
@@ -158,6 +158,90 @@ func compareFV(t *testing.T, got *FullValue, want
*FullValue) {
}
}
+// TestShardedKeyCoder_WireFormat verifies the exact bytes produced by the
+// ShardedKey coder against the standard_coders.yaml fixtures (lines
+// 501-521, urn "beam:coder:sharded_key:v1" with a string_utf8 key
+// component). A single divergent byte would silently corrupt cross-SDK
+// pipelines on Dataflow / Flink.
+func TestShardedKeyCoder_WireFormat(t *testing.T) {
+ c := coder.NewSK(coder.NewString())
+ enc := MakeElementEncoder(c)
+ dec := MakeElementDecoder(c)
+
+ type fixture struct {
+ name string
+ key string
+ shardID []byte
+ wire []byte
+ }
+ fixtures := []fixture{
+ {
+ name: "empty_empty",
+ key: "",
+ shardID: []byte{},
+ wire: []byte{0x00, 0x00},
+ },
+ {
+ name: "shardId_emptyKey",
+ key: "",
+ shardID: []byte("shard_id"),
+ wire: append(
+ append([]byte{0x08}, []byte("shard_id")...),
+ 0x00,
+ ),
+ },
+ {
+ name: "shardId_key",
+ key: "key",
+ shardID: []byte("shard_id"),
+ wire: append(
+ append([]byte{0x08}, []byte("shard_id")...),
+ append([]byte{0x03}, []byte("key")...)...,
+ ),
+ },
+ {
+ name: "emptyShardId_key",
+ key: "key",
+ shardID: []byte{},
+ wire: append([]byte{0x00, 0x03}, []byte("key")...),
+ },
+ }
+
+ for _, f := range fixtures {
+ f := f
+ t.Run(f.name, func(t *testing.T) {
+ var buf bytes.Buffer
+ // ShardedKey values are carried as FullValue{Elm: key,
Elm2: shardID}.
+ if err := enc.Encode(&FullValue{Elm: f.key, Elm2:
f.shardID}, &buf); err != nil {
+ t.Fatalf("Encode: %v", err)
+ }
+ if got := buf.Bytes(); !bytes.Equal(got, f.wire) {
+ t.Fatalf("Encode: got bytes %#v, want %#v",
got, f.wire)
+ }
+
+ fv, err := dec.Decode(bytes.NewReader(f.wire))
+ if err != nil {
+ t.Fatalf("Decode: %v", err)
+ }
+ gotKey, ok := fv.Elm.(string)
+ if !ok {
+ t.Fatalf("Decode Elm: got %T, want string",
fv.Elm)
+ }
+ if gotKey != f.key {
+ t.Errorf("Decode Elm: got %q, want %q", gotKey,
f.key)
+ }
+ gotShard, ok := fv.Elm2.([]byte)
+ if !ok {
+ t.Fatalf("Decode Elm2: got %T, want []byte",
fv.Elm2)
+ }
+ // Both sides "empty" — accept nil or zero-length slice
equivalence.
+ if len(gotShard) != len(f.shardID) || (len(gotShard) >
0 && !bytes.Equal(gotShard, f.shardID)) {
+ t.Errorf("Decode Elm2: got %#v, want %#v",
gotShard, f.shardID)
+ }
+ })
+ }
+}
+
func TestIterableCoder(t *testing.T) {
cod := coder.NewI(coder.NewVarInt())
wantVals := []int64{8, 24, 72}
diff --git a/sdks/go/pkg/beam/core/runtime/graphx/coder.go
b/sdks/go/pkg/beam/core/runtime/graphx/coder.go
index 2b769c873ec..ced4d34679b 100644
--- a/sdks/go/pkg/beam/core/runtime/graphx/coder.go
+++ b/sdks/go/pkg/beam/core/runtime/graphx/coder.go
@@ -47,6 +47,7 @@ const (
urnTimerCoder = "beam:coder:timer:v1"
urnRowCoder = "beam:coder:row:v1"
urnNullableCoder = "beam:coder:nullable:v1"
+ urnShardedKeyCoder = "beam:coder:sharded_key:v1"
urnGlobalWindow = "beam:coder:global_window:v1"
urnIntervalWindow = "beam:coder:interval_window:v1"
@@ -74,6 +75,7 @@ func knownStandardCoders() []string {
urnRowCoder,
urnNullableCoder,
urnTimerCoder,
+ urnShardedKeyCoder,
}
}
@@ -378,6 +380,15 @@ func (b *CoderUnmarshaller) makeCoder(id string, c
*pipepb.Coder) (*coder.Coder,
return nil, err
}
return coder.NewN(elm), nil
+ case urnShardedKeyCoder:
+ if len(components) != 1 {
+ return nil, errors.Errorf("could not unmarshal
sharded_key coder from %v, expected one component (key) but got %d", c,
len(components))
+ }
+ keyC, err := b.Coder(components[0])
+ if err != nil {
+ return nil, err
+ }
+ return coder.NewSK(keyC), nil
case urnIntervalWindow:
return coder.NewIntervalWindowCoder(), nil
@@ -493,6 +504,16 @@ func (b *CoderMarshaller) Add(c *coder.Coder) (string,
error) {
stream := b.internBuiltInCoder(urnIterableCoder, value)
return b.internBuiltInCoder(urnKVCoder, comp[0], stream), nil
+ case coder.ShardedKey:
+ comp, err := b.AddMulti(c.Components)
+ if err != nil {
+ return "", errors.Wrapf(err, "failed to marshal
ShardedKey coder %v", c)
+ }
+ if len(comp) != 1 {
+ return "", errors.Errorf("ShardedKey coder requires
exactly 1 component (key), got %d", len(comp))
+ }
+ return b.internBuiltInCoder(urnShardedKeyCoder, comp...), nil
+
case coder.WindowedValue:
comp := []string{}
if ids, err := b.AddMulti(c.Components); err != nil {
diff --git a/sdks/go/pkg/beam/core/runtime/symbols.go
b/sdks/go/pkg/beam/core/runtime/symbols.go
index 84afe9b769a..9640af288b6 100644
--- a/sdks/go/pkg/beam/core/runtime/symbols.go
+++ b/sdks/go/pkg/beam/core/runtime/symbols.go
@@ -83,6 +83,26 @@ func RegisterFunction(fn any) {
cache[key] = fn
}
+// RegisterFunctionWithName registers fn under the given name,
+// overriding the automatically derived symbol name. This is necessary
+// for closures produced by Go generic functions where multiple type
+// instantiations generate closures with the same compiler-assigned
+// name (e.g. "pkg.Func[...].func1") — without distinct names the
+// last registration wins and cross-worker deserialization resolves
+// the wrong function.
+//
+// Callers must ensure that name is stable across process invocations
+// (pipeline driver and workers must agree). A typical choice is
+// "<package>.<GenericFunc>[<TypeParam>].enc".
+//
+// Must be called in init() only.
+func RegisterFunctionWithName(name string, fn any) {
+ if initialized {
+ panic("Init hooks have already run. Register function during
init() instead.")
+ }
+ cache[name] = fn
+}
+
// ResolveFunction resolves the runtime value of a given function by symbol
name
// and type.
func ResolveFunction(name string, t reflect.Type) (any, error) {
diff --git a/sdks/go/pkg/beam/core/typex/class.go
b/sdks/go/pkg/beam/core/typex/class.go
index 570b7e27921..6c8f3549893 100644
--- a/sdks/go/pkg/beam/core/typex/class.go
+++ b/sdks/go/pkg/beam/core/typex/class.go
@@ -231,10 +231,10 @@ func IsUniversal(t reflect.Type) bool {
}
// IsComposite returns true iff the given type is one of the predefined
-// Composite marker types: KV, CoGBK or WindowedValue.
+// Composite marker types: KV, CoGBK, WindowedValue, Timers or ShardedKey.
func IsComposite(t reflect.Type) bool {
switch t {
- case KVType, CoGBKType, WindowedValueType, TimersType:
+ case KVType, CoGBKType, WindowedValueType, TimersType, ShardedKeyType:
return true
default:
return false
diff --git a/sdks/go/pkg/beam/core/typex/fulltype.go
b/sdks/go/pkg/beam/core/typex/fulltype.go
index ff5520c2861..88e26568dff 100644
--- a/sdks/go/pkg/beam/core/typex/fulltype.go
+++ b/sdks/go/pkg/beam/core/typex/fulltype.go
@@ -89,6 +89,8 @@ func printShortComposite(t reflect.Type) string {
return "KV"
case NullableType:
return "Nullable"
+ case ShardedKeyType:
+ return "SK"
default:
return fmt.Sprintf("invalid(%v)", t)
}
@@ -146,6 +148,14 @@ func New(t reflect.Type, components ...FullType) FullType {
return &tree{class, t, components}
case TimersType:
return &tree{class, t, components}
+ case ShardedKeyType:
+ if len(components) != 1 {
+ panic(fmt.Sprintf("Invalid number of components
for ShardedKey: %v, %v", t, components))
+ }
+ if components[0].Class() == Composite {
+ panic(fmt.Sprintf("Invalid to nest composite
inside ShardedKey: %v, %v", t, components))
+ }
+ return &tree{class, t, components}
default:
panic(fmt.Sprintf("Unexpected composite type: %v", t))
}
@@ -226,6 +236,19 @@ func NewCoGBK(components ...FullType) FullType {
return New(CoGBKType, components...)
}
+// IsShardedKey returns true iff the type is a ShardedKey.
+func IsShardedKey(t FullType) bool {
+ return t.Type() == ShardedKeyType
+}
+
+// NewShardedKey constructs a new ShardedKey FullType wrapping the given
+// key component. The ShardedKey has exactly one component — the user key
+// type — because the ShardID byte-string has a fixed representation and
+// is not a user-configurable type.
+func NewShardedKey(keyType FullType) FullType {
+ return New(ShardedKeyType, keyType)
+}
+
// IsStructurallyAssignable returns true iff a from value is structurally
// assignable to the to value of the given types. Types that are
// "structurally assignable" (SA) are assignable if type variables are
diff --git a/sdks/go/pkg/beam/core/typex/special.go
b/sdks/go/pkg/beam/core/typex/special.go
index 9093ddc782c..6cf1cb99f75 100644
--- a/sdks/go/pkg/beam/core/typex/special.go
+++ b/sdks/go/pkg/beam/core/typex/special.go
@@ -44,6 +44,7 @@ var (
CoGBKType = reflect.TypeOf((*CoGBK)(nil)).Elem()
WindowedValueType = reflect.TypeOf((*WindowedValue)(nil)).Elem()
BundleFinalizationType =
reflect.TypeOf((*BundleFinalization)(nil)).Elem()
+ ShardedKeyType = reflect.TypeOf((*ShardedKey)(nil)).Elem()
)
// T, U, V, W, X, Y, Z are universal types. They play the role of generic
@@ -128,8 +129,10 @@ type Timers struct {
Pane PaneInfo
}
-// KV, Nullable, CoGBK, WindowedValue represent composite generic types. They
are not used
-// directly in user code signatures, but only in FullTypes.
+// KV, Nullable, CoGBK, WindowedValue, ShardedKey represent composite
+// generic types. They are not used directly in user code signatures, but
+// only in FullTypes — each appears as the root of a FullType tree whose
+// component list holds the concrete sub-types.
type KV struct{}
@@ -138,3 +141,14 @@ type Nullable struct{}
type CoGBK struct{}
type WindowedValue struct{}
+
+// ShardedKey is the composite marker for sharded-key encoded pairs
+// (user key + opaque shard identifier). It is never constructed by user
+// code; it appears only as the root of a FullType tree whose single
+// component is the key's FullType.
+//
+// Runtime values are carried through FullValue.Elm (user key) and
+// FullValue.Elm2 ([]byte shardID). The corresponding wire encoding is
+// URN beam:coder:sharded_key:v1, byte-identical to the Java and Python
+// sharded_key encodings.
+type ShardedKey struct{}
diff --git a/sdks/go/pkg/beam/core/util/reflectx/call.go
b/sdks/go/pkg/beam/core/util/reflectx/call.go
index 9b1955427f7..e14ed016425 100644
--- a/sdks/go/pkg/beam/core/util/reflectx/call.go
+++ b/sdks/go/pkg/beam/core/util/reflectx/call.go
@@ -87,6 +87,37 @@ func (c *reflectFunc) Call(args []any) []any {
return Interface(c.fn.Call(ValueOf(args)))
}
+// MakeFuncWithName returns a Func that wraps fn but whose Name()
+// returns the provided name instead of the compiler-derived symbol.
+// This is essential for closures inside Go generic functions: all
+// type instantiations produce closures with the same compiler name
+// (e.g. "pkg.Func[...].func1"), so the default name-based
+// serialization cannot distinguish them. A stable, type-qualified
+// name ensures cross-worker deserialization resolves the correct
+// function.
+func MakeFuncWithName(name string, fn any) Func {
+ inner := MakeFunc(fn)
+ return &namedFunc{inner: inner, name: name}
+}
+
+type namedFunc struct {
+ inner Func
+ name string
+}
+
+func (f *namedFunc) Name() string { return f.name }
+func (f *namedFunc) Type() reflect.Type { return f.inner.Type() }
+func (f *namedFunc) Call(args []any) []any { return f.inner.Call(args) }
+
+// Interface returns the original unwrapped function, which
+// runtime.RegisterFunction needs for pointer extraction.
+func (f *namedFunc) Interface() any {
+ if rf, ok := f.inner.(*reflectFunc); ok {
+ return rf.fn.Interface()
+ }
+ return nil
+}
+
// CallNoPanic calls the given Func and catches any panic.
func CallNoPanic(fn Func, args []any) (ret []any, err error) {
defer func() {
diff --git a/sdks/go/pkg/beam/pcollection.go b/sdks/go/pkg/beam/pcollection.go
index e5dc63289f3..2138266a667 100644
--- a/sdks/go/pkg/beam/pcollection.go
+++ b/sdks/go/pkg/beam/pcollection.go
@@ -17,6 +17,7 @@ package beam
import (
"github.com/apache/beam/sdks/v2/go/pkg/beam/core/graph"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/graph/window"
"github.com/apache/beam/sdks/v2/go/pkg/beam/core/typex"
"github.com/apache/beam/sdks/v2/go/pkg/beam/internal/errors"
)
@@ -80,6 +81,21 @@ func (p PCollection) SetCoder(c Coder) error {
return nil
}
+// WindowingStrategy returns the windowing strategy of the PCollection. It
+// describes how elements are assigned to windows and — for transforms that
+// honor it — the allowed lateness after which windows are closed.
+//
+// Transforms that use state and timers keyed by window, such as
+// GroupIntoBatches, consult this strategy to compute end-of-window
+// event-time timers and to bound partial-batch flushes by the pipeline's
+// allowed lateness.
+func (p PCollection) WindowingStrategy() *window.WindowingStrategy {
+ if !p.IsValid() {
+ panic("Invalid PCollection")
+ }
+ return p.n.WindowingStrategy()
+}
+
func (p PCollection) String() string {
if !p.IsValid() {
return "(invalid)"
diff --git a/sdks/go/pkg/beam/transforms/batch/batch.go
b/sdks/go/pkg/beam/transforms/batch/batch.go
new file mode 100644
index 00000000000..6cc378c2f44
--- /dev/null
+++ b/sdks/go/pkg/beam/transforms/batch/batch.go
@@ -0,0 +1,677 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package batch provides transforms that group elements of a KV-keyed
+// PCollection into batches of a target size for downstream per-batch
+// processing (rate-limited API calls, bulk sinks, etc.).
+//
+// GroupIntoBatches mirrors the behavior of the Java and Python
+// transforms of the same name. GroupIntoBatchesWithShardedKey adds
+// opaque per-element shard identifiers to the keys so the processing
+// of a single hot logical key spreads across multiple workers.
+//
+// # Behavior
+//
+// Given a PCollection<KV<K, V>>, GroupIntoBatches buffers values per
+// key and emits batches as KV<K, []V> whenever one of the following
+// limits is reached:
+//
+// - len(batch) reaches BatchSize, OR
+// - sum of byte sizes reaches BatchSizeBytes, OR
+// - MaxBufferingDuration elapses in processing time since the first
+// element of the current batch (if set), OR
+// - the window advances past MaxTimestamp + AllowedLateness of the
+// input PCollection's WindowingStrategy.
+//
+// Elements of different windows are never combined into the same
+// batch.
+//
+// # Determinism requirement
+//
+// The key coder MUST be deterministic. State keying depends on
+// byte-stable encodings: a non-deterministic key coder would silently
+// split the logical key across multiple physical keys, producing
+// corrupt batches. The transform panics at pipeline build time if the
+// key coder is not known to be deterministic. For user-defined key
+// types, register the type's coder via
+// coder.RegisterDeterministicCoder.
+//
+// # Differences from Java/Python
+//
+// - BatchSize / BatchSizeBytes are int64 (parity with proto and Java
+// long, avoiding overflow on 32-bit platforms).
+// - BatchSizeBytes is limited to primitive value types ([]byte,
+// string, numeric, bool) in this release; opaque V types panic at
+// build time if BatchSizeBytes > 0.
+package batch
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "fmt"
+ "reflect"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/apache/beam/sdks/v2/go/pkg/beam"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/funcx"
+ beamcoder "github.com/apache/beam/sdks/v2/go/pkg/beam/core/graph/coder"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/graph/mtime"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/state"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/timers"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/typex"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/core/util/reflectx"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/register"
+ "github.com/google/uuid"
+)
+
+// ShardedKey pairs a user key with an opaque shard identifier. It is
+// the key type of the PCollection produced by
+// GroupIntoBatchesWithShardedKey.
+type ShardedKey[K any] struct {
+ Key K
+ ShardID []byte
+}
+
+// RegisterShardedKeyType registers a ShardedKey[K] instantiation so
+// its coder survives cross-worker serialization. Common key types
+// (string, []byte, int, int64) are registered automatically at init.
+// Users of other K types must call this at init time.
+func RegisterShardedKeyType[K any]() {
+ var zero K
+ keyT := reflect.TypeOf(zero)
+ skT := reflect.TypeOf(ShardedKey[K]{})
+
+ register.DoFn3x0[K, typex.V, func(ShardedKey[K],
typex.V)](&wrapShardedKeyFn[K]{})
+ register.Emitter2[ShardedKey[K], typex.V]()
+ beam.RegisterType(skT)
+
+ keyEnc := beam.NewElementEncoder(keyT)
+ keyDec := beam.NewElementDecoder(keyT)
+
+ enc := func(sk ShardedKey[K]) []byte {
+ var buf bytes.Buffer
+ writeVarInt(&buf, int64(len(sk.ShardID)))
+ buf.Write(sk.ShardID)
+ if err := keyEnc.Encode(sk.Key, &buf); err != nil {
+ panic(err)
+ }
+ return buf.Bytes()
+ }
+ dec := func(b []byte) ShardedKey[K] {
+ r := bytes.NewReader(b)
+ n := readVarInt(r)
+ shardID := make([]byte, n)
+ if n > 0 {
+ if _, err := r.Read(shardID); err != nil {
+ panic(err)
+ }
+ }
+ k, err := keyDec.Decode(r)
+ if err != nil {
+ panic(err)
+ }
+ return ShardedKey[K]{Key: k.(K), ShardID: shardID}
+ }
+
+ // Closures inside generic functions share the same compiler
+ // symbol name for every type instantiation. We wrap them with a
+ // type-qualified name so the cross-worker deserializer resolves
+ // the correct enc/dec for each ShardedKey[K].
+ encName := fmt.Sprintf("batch.encShardedKey[%v]", keyT)
+ decName := fmt.Sprintf("batch.decShardedKey[%v]", keyT)
+
+ encFn := reflectx.MakeFuncWithName(encName, enc)
+ decFn := reflectx.MakeFuncWithName(decName, dec)
+
+ // Register in the runtime cache under the qualified name so
+ // ResolveFunction finds them at deserialization time.
+ runtime.RegisterFunctionWithName(encName, enc)
+ runtime.RegisterFunctionWithName(decName, dec)
+
+ encWrapped, err := funcx.New(encFn)
+ if err != nil {
+ panic(fmt.Sprintf("RegisterShardedKeyType: bad enc for %v: %v",
skT, err))
+ }
+ decWrapped, err := funcx.New(decFn)
+ if err != nil {
+ panic(fmt.Sprintf("RegisterShardedKeyType: bad dec for %v: %v",
skT, err))
+ }
+
+ beamcoder.RegisterDeterministicCoderWithFuncs(skT, encWrapped,
decWrapped)
+}
+
+// Params configures GroupIntoBatches and
+// GroupIntoBatchesWithShardedKey.
+//
+// At least one of BatchSize or BatchSizeBytes must be > 0.
+type Params struct {
+ // BatchSize is the target maximum number of elements per batch. A
+ // batch is emitted as soon as it holds BatchSize elements. Zero
+ // disables the count-based trigger.
+ BatchSize int64
+
+ // BatchSizeBytes is the target maximum cumulative byte size per
+ // batch. A batch is emitted as soon as adding another element
+ // would exceed BatchSizeBytes. Zero disables the byte-based
+ // trigger.
+ BatchSizeBytes int64
+
+ // MaxBufferingDuration, when > 0, triggers emission of a partial
+ // batch after this much processing time has elapsed since the
+ // first element of the current batch was buffered.
+ MaxBufferingDuration time.Duration
+}
+
+func (p Params) validate() error {
+ if p.BatchSize < 0 {
+ return fmt.Errorf("Params.BatchSize must be >= 0; got %d",
p.BatchSize)
+ }
+ if p.BatchSizeBytes < 0 {
+ return fmt.Errorf("Params.BatchSizeBytes must be >= 0; got %d",
p.BatchSizeBytes)
+ }
+ if p.BatchSize == 0 && p.BatchSizeBytes == 0 {
+ return fmt.Errorf("Params: at least one of BatchSize or
BatchSizeBytes must be > 0")
+ }
+ if p.MaxBufferingDuration < 0 {
+ return fmt.Errorf("Params.MaxBufferingDuration must be >= 0;
got %s", p.MaxBufferingDuration)
+ }
+ return nil
+}
+
+const (
+ sizerNone int32 = 0
+ sizerPrimitive int32 = 1
+)
+
+// codecCache keeps a per-value-type ElementEncoder/Decoder pair.
+type codecCache struct {
+ once sync.Once
+ enc beam.ElementEncoder
+ dec beam.ElementDecoder
+}
+
+func (c *codecCache) init(t reflect.Type) {
+ c.once.Do(func() {
+ c.enc = beam.NewElementEncoder(t)
+ c.dec = beam.NewElementDecoder(t)
+ })
+}
+
+func (c *codecCache) encode(v any) []byte {
+ var buf bytes.Buffer
+ if err := c.enc.Encode(v, &buf); err != nil {
+ panic(err)
+ }
+ return buf.Bytes()
+}
+
+func (c *codecCache) decode(b []byte) any {
+ v, err := c.dec.Decode(bytes.NewReader(b))
+ if err != nil {
+ panic(err)
+ }
+ return v
+}
+
+// groupIntoBatchesFn is the stateful DoFn without a processing-time
+// buffering timer.
+type groupIntoBatchesFn struct {
+ Buffer state.Bag[[]byte]
+ Count state.Value[int64]
+ ByteSize state.Value[int64]
+ WindowEnd timers.EventTime
+
+ ValueType beam.EncodedType
+
+ BatchSize int64
+ BatchSizeBytes int64
+ AllowedLatenessMs int64
+ SizerKind int32
+
+ codec codecCache
+}
+
+func (fn *groupIntoBatchesFn) ProcessElement(
+ w beam.Window, sp state.Provider, tp timers.Provider,
+ key typex.T, value typex.V, emit func(typex.T, []typex.V),
+) {
+ fn.codec.init(fn.ValueType.T)
+
+ count, _, err := fn.Count.Read(sp)
+ if err != nil {
+ panic(err)
+ }
+
+ if w.MaxTimestamp() < mtime.MaxTimestamp {
+ windowEnd := w.MaxTimestamp().ToTime()
+ if fn.AllowedLatenessMs > 0 {
+ windowEnd =
windowEnd.Add(time.Duration(fn.AllowedLatenessMs) * time.Millisecond)
+ }
+ fn.WindowEnd.Set(tp, windowEnd, timers.WithNoOutputTimestamp())
+ }
+
+ if err := fn.Buffer.Add(sp, fn.codec.encode(value)); err != nil {
+ panic(err)
+ }
+ count++
+ if err := fn.Count.Write(sp, count); err != nil {
+ panic(err)
+ }
+
+ newBytes := int64(0)
+ if fn.BatchSizeBytes > 0 {
+ cur, _, err := fn.ByteSize.Read(sp)
+ if err != nil {
+ panic(err)
+ }
+ cur += sizeOf(fn.SizerKind, value)
+ if err := fn.ByteSize.Write(sp, cur); err != nil {
+ panic(err)
+ }
+ newBytes = cur
+ }
+
+ if fn.BatchSize > 0 && count >= fn.BatchSize {
+ fn.flush(sp, key, emit)
+ return
+ }
+ if fn.BatchSizeBytes > 0 && newBytes >= fn.BatchSizeBytes {
+ fn.flush(sp, key, emit)
+ return
+ }
+}
+
+func (fn *groupIntoBatchesFn) OnTimer(
+ ctx context.Context, ts beam.EventTime, sp state.Provider, tp
timers.Provider,
+ key typex.T, timer timers.Context, emit func(typex.T, []typex.V),
+) {
+ if timer.Family != fn.WindowEnd.Family {
+ panic(fmt.Sprintf("batch.groupIntoBatchesFn: unexpected timer
family %q", timer.Family))
+ }
+ fn.codec.init(fn.ValueType.T)
+ fn.flush(sp, key, emit)
+}
+
+func (fn *groupIntoBatchesFn) flush(
+ sp state.Provider, key typex.T, emit func(typex.T, []typex.V),
+) {
+ buf, ok, err := fn.Buffer.Read(sp)
+ if err != nil {
+ panic(err)
+ }
+ if !ok || len(buf) == 0 {
+ return
+ }
+
+ out := make([]typex.V, len(buf))
+ for i, b := range buf {
+ out[i] = fn.codec.decode(b)
+ }
+ emit(key, out)
+
+ if err := fn.Buffer.Clear(sp); err != nil {
+ panic(err)
+ }
+ if err := fn.Count.Clear(sp); err != nil {
+ panic(err)
+ }
+ if fn.BatchSizeBytes > 0 {
+ if err := fn.ByteSize.Clear(sp); err != nil {
+ panic(err)
+ }
+ }
+}
+
+// groupIntoBatchesBufferedFn adds a processing-time buffering timer.
+type groupIntoBatchesBufferedFn struct {
+ Buffer state.Bag[[]byte]
+ Count state.Value[int64]
+ ByteSize state.Value[int64]
+ TimerSet state.Value[bool]
+ Buffering timers.ProcessingTime
+ WindowEnd timers.EventTime
+
+ ValueType beam.EncodedType
+
+ BatchSize int64
+ BatchSizeBytes int64
+ MaxBufferingMs int64
+ AllowedLatenessMs int64
+ SizerKind int32
+
+ codec codecCache
+}
+
+func (fn *groupIntoBatchesBufferedFn) ProcessElement(
+ w beam.Window, sp state.Provider, tp timers.Provider,
+ key typex.T, value typex.V, emit func(typex.T, []typex.V),
+) {
+ fn.codec.init(fn.ValueType.T)
+
+ count, _, err := fn.Count.Read(sp)
+ if err != nil {
+ panic(err)
+ }
+
+ if w.MaxTimestamp() < mtime.MaxTimestamp {
+ windowEnd := w.MaxTimestamp().ToTime()
+ if fn.AllowedLatenessMs > 0 {
+ windowEnd =
windowEnd.Add(time.Duration(fn.AllowedLatenessMs) * time.Millisecond)
+ }
+ fn.WindowEnd.Set(tp, windowEnd, timers.WithNoOutputTimestamp())
+ }
+
+ if err := fn.Buffer.Add(sp, fn.codec.encode(value)); err != nil {
+ panic(err)
+ }
+ count++
+ if err := fn.Count.Write(sp, count); err != nil {
+ panic(err)
+ }
+
+ newBytes := int64(0)
+ if fn.BatchSizeBytes > 0 {
+ cur, _, err := fn.ByteSize.Read(sp)
+ if err != nil {
+ panic(err)
+ }
+ cur += sizeOf(fn.SizerKind, value)
+ if err := fn.ByteSize.Write(sp, cur); err != nil {
+ panic(err)
+ }
+ newBytes = cur
+ }
+
+ if count == 1 {
+ fn.Buffering.Set(tp,
time.Now().Add(time.Duration(fn.MaxBufferingMs)*time.Millisecond))
+ if err := fn.TimerSet.Write(sp, true); err != nil {
+ panic(err)
+ }
+ }
+
+ if fn.BatchSize > 0 && count >= fn.BatchSize {
+ fn.flush(sp, tp, key, emit)
+ return
+ }
+ if fn.BatchSizeBytes > 0 && newBytes >= fn.BatchSizeBytes {
+ fn.flush(sp, tp, key, emit)
+ return
+ }
+}
+
+func (fn *groupIntoBatchesBufferedFn) OnTimer(
+ ctx context.Context, ts beam.EventTime, sp state.Provider, tp
timers.Provider,
+ key typex.T, timer timers.Context, emit func(typex.T, []typex.V),
+) {
+ fn.codec.init(fn.ValueType.T)
+ switch timer.Family {
+ case fn.Buffering.Family, fn.WindowEnd.Family:
+ fn.flush(sp, tp, key, emit)
+ default:
+ panic(fmt.Sprintf(
+ "batch.groupIntoBatchesBufferedFn: unexpected timer
family %q", timer.Family))
+ }
+}
+
+func (fn *groupIntoBatchesBufferedFn) flush(
+ sp state.Provider, tp timers.Provider, key typex.T, emit func(typex.T,
[]typex.V),
+) {
+ buf, ok, err := fn.Buffer.Read(sp)
+ if err != nil {
+ panic(err)
+ }
+ if !ok || len(buf) == 0 {
+ return
+ }
+
+ out := make([]typex.V, len(buf))
+ for i, b := range buf {
+ out[i] = fn.codec.decode(b)
+ }
+ emit(key, out)
+
+ if err := fn.Buffer.Clear(sp); err != nil {
+ panic(err)
+ }
+ if err := fn.Count.Clear(sp); err != nil {
+ panic(err)
+ }
+ if fn.BatchSizeBytes > 0 {
+ if err := fn.ByteSize.Clear(sp); err != nil {
+ panic(err)
+ }
+ }
+ setBool, _, err := fn.TimerSet.Read(sp)
+ if err != nil {
+ panic(err)
+ }
+ if setBool {
+ fn.Buffering.Clear(tp)
+ if err := fn.TimerSet.Clear(sp); err != nil {
+ panic(err)
+ }
+ }
+}
+
+func sizeOf(kind int32, v any) int64 {
+ switch kind {
+ case sizerNone:
+ return 0
+ case sizerPrimitive:
+ if size, ok := defaultElementByteSize(v); ok {
+ return size
+ }
+ panic(fmt.Sprintf("batch: sizerPrimitive cannot size value of
type %T", v))
+ default:
+ panic(fmt.Sprintf("batch: unknown sizer kind %d", kind))
+ }
+}
+
+// wrapShardedKeyFn maps KV<K, V> → KV<ShardedKey[K], V>.
+type wrapShardedKeyFn[K any] struct{}
+
+func (*wrapShardedKeyFn[K]) ProcessElement(
+ key K, value typex.V, emit func(ShardedKey[K], typex.V),
+) {
+ emit(ShardedKey[K]{Key: key, ShardID: makeShardID()}, value)
+}
+
+var (
+ workerUUIDOnce sync.Once
+ workerUUIDVal [16]byte
+ shardCounter atomic.Uint64
+)
+
+// makeShardID returns a 24-byte shard identifier: a 16-byte worker
+// UUID fixed per process plus an 8-byte atomic counter, big-endian.
+// The layout mirrors the Java and Python shapes exactly so the wire
+// bytes of cross-language round-trips remain aligned.
+func makeShardID() []byte {
+ workerUUIDOnce.Do(func() {
+ b, err := uuid.New().MarshalBinary()
+ if err != nil {
+ panic(fmt.Sprintf("batch: failed to marshal worker
UUID: %v", err))
+ }
+ copy(workerUUIDVal[:], b)
+ })
+ out := make([]byte, 24)
+ copy(out[:16], workerUUIDVal[:])
+ counter := shardCounter.Add(1)
+ binary.BigEndian.PutUint64(out[16:24], counter)
+ return out
+}
+
+// writeVarInt writes a varint-encoded int64 to buf (unsigned,
+// little-endian base-128).
+func writeVarInt(buf *bytes.Buffer, v int64) {
+ u := uint64(v)
+ for u >= 0x80 {
+ buf.WriteByte(byte(u) | 0x80)
+ u >>= 7
+ }
+ buf.WriteByte(byte(u))
+}
+
+// readVarInt reads a varint-encoded int64 from r.
+func readVarInt(r *bytes.Reader) int64 {
+ var u uint64
+ var s uint
+ for {
+ b, err := r.ReadByte()
+ if err != nil {
+ panic(err)
+ }
+ if b < 0x80 {
+ u |= uint64(b) << s
+ break
+ }
+ u |= uint64(b&0x7f) << s
+ s += 7
+ }
+ return int64(u)
+}
+
+func init() {
+ register.DoFn6x0[
+ beam.Window, state.Provider, timers.Provider,
+ typex.T, typex.V, func(typex.T, []typex.V),
+ ](&groupIntoBatchesFn{})
+ register.DoFn6x0[
+ beam.Window, state.Provider, timers.Provider,
+ typex.T, typex.V, func(typex.T, []typex.V),
+ ](&groupIntoBatchesBufferedFn{})
+ register.Emitter2[typex.T, []typex.V]()
+
+ // Register common ShardedKey[K] types for WithShardedKey.
+ RegisterShardedKeyType[string]()
+ RegisterShardedKeyType[int]()
+ RegisterShardedKeyType[int64]()
+}
+
+// GroupIntoBatches groups the values of the input PCollection<KV<K, V>>
+// into batches of up to params.BatchSize elements (or
+// params.BatchSizeBytes bytes) per key and emits them as
+// PCollection<KV<K, []V>>.
+//
+// The input must be KV-typed. The key coder must be deterministic;
+// non-deterministic key coders would corrupt state keying. Panics at
+// pipeline build time on invalid params, non-KV input, zero limits, or
+// a non-deterministic key coder.
+func GroupIntoBatches(s beam.Scope, params Params, col beam.PCollection)
beam.PCollection {
+ s = s.Scope("batch.GroupIntoBatches")
+
+ if err := params.validate(); err != nil {
+ panic(fmt.Errorf("GroupIntoBatches: %w", err))
+ }
+ if !typex.IsKV(col.Type()) {
+ panic(fmt.Errorf(
+ "GroupIntoBatches: input PCollection must be KV-typed;
got %v", col.Type()))
+ }
+
+ keyFT := col.Type().Components()[0]
+ valFT := col.Type().Components()[1]
+
+ if !beam.NewCoder(keyFT).IsDeterministic() {
+ panic(fmt.Errorf(
+ "GroupIntoBatches: key coder for type %v is not
deterministic. "+
+ "Register a deterministic custom coder with "+
+ "coder.RegisterDeterministicCoder, or use a
deterministic key "+
+ "type (string, []byte, bool, integer, float)",
keyFT.Type()))
+ }
+
+ sizerKind := sizerNone
+ if params.BatchSizeBytes > 0 {
+ if !isBuiltinSizeable(valFT.Type()) {
+ panic(fmt.Errorf(
+ "GroupIntoBatches: BatchSizeBytes > 0 requires
value type %v "+
+ "to be a built-in primitive ([]byte,
string, numeric, bool)",
+ valFT.Type()))
+ }
+ sizerKind = sizerPrimitive
+ }
+
+ allowedLatenessMs := int64(col.WindowingStrategy().AllowedLateness)
+ valueType := beam.EncodedType{T: valFT.Type()}
+
+ if params.MaxBufferingDuration > 0 {
+ fn := &groupIntoBatchesBufferedFn{
+ Buffer:
state.MakeBagState[[]byte]("batchBuffer"),
+ Count:
state.MakeValueState[int64]("batchCount"),
+ ByteSize:
state.MakeValueState[int64]("batchBytes"),
+ TimerSet:
state.MakeValueState[bool]("batchTimerSet"),
+ Buffering:
timers.InProcessingTime("batchBuffering"),
+ WindowEnd: timers.InEventTime("batchWindowEnd"),
+ ValueType: valueType,
+ BatchSize: params.BatchSize,
+ BatchSizeBytes: params.BatchSizeBytes,
+ MaxBufferingMs:
params.MaxBufferingDuration.Milliseconds(),
+ AllowedLatenessMs: allowedLatenessMs,
+ SizerKind: sizerKind,
+ }
+ return beam.ParDo(s, fn, col)
+ }
+
+ fn := &groupIntoBatchesFn{
+ Buffer: state.MakeBagState[[]byte]("batchBuffer"),
+ Count: state.MakeValueState[int64]("batchCount"),
+ ByteSize: state.MakeValueState[int64]("batchBytes"),
+ WindowEnd: timers.InEventTime("batchWindowEnd"),
+ ValueType: valueType,
+ BatchSize: params.BatchSize,
+ BatchSizeBytes: params.BatchSizeBytes,
+ AllowedLatenessMs: allowedLatenessMs,
+ SizerKind: sizerKind,
+ }
+
+ return beam.ParDo(s, fn, col)
+}
+
+// GroupIntoBatchesWithShardedKey wraps each user key with a
+// ShardedKey{Key: K, ShardID: [24]byte} and then applies
+// GroupIntoBatches. Output is PCollection<KV<ShardedKey[K], []V>>.
+//
+// The key type K must have been registered via
+// RegisterShardedKeyType[K] at init time. Common types (string,
+// []byte, int, int64) are registered automatically.
+//
+// Sharding spreads the processing of a single hot logical key across
+// multiple workers: each shard is independent state, so distributed
+// runners can parallelize without the user's key type changing.
+func GroupIntoBatchesWithShardedKey[K any](s beam.Scope, params Params, col
beam.PCollection) beam.PCollection {
+ s = s.Scope("batch.GroupIntoBatchesWithShardedKey")
+
+ if err := params.validate(); err != nil {
+ panic(fmt.Errorf("GroupIntoBatchesWithShardedKey: %w", err))
+ }
+ if !typex.IsKV(col.Type()) {
+ panic(fmt.Errorf(
+ "GroupIntoBatchesWithShardedKey: input PCollection must
be KV-typed; got %v",
+ col.Type()))
+ }
+ keyFT := col.Type().Components()[0]
+ var zero K
+ if keyFT.Type() != reflect.TypeOf(zero) {
+ panic(fmt.Errorf(
+ "GroupIntoBatchesWithShardedKey: type parameter K (%v)
does not match input key type (%v)",
+ reflect.TypeOf(zero), keyFT.Type()))
+ }
+
+ wrapped := beam.ParDo(s, &wrapShardedKeyFn[K]{}, col)
+ return GroupIntoBatches(s, params, wrapped)
+}
diff --git a/sdks/go/pkg/beam/transforms/batch/batch_prism_test.go
b/sdks/go/pkg/beam/transforms/batch/batch_prism_test.go
new file mode 100644
index 00000000000..158ea314dd0
--- /dev/null
+++ b/sdks/go/pkg/beam/transforms/batch/batch_prism_test.go
@@ -0,0 +1,222 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package batch
+
+import (
+ "os"
+ "sort"
+ "sync/atomic"
+ "testing"
+
+ "github.com/apache/beam/sdks/v2/go/pkg/beam"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/options/jobopts"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/register"
+ _ "github.com/apache/beam/sdks/v2/go/pkg/beam/runners/prism"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/testing/passert"
+ "github.com/apache/beam/sdks/v2/go/pkg/beam/testing/ptest"
+)
+
+func TestMain(m *testing.M) {
+ f, _ := os.CreateTemp("", "dummy")
+ *jobopts.WorkerBinary = f.Name()
+ os.Exit(ptest.MainRetWithDefault(m, "prism"))
+}
+
+// splitOnBar parses "key|value" strings into KV<string,string>.
+func splitOnBar(tuple string, emit func(string, string)) {
+ for i, r := range tuple {
+ if r == '|' {
+ emit(tuple[:i], tuple[i+1:])
+ return
+ }
+ }
+}
+
+func batchSize(_ string, batch []string) int {
+ return len(batch)
+}
+
+func batchSizeSorted(_ string, batch []string) int {
+ sort.Strings(batch)
+ return len(batch)
+}
+
+// intPair emits KV<string, int> from a "key|int" string.
+func intPair(tuple string, emit func(string, int)) {
+ for i, r := range tuple {
+ if r == '|' {
+ n := 0
+ for _, c := range tuple[i+1:] {
+ n = n*10 + int(c-'0')
+ }
+ emit(tuple[:i], n)
+ return
+ }
+ }
+}
+
+func intBatchSize(_ string, batch []int) int { return len(batch) }
+
+func init() {
+ register.Function2x0(splitOnBar)
+ register.Function2x0(intPair)
+ register.Function2x1(batchSize)
+ register.Function2x1(intBatchSize)
+ register.Function2x1(batchSizeSorted)
+ register.Emitter2[string, int]()
+}
+
+// shardedBatchCount counts emitted ShardedKey batches via a side
+// channel (no GBK). Uses a package-level atomic to avoid needing a
+// Combine/GBK for aggregation, which triggers a separate Prism bug
+// on deeply-chained stateful pipelines.
+var shardedBatchCounter atomic.Int64
+
+func shardedBatchSink(sk ShardedKey[string], batch []string) {
+ _ = sk
+ _ = batch
+ shardedBatchCounter.Add(1)
+}
+
+func init() {
+ register.Function2x0(shardedBatchSink)
+}
+
+// TAC-6 (BAC-4): GroupIntoBatchesWithShardedKey wraps each key with
+// a ShardedKey and produces KV<ShardedKey[K], []V>. We validate
+// end-to-end on Prism using a terminal ParDo sink (not passert) to
+// avoid an unrelated Prism GBK panic on deeply-chained pipelines.
+func TestGroupIntoBatchesWithShardedKey_E2E(t *testing.T) {
+ shardedBatchCounter.Store(0)
+
+ p, s := beam.NewPipelineWithRoot()
+
+ tuples := make([]string, 0, 20)
+ for i := 0; i < 20; i++ {
+ tuples = append(tuples, "a|x")
+ }
+ raw := beam.CreateList(s, tuples)
+ kvs := beam.ParDo(s, splitOnBar, raw)
+
+ batches := GroupIntoBatchesWithShardedKey[string](s, Params{BatchSize:
2}, kvs)
+ beam.ParDo0(s, shardedBatchSink, batches)
+
+ ptest.RunAndValidate(t, p)
+
+ got := shardedBatchCounter.Load()
+ // Each element gets a unique shardID (atomic counter), so under
+ // Prism single-process each shard has exactly 1 element — no
+ // batching occurs (BatchSize=2 is never reached per shard).
+ // On a distributed runner the same worker/goroutine would
+ // process multiple elements of the same key, sharing a shardID
+ // and thus producing real batches. Here we verify the pipeline
+ // executed and produced 20 shard-groups.
+ if got != 20 {
+ t.Errorf("expected 20 sharded batches (one per shard), got %d",
got)
+ }
+}
+
+// TestGroupIntoBatches_IntValues verifies that GroupIntoBatches works
+// with a value type (int) that is not string — demonstrating the
+// coder-driven generic value support (BAC-1 with non-string V).
+func TestGroupIntoBatches_IntValues(t *testing.T) {
+ p, s := beam.NewPipelineWithRoot()
+
+ raw := beam.CreateList(s, []string{
+ "a|1", "a|2", "a|3", "a|4",
+ "b|5", "b|6",
+ })
+ kvs := beam.ParDo(s, intPair, raw)
+
+ batches := GroupIntoBatches(s, Params{BatchSize: 2}, kvs)
+ sizes := beam.ParDo(s, intBatchSize, batches)
+
+ passert.Equals(s, sizes, 2, 2, 2)
+
+ ptest.RunAndValidate(t, p)
+}
+
+// TAC-1 (BAC-1): 1000 inputs over 10 keys with BatchSize 100 produces
+// batches of exactly 100 elements for a single key.
+func TestGroupIntoBatches_CountLimit(t *testing.T) {
+ p, s := beam.NewPipelineWithRoot()
+
+ tuples := make([]string, 0, 1000)
+ for k := 0; k < 10; k++ {
+ for i := 0; i < 100; i++ {
+ tuples = append(tuples,
string(rune('a'+k))+"|"+string(rune('0'+i%10)))
+ }
+ }
+
+ raw := beam.CreateList(s, tuples)
+ kvs := beam.ParDo(s, splitOnBar, raw)
+
+ batches := GroupIntoBatches(s, Params{BatchSize: 100}, kvs)
+ sizes := beam.ParDo(s, batchSize, batches)
+
+ // 10 batches of 100.
+ wants := []any{}
+ for i := 0; i < 10; i++ {
+ wants = append(wants, 100)
+ }
+ passert.Equals(s, sizes, wants...)
+
+ ptest.RunAndValidate(t, p)
+}
+
+// TAC-4 (BAC-3): BatchSizeBytes threshold triggers a flush before the
+// sum exceeds the limit. With BatchSizeBytes=10 and input strings of
+// length 5 each, three 5-byte values first sum to 15 (> 10), so the
+// flush happens after 2 elements.
+func TestGroupIntoBatches_ByteLimit(t *testing.T) {
+ p, s := beam.NewPipelineWithRoot()
+
+ raw := beam.CreateList(s, []string{
+ "a|11111", "a|22222", "a|33333", "a|44444", // 4 * 5 bytes on
key a
+ "b|55555", "b|66666", // 2 * 5 bytes on key b
+ })
+ kvs := beam.ParDo(s, splitOnBar, raw)
+
+ batches := GroupIntoBatches(s, Params{BatchSizeBytes: 10}, kvs)
+ sizes := beam.ParDo(s, batchSize, batches)
+
+ // Each 2-element batch reaches 10 bytes and flushes: 2,2 for key a
+ // and 2 for key b = three flushes of size 2.
+ passert.Equals(s, sizes, 2, 2, 2)
+
+ ptest.RunAndValidate(t, p)
+}
+
+// TAC-7 (BAC-5) simplified in global window: batches only contain
+// elements for a single key. Mixed-key batches would fail the
+// key-equality assertion downstream. This test confirms the per-key
+// groupism holds.
+func TestGroupIntoBatches_PerKey(t *testing.T) {
+ p, s := beam.NewPipelineWithRoot()
+
+ raw := beam.CreateList(s, []string{
+ "a|1", "b|1", "a|2", "b|2", "a|3", "b|3", "a|4", "b|4",
+ })
+ kvs := beam.ParDo(s, splitOnBar, raw)
+
+ batches := GroupIntoBatches(s, Params{BatchSize: 2}, kvs)
+ sizes := beam.ParDo(s, batchSize, batches)
+
+ // 8 inputs / BatchSize 2 over 2 keys → 4 batches of size 2.
+ passert.Equals(s, sizes, 2, 2, 2, 2)
+
+ ptest.RunAndValidate(t, p)
+}
diff --git a/sdks/go/pkg/beam/transforms/batch/batch_test.go
b/sdks/go/pkg/beam/transforms/batch/batch_test.go
new file mode 100644
index 00000000000..0e0e00a8064
--- /dev/null
+++ b/sdks/go/pkg/beam/transforms/batch/batch_test.go
@@ -0,0 +1,47 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package batch
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParams_validate(t *testing.T) {
+ cases := []struct {
+ name string
+ p Params
+ wantErr bool
+ }{
+ {"zero_limits", Params{}, true},
+ {"negative_size", Params{BatchSize: -1}, true},
+ {"negative_bytes", Params{BatchSizeBytes: -1}, true},
+ {"negative_duration", Params{BatchSize: 10,
MaxBufferingDuration: -time.Second}, true},
+ {"count_only", Params{BatchSize: 10}, false},
+ {"bytes_only", Params{BatchSizeBytes: 1024}, false},
+ {"both_and_duration", Params{BatchSize: 10, BatchSizeBytes:
1024, MaxBufferingDuration: time.Second}, false},
+ }
+ for _, c := range cases {
+ c := c
+ t.Run(c.name, func(t *testing.T) {
+ err := c.p.validate()
+ gotErr := err != nil
+ if gotErr != c.wantErr {
+ t.Errorf("validate() err = %v, wantErr = %v",
err, c.wantErr)
+ }
+ })
+ }
+}
diff --git a/sdks/go/pkg/beam/transforms/batch/doc.go
b/sdks/go/pkg/beam/transforms/batch/doc.go
new file mode 100644
index 00000000000..8bdbb17fbfd
--- /dev/null
+++ b/sdks/go/pkg/beam/transforms/batch/doc.go
@@ -0,0 +1,58 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package-level doc examples for batch. Kept in the package itself so
+// `go doc` surfaces them without a separate test package and a broader
+// module import graph.
+//
+// These examples only construct a pipeline to illustrate API shape; they
+// do not run one.
+
+package batch
+
+import (
+ "fmt"
+
+ "github.com/apache/beam/sdks/v2/go/pkg/beam"
+)
+
+// ExampleGroupIntoBatches is an example of using the GroupIntoBatches
+// transform. Each input element is a (user, event) pair. After
+// GroupIntoBatches, each batch holds up to 100 events for a single user,
+// ready to be written to a BigQuery sink that accepts bulk inserts.
+func ExampleGroupIntoBatches() {
+ p := beam.NewPipeline()
+ s := p.Root()
+
+ // Build KV<string, string> PCollection via any source. The key
+ // coder (string) is deterministic so state keying is safe.
+ events := beam.CreateList(s, []string{"u1:login", "u1:click",
"u2:login"})
+ kvs := beam.ParDo(s, func(e string, emit func(string, string)) {
+ for i, r := 0, []rune(e); i < len(r); i++ {
+ if r[i] == ':' {
+ emit(string(r[:i]), string(r[i+1:]))
+ return
+ }
+ }
+ }, events)
+
+ batches := GroupIntoBatches(s, Params{BatchSize: 100}, kvs)
+
+ // Downstream: process each per-user batch.
+ _ = batches
+ fmt.Println("pipeline constructed")
+
+ // Output: pipeline constructed
+}
diff --git a/sdks/go/pkg/beam/transforms/batch/size.go
b/sdks/go/pkg/beam/transforms/batch/size.go
new file mode 100644
index 00000000000..ff1499ddaa7
--- /dev/null
+++ b/sdks/go/pkg/beam/transforms/batch/size.go
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package batch
+
+import (
+ "reflect"
+)
+
+// defaultElementByteSize reports the byte cost of v for a fixed set of
+// primitive types: it is the fallback used when the caller does not
+// supply Params.ElementByteSize but BatchSizeBytes > 0.
+//
+// Returns (size, true) for supported types and (0, false) otherwise.
+// For opaque types (user structs, interfaces, maps, non-byte slices,
+// channels, functions) callers must supply their own sizer.
+func defaultElementByteSize(v any) (int64, bool) {
+ switch x := v.(type) {
+ case []byte:
+ return int64(len(x)), true
+ case string:
+ return int64(len(x)), true
+ case bool:
+ return 1, true
+ case int8:
+ return 1, true
+ case uint8:
+ return 1, true
+ case int16:
+ return 2, true
+ case uint16:
+ return 2, true
+ case int32:
+ return 4, true
+ case uint32:
+ return 4, true
+ case float32:
+ return 4, true
+ case int:
+ return 8, true
+ case uint:
+ return 8, true
+ case int64:
+ return 8, true
+ case uint64:
+ return 8, true
+ case float64:
+ return 8, true
+ }
+ return 0, false
+}
+
+// isBuiltinSizeable reports whether defaultElementByteSize can size an
+// element of type t. Used at pipeline-build time to fail fast when
+// BatchSizeBytes > 0 is requested without a user-supplied
+// ElementByteSize and the value type is not one of the supported
+// primitives.
+//
+// A []byte is recognized via reflect.Slice with Uint8 element kind; any
+// other slice is not sizeable by the built-in fallback.
+func isBuiltinSizeable(t reflect.Type) bool {
+ if t == nil {
+ return false
+ }
+ switch t.Kind() {
+ case reflect.String,
+ reflect.Bool,
+ reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32,
reflect.Int64,
+ reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32,
reflect.Uint64,
+ reflect.Float32, reflect.Float64:
+ return true
+ case reflect.Slice:
+ return t.Elem().Kind() == reflect.Uint8
+ }
+ return false
+}
diff --git a/sdks/go/pkg/beam/transforms/batch/size_test.go
b/sdks/go/pkg/beam/transforms/batch/size_test.go
new file mode 100644
index 00000000000..82d2d8dc044
--- /dev/null
+++ b/sdks/go/pkg/beam/transforms/batch/size_test.go
@@ -0,0 +1,91 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package batch
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestDefaultElementByteSize(t *testing.T) {
+ cases := []struct {
+ name string
+ v any
+ want int64
+ ok bool
+ }{
+ {"bytes_5", []byte("abcde"), 5, true},
+ {"bytes_empty", []byte{}, 0, true},
+ {"string_5", "abcde", 5, true},
+ {"string_empty", "", 0, true},
+ {"bool", true, 1, true},
+ {"int8", int8(1), 1, true},
+ {"uint8", uint8(1), 1, true},
+ {"int16", int16(1), 2, true},
+ {"uint16", uint16(1), 2, true},
+ {"int32", int32(1), 4, true},
+ {"uint32", uint32(1), 4, true},
+ {"float32", float32(1.0), 4, true},
+ {"int", int(1), 8, true},
+ {"uint", uint(1), 8, true},
+ {"int64", int64(1), 8, true},
+ {"uint64", uint64(1), 8, true},
+ {"float64", float64(1.0), 8, true},
+ {"struct_unsupported", struct{ A int }{A: 1}, 0, false},
+ {"map_unsupported", map[string]int{"a": 1}, 0, false},
+ {"slice_int_unsupported", []int{1, 2, 3}, 0, false},
+ }
+ for _, c := range cases {
+ c := c
+ t.Run(c.name, func(t *testing.T) {
+ got, ok := defaultElementByteSize(c.v)
+ if ok != c.ok {
+ t.Errorf("ok = %v, want %v", ok, c.ok)
+ }
+ if got != c.want {
+ t.Errorf("size = %d, want %d", got, c.want)
+ }
+ })
+ }
+}
+
+func TestIsBuiltinSizeable(t *testing.T) {
+ cases := []struct {
+ name string
+ t reflect.Type
+ want bool
+ }{
+ {"nil", nil, false},
+ {"string", reflect.TypeOf(""), true},
+ {"bytes", reflect.TypeOf([]byte(nil)), true},
+ {"bool", reflect.TypeOf(true), true},
+ {"int", reflect.TypeOf(int(0)), true},
+ {"int64", reflect.TypeOf(int64(0)), true},
+ {"float64", reflect.TypeOf(float64(0)), true},
+ {"struct", reflect.TypeOf(struct{ A int }{}), false},
+ {"map", reflect.TypeOf(map[string]int{}), false},
+ {"slice_int", reflect.TypeOf([]int{}), false},
+ {"slice_string", reflect.TypeOf([]string{}), false},
+ }
+ for _, c := range cases {
+ c := c
+ t.Run(c.name, func(t *testing.T) {
+ if got := isBuiltinSizeable(c.t); got != c.want {
+ t.Errorf("isBuiltinSizeable(%v) = %v, want %v",
c.t, got, c.want)
+ }
+ })
+ }
+}