diff --git a/CMakeLists.txt b/CMakeLists.txt index 24699bc..9116e7e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,7 @@ if(GTest_FOUND) tests/unit/PutTest.cpp tests/unit/RemoveTest.cpp tests/unit/RemoveWithVersionTest.cpp + tests/unit/ReplaceWithVersionTest.cpp tests/unit/HashAwareRoutingTest.cpp ) @@ -135,6 +136,10 @@ if(GTest_FOUND) tests/integration/RemoveWithVersionIntegrationTest.cpp ) + add_executable(replacewithversion_integration_tests + tests/integration/ReplaceWithVersionIntegrationTest.cpp + ) + # Topology integration tests (multi-server cluster) add_executable(topology_integration_tests tests/integration/TopologyChangeTest.cpp @@ -187,6 +192,13 @@ if(GTest_FOUND) # Note: NOT gtest_main - we provide our own main() ) + target_link_libraries(replacewithversion_integration_tests + PRIVATE + hotrod-client + GTest::gtest + # Note: NOT gtest_main - we provide our own main() + ) + target_link_libraries(topology_integration_tests PRIVATE hotrod-client @@ -231,6 +243,7 @@ if(GTest_FOUND) add_test(NAME PutIntegrationTests COMMAND put_integration_tests) add_test(NAME RemoveIntegrationTests COMMAND remove_integration_tests) add_test(NAME RemoveWithVersionIntegrationTests COMMAND removewithversion_integration_tests) + add_test(NAME ReplaceWithVersionIntegrationTests COMMAND replacewithversion_integration_tests) add_test(NAME TopologyIntegrationTests COMMAND topology_integration_tests) add_test(NAME ConcurrentClientsTests COMMAND concurrent_clients_tests) add_test(NAME HashAwareRoutingIntegrationTests COMMAND hash_aware_routing_integration_tests) @@ -261,6 +274,10 @@ if(GTest_FOUND) WORKING_DIRECTORY ${CMAKE_BINARY_DIR} LABELS "integration;docker" ) + set_tests_properties(ReplaceWithVersionIntegrationTests PROPERTIES + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + LABELS "integration;docker" + ) set_tests_properties(TopologyIntegrationTests PROPERTIES WORKING_DIRECTORY ${CMAKE_BINARY_DIR} LABELS "integration;docker;topology;multi-server" diff --git a/docs/STATUS.md b/docs/STATUS.md index 95c3493..5569545 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -4,7 +4,7 @@ > If any other doc disagrees with this file, this file wins. Point-in-time > snapshots live in [`archive/`](archive/) and are historical only. > -> **Last updated:** 2026-09-19 +> **Last updated:** 2026-09-20 --- @@ -28,12 +28,12 @@ _(Full per-session workflow: [`WORKFLOW.md`](WORKFLOW.md).)_ _The 1–3 concrete things to do next. Keep this short and current._ -1. **Step 10 — Metadata operations, continued.** `getWithMetadata` (0x1B/0x1C) - and `removeWithVersion` (0x0D/0x0E) are now **shipped** (see "Working and - shipped"). Next in the recommended order: **`replaceWithVersion`** - (0x09/0x0A), then the conditionals `putIfAbsent`/`replace`/`containsKey`. - Version comes from `getWithMetadata().get()->metadata.version`; - `replaceWithVersion` also carries lifespan/maxIdle + value in the request. +1. **Step 10 — Metadata operations, continued.** `getWithMetadata` (0x1B/0x1C), + `removeWithVersion` (0x0D/0x0E) and `replaceWithVersion` (0x09/0x0A) are now + **shipped** (see "Working and shipped"). Next in the recommended order: the + conditionals **`putIfAbsent`** (0x05/0x06), **`replace`** (0x07/0x08), + **`containsKey`** (0x0F/0x10). Version comes from + `getWithMetadata().get()->metadata.version`. 2. **Benchmark the multiplexing path** — the async rewrite targets 5–10× concurrent throughput; this has not been measured yet. 3. **Small cleanup:** read header "other params" when `paramCount > 0` @@ -52,7 +52,7 @@ is authoritative for what's done._ - **Step 10 — Metadata operations** (version-based CAS). Deliverables: - [x] `getWithMetadata` (0x1B/0x1C) — **shipped 2026-09-17** (8 unit + 7 integration tests) - [x] `removeWithVersion` (0x0D/0x0E) — **shipped 2026-09-19** (7 unit + 5 integration tests). Returns `future` (matches Java `boolean removeWithVersion(K, long)`); added `Codec::writeLong`/`readLong` (fixed 8-byte BE). Parser drains the `*_WITH_PREVIOUS` (0x03/0x04) body defensively though FORCE_RETURN_VALUE isn't wired yet. Byte layout cross-checked against all three legs: this client, Java `RemoveIfUnmodifiedOperation`, and `hotrod40.ksy` (`remove_if_unmodified_request` = `key: lp_bytes` + `entry_version: s8`, big-endian per `meta.endian: be`). - - [ ] `replaceWithVersion`/REPLACE_IF_UNMODIFIED (0x09/0x0A) + - [x] `replaceWithVersion`/REPLACE_IF_UNMODIFIED (0x09/0x0A) — **shipped 2026-09-20** (5 unit + 5 integration tests). Returns `future`; request reuses PUT's expiration byte (high nibble=lifespan, low=maxIdle) + `Codec::writeLong` for the version. **Schema bug found:** `hotrod40.ksy` `replace_if_unmodified_request` has the expiration nibbles flipped vs `put_request`/`expiration_params`; Java uses one shared `writeExpirationParams` for both, so PUT's order is authoritative. Cross-checked against client + Java `ReplaceIfUnmodifiedOperation` + (corrected reading of) the schema. - [ ] `putIfAbsent` (0x05/0x06), `replace` (0x07/0x08), `containsKey` (0x0F/0x10) Java ref: `GetWithMetadataOperation`, `ReplaceIfUnmodifiedOperation`. See the @@ -97,6 +97,8 @@ multiplexing. See "Working and shipped" below and `PROGRESS.md`._ the entry point for version-based CAS (Step 10) - **removeWithVersion** (0x0D/0x0E) — version-based conditional remove (CAS); returns `future` (removed?), version from `getWithMetadata` +- **replaceWithVersion** (0x09/0x0A) — version-based conditional replace (CAS); + returns `future` (replaced?); request = key + expiration + version + value - **Async API:** all operations return `std::future` / `std::optional` - **MultiplexedConnection:** true async — dedicated read-loop thread, `messageId → promise` pending map, `execute()` used by all four operations. @@ -108,9 +110,9 @@ multiplexing. See "Working and shipped" below and `PROGRESS.md`._ Docker daemon; Windows doesn't build the integration tests). Windows/MSVC portability and `-Werror` build parity also landed (Sept 2026). -**Test status (verified 2026-09-19):** -- Unit: **160/160** passing (`./build/unit_tests`, <1s) -- Integration: **58/58** passing across 10 suites (`ctest`, spins up Docker +**Test status (verified 2026-09-20):** +- Unit: **165/165** passing (`./build/unit_tests`, <1s) +- Integration: **63/63** passing across 11 suites (`ctest`, spins up Docker Infinispan single-server + multi-node clusters), now also green on Linux CI. The interlaced distributed tests (`ConcurrentMultiServerTest`) were fixed to tolerate a GET racing ahead of its PUT — a `nullopt` is expected, only a @@ -157,6 +159,14 @@ on 2026-09-16. That `.ksy` is the wire-format source of truth for protocol > cross-check. If this client, the schema, and the Java client ever disagree on > a byte layout, one of them has a bug — investigate before shipping. Public > copy: . +> +> **Known schema bug (found 2026-09-20):** `hotrod40.ksy` +> `replace_if_unmodified_request` packs the expiration `time_units` nibbles in +> the *opposite* order from `put_request`/`expiration_params` (lifespan and +> maxIdle swapped). Java uses one shared `writeExpirationParams` for PUT and +> replaceWithVersion, so PUT's order — **high nibble = lifespan, low = maxIdle** +> — is authoritative and is what this client implements. Fix pending upstream in +> hotrod-dissector. ### Opcode table (request / response) @@ -166,7 +176,7 @@ on 2026-09-16. That `.ksy` is the wire-format source of truth for protocol | get | 0x03 | 0x04 | ✅ | | putIfAbsent | 0x05 | 0x06 | ❌ | | replace (only if present) | 0x07 | 0x08 | ❌ | -| **replaceWithVersion** (replaceIfUnmodified) | **0x09** | **0x0A** | ❌ | +| **replaceWithVersion** (replaceIfUnmodified) | **0x09** | **0x0A** | ✅ | | remove | 0x0B | 0x0C | ✅ | | **removeWithVersion** (removeIfUnmodified) | **0x0D** | **0x0E** | ✅ | | containsKey | 0x0F | 0x10 | ❌ | @@ -181,8 +191,8 @@ namespace) — currently only PUT/GET/REMOVE/PING. Add the new ones there. version-based writes that depend on it, then the simpler conditionals: 1. ~~**getWithMetadata** (0x1B/0x1C)~~ ✅ shipped 2026-09-17 2. ~~**removeWithVersion** (0x0D/0x0E)~~ ✅ shipped 2026-09-19 -3. replaceWithVersion (0x09/0x0A) ← next session -4. putIfAbsent (0x05/0x06), replace (0x07/0x08), containsKey (0x0F/0x10) +3. ~~**replaceWithVersion** (0x09/0x0A)~~ ✅ shipped 2026-09-20 +4. putIfAbsent (0x05/0x06), replace (0x07/0x08), containsKey (0x0F/0x10) ← next session ### Step-by-step: getWithMetadata (the entry point) diff --git a/include/hotrod/HeaderCodec.h b/include/hotrod/HeaderCodec.h index 3bb917f..c23f4da 100644 --- a/include/hotrod/HeaderCodec.h +++ b/include/hotrod/HeaderCodec.h @@ -32,6 +32,8 @@ namespace Opcode { const uint8_t PUT_RESPONSE = 0x02; const uint8_t GET_REQUEST = 0x03; const uint8_t GET_RESPONSE = 0x04; + const uint8_t REPLACE_WITH_VERSION_REQUEST = 0x09; // replaceIfUnmodified + const uint8_t REPLACE_WITH_VERSION_RESPONSE = 0x0A; const uint8_t REMOVE_REQUEST = 0x0B; const uint8_t REMOVE_RESPONSE = 0x0C; const uint8_t REMOVE_WITH_VERSION_REQUEST = 0x0D; // removeIfUnmodified diff --git a/include/hotrod/RemoteCache.h b/include/hotrod/RemoteCache.h index a401307..9287db4 100644 --- a/include/hotrod/RemoteCache.h +++ b/include/hotrod/RemoteCache.h @@ -170,6 +170,36 @@ class RemoteCache { */ std::future removeWithVersion(const ByteArray& key, int64_t version); + /** + * REPLACE_WITH_VERSION operation - version-based conditional replace (CAS). + * + * Replaces the entry's value only if its current version matches `version` + * (obtained from a prior getWithMetadata() call). + * + * Opcode: 0x09 (REPLACE_WITH_VERSION_REQUEST) → 0x0A (RESPONSE) + * + * @param key The key as raw bytes + * @param value The new value as raw bytes + * @param version Expected entry version (from getWithMetadata().metadata.version) + * @param lifespan Lifespan in seconds (0 = server default / infinite) + * @param maxIdle Max idle in seconds (0 = server default / infinite) + * @return Future: true if the entry was replaced; false if the version + * did not match (entry modified) or the key did not exist + * @throws std::runtime_error on communication errors or unexpected status + * (via future.get()) + * + * Usage: + * if (auto entry = cache.getWithMetadata(key).get()) { + * bool replaced = cache.replaceWithVersion(key, newValue, + * entry->metadata.version).get(); + * } + * + * Reference: + * - Java: org.infinispan.client.hotrod.impl.operations.ReplaceIfUnmodifiedOperation + */ + std::future replaceWithVersion(const ByteArray& key, const ByteArray& value, + int64_t version, uint64_t lifespan = 0, uint64_t maxIdle = 0); + /** * Close connection to server. */ diff --git a/src/operations/RemoteCache.cpp b/src/operations/RemoteCache.cpp index 382ddfd..131e9cd 100644 --- a/src/operations/RemoteCache.cpp +++ b/src/operations/RemoteCache.cpp @@ -516,6 +516,77 @@ namespace hotrod }); } + std::future RemoteCache::replaceWithVersion(const ByteArray &key, const ByteArray &value, + int64_t version, uint64_t lifespan, uint64_t maxIdle) + { + // Build REPLACE_WITH_VERSION request body: + // key (lp_bytes) + time_units (u1) + [lifespan vLong] + [maxIdle vLong] + // + version (8-byte big-endian s8) + value (lp_bytes) + // Mirrors Java ReplaceIfUnmodifiedOperation, which writes the *same* + // expiration params as PUT, then buf.writeLong(version), then the value. + // + // NOTE: The expiration byte packs the lifespan time unit in the HIGH + // nibble and maxIdle in the LOW nibble (Java TimeUnitParam.encodeTimeUnits: + // (encodedLifespan << 4) | encodedMaxIdle), identical to put(). The + // hotrod40.ksy `replace_if_unmodified_request` has these nibbles flipped; + // that is a schema bug — Java (authoritative) uses one shared encoder for + // both PUT and replaceWithVersion. + ByteArray requestBody; + Codec::writeByteArray(requestBody, key); + + uint8_t timeUnits = 0; + timeUnits |= (lifespan == 0 ? 0x07 : 0x00) << 4; // high nibble: lifespan + timeUnits |= (maxIdle == 0 ? 0x07 : 0x00); // low nibble: maxIdle + requestBody.push_back(timeUnits); + if (lifespan > 0) + Codec::writeVLong(requestBody, lifespan); + if (maxIdle > 0) + Codec::writeVLong(requestBody, maxIdle); + + Codec::writeLong(requestBody, version); + Codec::writeByteArray(requestBody, value); + + // Status codes (see Java VersionedOperationResponse.RspCode): + // 0x00 = SUCCESS (replaced) -> true + // 0x01 = NOT_EXECUTED (version mismatch/modified) -> false + // 0x02 = KEY_DOES_NOT_EXIST -> false + // 0x03 = SUCCESS_WITH_PREVIOUS -> true (body present) + // 0x04 = NOT_EXECUTED_WITH_PREVIOUS -> false (body present) + // The *_WITH_PREVIOUS variants only occur when FORCE_RETURN_VALUE is set + // (not yet exposed). We drain their body defensively so the shared read + // loop stays byte-aligned if that flag is added later. + auto bodyParser = [](uint8_t status, Connection *conn, uint8_t /*protocolVersion*/, int32_t /*flags*/) -> std::any + { + if (status == 0x03 || status == 0x04) + { + EntryWithMetadata entry; + entry.metadata = conn->receiveMetadata(); + entry.value = conn->receiveByteArray(); + return entry; + } + if (status == 0x00 || status == 0x01 || status == 0x02) + { + return {}; + } + throw std::runtime_error("REPLACE_WITH_VERSION failed with status: " + std::to_string(status)); + }; + + // Execute + MultiplexedConnection *conn = selectServerForKey(key); + auto responseFuture = conn->execute(requestBody, 0x09, 0x0A, bodyParser, cacheName_); + + // Transform Response → bool (replaced?). Success is 0x00 or 0x03, matching + // Java's HotRodConstants.isSuccess / RspCode.isUpdated(). + return std::async(std::launch::deferred, + [responseFuture = std::move(responseFuture)]() mutable -> bool + { + Response resp = responseFuture.get(); + if (resp.error) + std::rethrow_exception(resp.error); + return resp.status == 0x00 || resp.status == 0x03; + }); + } + MultiplexedConnection *RemoteCache::selectServerForKey(const ByteArray &key) { // If hash-aware routing is enabled and hash topology is available diff --git a/tests/integration/ReplaceWithVersionIntegrationTest.cpp b/tests/integration/ReplaceWithVersionIntegrationTest.cpp new file mode 100644 index 0000000..b6cc55e --- /dev/null +++ b/tests/integration/ReplaceWithVersionIntegrationTest.cpp @@ -0,0 +1,162 @@ +#include +#include +#include "hotrod/RemoteCache.h" +#include "InfinispanTestEnvironment.h" + +using namespace hotrod; +using namespace hotrod::test; + +/** + * REPLACE_WITH_VERSION (replaceIfUnmodified) Integration Tests - No Auth + * + * Version-based conditional replace (CAS). Uses PUT + getWithMetadata to obtain + * the entry version, then replaceWithVersion to conditionally update the value. + * + * Reference: + * - ROADMAP Step 10: metadata / version-based operations + * - Java: org.infinispan.client.hotrod.impl.operations.ReplaceIfUnmodifiedOperation + */ + +namespace { + +void createCacheViaCLI(const std::string& cacheName) { + std::string cmd = "docker exec " + InfinispanTestEnvironment::containerID + + " bash -c \"echo 'create cache --template=org.infinispan.DIST_SYNC " + cacheName + + "' | /opt/infinispan/bin/cli.sh -c http://admin:password@localhost:11222\" >/dev/null 2>&1"; + std::ignore = system(cmd.c_str()); +} + +} // anonymous namespace + +// Test 1: matching version -> value is replaced +TEST(ReplaceWithVersionIntegrationTest, MatchingVersionReplaces) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'r', 'p', 'v', '1'}; + ByteArray value1 = {'o', 'l', 'd'}; + ByteArray value2 = {'n', 'e', 'w'}; + cache.put(key, value1).get(); + + auto entry = cache.getWithMetadata(key).get(); + ASSERT_TRUE(entry.has_value()); + int64_t version = entry->metadata.version; + + bool replaced = cache.replaceWithVersion(key, value2, version).get(); + EXPECT_TRUE(replaced); + + auto after = cache.get(key).get(); + ASSERT_TRUE(after.has_value()); + EXPECT_EQ(value2, after.value()); + + cache.disconnect(); +} + +// Test 2: stale version -> not replaced, original value survives +TEST(ReplaceWithVersionIntegrationTest, StaleVersionDoesNotReplace) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'r', 'p', 'v', '2'}; + ByteArray value1 = {'f', 'i', 'r', 's', 't'}; + cache.put(key, value1).get(); + + auto entry1 = cache.getWithMetadata(key).get(); + ASSERT_TRUE(entry1.has_value()); + int64_t staleVersion = entry1->metadata.version; + + // Overwrite so the version changes. + ByteArray value2 = {'s', 'e', 'c', 'o', 'n', 'd'}; + cache.put(key, value2).get(); + + // Replace with the stale version must fail. + ByteArray value3 = {'t', 'h', 'i', 'r', 'd'}; + bool replaced = cache.replaceWithVersion(key, value3, staleVersion).get(); + EXPECT_FALSE(replaced); + + // Value remains the second one, not the attempted third. + auto after = cache.get(key).get(); + ASSERT_TRUE(after.has_value()); + EXPECT_EQ(value2, after.value()); + + cache.disconnect(); +} + +// Test 3: missing key -> not replaced +TEST(ReplaceWithVersionIntegrationTest, MissingKeyDoesNotReplace) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'r', 'p', 'v', 'n', 'o', 'p', 'e'}; + ByteArray value = {'x'}; + bool replaced = cache.replaceWithVersion(key, value, 123456789LL).get(); + EXPECT_FALSE(replaced); + + auto after = cache.get(key).get(); + EXPECT_FALSE(after.has_value()); // must not have been created + + cache.disconnect(); +} + +// Test 4: version advances after a successful replace (chained CAS) +TEST(ReplaceWithVersionIntegrationTest, VersionAdvancesAfterReplace) { + createCacheViaCLI("testcache"); + + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + + ByteArray key = {'r', 'p', 'v', '4'}; + cache.put(key, ByteArray{'a'}).get(); + + auto e1 = cache.getWithMetadata(key).get(); + ASSERT_TRUE(e1.has_value()); + int64_t v1 = e1->metadata.version; + + ASSERT_TRUE(cache.replaceWithVersion(key, ByteArray{'b'}, v1).get()); + + // Old version now stale; a second replace with v1 must fail. + EXPECT_FALSE(cache.replaceWithVersion(key, ByteArray{'c'}, v1).get()); + + // Re-read version and replace succeeds again. + auto e2 = cache.getWithMetadata(key).get(); + ASSERT_TRUE(e2.has_value()); + EXPECT_NE(v1, e2->metadata.version); + EXPECT_TRUE(cache.replaceWithVersion(key, ByteArray{'c'}, e2->metadata.version).get()); + + cache.disconnect(); +} + +// Test 5: replaceWithVersion after disconnect should fail +TEST(ReplaceWithVersionIntegrationTest, ReplaceWithVersionAfterDisconnect) { + RemoteCache cache(InfinispanTestEnvironment::host, + InfinispanTestEnvironment::port, + "testcache"); + cache.connect(); + cache.disconnect(); + + ByteArray key = {'t', 'e', 's', 't'}; + ByteArray value = {'v'}; + EXPECT_THROW({ + cache.replaceWithVersion(key, value, 1); + }, std::runtime_error); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + ::testing::AddGlobalTestEnvironment(new InfinispanTestEnvironment()); + return RUN_ALL_TESTS(); +} diff --git a/tests/unit/ReplaceWithVersionTest.cpp b/tests/unit/ReplaceWithVersionTest.cpp new file mode 100644 index 0000000..30f996d --- /dev/null +++ b/tests/unit/ReplaceWithVersionTest.cpp @@ -0,0 +1,166 @@ +#include +#include "hotrod/Codec.h" +#include "hotrod/HeaderCodec.h" +#include +#include + +using namespace hotrod; + +/** + * REPLACE_WITH_VERSION (replaceIfUnmodified) Unit Tests + * + * Request format (opcode 0x09): + * - Protocol 4.0 header (11+ bytes) + * - Key (lp_bytes) + * - time_units (u1): HIGH nibble = lifespan unit, LOW nibble = maxIdle unit + * (0x07 = DEFAULT/infinite). Matches Java TimeUnitParam.encodeTimeUnits: + * (encodedLifespan << 4) | encodedMaxIdle — same encoder PUT uses. + * - lifespan (vLong) only if lifespan unit != DEFAULT + * - max_idle (vLong) only if maxIdle unit != DEFAULT + * - Version (int64, 8 bytes, big-endian / s8) + * - Value (lp_bytes) + * + * NOTE: hotrod40.ksy `replace_if_unmodified_request` has the two expiration + * nibbles flipped vs `put_request`/`expiration_params`; that is a schema bug. + * Java uses one shared writeExpirationParams for PUT and replaceWithVersion, + * so the encoding below (high=lifespan, low=maxIdle) is authoritative. + * + * Reference: + * - Java: org.infinispan.client.hotrod.impl.operations.ReplaceIfUnmodifiedOperation + * (writeArray(key); writeExpirationParams(...); writeLong(version); writeArray(value)) + */ + +namespace { + +RequestHeader makeHeader() { + RequestHeader h; + h.messageId = 1; + h.version = 0x28; // Protocol 4.0 + h.opcode = 0x09; // REPLACE_WITH_VERSION_REQUEST + h.cacheName = ""; + h.flags = 0; + h.clientIntelligence = ClientIntelligence::BASIC; + h.topologyId = 0; + h.keyMediaType = 0; + h.valueMediaType = 0; + return h; +} + +} // namespace + +// Test 1: default expiration (lifespan=0, maxIdle=0) -> time_units 0x77, no +// vLongs; then version (8 bytes) then value. +TEST(ReplaceWithVersionTest, EncodeDefaultExpiration) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {'k', '1'}; + ByteArray value = {'v', '1', 'v'}; + Codec::writeByteArray(request, key); + + request.push_back(0x77); // high=0x7 lifespan default, low=0x7 maxIdle default + Codec::writeLong(request, static_cast(0x1122334455667788LL)); + Codec::writeByteArray(request, value); + + EXPECT_EQ(0xA0, request[0]); + EXPECT_EQ(0x09, request[3]); // opcode + + size_t off = 11; + ByteArray decKey = Codec::readByteArray(request, off); + EXPECT_EQ(key, decKey); + + uint8_t timeUnits = request[off++]; + EXPECT_EQ(0x77, timeUnits); // both default -> no vLongs follow + + int64_t version = Codec::readLong(request, off); + EXPECT_EQ(0x1122334455667788LL, version); + + ByteArray decVal = Codec::readByteArray(request, off); + EXPECT_EQ(value, decVal); + EXPECT_EQ(off, request.size()); // nothing trailing +} + +// Test 2: lifespan set (high nibble = SECONDS 0x0), maxIdle default (low 0x7) +TEST(ReplaceWithVersionTest, EncodeLifespanOnly) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {'k'}; + ByteArray value = {'v'}; + Codec::writeByteArray(request, key); + + uint8_t timeUnits = (0x00 << 4) | 0x07; // lifespan SECONDS, maxIdle DEFAULT + request.push_back(timeUnits); + Codec::writeVLong(request, 30); // lifespan present + Codec::writeLong(request, static_cast(7)); + Codec::writeByteArray(request, value); + + size_t off = 11; + Codec::readByteArray(request, off); // key + uint8_t tu = request[off++]; + EXPECT_EQ(0x07, tu); // high=0 lifespan, low=7 maxIdle + EXPECT_EQ(0x00, (tu >> 4) & 0x0F); // lifespan unit present + EXPECT_EQ(0x07, tu & 0x0F); // maxIdle default + + uint64_t lifespan = Codec::readVLong(request, off); + EXPECT_EQ(30u, lifespan); + EXPECT_EQ(7, Codec::readLong(request, off)); // version follows lifespan +} + +// Test 3: maxIdle set (low nibble = SECONDS 0x0), lifespan default (high 0x7) +TEST(ReplaceWithVersionTest, EncodeMaxIdleOnly) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {'k'}; + ByteArray value = {'v'}; + Codec::writeByteArray(request, key); + + uint8_t timeUnits = (0x07 << 4) | 0x00; // lifespan DEFAULT, maxIdle SECONDS + request.push_back(timeUnits); + Codec::writeVLong(request, 45); // maxIdle present + Codec::writeLong(request, static_cast(9)); + Codec::writeByteArray(request, value); + + size_t off = 11; + Codec::readByteArray(request, off); // key + uint8_t tu = request[off++]; + EXPECT_EQ(0x70, tu); // high=7 lifespan default, low=0 maxIdle + EXPECT_EQ(0x07, (tu >> 4) & 0x0F); // lifespan default + EXPECT_EQ(0x00, tu & 0x0F); // maxIdle unit present + + uint64_t maxIdle = Codec::readVLong(request, off); + EXPECT_EQ(45u, maxIdle); + EXPECT_EQ(9, Codec::readLong(request, off)); // version follows maxIdle +} + +// Test 4: both lifespan and maxIdle set -> two vLongs, lifespan first +TEST(ReplaceWithVersionTest, EncodeBothExpirations) { + ByteArray request; + HeaderCodec::writeRequestHeader(request, makeHeader()); + + ByteArray key = {'k'}; + ByteArray value = {'v'}; + Codec::writeByteArray(request, key); + + uint8_t timeUnits = (0x00 << 4) | 0x00; // both SECONDS + request.push_back(timeUnits); + Codec::writeVLong(request, 30); // lifespan + Codec::writeVLong(request, 45); // maxIdle + Codec::writeLong(request, static_cast(-1)); + Codec::writeByteArray(request, value); + + size_t off = 11; + Codec::readByteArray(request, off); // key + EXPECT_EQ(0x00, request[off++]); // time_units + EXPECT_EQ(30u, Codec::readVLong(request, off)); // lifespan first + EXPECT_EQ(45u, Codec::readVLong(request, off)); // maxIdle second + EXPECT_EQ(-1, Codec::readLong(request, off)); // version + EXPECT_EQ(value, Codec::readByteArray(request, off)); +} + +// Test 5: opcode constants +TEST(ReplaceWithVersionTest, OpcodeConstants) { + EXPECT_EQ(0x09, Opcode::REPLACE_WITH_VERSION_REQUEST); + EXPECT_EQ(0x0A, Opcode::REPLACE_WITH_VERSION_RESPONSE); +}