Skip to content

EVCacheValue: opt-in compact binary serialization with backwards-compatible reads#196

Open
joegoogle123 wants to merge 1 commit into
masterfrom
evcache-value-binary-serde
Open

EVCacheValue: opt-in compact binary serialization with backwards-compatible reads#196
joegoogle123 wants to merge 1 commit into
masterfrom
evcache-value-binary-serde

Conversation

@joegoogle123
Copy link
Copy Markdown

@joegoogle123 joegoogle123 commented Jun 4, 2026

Summary

Hashed-key values are wrapped in an EVCacheValue envelope (key, value, flags, ttl, createTime) that is currently serialized with Java ObjectOutputStream, adding ~50–80 bytes of structural overhead per item. This adds a compact, length-prefixed binary format for the envelope while remaining fully backwards-compatible on reads.

What changed

  • New EVCacheValueSerde class (com.netflix.evcache.pool) — public-final-non-instantiable codec, owns the wire format and all error handling:
    • static byte[] serialize(EVCacheValue) — length-prefixed binary layout: [magic 0x0C][reserved 0x00][int keyLen][key UTF-8][int valLen][value][int flags][long ttl][long createTime].
    • static EVCacheValue deserialize(byte[]) — bounds-checks length prefixes before allocating; on any corruption / unexpected exception warn-logs the failing field and a truncated hex dump (via Apache Commons Hex.encodeHexString, capped at 1024 bytes) and returns null. Matches BaseSerializingTranscoder's resilience contract (corruption → cache miss → caller refills from source of truth) so a single corrupt entry never crashes a get / getBulk / async pipeline.
    • static boolean isBinaryFormat(byte[]) — exposed for the dispatcher.
  • EVCacheTranscoder becomes a thin dispatcher (no try/catch):
    • serialize: gates on useBinarySerialization && o instanceof EVCacheValueEVCacheValueSerde.serialize; else super.serialize (Java).
    • deserialize: dispatches on EVCacheValueSerde.isBinaryFormatEVCacheValueSerde.deserialize; else super.deserialize.
  • EVCacheValue stays a pure POJO (codec moved out; constructor unchanged from pre-PR).
  • EVCacheImpl reads a Feature Property at client construction and injects it into the (immutable) envelope transcoder.
  • Reads auto-detect format by the leading byte (0xAC 0xED = legacy Java, 0x0C = binary), so a new client decodes existing cache entries unchanged.

Format-flag decision (reuse SERIALIZED + magic byte, not a fresh flag)

The binary envelope keeps the existing SERIALIZED flag and is disambiguated from Java by the leading byte, rather than allocating a new CachedData flag. Rationale:

  • SERIALIZED semantically still means "serialized object → deserialize()"; the codec choice (Java vs binary) lives inside deserialize(). No flag constant is reassigned or repurposed, and decode() branch order is untouched.
  • Consumers that route on SERIALIZED (e.g. the admin inspector, cache-warmer) keep working without a new flag constant to propagate.
  • An old reader that hits binary bytes under SERIALIZED throws StreamCorruptedException (fails loud) rather than silently decoding garbage — which a fresh low-byte flag would cause (decodeString) on old readers.
  • Invariant (documented in EVCacheValueSerde Javadoc): SERIALIZED payloads are self-describing by leading byte; a future third format must use a distinct non-colliding magic + the reserved version byte.

Reserved version byte

Byte index 1 of the binary payload is reserved (always 0x00 today). Reader read-and-ignores; not validated. Reason: forward-compat without an emergency reader rollout. If today's readers rejected any non-zero version, introducing a v2 in the future would require shipping reader support fleet-wide before any writer could emit a v2 byte, and a single misconfigured writer would crash all readers. By accepting any value today, future readers can branch on this byte to introduce breaking format changes backwards-compatibly.

