Skip to content

Feat/shard memory storage - #4401

Closed
dima11223432 wants to merge 19 commits into
gofiber:mainfrom
dima11223432:feat/shard-memory-storage
Closed

Feat/shard memory storage#4401
dima11223432 wants to merge 19 commits into
gofiber:mainfrom
dima11223432:feat/shard-memory-storage

Conversation

@dima11223432

Copy link
Copy Markdown

Description

This PR eliminates global mutex contention under high concurrent loads (>10K QPS) by implementing the Sharded Map pattern across two internal components:

  1. Idempotency Locker (middleware/idempotency/locker.go): Replaced the single global sync.Mutex with a 32-shard structure. Implemented a robust double-checked locking mechanism to safely handle thread creation, deletion, and reference counting without concurrent map writes or deadlocks.
  2. Memory Storage Backend (internal/storage/memory/memory.go): Converted the single sync.RWMutex protecting the entire database map into a 32-shard segmented array ([]*Shard). Each shard manages its own lock, isolated map, and garbage collection sequence.

Benefits

  • Reduces lock contention by ~32x.
  • Write operations (Set/Delete) in one shard no longer block read operations (Get) in other shards.
  • Prevents serialization of requests using different idempotency keys.

Fixes #4361

Changes introduced

  • Performance improvement: Mitigated lock contention bottlenecks at high concurrency levels.
  • Code consistency: Ensured complete thread-safety across all segmented structures (including edge cases in nested lock evaluations during garbage collection and key rotation).

Type of change

  • Performance improvement (non-breaking change which improves efficiency)
  • Code consistency (non-breaking change which improves code reliability and robustness)

Checklist

  • Conducted a self-review of the code and provided comments for complex or critical parts.
  • Ensured that new and existing unit tests pass locally with the changes.
  • Aimed for optimal performance with minimal allocations in the new code.

@dima11223432
dima11223432 requested a review from a team as a code owner June 4, 2026 12:59
@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 28598061-b29b-4569-9ea3-0bb91bd4b7d7

📥 Commits

Reviewing files that changed from the base of the PR and between c450d8a and eb8f79e.

📒 Files selected for processing (2)
  • internal/storage/memory/memory.go
  • middleware/idempotency/locker.go
💤 Files with no reviewable changes (2)
  • middleware/idempotency/locker.go
  • internal/storage/memory/memory.go

Walkthrough

Replaces single global mutex maps with fixed-size sharded maps for internal memory storage and the idempotency locker; operations are routed by an FNV-1a-style hash to per-shard maps protected by shard-local locks, and GC/connection snapshots operate per-shard.

Changes

Sharded concurrency pattern for storage and idempotency

Layer / File(s) Summary
Memory storage: data structures and initialization
internal/storage/memory/memory.go
Adds Shard type, replaces Storage internals with shards []*Shard, done chan struct{}, and closeOnce sync.Once; New() initializes shards and starts GC.
Memory storage: Get, Set, Delete, Reset
internal/storage/memory/memory.go
Get/Set/Delete route to shard via getHash(key) % numShards and use per-shard RW locks; Set copies key/value before storing; Reset replaces each shard's map under that shard's lock.
Memory storage: GC, Conn, Keys, Close
internal/storage/memory/memory.go
Close stops GC using closeOnce; GC uses a per-shard ticker and two-step expiry (collect under RLock, delete under Lock); Conn merges shard snapshots under RLocks; Keys scans shards and filters expired entries.
Memory storage: hashing helper
internal/storage/memory/memory.go
Adds getHash(key string) uint32 implementing an FNV-1a-like hash used for shard selection.
Idempotency locker: data structures and initialization
middleware/idempotency/locker.go
Adds numShards and lockerShard; refactors MemoryLock to hold shards []*lockerShard; NewMemoryLock allocates shards and per-shard key maps.
Idempotency locker: Lock and Unlock operations
middleware/idempotency/locker.go
Lock routes to a shard and uses per-key countedLock with refcount and retry if the shard map entry changed; Unlock decrements and removes keys when count reaches zero; getShard hashes key bytes to pick shard.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • gofiber/fiber#3828: Modifies memory storage Get/Set behavior around copying stored key/value buffers; related to storage data handling changes.
  • gofiber/fiber#3263: Changes MemoryLock Lock/Unlock refcounting and key deletion logic; related to idempotency locker refactor with added sharding.

