From 4b0243bec01bf76c5731e82b8cb871e52985f52d Mon Sep 17 00:00:00 2001 From: Johnny Miller <163300+millerjp@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:28:51 +0200 Subject: [PATCH] feat: add top-level CompareAndSwap and CompareAndDelete (#14) Wrap sync.Map.CompareAndSwap and sync.Map.CompareAndDelete as package-level generic functions constrained to [K, V comparable]. SyncMap's own constraints stay [K comparable, V any] so non-comparable value types remain supported; the CAS operations simply reject them at compile time rather than panicking at runtime inside sync.Map. Godoc calls out the residual interface-V panic path (an interface V holding a dynamic non-comparable value still panics inside sync.Map comparison, matching Go's == semantics). Tests cover the four happy and unhappy paths per function, plus a 100-goroutine contention test that asserts exactly one CAS wins and the stored value matches the winner's id. Includes a comment block demonstrating the non-comparable-V compile error next to the happy path tests. Seeds syncmap_bench_test.go with CAS + CAD benchmarks (contended and uncontended). Full benchmark coverage across all public methods is owned by #15. CHANGELOG entry for the new functions is deferred to #17 (CHANGELOG owner). Coverage remains at 100%. --- syncmap.go | 28 +++++++++ syncmap_bench_test.go | 76 ++++++++++++++++++++++++ syncmap_test.go | 130 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 syncmap_bench_test.go diff --git a/syncmap.go b/syncmap.go index 18a4045..5615df7 100644 --- a/syncmap.go +++ b/syncmap.go @@ -67,6 +67,34 @@ func (m *SyncMap[K, V]) Delete(key K) { m.syncMap.Delete(key) } +// CompareAndSwap swaps the old and new values for key if the value +// currently stored in m is equal to old. The swapped result reports +// whether the swap was performed. +// +// V must be comparable. Because SyncMap is declared with V any to +// support non-comparable value types, this operation cannot be a +// method on SyncMap[K, V]; instantiating it with a non-comparable V +// (slice, map, func, or a struct containing one of those) produces a +// compile-time error rather than the runtime panic that the +// underlying [sync.Map.CompareAndSwap] would raise. +// +// If V is itself an interface type, the comparison performed inside +// [sync.Map] can still panic at runtime when either operand's dynamic +// type is not comparable. This matches Go's `==` semantics for +// interfaces and is outside this wrapper's control. +func CompareAndSwap[K, V comparable](m *SyncMap[K, V], key K, old, new V) (swapped bool) { + return m.syncMap.CompareAndSwap(key, old, new) +} + +// CompareAndDelete deletes the entry for key if its current value is +// equal to old. The deleted result reports whether the entry was +// removed. +// +// V must be comparable, for the same reason as [CompareAndSwap]. +func CompareAndDelete[K, V comparable](m *SyncMap[K, V], key K, old V) (deleted bool) { + return m.syncMap.CompareAndDelete(key, old) +} + // Range calls f sequentially for each key and value present in the // map. If f returns false, Range stops iteration. // diff --git a/syncmap_bench_test.go b/syncmap_bench_test.go new file mode 100644 index 0000000..f468375 --- /dev/null +++ b/syncmap_bench_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 AxonOps Limited. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package syncmap_test + +import ( + "testing" + + "github.com/axonops/syncmap" +) + +// This file seeds the benchmark suite with coverage for the functions +// landing in issue #14 (CompareAndSwap / CompareAndDelete). A full +// benchmark set covering every public method, plus a raw-sync.Map +// overhead comparison and a committed bench.txt baseline, is owned by +// issue #15. + +func BenchmarkCompareAndSwap(b *testing.B) { + b.ReportAllocs() + var m syncmap.SyncMap[string, int] + m.Store("k", 0) + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Alternate old→new so every call performs a real swap. + syncmap.CompareAndSwap(&m, "k", i, i+1) + } +} + +func BenchmarkCompareAndSwapMismatch(b *testing.B) { + b.ReportAllocs() + var m syncmap.SyncMap[string, int] + m.Store("k", 0) + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Old never matches — exercises the fast-reject path. + syncmap.CompareAndSwap(&m, "k", -1, i) + } +} + +func BenchmarkCompareAndDelete(b *testing.B) { + b.ReportAllocs() + var m syncmap.SyncMap[string, int] + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.Store("k", 0) + syncmap.CompareAndDelete(&m, "k", 0) + } +} + +func BenchmarkCompareAndSwapParallel(b *testing.B) { + b.ReportAllocs() + var m syncmap.SyncMap[string, int] + m.Store("k", 0) + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + // Most attempts will fail (only one goroutine's old + // matches at any moment), which is the realistic + // contention pattern. + syncmap.CompareAndSwap(&m, "k", i, i+1) + i++ + } + }) +} diff --git a/syncmap_test.go b/syncmap_test.go index c787200..467f5c6 100644 --- a/syncmap_test.go +++ b/syncmap_test.go @@ -548,3 +548,133 @@ func TestDeleteDuringRange(t *testing.T) { wg.Wait() } + +// Note: the following would intentionally fail to compile and is +// retained as documentation — CompareAndSwap / CompareAndDelete +// require V to be comparable. +// +// var m syncmap.SyncMap[string, []byte] +// _ = syncmap.CompareAndSwap(&m, "k", nil, nil) // []byte is not comparable + +func TestCompareAndSwap(t *testing.T) { + t.Parallel() + + t.Run("match_swaps_returns_true", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 10) + swapped := syncmap.CompareAndSwap(m, "k", 10, 20) + assert.True(t, swapped) + v, ok := m.Load("k") + require.True(t, ok) + assert.Equal(t, 20, v) + }) + + t.Run("mismatch_no_swap_returns_false", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 10) + swapped := syncmap.CompareAndSwap(m, "k", 99, 20) + assert.False(t, swapped) + v, ok := m.Load("k") + require.True(t, ok) + assert.Equal(t, 10, v) + }) + + t.Run("missing_key_returns_false", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + swapped := syncmap.CompareAndSwap(m, "absent", 0, 1) + assert.False(t, swapped) + _, ok := m.Load("absent") + assert.False(t, ok) + }) + + t.Run("zero_V_match_works", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 0) + swapped := syncmap.CompareAndSwap(m, "k", 0, 1) + assert.True(t, swapped) + v, ok := m.Load("k") + require.True(t, ok) + assert.Equal(t, 1, v) + }) +} + +func TestCompareAndDelete(t *testing.T) { + t.Parallel() + + t.Run("match_deletes_returns_true", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 10) + deleted := syncmap.CompareAndDelete(m, "k", 10) + assert.True(t, deleted) + _, ok := m.Load("k") + assert.False(t, ok) + }) + + t.Run("mismatch_no_delete_returns_false", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 10) + deleted := syncmap.CompareAndDelete(m, "k", 99) + assert.False(t, deleted) + v, ok := m.Load("k") + require.True(t, ok) + assert.Equal(t, 10, v) + }) + + t.Run("missing_key_returns_false", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + deleted := syncmap.CompareAndDelete(m, "absent", 0) + assert.False(t, deleted) + }) + + t.Run("zero_V_match_works", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 0) + deleted := syncmap.CompareAndDelete(m, "k", 0) + assert.True(t, deleted) + _, ok := m.Load("k") + assert.False(t, ok) + }) +} + +func TestCompareAndSwapContention(t *testing.T) { + t.Parallel() + + const goroutines = 100 + + m := &syncmap.SyncMap[string, int]{} + m.Store("contended", 0) + + var wg sync.WaitGroup + var successCount atomic.Int32 + var winnerID atomic.Int32 + + // ids start at 1 so 0 (the initial stored value) is never a valid + // winner id — preserves the "exactly one winner" invariant + // unambiguously. + for i := 1; i <= goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + if syncmap.CompareAndSwap(m, "contended", 0, id) { + successCount.Add(1) + winnerID.Store(int32(id)) + } + }(i) + } + + wg.Wait() + + assert.Equal(t, int32(1), successCount.Load(), "exactly one goroutine should win the CAS") + + finalVal, ok := m.Load("contended") + require.True(t, ok) + assert.Equal(t, int(winnerID.Load()), finalVal, "stored value must match the winning goroutine's id") +}