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
51 changes: 51 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ if(GTest_FOUND)
tests/unit/RemoveTest.cpp
tests/unit/RemoveWithVersionTest.cpp
tests/unit/ReplaceWithVersionTest.cpp
tests/unit/PutIfAbsentTest.cpp
tests/unit/ReplaceTest.cpp
tests/unit/ContainsKeyTest.cpp
tests/unit/HashAwareRoutingTest.cpp
)

Expand Down Expand Up @@ -140,6 +143,18 @@ if(GTest_FOUND)
tests/integration/ReplaceWithVersionIntegrationTest.cpp
)

add_executable(putifabsent_integration_tests
tests/integration/PutIfAbsentIntegrationTest.cpp
)

add_executable(replace_integration_tests
tests/integration/ReplaceIntegrationTest.cpp
)

add_executable(containskey_integration_tests
tests/integration/ContainsKeyIntegrationTest.cpp
)

# Topology integration tests (multi-server cluster)
add_executable(topology_integration_tests
tests/integration/TopologyChangeTest.cpp
Expand Down Expand Up @@ -199,6 +214,27 @@ if(GTest_FOUND)
# Note: NOT gtest_main - we provide our own main()
)

target_link_libraries(putifabsent_integration_tests
PRIVATE
hotrod-client
GTest::gtest
# Note: NOT gtest_main - we provide our own main()
)

target_link_libraries(replace_integration_tests
PRIVATE
hotrod-client
GTest::gtest
# Note: NOT gtest_main - we provide our own main()
)

