Files
teleport/lib/utils/fanoutbuffer/buffer.go
T
rosstimothy 05be924ae0 Convert lib/utils to slog (#50340)
This only converts the places in the utils package that were
logging with logrus, but it does not fully remove the logrus
package as a dependency. The package is home to the logrus text
formatter which needs to exist until slog is the only logging
mechanism in use.
2024-12-19 15:08:04 +00:00

426 lines
12 KiB
Go

/*
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package fanoutbuffer
import (
"context"
"errors"
"log/slog"
"runtime"
"sync"
"sync/atomic"
"time"
"github.com/jonboulle/clockwork"
)
// ErrGracePeriodExceeded is an error returned by Cursor.Read indicating that the cursor fell
// too far behind. Observing this error indicates that either the reader is too slow, or the
// buffer has been configured with insufficient capacity/backlog for the usecase.
var ErrGracePeriodExceeded = errors.New("failed to process fanout buffer backlog within grace period")
// ErrUseOfClosedCursor is an error indicating that Cursor.Read was called after the cursor had
// either been explicitly closed, or had previously returned an error.
var ErrUseOfClosedCursor = errors.New("use of closed fanout buffer cursor (this is a bug)")
// ErrBufferClosed is an error indicating that the event buffer as a whole has been closed.
var ErrBufferClosed = errors.New("fanout buffer closed")
// Config holds all configuration parameters for the fanout buffer. All parameters are optional.
type Config struct {
// Capacity is the capacity to allocate for the main circular buffer. Capacity should be selected s.t. cursors rarely
// fall behind capacity during normal operation.
Capacity uint64
// GracePeriod is the amount of time a backlog (beyond the specified capacity) will be allowed to persist. Longer grace
// periods give cursors more time to catch up in the event of spikes, at the cost of higher potential memory usage and
// longer waits before unhealthy cursors are ejected.
GracePeriod time.Duration
// Clock is used to override default time-behaviors in tests.
Clock clockwork.Clock
}
// SetDefaults sets default config parameters.
func (c *Config) SetDefaults() {
if c.Capacity == 0 {
c.Capacity = 512
}
if c.GracePeriod == 0 {
c.GracePeriod = 30 * time.Second
}
if c.Clock == nil {
c.Clock = clockwork.NewRealClock()
}
}
// Buffer is a circular buffer that keeps track of how many cursors exist, and how many have seen
// each item, so that it knows when items can be cleared. If one or more cursors fall behind, the items
// they have yet to see go into a temporary backlog of infinite size. If the backlog persists for greater
// than the allowed grace period, it is cleared and all cursors still in the backlog fail on their next
// attempt to read.
type Buffer[T any] struct {
rw sync.RWMutex
// cfg holds buffer configuration.
cfg Config
// notify is closed and replaced each time an item is added to wake any cursors
// that are fully caught up.
notify chan struct{}
// closed indicates that the buffer has been closed.
closed bool
// cursors is the total number of cursors that exist. used to determine the number of cursors expected
// to observe each item.
cursors uint64
// pos is the absolute position of the oldest element in the ring, meaning that pos%Capacity is the index
// of that element.
pos uint64
// len is the total number of elements in the ring (i.e. the index of the newest element is at `(pos + len - 1) % Capacity`).
len uint64
// ring is the fixed-size circular buffer that serves as our primary storage location.
ring []entry[T]
// overflow is a variable length buffer of items that still need to be observed.
overflow []overflowEntry[T]
}
// entry represents an item in a buffer
type entry[T any] struct {
item T
// wait is the number of cursors that still need to observe this item. each cursor
// decrements this value when it observes the item. items with wait==0 are cleaned
// up. Note that this atomic is only safe to decrement while read lock is held. Entries
// may be moved while write lock is held.
wait atomic.Uint64
}
type overflowEntry[T any] struct {
entry[T]
expires time.Time
}
// NewBuffer creates a new fanout buffer instance.
func NewBuffer[T any](cfg Config) *Buffer[T] {
cfg.SetDefaults()
return &Buffer[T]{
cfg: cfg,
notify: make(chan struct{}),
ring: make([]entry[T], cfg.Capacity),
}
}
// Close permanently closes the fanout buffer. Note that all cursors are terminated immediately
// and may therefore miss items that had not been read at the time Close is called.
func (b *Buffer[T]) Close() {
b.write(func() {
b.closed = true
close(b.notify)
})
}
// NewCursor gets a new cursor into the buffer. Cursors *must* be closed as soon as they are
// no longer being read from. Stale cursors may cause performance degredation.
func (b *Buffer[T]) NewCursor() *Cursor[T] {
var c uint64
b.write(func() {
if b.closed {
return
}
c = b.pos + b.len
b.cursors++
})
cursor := &Cursor[T]{
buf: b,
cursor: c,
}
runtime.SetFinalizer(cursor, finalizeCursor[T])
return cursor
}
// Append appends to the buffer and wakes all dormant cursors.
func (b *Buffer[T]) Append(items ...T) {
var notify chan struct{}
b.write(func() {
if b.closed {
panic("Append called on closed fanout buffer")
}
// free up slots that are no longer needed
b.cleanupSlots()
for _, item := range items {
// ensure that there is at least one free slot in ring
b.ensureSlot()
// insert item into next free slot in ring
idx := int((b.pos + b.len) % b.cfg.Capacity)
b.ring[idx].item = item
b.ring[idx].wait.Store(b.cursors)
b.len++
}
notify = b.notify
b.notify = make(chan struct{})
})
// notify all waiting cursors. we do this outside of the
// lock to (theoretically) reduce potential contention.
close(notify)
}
// cleanupSlots frees up enries that have been seen by all cursors and clears the backlog if
// it has exceeded its grace period.
func (b *Buffer[T]) cleanupSlots() {
// trim items from overflow that have been seen by all cursors or are past their expiry
now := b.cfg.Clock.Now()
var clearOverflowTo int
for i := 0; i < len(b.overflow); i++ {
clearOverflowTo = i
if b.overflow[i].wait.Load() > 0 && b.overflow[i].expires.After(now) {
break
}
}
clear(b.overflow[:clearOverflowTo])
b.overflow = b.overflow[clearOverflowTo:]
// clear overflow start state if overflow is empty
if len(b.overflow) == 0 {
b.overflow = nil
}
// clear items from ring that have been seen by all cursors
for b.len > 0 && b.ring[int(b.pos%b.cfg.Capacity)].wait.Load() == 0 {
b.ring[int(b.pos%b.cfg.Capacity)] = entry[T]{}
b.pos++
b.len--
}
}
// ensure that there is at least one open slot in the ring. overflows items as needed.
func (b *Buffer[T]) ensureSlot() {
if b.len < b.cfg.Capacity {
// we have at least one free slot in ring, no need for overflow
return
}
// copy oldest entry to overflow. note the that it would actually be safe to just append the
// entire `entry` value rather than this append -> load -> store pattern that we follow
// here, but 'go vet' doesn't understand our locking semantics so we need to avoid implicitly
// copying the atomic, even when the write lock is held.
b.overflow = append(b.overflow, overflowEntry[T]{
entry: entry[T]{
item: b.ring[int(b.pos%b.cfg.Capacity)].item,
},
expires: b.cfg.Clock.Now().Add(b.cfg.GracePeriod),
})
b.overflow[len(b.overflow)-1].wait.Store(b.ring[int(b.pos%b.cfg.Capacity)].wait.Load())
// clear previous entry location.
b.ring[int(b.pos%b.cfg.Capacity)] = entry[T]{}
b.pos++
b.len--
}
func (b *Buffer[T]) read(fn func()) {
b.rw.RLock()
defer b.rw.RUnlock()
fn()
}
func (b *Buffer[T]) write(fn func()) {
b.rw.Lock()
defer b.rw.Unlock()
fn()
}
// getEntry gets the entry for a given cursor value. if entry is nil, the cursor is up to date. if healthy is false,
// then the cursor is too behind and no longer valid. The entry pointer is only valid so long
// as the lock is held.
func (b *Buffer[T]) getEntry(cursor uint64) (e *entry[T], healthy bool) {
if cursor >= b.pos+b.len {
// cursor has seen all items
return nil, true
}
if cursor >= b.pos {
// cursor is between oldest and newest item
return &b.ring[int(cursor%b.cfg.Capacity)], true
}
if off := b.pos - cursor; int(off) <= len(b.overflow) {
// cursor is within the backlog
return &b.overflow[len(b.overflow)-int(off)].entry, true
}
return nil, false
}
// Cursor is a cursor into a fanout buffer. Cursor's *must* be closed if not being actively read to avoid
// buffer performance degredation. Cursors are not intended for concurrent use (though they are "safe",
// concurrent calls may block longer than expected due to the lock being held across blocking reads).
type Cursor[T any] struct {
buf *Buffer[T]
mu sync.Mutex
cursor uint64
closed bool
}
// Read blocks until items become available and then reads them into the supplied buffer, returning the
// number of items that were read. Buffer size should be selected based on the expected throughput.
func (c *Cursor[T]) Read(ctx context.Context, out []T) (n int, err error) {
return c.read(ctx, out, true)
}
// TryRead performs a non-blocking read. returns (0, nil) if no output is available.
func (c *Cursor[T]) TryRead(out []T) (n int, err error) {
return c.read(context.Background(), out, false)
}
func (c *Cursor[T]) read(ctx context.Context, out []T, blocking bool) (n int, err error) {
c.mu.Lock()
defer func() {
if err != nil {
// note that we're a bit aggressive about closure here. in theory it would be
// acceptable to leave a cursor in a usable state after context cancellation.
// we opt to err on the side of caution here and close on all errors in order to
// limit the chances that misuse results in a leaked cursor.
c.closeLocked()
}
c.mu.Unlock()
}()
if len(out) == 0 {
// panic on empty output buffer (consistent with how standard library treats reads with empty buffers)
panic("empty buffer in Cursor.Read")
}
if c.closed {
return n, ErrUseOfClosedCursor
}
for {
var notify chan struct{}
var healthy bool
var closed bool
c.buf.read(func() {
if c.buf.closed {
closed = true
return
}
notify = c.buf.notify
for i := range out {
entry, h := c.buf.getEntry(c.cursor)
healthy = h
if entry == nil {
return
}
n++
c.cursor++
// pointer to entry is only valid while lock is held,
// so we decrement counter and extract item here.
entry.wait.Add(^uint64(0))
out[i] = entry.item
}
})
if closed {
// buffer has been closed
return n, ErrBufferClosed
}
if !healthy {
// fell too far behind
return n, ErrGracePeriodExceeded
}
if n != 0 || !blocking {
return n, nil
}
select {
case <-notify:
case <-ctx.Done():
return n, ctx.Err()
}
}
}
func finalizeCursor[T any](cursor *Cursor[T]) {
cursor.mu.Lock()
defer cursor.mu.Unlock()
if cursor.closed {
return
}
cursor.closeLocked()
slog.WarnContext(context.Background(), "Fanout buffer cursor was never closed. (this is a bug)")
}
// Close closes the cursor. Close is safe to double-call and should be called as soon as possible if
// the cursor is no longer in use.
func (c *Cursor[T]) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.closeLocked()
return nil
}
func (c *Cursor[T]) closeLocked() {
if c.closed {
return
}
c.closed = true
var cend uint64
c.buf.write(func() {
if c.buf.closed {
return
}
cend = c.buf.pos + c.buf.len
c.buf.cursors--
})
c.buf.read(func() {
if c.buf.closed {
return
}
// scan through all unread values and decrement their wait counters, since this
// watcher will never read them.
for cc := c.cursor; cc < cend; cc++ {
entry, _ := c.buf.getEntry(cc)
if entry != nil {
// decrement entry's wait count
entry.wait.Add(^uint64(0))
}
}
})
}