Skip to content

AVRO-4300: [java] Bound array/map allocation and skipping for zero-byte elements and on the fast reader path#3865

Merged
RyanSkraba merged 25 commits into
apache:mainfrom
iemejia:AVRO-4300-java-collection-zero-byte
Jul 19, 2026
Merged

AVRO-4300: [java] Bound array/map allocation and skipping for zero-byte elements and on the fast reader path#3865
RyanSkraba merged 25 commits into
apache:mainfrom
iemejia:AVRO-4300-java-collection-zero-byte

Conversation

@iemejia

@iemejia iemejia commented Jul 12, 2026

Copy link
Copy Markdown
Member

What is the purpose of the change

Fixes AVRO-4300 (sub-task of AVRO-4292). When GenericDatumReader decodes an array it reads the block item count from the stream and pre-allocates the backing store (newArraynew Object[count]) before decoding any element. Several independent gaps let a tiny payload drive an unbounded allocation — or an unbounded skip loop — and exhaust the heap:

  1. Zero-byte elements (classic and fast reader). Elements whose schema encodes to zero bytes (null, a zero-length fixed, or a record with only zero-byte fields) consume no input, so ensureAvailableCollectionBytes (AVRO-4241) skips the check for them (minBytesPerElement == 0), and the collection-length cap is Integer.MAX_VALUE - 8 (a JVM array-size ceiling, not a memory budget). An array such as {"type":"array","items":"null"} declaring a block count of 200,000,000 is a ~6-byte payload that allocates a 200M-slot array (~1.6 GB).

  2. The fast reader never had the AVRO-4241 available-bytes guard at all. That change only modified the classic GenericDatumReader (and BinaryDecoder/Decoder/ValidatingDecoder); it never touched FastReaderBuilder, which is the default decode path (avro.io.fastread defaults to true). So on the default reader even a non-zero-byte array such as array<long> or array<int> with a huge block count and no data pre-allocated new GenericData.Array<>((int) count) and exhausted the heap. Only the classic reader was protected.

  3. The skip path was unbounded. BinaryDecoder.skipArray()/skipMap() returned doSkipItems() without applying the collection cap, and GenericDatumReader.skip() looped over that count. Skipping a huge block of zero-byte elements (e.g. a writer array<null> field absent from the reader schema, skipped during projection) could therefore loop unboundedly even though skipping reads and allocates nothing.

Fix (applied identically on the classic and fast reader paths)

  • Add SystemLimitException.checkMaxCollectionAllocation, a heap-aware cumulative cap for zero-byte elements (default maxMemory()/4/8 elements, overridable via the org.apache.avro.limits.collectionItems.maxAllocation system property, mirroring the existing decompression limit).
  • Expose GenericDatumReader.ensureAvailableCollectionBytes and apply it, together with the zero-byte cap, before allocating each array block in FastReaderBuilder, so the fast and classic readers enforce identical guards.
  • Bound the skip path: BinaryDecoder.skipArray()/skipMap() now apply the structural collection cap (checkMaxCollectionLength), covering the resolving-decoder projection skip on both reader types, and GenericDatumReader.skip() additionally bounds the cumulative count — using the heap-aware cap for zero-byte elements and the structural cap otherwise.
  • Maps were already safe against pre-allocation on both paths because each entry carries a string key of at least one byte (ensureAvailableMapBytes on the classic path; key reads consume bytes on the fast path).

Verifying this change

This change added tests and can be verified as follows:

  • Added SystemLimitException.checkMaxCollectionAllocation tests (single/cumulative/negative/overflow and heap-derived default).
  • Added a full matrix test asserting every collection kind is rejected (never OOM) with a huge block count and no data on both the fast (default) and classic readers: array<null>SystemLimitException; array<long>, array<int>, map<null>, map<long>EOFException (the full 5×2 set of combinations), plus a cumulative multi-block null case and a positive within-limit decode.
  • Added skip-path tests: skipArrayOfNullRejectsHugeCount, skipSmallNullArraySucceeds, skipMapRejectsHugeCount, and resolvingSkipOfHugeNullArrayFieldIsBounded (bounded on both readers under schema resolution).
  • Manually verified against a PoC (array<null>, block count 200,000,000) under -Xmx256m: rejected with a clean SystemLimitException (no allocation) instead of OutOfMemoryError, on both reader paths; legitimate collections within the limit still decode.
  • mvn -pl avro test for the generic and io packages passes (3860 tests, no regressions); Spotless and Checkstyle are clean.

Documentation

  • Does this pull request introduce a new feature? no
  • If yes, how is the feature documented? not applicable (adds the org.apache.avro.limits.collectionItems.maxAllocation system property, documented in SystemLimitException JavaDoc alongside the existing limit properties)

iemejia added 3 commits July 12, 2026 12:24
An array whose element schema encodes to zero bytes (null, a zero-length
fixed, or a record with only zero-byte fields) consumes no input per element,
so the number of elements a block declares cannot be bounded by the bytes
remaining in the stream. ensureAvailableCollectionBytes therefore skips the
check for such elements, and the collection-length cap is Integer.MAX_VALUE-8
(a VM array-size ceiling, not a memory budget). A tiny payload declaring a huge
block count of such elements (e.g. {"type":"array","items":"null"} with a
count of 200,000,000) drives an unbounded backing-array allocation and exhausts
the heap. This affects both the classic GenericDatumReader.readArray path and
the default fast-reader path (FastReaderBuilder), which had no collection guard
at all.

Add SystemLimitException.checkMaxCollectionAllocation, a heap-aware cumulative
cap (default: maxMemory()/4/8 elements, overridable via the
org.apache.avro.limits.collectionItems.maxAllocation system property, mirroring
the existing decompression limit). Enforce it before allocating in both reader
paths, keyed on GenericDatumReader.isZeroByteSchema so only the unbounded
zero-byte case is affected; all other element types remain bounded by
ensureAvailableCollectionBytes and are unchanged. Maps are already bounded
because each entry carries a string key of at least one byte.

Assisted-by: GitHub Copilot:claude-opus-4.8
…path

The fast reader (FastReaderBuilder, the default decode path) never received the
AVRO-4241 bytes-remaining guard: that change only touched the classic
GenericDatumReader. As a result an array of non-zero-byte elements with a huge
declared block count and no data (e.g. array<long>/array<int> with a count of
200,000,000) still pre-allocated new GenericData.Array<>((int) count) on the
default path and exhausted the heap.

Expose GenericDatumReader.ensureAvailableCollectionBytes and apply it, together
with the zero-byte allocation cap, before allocating each array block in
FastReaderBuilder, so the fast and classic readers enforce identical guards.
Maps were already safe on both paths (each entry carries a >=1-byte key).

Verified with a matrix of array<null|long|int> and map<null|long> at a huge
count under -Xmx256m: every combination is now rejected (SystemLimitException
for zero-byte elements, EOFException otherwise) on both reader paths instead of
OOM.

Assisted-by: GitHub Copilot:claude-opus-4.8
Add a matrix test asserting every collection kind is rejected (never OOM) with
a huge block count and no data, on both the fast (default) and classic reader:
array<null> via the heap-aware allocation cap (SystemLimitException) and
array<long>, array<int>, map<null>, map<long> via the bytes-remaining check
(EOFException) -- the full 5x2 set of combinations. Keep a cumulative
multi-block null test and a positive within-limit decode test on both readers.

Assisted-by: GitHub Copilot:claude-opus-4.8
@github-actions github-actions Bot added the Java Pull Requests for Java binding label Jul 12, 2026
iemejia added 3 commits July 12, 2026 13:16
The C and Python collection-limit tests cover a negative block count (abs(count)
zero-byte elements preceded by a block byte-size); the Java tests did not. The
decoder normalizes the negative count to a positive one, which must still be
bounded by the heap-aware allocation cap. Add a test asserting this on both the
fast and classic reader paths.

Assisted-by: GitHub Copilot:claude-opus-4.8
Mirror the C SDK's INT64_MIN edge case. Long.MIN_VALUE as a block count is the
pathological overflow: negating it overflows back to a negative value. Java's
decoder normalizes this to an empty collection (no allocation) rather than
rejecting it as the C SDK does, which is equally non-exploitable. Add a test
asserting the safe, allocation-free result on both the fast and classic reader
paths.

