Skip to content

Commit 84eccf2

Browse files
CesarCoelhoclaude
andcommitted
Stream Blob content during encoding instead of materialising it
encodeBlob previously called Blob.getValue(), which loads the entire content into a single byte array before writing. For large (URL/file- backed) Blobs this holds the whole payload in memory and fails outright above ~2 GB (the Java array limit). Add a streaming path: - Blob: getAsStream() (lazy InputStream over the URL/file or wrapped array) and getLengthLong() (long length, e.g. File.length()), plus the File constructor already added. - StreamHolder: writeStream(InputStream, long) with a materialising default (reads fully then writeBytes) so non-overriding encodings (string, xml) are unchanged. - BaseBinaryStreamHolder / FixedBinaryStreamHolder override writeStream to copy the content in fixed-size chunks after the length prefix, so the payload is never fully held in memory. A guard rejects lengths above the 32-bit length field with a clear message. - Encoder.encodeBlob now streams via writeStream(getAsStream(), getLengthLong()). Add LargeBlobTest, an @ignore'd probe that generates a 3 GB file and attempts to encode/decode it, documenting the remaining 32-bit length field limitation. It is disabled so it does not break CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent df67bb4 commit 84eccf2

6 files changed

Lines changed: 265 additions & 1 deletion

File tree

apis/api-area001-v003-mal/src/main/java/org/ccsds/moims/mo/mal/encoding/Encoder.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,10 @@ public void encodeBlob(final Blob value) throws MALException {
439439
} else {
440440
checkForNull(value.getValue());
441441
}
442-
outputStream.writeBytes(value.getValue());
442+
// Stream the content instead of materialising it into a single byte
443+
// array, so that large (URL/file-backed) Blobs are not loaded fully
444+
// into memory during encoding.
445+
outputStream.writeStream(value.getAsStream(), value.getLengthLong());
443446
} catch (IOException ex) {
444447
throw new MALException(ENCODING_EXCEPTION_STR, ex);
445448
}

apis/api-area001-v003-mal/src/main/java/org/ccsds/moims/mo/mal/encoding/StreamHolder.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
*/
2121
package org.ccsds.moims.mo.mal.encoding;
2222

23+
import java.io.ByteArrayOutputStream;
2324
import java.io.IOException;
25+
import java.io.InputStream;
2426
import java.io.OutputStream;
2527
import java.math.BigInteger;
2628

