package ring // Ring is a ring buffer backed by a slice that rearranges itself to grow and // shrink as needed. It can also be grown and shrunk manually. It is not safe // for concurrent use, and the zero value is ready for use. Dequeued and cleared // items are zeroed in the underlying slice to release references and allow // garbage collection. Leaving growth and shrinkage of the internal slice apart, // which can be directly controlled, all operations are allocation free. type Ring[T any] struct { buf []T stats RingStats back, len int // Min sets the minimum capacity that the Ring can have, and takes effect // in the next write operation. The Ring will naturally tend to shrink // towards Min when free capacity allows it. Setting this value has no // immediate effect, but instead affects future writing operations. Min is // valid only if: // 0 < Min && ( Max <= 0 || Min <= Max ) // Note that this allows setting Min but not Max, or setting both as well. Min int // Max sets the maximum capacity that the Ring can grow to store new items. // Setting this value has no immediate effect, but instead affects future // writing operations. Max is valid only if: // 0 < Max && Min <= Max // Note that this allows setting Max but not Min, or setting both as well. Max int } // RingStats provides general stats for a Ring. type RingStats struct { // Len is the used capacity. Len int // Cap is the current total capacity. Cap int // Grown is the number of times a larger buffer was allocated. Grown uint64 // Shrunk is the number of times a smaller buffer was allocated. Shrunk uint64 // Allocs is Grown + Shrunk. Allocs uint64 // Enqueued is the total number of items entered into the Ring, including // those which caused other items to be dropped. Enqueued uint64 // Dequeued is the total number of items removed from the Ring, including // items removed with Dequeue and with Clear. Dequeued uint64 // Dropped is the number of items lost due to the Ring being at capacity. Dropped uint64 } // Len returns the used capacity. func (rq *Ring[T]) Len() int { return rq.len } // Cap returns the current total capacity. func (rq *Ring[T]) Cap() int { return len(rq.buf) } // WriteStats writes general stats about this Ring to the given *RingStats, if // it's non-nil. func (rq *Ring[T]) WriteStats(s *RingStats) { if s == nil { return } rq.stats.Len = rq.len rq.stats.Cap = len(rq.buf) *s = rq.stats } // Clear removes all items from the Ring and returns the number of items // removed. If Min is valid and Cap() > Min, it will also shrink the capacity to // Min. Stats are not cleared, but instead Dequeued is increased by the number // of removed items. func (rq *Ring[T]) Clear() int { cleared := rq.len rq.stats.Dequeued += uint64(cleared) shouldMigrate := clearShouldMigrate(len(rq.buf), rq.Min, rq.Max) if rq.len > 0 && !shouldMigrate { // if we migrate we don't need to clear items, since moving to the new // slice will just have the old slice garbage collected chunk := min(rq.back+rq.len, len(rq.buf)) clear(rq.buf[rq.back:chunk]) clear(rq.buf[:rq.len-chunk]) } rq.back = 0 rq.len = 0 if shouldMigrate { rq.migrate(rq.Min) } return cleared } // Shrink makes sure free capacity is not greater than n, shrinking if // necessary. If a new allocation is needed then it will be capped to Min, given // than Min is valid. func (rq *Ring[T]) Shrink(n int) { if n < 0 || rq.len+n >= len(rq.buf) { return } rq.migrate(n) } // Grow makes sure free capacity is at least n, growing if necessary. If a new // allocation is needed then it will be capped to Max, given that Max is valid. func (rq *Ring[T]) Grow(n int) { if n < 1 || rq.len+n <= len(rq.buf) { return } rq.migrate(n) } func (rq *Ring[T]) migrate(newFreeCap int) { newCap := rq.len + newFreeCap newCap = fixAllocSize(rq.len, rq.Min, rq.Max, newCap) if newCap == len(rq.buf) { return } var s []T if newCap > 0 { // if newCap == 0 then just set rq.s to nil s = make([]T, newCap) } if len(s) > len(rq.buf) { rq.stats.Grown++ } else { rq.stats.Shrunk++ } if rq.len > 0 { chunk1 := min(rq.back+rq.len, len(rq.buf)) copied := copy(s, rq.buf[rq.back:chunk1]) if copied < rq.len { // wrapped the slice chunk2 := rq.len - copied copy(s[copied:], rq.buf[:chunk2]) } } rq.back = 0 rq.buf = s } // Enqueue adds the given item to the Ring, growing the capacity if needed. If // the Ring is at capacity (0 < Max && Min <= Max && rq.Len() == rq.Cap()), // then the new item will overwrite the oldest enqueued item. func (rq *Ring[T]) Enqueue(v T) { // try to add space if we're at capacity or fix min allocation if rq.len == len(rq.buf) || (minIsValid(rq.Min, rq.Max) && len(rq.buf) < rq.Min) { newFreeCap := rq.len + 1 newFreeCap = newFreeCap*3/2 + 1 // classic append: https://go.dev/blog/slices newFreeCap -= rq.len // migrate only takes free capacity rq.migrate(newFreeCap) // if growing was capped at max, then overwrite the first item to be // dequeued if rq.len == len(rq.buf) { rq.stats.Dropped++ rq.len-- if rq.back++; rq.back >= len(rq.buf) { rq.back = 0 // wrap the slice } } } writePos := rq.back + rq.len if writePos >= len(rq.buf) { writePos -= len(rq.buf) } rq.buf[writePos] = v rq.len++ rq.stats.Enqueued++ } // Peek is like Dequeue, but it doesn't remove the item. func (rq *Ring[T]) Peek() (v T) { if rq.len == 0 { return } return rq.buf[rq.back] } // Dequeue removes the oldest enqueued item and returns it. If the Ring is // empty, it returns the zero value. func (rq *Ring[T]) Dequeue() (v T) { if rq.len == 0 { return } // get the value into v, and also zero out the slice item to release // references so they can be gc'd v, rq.buf[rq.back] = rq.buf[rq.back], v rq.len-- if rq.back++; rq.back >= len(rq.buf) { rq.back = 0 // wrap the slice } if minIsValid(rq.Min, rq.Max) && rq.len < len(rq.buf)/2+1 { newFreeCap := len(rq.buf)*2/3 + 1 // opposite of growing arithmetic newFreeCap -= rq.len // migrate only takes free capacity rq.migrate(newFreeCap) } rq.stats.Dequeued++ return v } // the following functions provide small checks and arithmetics that are far // easier to test separately than creating big and more complex tests covering a // huge amount of combinatory options. This reduces the complexity of higher // level tests and leaves only higher level logic, but also allows us to provide // high coverage for even the most rare edge and boundary cases by adding a new // line to the test cases table. They're also inlineable, so no penalty in // calling them. func minIsValid(Min, Max int) bool { return 0 < Min && (Max <= 0 || Min <= Max) } func maxIsValid(Min, Max int) bool { return 0 < Max && Min <= Max } func clearShouldMigrate(CurCap, Min, Max int) bool { return minIsValid(Min, Max) && CurCap > Min } // fixAllocSize is a helper to determine what should be the new size to be // allocated for a new slice, given the intended NewCap and the current relevant // state of Ring. This is expected to be called inside (*Ring).migrate. func fixAllocSize(CurLen, Min, Max, NewCap int) int { if minIsValid(Min, Max) { // Min is valid NewCap = max(NewCap, CurLen, Min) } else { NewCap = max(CurLen, NewCap) } if maxIsValid(Min, Max) { // Max is valid NewCap = min(NewCap, Max) } return NewCap }