SteNicholas commented on code in PR #3689:
URL: https://github.com/apache/celeborn/pull/3689#discussion_r3464927095
##########
client-spark/spark-3/src/main/java/org/apache/spark/shuffle/celeborn/SparkShuffleManager.java:
##########
@@ -91,6 +93,17 @@ public class SparkShuffleManager implements ShuffleManager {
private ExecutorShuffleIdTracker shuffleIdTracker = new
ExecutorShuffleIdTracker();
+ // The IO encryption key is fixed for the app lifetime. Lazily initialized
on first
+ // writer/reader call (not in the constructor) to ensure SparkEnv is
available.
+ private volatile Optional<CryptoHandler> cryptoHandler = null;
+
+ private Optional<CryptoHandler> getCryptoHandler() {
Review Comment:
`getCryptoHandler()` is an unsynchronized check-then-act on a `volatile
Optional` with `null` as the sentinel. Two notes:
1. **Race (minor):** concurrent `getReader`/`getWriter` task threads can
each observe `cryptoHandler == null` and independently call
`SparkCommonUtils.getCryptoHandler(conf)`, building redundant
`SparkCryptoHandler` instances and emitting duplicate `IO encryption enabled`
logs. The key is identical, so data stays correct — wasted allocation, not
corruption.
2. **Permanent empty memoization (latent):**
`SparkCommonUtils.getCryptoHandler` returns `Optional.empty()` when
`SparkEnv.get() == null` or the IO key is undefined. If the first reader/writer
call on an executor ever observes a transiently-unready `SparkEnv`, the empty
handler is cached for the JVM lifetime and that executor then reads/writes
shuffle **without** encryption while peers encrypt — a silent cross-executor
`Invalid decrypted length`/garbage-deserialize failure. The comment notes init
is deferred so SparkEnv is up by task time, so this is unlikely in practice;
but since the failure is silent and irreversible, consider not memoizing the
empty result (recompute until a present handler resolves).
This block is duplicated verbatim in spark-2 `SparkShuffleManager` — a
shared helper that caches internally would remove both copies.
##########
client/src/main/java/org/apache/celeborn/client/ShuffleClientImpl.java:
##########
@@ -1054,6 +1057,22 @@ public int pushOrMergeData(
length = compressor.getCompressedTotalSize();
}
+ // Snapshot volatile field once to avoid a TOCTOU race between isPresent()
and get().
+ Optional<CryptoHandler> handler = cryptoHandler;
+ if (handler.isPresent()) {
+ byte[] encrypted = handler.get().encrypt(data, offset, length);
+ logger.debug(
Review Comment:
Minor: this encrypt-side `logger.debug(...)` is unguarded, while the
symmetric decrypt-side log in `CelebornInputStream` uses `if
(logger.isDebugEnabled())`. With debug off (the prod default) this still
evaluates/boxes 5 args on every push; pick one idiom for both sides (either
drop the read-side guard or add one here).
Non-blocking design note: encryption is applied per-batch in
`pushOrMergeData` after per-batch compression, so every batch carries its own
`CryptoOutputStream` header/IV + 4-byte length (~20B framing) and a fresh
cipher init, inflating on-wire bytes and CPU proportional to batch count rather
than data volume. Encrypting the partition byte stream once would be a deeper
seam if this overhead ever shows up in profiling.
##########
client/src/main/java/org/apache/celeborn/client/read/CelebornInputStream.java:
##########
@@ -823,65 +839,94 @@ private boolean fillBuffer() throws IOException {
int batchId = Platform.getInt(sizeBuf, Platform.BYTE_ARRAY_OFFSET +
8);
int size = Platform.getInt(sizeBuf, Platform.BYTE_ARRAY_OFFSET + 12);
- if (shouldDecompress) {
+ // encryptedSize is the on-wire byte count (used for metrics); size
will be
+ // reassigned to the decrypted length after decryption.
+ int encryptedSize = size;
+
+ // Perform dedup/stale-attempt checks before decrypting to avoid
paying the
+ // crypto cost for batches that will be discarded anyway.
+ if (attemptId != attempts[mapId]) {
+ currentChunk.skipBytes(size);
+ continue;
+ }
+ if (readSkewPartitionWithoutMapRange) {
+ LocationPushFailedBatches locationPushFailedBatches =
+
this.failedBatches.get(currentReader.getLocation().getUniqueId());
+ if (null != locationPushFailedBatches) {
+ if (locationPushFailedBatches.contains(mapId, attemptId,
batchId)) {
+ logger.warn(
+ "Skip duplicated batch: mapId={}, attemptId={},
batchId={}",
+ mapId,
+ attemptId,
+ batchId);
+ currentChunk.skipBytes(size);
+ continue;
+ }
+ }
+ }
+ Set<Integer> batchSet = batchesRead.computeIfAbsent(mapId, k -> new
HashSet<>());
+ if (batchSet.contains(batchId)) {
+ callback.incDuplicateBytesRead(BATCH_HEADER_SIZE + encryptedSize);
+ logger.debug(
+ "Skip duplicated batch: mapId {}, attemptId {}, batchId {}.",
+ mapId,
+ attemptId,
+ batchId);
+ currentChunk.skipBytes(size);
+ continue;
+ }
+
+ // Batch is unique and from the correct attempt — now read and
optionally decrypt.
+ if (cryptoHandler.isPresent()) {
+ if (size > encryptedBuf.length) {
+ encryptedBuf = new byte[size];
+ }
+ currentChunk.readBytes(encryptedBuf, 0, size);
+ byte[] decrypted = cryptoHandler.get().decrypt(encryptedBuf, 0,
size);
+ if (logger.isDebugEnabled()) {
+ logger.debug(
+ "Decrypted shuffle data for shuffle {} partition {}: {}
bytes -> {} bytes.",
+ shuffleId,
+ partitionId,
+ size,
+ decrypted.length);
+ }
+ size = decrypted.length;
+ if (shouldDecompress) {
+ compressedBuf = decrypted;
Review Comment:
`decrypt()` returns a fresh array sized exactly to the plaintext, bound
straight into `compressedBuf` here with no check that `decrypted.length` is at
least the compression header size. On the non-crypto branches the payload is
`readBytes` into a `>= bufferSize` buffer; here a corrupt/truncated batch whose
4-byte length prefix decoded to a small-but-non-negative value (cf. the loose
`decryptedLength > length - 4` bound in `SparkCryptoHandler`) yields a short
`decrypted`, and the subsequent `decompressor.getOriginalLen(compressedBuf)`
reads past it — surfacing as an opaque `ArrayIndexOutOfBounds`/fetch failure
rather than a clear corruption error. A length guard before decompress would
make this diagnosable.
##########
client-spark/spark-3-shaded/pom.xml:
##########
@@ -76,6 +76,7 @@
<include>com.google.guava:failureaccess</include>
<include>io.netty:*</include>
<include>org.apache.commons:commons-lang3</include>
+ <include>org.apache.commons:commons-crypto</include>
Review Comment:
spark-2-shaded and spark-3-shaded both add this `commons-crypto` include,
but `client-spark/spark-4-shaded/pom.xml` was not updated — its `<includes>`
block (currently `commons-lang3`, `RoaringBitmap`, `commons-io`, …) has no
`commons-crypto`. Since `client-spark/common` now hard-depends on
`org.apache.commons:commons-crypto` at compile scope (via `SparkCryptoHandler`
→ `CryptoStreamUtils`), an encrypted shuffle on Spark 4 assembled from the
shaded jar would hit `NoClassDefFoundError` at the first encrypt/decrypt unless
commons-crypto is on the classpath transitively from spark-core. Please confirm
Spark 4 provides it transitively, or add the same include to spark-4-shaded for
parity.
##########
client-spark/common/src/main/java/org/apache/spark/shuffle/celeborn/SparkCryptoHandler.java:
##########
@@ -0,0 +1,71 @@
+/*
+ * 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 org.apache.spark.shuffle.celeborn;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.apache.spark.SparkConf;
+import org.apache.spark.security.CryptoStreamUtils;
+
+import org.apache.celeborn.client.security.CryptoHandler;
+
+public class SparkCryptoHandler implements CryptoHandler {
+ private final SparkConf sparkConf;
+ private final byte[] key;
+
+ public SparkCryptoHandler(SparkConf sparkConf, byte[] key) {
+ this.sparkConf = sparkConf;
+ this.key = key;
+ }
+
+ @Override
+ public byte[] encrypt(byte[] input, int offset, int length) throws
IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(baos);
+ dos.writeInt(length);
+ try (OutputStream cos = CryptoStreamUtils.createCryptoOutputStream(dos,
sparkConf, key)) {
+ cos.write(input, offset, length);
+ }
+ return baos.toByteArray();
+ }
+
+ @Override
+ public byte[] decrypt(byte[] input, int offset, int length) throws
IOException {
Review Comment:
`decrypt()` (and `encrypt()`) call
`CryptoStreamUtils.createCrypto*Stream(stream, sparkConf, …)` on every batch,
which rebuilds `CryptoParams` via `toCryptoConf(sparkConf)` — iterating the
entire `SparkConf` into a `Properties` — plus a fresh `SecretKeySpec` and
Commons-Crypto cipher init each time. The transformation, key spec, and crypto
`Properties` are app-lifetime constant; with thousands of batches per reducer
partition this per-batch conf scan + cipher init is pure overhead versus the
unencrypted path. Consider resolving the key/properties once in the constructor
and only reading the per-batch IV in the hot loop.
##########
client/src/test/java/org/apache/celeborn/client/read/CelebornInputStreamCryptoRoundTripSuiteJ.java:
##########
@@ -0,0 +1,298 @@
+/*
+ * 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 org.apache.celeborn.client.read;
+
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import org.junit.Test;
+
+import org.apache.celeborn.client.ShuffleClient;
+import org.apache.celeborn.client.compress.Compressor;
+import org.apache.celeborn.client.security.CryptoHandler;
+import org.apache.celeborn.common.CelebornConf;
+import org.apache.celeborn.common.network.buffer.NettyManagedBuffer;
+import org.apache.celeborn.common.network.client.ChunkReceivedCallback;
+import org.apache.celeborn.common.network.client.TransportClient;
+import org.apache.celeborn.common.network.client.TransportClientFactory;
+import org.apache.celeborn.common.network.protocol.TransportMessage;
+import org.apache.celeborn.common.protocol.MessageType;
+import org.apache.celeborn.common.protocol.PartitionLocation;
+import org.apache.celeborn.common.protocol.PbStreamHandler;
+import org.apache.celeborn.common.protocol.StorageInfo;
+import org.apache.celeborn.common.unsafe.Platform;
+
+/**
+ * Integration-style round-trip tests for EAR (Encryption At Rest) wiring in
{@link
+ * CelebornInputStream}. These tests verify that the encrypt-on-write /
decrypt-on-read path works
+ * end-to-end, including interactions with compression and the shuffle
integrity check.
+ */
+public class CelebornInputStreamCryptoRoundTripSuiteJ {
+
+ private static final int BATCH_HEADER_SIZE = 16;
+ private static final String SHUFFLE_KEY = "app-1-1";
+
+ /**
+ * A minimal CryptoHandler for testing: the encrypted format is [4-byte
plaintext length
+ * (int)][XOR-encrypted payload]. This matches the structural contract of
SparkCryptoHandler so
+ * the bounds check (decryptedLength > length - 4) is also exercised.
+ */
+ static class XorCryptoHandler implements CryptoHandler {
+ private final byte key;
+
+ XorCryptoHandler(byte key) {
+ this.key = key;
+ }
+
+ @Override
+ public byte[] encrypt(byte[] input, int offset, int length) throws
IOException {
+ // Prefix with 4-byte plaintext length, then XOR-encrypt the payload
+ byte[] out = new byte[4 + length];
+ Platform.putInt(out, Platform.BYTE_ARRAY_OFFSET, length);
+ for (int i = 0; i < length; i++) {
+ out[4 + i] = (byte) (input[offset + i] ^ key);
+ }
+ return out;
+ }
+
+ @Override
+ public byte[] decrypt(byte[] input, int offset, int length) throws
IOException {
+ // Validate the buffer is large enough to hold the 4-byte length prefix
+ if (length < 4) {
+ throw new IOException("Encrypted buffer too short: " + length);
+ }
+ // Read the plaintext length from the 4-byte prefix
+ int decryptedLength = Platform.getInt(input, Platform.BYTE_ARRAY_OFFSET
+ offset);
+ // Validate bounds: the 4-byte prefix must fit inside the encrypted
buffer
+ if (decryptedLength < 0 || decryptedLength > length - 4) {
+ throw new IOException(
+ "Invalid decrypted length: " + decryptedLength + ", encrypted
length: " + length);
+ }
+ byte[] out = new byte[decryptedLength];
+ for (int i = 0; i < decryptedLength; i++) {
+ out[i] = (byte) (input[offset + 4 + i] ^ key);
+ }
+ return out;
+ }
+ }
+
+ /**
+ * Build a single batch ByteBuf as ShuffleClientImpl.pushOrMergeData does:
optionally compress,
+ * optionally encrypt, then prepend the 16-byte batch header.
+ */
+ private ByteBuf buildBatch(
+ byte[] plaintext, boolean compress, CryptoHandler cryptoHandler,
CelebornConf conf)
+ throws IOException {
+ byte[] data = plaintext;
+ int offset = 0;
+ int length = plaintext.length;
+
+ // Step 1: optionally compress (compress-then-encrypt ordering matches
ShuffleClientImpl)
+ if (compress) {
+ Compressor compressor = Compressor.getCompressor(conf);
+ compressor.compress(data, offset, length);
+ data = compressor.getCompressedBuffer();
+ offset = 0;
+ length = compressor.getCompressedTotalSize();
+ }
+
+ // Step 2: optionally encrypt the (possibly compressed) payload
+ if (cryptoHandler != null) {
+ data = cryptoHandler.encrypt(data, offset, length);
+ offset = 0;
+ length = data.length;
+ }
+
+ // Step 3: prepend the 16-byte batch header
[mapId|attemptId|batchId|payloadLen]
+ byte[] body = new byte[BATCH_HEADER_SIZE + length];
+ Platform.putInt(body, Platform.BYTE_ARRAY_OFFSET, 0); // mapId
+ Platform.putInt(body, Platform.BYTE_ARRAY_OFFSET + 4, 0); // attemptId
+ Platform.putInt(body, Platform.BYTE_ARRAY_OFFSET + 8, 0); // batchId
+ Platform.putInt(body, Platform.BYTE_ARRAY_OFFSET + 12, length); // payload
length
+ System.arraycopy(data, offset, body, BATCH_HEADER_SIZE, length);
+ return Unpooled.wrappedBuffer(body);
+ }
+
+ /**
+ * Create a CelebornInputStream backed by a mock TransportClient that serves
the given batchBuf as
+ * a single chunk.
+ */
+ private CelebornInputStream createStream(
+ ByteBuf batchBuf,
+ boolean needDecompress,
+ Optional<CryptoHandler> cryptoHandler,
+ CelebornConf conf)
+ throws IOException, InterruptedException {
+ TransportClient client = mock(TransportClient.class);
+ PbStreamHandler pbHandler =
+ PbStreamHandler.newBuilder().setStreamId(1L).setNumChunks(1).build();
+ // Encode the stream handler into an RPC response that CelebornInputStream
expects
+ ByteBuffer rpcResponse =
+ new TransportMessage(MessageType.STREAM_HANDLER,
pbHandler.toByteArray()).toByteBuffer();
+ when(client.sendRpcSync(any(ByteBuffer.class),
anyLong())).thenReturn(rpcResponse);
+ doNothing().when(client).sendRpc(any(ByteBuffer.class));
+ doAnswer(
+ invocation -> {
+ ChunkReceivedCallback cb = invocation.getArgument(3);
+ // Serve the pre-built batch buffer immediately as chunk 0;
duplicate() shares
+ // the underlying data without incrementing the ref count, so
the stream's
+ // single release correctly frees the buffer.
+ cb.onSuccess(0, new NettyManagedBuffer(batchBuf.duplicate()));
+ return null;
+ })
+ .when(client)
+ .fetchChunk(anyLong(), anyInt(), anyLong(),
any(ChunkReceivedCallback.class));
+
+ TransportClientFactory clientFactory = mock(TransportClientFactory.class);
+ when(clientFactory.createClient(anyString(), anyInt())).thenReturn(client);
+
+ ShuffleClient shuffleClient = mock(ShuffleClient.class);
+
+ // PRIMARY location pointing to a single HDD partition
+ PartitionLocation location =
+ new PartitionLocation(
+ 0, 0, "host1", 9001, 9002, 9003, 9004,
PartitionLocation.Mode.PRIMARY);
+ location.setStorageInfo(new StorageInfo(StorageInfo.Type.HDD, true,
"/mnt/disk1"));
+
+ ArrayList<PartitionLocation> locations = new ArrayList<>();
+ locations.add(location);
+ ArrayList<PbStreamHandler> handlers = new ArrayList<>();
+
handlers.add(PbStreamHandler.newBuilder().setStreamId(1L).setNumChunks(1).build());
+
+ return CelebornInputStream.create(
+ conf,
+ clientFactory,
+ SHUFFLE_KEY,
+ locations,
+ handlers,
+ new int[] {0},
+ new HashMap<>(),
+ new HashMap<>(),
+ 0,
+ 1L,
+ 0,
+ 100,
+ new ConcurrentHashMap<>(),
+ shuffleClient,
+ 1,
+ 1,
+ 0,
+ null,
+ new MetricsCallback() {
+ @Override
+ public void incBytesRead(long bytes) {}
+
+ @Override
+ public void incReadTime(long time) {}
+ },
+ needDecompress,
+ cryptoHandler);
+ }
+
+ private byte[] readAll(CelebornInputStream stream) throws IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int n;
+ while ((n = stream.read(buf)) != -1) {
+ baos.write(buf, 0, n);
+ }
+ return baos.toByteArray();
+ }
+
+ @Test
+ public void testEncryptDecryptRoundTrip() throws IOException,
InterruptedException {
+ byte[] plaintext = "hello, EAR round-trip without compression".getBytes();
+ CelebornConf conf = new CelebornConf();
+ XorCryptoHandler handler = new XorCryptoHandler((byte) 0x5A);
+
+ // Build an encrypted batch and read it back through CelebornInputStream
+ ByteBuf batchBuf = buildBatch(plaintext, false, handler, conf);
+ try (CelebornInputStream stream = createStream(batchBuf, false,
Optional.of(handler), conf)) {
+ assertArrayEquals(plaintext, readAll(stream));
+ }
+ }
+
+ @Test
+ public void testNoEncryptionRoundTrip() throws IOException,
InterruptedException {
+ byte[] plaintext = "unencrypted shuffle data sanity check".getBytes();
+ CelebornConf conf = new CelebornConf();
+
+ // Baseline: with no CryptoHandler the data flows through unchanged
+ ByteBuf batchBuf = buildBatch(plaintext, false, null, conf);
+ try (CelebornInputStream stream = createStream(batchBuf, false,
Optional.empty(), conf)) {
+ assertArrayEquals(plaintext, readAll(stream));
+ }
+ }
+
+ @Test
+ public void testCompressThenEncryptRoundTrip() throws IOException,
InterruptedException {
+ // Reproduce the compress-then-encrypt ordering used in ShuffleClientImpl.
+ byte[] plaintext = "shuffle data with compression and encryption enabled
for EAR".getBytes();
+ CelebornConf conf = new CelebornConf();
+ // Use LZ4 (default)
+ conf.set(CelebornConf.SHUFFLE_COMPRESSION_CODEC().key(), "lz4");
+ XorCryptoHandler handler = new XorCryptoHandler((byte) 0x3C);
+
+ // Writer: LZ4-compress then XOR-encrypt; Reader: decrypt then decompress
+ ByteBuf batchBuf = buildBatch(plaintext, true, handler, conf);
+ try (CelebornInputStream stream = createStream(batchBuf, true,
Optional.of(handler), conf)) {
+ assertArrayEquals(plaintext, readAll(stream));
+ }
+ }
+
+ @Test
+ public void testEncryptWithIntegrityCheckEnabled() throws IOException,
InterruptedException {
Review Comment:
This test sets `integrityCheck.enabled=true` and reads back through
`CelebornInputStream`, but it never actually exercises the integrity
comparison. `validateIntegrity()` only aggregates bytes locally and delegates
the real checksum/byte comparison to
`shuffleClient.readReducerPartitionEnd(...)`; here `shuffleClient` is a Mockito
`mock(ShuffleClient.class)`, so that call is a no-op stub that compares nothing
and never throws, and the test only asserts `decrypted == plaintext` with no
`verify(shuffleClient).readReducerPartitionEnd(...)`. A regression that
computed the integrity checksum over the wrong bytes (e.g. still-encrypted)
would still pass here — false confidence on the most fragile interaction this
PR introduces.
Consider stubbing `readReducerPartitionEnd` to assert the expected
checksum/bytes, or at least `verify(...)` it was invoked with them. Relatedly,
there's no test covering compression + encryption + integrity-check *together*,
which is the combination most likely to diverge from the write-side plaintext
CRC.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]