Suggested reviewers

  • gaby
  • ReneWerner87
  • efectn
  • sixcolors

Poem

🐰 I hopped through shards with tiny paws,

I hashed each key to split the draws,
Thirty-two burrows, locks snug and tight,
GC hums softly through the night,
— a rabbit’s wiggle at concurrency done right.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Feat/shard memory storage' clearly and concisely describes the main change—converting single global mutex storage to a sharded design for better concurrency. It is specific enough to convey the core improvement.
Description check ✅ Passed The description is comprehensive: it clearly states the problem (global mutex contention), the solution (32-shard pattern), the benefits (32x contention reduction), and includes relevant sections (Changes introduced, Type of change, Checklist). Key details and issue reference (#4361) are provided.
Linked Issues check ✅ Passed The PR fully implements the linked issue #4361 requirements: both components (memory storage and idempotency locker) are converted to 32-shard maps with per-shard locks, reducing global contention by ~32x while maintaining thread-safety and garbage collection correctness.
Out of Scope Changes check ✅ Passed All changes are in-scope: memory storage sharding, idempotency locker sharding, and FNV-1a hash implementation for shard selection. All modifications directly address the contention bottleneck identified in #4361.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ReneWerner87 ReneWerner87 added the v3 label Jun 4, 2026
@ReneWerner87 ReneWerner87 added this to v3 Jun 4, 2026
@ReneWerner87 ReneWerner87 added this to the v3 milestone Jun 4, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces sharding to both the in-memory storage and the idempotency locker to reduce lock contention and improve concurrent performance. While the sharding implementation is a solid improvement, the reviewer identifies several opportunities to further optimize performance and reduce heap allocations. Specifically, the reviewer suggests implementing an inline, allocation-free FNV-1a hash function to avoid allocations from fnv.New32a() and string-to-byte conversions. Additionally, the reviewer recommends refactoring the Reset and Keys methods to process shards sequentially rather than spawning concurrent goroutines, which eliminates unnecessary scheduling overhead and slice allocations.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +308 to +312
func getHash(key string) uint32 {
h := fnv.New32a()
h.Write([]byte(key))
return h.Sum32()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current getHash implementation uses fnv.New32a() which allocates a new hash.Hash32 interface/struct on the heap on every call, and converting the string to a byte slice []byte(key) can also cause allocations. Since this is a high-throughput memory storage, we can implement an inline, allocation-free FNV-1a hash function directly on the string to completely eliminate heap allocations.

Suggested change
func getHash(key string) uint32 {
h := fnv.New32a()
h.Write([]byte(key))
return h.Sum32()
}
func getHash(key string) uint32 {
const (
offset32 = 2166136261
prime32 = 16777619
)
hash := uint32(offset32)
for i := 0; i < len(key); i++ {
hash ^= uint32(key[i])
hash *= prime32
}
return hash
}

Comment on lines +96 to 100
func (l *MemoryLock) getShard(key string) *lockerShard {
h := fnv.New32a()
h.Write([]byte(key))
return l.shards[h.Sum32()%numShards]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current getShard implementation uses fnv.New32a() which allocates a new hash.Hash32 interface/struct on the heap on every call, and converting the string to a byte slice []byte(key) can also cause allocations. Since this is a high-throughput memory storage, we can implement an inline, allocation-free FNV-1a hash function directly on the string to completely eliminate heap allocations.

Suggested change
func (l *MemoryLock) getShard(key string) *lockerShard {
h := fnv.New32a()
h.Write([]byte(key))
return l.shards[h.Sum32()%numShards]
}
func (l *MemoryLock) getShard(key string) *lockerShard {
const (
offset32 = 2166136261
prime32 = 16777619
)
hash := uint32(offset32)
for i := 0; i < len(key); i++ {
hash ^= uint32(key[i])
hash *= prime32
}
return l.shards[hash%numShards]
}

Comment on lines 159 to 175
func (s *Storage) Reset() error {
ndb := make(map[string]Entry)
s.mux.Lock()
s.db = ndb
s.mux.Unlock()
wg := &sync.WaitGroup{}

for _, shard := range s.shards {
wg.Add(1)
go func(shrd *Shard) {
defer wg.Done()

shrd.mux.Lock()
shrd.db = make(map[string]Entry)
shrd.mux.Unlock()
}(shard)
}

wg.Wait()
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Spawning 32 goroutines and using a sync.WaitGroup to reset 32 shards introduces unnecessary scheduling overhead and allocations. Since numShards is small (32), resetting them sequentially in a simple loop is much faster, cleaner, and avoids spawning goroutines entirely.

func (s *Storage) Reset() error {
	for _, shard := range s.shards {
		shard.mux.Lock()
		shard.db = make(map[string]Entry)
		shard.mux.Unlock()
	}
	return nil
}

Comment on lines 257 to 299
func (s *Storage) Keys() ([][]byte, error) {
s.mux.RLock()
defer s.mux.RUnlock()
wg := &sync.WaitGroup{}
var keysLen = 0
for _, shard := range s.shards {
shard.mux.RLock()
keysLen += len(shard.db)
shard.mux.RUnlock()
}

if len(s.db) == 0 {
// check if no valid keys were found
if keysLen == 0 {
return nil, nil
}

localKeys := make([][][]byte, numShards)
ts := utils.Timestamp()
keys := make([][]byte, 0, len(s.db))
for key, v := range s.db {
// Filter out the expired keys
if v.expiry == 0 || v.expiry > ts {
keys = append(keys, []byte(key))
}
for i, shard := range s.shards {
wg.Add(1)
go func(idx int, shrd *Shard) {
defer wg.Done()
shrd.mux.RLock()
defer shrd.mux.RUnlock()
for key, v := range shrd.db {
// Filter out the expired keys
if v.expiry == 0 || v.expiry > ts {
localKeys[idx] = append(localKeys[idx], []byte(key))
}
}
}(i, shard)

}

wg.Wait()

keys := make([][]byte, 0, keysLen)
for _, shardKeys := range localKeys {
keys = append(keys, shardKeys...)
}
// Double check if no valid keys were found
if len(keys) == 0 {
return nil, nil
}

return keys, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Spawning 32 goroutines in Keys() to collect keys concurrently introduces significant overhead and multiple slice allocations (a slice of slices localKeys, plus individual slices per shard).

By refactoring this to a sequential loop, we can pre-allocate a single keys slice with the exact capacity needed (keysLen) and append directly to it. This completely eliminates goroutine scheduling overhead and reduces heap allocations to a single slice allocation.

func (s *Storage) Keys() ([][]byte, error) {
	var keysLen = 0
	for _, shard := range s.shards {
		shard.mux.RLock()
		keysLen += len(shard.db)
		shard.mux.RUnlock()
	}

	if keysLen == 0 {
		return nil, nil
	}

	keys := make([][]byte, 0, keysLen)
	ts := utils.Timestamp()
	for _, shard := range s.shards {
		shard.mux.RLock()
		for key, v := range shard.db {
			if v.expiry == 0 || v.expiry > ts {
				keys = append(keys, []byte(key))
			}
		}
		shard.mux.RUnlock()
	}

	if len(keys) == 0 {
		return nil, nil
	}
	return keys, nil
}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/storage/memory/memory.go`:
- Around line 235-254: The Conn() comment is stale: after the sharding refactor
Conn() builds and returns a fresh mergedMaps snapshot instead of a live shared
map; update the docstring for Storage.Conn to state it returns a point-in-time
copy (a new map built from s.shards/shard.db) and warn callers that the returned
map is not kept synchronized with storage and must not be used for concurrent
mutations or to observe live updates.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1efbd556-7e84-4ad6-8bce-da2f8d8cf894

📥 Commits

Reviewing files that changed from the base of the PR and between eb5b174 and 02af2f0.

📒 Files selected for processing (2)
  • internal/storage/memory/memory.go
  • middleware/idempotency/locker.go

Comment on lines 235 to 254
// Conn returns the underlying storage map. The returned map remains shared with
// the storage, so callers must not modify it and must synchronize any access
// that overlaps with other storage operations.
func (s *Storage) Conn() map[string]Entry {
s.mux.RLock()
defer s.mux.RUnlock()
return s.db
var allocatedMapLen = 0
for _, shard := range s.shards {
shard.mux.RLock()
allocatedMapLen += len(shard.db)
shard.mux.RUnlock()
}
mergedMaps := make(map[string]Entry, allocatedMapLen)
for _, shard := range s.shards {
shard.mux.RLock()
for k, v := range shard.db {
mergedMaps[k] = v
}
shard.mux.RUnlock()
}
return mergedMaps
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Documentation is outdated after sharding refactor.

The comment states the returned map "remains shared with the storage," but the implementation now creates a fresh merged snapshot each call. Callers expecting live updates via the returned map would be surprised.

If the snapshot behavior is intentional, update the comment to reflect that this returns a point-in-time copy.

📝 Suggested comment update
-// Conn returns the underlying storage map. The returned map remains shared with
-// the storage, so callers must not modify it and must synchronize any access
-// that overlaps with other storage operations.
+// Conn returns a merged snapshot of all shard maps. The returned map is a copy;
+// modifications to it do not affect the storage. Note that the snapshot is not
+// atomic across shards—concurrent writes may result in a partially stale view.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/storage/memory/memory.go` around lines 235 - 254, The Conn() comment
is stale: after the sharding refactor Conn() builds and returns a fresh
mergedMaps snapshot instead of a live shared map; update the docstring for
Storage.Conn to state it returns a point-in-time copy (a new map built from
s.shards/shard.db) and warn callers that the returned map is not kept
synchronized with storage and must not be used for concurrent mutations or to
observe live updates.

@ReneWerner87

Copy link
Copy Markdown
Member

Thanks @dima11223432 for picking this up, the implementation work and the careful per-shard refactoring across both files is real engineering effort and I appreciate the time you put into it.

I want to be upfront about why we are not going to merge this in the current form: after looking at the trade-off and at the underlying issue (#4361), the sharding pattern is not the right fit for these two components in this codebase.

The short version:

  • The internal memory storage targets low to medium workloads. Production deployments that hit >10K QPS on storage use external backends (Redis, Memcached, etc.), not in-process maps. Sharding the in-process variant adds substantial complexity in a place where the realistic upper bound on throughput sits well below the contention threshold the issue describes.
  • The idempotency locker's global sync.Mutex is held only during the keys-map lookup plus create or delete (nanoseconds). The actual idempotency wait already runs on a per-key sync.Mutex, so different keys do not serialize. At 10K req/s with distinct keys the global mu accounts for roughly 0.05% CPU. There is no measured contention here.
  • For the real hot-key patterns we see in practice (rate-limiter under the same source IP, cache stampede on a single key), sharding by key buys nothing because all the contending requests hash to the same shard.

Separate from the underlying question, several things on the change itself would need work before it could land:

  • Conn() silently changes from returning a live shared map to a snapshot. Downstream sessions / ratelimit / csrf may rely on the live-map semantics; CodeRabbit caught this.
  • Reset() and Keys() spawn 32 goroutines + a sync.WaitGroup for what is a 32-iteration loop. Gemini caught this.
  • getHash calls fnv.New32a() which heap-allocates per call. Gemini caught this too.
  • The per-key refcount + delete logic in the idempotency locker is famously hard to get right under sharding, and there are no race tests in the PR demonstrating the new code is correct.
  • There are no concurrent benchmarks to back up the "32x" performance claim.

If at some point we see profile data from a real workload showing the storage or locker mutex as the bottleneck, we will reopen this discussion with that evidence in hand. Until then, the maintenance and correctness cost of carrying this design exceeds the benefit for the workloads the in-process implementation targets.

Closing #4361 alongside this PR with the same reasoning.

Thanks again for the effort, and sorry for the negative outcome on a substantial chunk of work.

@dima11223432

Copy link
Copy Markdown
Author

Thanks for your feedback, Rene! It is incredibly important and educational for me.

Just to clarify and lock down the learning experience: so the underlying issue basically described a theoretical problem rather than a real-world bottleneck, meaning the lock contention it anticipated doesn't actually impact performance in realistic workloads?

I completely understand the reasoning behind the trade-offs regarding simplicity and hot-key patterns now. Thank you for taking the time to write such a detailed design review!

@ReneWerner87

Copy link
Copy Markdown
Member

That's right; unfortunately, I didn't realize that until I looked at the resulting code and did some research.

@ReneWerner87 ReneWerner87 modified the milestones: v3, v3.4.0 Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

🔥 feat: shard memory storage and idempotency locker to reduce lock contention

2 participants