|
| 1 | +package com.netflix.evcache.pool; |
| 2 | + |
| 3 | +import java.nio.BufferUnderflowException; |
| 4 | +import java.nio.ByteBuffer; |
| 5 | +import java.nio.ByteOrder; |
| 6 | +import java.nio.charset.StandardCharsets; |
| 7 | +import java.util.Arrays; |
| 8 | + |
| 9 | +import org.apache.commons.codec.binary.Hex; |
| 10 | +import org.slf4j.Logger; |
| 11 | +import org.slf4j.LoggerFactory; |
| 12 | + |
| 13 | +/** |
| 14 | + * Length-prefixed binary wire format for the {@link EVCacheValue} envelope. EVCache wraps the |
| 15 | + * user's value in an {@link EVCacheValue} when the cache key needs to be hashed — typically |
| 16 | + * because the canonical key would exceed memcached's key-length limit (auto-hashing path) or |
| 17 | + * because the app opted into hashing explicitly (see {@code EVCacheImpl.getEVCacheKey}). The |
| 18 | + * envelope carries the original (pre-hash) key so it can be recovered from the value to detect |
| 19 | + * hash collisions on read. The on-the-wire layout is: |
| 20 | + * <pre> |
| 21 | + * [byte 0: magic 0x0C][byte 1: reserved/version 0x00] |
| 22 | + * [int keyLen][key UTF-8 bytes] |
| 23 | + * [int valLen][value bytes] |
| 24 | + * [int flags][long ttl][long createTime] |
| 25 | + * </pre> |
| 26 | + * |
| 27 | + * <p>Byte 0 (magic {@code 0x0C}) discriminates this format from the legacy Java |
| 28 | + * {@code ObjectOutputStream} stream header ({@code 0xAC 0xED}), so both formats can coexist |
| 29 | + * under the same {@code SERIALIZED} CachedData flag. Callers use {@link #isBinaryFormat(byte[])} |
| 30 | + * to decide between this codec and the legacy Java path. |
| 31 | + * |
| 32 | + * <p>Byte 1 is RESERVED for future breaking changes to the wire format. It is currently always {@code 0x00}. |
| 33 | + * Versioning is intentionally NOT implemented yet, and the reader read-and-ignores this byte |
| 34 | + * (it does NOT validate that it equals {@code 0x00}). |
| 35 | + * |
| 36 | + * <p><b>Byte order:</b> all multi-byte integer fields ({@code keyLen}, {@code valLen}, |
| 37 | + * {@code flags}, {@code ttl}, {@code createTime}) are encoded in <b>big-endian / network byte |
| 38 | + * order</b>. This matches Java {@link ByteBuffer}'s default and is set explicitly on both the |
| 39 | + * writer and the reader so a future change to that default cannot silently corrupt the wire |
| 40 | + * format. Any non-JVM reader of these bytes must use big-endian decoding. |
| 41 | + */ |
| 42 | +public final class EVCacheValueSerde { |
| 43 | + |
| 44 | + private static final Logger log = LoggerFactory.getLogger(EVCacheValueSerde.class); |
| 45 | + |
| 46 | + static final byte BINARY_SERDE_MAGIC_CONSTANT_BYTE = 0x0C; // 12 |
| 47 | + private static final byte RESERVED_VERSION_BYTE = 0x00; |
| 48 | + |
| 49 | + private static final int CORRUPT_PAYLOAD_LOG_LIMIT = 1024; |
| 50 | + |
| 51 | + private EVCacheValueSerde() { |
| 52 | + // Utility class; not instantiable. |
| 53 | + } |
| 54 | + |
| 55 | + /** True iff {@code bytes} starts with the binary envelope magic byte. */ |
| 56 | + public static boolean isBinaryFormat(byte[] bytes) { |
| 57 | + return bytes != null && bytes.length > 0 && bytes[0] == BINARY_SERDE_MAGIC_CONSTANT_BYTE; |
| 58 | + } |
| 59 | + |
| 60 | + /** |
| 61 | + * Encode an {@link EVCacheValue} into its compact binary envelope. See class Javadoc for layout. |
| 62 | + * |
| 63 | + * <p>The {@code EVCacheValue}'s key and value must be non-null. Production writes can only |
| 64 | + * reach this method via {@link com.netflix.evcache.EVCacheTranscoder}/{@code CachedData}, |
| 65 | + * which both reject null payloads upstream — so this method does not defensively check. |
| 66 | + */ |
| 67 | + public static byte[] serialize(EVCacheValue v) { |
| 68 | + final byte[] keyBytes = v.getKey().getBytes(StandardCharsets.UTF_8); |
| 69 | + final byte[] valueBytes = v.getValue(); |
| 70 | + |
| 71 | + final int bufferSize = Byte.BYTES + // magic byte |
| 72 | + Byte.BYTES + // reserved/version byte |
| 73 | + Integer.BYTES + keyBytes.length + // keyLen + key |
| 74 | + Integer.BYTES + valueBytes.length + // valLen + value |
| 75 | + Integer.BYTES + // flags |
| 76 | + Long.BYTES + // ttl |
| 77 | + Long.BYTES; // createTime |
| 78 | + |
| 79 | + final ByteBuffer buffer = ByteBuffer.allocate(bufferSize).order(ByteOrder.BIG_ENDIAN); |
| 80 | + |
| 81 | + buffer.put(BINARY_SERDE_MAGIC_CONSTANT_BYTE); |
| 82 | + buffer.put(RESERVED_VERSION_BYTE); |
| 83 | + |
| 84 | + buffer.putInt(keyBytes.length); |
| 85 | + buffer.put(keyBytes); |
| 86 | + |
| 87 | + buffer.putInt(valueBytes.length); |
| 88 | + buffer.put(valueBytes); |
| 89 | + |
| 90 | + buffer.putInt(v.getFlags()); |
| 91 | + buffer.putLong(v.getTTL()); |
| 92 | + buffer.putLong(v.getCreateTimeUTC()); |
| 93 | + |
| 94 | + return buffer.array(); |
| 95 | + } |
| 96 | + |
| 97 | + /** |
| 98 | + * Deserializes bytes into {@link EVCacheValue} from custom wire format. |
| 99 | + * |
| 100 | + * <p>Error behavior: on any corrupt or truncated payload (failed bounds check, buffer |
| 101 | + * underflow, or any other unexpected exception) this method warn-logs the field that failed |
| 102 | + * and a truncated hex dump of the source bytes, then returns {@code null}. The caller sees a |
| 103 | + * cache miss rather than a thrown exception, matching {@code BaseSerializingTranscoder}'s |
| 104 | + * resilience contract. |
| 105 | + * |
| 106 | + * <p>Length prefixes are bounds-checked against the remaining buffer before allocating, so a |
| 107 | + * malformed length prefix is rejected before any huge allocation or |
| 108 | + * {@link NegativeArraySizeException}. |
| 109 | + */ |
| 110 | + public static EVCacheValue deserialize(byte[] bytes) { |
| 111 | + String field = "magic"; |
| 112 | + try { |
| 113 | + final ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); |
| 114 | + |
| 115 | + final byte magic = buffer.get(); |
| 116 | + if (BINARY_SERDE_MAGIC_CONSTANT_BYTE != magic) { |
| 117 | + logCorruption(bytes, "Invalid magic constant: " + magic); |
| 118 | + return null; |
| 119 | + } |
| 120 | + // Reserved/version byte: read-and-ignore (see class Javadoc). |
| 121 | + field = "reserved"; |
| 122 | + buffer.get(); |
| 123 | + |
| 124 | + field = "keyLength"; |
| 125 | + final int keyLength = buffer.getInt(); |
| 126 | + if (keyLength < 0 || keyLength > buffer.remaining()) { |
| 127 | + logCorruption(bytes, |
| 128 | + "Invalid keyLength: " + keyLength + ", remaining=" + buffer.remaining()); |
| 129 | + return null; |
| 130 | + } |
| 131 | + field = "key"; |
| 132 | + final byte[] keyBytes = new byte[keyLength]; |
| 133 | + buffer.get(keyBytes); |
| 134 | + final String key = new String(keyBytes, StandardCharsets.UTF_8); |
| 135 | + |
| 136 | + field = "valueLength"; |
| 137 | + final int valueLength = buffer.getInt(); |
| 138 | + if (valueLength < 0 || valueLength > buffer.remaining()) { |
| 139 | + logCorruption(bytes, |
| 140 | + "Invalid valueLength: " + valueLength + ", remaining=" + buffer.remaining()); |
| 141 | + return null; |
| 142 | + } |
| 143 | + field = "value"; |
| 144 | + final byte[] valueBytes = new byte[valueLength]; |
| 145 | + buffer.get(valueBytes); |
| 146 | + |
| 147 | + field = "flags"; |
| 148 | + final int flags = buffer.getInt(); |
| 149 | + field = "ttl"; |
| 150 | + final long ttl = buffer.getLong(); |
| 151 | + field = "createTime"; |
| 152 | + final long createTime = buffer.getLong(); |
| 153 | + |
| 154 | + return new EVCacheValue(key, valueBytes, flags, ttl, createTime); |
| 155 | + } catch (BufferUnderflowException e) { |
| 156 | + logCorruption(bytes, "BufferUnderflow at field '" + field + "'"); |
| 157 | + return null; |
| 158 | + } catch (Exception e) { |
| 159 | + // Defensive catch-all for any unexpected exception |
| 160 | + log.warn("Uncaught exception decoding {} bytes of EVCacheValue binary envelope at field '{}'", |
| 161 | + bytes.length, field, e); |
| 162 | + return null; |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + /** |
| 167 | + * Warn-log a corruption event with the source byte length, the failure reason, and a hex |
| 168 | + * dump of the payload. We deliberately do not pass a Throwable as an SLF4J argument because |
| 169 | + * data corruption is an expected/recoverable condition at WARN level; a full stack trace |
| 170 | + * would be noise. The hex dump is capped at {@value #CORRUPT_PAYLOAD_LOG_LIMIT} bytes |
| 171 | + * (with a truncation marker appended) to keep log spam bounded for very large corrupt |
| 172 | + * payloads while preserving enough context to triage. |
| 173 | + */ |
| 174 | + private static void logCorruption(byte[] bytes, String error) { |
| 175 | + log.warn("Failed to deserialize {} bytes of EVCacheValue binary envelope, error={}, payload hex: {}", |
| 176 | + bytes.length, error, toHex(bytes, CORRUPT_PAYLOAD_LOG_LIMIT)); |
| 177 | + } |
| 178 | + |
| 179 | + private static String toHex(byte[] bytes, int maxBytes) { |
| 180 | + if (bytes == null) { |
| 181 | + return "null"; |
| 182 | + } |
| 183 | + if (bytes.length <= maxBytes) { |
| 184 | + return Hex.encodeHexString(bytes); |
| 185 | + } |
| 186 | + return Hex.encodeHexString(Arrays.copyOf(bytes, maxBytes)) |
| 187 | + + "...(truncated, total=" + bytes.length + " bytes)"; |
| 188 | + } |
| 189 | +} |
0 commit comments