target_link_libraries(containskey_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 @@ -244,6 +280,9 @@ if(GTest_FOUND)
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 PutIfAbsentIntegrationTests COMMAND putifabsent_integration_tests)
add_test(NAME ReplaceIntegrationTests COMMAND replace_integration_tests)
add_test(NAME ContainsKeyIntegrationTests COMMAND containskey_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 @@ -278,6 +317,18 @@ if(GTest_FOUND)
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
LABELS "integration;docker"
)
set_tests_properties(PutIfAbsentIntegrationTests PROPERTIES
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
LABELS "integration;docker"
)
set_tests_properties(ReplaceIntegrationTests PROPERTIES
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
LABELS "integration;docker"
)
set_tests_properties(ContainsKeyIntegrationTests 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
35 changes: 20 additions & 15 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
`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`.
1. **Step 10 is complete.** `getWithMetadata` (0x1B/0x1C), `removeWithVersion`
(0x0D/0x0E), `replaceWithVersion` (0x09/0x0A) and the conditionals
`putIfAbsent` (0x05/0x06), `replace` (0x07/0x08), `containsKey` (0x0F/0x10)
are all **shipped** (see "Working and shipped"). Next roadmap step:
**Step 11 — Error handling** (ERROR response 0x50, exception hierarchy,
retry on transient errors).
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 @@ -53,7 +53,7 @@ is authoritative for what's done._
- [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`).
- [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)
- [x] `putIfAbsent` (0x05/0x06), `replace` (0x07/0x08), `containsKey` (0x0F/0x10) — **shipped 2026-09-20** (11 unit + 12 integration tests). `putIfAbsent`/`replace` reuse PUT's request layout (`key` + expiration byte + `value`) and return `future<optional<EntryWithMetadata>>` — the previous/replaced entry when `previousValue` (FORCE_RETURN_VALUE) is set, `nullopt` otherwise; parsers drain the `*_WITH_PREVIOUS` (0x03/0x04) body defensively. `containsKey` is a key-only request returning `future<bool>` from the status (no response body). Cross-checked against Java `PutIfAbsentOperation`/`ReplaceOperation`/`ContainsKeyOperation` and `hotrod40.ksy` (put_if_absent/replace → `put_request`/`put_response`; contains_key → `key_request`, status-only response).

Java ref: `GetWithMetadataOperation`, `ReplaceIfUnmodifiedOperation`. See the
[step-by-step plan](#plan-metadata-operations-step-10) below.
Expand Down Expand Up @@ -99,6 +99,11 @@ multiplexing. See "Working and shipped" below and `PROGRESS.md`._
returns `future<bool>` (removed?), version from `getWithMetadata`
- **replaceWithVersion** (0x09/0x0A) — version-based conditional replace (CAS);
returns `future<bool>` (replaced?); request = key + expiration + version + value
- **putIfAbsent** (0x05/0x06) — store only if key absent; returns
`future<optional<EntryWithMetadata>>` (existing entry when `previousValue` set)
- **replace** (0x07/0x08) — store only if key present; returns
`future<optional<EntryWithMetadata>>` (replaced entry when `previousValue` set)
- **containsKey** (0x0F/0x10) — key-existence test; returns `future<bool>`
- **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 @@ -111,8 +116,8 @@ multiplexing. See "Working and shipped" below and `PROGRESS.md`._
portability and `-Werror` build parity also landed (Sept 2026).

**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
- Unit: **176/176** passing (`./build/unit_tests`, <1s)
- Integration: **75/75** passing across 14 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 @@ -174,25 +179,25 @@ on 2026-09-16. That `.ksy` is the wire-format source of truth for protocol
|-----------|-----|------|----------|
| put | 0x01 | 0x02 | ✅ |
| get | 0x03 | 0x04 | ✅ |
| putIfAbsent | 0x05 | 0x06 | ❌ |
| replace (only if present) | 0x07 | 0x08 | ❌ |
| putIfAbsent | 0x05 | 0x06 | ✅ |
| replace (only if present) | 0x07 | 0x08 | ✅ |
| **replaceWithVersion** (replaceIfUnmodified) | **0x09** | **0x0A** | ✅ |
| remove | 0x0B | 0x0C | ✅ |
| **removeWithVersion** (removeIfUnmodified) | **0x0D** | **0x0E** | ✅ |
| containsKey | 0x0F | 0x10 | ❌ |
| containsKey | 0x0F | 0x10 | ✅ |
| getWithVersion | 0x11 | 0x12 | ❌ |
| **getWithMetadata** | **0x1B** | **0x1C** | ✅ |

Opcode constants are defined in `include/hotrod/HeaderCodec.h` (`Opcodes`
namespace) — currently only PUT/GET/REMOVE/PING. Add the new ones there.
Opcode constants are defined in `include/hotrod/HeaderCodec.h` (`Opcode`
namespace). All Step 10 opcodes above are present; add new ones there.

### Recommended order
`getWithMetadata` first (it yields the entry version), then the two
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)~~ ✅ shipped 2026-09-20
4. putIfAbsent (0x05/0x06), replace (0x07/0x08), containsKey (0x0F/0x10) ← next session
4. ~~putIfAbsent (0x05/0x06), replace (0x07/0x08), containsKey (0x0F/0x10)~~ ✅ shipped 2026-09-20 — **Step 10 complete**

### Step-by-step: getWithMetadata (the entry point)

Expand Down
6 changes: 6 additions & 0 deletions include/hotrod/HeaderCodec.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@ namespace Opcode {
const uint8_t PUT_RESPONSE = 0x02;
const uint8_t GET_REQUEST = 0x03;
const uint8_t GET_RESPONSE = 0x04;
const uint8_t PUT_IF_ABSENT_REQUEST = 0x05;
const uint8_t PUT_IF_ABSENT_RESPONSE = 0x06;
const uint8_t REPLACE_REQUEST = 0x07;
const uint8_t REPLACE_RESPONSE = 0x08;
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
const uint8_t REMOVE_WITH_VERSION_RESPONSE = 0x0E;
const uint8_t CONTAINS_KEY_REQUEST = 0x0F;
const uint8_t CONTAINS_KEY_RESPONSE = 0x10;
const uint8_t GET_WITH_METADATA_REQUEST = 0x1B;
const uint8_t GET_WITH_METADATA_RESPONSE = 0x1C;
const uint8_t PING_REQUEST = 0x17;
Expand Down
62 changes: 62 additions & 0 deletions include/hotrod/RemoteCache.h
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,68 @@ class RemoteCache {
std::future<std::optional<EntryWithMetadata>> put(const ByteArray &key, const ByteArray &value,
uint64_t lifespan = 0, uint64_t maxIdle = 0, bool previousValue = false);

/**
* PUT_IF_ABSENT operation - store key-value pair only if the key is absent.
*
* Opcode: 0x05 (PUT_IF_ABSENT_REQUEST) → 0x06 (PUT_IF_ABSENT_RESPONSE)
*
* Stores the entry only if no value is currently associated with the key.
* If the key already exists the entry is left untouched.
*
* @param key The key as raw bytes
* @param value The value as raw bytes
* @param lifespan Entry lifespan in seconds (0 = immortal)
* @param maxIdle Max idle time in seconds (0 = no max idle)
* @param previousValue If true, request previous value with metadata (Protocol 4.0+)
* @return Future with optional<EntryWithMetadata>: the existing entry that
* prevented storage (when previousValue is set and the key existed);
* nullopt if the value was stored (key was absent)
* @throws std::runtime_error on communication errors (via future.get())
*
* Reference:
* - Java: org.infinispan.client.hotrod.impl.operations.PutIfAbsentOperation
*/
std::future<std::optional<EntryWithMetadata>> putIfAbsent(const ByteArray &key, const ByteArray &value,
uint64_t lifespan = 0, uint64_t maxIdle = 0, bool previousValue = false);

/**
* REPLACE operation - store key-value pair only if the key is present.
*
* Opcode: 0x07 (REPLACE_REQUEST) → 0x08 (REPLACE_RESPONSE)
*
* Replaces the entry's value only if a value is currently associated with
* the key. If the key does not exist nothing is stored.
*
* @param key The key as raw bytes
* @param value The new value as raw bytes
* @param lifespan Entry lifespan in seconds (0 = immortal)
* @param maxIdle Max idle time in seconds (0 = no max idle)
* @param previousValue If true, request previous value with metadata (Protocol 4.0+)
* @return Future with optional<EntryWithMetadata>: the replaced (previous)
* entry (when previousValue is set and the key existed); nullopt if
* the key did not exist (nothing replaced)
* @throws std::runtime_error on communication errors (via future.get())
*
* Reference:
* - Java: org.infinispan.client.hotrod.impl.operations.ReplaceOperation
*/
std::future<std::optional<EntryWithMetadata>> replace(const ByteArray &key, const ByteArray &value,
uint64_t lifespan = 0, uint64_t maxIdle = 0, bool previousValue = false);

/**
* CONTAINS_KEY operation - test whether a key exists in the cache.
*
* Opcode: 0x0F (CONTAINS_KEY_REQUEST) → 0x10 (CONTAINS_KEY_RESPONSE)
*
* @param key The key as raw bytes
* @return Future<bool>: true if the key exists; false otherwise
* @throws std::runtime_error on communication errors (via future.get())
*
* Reference:
* - Java: org.infinispan.client.hotrod.impl.operations.ContainsKeyOperation
*/
std::future<bool> containsKey(const ByteArray &key);

/**
* GET_WITH_METADATA operation - retrieve value plus entry metadata.
*
Expand Down
Loading
Loading