Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
38 changes: 24 additions & 14 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand All @@ -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`
Expand All @@ -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<bool>` (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<bool>`; 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
Expand Down Expand Up @@ -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<bool>` (removed?), version from `getWithMetadata`
- **replaceWithVersion** (0x09/0x0A) — version-based conditional replace (CAS);
returns `future<bool>` (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.
Expand All @@ -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
Expand Down Expand Up @@ -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: <https://github.com/rigazilla/hotrod-dissector/tree/main/schemas>.
>
> **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)

Expand All @@ -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 | ❌ |
Expand All @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions include/hotrod/HeaderCodec.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions include/hotrod/RemoteCache.h
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,36 @@ class RemoteCache {
*/
std::future<bool> 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<bool>: 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<bool> replaceWithVersion(const ByteArray& key, const ByteArray& value,
int64_t version, uint64_t lifespan = 0, uint64_t maxIdle = 0);

/**
* Close connection to server.
*/
Expand Down
71 changes: 71 additions & 0 deletions src/operations/RemoteCache.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,77 @@ namespace hotrod
});
}

std::future<bool> 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
Expand Down
Loading
Loading