@@ -153,6 +155,29 @@ public StreamHolder(OutputStream outputStream) {
153155
*/
154156
public abstract void writeBytes(final byte[] value) throws IOException;
155157

158+
/**
159+
* Adds a length-prefixed byte block to the output stream, streamed from an
160+
* input source without holding the whole payload in memory. The default
161+
* implementation reads the input fully and delegates to
162+
* {@link #writeBytes(byte[])} for backwards compatibility; sub-classes that
163+
* can stream should override this. The input stream is always closed.
164+
*
165+
* @param input the source of the bytes to encode.
166+
* @param length the number of bytes provided by the input.
167+
* @throws IOException is there is a problem adding the value to the stream.
168+
*/
169+
public void writeStream(final InputStream input, final long length) throws IOException {
170+
try (InputStream in = input) {
171+
final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
172+
final byte[] chunk = new byte[8192];
173+
int read;
174+
while ((read = in.read(chunk)) != -1) {
175+
buffer.write(chunk, 0, read);
176+
}
177+
writeBytes(buffer.toByteArray());
178+
}
179+
}
180+
156181
/**
157182
* Adds a byte to the output stream.
158183
*

apis/api-area001-v003-mal/src/main/java/org/ccsds/moims/mo/mal/structures/Blob.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
*/
2121
package org.ccsds.moims.mo.mal.structures;
2222

23+
import java.io.ByteArrayInputStream;
2324
import java.io.ByteArrayOutputStream;
2425
import java.io.File;
26+
import java.io.IOException;
2527
import java.io.InputStream;
2628
import java.net.URL;
2729
import java.util.Arrays;
@@ -157,6 +159,58 @@ public int getLength() {
157159
return length;
158160
}
159161

162+
/**
163+
* Returns the content of this Blob as a stream, without loading the whole
164+
* content into memory. For a URL-based Blob the stream is opened lazily over
165+
* the designated resource; for a byte array based Blob the stream reads from
166+
* the wrapped array. The returned stream should be closed by the caller.
167+
*
168+
* @return The Blob content as an input stream.
169+
* @throws MALException if the stream could not be opened.
170+
*/
171+
public InputStream getAsStream() throws MALException {
172+
if (isURLBased()) {
173+
try {
174+
return new URL(uvalue).openStream();
175+
} catch (IOException ex) {
176+
throw new MALException("The Blob URL stream could not be opened: " + uvalue, ex);
177+
}
178+
}
179+
180+
return new ByteArrayInputStream((value != null) ? value : new byte[0]);
181+
}
182+
183+
/**
184+
* Returns the length of the Blob content as a {@code long}, allowing lengths
185+
* larger than {@link Integer#MAX_VALUE}. For a URL-based Blob the length is
186+
* that of the designated resource; for a byte array based Blob it is the
187+
* length of the wrapped array.
188+
*
189+
* @return The length of the Blob content in bytes.
190+
* @throws MALException if the length could not be determined.
191+
*/
192+
public long getLengthLong() throws MALException {
193+
if (isURLBased()) {
194+
try {
195+
final URL url = new URL(uvalue);
196+
197+
if ("file".equals(url.getProtocol())) {
198+
return new File(url.toURI()).length();
199+
}
200+
201+
final long len = url.openConnection().getContentLengthLong();
202+
if (len < 0) {
203+
throw new MALException("The Blob URL length is unknown: " + uvalue);
204+
}
205+
return len;
206+
} catch (IOException | java.net.URISyntaxException ex) {
207+
throw new MALException("The Blob URL length could not be determined: " + uvalue, ex);
208+
}
209+
}
210+
211+
return (value != null) ? value.length : 0;
212+
}
213+
160214
/**
161215
* Returns the URL of this Blob.
162216
*

encodings/encoding-binary/src/main/java/esa/mo/mal/encoder/binary/base/BaseBinaryEncoder.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
package esa.mo.mal.encoder.binary.base;
2222

2323
import java.io.IOException;
24+
import java.io.InputStream;
2425
import java.io.OutputStream;
2526
import org.ccsds.moims.mo.mal.MALException;
2627
import org.ccsds.moims.mo.mal.encoding.Encoder;
@@ -121,6 +122,45 @@ public void writeBytes(final byte[] value) throws IOException {
121122
}
122123
}
123124

125+
@Override
126+
public void writeStream(final InputStream input, final long length) throws IOException {
127+
if (length > Integer.MAX_VALUE) {
128+
throw new IOException("Blob is too large to encode: " + length
129+
+ " bytes exceeds the maximum of " + Integer.MAX_VALUE
130+
+ " supported by this encoding's 32-bit length field");
131+
}
132+
writeUnsignedInt((int) length);
133+
copyStream(input, length);
134+
}
135+
136+
/**
137+
* Copies exactly {@code length} bytes from the input to the output stream
138+
* in fixed-size chunks, without holding the whole payload in memory. The
139+
* input stream is always closed. The length prefix must already have been
140+
* written by the caller.
141+
*
142+
* @param input the source of the bytes to copy.
143+
* @param length the number of bytes to copy.
144+
* @throws IOException if there is a problem reading or writing, or if the
145+
* input ends before {@code length} bytes have been copied.
146+
*/
147+
protected void copyStream(final InputStream input, final long length) throws IOException {
148+
try (InputStream in = input) {
149+
final byte[] chunk = new byte[65536];
150+
long remaining = length;
151+
int read;
152+
while (remaining > 0
153+
&& (read = in.read(chunk, 0, (int) Math.min(chunk.length, remaining))) != -1) {
154+
write(chunk, 0, read);
155+
remaining -= read;
156+
}
157+
if (remaining != 0) {
158+
throw new IOException("Blob stream ended early: " + remaining
159+
+ " of " + length + " bytes were missing");
160+
}
161+
}
162+
}
163+
124164
@Override
125165
public void writeString(String value) throws IOException {
126166
writeBytes(value.getBytes(UTF8_CHARSET));

encodings/encoding-binary/src/main/java/esa/mo/mal/encoder/binary/fixed/FixedBinaryEncoder.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import esa.mo.mal.encoder.binary.base.BinaryTimeHandler;
2424
import java.io.IOException;
25+
import java.io.InputStream;
2526
import java.io.OutputStream;
2627
import java.math.BigInteger;
2728
import org.ccsds.moims.mo.mal.encoding.StreamHolder;
@@ -165,5 +166,21 @@ public void writeBytes(final byte[] value) throws IOException {
165166
write(value);
166167
}
167168
}
169+
170+
@Override
171+
public void writeStream(final InputStream input, final long length) throws IOException {
172+
final long max = shortLengthField ? 0xFFFFL : Integer.MAX_VALUE;
173+
if (length > max) {
174+
throw new IOException("Blob is too large to encode: " + length
175+
+ " bytes exceeds the maximum of " + max
176+
+ " supported by this encoding's length field");
177+
}
178+
if (shortLengthField) {
179+
writeUnsignedShort((int) length);
180+
} else {
181+
writeUnsignedInt((int) length);
182+
}
183+
copyStream(input, length);
184+
}
168185
}
169186
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/* ----------------------------------------------------------------------------
2+
* Copyright (C) 2026 European Space Agency
3+
* European Space Operations Centre
4+
* Darmstadt
5+
* Germany
6+
* ----------------------------------------------------------------------------
7+
* System : CCSDS MO Transport Framework
8+
* ----------------------------------------------------------------------------
9+
* Licensed under the European Space Agency Public License, Version 2.0
10+
* You may not use this file except in compliance with the License.
11+
*
12+
* Except as expressly set forth in this License, the Software is provided to
13+
* You on an "as is" basis and without warranties of any kind, including without
14+
* limitation merchantability, fitness for a particular purpose, absence of
15+
* defects or errors, accuracy or non-infringement of intellectual property rights.
16+
*
17+
* See the License for the specific language governing permissions and
18+
* limitations under the License.
19+
* ----------------------------------------------------------------------------
20+
*/
21+
package esa.mo.mal.encoders;
22+
23+
import esa.mo.mal.encoder.binary.base.BinaryTimeHandler;
24+
import esa.mo.mal.encoder.binary.fixed.FixedBinaryDecoder;
25+
import esa.mo.mal.encoder.binary.fixed.FixedBinaryEncoder;
26+
import java.io.BufferedInputStream;
27+
import java.io.BufferedOutputStream;
28+
import java.io.File;
29+
import java.io.FileInputStream;
30+
import java.io.FileOutputStream;
31+
import java.io.InputStream;
32+
import java.io.OutputStream;
33+
import java.util.Random;
34+
import org.ccsds.moims.mo.mal.structures.Blob;
35+
import org.ccsds.moims.mo.mal.structures.Element;
36+
import static org.junit.Assume.assumeTrue;
37+
import org.junit.Ignore;
38+
import org.junit.Test;
39+
40+
/**
41+
* Probes whether a Blob larger than 2 GB can be encoded and decoded:
42+
* generate a 3 GB file, wrap it in a Blob, encode, then decode.
43+
*/
44+
public class LargeBlobTest {
45+
46+
private static final long SIZE = 3L * 1024 * 1024 * 1024; // 3 GB (on purpose > 2 GB)
47+
48+
@Ignore("A Blob larger than 2 GB cannot yet be encoded (the length prefix is a "
49+
+ "32-bit field); this probe is expected to fail until that is addressed. "
50+
+ "Enable it manually to reproduce the limitation.")
51+
@Test
52+
public void encodeThenDecode3GBBlob() throws Exception {
53+
File dir = new File("target/large-blob-test");
54+
dir.mkdirs();
55+
File srcFile = new File(dir, "blob-3gb.bin");
56+
File encodedFile = new File(dir, "encoded.bin");
57+
58+
// Pre-flight resource checks. This test deliberately uses a 3 GB Blob to
59+
// exercise the >2 GB limitation. Without enough heap or disk it would fail
60+
// for the wrong reason (a heap OutOfMemoryError, or "No space left on
61+
// device") which would mask the actual Blob limitation. In that case,
62+
// alert and skip the test rather than report a misleading failure.
63+
long maxHeap = Runtime.getRuntime().maxMemory();
64+
long freeDisk = dir.getUsableSpace();
65+
66+
if (maxHeap < SIZE) {
67+
System.err.println("ALERT: skipping - not enough heap for a " + SIZE
68+
+ " byte Blob. Need -Xmx >= " + SIZE + " bytes, have " + maxHeap
69+
+ ". The test would fail with a heap OutOfMemoryError for lack of RAM,"
70+
+ " not because of the Blob 2 GB limitation.");
71+
}
72+
if (freeDisk < SIZE) {
73+
System.err.println("ALERT: skipping - not enough free disk to generate a " + SIZE
74+
+ " byte file in " + dir.getAbsolutePath() + ". Need " + SIZE
75+
+ " bytes, have " + freeDisk + ". The test would fail while writing the"
76+
+ " file, not because of the Blob limitation.");
77+
}
78+
assumeTrue("Not enough heap (-Xmx) for a 3 GB Blob", maxHeap >= SIZE);
79+
assumeTrue("Not enough free disk for a 3 GB file", freeDisk >= SIZE);
80+
81+
try {
82+
// 1. Generate a 3 GB file with random content
83+
System.out.println("[1] Generating " + SIZE + " byte random file...");
84+
byte[] chunk = new byte[8 * 1024 * 1024];
85+
Random rnd = new Random(42);
86+
long written = 0;
87+
try (OutputStream os = new BufferedOutputStream(new FileOutputStream(srcFile))) {
88+
while (written < SIZE) {
89+
rnd.nextBytes(chunk);
90+
int toWrite = (int) Math.min(chunk.length, SIZE - written);
91+
os.write(chunk, 0, toWrite);
92+
written += toWrite;
93+
}
94+
}
95+
System.out.println(" File generated: " + srcFile.length() + " bytes");
96+
97+
// 2. Create a Blob with it (file-based: a byte[] cannot hold 3 GB)
98+
Blob blob = new Blob(srcFile);
99+
System.out.println("[2] Blob created: " + blob);
100+
101+
// 3. Encode
102+
System.out.println("[3] Encoding...");
103+
try (OutputStream fos = new BufferedOutputStream(new FileOutputStream(encodedFile))) {
104+
FixedBinaryEncoder encoder = new FixedBinaryEncoder(fos, new BinaryTimeHandler(), false);
105+
blob.encode(encoder);
106+
encoder.close();
107+
}
108+
System.out.println(" Encoded: " + encodedFile.length() + " bytes");
109+
110+
// 4. Decode
111+
System.out.println("[4] Decoding...");
112+
try (InputStream fis = new BufferedInputStream(new FileInputStream(encodedFile))) {
113+
FixedBinaryDecoder decoder = new FixedBinaryDecoder(fis, new BinaryTimeHandler(), false);
114+
Element decoded = new Blob().decode(decoder);
115+
System.out.println(" Decoded: " + decoded);
116+
}
117+
118+
System.out.println("RESULT: PASS");
119+
} finally {
120+
srcFile.delete();
121+
encodedFile.delete();
122+
dir.delete();
123+
}
124+
}
125+
}

0 commit comments

Comments
 (0)