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
28 changes: 28 additions & 0 deletions syncmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
76 changes: 76 additions & 0 deletions syncmap_bench_test.go
Original file line number Diff line number Diff line change
@@ -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++
}
})
}
130 changes: 130 additions & 0 deletions syncmap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}