diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..d134ae2 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ + // For format details, see https://aka.ms/devcontainer.json. +{ + "name": "Ubuntu", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers-extra/features/mise:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {} + }, + "postCreateCommand": "bash .devcontainer/post_create.sh", + "customizations": { + "vscode": { + "extensions": [ + "golang.go" + ] + } + } +} diff --git a/.devcontainer/post_create.sh b/.devcontainer/post_create.sh new file mode 100644 index 0000000..cf64f30 --- /dev/null +++ b/.devcontainer/post_create.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# note that bash will read from ~/.profile or ~/.bash_profile if the latter exists +# ergo, you may want to check to see which is defined on your system and only append to the existing file +echo 'eval "$(mise activate bash --shims)"' >>~/.bash_profile # this sets up non-interactive sessions +echo 'eval "$(mise activate bash)"' >>~/.bashrc # this sets up interactive sessions + +mise trust . + +mise install + +mise exec -- go install -v golang.org/x/tools/gopls@latest +mise exec -- go install -v github.com/go-delve/delve/cmd/dlv@latest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..820bfcf --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,51 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +name: "CodeQL" + +on: + push: + branches: + - main + pull_request: + # The branches below must be a subset of the branches above + branches: + - main + schedule: + - cron: "0 17 * * 5" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + permissions: + # required for all workflows + security-events: write + + strategy: + fail-fast: false + matrix: + # Override automatic language detection by changing the below list + # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] + # TODO: Enable for javascript later + language: ["go"] + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..14c92c5 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,25 @@ +name: PR Check + +on: + pull_request: + branches: + - main + +jobs: + check: + name: Lint & Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install mise + uses: jdx/mise-action@v4 + + - name: Download Go dependencies + run: go mod download + + - name: Run check (lint + test) + run: make check diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..8891809 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,101 @@ +# https://golangci-lint.run/docs/configuration/file/ +version: "2" + +run: + timeout: 5m + +issues: + new: true + whole-files: true + new-from-rev: HEAD~ + max-same-issues: 50 + +severity: + default: error + # rules: + # - linters: + # - dupl + # severity: error + +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + - golines + - swaggo + settings: + gofmt: + rewrite-rules: + - pattern: "interface{}" + replacement: "any" + gofumpt: + extra-rules: true + exclusions: + paths: [] + +linters: + default: none + enable: + # Standard + - errcheck + - govet + - ineffassign + - staticcheck + - unused + + # Additional + - bodyclose + - depguard + - errname + - errorlint + - gocognit + - goconst + - goheader + - gomodguard + - goprintffuncname + - gosec + - nakedret + - nilerr + - nilnil + - noctx + - nolintlint + - prealloc + - promlinter + - revive + - testifylint + - testpackage + - thelper + - usestdlibvars + - wastedassign + - wsl_v5 + + disable: [] + + settings: + depguard: + rules: + main: + deny: + - pkg: github.com/pkg/errors + desc: Should be replaced by standard lib errors package + - pkg: golang.org/x/exp/slices + desc: Should be replaced by standard lib slices package + - pkg: github.com/go-resty/resty$ + desc: Use github.com/go-resty/resty/v2 instead + - pkg: github.com/aws/smithy-go/ptr$ + desc: Use github.com/aws/aws-sdk-go-v2/aws instead + govet: + enable: + - nilness + + # exclusions: + # paths: + # - (.+)_test\.go + # rules: + # - path: (.+)_test\.go + # linters: + # - dupl + # - mnd + # - lll diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index b818b94..0000000 --- a/.tool-versions +++ /dev/null @@ -1,2 +0,0 @@ -golang 1.23.6 -golangci-lint 1.63.4 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5726fc4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "go.useLanguageServer": true, + "go.toolsManagement.autoUpdate": true, + "go.lintTool": "golangci-lint-v2", + "go.lintFlags": [ + "--path-mode=abs", + "--fast-only" + ], + "go.alternateTools": { + "customFormatter": "golangci-lint-v2" + }, + "go.formatTool": "custom", + "go.formatFlags": [ + "fmt", + "--stdin" + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1a7c8fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# Agent Guidelines + +This repository provides `gsyncmap` — a generic, type-safe wrapper around [`sync.Map`](https://pkg.go.dev/sync#Map) for Go. + +## Repository Structure + +| File | Description | +|------|-------------| +| `map.go` | `Map[Key, Value]` — generic concurrent map, value type can be `any` | +| `comparable_map.go` | `ComparableMap[Key, Value]` — variant requiring `comparable` value type | +| `map_test.go` | Unit tests | +| `map_example_test.go` | Runnable example tests (shown in pkg.go.dev) | + +## Development Commands + +```sh +# Run tests (with race detector) +make test +# go test -v -race -failfast ./... + +# Run linter +make lint +# golangci-lint run -v ./... + +# Run linter with auto-fix +make fix +# golangci-lint run -v --fix ./... + +# Run lint + test together +make check +``` + +## Code Conventions + +- **Go version**: 1.24+, module path `github.com/min0625/gsyncmap` +- All exported types and methods must have doc comments. +- `Map` wraps `sync.Map` via a direct type alias (`type Map[K, V] sync.Map`); avoid adding fields. +- `CompareAndDelete` / `CompareAndSwap` on `Map` are intentionally **deprecated** — do not un-deprecate them. Direct users to `ComparableMap` instead. +- Keep the two types (`Map` and `ComparableMap`) in sync: any new method added to one should be added to the other when applicable. +- New example functions must follow `Example*` naming conventions and include expected output comments so they serve as testable examples. + +## Testing Guidelines + +- All tests must pass with `-race` flag enabled. +- Cover both the normal (value found) and zero-value (key absent) code paths for every method. +- Do not add external test dependencies; use only the standard library. diff --git a/Makefile b/Makefile index 25982e5..17d5bcf 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,10 @@ -MODULE_DIRS = . ./tools +fix: + golangci-lint run -v --fix ./... -gowork: - go work init . ./tools - -tidy: - $(foreach dir,$(MODULE_DIRS), \ - (cd $(dir) && go mod tidy) &&) true - -install: tidy - cd tools && go install \ - mvdan.cc/gofumpt - -fmt: install - gofumpt -l -w -extra . - -lint: install - golangci-lint run ./... +lint: + golangci-lint run -v ./... test: - go test ./... + go test -v -race -failfast ./... -check: fmt lint test +check: lint test diff --git a/README.md b/README.md index e7de675..2a9124e 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,38 @@ # Generic Sync Map [![Go Reference](https://pkg.go.dev/badge/github.com/min0625/gsyncmap.svg)](https://pkg.go.dev/github.com/min0625/gsyncmap) - -This Map is based on `generic` and `sync.Map`. +A generic, type-safe wrapper around [`sync.Map`](https://pkg.go.dev/sync#Map). ## Installation ```sh go get github.com/min0625/gsyncmap ``` +## Types + +### `Map[Key comparable, Value any]` + +A generic concurrent map that accepts any value type. Suitable for most use cases. + +> **Note:** `CompareAndDelete` and `CompareAndSwap` are available on `Map` but deprecated. +> They may panic at runtime if `Value` is not a comparable type (e.g. slice, map, func). +> Use `ComparableMap` instead when these operations are needed. + +### `ComparableMap[Key, Value comparable]` + +A drop-in replacement for `Map` that requires the value type to be comparable. +Provides safe `CompareAndDelete` and `CompareAndSwap` without risk of runtime panic. + ## Quick start + ```go package main -import "github.com/min0625/gsyncmap" +import ( + "fmt" + + "github.com/min0625/gsyncmap" +) func main() { var m gsyncmap.Map[string, string] @@ -27,5 +46,26 @@ func main() { } ``` -## Example -See: [./map_example_test.go](./map_example_test.go) +When `CompareAndDelete` or `CompareAndSwap` is needed, use `ComparableMap`: + +```go +package main + +import ( + "fmt" + + "github.com/min0625/gsyncmap" +) + +func main() { + var m gsyncmap.ComparableMap[string, string] + + m.Store("k1", "v1") + fmt.Println(m.CompareAndSwap("k1", "v1", "v2")) // true + fmt.Println(m.Load("k1")) // v2 true +} +``` + +## Examples +See: [map_example_test.go](./map_example_test.go) + diff --git a/comparable_map.go b/comparable_map.go new file mode 100644 index 0000000..6a1aa1d --- /dev/null +++ b/comparable_map.go @@ -0,0 +1,26 @@ +package gsyncmap + +// ComparableMap is a generic, type-safe concurrent map wrapping [sync.Map]. +// +// ComparableMap is a drop-in replacement for [Map] when the value type is +// comparable. It overrides CompareAndDelete and CompareAndSwap with safe +// implementations that cannot panic, because the Value type constraint +// guarantees comparability at compile time. +// +// The zero value of ComparableMap is valid and ready to use. ComparableMap +// must not be copied after first use (same restriction as [sync.Map]). +type ComparableMap[Key, Value comparable] struct { + Map[Key, Value] +} + +// CompareAndDelete deletes the entry for key if its value is equal to oldVal. +// Returns true if the entry was deleted. +func (m *ComparableMap[Key, Value]) CompareAndDelete(key Key, oldVal Value) (deleted bool) { + return m.Map.syncMap().CompareAndDelete(key, oldVal) +} + +// CompareAndSwap swaps the oldVal and newVal values for key if the value stored in +// the map is equal to oldVal. Returns true if the swap was performed. +func (m *ComparableMap[Key, Value]) CompareAndSwap(key Key, oldVal, newVal Value) (swapped bool) { + return m.Map.syncMap().CompareAndSwap(key, oldVal, newVal) +} diff --git a/go.mod b/go.mod index 5b063ca..eb2ce72 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/min0625/gsyncmap -go 1.23 +go 1.24.0 diff --git a/map.go b/map.go index ad69944..8a78a0e 100644 --- a/map.go +++ b/map.go @@ -1,3 +1,13 @@ +// Package gsyncmap provides a generic, type-safe wrapper around [sync.Map]. +// +// There are two map types available: +// +// - [Map]: accepts any value type. Suitable for most use cases. +// CompareAndDelete and CompareAndSwap are available but deprecated due to +// potential runtime panic when the value type is not comparable. +// +// - [ComparableMap]: requires the value type to implement comparable. +// Provides safe CompareAndDelete and CompareAndSwap without risk of panic. package gsyncmap import "sync" @@ -6,20 +16,31 @@ func zero[T any]() T { return *new(T) } +// Map is a generic, type-safe concurrent map wrapping [sync.Map]. +// +// The zero value of Map is valid and ready to use. Map must not be copied +// after first use (same restriction as [sync.Map]). +// +// If the value type is known to be comparable and CompareAndDelete or +// CompareAndSwap is required, prefer using [ComparableMap] instead. type Map[Key comparable, Value any] sync.Map func (m *Map[Key, Value]) syncMap() *sync.Map { return (*sync.Map)(m) } +// Store sets the value for a key. func (m *Map[Key, Value]) Store(key Key, value Value) { m.syncMap().Store(key, value) } +// Clear deletes all the entries, resulting in an empty Map. func (m *Map[Key, Value]) Clear() { m.syncMap().Clear() } +// Load returns the value stored in the map for a key, or the zero value if no +// value is present. The ok result indicates whether value was found in the map. func (m *Map[Key, Value]) Load(key Key) (value Value, ok bool) { anyValue, ok := m.syncMap().Load(key) if !ok { @@ -29,21 +50,34 @@ func (m *Map[Key, Value]) Load(key Key) (value Value, ok bool) { return anyValue.(Value), ok } +// Delete deletes the value for a key. func (m *Map[Key, Value]) Delete(key Key) { m.syncMap().Delete(key) } +// Range calls f sequentially for each key and value present in the map. +// If f returns false, range stops the iteration. +// +// Range does not necessarily correspond to any consistent snapshot of the Map's +// contents: no key will be visited more than once, but if the value for any key +// is stored or deleted concurrently, Range may reflect any mapping for that key +// from any point during the Range call. func (m *Map[Key, Value]) Range(f func(key Key, value Value) bool) { m.syncMap().Range(func(key, value any) bool { return f(key.(Key), value.(Value)) }) } +// LoadOrStore returns the existing value for the key if present. +// Otherwise, it stores and returns the given value. +// The loaded result is true if the value was loaded, false if stored. func (m *Map[Key, Value]) LoadOrStore(key Key, value Value) (actual Value, loaded bool) { anyActual, loaded := m.syncMap().LoadOrStore(key, value) return anyActual.(Value), loaded } +// LoadAndDelete deletes the value for a key, returning the previous value if any. +// The loaded result reports whether the key was present. func (m *Map[Key, Value]) LoadAndDelete(key Key) (value Value, loaded bool) { anyValue, loaded := m.syncMap().LoadAndDelete(key) if !loaded { @@ -53,14 +87,27 @@ func (m *Map[Key, Value]) LoadAndDelete(key Key) (value Value, loaded bool) { return anyValue.(Value), loaded } -func (m *Map[Key, Value]) CompareAndDelete(key Key, old Value) (deleted bool) { - return m.syncMap().CompareAndDelete(key, old) +// CompareAndDelete deletes the entry for key if its value is equal to oldVal. +// +// Deprecated: CompareAndDelete panics at runtime if Value is not a comparable +// type (e.g. slice, map, or func). Use [ComparableMap.CompareAndDelete] instead +// to enforce comparability at compile time. +func (m *Map[Key, Value]) CompareAndDelete(key Key, oldVal Value) (deleted bool) { + return m.syncMap().CompareAndDelete(key, oldVal) } -func (m *Map[Key, Value]) CompareAndSwap(key Key, old, new Value) bool { - return m.syncMap().CompareAndSwap(key, old, new) +// CompareAndSwap swaps the oldVal and newVal values for key if the value stored in +// the map is equal to oldVal. +// +// Deprecated: CompareAndSwap panics at runtime if Value is not a comparable +// type (e.g. slice, map, or func). Use [ComparableMap.CompareAndSwap] instead +// to enforce comparability at compile time. +func (m *Map[Key, Value]) CompareAndSwap(key Key, oldVal, newVal Value) bool { + return m.syncMap().CompareAndSwap(key, oldVal, newVal) } +// Swap stores the value for a key and returns the previous value if any. +// The loaded result reports whether the key was present. func (m *Map[Key, Value]) Swap(key Key, value Value) (previous Value, loaded bool) { anyPrevious, loaded := m.syncMap().Swap(key, value) if !loaded { diff --git a/map_example_test.go b/map_example_test.go index 1e844a0..ddfa2e8 100644 --- a/map_example_test.go +++ b/map_example_test.go @@ -81,7 +81,7 @@ func ExampleMap_Range_break() { var cnt int - m.Range(func(key, value string) bool { + m.Range(func(_, _ string) bool { cnt++ return false }) @@ -92,8 +92,8 @@ func ExampleMap_Range_break() { // 1 } -func ExampleMap_CompareAndDelete() { - var m gsyncmap.Map[string, string] +func ExampleComparableMap_CompareAndDelete() { + var m gsyncmap.ComparableMap[string, string] m.Store("k1", "v1") @@ -109,8 +109,8 @@ func ExampleMap_CompareAndDelete() { // false } -func ExampleMap_CompareAndSwap() { - var m gsyncmap.Map[string, string] +func ExampleComparableMap_CompareAndSwap() { + var m gsyncmap.ComparableMap[string, string] m.Store("k1", "a1") diff --git a/map_test.go b/map_test.go new file mode 100644 index 0000000..d06a9da --- /dev/null +++ b/map_test.go @@ -0,0 +1,521 @@ +package gsyncmap_test + +import ( + "sync" + "sync/atomic" + "testing" + + "github.com/min0625/gsyncmap" +) + +// --- Map tests --- + +func TestMap_StoreAndLoad(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, int] + + // Load non-existing key returns zero value and false. + v, ok := m.Load("missing") + if ok || v != 0 { + t.Fatalf("Load missing key: got (%v, %v), want (0, false)", v, ok) + } + + m.Store("k", 42) + + v, ok = m.Load("k") + if !ok || v != 42 { + t.Fatalf("Load after Store: got (%v, %v), want (42, true)", v, ok) + } +} + +func TestMap_StoreOverwrite(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, int] + + m.Store("k", 1) + m.Store("k", 2) + + v, ok := m.Load("k") + if !ok || v != 2 { + t.Fatalf("Load after overwrite: got (%v, %v), want (2, true)", v, ok) + } +} + +func TestMap_Delete(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, int] + + // Delete non-existing key should not panic. + m.Delete("missing") + + m.Store("k", 1) + m.Delete("k") + + _, ok := m.Load("k") + if ok { + t.Fatal("expected key to be deleted") + } +} + +func TestMap_LoadOrStore(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, int] + + // Key absent: stores and returns the given value, loaded=false. + actual, loaded := m.LoadOrStore("k", 10) + if loaded || actual != 10 { + t.Fatalf("LoadOrStore new key: got (%v, %v), want (10, false)", actual, loaded) + } + + // Key present: returns the existing value, loaded=true. + actual, loaded = m.LoadOrStore("k", 99) + if !loaded || actual != 10 { + t.Fatalf("LoadOrStore existing key: got (%v, %v), want (10, true)", actual, loaded) + } +} + +func TestMap_LoadAndDelete(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, int] + + // Key absent: returns zero value and false. + v, loaded := m.LoadAndDelete("missing") + if loaded || v != 0 { + t.Fatalf("LoadAndDelete missing: got (%v, %v), want (0, false)", v, loaded) + } + + m.Store("k", 7) + + v, loaded = m.LoadAndDelete("k") + if !loaded || v != 7 { + t.Fatalf("LoadAndDelete existing: got (%v, %v), want (7, true)", v, loaded) + } + + _, ok := m.Load("k") + if ok { + t.Fatal("key should be absent after LoadAndDelete") + } +} + +func TestMap_Swap(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, int] + + // Key absent: previous is zero, loaded=false. + prev, loaded := m.Swap("k", 1) + if loaded || prev != 0 { + t.Fatalf("Swap missing key: got (%v, %v), want (0, false)", prev, loaded) + } + + // Key present: previous is old value, loaded=true. + prev, loaded = m.Swap("k", 2) + if !loaded || prev != 1 { + t.Fatalf("Swap existing key: got (%v, %v), want (1, true)", prev, loaded) + } + + v, _ := m.Load("k") + if v != 2 { + t.Fatalf("Load after Swap: got %v, want 2", v) + } +} + +func TestMap_Clear(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, int] + + m.Store("a", 1) + m.Store("b", 2) + m.Clear() + + var count int + + m.Range(func(_ string, _ int) bool { + count++ + return true + }) + + if count != 0 { + t.Fatalf("expected empty map after Clear, got %d entries", count) + } +} + +func TestMap_Range(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, int] + + want := map[string]int{"a": 1, "b": 2, "c": 3} + for k, v := range want { + m.Store(k, v) + } + + got := make(map[string]int) + + m.Range(func(k string, v int) bool { + got[k] = v + return true + }) + + if len(got) != len(want) { + t.Fatalf("Range visited %d entries, want %d", len(got), len(want)) + } + + for k, v := range want { + if got[k] != v { + t.Errorf("Range: key %q got %v, want %v", k, got[k], v) + } + } +} + +func TestMap_Range_EmptyMap(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, int] + + var visited int + + m.Range(func(_ string, _ int) bool { + visited++ + return true + }) + + if visited != 0 { + t.Fatalf("Range on empty map should not call f, called %d times", visited) + } +} + +func TestMap_Range_EarlyStop(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, int] + m.Store("a", 1) + m.Store("b", 2) + m.Store("c", 3) + + var visited int + + m.Range(func(_ string, _ int) bool { + visited++ + return false + }) + + if visited != 1 { + t.Fatalf("Range with early stop should visit exactly 1 entry, got %d", visited) + } +} + +func TestMap_CompareAndDelete_Deprecated(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, string] + + m.Store("k", "v1") + + // Wrong value: should not delete. + if m.CompareAndDelete("k", "wrong") { + t.Fatal("CompareAndDelete with wrong value should return false") + } + + v, ok := m.Load("k") + if !ok || v != "v1" { + t.Fatalf("key should still exist: got (%v, %v)", v, ok) + } + + // Correct value: should delete. + if !m.CompareAndDelete("k", "v1") { + t.Fatal("CompareAndDelete with correct value should return true") + } + + _, ok = m.Load("k") + if ok { + t.Fatal("key should be deleted") + } + + // Key absent: should return false. + if m.CompareAndDelete("k", "v1") { + t.Fatal("CompareAndDelete on missing key should return false") + } +} + +func TestMap_CompareAndSwap_Deprecated(t *testing.T) { + t.Parallel() + + var m gsyncmap.Map[string, string] + + m.Store("k", "old") + + // Wrong old value: should not swap. + if m.CompareAndSwap("k", "wrong", "new") { + t.Fatal("CompareAndSwap with wrong old value should return false") + } + + // Correct old value: should swap. + if !m.CompareAndSwap("k", "old", "new") { + t.Fatal("CompareAndSwap with correct old value should return true") + } + + v, _ := m.Load("k") + if v != "new" { + t.Fatalf("value after swap: got %v, want new", v) + } +} + +// --- ComparableMap tests --- + +func TestComparableMap_BasicOperations(t *testing.T) { + t.Parallel() + + var m gsyncmap.ComparableMap[int, int] + + m.Store(1, 100) + + v, ok := m.Load(1) + if !ok || v != 100 { + t.Fatalf("Load: got (%v, %v), want (100, true)", v, ok) + } + + m.Delete(1) + + _, ok = m.Load(1) + if ok { + t.Fatal("key should be deleted") + } +} + +func TestComparableMap_CompareAndDelete(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stored string + compareWith string + wantDeleted bool + }{ + {"match", "v1", "v1", true}, + {"mismatch", "v1", "v2", false}, + {"key absent", "", "", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var m gsyncmap.ComparableMap[string, string] + + if tc.name != "key absent" { + m.Store("k", tc.stored) + } + + deleted := m.CompareAndDelete("k", tc.compareWith) + if deleted != tc.wantDeleted { + t.Fatalf("CompareAndDelete: got %v, want %v", deleted, tc.wantDeleted) + } + }) + } +} + +func TestComparableMap_CompareAndSwap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stored string + oldVal string + newVal string + wantSwap bool + wantValue string + }{ + {"match", "v1", "v1", "v2", true, "v2"}, + {"mismatch", "v1", "wrong", "v2", false, "v1"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var m gsyncmap.ComparableMap[string, string] + m.Store("k", tc.stored) + + swapped := m.CompareAndSwap("k", tc.oldVal, tc.newVal) + if swapped != tc.wantSwap { + t.Fatalf("CompareAndSwap: got %v, want %v", swapped, tc.wantSwap) + } + + v, _ := m.Load("k") + if v != tc.wantValue { + t.Fatalf("value after swap: got %v, want %v", v, tc.wantValue) + } + }) + } +} + +func TestComparableMap_LoadOrStore(t *testing.T) { + t.Parallel() + + var m gsyncmap.ComparableMap[int, int] + + actual, loaded := m.LoadOrStore(1, 10) + if loaded || actual != 10 { + t.Fatalf("LoadOrStore new: got (%v, %v), want (10, false)", actual, loaded) + } + + actual, loaded = m.LoadOrStore(1, 99) + if !loaded || actual != 10 { + t.Fatalf("LoadOrStore existing: got (%v, %v), want (10, true)", actual, loaded) + } +} + +func TestComparableMap_Clear(t *testing.T) { + t.Parallel() + + var m gsyncmap.ComparableMap[int, int] + + for i := range 5 { + m.Store(i, i) + } + + m.Clear() + + var count int + + m.Range(func(_, _ int) bool { + count++ + return true + }) + + if count != 0 { + t.Fatalf("expected empty map after Clear, got %d entries", count) + } +} + +// --- Concurrent tests --- + +func TestMap_Concurrent_StoreLoad(t *testing.T) { + t.Parallel() + + const ( + goroutines = 50 + iterations = 100 + ) + + var ( + m gsyncmap.Map[int, int] + wg sync.WaitGroup + ) + + wg.Add(goroutines) + + for g := range goroutines { + go func(id int) { + defer wg.Done() + + for i := range iterations { + m.Store(id*iterations+i, i) + m.Load(id*iterations + i) + } + }(g) + } + + wg.Wait() +} + +func TestMap_Concurrent_LoadOrStore(t *testing.T) { + t.Parallel() + + const goroutines = 50 + + var ( + m gsyncmap.Map[string, int] + stored atomic.Int64 + ) + + var wg sync.WaitGroup + wg.Add(goroutines) + + for range goroutines { + go func() { + defer wg.Done() + + _, loaded := m.LoadOrStore("shared", 1) + if !loaded { + stored.Add(1) + } + }() + } + + wg.Wait() + + // Only one goroutine should have stored the value. + if stored.Load() != 1 { + t.Fatalf("LoadOrStore: expected exactly 1 store, got %d", stored.Load()) + } +} + +func TestMap_Concurrent_Range(t *testing.T) { + t.Parallel() + + const ( + entries = 100 + goroutines = 10 + ) + + var m gsyncmap.Map[int, int] + for i := range entries { + m.Store(i, i) + } + + var wg sync.WaitGroup + wg.Add(goroutines) + + for range goroutines { + go func() { + defer wg.Done() + + m.Range(func(_, _ int) bool { + return true + }) + }() + } + + wg.Wait() +} + +func TestComparableMap_Concurrent_CompareAndSwap(t *testing.T) { + t.Parallel() + + const goroutines = 50 + + var m gsyncmap.ComparableMap[string, int] + m.Store("counter", 0) + + // Each goroutine races to be the first to swap 0 -> 1. + var ( + swapped atomic.Int64 + wg sync.WaitGroup + ) + + wg.Add(goroutines) + + for range goroutines { + go func() { + defer wg.Done() + + if m.CompareAndSwap("counter", 0, 1) { + swapped.Add(1) + } + }() + } + + wg.Wait() + + if swapped.Load() != 1 { + t.Fatalf("expected exactly one successful CompareAndSwap, got %d", swapped.Load()) + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..42162e8 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +go = "1.24.13" +golangci-lint = "2.11.2" diff --git a/tools/go.mod b/tools/go.mod deleted file mode 100644 index adbf4df..0000000 --- a/tools/go.mod +++ /dev/null @@ -1,12 +0,0 @@ -module github.com/min0625/gsyncmap/tools - -go 1.20 - -require mvdan.cc/gofumpt v0.5.0 - -require ( - github.com/google/go-cmp v0.6.0 // indirect - golang.org/x/mod v0.13.0 // indirect - golang.org/x/sync v0.4.0 // indirect - golang.org/x/tools v0.14.0 // indirect -) diff --git a/tools/go.sum b/tools/go.sum deleted file mode 100644 index c037194..0000000 --- a/tools/go.sum +++ /dev/null @@ -1,15 +0,0 @@ -github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= -golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= -golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= -golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -mvdan.cc/gofumpt v0.5.0 h1:0EQ+Z56k8tXjj/6TQD25BFNKQXpCvT0rnansIc7Ug5E= -mvdan.cc/gofumpt v0.5.0/go.mod h1:HBeVDtMKRZpXyxFciAirzdKklDlGu8aAy1wEbH5Y9js= diff --git a/tools/tools.go b/tools/tools.go deleted file mode 100644 index de6f5cf..0000000 --- a/tools/tools.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build tools -// +build tools - -package tools - -import ( - _ "mvdan.cc/gofumpt" -)