mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-01 21:52:02 +08:00
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.
426 lines
12 KiB
Go
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))
|
|
}
|
|
}
|
|
})
|
|
}
|