Feature Property (rollout gate)

  • <appName>.envelope.binary.serialization.enabled (global fallback evcache.envelope.binary.serialization.enabled), default false.
  • "Envelope" matches the codebase's existing term for the hashed-key EVCacheValue wrapping (envelopeTranscoder in EVCacheMemcachedClient).
  • Read once at client construction and injected into the immutable transcoder ⇒ deploy/restart required to take effect; this is NOT a live runtime toggle. Flip the property, then redeploy the consuming app.
  • Default-off means production keeps writing Java; reads auto-detect both formats. Roll out reader-first: ship this change everywhere — including the admin inspector and cache-warmer — before enabling the FP for any writer.

Compatibility

  • A client with this change decodes existing Java-serialized values unchanged (dual-format read).
  • With the FP off (default), wire output is byte-identical to today.
  • Corrupt binary payloads degrade to cache miss (null), matching the existing Java path. A single corrupt entry never crashes the caller.

Testing

EVCacheValueSerdeTest17 cases via the public EVCacheTranscoder.encode/decode API:

  • Binary round-trip across edge cases (empty / unicode key / large 2 MB value / negative flags / zero ttl / negative & MAX createTime / MIN flags)
  • Transcoder wire shape (binary flag on): SERIALIZED flag set, leading byte 0x0C, reserved byte 0x00
  • Default transcoder (flag OFF) writes Java (0xAC 0xED) but reads both formats
  • Backwards-compat: legacy ObjectOutputStream-serialized envelope still decodes
  • Non-EVCacheValue passthrough (ArrayList stays on the Java path even with binary flag on)
  • Size win (binary is ~4.2× smaller for typical small items)
  • Malformed binary: truncated, bogus oversize keyLen, negative keyLen all decode to null (logged with field + hex dump)

Full evcache-core suite (./gradlew :evcache-core:test): 28/28 green (EVCacheValueSerdeTest 17, NodeLocatorLookupTest 3, MockEVCacheTest 7, plus runtime tests in other modules).

Chunked-payload integration is not covered by an automated test in this PR — chunking lives in EVCacheClient.createChunks/assembleChunks, which are content-opaque (byte copy + CRC + manifest) and require a live client to exercise. The binary format introduces no new chunking risk by construction: assembleChunks reassembles bytes byte-for-byte and CRC-checks them against the manifest before handing the result to the transcoder.

🤖 Generated with Claude Code

@joegoogle123 joegoogle123 force-pushed the evcache-value-binary-serde branch 15 times, most recently from d443e30 to 3892873 Compare June 4, 2026 22:22
@joegoogle123 joegoogle123 force-pushed the evcache-value-binary-serde branch 8 times, most recently from 665a6d9 to 6bde69d Compare June 4, 2026 23:08
The hashed-key EVCacheValue envelope is serialized with Java ObjectOutputStream,
adding ~50-80 bytes/item. Add a compact length-prefixed binary format and have
EVCacheTranscoder override serialize()/deserialize() so super.encode() still sets
the SERIALIZED flag and CachedData as before.

- Binary writing is OPT-IN and OFF BY DEFAULT (EVCacheTranscoder.setUseBinarySerialization);
  by default EVCacheValue is still Java-serialized. This is the backwards-compat gate:
  readers must ship the decode change before any writer enables binary.
- Reads always auto-detect by leading byte (0xAC ED = legacy Java, 0x0C = binary), so a
  client with this change decodes existing Java-serialized values unchanged.
- Wire format reserves a version byte (0x00) after the magic byte for future breaking
  changes; versioning is not implemented yet (documented as reserved).

Tests: EVCacheValueSerdeTest (17 cases) — binary round-trip, transcoder encode/decode
with the flag on, default-off (Java) write + dual-format read, legacy-Java backwards-compat
decode, non-EVCacheValue passthrough, magic/reserved-byte dispatch, size win, malformed input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@joegoogle123 joegoogle123 force-pushed the evcache-value-binary-serde branch from 6bde69d to d46bf97 Compare June 4, 2026 23:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant