-
Notifications
You must be signed in to change notification settings - Fork 1.8k
AVRO-4300: [java] Bound array/map allocation and skipping for zero-byte elements and on the fast reader path #3865
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f535335
24d6455
57a247f
eee0f0a
67ae814
ac00650
f2c686f
e47f206
6bf53e3
d6fc66c
dc2250c
c2ab33b
178027d
a919875
d6ddf53
f8c3337
d5daffc
d5e57a3
112a3fa
2cdb920
59aa3c7
b3c547f
9d27ea3
bb0de73
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ | |
| import org.apache.avro.LogicalType; | ||
| import org.apache.avro.Schema; | ||
| import org.apache.avro.Schema.Field; | ||
| import org.apache.avro.SystemLimitException; | ||
| import org.apache.avro.io.DatumReader; | ||
| import org.apache.avro.io.Decoder; | ||
| import org.apache.avro.io.DecoderFactory; | ||
|
|
@@ -114,6 +115,26 @@ public void setExpected(Schema reader) { | |
| private static final ThreadLocal<Map<Schema, Map<Schema, ResolvingDecoder>>> RESOLVER_CACHE = ThreadLocalWithInitial | ||
| .of(WeakIdentityHashMap::new); | ||
|
|
||
| /** | ||
| * Upper bound on the initial capacity eagerly allocated for a collection from | ||
| * its declared block count. The backing array/map grows on demand as elements | ||
| * are read, so this is only a starting hint: it prevents a large declared count | ||
| * from driving a huge up-front allocation before any element is decoded. This | ||
|
Comment on lines
+118
to
+122
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is intentional. The clamp is only the initial capacity; the array/map still grows to hold every element actually read, and the growth is geometric (amortized O(n)), so for a legitimate large in-memory array it costs a handful of extra resizes rather than a single up-front allocation. Keeping it unconditional keeps the hot path simple and also covers the cases where it's essential — zero-byte elements (bounded by the 10M item cap, not by bytes, so remainingBytes doesn't constrain them) and stream sources (remainingBytes() == -1). A remaining/zero-byte-aware refinement could be added later if profiling shows the extra resizes matter, but functionally this is safe and the perf impact is negligible. |
||
| * matters most for stream sources, where the decoder cannot know how many bytes | ||
| * remain and so cannot otherwise bound the declared count against the input. | ||
| */ | ||
| private static final int MAX_COLLECTION_PREALLOC = 1024; | ||
|
|
||
| /** | ||
| * Clamp a declared collection block count to a safe initial allocation size. | ||
| * | ||
| * @param count the declared (already limit-checked) block count | ||
| * @return {@code count} capped at {@link #MAX_COLLECTION_PREALLOC} | ||
| */ | ||
| public static int initialCollectionCapacity(long count) { | ||
| return (int) Math.min(count, MAX_COLLECTION_PREALLOC); | ||
| } | ||
|
|
||
| /** | ||
| * Gets a resolving decoder for use by this GenericDatumReader. Unstable API. | ||
| * Currently uses a thread local cache to prevent constructing the resolvers too | ||
|
|
@@ -296,9 +317,20 @@ protected Object readArray(Object old, Schema expected, ResolvingDecoder in) thr | |
| long base = 0; | ||
| if (l > 0) { | ||
| ensureAvailableCollectionBytes(in, l, expectedType); | ||
| // Elements whose minimum encoded size is zero (null, a zero-length fixed, a | ||
| // record whose fields are all zero-byte, or a recursive schema where the | ||
| // cycle is broken with a 0 minimum) consume no guaranteed input, so | ||
| // ensureAvailableCollectionBytes cannot bound their count from the bytes | ||
| // remaining. Cap such collections against a heap-aware limit so a tiny | ||
| // payload cannot declare a huge block count and drive an unbounded | ||
| // backing-array allocation. | ||
| boolean zeroByteElements = minBytesPerElement(expectedType) == 0; | ||
| if (zeroByteElements) { | ||
| SystemLimitException.checkMaxCollectionAllocation(base, l); | ||
| } | ||
| LogicalType logicalType = expectedType.getLogicalType(); | ||
| Conversion<?> conversion = getData().getConversionFor(logicalType); | ||
| Object array = newArray(old, (int) l, expected); | ||
| Object array = newArray(old, initialCollectionCapacity(l), expected); | ||
| do { | ||
| if (logicalType != null && conversion != null) { | ||
| for (long i = 0; i < l; i++) { | ||
|
|
@@ -311,7 +343,11 @@ protected Object readArray(Object old, Schema expected, ResolvingDecoder in) thr | |
| } | ||
| } | ||
| base += l; | ||
| } while ((l = arrayNext(in, expectedType)) > 0); | ||
| l = arrayNext(in, expectedType); | ||
| if (zeroByteElements && l > 0) { | ||
| SystemLimitException.checkMaxCollectionAllocation(base, l); | ||
| } | ||
| } while (l > 0); | ||
| return pruneArray(array); | ||
| } else { | ||
| return pruneArray(newArray(old, 0, expected)); | ||
|
|
@@ -364,7 +400,7 @@ protected Object readMap(Object old, Schema expected, ResolvingDecoder in) throw | |
| LogicalType logicalType = eValue.getLogicalType(); | ||
| Conversion<?> conversion = getData().getConversionFor(logicalType); | ||
| ensureAvailableMapBytes(in, l, eValue); | ||
| Object map = newMap(old, (int) l); | ||
| Object map = newMap(old, initialCollectionCapacity(l)); | ||
| if (l > 0) { | ||
| do { | ||
| if (logicalType != null && conversion != null) { | ||
|
|
@@ -441,6 +477,23 @@ static int minBytesPerElement(Schema schema) { | |
| return minBytesPerElement(schema, Collections.newSetFromMap(new IdentityHashMap<>())); | ||
| } | ||
|
|
||
| /** | ||
| * Whether the minimum encoded size of the given schema is zero, i.e. | ||
| * {@link #minBytesPerElement(Schema)} is {@code 0}. This is true for values | ||
| * that always encode to zero bytes (e.g. {@code null}, a zero-length | ||
| * {@code fixed}, or a record whose fields are all zero-byte), and | ||
| * conservatively for recursive schemas, where the cycle is broken by returning | ||
| * a 0 minimum. Such elements cannot be bounded by the number of bytes remaining | ||
| * in the stream, so a collection of them must be bounded by a heap-aware | ||
| * allocation limit instead. | ||
| * | ||
| * @param schema the element (or map value) schema | ||
| * @return {@code true} if the schema's minimum encoded size is zero | ||
| */ | ||
| public static boolean isZeroByteSchema(Schema schema) { | ||
| return minBytesPerElement(schema) == 0; | ||
| } | ||
|
|
||
| private static int minBytesPerElement(Schema schema, Set<Schema> visited) { | ||
| switch (schema.getType()) { | ||
| case NULL: | ||
|
|
@@ -481,9 +534,11 @@ private static int minBytesPerElement(Schema schema, Set<Schema> visited) { | |
| * reports fewer remaining bytes than required. | ||
| * <p> | ||
| * This check prevents out-of-memory errors from pre-allocating huge backing | ||
| * arrays when the source data is truncated or malicious. | ||
| * arrays when the source data is truncated or malicious. It is exposed so the | ||
| * fast reader ({@code FastReaderBuilder}) can apply the same guard as this | ||
| * classic reader. | ||
| */ | ||
| private static void ensureAvailableCollectionBytes(Decoder decoder, long count, Schema elementSchema) | ||
| public static void ensureAvailableCollectionBytes(Decoder decoder, long count, Schema elementSchema) | ||
| throws EOFException { | ||
| if (count <= 0) { | ||
| return; | ||
|
|
@@ -747,15 +802,35 @@ public static void skip(Schema schema, Decoder in) throws IOException { | |
| break; | ||
| case ARRAY: | ||
| Schema elementType = schema.getElementType(); | ||
| // Bound the cumulative element count: skipping a huge block of elements | ||
| // whose minimum encoded size is zero (e.g. null) would otherwise loop | ||
| // unboundedly (a CPU exhaustion) even though it reads nothing. Such | ||
| // elements use the heap-aware allocation cap; others the structural | ||
| // collection cap. | ||
| boolean zeroByteElements = isZeroByteSchema(elementType); | ||
| long arrayTotal = 0; | ||
| for (long l = in.skipArray(); l > 0; l = in.skipArray()) { | ||
| // Always enforce the cumulative structural cap, then additionally the | ||
| // heap-aware allocation cap for zero-byte elements (which the structural | ||
| // cap alone does not bound tightly), so a huge count split across blocks | ||
| // cannot drive an unbounded skip loop. | ||
| SystemLimitException.checkMaxCollectionLength(arrayTotal, l); | ||
| if (zeroByteElements) { | ||
| SystemLimitException.checkMaxCollectionAllocation(arrayTotal, l); | ||
| } | ||
| arrayTotal += l; | ||
| for (long i = 0; i < l; i++) { | ||
| skip(elementType, in); | ||
| } | ||
| } | ||
| break; | ||
| case MAP: | ||
| Schema value = schema.getValueType(); | ||
| // Map entries always carry a >= 1 byte key, so the structural cap applies. | ||
| long mapTotal = 0; | ||
| for (long l = in.skipMap(); l > 0; l = in.skipMap()) { | ||
| SystemLimitException.checkMaxCollectionLength(mapTotal, l); | ||
| mapTotal += l; | ||
| for (long i = 0; i < l; i++) { | ||
| in.skipString(); | ||
| skip(value, in); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -408,8 +408,21 @@ protected void doReadBytes(byte[] bytes, int start, int length) throws IOExcepti | |
| protected long doReadItemCount() throws IOException { | ||
| long result = readLong(); | ||
| if (result < 0L) { | ||
| // Consume byte-count if present | ||
| readLong(); | ||
| // A negative block count is followed by a block byte-size; consume it. | ||
| final long bytecount = readLong(); | ||
| if (result == Long.MIN_VALUE) { | ||
|
Comment on lines
409
to
+413
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — doReadItemCount now reads the block byte-size into a variable and rejects a negative value, matching doSkipItems. |
||
| // Long.MIN_VALUE cannot be negated (-Long.MIN_VALUE overflows back to | ||
| // Long.MIN_VALUE), so it is not a valid block count. Reject it rather | ||
| // than letting it fall through as a negative "count" that would later be | ||
| // truncated to 0 and silently terminate the collection without consuming | ||
| // the end marker, desynchronizing decoding of subsequent fields. | ||
| throw new AvroRuntimeException("Malformed data. Block count is invalid: " + result); | ||
| } | ||
| if (bytecount < 0L) { | ||
| // The block byte-size is a byte count and must be non-negative, matching | ||
| // doSkipItems(). | ||
| throw new AvroRuntimeException("Malformed data. Block byte-size is negative: " + bytecount); | ||
| } | ||
| result = -result; | ||
| } | ||
| return result; | ||
|
|
@@ -436,7 +449,17 @@ protected long doReadItemCount() throws IOException { | |
| private long doSkipItems() throws IOException { | ||
| long result = readLong(); | ||
| while (result < 0L) { | ||
| if (result == Long.MIN_VALUE) { | ||
| // Consistent with doReadItemCount: Long.MIN_VALUE is not a valid block | ||
| // count (it cannot be negated), so reject it rather than treating it as | ||
| // a byte-sized block and continuing to skip. | ||
| readLong(); | ||
| throw new AvroRuntimeException("Malformed data. Block count is invalid: " + result); | ||
| } | ||
| final long bytecount = readLong(); | ||
| if (bytecount < 0L) { | ||
| throw new AvroRuntimeException("Malformed data. Block byte-size is negative: " + bytecount); | ||
| } | ||
| doSkipBytes(bytecount); | ||
| result = readLong(); | ||
| } | ||
|
|
@@ -458,7 +481,7 @@ public long arrayNext() throws IOException { | |
|
|
||
| @Override | ||
| public long skipArray() throws IOException { | ||
| return doSkipItems(); | ||
| return SystemLimitException.checkMaxCollectionLength(doSkipItems()); | ||
| } | ||
|
Comment on lines
482
to
485
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — doSkipItems now rejects a Long.MIN_VALUE block count (matching doReadItemCount) and a negative block byte-size, instead of silently continuing to skip. |
||
|
|
||
| @Override | ||
|
|
@@ -476,7 +499,7 @@ public long mapNext() throws IOException { | |
|
|
||
| @Override | ||
| public long skipMap() throws IOException { | ||
| return doSkipItems(); | ||
| return SystemLimitException.checkMaxCollectionLength(doSkipItems()); | ||
| } | ||
|
|
||
| @Override | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in f2c686f: maxCollectionAllocation is now clamped to MAX_ARRAY_VM_LIMIT when refreshing limits, so a configured/large-heap value stays consistent with the other collection caps.