Assisted-by: GitHub Copilot:claude-opus-4.8
The skip path was unbounded: BinaryDecoder.skipArray()/skipMap() returned
doSkipItems() without calling checkMaxCollectionLength, and
GenericDatumReader.skip() looped over the returned count. Skipping a huge block
of zero-byte elements (e.g. a writer array<null> field absent from the reader
schema, skipped during projection) could therefore loop unboundedly -- a CPU
exhaustion even though skipping reads and allocates nothing.

Two complementary bounds:
 - BinaryDecoder.skipArray()/skipMap() now apply the structural collection cap
   (checkMaxCollectionLength), mirroring readArrayStart()/readMapStart() and
   covering the resolving-decoder projection skip path on both reader types.
 - GenericDatumReader.skip() additionally bounds the cumulative count, using the
   heap-aware allocation cap for zero-byte element arrays and the structural cap
   otherwise, matching the read path and the other language SDKs.

Assisted-by: GitHub Copilot:claude-opus-4.8
@iemejia iemejia changed the title AVRO-4300: [java] Bound array allocation for zero-byte elements and on the fast reader path AVRO-4300: [java] Bound array/map allocation and skipping for zero-byte elements and on the fast reader path Jul 12, 2026
@iemejia
iemejia requested a review from Copilot July 12, 2026 14:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses AVRO-4300 by adding heap-aware bounds for allocating and skipping collections whose elements encode to zero bytes (notably array<null> and similar zero-byte schemas), and by ensuring the fast reader path enforces the same guards as the classic GenericDatumReader path to prevent OOM and unbounded skip loops from tiny malicious payloads.

Changes:

  • Introduces SystemLimitException.checkMaxCollectionAllocation and a new system property (org.apache.avro.limits.collectionItems.maxAllocation) to cap allocations for zero-byte element arrays using a heap-derived default.
  • Applies the same “available bytes” / allocation guards to the fast reader array path and tightens BinaryDecoder.skipArray() / skipMap() with structural collection caps.
  • Adds regression tests covering fast vs classic readers, multi-block cumulative cases, negative block counts, and skip/projection behavior.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java Adds heap-aware allocation limit for zero-byte element arrays and new limit property.
lang/java/avro/src/main/java/org/apache/avro/generic/GenericDatumReader.java Enforces zero-byte allocation cap during array reads; exposes helpers; bounds skip loops cumulatively.
lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java Applies classic-reader guards (bytes remaining + zero-byte allocation cap) before fast-path array allocation.
lang/java/avro/src/main/java/org/apache/avro/io/BinaryDecoder.java Applies structural collection-length cap when skipping arrays/maps.
lang/java/avro/src/test/java/org/apache/avro/TestSystemLimitException.java Adds unit tests for checkMaxCollectionAllocation and clears the new property in cleanup.
lang/java/avro/src/test/java/org/apache/avro/generic/TestGenericDatumReader.java Adds end-to-end tests for huge collection rejection and bounded skip behavior on fast and classic readers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java Outdated
…M limit

Addresses review feedback:
 - Fix the malformed <li> markup in the limit-properties list (each item closed
   </li> prematurely after the <tt> tag) and correct the bytes property name
   (org.apache.avro.limits.bytes.maxLength) so the Javadoc renders correctly.
 - Clamp maxCollectionAllocation to MAX_ARRAY_VM_LIMIT when refreshing limits, so
   a configured (or large-heap-derived) zero-byte allocation cap stays consistent
   with the other collection caps and cannot exceed the VM array ceiling.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment on lines 33 to 35
* <li><tt>org.apache.avro.limits.collectionItems.maxLength</tt> limits the
* maximum number of <tt>map</tt> and <tt>list</tt> items that can be read at
* once single sequence.</li>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in e47f206: 'read at once single sequence' -> 'read in a single sequence'.

Comment thread lang/java/avro/src/test/java/org/apache/avro/TestSystemLimitException.java Outdated
…ministic

Addresses review feedback:
 - Javadoc: "read at once single sequence" -> "read in a single sequence".
 - testCheckMaxCollectionAllocation asserts with MAX_ARRAY_VM_LIMIT + 1 instead
   of Integer.MAX_VALUE - 8. Since the default allocation cap is derived from the
   heap and then clamped to MAX_ARRAY_VM_LIMIT, a value equal to that limit may
   not exceed the computed default on a very large heap; +1 guarantees it does,
   keeping the test deterministic across environments.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Comment thread lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java Outdated
