From d8cf64e8c6a7c0fbe9b04ffafed03b24d1addfd4 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Tue, 2 Jun 2026 10:28:25 -0300 Subject: [PATCH 1/5] feat: wire ElementId into Counter/Register/Map + Map with recursive merge - Counter/Register/Map opaque, carry an ElementId set at create. - Map slot value is Element (was Scalar). map_set/get/delete/merge updated. - map_merge same-key path: same composite kind + same id -> element_merge (recursive). Otherwise LWW on slot stamp. - LWW with composite displacement host_aborts (cross-arena pointer would dangle). Deterministic id derivation in PR 5 keeps the path unreachable in normal use. - element.h breaks the map.h cycle via forward decl typedef struct Map. - elementid.h header guard fixed (#define was missing). - Death test test_map_abort.c exercises the abort; Makefile inverts exit. --- .gitignore | 1 + Makefile | 25 ++- counter.c | 11 +- counter.h | 10 +- element.c | 1 + element.h | 6 +- elementid.h | 4 +- map.c | 86 ++++++-- map.h | 12 +- register.c | 13 +- register.h | 12 +- test_counter.c | 27 ++- test_element.c | 59 +++--- test_map.c | 498 +++++++++++++++++++++++++++++++++-------------- test_map_abort.c | 45 +++++ test_register.c | 18 +- 16 files changed, 614 insertions(+), 214 deletions(-) create mode 100644 test_map_abort.c diff --git a/.gitignore b/.gitignore index 9c55a3c..756daed 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ a.out /test_clientid /test_stamp /test_map +/test_map_abort /test_elementid /test_element diff --git a/Makefile b/Makefile index e9be101..f0ba96b 100644 --- a/Makefile +++ b/Makefile @@ -37,8 +37,8 @@ test-string: string.c test_string.c test_util.h ./test_string .PHONY: test-counter -test-counter: arena.c string.c hashtable.c clientid.c host_posix.c counter.c test_counter.c test_util.h - $(CC) $(CFLAGS) -o test_counter arena.c string.c hashtable.c clientid.c host_posix.c counter.c test_counter.c +test-counter: arena.c string.c hashtable.c clientid.c elementid.c host_posix.c counter.c test_counter.c test_util.h + $(CC) $(CFLAGS) -o test_counter arena.c string.c hashtable.c clientid.c elementid.c host_posix.c counter.c test_counter.c ./test_counter .PHONY: test-scalar @@ -47,18 +47,25 @@ test-scalar: arena.c string.c host_posix.c scalar.c test_scalar.c test_util.h ./test_scalar .PHONY: test-register -test-register: arena.c string.c clientid.c host_posix.c stamp.c scalar.c register.c test_register.c test_util.h - $(CC) $(CFLAGS) -o test_register arena.c string.c clientid.c host_posix.c stamp.c scalar.c register.c test_register.c +test-register: arena.c string.c clientid.c elementid.c host_posix.c stamp.c scalar.c register.c test_register.c test_util.h + $(CC) $(CFLAGS) -o test_register arena.c string.c clientid.c elementid.c host_posix.c stamp.c scalar.c register.c test_register.c ./test_register .PHONY: test-map -test-map: arena.c string.c hashtable.c clientid.c host_posix.c stamp.c scalar.c map.c test_map.c test_util.h - $(CC) $(CFLAGS) -o test_map arena.c string.c hashtable.c clientid.c host_posix.c stamp.c scalar.c map.c test_map.c +test-map: arena.c string.c hashtable.c clientid.c elementid.c host_posix.c stamp.c scalar.c register.c counter.c element.c map.c test_map.c test_util.h + $(CC) $(CFLAGS) -o test_map arena.c string.c hashtable.c clientid.c elementid.c host_posix.c stamp.c scalar.c register.c counter.c element.c map.c test_map.c ./test_map +# Death test: binary must abort. Exit status is inverted so success means +# the process died via host_abort. +.PHONY: test-map-abort +test-map-abort: arena.c string.c hashtable.c clientid.c elementid.c host_posix.c stamp.c scalar.c register.c counter.c element.c map.c test_map_abort.c + $(CC) $(CFLAGS) -o test_map_abort arena.c string.c hashtable.c clientid.c elementid.c host_posix.c stamp.c scalar.c register.c counter.c element.c map.c test_map_abort.c + ! ./test_map_abort 2>/dev/null + .PHONY: test-element -test-element: arena.c string.c hashtable.c clientid.c host_posix.c stamp.c scalar.c register.c counter.c map.c element.c test_element.c test_util.h - $(CC) $(CFLAGS) -o test_element arena.c string.c hashtable.c clientid.c host_posix.c stamp.c scalar.c register.c counter.c map.c element.c test_element.c +test-element: arena.c string.c hashtable.c clientid.c elementid.c host_posix.c stamp.c scalar.c register.c counter.c map.c element.c test_element.c test_util.h + $(CC) $(CFLAGS) -o test_element arena.c string.c hashtable.c clientid.c elementid.c host_posix.c stamp.c scalar.c register.c counter.c map.c element.c test_element.c ./test_element .PHONY: test-clientid @@ -77,4 +84,4 @@ test-elementid: string.c clientid.c host_posix.c elementid.c test_elementid.c te ./test_elementid .PHONY: test -test: test-arena test-hashtable test-string test-counter test-scalar test-register test-clientid test-stamp test-map test-elementid test-element +test: test-arena test-hashtable test-string test-counter test-scalar test-register test-clientid test-stamp test-map test-map-abort test-elementid test-element diff --git a/counter.c b/counter.c index feb2fdf..a6eafea 100644 --- a/counter.c +++ b/counter.c @@ -3,6 +3,12 @@ #include "hashtable.h" #include "host.h" +struct Counter { + ElementId id; + Arena *arena; + HashTable *entries; // client_id (uint32_t) -> CounterEntry +}; + static inline uint32_t max_u32(uint32_t a, uint32_t b) { if (a > b) { return a; @@ -11,13 +17,14 @@ static inline uint32_t max_u32(uint32_t a, uint32_t b) { return b; } -Counter *counter_create(Arena *arena) { +Counter *counter_create(Arena *arena, ElementId id) { Counter *counter = arena_alloc(arena, sizeof(Counter)); if (!counter) { host_abortf( "counter_create: arena OOM (requested %zu bytes for Counter)", sizeof(Counter)); } + counter->id = id; counter->arena = arena; counter->entries = hashtable_create(arena); if (!counter->entries) { @@ -26,6 +33,8 @@ Counter *counter_create(Arena *arena) { return counter; } +ElementId counter_id(const Counter *counter) { return counter->id; } + int64_t counter_read(const Counter *counter) { int64_t total = 0; HashTableIter it = hashtable_iter(counter->entries); diff --git a/counter.h b/counter.h index 388cd29..9749201 100644 --- a/counter.h +++ b/counter.h @@ -3,6 +3,7 @@ #include "arena.h" #include "clientid.h" +#include "elementid.h" #include "hashtable.h" #include @@ -12,12 +13,11 @@ typedef struct CounterEntry { uint32_t dec; } CounterEntry; -typedef struct Counter { - Arena *arena; - HashTable *entries; // client_id (uint32_t) -> CounterEntry -} Counter; +typedef struct Counter Counter; -Counter *counter_create(Arena *arena); +Counter *counter_create(Arena *arena, ElementId id); + +ElementId counter_id(const Counter *counter); int64_t counter_read(const Counter *counter); diff --git a/element.c b/element.c index 65da63f..22853ff 100644 --- a/element.c +++ b/element.c @@ -1,5 +1,6 @@ #include "element.h" #include "host.h" +#include "map.h" Element element_scalar(Scalar s) { Element e = {.kind = ELEMENT_SCALAR, .as.scalar = s}; diff --git a/element.h b/element.h index 94b4a77..9045c89 100644 --- a/element.h +++ b/element.h @@ -1,10 +1,14 @@ #ifndef _CRDT_ELEMENT_H +#define _CRDT_ELEMENT_H #include "counter.h" -#include "map.h" #include "register.h" #include "scalar.h" +typedef struct Map Map; +typedef struct Register Register; +typedef struct Counter Counter; + typedef enum ElementKind { ELEMENT_SCALAR, ELEMENT_REGISTER, diff --git a/elementid.h b/elementid.h index 0794612..73ebf4e 100644 --- a/elementid.h +++ b/elementid.h @@ -1,5 +1,7 @@ -#include "clientid.h" #ifndef _CRDT_ELEMENTID_H +#define _CRDT_ELEMENTID_H + +#include "clientid.h" typedef struct ElementId { ClientId origin; diff --git a/map.c b/map.c index be02f56..23c69c3 100644 --- a/map.c +++ b/map.c @@ -1,25 +1,28 @@ #include "map.h" +#include "element.h" #include "hashtable.h" #include "host.h" #include "string.h" typedef struct MapEntry { Stamp stamp; - Scalar value; + Element value; bool is_tombstone; } Entry; struct Map { + ElementId id; Arena *arena; HashTable *entries; }; -Map *map_create(Arena *arena) { +Map *map_create(Arena *arena, ElementId id) { Map *map = arena_alloc(arena, sizeof(Map)); if (!map) { host_abortf("map_create: arena OOM (requested %zu bytes for Map)", sizeof(Map)); } + map->id = id; map->arena = arena; map->entries = hashtable_create(arena); if (!map->entries) { @@ -28,7 +31,9 @@ Map *map_create(Arena *arena) { return map; } -bool map_get(const Map *map, const void *key, size_t key_len, Scalar *out) { +ElementId map_id(const Map *map) { return map->id; } + +bool map_get(const Map *map, const void *key, size_t key_len, Element *out) { void *entry; bool present = hashtable_get(map->entries, key, key_len, &entry); if (!present) { @@ -44,7 +49,7 @@ bool map_get(const Map *map, const void *key, size_t key_len, Scalar *out) { return true; } -void map_set(Map *map, const void *key, size_t key_len, Scalar value, +void map_set(Map *map, const void *key, size_t key_len, Element value, Stamp stamp) { Entry *entry; bool present = hashtable_get(map->entries, key, key_len, (void **)&entry); @@ -65,11 +70,23 @@ void map_set(Map *map, const void *key, size_t key_len, Scalar value, } } - Scalar copy = scalar_dup(map->arena, value); + switch (value.kind) { + case ELEMENT_SCALAR: { + Scalar copy = scalar_dup(map->arena, value.as.scalar); + value.as.scalar = copy; + break; + } + case ELEMENT_REGISTER: + case ELEMENT_COUNTER: + case ELEMENT_MAP: + // Composite values are pointers to separately-allocated heap + // objects; no dup needed. + break; + } + entry->value = value; entry->stamp = stamp; entry->is_tombstone = false; - entry->value = copy; } } @@ -102,15 +119,58 @@ void map_delete(Map *map, const void *key, size_t key_len, Stamp stamp) { void map_merge(Map *dst, const Map *src) { HashTableIter it = hashtable_iter(src->entries); - const void *k = NULL; - size_t klen = 0; - void *v = NULL; + const void *k; + size_t klen; + void *v; while (hashtable_iter_next(&it, &k, &klen, &v)) { - Entry *src_entry = v; - if (src_entry->is_tombstone) { - map_delete(dst, k, klen, src_entry->stamp); + Entry *se = v; + + Entry *de; + bool dst_has = hashtable_get(dst->entries, k, klen, (void **)&de); + + // Recursive: both alive, same composite kind and same id then + // element_merge. This wins over slot LWW. + if (dst_has && !de->is_tombstone && !se->is_tombstone && + de->value.kind == se->value.kind && + de->value.kind != ELEMENT_SCALAR) { + ElementId did, sid; + switch (de->value.kind) { + case ELEMENT_SCALAR: + did = sid = elementid_root(); // unused, won't compare equal, + // assign to silence warning + break; + case ELEMENT_REGISTER: + did = register_id(de->value.as.reg); + sid = register_id(se->value.as.reg); + break; + case ELEMENT_COUNTER: + did = counter_id(de->value.as.counter); + sid = counter_id(se->value.as.counter); + break; + case ELEMENT_MAP: + did = map_id(de->value.as.map); + sid = map_id(se->value.as.map); + break; + } + if (elementid_eq(did, sid)) { + element_merge(de->value, se->value); + continue; + } + } + + // LWW fallthrough. + if (se->is_tombstone) { + map_delete(dst, k, klen, se->stamp); } else { - map_set(dst, k, klen, src_entry->value, src_entry->stamp); + if (se->value.kind != ELEMENT_SCALAR) { + host_abortf("map_merge: cross-replica composite displacement " + "at key (LWW path) — " + "src %s id != dst id (or dst slot " + "empty/tombstone). Use deterministic " + "id derivation for composite slots.", + element_kind_name(se->value.kind)); + } + map_set(dst, k, klen, se->value, se->stamp); } } } diff --git a/map.h b/map.h index 6b3cdd5..b7bd0e1 100644 --- a/map.h +++ b/map.h @@ -1,7 +1,7 @@ #ifndef _CRDT_MAP_H #define _CRDT_MAP_H -// LWW Map with tombstones, keyed on raw bytes (binary-safe), Scalar-valued. +// LWW Map with tombstones, keyed on raw bytes (binary-safe), Element-valued. // // Semantics: // - Each slot carries a Stamp. set / delete take effect iff the new stamp @@ -25,6 +25,8 @@ // Lifetime: Map must not outlive its arena. #include "arena.h" +#include "element.h" +#include "elementid.h" #include "scalar.h" #include "stamp.h" #include @@ -32,13 +34,15 @@ typedef struct Map Map; -Map *map_create(Arena *arena); +Map *map_create(Arena *arena, ElementId id); + +ElementId map_id(const Map *map); // Returns true if the key has a live (non-tombstone) entry, in which case // *out is set. Returns false otherwise; *out is untouched. -bool map_get(const Map *map, const void *key, size_t key_len, Scalar *out); +bool map_get(const Map *map, const void *key, size_t key_len, Element *out); -void map_set(Map *map, const void *key, size_t key_len, Scalar value, +void map_set(Map *map, const void *key, size_t key_len, Element value, Stamp stamp); void map_delete(Map *map, const void *key, size_t key_len, Stamp stamp); diff --git a/register.c b/register.c index 1cb5b25..388d40c 100644 --- a/register.c +++ b/register.c @@ -5,6 +5,13 @@ #include #include +struct Register { + ElementId id; + Arena *arena; + Scalar value; + Stamp stamp; +}; + Scalar accept_value(Arena *arena, Scalar value) { switch (value.kind) { case SCALAR_STRING: { @@ -24,19 +31,23 @@ Scalar accept_value(Arena *arena, Scalar value) { } } -Register *register_create(Arena *arena, Scalar value, Stamp stamp) { +Register *register_create(Arena *arena, ElementId id, Scalar value, + Stamp stamp) { Register *reg = arena_alloc(arena, sizeof(Register)); if (!reg) { host_abortf( "register_create: arena OOM (requested %zu bytes for Register)", sizeof(Register)); } + reg->id = id; reg->arena = arena; reg->value = accept_value(arena, value); reg->stamp = stamp; return reg; } +ElementId register_id(const Register *reg) { return reg->id; } + Scalar register_read(const Register *reg) { return reg->value; } void register_set(Register *reg, Scalar value, Stamp stamp) { diff --git a/register.h b/register.h index d434498..a3e61bd 100644 --- a/register.h +++ b/register.h @@ -29,18 +29,18 @@ // Lifetime: Register must not outlive its arena. #include "arena.h" +#include "elementid.h" #include "scalar.h" #include "stamp.h" #include #include -typedef struct Register { - Arena *arena; - Scalar value; - Stamp stamp; -} Register; +typedef struct Register Register; -Register *register_create(Arena *arena, Scalar value, Stamp stamp); +Register *register_create(Arena *arena, ElementId id, Scalar value, + Stamp stamp); + +ElementId register_id(const Register *reg); Scalar register_read(const Register *reg); diff --git a/test_counter.c b/test_counter.c index a9592c0..08f5b0b 100644 --- a/test_counter.c +++ b/test_counter.c @@ -1,13 +1,9 @@ #include "arena.h" #include "clientid.h" #include "counter.h" +#include "elementid.h" #include "test_util.h" -static Counter *fresh(void) { - Arena *arena = arena_create(); - return counter_create(arena); -} - // Build a ClientId fixture from a single byte (rest zero). Keeps tests // compact; ClientId is otherwise 16 raw bytes. static ClientId cid(uint8_t first_byte) { @@ -16,6 +12,26 @@ static ClientId cid(uint8_t first_byte) { return clientid_from_bytes(b); } +static ElementId eid(uint8_t origin_byte, uint64_t seq) { + return elementid_new(cid(origin_byte), seq); +} + +// Default id for tests that don't care about identity. +static ElementId default_id(void) { return eid(0xFF, 0); } + +static Counter *fresh(void) { + Arena *arena = arena_create(); + return counter_create(arena, default_id()); +} + +TEST(counter_create_stores_id) { + Arena *a = arena_create(); + ElementId id = eid(7, 42); + Counter *c = counter_create(a, id); + ASSERT(elementid_eq(counter_id(c), id) == true); + arena_destroy(a); +} + // --- local operations (single replica) --- TEST(empty_reads_zero) { @@ -239,6 +255,7 @@ TEST(local_inc_after_merge_accumulates) { } int main(void) { + RUN(counter_create_stores_id); RUN(empty_reads_zero); RUN(single_inc); RUN(inc_then_dec_nets); diff --git a/test_element.c b/test_element.c index eda96b9..d73b59a 100644 --- a/test_element.c +++ b/test_element.c @@ -2,6 +2,7 @@ #include "clientid.h" #include "counter.h" #include "element.h" +#include "elementid.h" #include "map.h" #include "register.h" #include "scalar.h" @@ -17,6 +18,12 @@ static ClientId cid(uint8_t first_byte) { return clientid_from_bytes(b); } +static ElementId eid(uint8_t origin_byte, uint64_t seq) { + return elementid_new(cid(origin_byte), seq); +} + +static ElementId default_id(void) { return eid(0xFF, 0); } + static Stamp stmp(uint64_t lamport, uint8_t client_first_byte) { return (Stamp){.lamport = lamport, .client_id = cid(client_first_byte)}; } @@ -31,7 +38,7 @@ TEST(scalar_constructor_sets_kind_and_value) { TEST(register_constructor_sets_kind_and_pointer) { Arena *a = arena_create(); - Register *r = register_create(a, scalar_int(1), stmp(1, 1)); + Register *r = register_create(a, default_id(), scalar_int(1), stmp(1, 1)); Element e = element_register(r); ASSERT_EQ(element_kind(e), ELEMENT_REGISTER); ASSERT(e.as.reg == r); @@ -40,7 +47,7 @@ TEST(register_constructor_sets_kind_and_pointer) { TEST(counter_constructor_sets_kind_and_pointer) { Arena *a = arena_create(); - Counter *c = counter_create(a); + Counter *c = counter_create(a, default_id()); Element e = element_counter(c); ASSERT_EQ(element_kind(e), ELEMENT_COUNTER); ASSERT(e.as.counter == c); @@ -49,7 +56,7 @@ TEST(counter_constructor_sets_kind_and_pointer) { TEST(map_constructor_sets_kind_and_pointer) { Arena *a = arena_create(); - Map *m = map_create(a); + Map *m = map_create(a, default_id()); Element e = element_map(m); ASSERT_EQ(element_kind(e), ELEMENT_MAP); ASSERT(e.as.map == m); @@ -81,8 +88,10 @@ TEST(kind_name_map) { TEST(merge_register_takes_newer_value) { Arena *ad = arena_create(); Arena *as = arena_create(); - Register *dst = register_create(ad, scalar_int(10), stmp(1, 1)); - Register *src = register_create(as, scalar_int(20), stmp(5, 1)); // newer + Register *dst = + register_create(ad, default_id(), scalar_int(10), stmp(1, 1)); + Register *src = + register_create(as, default_id(), scalar_int(20), stmp(5, 1)); // newer element_merge(element_register(dst), element_register(src)); @@ -95,8 +104,8 @@ TEST(merge_register_takes_newer_value) { TEST(merge_counter_unions_clients) { Arena *ad = arena_create(); Arena *as = arena_create(); - Counter *dst = counter_create(ad); - Counter *src = counter_create(as); + Counter *dst = counter_create(ad, default_id()); + Counter *src = counter_create(as, default_id()); counter_inc(dst, cid(1), 5); counter_inc(src, cid(2), 3); @@ -112,19 +121,20 @@ TEST(merge_counter_unions_clients) { TEST(merge_map_takes_newer_slot) { Arena *ad = arena_create(); Arena *as = arena_create(); - Map *dst = map_create(ad); - Map *src = map_create(as); + Map *dst = map_create(ad, default_id()); + Map *src = map_create(as, default_id()); const uint8_t *k = (const uint8_t *)"k"; size_t klen = 1; - map_set(dst, k, klen, scalar_int(10), stmp(1, 1)); - map_set(src, k, klen, scalar_int(20), stmp(5, 1)); // newer + map_set(dst, k, klen, element_scalar(scalar_int(10)), stmp(1, 1)); + map_set(src, k, klen, element_scalar(scalar_int(20)), stmp(5, 1)); // newer element_merge(element_map(dst), element_map(src)); - Scalar out; + Element out; ASSERT(map_get(dst, k, klen, &out) == true); - ASSERT(scalar_eq(out, scalar_int(20))); + ASSERT_EQ(element_kind(out), ELEMENT_SCALAR); + ASSERT(scalar_eq(out.as.scalar, scalar_int(20))); arena_destroy(ad); arena_destroy(as); } @@ -133,8 +143,10 @@ TEST(merge_map_takes_newer_slot) { TEST(merge_register_does_not_mutate_src) { Arena *ad = arena_create(); Arena *as = arena_create(); - Register *dst = register_create(ad, scalar_int(99), stmp(10, 1)); // newer - Register *src = register_create(as, scalar_int(7), stmp(1, 1)); + Register *dst = + register_create(ad, default_id(), scalar_int(99), stmp(10, 1)); // newer + Register *src = + register_create(as, default_id(), scalar_int(7), stmp(1, 1)); element_merge(element_register(dst), element_register(src)); @@ -147,8 +159,8 @@ TEST(merge_register_does_not_mutate_src) { TEST(merge_counter_does_not_mutate_src) { Arena *ad = arena_create(); Arena *as = arena_create(); - Counter *dst = counter_create(ad); - Counter *src = counter_create(as); + Counter *dst = counter_create(ad, default_id()); + Counter *src = counter_create(as, default_id()); counter_inc(src, cid(1), 3); element_merge(element_counter(dst), element_counter(src)); @@ -162,16 +174,17 @@ TEST(merge_counter_does_not_mutate_src) { TEST(merge_map_does_not_mutate_src) { Arena *ad = arena_create(); Arena *as = arena_create(); - Map *dst = map_create(ad); - Map *src = map_create(as); + Map *dst = map_create(ad, default_id()); + Map *src = map_create(as, default_id()); const uint8_t *k = (const uint8_t *)"k"; - map_set(src, k, 1, scalar_int(7), stmp(1, 1)); + map_set(src, k, 1, element_scalar(scalar_int(7)), stmp(1, 1)); element_merge(element_map(dst), element_map(src)); - Scalar out; + Element out; ASSERT(map_get(src, k, 1, &out) == true); - ASSERT(scalar_eq(out, scalar_int(7))); + ASSERT_EQ(element_kind(out), ELEMENT_SCALAR); + ASSERT(scalar_eq(out.as.scalar, scalar_int(7))); arena_destroy(ad); arena_destroy(as); } @@ -180,7 +193,7 @@ TEST(merge_map_does_not_mutate_src) { // constructor / accessor pair without losing the payload. TEST(round_trip_via_kind_and_payload) { Arena *a = arena_create(); - Counter *c = counter_create(a); + Counter *c = counter_create(a, default_id()); Element e = element_counter(c); ASSERT_EQ(element_kind(e), ELEMENT_COUNTER); ASSERT(e.as.counter == c); diff --git a/test_map.c b/test_map.c index 750182b..bb7fa1a 100644 --- a/test_map.c +++ b/test_map.c @@ -1,6 +1,10 @@ #include "arena.h" #include "clientid.h" +#include "counter.h" +#include "element.h" +#include "elementid.h" #include "map.h" +#include "register.h" #include "scalar.h" #include "stamp.h" #include "string.h" @@ -14,6 +18,13 @@ static ClientId cid(uint8_t first_byte) { return clientid_from_bytes(b); } +static ElementId eid(uint8_t origin_byte, uint64_t seq) { + return elementid_new(cid(origin_byte), seq); +} + +// Default id for the Map under test when identity does not matter. +static ElementId default_id(void) { return eid(0xFF, 0); } + static Stamp stmp(uint64_t lamport, uint8_t client_first_byte) { return (Stamp){.lamport = lamport, .client_id = cid(client_first_byte)}; } @@ -21,92 +32,113 @@ static Stamp stmp(uint64_t lamport, uint8_t client_first_byte) { // String-key shorthand: expands to (bytes, length) without the NUL terminator. #define SK(s) ((const void *)(s)), strlen(s) +// Element wrappers for readability at the call site. +#define EI(n) element_scalar(scalar_int(n)) +#define ES(p, n) element_scalar(scalar_string((const uint8_t *)(p), (n))) + static Map *fresh(void) { Arena *arena = arena_create(); - return map_create(arena); + return map_create(arena, default_id()); +} + +// Assert helper: out is a SCALAR element equal to expected Scalar. +#define ASSERT_SCALAR_EQ(out, expected) \ + do { \ + ASSERT_EQ(element_kind(out), ELEMENT_SCALAR); \ + ASSERT(scalar_eq((out).as.scalar, (expected))); \ + } while (0) + +// --- identity --- + +TEST(map_create_stores_id) { + Arena *a = arena_create(); + ElementId id = eid(7, 42); + Map *m = map_create(a, id); + ASSERT(elementid_eq(map_id(m), id) == true); + arena_destroy(a); } -// --- local set / get --- +// --- local set / get (scalar slots) --- TEST(empty_get_returns_false) { Map *m = fresh(); - Scalar out; + Element out; ASSERT(map_get(m, SK("missing"), &out) == false); } TEST(set_then_get) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(42), stmp(1, 1)); - Scalar out; + map_set(m, SK("k"), EI(42), stmp(1, 1)); + Element out; ASSERT(map_get(m, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(42))); + ASSERT_SCALAR_EQ(out, scalar_int(42)); } TEST(set_overwrites_with_newer_stamp) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(10), stmp(1, 1)); - map_set(m, SK("k"), scalar_int(20), stmp(2, 1)); - Scalar out; + map_set(m, SK("k"), EI(10), stmp(1, 1)); + map_set(m, SK("k"), EI(20), stmp(2, 1)); + Element out; ASSERT(map_get(m, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(20))); + ASSERT_SCALAR_EQ(out, scalar_int(20)); } TEST(set_lower_stamp_ignored) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(20), stmp(5, 1)); - map_set(m, SK("k"), scalar_int(10), stmp(3, 1)); // older — ignored - Scalar out; + map_set(m, SK("k"), EI(20), stmp(5, 1)); + map_set(m, SK("k"), EI(10), stmp(3, 1)); // older — ignored + Element out; ASSERT(map_get(m, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(20))); + ASSERT_SCALAR_EQ(out, scalar_int(20)); } TEST(set_equal_lamport_higher_client_wins) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(10), stmp(5, 1)); - map_set(m, SK("k"), scalar_int(20), stmp(5, 2)); // same lamport, > client - Scalar out; + map_set(m, SK("k"), EI(10), stmp(5, 1)); + map_set(m, SK("k"), EI(20), stmp(5, 2)); // same lamport, > client + Element out; ASSERT(map_get(m, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(20))); + ASSERT_SCALAR_EQ(out, scalar_int(20)); } TEST(set_equal_lamport_lower_client_ignored) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(20), stmp(5, 2)); - map_set(m, SK("k"), scalar_int(10), stmp(5, 1)); - Scalar out; + map_set(m, SK("k"), EI(20), stmp(5, 2)); + map_set(m, SK("k"), EI(10), stmp(5, 1)); + Element out; ASSERT(map_get(m, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(20))); + ASSERT_SCALAR_EQ(out, scalar_int(20)); } TEST(set_same_stamp_idempotent) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(42), stmp(5, 1)); - map_set(m, SK("k"), scalar_int(42), stmp(5, 1)); - Scalar out; + map_set(m, SK("k"), EI(42), stmp(5, 1)); + map_set(m, SK("k"), EI(42), stmp(5, 1)); + Element out; ASSERT(map_get(m, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(42))); + ASSERT_SCALAR_EQ(out, scalar_int(42)); } // A newer write can change the Scalar kind. TEST(set_can_change_value_kind) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(42), stmp(1, 1)); - map_set(m, SK("k"), scalar_string((const uint8_t *)"hi", 2), stmp(2, 1)); - Scalar out; + map_set(m, SK("k"), EI(42), stmp(1, 1)); + map_set(m, SK("k"), ES("hi", 2), stmp(2, 1)); + Element out; ASSERT(map_get(m, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_string((const uint8_t *)"hi", 2))); + ASSERT_SCALAR_EQ(out, scalar_string((const uint8_t *)"hi", 2)); } // Distinct keys are independent — writing one must not affect the other. TEST(distinct_keys_are_independent) { Map *m = fresh(); - map_set(m, SK("a"), scalar_int(1), stmp(1, 1)); - map_set(m, SK("b"), scalar_int(2), stmp(1, 1)); - Scalar a, b; + map_set(m, SK("a"), EI(1), stmp(1, 1)); + map_set(m, SK("b"), EI(2), stmp(1, 1)); + Element a, b; ASSERT(map_get(m, SK("a"), &a) == true); ASSERT(map_get(m, SK("b"), &b) == true); - ASSERT(scalar_eq(a, scalar_int(1))); - ASSERT(scalar_eq(b, scalar_int(2))); + ASSERT_SCALAR_EQ(a, scalar_int(1)); + ASSERT_SCALAR_EQ(b, scalar_int(2)); } // Headline reason for byte keys: keys with embedded NUL bytes must be @@ -115,53 +147,53 @@ TEST(keys_with_embedded_nul_are_distinct) { Map *m = fresh(); uint8_t k1[3] = {0x01, 0x00, 0x02}; uint8_t k2[3] = {0x01, 0x00, 0x03}; - map_set(m, k1, sizeof k1, scalar_int(1), stmp(1, 1)); - map_set(m, k2, sizeof k2, scalar_int(2), stmp(1, 1)); - Scalar v1, v2; + map_set(m, k1, sizeof k1, EI(1), stmp(1, 1)); + map_set(m, k2, sizeof k2, EI(2), stmp(1, 1)); + Element v1, v2; ASSERT(map_get(m, k1, sizeof k1, &v1) == true); ASSERT(map_get(m, k2, sizeof k2, &v2) == true); - ASSERT(scalar_eq(v1, scalar_int(1))); - ASSERT(scalar_eq(v2, scalar_int(2))); + ASSERT_SCALAR_EQ(v1, scalar_int(1)); + ASSERT_SCALAR_EQ(v2, scalar_int(2)); } // --- delete / tombstones --- TEST(delete_makes_get_return_false) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(42), stmp(1, 1)); + map_set(m, SK("k"), EI(42), stmp(1, 1)); map_delete(m, SK("k"), stmp(2, 1)); - Scalar out; + Element out; ASSERT(map_get(m, SK("k"), &out) == false); } // A delete with a stamp older than the existing value must NOT clobber. TEST(delete_with_lower_stamp_ignored) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(42), stmp(5, 1)); + map_set(m, SK("k"), EI(42), stmp(5, 1)); map_delete(m, SK("k"), stmp(3, 1)); // older — ignored - Scalar out; + Element out; ASSERT(map_get(m, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(42))); + ASSERT_SCALAR_EQ(out, scalar_int(42)); } // After delete, a set with a higher stamp must resurrect the slot. TEST(set_after_delete_with_higher_stamp_resurrects) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(10), stmp(1, 1)); + map_set(m, SK("k"), EI(10), stmp(1, 1)); map_delete(m, SK("k"), stmp(2, 1)); - map_set(m, SK("k"), scalar_int(20), stmp(3, 1)); - Scalar out; + map_set(m, SK("k"), EI(20), stmp(3, 1)); + Element out; ASSERT(map_get(m, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(20))); + ASSERT_SCALAR_EQ(out, scalar_int(20)); } // After delete, a set with a lower-or-equal stamp must NOT resurrect. TEST(set_after_delete_with_lower_stamp_ignored) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(10), stmp(1, 1)); + map_set(m, SK("k"), EI(10), stmp(1, 1)); map_delete(m, SK("k"), stmp(5, 1)); - map_set(m, SK("k"), scalar_int(20), stmp(3, 1)); // older than delete - Scalar out; + map_set(m, SK("k"), EI(20), stmp(3, 1)); // older than delete + Element out; ASSERT(map_get(m, SK("k"), &out) == false); } @@ -169,18 +201,18 @@ TEST(set_after_delete_with_lower_stamp_ignored) { // higher stamp, so the slot ends up tombstoned. TEST(set_vs_delete_higher_stamp_wins_delete) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(10), stmp(1, 1)); + map_set(m, SK("k"), EI(10), stmp(1, 1)); map_delete(m, SK("k"), stmp(5, 1)); - Scalar out; + Element out; ASSERT(map_get(m, SK("k"), &out) == false); } TEST(delete_idempotent_same_stamp) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(10), stmp(1, 1)); + map_set(m, SK("k"), EI(10), stmp(1, 1)); map_delete(m, SK("k"), stmp(5, 1)); map_delete(m, SK("k"), stmp(5, 1)); - Scalar out; + Element out; ASSERT(map_get(m, SK("k"), &out) == false); } @@ -189,8 +221,8 @@ TEST(delete_idempotent_same_stamp) { TEST(delete_absent_key_still_installs_tombstone) { Map *m = fresh(); map_delete(m, SK("ghost"), stmp(10, 1)); - map_set(m, SK("ghost"), scalar_int(1), stmp(5, 1)); // older than delete - Scalar out; + map_set(m, SK("ghost"), EI(1), stmp(5, 1)); // older than delete + Element out; ASSERT(map_get(m, SK("ghost"), &out) == false); } @@ -203,210 +235,379 @@ TEST(size_zero_initially) { TEST(size_counts_live_entries) { Map *m = fresh(); - map_set(m, SK("a"), scalar_int(1), stmp(1, 1)); - map_set(m, SK("b"), scalar_int(2), stmp(1, 1)); - map_set(m, SK("c"), scalar_int(3), stmp(1, 1)); + map_set(m, SK("a"), EI(1), stmp(1, 1)); + map_set(m, SK("b"), EI(2), stmp(1, 1)); + map_set(m, SK("c"), EI(3), stmp(1, 1)); ASSERT_EQ(map_size(m), 3); } TEST(size_excludes_tombstones) { Map *m = fresh(); - map_set(m, SK("a"), scalar_int(1), stmp(1, 1)); - map_set(m, SK("b"), scalar_int(2), stmp(1, 1)); + map_set(m, SK("a"), EI(1), stmp(1, 1)); + map_set(m, SK("b"), EI(2), stmp(1, 1)); map_delete(m, SK("b"), stmp(2, 1)); ASSERT_EQ(map_size(m), 1); } TEST(size_recovers_on_resurrect) { Map *m = fresh(); - map_set(m, SK("k"), scalar_int(1), stmp(1, 1)); + map_set(m, SK("k"), EI(1), stmp(1, 1)); map_delete(m, SK("k"), stmp(2, 1)); ASSERT_EQ(map_size(m), 0); - map_set(m, SK("k"), scalar_int(2), stmp(3, 1)); + map_set(m, SK("k"), EI(2), stmp(3, 1)); ASSERT_EQ(map_size(m), 1); } -// --- merge (two replicas) --- +// --- composite slot reads --- + +TEST(set_counter_then_get_returns_element_counter) { + Arena *ar = arena_create(); + Map *m = map_create(ar, default_id()); + Counter *c = counter_create(ar, eid(1, 1)); + counter_inc(c, cid(1), 5); + map_set(m, SK("votes"), element_counter(c), stmp(1, 1)); + + Element out; + ASSERT(map_get(m, SK("votes"), &out) == true); + ASSERT_EQ(element_kind(out), ELEMENT_COUNTER); + ASSERT_EQ(counter_read(out.as.counter), 5); + arena_destroy(ar); +} + +TEST(set_register_then_get_returns_element_register) { + Arena *ar = arena_create(); + Map *m = map_create(ar, default_id()); + Register *r = register_create(ar, eid(1, 1), scalar_int(7), stmp(1, 1)); + map_set(m, SK("title"), element_register(r), stmp(1, 1)); + + Element out; + ASSERT(map_get(m, SK("title"), &out) == true); + ASSERT_EQ(element_kind(out), ELEMENT_REGISTER); + ASSERT(scalar_eq(register_read(out.as.reg), scalar_int(7))); + arena_destroy(ar); +} + +TEST(set_nested_map_then_get_returns_element_map) { + Arena *ar = arena_create(); + Map *outer = map_create(ar, default_id()); + Map *inner = map_create(ar, eid(1, 1)); + map_set(inner, SK("a"), EI(1), stmp(1, 1)); + map_set(outer, SK("child"), element_map(inner), stmp(1, 1)); + + Element out; + ASSERT(map_get(outer, SK("child"), &out) == true); + ASSERT_EQ(element_kind(out), ELEMENT_MAP); + Element inner_out; + ASSERT(map_get(out.as.map, SK("a"), &inner_out) == true); + ASSERT_SCALAR_EQ(inner_out, scalar_int(1)); + arena_destroy(ar); +} + +// --- merge (two replicas, scalar slots) --- TEST(merge_disjoint_keys_unions) { - Map *a = map_create(arena_create()); - Map *b = map_create(arena_create()); - map_set(a, SK("x"), scalar_int(1), stmp(1, 1)); - map_set(b, SK("y"), scalar_int(2), stmp(1, 2)); + Map *a = map_create(arena_create(), default_id()); + Map *b = map_create(arena_create(), default_id()); + map_set(a, SK("x"), EI(1), stmp(1, 1)); + map_set(b, SK("y"), EI(2), stmp(1, 2)); map_merge(a, b); - Scalar x, y; + Element x, y; ASSERT(map_get(a, SK("x"), &x) == true); ASSERT(map_get(a, SK("y"), &y) == true); - ASSERT(scalar_eq(x, scalar_int(1))); - ASSERT(scalar_eq(y, scalar_int(2))); + ASSERT_SCALAR_EQ(x, scalar_int(1)); + ASSERT_SCALAR_EQ(y, scalar_int(2)); ASSERT_EQ(map_size(a), 2); } TEST(merge_same_key_newer_wins) { - Map *a = map_create(arena_create()); - Map *b = map_create(arena_create()); - map_set(a, SK("k"), scalar_int(10), stmp(1, 1)); - map_set(b, SK("k"), scalar_int(20), stmp(2, 2)); // newer + Map *a = map_create(arena_create(), default_id()); + Map *b = map_create(arena_create(), default_id()); + map_set(a, SK("k"), EI(10), stmp(1, 1)); + map_set(b, SK("k"), EI(20), stmp(2, 2)); // newer map_merge(a, b); - Scalar out; + Element out; ASSERT(map_get(a, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(20))); + ASSERT_SCALAR_EQ(out, scalar_int(20)); } TEST(merge_src_older_loses) { - Map *a = map_create(arena_create()); - Map *b = map_create(arena_create()); - map_set(a, SK("k"), scalar_int(20), stmp(5, 1)); // newer - map_set(b, SK("k"), scalar_int(10), stmp(2, 2)); + Map *a = map_create(arena_create(), default_id()); + Map *b = map_create(arena_create(), default_id()); + map_set(a, SK("k"), EI(20), stmp(5, 1)); // newer + map_set(b, SK("k"), EI(10), stmp(2, 2)); map_merge(a, b); - Scalar out; + Element out; ASSERT(map_get(a, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(20))); + ASSERT_SCALAR_EQ(out, scalar_int(20)); } // Concurrent: dst has a value, src has a delete with a higher stamp. TEST(merge_delete_beats_older_set) { - Map *a = map_create(arena_create()); - Map *b = map_create(arena_create()); - map_set(a, SK("k"), scalar_int(10), stmp(1, 1)); + Map *a = map_create(arena_create(), default_id()); + Map *b = map_create(arena_create(), default_id()); + map_set(a, SK("k"), EI(10), stmp(1, 1)); map_delete(b, SK("k"), stmp(5, 1)); // newer map_merge(a, b); - Scalar out; + Element out; ASSERT(map_get(a, SK("k"), &out) == false); } // Concurrent: dst has a delete, src has a value with a higher stamp. TEST(merge_set_beats_older_delete) { - Map *a = map_create(arena_create()); - Map *b = map_create(arena_create()); + Map *a = map_create(arena_create(), default_id()); + Map *b = map_create(arena_create(), default_id()); map_delete(a, SK("k"), stmp(1, 1)); - map_set(b, SK("k"), scalar_int(42), stmp(5, 1)); // newer + map_set(b, SK("k"), EI(42), stmp(5, 1)); // newer map_merge(a, b); - Scalar out; + Element out; ASSERT(map_get(a, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(42))); + ASSERT_SCALAR_EQ(out, scalar_int(42)); } TEST(merge_commutative) { // path 1: a <- b - Map *a1 = map_create(arena_create()); - Map *b1 = map_create(arena_create()); - map_set(a1, SK("k"), scalar_int(10), stmp(5, 1)); - map_set(b1, SK("k"), scalar_int(20), stmp(5, 2)); + Map *a1 = map_create(arena_create(), default_id()); + Map *b1 = map_create(arena_create(), default_id()); + map_set(a1, SK("k"), EI(10), stmp(5, 1)); + map_set(b1, SK("k"), EI(20), stmp(5, 2)); map_merge(a1, b1); // path 2: b <- a - Map *a2 = map_create(arena_create()); - Map *b2 = map_create(arena_create()); - map_set(a2, SK("k"), scalar_int(10), stmp(5, 1)); - map_set(b2, SK("k"), scalar_int(20), stmp(5, 2)); + Map *a2 = map_create(arena_create(), default_id()); + Map *b2 = map_create(arena_create(), default_id()); + map_set(a2, SK("k"), EI(10), stmp(5, 1)); + map_set(b2, SK("k"), EI(20), stmp(5, 2)); map_merge(b2, a2); - Scalar v1, v2; + Element v1, v2; ASSERT(map_get(a1, SK("k"), &v1) == true); ASSERT(map_get(b2, SK("k"), &v2) == true); - ASSERT(scalar_eq(v1, v2)); - ASSERT(scalar_eq(v1, scalar_int(20))); + ASSERT_EQ(element_kind(v1), ELEMENT_SCALAR); + ASSERT_EQ(element_kind(v2), ELEMENT_SCALAR); + ASSERT(scalar_eq(v1.as.scalar, v2.as.scalar)); + ASSERT(scalar_eq(v1.as.scalar, scalar_int(20))); } TEST(merge_idempotent) { - Map *a = map_create(arena_create()); - Map *b = map_create(arena_create()); - map_set(a, SK("k"), scalar_int(10), stmp(1, 1)); - map_set(b, SK("k"), scalar_int(20), stmp(2, 1)); + Map *a = map_create(arena_create(), default_id()); + Map *b = map_create(arena_create(), default_id()); + map_set(a, SK("k"), EI(10), stmp(1, 1)); + map_set(b, SK("k"), EI(20), stmp(2, 1)); map_merge(a, b); - Scalar once; + Element once; ASSERT(map_get(a, SK("k"), &once) == true); map_merge(a, b); - Scalar twice; + Element twice; ASSERT(map_get(a, SK("k"), &twice) == true); - ASSERT(scalar_eq(once, twice)); - ASSERT(scalar_eq(twice, scalar_int(20))); + ASSERT_EQ(element_kind(once), ELEMENT_SCALAR); + ASSERT_EQ(element_kind(twice), ELEMENT_SCALAR); + ASSERT(scalar_eq(once.as.scalar, twice.as.scalar)); + ASSERT(scalar_eq(twice.as.scalar, scalar_int(20))); } TEST(merge_associative) { // (a <- b) <- c - Map *a = map_create(arena_create()); - Map *b = map_create(arena_create()); - Map *c = map_create(arena_create()); - map_set(a, SK("k"), scalar_int(10), stmp(1, 1)); - map_set(b, SK("k"), scalar_int(20), stmp(2, 1)); - map_set(c, SK("k"), scalar_int(30), stmp(3, 1)); + Map *a = map_create(arena_create(), default_id()); + Map *b = map_create(arena_create(), default_id()); + Map *c = map_create(arena_create(), default_id()); + map_set(a, SK("k"), EI(10), stmp(1, 1)); + map_set(b, SK("k"), EI(20), stmp(2, 1)); + map_set(c, SK("k"), EI(30), stmp(3, 1)); map_merge(a, b); map_merge(a, c); // a <- (b <- c) - Map *a2 = map_create(arena_create()); - Map *b2 = map_create(arena_create()); - Map *c2 = map_create(arena_create()); - map_set(a2, SK("k"), scalar_int(10), stmp(1, 1)); - map_set(b2, SK("k"), scalar_int(20), stmp(2, 1)); - map_set(c2, SK("k"), scalar_int(30), stmp(3, 1)); + Map *a2 = map_create(arena_create(), default_id()); + Map *b2 = map_create(arena_create(), default_id()); + Map *c2 = map_create(arena_create(), default_id()); + map_set(a2, SK("k"), EI(10), stmp(1, 1)); + map_set(b2, SK("k"), EI(20), stmp(2, 1)); + map_set(c2, SK("k"), EI(30), stmp(3, 1)); map_merge(b2, c2); map_merge(a2, b2); - Scalar v1, v2; + Element v1, v2; ASSERT(map_get(a, SK("k"), &v1) == true); ASSERT(map_get(a2, SK("k"), &v2) == true); - ASSERT(scalar_eq(v1, v2)); - ASSERT(scalar_eq(v1, scalar_int(30))); + ASSERT_EQ(element_kind(v1), ELEMENT_SCALAR); + ASSERT_EQ(element_kind(v2), ELEMENT_SCALAR); + ASSERT(scalar_eq(v1.as.scalar, v2.as.scalar)); + ASSERT(scalar_eq(v1.as.scalar, scalar_int(30))); } TEST(merge_does_not_mutate_src) { - Map *a = map_create(arena_create()); - Map *b = map_create(arena_create()); - map_set(a, SK("k"), scalar_int(99), stmp(10, 1)); // newer - map_set(b, SK("k"), scalar_int(7), stmp(1, 1)); + Map *a = map_create(arena_create(), default_id()); + Map *b = map_create(arena_create(), default_id()); + map_set(a, SK("k"), EI(99), stmp(10, 1)); // newer + map_set(b, SK("k"), EI(7), stmp(1, 1)); map_merge(a, b); - Scalar out; + Element out; ASSERT(map_get(b, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_int(7))); // b unchanged + ASSERT_SCALAR_EQ(out, scalar_int(7)); // b unchanged } // When merge accepts a winning string value from src, dst must own its own // copy in dst's arena. Mutating the source bytes after merge must not affect // dst's stored value. TEST(merge_copies_string_into_dst_arena) { - Map *a = map_create(arena_create()); - Map *b = map_create(arena_create()); + Map *a = map_create(arena_create(), default_id()); + Map *b = map_create(arena_create(), default_id()); uint8_t src_bytes[8]; memcpy(src_bytes, "hello", 5); - map_set(a, SK("k"), scalar_int(0), stmp(1, 1)); - map_set(b, SK("k"), scalar_string(src_bytes, 5), stmp(5, 1)); + map_set(a, SK("k"), EI(0), stmp(1, 1)); + map_set(b, SK("k"), ES(src_bytes, 5), stmp(5, 1)); map_merge(a, b); // a takes b's string src_bytes[0] = 'X'; src_bytes[1] = 'X'; - Scalar out; + Element out; ASSERT(map_get(a, SK("k"), &out) == true); - ASSERT(scalar_eq(out, scalar_string((const uint8_t *)"hello", 5))); + ASSERT_SCALAR_EQ(out, scalar_string((const uint8_t *)"hello", 5)); } // Tombstones survive merge: dst with a tombstone merged with src that has an // older value must keep the tombstone (the higher stamp wins). TEST(merge_preserves_tombstone_against_older_set) { - Map *a = map_create(arena_create()); - Map *b = map_create(arena_create()); + Map *a = map_create(arena_create(), default_id()); + Map *b = map_create(arena_create(), default_id()); map_delete(a, SK("k"), stmp(5, 1)); - map_set(b, SK("k"), scalar_int(10), stmp(2, 1)); + map_set(b, SK("k"), EI(10), stmp(2, 1)); map_merge(a, b); - Scalar out; + Element out; ASSERT(map_get(a, SK("k"), &out) == false); } +// --- nested merge: same id same kind recurses into element_merge --- + +// Two replicas hold the same Counter at "votes" (same id). Merge must combine +// their per-client tallies via counter_merge, NOT do LWW on the slot stamp. +// The dst slot has the OLDER stamp on purpose: if the implementation chose +// LWW, dst would inherit src's counter (=3) instead of the union (=8). +TEST(merge_same_id_counter_recurses) { + Arena *ad = arena_create(); + Arena *as = arena_create(); + ElementId votes_id = eid(7, 1); + + Map *dst = map_create(ad, default_id()); + Counter *dc = counter_create(ad, votes_id); + counter_inc(dc, cid(1), 5); + map_set(dst, SK("votes"), element_counter(dc), + stmp(1, 1)); // older slot stamp + + Map *src = map_create(as, default_id()); + Counter *sc = counter_create(as, votes_id); + counter_inc(sc, cid(2), 3); + map_set(src, SK("votes"), element_counter(sc), + stmp(10, 1)); // newer slot stamp + + map_merge(dst, src); + + Element out; + ASSERT(map_get(dst, SK("votes"), &out) == true); + ASSERT_EQ(element_kind(out), ELEMENT_COUNTER); + ASSERT_EQ(counter_read(out.as.counter), 8); // unioned, not replaced + arena_destroy(ad); + arena_destroy(as); +} + +// Same shape with Register: same id → element_merge (register_merge by stamp). +// Pick stamps so register_merge picks src's value; that's distinct from "dst +// took src's whole Register" because dst's Register pointer must be preserved. +TEST(merge_same_id_register_recurses) { + Arena *ad = arena_create(); + Arena *as = arena_create(); + ElementId reg_id = eid(7, 1); + + Map *dst = map_create(ad, default_id()); + Register *dr = register_create(ad, reg_id, scalar_int(10), stmp(1, 1)); + map_set(dst, SK("title"), element_register(dr), stmp(1, 1)); + + Map *src = map_create(as, default_id()); + Register *sr = register_create(as, reg_id, scalar_int(20), stmp(5, 1)); + map_set(src, SK("title"), element_register(sr), stmp(1, 1)); + + map_merge(dst, src); + + Element out; + ASSERT(map_get(dst, SK("title"), &out) == true); + ASSERT_EQ(element_kind(out), ELEMENT_REGISTER); + // dst kept its OWN Register pointer; that Register absorbed src's value. + ASSERT(out.as.reg == dr); + ASSERT(scalar_eq(register_read(dr), scalar_int(20))); + arena_destroy(ad); + arena_destroy(as); +} + +// Same shape with nested Map: same id → element_merge recurses into map_merge +// on the inner maps. Inner slot from src must show up in dst's inner map. +TEST(merge_same_id_nested_map_recurses) { + Arena *ad = arena_create(); + Arena *as = arena_create(); + ElementId inner_id = eid(7, 1); + + Map *dst = map_create(ad, default_id()); + Map *di = map_create(ad, inner_id); + map_set(di, SK("a"), EI(1), stmp(1, 1)); + map_set(dst, SK("child"), element_map(di), stmp(1, 1)); + + Map *src = map_create(as, default_id()); + Map *si = map_create(as, inner_id); + map_set(si, SK("b"), EI(2), stmp(1, 2)); + map_set(src, SK("child"), element_map(si), stmp(1, 1)); + + map_merge(dst, src); + + Element out; + ASSERT(map_get(dst, SK("child"), &out) == true); + ASSERT_EQ(element_kind(out), ELEMENT_MAP); + ASSERT(out.as.map == di); // dst kept its own inner Map pointer + Element a_out, b_out; + ASSERT(map_get(di, SK("a"), &a_out) == true); + ASSERT(map_get(di, SK("b"), &b_out) == true); + ASSERT_SCALAR_EQ(a_out, scalar_int(1)); + ASSERT_SCALAR_EQ(b_out, scalar_int(2)); + arena_destroy(ad); + arena_destroy(as); +} + +// Recursive merge does not touch src's composite — dst absorbs, src untouched. +TEST(merge_same_id_counter_does_not_mutate_src) { + Arena *ad = arena_create(); + Arena *as = arena_create(); + ElementId votes_id = eid(7, 1); + + Map *dst = map_create(ad, default_id()); + Counter *dc = counter_create(ad, votes_id); + counter_inc(dc, cid(1), 5); + map_set(dst, SK("votes"), element_counter(dc), stmp(1, 1)); + + Map *src = map_create(as, default_id()); + Counter *sc = counter_create(as, votes_id); + counter_inc(sc, cid(2), 3); + map_set(src, SK("votes"), element_counter(sc), stmp(1, 1)); + + map_merge(dst, src); + + ASSERT_EQ(counter_read(sc), 3); // src counter unchanged + arena_destroy(ad); + arena_destroy(as); +} + int main(void) { + RUN(map_create_stores_id); + RUN(empty_get_returns_false); RUN(set_then_get); RUN(set_overwrites_with_newer_stamp); @@ -431,6 +632,10 @@ int main(void) { RUN(size_excludes_tombstones); RUN(size_recovers_on_resurrect); + RUN(set_counter_then_get_returns_element_counter); + RUN(set_register_then_get_returns_element_register); + RUN(set_nested_map_then_get_returns_element_map); + RUN(merge_disjoint_keys_unions); RUN(merge_same_key_newer_wins); RUN(merge_src_older_loses); @@ -443,5 +648,10 @@ int main(void) { RUN(merge_copies_string_into_dst_arena); RUN(merge_preserves_tombstone_against_older_set); + RUN(merge_same_id_counter_recurses); + RUN(merge_same_id_register_recurses); + RUN(merge_same_id_nested_map_recurses); + RUN(merge_same_id_counter_does_not_mutate_src); + TEST_SUMMARY(); } diff --git a/test_map_abort.c b/test_map_abort.c new file mode 100644 index 0000000..7cbad24 --- /dev/null +++ b/test_map_abort.c @@ -0,0 +1,45 @@ +// Death test: cross-arena composite LWW must host_abort. +// +// src holds a composite (Counter) at "votes" but dst has no entry. map_merge's +// LWW fallthrough must abort rather than store a cross-arena pointer. +// Deterministic id derivation (PR 5) keeps this path unreachable in normal +// use; the abort guards against silent dangling-pointer corruption. +// +// The Makefile target inverts the exit status: success means the binary died. + +#include "arena.h" +#include "clientid.h" +#include "counter.h" +#include "element.h" +#include "elementid.h" +#include "map.h" +#include "stamp.h" +#include +#include + +static ClientId cid(uint8_t first_byte) { + uint8_t b[16] = {0}; + b[0] = first_byte; + return clientid_from_bytes(b); +} + +static ElementId eid(uint8_t origin_byte, uint64_t seq) { + return elementid_new(cid(origin_byte), seq); +} + +int main(void) { + Arena *ad = arena_create(); + Arena *as = arena_create(); + Map *dst = map_create(ad, eid(0xFF, 0)); + Map *src = map_create(as, eid(0xFF, 0)); + Counter *sc = counter_create(as, eid(7, 1)); + counter_inc(sc, cid(1), 3); + map_set(src, (const void *)"votes", 5, element_counter(sc), + (Stamp){.lamport = 5, .client_id = cid(1)}); + + map_merge(dst, src); // expected: host_abort, process dies + + fprintf(stderr, + "test_map_abort: map_merge returned without aborting (bug)\n"); + return 0; // 0 exit will be inverted by Makefile -> failure +} diff --git a/test_register.c b/test_register.c index d6f1e7b..de38616 100644 --- a/test_register.c +++ b/test_register.c @@ -1,5 +1,6 @@ #include "arena.h" #include "clientid.h" +#include "elementid.h" #include "register.h" #include "scalar.h" #include "stamp.h" @@ -13,6 +14,12 @@ static ClientId cid(uint8_t first_byte) { return clientid_from_bytes(b); } +static ElementId eid(uint8_t origin_byte, uint64_t seq) { + return elementid_new(cid(origin_byte), seq); +} + +static ElementId default_id(void) { return eid(0xFF, 0); } + // Build a Stamp from lamport + a ClientId's first byte. Tests stay readable. static Stamp stmp(uint64_t lamport, uint8_t client_first_byte) { return (Stamp){.lamport = lamport, .client_id = cid(client_first_byte)}; @@ -20,7 +27,15 @@ static Stamp stmp(uint64_t lamport, uint8_t client_first_byte) { static Register *fresh(Scalar value, Stamp stamp) { Arena *arena = arena_create(); - return register_create(arena, value, stamp); + return register_create(arena, default_id(), value, stamp); +} + +TEST(register_create_stores_id) { + Arena *a = arena_create(); + ElementId id = eid(7, 42); + Register *r = register_create(a, id, scalar_int(0), stmp(1, 1)); + ASSERT(elementid_eq(register_id(r), id) == true); + arena_destroy(a); } // --- create / read --- @@ -226,6 +241,7 @@ TEST(merge_copies_string_into_dst_arena) { } int main(void) { + RUN(register_create_stores_id); RUN(create_seeds_value); RUN(create_with_string); RUN(create_with_null); From 627bd275fe24d76ab3033489480dba7c2c98dce5 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Tue, 2 Jun 2026 11:21:16 -0300 Subject: [PATCH 2/5] docs: header docs for Counter/Element/ElementId; refresh Map/Register --- counter.h | 22 ++++++++++++++++++++++ element.h | 20 ++++++++++++++++++++ elementid.h | 15 +++++++++++++++ map.h | 22 +++++++++++++++++----- register.h | 4 +++- 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/counter.h b/counter.h index 9749201..9a70504 100644 --- a/counter.h +++ b/counter.h @@ -1,6 +1,28 @@ #ifndef _CRDT_COUNTER_H #define _CRDT_COUNTER_H +// PN-Counter: integer counter with concurrent increments and decrements. +// Carries an ElementId set at create, exposed via counter_id — that's how +// parent containers identify "same logical Counter across replicas". +// +// Semantics: +// - Per-client (inc, dec) tallies, one CounterEntry per ClientId that +// ever wrote to this Counter. counter_inc / counter_dec add into the +// calling client's own tallies. +// - counter_read returns sum over all clients of (inc - dec). +// - counter_merge unions src into dst per-direction: dst's entry for +// each ClientId becomes (max(dst.inc, src.inc), max(dst.dec, src.dec)). +// Merge is NOT addition — replicas may have observed the same writes +// concurrently, so max is what makes the merge idempotent / commutative +// / associative. +// - Increments and decrements use uint32_t to keep per-direction max +// well-defined; counter_read widens to int64_t for the signed total. +// +// Ownership: +// - Per-client entries live in the Counter's arena. +// +// Lifetime: Counter must not outlive its arena. + #include "arena.h" #include "clientid.h" #include "elementid.h" diff --git a/element.h b/element.h index 9045c89..7207639 100644 --- a/element.h +++ b/element.h @@ -1,6 +1,26 @@ #ifndef _CRDT_ELEMENT_H #define _CRDT_ELEMENT_H +// Element: tagged union over the four value kinds a Map slot can hold — +// SCALAR (inline value), or one of REGISTER / COUNTER / MAP (pointer to +// a separately-allocated composite). +// +// Constructors (element_scalar / _register / _counter / _map) tag the +// kind and stash the payload. element_kind reads the tag back. +// +// element_merge dispatches on dst's kind: +// - REGISTER → register_merge(dst, src) +// - COUNTER → counter_merge(dst, src) +// - MAP → map_merge(dst, src) +// - SCALAR → host_abort. Scalars do not merge as elements; their LWW +// lives at the slot level (in Map). Reaching this branch +// is a programmer error. +// +// Ownership: composites are referenced by pointer; element_merge mutates +// dst's composite in place and never touches src's. Callers are +// responsible for keeping pointed-to composites alive (typically by +// putting them in the same arena as the containing Map). + #include "counter.h" #include "register.h" #include "scalar.h" diff --git a/elementid.h b/elementid.h index 73ebf4e..81a3031 100644 --- a/elementid.h +++ b/elementid.h @@ -1,6 +1,21 @@ #ifndef _CRDT_ELEMENTID_H #define _CRDT_ELEMENTID_H +// ElementId: identity of a composite element (Register / Counter / Map), +// shared across replicas. Two replicas creating "the same logical element" +// must give it the same ElementId; that's the hook map_merge uses to know +// "these two slots are the same object, recurse" vs "these are different +// objects, LWW the slot". +// +// Shape: { ClientId origin, uint64 seq }. Pass by value (~24 bytes), like +// Stamp / ClientId / Scalar. Fields are public. +// +// elementid_new builds one from (origin, seq). elementid_root is a fixed +// sentinel for the top-level Map of a document; it does not collide with +// any id derived from a real ClientId. elementid_eq is the equality used +// by map_merge's recursive path; elementid_cmp gives a total order +// (origin first via clientid_cmp, then seq). + #include "clientid.h" typedef struct ElementId { diff --git a/map.h b/map.h index b7bd0e1..4d9273a 100644 --- a/map.h +++ b/map.h @@ -2,23 +2,35 @@ #define _CRDT_MAP_H // LWW Map with tombstones, keyed on raw bytes (binary-safe), Element-valued. +// Map itself carries an ElementId, set at create, exposed via map_id — +// that's how parent containers identify "same logical Map across replicas". // // Semantics: // - Each slot carries a Stamp. set / delete take effect iff the new stamp // is strictly greater than the slot's current stamp (per stamp_gt). // - Older-stamped writes are silently ignored — set is itself LWW, not a // blind overwrite. -// - Delete installs a tombstone Entry (is_tombstone = true). Tombstones -// block older sets, lose to newer sets, and persist across merge so -// replicas converge on the same delete decision. -// - merge folds each src slot through map_set / map_delete — one LWW -// comparison code path, can't drift from local operations. +// - Delete installs a tombstone Entry. Tombstones block older sets, lose +// to newer sets, and persist across merge so replicas converge on the +// same delete decision. +// +// Merge (per src slot): +// - Both alive, same composite kind (REGISTER / COUNTER / MAP) and same +// ElementId → element_merge(dst, src) recurses in place. Slot stamps +// are ignored on this path; the composite owns its own merge order. +// - Otherwise → LWW on slot stamp. Scalar winners are dup'd into dst's +// arena. A composite winner is a cross-arena dangling-pointer hazard; +// map_merge host_aborts. Deterministic id derivation keeps this path +// unreachable in normal use. // // Ownership: // - SCALAR_STRING values are dup'd into the Map's arena on every accepted // write (set, winning merge). map_get returns a Scalar whose string // bytes are a borrowed view into that arena; valid as long as the arena // lives. Caller must not free or mutate. +// - Composite slots (REGISTER / COUNTER / MAP) are stored as pointers. +// The pointed-to object must live in the same arena as the Map, or at +// least outlive it. map_set does not clone composites. // - Map lives in its arena; arena_destroy cleans up everything (no // separate map_destroy needed). // diff --git a/register.h b/register.h index a3e61bd..e3b96ea 100644 --- a/register.h +++ b/register.h @@ -2,7 +2,9 @@ #define _CRDT_REGISTER_H // LWW (last-writer-wins) Register holding a Scalar value with a -// (lamport, client_id) stamp. +// (lamport, client_id) stamp. Carries an ElementId set at create, +// exposed via register_id — that's how parent containers identify +// "same logical Register across replicas". // // Semantics: // - Register always holds a value (seeded at create); there is no "unset" From 449fcb20032b0ed690bb055e2e4279418fcd3a9e Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Tue, 2 Jun 2026 11:28:26 -0300 Subject: [PATCH 3/5] fix(map): advance slot stamp on recursive merge; only abort composite LWW when src wins - Recursive same-id merge now sets dst slot stamp to max(dst, src). Without this, a later slot-level op on dst could flip the slot using a stamp src has already moved past, and replicas diverge. - LWW fallthrough no longer aborts on composite src that LOSES the stamp comparison. The abort guard is for the cross-arena displacement hazard (src wins); when src loses, dst keeps its slot and nothing dangles. - Tombstone branch in the fallthrough takes its own continue to keep the remaining LWW-vs-abort flow flat. --- map.c | 37 ++++++++++++++++++++++---------- test_map.c | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/map.c b/map.c index 23c69c3..eb51772 100644 --- a/map.c +++ b/map.c @@ -154,24 +154,39 @@ void map_merge(Map *dst, const Map *src) { } if (elementid_eq(did, sid)) { element_merge(de->value, se->value); + // Advance slot stamp to max(dst, src) so future slot-level + // ops on this key are LWW-deterministic across replicas. + if (stamp_gt(se->stamp, de->stamp)) { + de->stamp = se->stamp; + } continue; } } // LWW fallthrough. if (se->is_tombstone) { - map_delete(dst, k, klen, se->stamp); - } else { - if (se->value.kind != ELEMENT_SCALAR) { - host_abortf("map_merge: cross-replica composite displacement " - "at key (LWW path) — " - "src %s id != dst id (or dst slot " - "empty/tombstone). Use deterministic " - "id derivation for composite slots.", - element_kind_name(se->value.kind)); - } - map_set(dst, k, klen, se->value, se->stamp); + map_delete(dst, k, klen, + se->stamp); // map_delete is itself LWW-guarded + continue; + } + + // src has a live value. Only abort if src's value would actually + // win LWW and is a composite — that's the cross-arena displacement + // hazard. If src loses by stamp, dst keeps its slot and nothing + // dangerous happens. + bool src_wins = !dst_has || stamp_gt(se->stamp, de->stamp); + if (!src_wins) { + continue; + } + if (se->value.kind != ELEMENT_SCALAR) { + host_abortf("map_merge: cross-replica composite displacement " + "at key (LWW path) — " + "src %s id != dst id (or dst slot " + "empty/tombstone). Use deterministic " + "id derivation for composite slots.", + element_kind_name(se->value.kind)); } + map_set(dst, k, klen, se->value, se->stamp); } } diff --git a/test_map.c b/test_map.c index bb7fa1a..179b1c6 100644 --- a/test_map.c +++ b/test_map.c @@ -605,6 +605,67 @@ TEST(merge_same_id_counter_does_not_mutate_src) { arena_destroy(as); } +// After recursive same-id merge the slot stamp must advance to the max of +// dst's and src's slot stamps. Otherwise a later slot-level op on dst could +// flip the slot using a stamp that src has already moved past, and the +// replicas diverge. Probe externally: a subsequent set with a stamp above +// dst's old slot stamp but below src's must be ignored. +TEST(merge_same_id_counter_advances_slot_stamp) { + Arena *ad = arena_create(); + Arena *as = arena_create(); + ElementId votes_id = eid(7, 1); + + Map *dst = map_create(ad, default_id()); + Counter *dc = counter_create(ad, votes_id); + counter_inc(dc, cid(1), 5); + map_set(dst, SK("votes"), element_counter(dc), stmp(1, 1)); + + Map *src = map_create(as, default_id()); + Counter *sc = counter_create(as, votes_id); + counter_inc(sc, cid(2), 3); + map_set(src, SK("votes"), element_counter(sc), stmp(10, 1)); + + map_merge(dst, src); + + // Concurrent later write with stamp between (1,1) and (10,1). + // On src this would be ignored (src.slot.stamp = 10 > 5). dst must + // ignore it too; otherwise replicas diverge on the same key. + map_set(dst, SK("votes"), EI(99), stmp(5, 1)); + + Element out; + ASSERT(map_get(dst, SK("votes"), &out) == true); + ASSERT_EQ(element_kind(out), ELEMENT_COUNTER); + ASSERT_EQ(counter_read(out.as.counter), 8); + arena_destroy(ad); + arena_destroy(as); +} + +// LWW path with a composite src that LOSES the stamp comparison must NOT +// abort. The abort guard exists for composite displacement (src wins, +// would dangle across arenas) — when src loses, dst keeps its slot and no +// displacement happens. Scenario: dst has newer scalar, src has older +// Counter at same key. +TEST(merge_composite_in_src_loses_lww_does_not_abort) { + Arena *ad = arena_create(); + Arena *as = arena_create(); + + Map *dst = map_create(ad, default_id()); + map_set(dst, SK("x"), EI(42), stmp(10, 1)); // newer scalar in dst + + Map *src = map_create(as, default_id()); + Counter *sc = counter_create(as, eid(7, 1)); + counter_inc(sc, cid(2), 5); + map_set(src, SK("x"), element_counter(sc), stmp(1, 1)); // older composite + + map_merge(dst, src); // must NOT abort + + Element out; + ASSERT(map_get(dst, SK("x"), &out) == true); + ASSERT_SCALAR_EQ(out, scalar_int(42)); + arena_destroy(ad); + arena_destroy(as); +} + int main(void) { RUN(map_create_stores_id); @@ -652,6 +713,8 @@ int main(void) { RUN(merge_same_id_register_recurses); RUN(merge_same_id_nested_map_recurses); RUN(merge_same_id_counter_does_not_mutate_src); + RUN(merge_same_id_counter_advances_slot_stamp); + RUN(merge_composite_in_src_loses_lww_does_not_abort); TEST_SUMMARY(); } From 3d73827c3563924a39f1298c7401d0cd5daf1ffc Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Tue, 2 Jun 2026 11:32:48 -0300 Subject: [PATCH 4/5] revert: drop stamp-conditional skip in map_merge LWW path Hitting LWW with a composite means id derivation is broken or types diverged at this key. That is a programmer error regardless of which side's stamp wins; skipping the abort when src loses by stamp made the crash stamp-dependent, which would let the same broken state crash one replica and silently pass on another. Restore unconditional abort; drop the now-misleading "src loses does not abort" regression test. --- map.c | 25 ++++++++++--------------- test_map.c | 27 --------------------------- 2 files changed, 10 insertions(+), 42 deletions(-) diff --git a/map.c b/map.c index eb51772..28e1482 100644 --- a/map.c +++ b/map.c @@ -165,25 +165,20 @@ void map_merge(Map *dst, const Map *src) { // LWW fallthrough. if (se->is_tombstone) { - map_delete(dst, k, klen, - se->stamp); // map_delete is itself LWW-guarded + map_delete(dst, k, klen, se->stamp); continue; } - // src has a live value. Only abort if src's value would actually - // win LWW and is a composite — that's the cross-arena displacement - // hazard. If src loses by stamp, dst keeps its slot and nothing - // dangerous happens. - bool src_wins = !dst_has || stamp_gt(se->stamp, de->stamp); - if (!src_wins) { - continue; - } + // Reaching the LWW path with a composite means id derivation is + // broken or types diverged at this key — programmer error + // regardless of which side's stamp wins. Abort unconditionally so + // the failure is not stamp-dependent (which would let the same + // broken state crash one replica and silently pass on another). if (se->value.kind != ELEMENT_SCALAR) { - host_abortf("map_merge: cross-replica composite displacement " - "at key (LWW path) — " - "src %s id != dst id (or dst slot " - "empty/tombstone). Use deterministic " - "id derivation for composite slots.", + host_abortf("map_merge: composite at LWW path — " + "src kind %s, dst id != src id (or dst empty / " + "tombstoned / different kind). " + "Use deterministic id derivation for composite slots.", element_kind_name(se->value.kind)); } map_set(dst, k, klen, se->value, se->stamp); diff --git a/test_map.c b/test_map.c index 179b1c6..129e537 100644 --- a/test_map.c +++ b/test_map.c @@ -640,32 +640,6 @@ TEST(merge_same_id_counter_advances_slot_stamp) { arena_destroy(as); } -// LWW path with a composite src that LOSES the stamp comparison must NOT -// abort. The abort guard exists for composite displacement (src wins, -// would dangle across arenas) — when src loses, dst keeps its slot and no -// displacement happens. Scenario: dst has newer scalar, src has older -// Counter at same key. -TEST(merge_composite_in_src_loses_lww_does_not_abort) { - Arena *ad = arena_create(); - Arena *as = arena_create(); - - Map *dst = map_create(ad, default_id()); - map_set(dst, SK("x"), EI(42), stmp(10, 1)); // newer scalar in dst - - Map *src = map_create(as, default_id()); - Counter *sc = counter_create(as, eid(7, 1)); - counter_inc(sc, cid(2), 5); - map_set(src, SK("x"), element_counter(sc), stmp(1, 1)); // older composite - - map_merge(dst, src); // must NOT abort - - Element out; - ASSERT(map_get(dst, SK("x"), &out) == true); - ASSERT_SCALAR_EQ(out, scalar_int(42)); - arena_destroy(ad); - arena_destroy(as); -} - int main(void) { RUN(map_create_stores_id); @@ -714,7 +688,6 @@ int main(void) { RUN(merge_same_id_nested_map_recurses); RUN(merge_same_id_counter_does_not_mutate_src); RUN(merge_same_id_counter_advances_slot_stamp); - RUN(merge_composite_in_src_loses_lww_does_not_abort); TEST_SUMMARY(); } From 8f1672b7fc3bc14ec0c81cb59a75a8fff64093be Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 3 Jun 2026 07:29:30 -0300 Subject: [PATCH 5/5] docs(map): fix stale 'map_get returns Scalar' wording in header --- map.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/map.h b/map.h index 4d9273a..a31bc11 100644 --- a/map.h +++ b/map.h @@ -25,9 +25,9 @@ // // Ownership: // - SCALAR_STRING values are dup'd into the Map's arena on every accepted -// write (set, winning merge). map_get returns a Scalar whose string -// bytes are a borrowed view into that arena; valid as long as the arena -// lives. Caller must not free or mutate. +// write (set, winning merge). When map_get fills *out with a SCALAR +// Element, the string bytes are a borrowed view into that arena; valid +// as long as the arena lives. Caller must not free or mutate. // - Composite slots (REGISTER / COUNTER / MAP) are stored as pointers. // The pointed-to object must live in the same arena as the Map, or at // least outlive it. map_set does not clone composites.