Comment on lines +298 to +300
* Elements whose schema encodes to zero bytes (e.g. {@code null} or a
* self-referencing record) consume no input bytes, so the number that may be
* declared is not bounded by the bytes remaining in the stream. Without a cap,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 6bf53e3: same rewording applied to this occurrence.

Comment thread lang/java/avro/src/test/java/org/apache/avro/generic/TestGenericDatumReader.java Outdated
Comment thread lang/java/avro/src/test/java/org/apache/avro/generic/TestGenericDatumReader.java Outdated
iemejia added 2 commits July 12, 2026 20:56
Addresses review feedback:
 - Javadoc: "a self-referencing record" is not inherently zero-byte (the code
   only computes a 0-byte minimum for some recursive schemas to break recursion).
   Reword the three occurrences to describe actual zero-byte encodings: null, a
   zero-length fixed, or a record whose fields all encode to zero bytes.
 - skipMapRejectsHugeCount now asserts UnsupportedOperationException (a count of
   Integer.MAX_VALUE deterministically hits the VM structural-limit path), and
   resolvingSkipOfHugeNullArrayFieldIsBounded asserts SystemLimitException, so the
   tests pin the intended exception rather than a generic RuntimeException.

Assisted-by: GitHub Copilot:claude-opus-4.8
The zero-byte Javadoc rewording did not match the Eclipse formatter's
line-wrapping, failing the spotless check. Reflowed via `mvn spotless:apply`.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Comment thread lang/java/avro/src/main/java/org/apache/avro/generic/GenericDatumReader.java Outdated
Comment thread lang/java/avro/src/main/java/org/apache/avro/generic/GenericDatumReader.java Outdated
* instead.
*
* @param schema the element (or map value) schema
* @return {@code true} if the schema encodes to zero bytes

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated the @return in dc2250c to 'true if the schema's minimum encoded size is zero'.

Comment thread lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java Outdated
…icate

Addresses review feedback: the "encodes to zero bytes" wording in
GenericDatumReader (the array cap comment and isZeroByteSchema Javadoc) and
FastReaderBuilder is imprecise. The guard is minBytesPerElement(schema) == 0,
which is also true for recursive schemas whose cycle is broken by returning a 0
minimum. Reword to describe the "minimum encoded size is zero" predicate so the
docs match the actual condition under which the heap-aware cap applies.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment on lines +492 to +496
* {@code Long.MIN_VALUE} as a block count is the pathological overflow case:
* negating it overflows back to a negative value. It must be handled safely
* without allocating -- the decoder normalizes it to an empty collection rather
* than a huge one -- on both reader paths. (The C SDK rejects it outright; this
* normalization is equally non-exploitable.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You're right — this was a genuine desync, not merely an empty-collection normalization. doReadItemCount() negates the count, but Long.MIN_VALUE overflows back to Long.MIN_VALUE, and the single-arg checkMaxCollectionLength doesn't reject negatives, so (int) Long.MIN_VALUE == 0 silently ended the collection without consuming the end marker. Fixed in c2ab33b by rejecting Long.MIN_VALUE block counts as malformed (AvroRuntimeException), matching the C SDK. The test now asserts the rejection on both reader paths.

Addresses review feedback: doReadItemCount() negates a negative block count, but
Long.MIN_VALUE negates back to Long.MIN_VALUE (still negative). The single-arg
checkMaxCollectionLength does not reject negatives, so it was truncated via
(int) cast to 0, silently ending the collection without consuming the
end-of-array/map marker and desynchronizing decoding of subsequent fields.

Reject Long.MIN_VALUE outright as malformed, matching the C SDK, instead of
normalizing to an empty collection. Update the test to assert the rejection.

Assisted-by: GitHub Copilot:claude-opus-4.8
@iemejia
iemejia requested a review from Copilot July 12, 2026 19:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comment thread lang/java/avro/src/main/java/org/apache/avro/io/BinaryDecoder.java
doReadItemCount consumed the block byte-size for a negative-count block without
validating it. doSkipItems now rejects a negative byte-size, so the read path
does the same for consistency and to reject malformed encodings.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

lang/java/avro/src/main/java/org/apache/avro/SystemLimitException.java:48

  • The class-level Javadoc says all limits default to permitting sizes up to MAX_ARRAY_VM_LIMIT, but MAX_COLLECTION_ALLOCATION_PROPERTY defaults to a heap-derived fraction (then clamped). Updating this sentence will keep the public docs accurate for the new allocation cap.
 *
 * The default is to permit sizes up to {@link #MAX_ARRAY_VM_LIMIT}.
 */

…ata"

The huge-collection matrix payload includes a trailing 0L varint (an element
value for arrays, a 0-length key for maps); reword "no element data" to reflect
that.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Reword the DEFAULT_MAX_COLLECTION_ALLOCATION_HEAP_FRACTION Javadoc from
"zero-byte elements" to "elements whose minimum encoded size is zero".

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

arrayHugeCountOnStreamClampsPreallocation only asserted an EOFException, which a
reintroduced new Object[(int) count] preallocation could still satisfy on a
large-heap JVM (allocating ~200M slots then hitting EOF). Override newArray to
record the largest requested capacity and assert it stays clamped to
initialCollectionCapacity, and assert remainingBytes() == -1 to confirm the
unknown-remaining-bytes precondition.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

@iemejia
iemejia requested a review from RyanSkraba July 13, 2026 09:31

@RyanSkraba RyanSkraba left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I've read through this -- good and necessary work with only minor nit-picks while I was reading, and nothing that would block this PR from being merged as it is.

I definitely don't have any objection to LLM assisted pull requests, but this one has a lot of repetitive comments and so many detailed unit tests to read... there's probably some work to be done in choosing a judicious balance!

Comment thread lang/java/avro/src/main/java/org/apache/avro/generic/GenericDatumReader.java Outdated
Comment thread lang/java/avro/src/main/java/org/apache/avro/io/BinaryDecoder.java
Comment thread lang/java/avro/src/main/java/org/apache/avro/io/BinaryDecoder.java Outdated
Comment thread lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java Outdated
…ck-count guards

Apply RyanSkraba's review feedback:
- GenericDatumReader.readArray uses the isZeroByteSchema helper instead
  of inlining minBytesPerElement == 0, matching skip().
- BinaryDecoder.doReadItemCount rejects Long.MIN_VALUE before consuming
  the block byte-size, so no readLong runs once the count is invalid.
- BinaryDecoder.doSkipItems drops the readLong before throwing on an
  invalid Long.MIN_VALUE count, consistent with doReadItemCount.
- FastReaderBuilder.createArrayReader drops the redundant comment;
  the rationale is documented on checkArrayBlock.
@RyanSkraba
RyanSkraba merged commit 61270d4 into apache:main Jul 19, 2026
9 checks passed
RyanSkraba pushed a commit that referenced this pull request Jul 19, 2026
…te elements and on the fast reader path (#3865)

* AVRO-4300: [java] Bound zero-byte array element allocation

An array whose element schema encodes to zero bytes (null, a zero-length
fixed, or a record with only zero-byte fields) consumes no input per element,
so the number of elements a block declares cannot be bounded by the bytes
remaining in the stream. ensureAvailableCollectionBytes therefore skips the
check for such elements, and the collection-length cap is Integer.MAX_VALUE-8
(a VM array-size ceiling, not a memory budget). A tiny payload declaring a huge
block count of such elements (e.g. {"type":"array","items":"null"} with a
count of 200,000,000) drives an unbounded backing-array allocation and exhausts
the heap. This affects both the classic GenericDatumReader.readArray path and
the default fast-reader path (FastReaderBuilder), which had no collection guard
at all.

Add SystemLimitException.checkMaxCollectionAllocation, a heap-aware cumulative
cap (default: maxMemory()/4/8 elements, overridable via the
org.apache.avro.limits.collectionItems.maxAllocation system property, mirroring
the existing decompression limit). Enforce it before allocating in both reader
paths, keyed on GenericDatumReader.isZeroByteSchema so only the unbounded
zero-byte case is affected; all other element types remain bounded by
ensureAvailableCollectionBytes and are unchanged. Maps are already bounded
because each entry carries a string key of at least one byte.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Apply the available-bytes check on the fast reader path

The fast reader (FastReaderBuilder, the default decode path) never received the
AVRO-4241 bytes-remaining guard: that change only touched the classic
GenericDatumReader. As a result an array of non-zero-byte elements with a huge
declared block count and no data (e.g. array<long>/array<int> with a count of
200,000,000) still pre-allocated new GenericData.Array<>((int) count) on the
default path and exhausted the heap.

Expose GenericDatumReader.ensureAvailableCollectionBytes and apply it, together
with the zero-byte allocation cap, before allocating each array block in
FastReaderBuilder, so the fast and classic readers enforce identical guards.
Maps were already safe on both paths (each entry carries a >=1-byte key).

Verified with a matrix of array<null|long|int> and map<null|long> at a huge
count under -Xmx256m: every combination is now rejected (SystemLimitException
for zero-byte elements, EOFException otherwise) on both reader paths instead of
OOM.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Cover all collection/reader combinations in tests

Add a matrix test asserting every collection kind is rejected (never OOM) with
a huge block count and no data, on both the fast (default) and classic reader:
array<null> via the heap-aware allocation cap (SystemLimitException) and
array<long>, array<int>, map<null>, map<long> via the bytes-remaining check
(EOFException) -- the full 5x2 set of combinations. Keep a cumulative
multi-block null test and a positive within-limit decode test on both readers.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Test negative block count is bounded on both readers

The C and Python collection-limit tests cover a negative block count (abs(count)
zero-byte elements preceded by a block byte-size); the Java tests did not. The
decoder normalizes the negative count to a positive one, which must still be
bounded by the heap-aware allocation cap. Add a test asserting this on both the
fast and classic reader paths.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Test Long.MIN_VALUE block count is handled safely

Mirror the C SDK's INT64_MIN edge case. Long.MIN_VALUE as a block count is the
pathological overflow: negating it overflows back to a negative value. Java's
decoder normalizes this to an empty collection (no allocation) rather than
rejecting it as the C SDK does, which is equally non-exploitable. Add a test
asserting the safe, allocation-free result on both the fast and classic reader
paths.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Bound the array/map skip path

The skip path was unbounded: BinaryDecoder.skipArray()/skipMap() returned
doSkipItems() without calling checkMaxCollectionLength, and
GenericDatumReader.skip() looped over the returned count. Skipping a huge block
of zero-byte elements (e.g. a writer array<null> field absent from the reader
schema, skipped during projection) could therefore loop unboundedly -- a CPU
exhaustion even though skipping reads and allocates nothing.

Two complementary bounds:
 - BinaryDecoder.skipArray()/skipMap() now apply the structural collection cap
   (checkMaxCollectionLength), mirroring readArrayStart()/readMapStart() and
   covering the resolving-decoder projection skip path on both reader types.
 - GenericDatumReader.skip() additionally bounds the cumulative count, using the
   heap-aware allocation cap for zero-byte element arrays and the structural cap
   otherwise, matching the read path and the other language SDKs.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Fix limit Javadoc markup; clamp allocation cap to VM limit

Addresses review feedback:
 - Fix the malformed <li> markup in the limit-properties list (each item closed
   </li> prematurely after the <tt> tag) and correct the bytes property name
   (org.apache.avro.limits.bytes.maxLength) so the Javadoc renders correctly.
 - Clamp maxCollectionAllocation to MAX_ARRAY_VM_LIMIT when refreshing limits, so
   a configured (or large-heap-derived) zero-byte allocation cap stays consistent
   with the other collection caps and cannot exceed the VM array ceiling.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Fix limit Javadoc grammar; make heap-cap test deterministic

Addresses review feedback:
 - Javadoc: "read at once single sequence" -> "read in a single sequence".
 - testCheckMaxCollectionAllocation asserts with MAX_ARRAY_VM_LIMIT + 1 instead
   of Integer.MAX_VALUE - 8. Since the default allocation cap is derived from the
   heap and then clamped to MAX_ARRAY_VM_LIMIT, a value equal to that limit may
   not exceed the computed default on a very large heap; +1 guarantees it does,
   keeping the test deterministic across environments.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Reword zero-byte Javadoc; tighten skip test assertions

Addresses review feedback:
 - Javadoc: "a self-referencing record" is not inherently zero-byte (the code
   only computes a 0-byte minimum for some recursive schemas to break recursion).
   Reword the three occurrences to describe actual zero-byte encodings: null, a
   zero-length fixed, or a record whose fields all encode to zero bytes.
 - skipMapRejectsHugeCount now asserts UnsupportedOperationException (a count of
   Integer.MAX_VALUE deterministically hits the VM structural-limit path), and
   resolvingSkipOfHugeNullArrayFieldIsBounded asserts SystemLimitException, so the
   tests pin the intended exception rather than a generic RuntimeException.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Apply spotless formatting to reworded Javadoc

The zero-byte Javadoc rewording did not match the Eclipse formatter's
line-wrapping, failing the spotless check. Reflowed via `mvn spotless:apply`.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Clarify zero-byte comments to match the actual predicate

Addresses review feedback: the "encodes to zero bytes" wording in
GenericDatumReader (the array cap comment and isZeroByteSchema Javadoc) and
FastReaderBuilder is imprecise. The guard is minBytesPerElement(schema) == 0,
which is also true for recursive schemas whose cycle is broken by returning a 0
minimum. Reword to describe the "minimum encoded size is zero" predicate so the
docs match the actual condition under which the heap-aware cap applies.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Reject Long.MIN_VALUE block count as malformed

Addresses review feedback: doReadItemCount() negates a negative block count, but
Long.MIN_VALUE negates back to Long.MIN_VALUE (still negative). The single-arg
checkMaxCollectionLength does not reject negatives, so it was truncated via
(int) cast to 0, silently ending the collection without consuming the
end-of-array/map marker and desynchronizing decoding of subsequent fields.

Reject Long.MIN_VALUE outright as malformed, matching the C SDK, instead of
normalizing to an empty collection. Update the test to assert the rejection.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Clamp collection preallocation from declared block count

The array/map readers passed the declared block count straight to newArray/
newMap as the initial capacity. On a stream source the bytes-available guard is
skipped (BinaryDecoder.remainingBytes() returns -1 for sources other than
ByteArrayInputStream/ByteBufferInputStream), so a large declared count reaches
the allocation path directly and drives a huge up-front allocation (e.g.
new Object[count]) before a single element is read.

Clamp the initial capacity to a modest bound (MAX_COLLECTION_PREALLOC) via
initialCollectionCapacity(); the backing array/map still grows on demand as
elements are decoded, so a truncated or hostile stream now fails with
EOFException after a bounded allocation instead of attempting to preallocate
hundreds of millions of slots. Applies to both the classic and fast readers.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Reject out-of-range union and enum indices with AvroTypeException

The union branch index and enum symbol index were used to index the branch/
symbol tables without an explicit range check on several paths, surfacing a
malformed index as a generic IndexOutOfBoundsException instead of a clear
Avro-specific error:

- Union: Symbol.Alternative.getSymbol (used by both the validating and resolving
  decoders, and therefore by GenericDatumReader and the fast reader) indexed
  symbols[] directly. Add a [0, size) check throwing AvroTypeException.
- Enum: ResolvingDecoder.readEnum returned the raw index in the no-adjustments
  case and indexed the adjustment table otherwise, both without validation
  (ValidatingDecoder.readEnum already checked). Add the range checks.
- The fast reader reads from a plain decoder (resolution is pre-baked), so its
  union and enum readers need their own [0, length) checks.

Adds tests covering negative and too-large union and enum indices on both the
classic and fast reader paths.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Reword remaining "zero-byte" docs to the actual predicate

The maxAllocation limit is applied when a schema's minimum encoded size is zero
(GenericDatumReader.isZeroByteSchema, i.e. minBytesPerElement == 0), which also
covers recursive schemas whose cycle is conservatively broken with a 0 minimum.
Reword the remaining "encodes to zero bytes" wording -- the class Javadoc, the
MAX_COLLECTION_ALLOCATION_PROPERTY Javadoc, the SystemLimitException message, and
the skipArray comment -- to say "minimum encoded size is zero" so the docs match
the condition under which the cap actually applies.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Half-open range in enum messages; finish zero-byte reword

Address review feedback:
- The enum out-of-range messages said "max is <size>", implying <size> is a
  valid index. Reword to the half-open "must be in [0, <size>)" to match the
  actual check and the union messages, in ResolvingDecoder (both the
  no-adjustments and adjustments branches), FastReaderBuilder, and
  ValidatingDecoder.
- Reword the remaining "encodes to zero bytes" wording in the
  checkMaxCollectionAllocation Javadoc to "minimum encoded size is zero"
  (including the recursive-schema case), matching isZeroByteSchema.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Enforce structural cap when skipping zero-byte arrays; tidy

Address review feedback:
- skip(ARRAY) bounded zero-byte-element arrays only by the heap-aware allocation
  cap, so the configurable structural collection limit
  (MAX_COLLECTION_LENGTH_PROPERTY) was not enforced cumulatively and a large
  count split into many blocks could drive a long skip loop. Always enforce
  checkMaxCollectionLength cumulatively, and additionally
  checkMaxCollectionAllocation for zero-byte schemas.
- FastReaderBuilder.checkArrayBlock: skip the ensureAvailableCollectionBytes
  call for zero-byte elements (it recomputes minBytesPerElement and no-ops),
  applying the allocation cap directly instead.
- Reword the checkMaxCollectionAllocation summary line from "zero-byte-encoded"
  to "minimum encoded size is zero".

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Reword defaultMaxCollectionAllocation Javadoc

Update the last "zero-byte-encoded" wording (the private
defaultMaxCollectionAllocation helper) to "minimum encoded size is zero",
consistent with the public property/Javadoc and the isZeroByteSchema predicate.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Reword last zero-byte field doc; sort test imports

- Reword the maxCollectionAllocation field Javadoc from "zero-byte-encoded" to
  "minimum encoded size is zero".
- Sort the java.io imports in TestGenericDatumReader (BufferedInputStream before
  the ByteArray* imports) to satisfy Spotless.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Reject Long.MIN_VALUE and negative byte-size in doSkipItems

doSkipItems accepted a Long.MIN_VALUE block count (treating it as a byte-sized
block and continuing to skip), inconsistent with doReadItemCount which rejects
it. Reject Long.MIN_VALUE, and also reject a negative block byte-size, so the
skip path fails fast on malformed input like the read path.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Reject negative block byte-size in doReadItemCount

doReadItemCount consumed the block byte-size for a negative-count block without
validating it. doSkipItems now rejects a negative byte-size, so the read path
does the same for consistency and to reject malformed encodings.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Clarify test Javadoc: trailing 0L varint, not "no data"

The huge-collection matrix payload includes a trailing 0L varint (an element
value for arrays, a 0-length key for maps); reword "no element data" to reflect
that.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Reword heap-fraction Javadoc to the actual predicate

Reword the DEFAULT_MAX_COLLECTION_ALLOCATION_HEAP_FRACTION Javadoc from
"zero-byte elements" to "elements whose minimum encoded size is zero".

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Make array preallocation-clamp test regression-proof

arrayHugeCountOnStreamClampsPreallocation only asserted an EOFException, which a
reintroduced new Object[(int) count] preallocation could still satisfy on a
large-heap JVM (allocating ~200M slots then hitting EOF). Override newArray to
record the largest requested capacity and assert it stays clamped to
initialCollectionCapacity, and assert remainingBytes() == -1 to confirm the
unknown-remaining-bytes precondition.

Assisted-by: GitHub Copilot:claude-opus-4.8

* AVRO-4300: [java] Address review nits: use isZeroByteSchema, tidy block-count guards

Apply RyanSkraba's review feedback:
- GenericDatumReader.readArray uses the isZeroByteSchema helper instead
  of inlining minBytesPerElement == 0, matching skip().
- BinaryDecoder.doReadItemCount rejects Long.MIN_VALUE before consuming
  the block byte-size, so no readLong runs once the count is invalid.
- BinaryDecoder.doSkipItems drops the readLong before throwing on an
  invalid Long.MIN_VALUE count, consistent with doReadItemCount.
- FastReaderBuilder.createArrayReader drops the redundant comment;
  the rationale is documented on checkArrayBlock.
@RyanSkraba

Copy link
Copy Markdown
Contributor

Cherry-picked to branch-1.12.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Java Pull Requests for Java binding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants