mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
* Add (commented out) linters that we should introduce I went through the available linters and added (commented out) the ones I think we should work on in the near term. In other words, fix them, then uncomment them so they are enabled in CI. * linter: errchkjson * linter: ineffassign * linter: gosimple * linter: errname
389 lines
12 KiB
Go
389 lines
12 KiB
Go
package kinesis
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/aws/awserr"
|
|
"github.com/aws/aws-sdk-go/service/kinesis"
|
|
"github.com/aws/aws-sdk-go/service/kinesis/kinesisiface"
|
|
"github.com/aws/aws-sdk-go/service/s3/s3iface"
|
|
"github.com/featurebasedb/featurebase/v3/idk/internal"
|
|
"github.com/featurebasedb/featurebase/v3/logger"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
defaultGetRecordsBatchSize = 10000
|
|
defaultGetRecordsQueriesPerSecondAtTip = 1
|
|
defaultGetRecordsQueriesPerSecondBehindTip = 5
|
|
|
|
getRecordsErrorBackoff = 10 * time.Second
|
|
waitForActiveStreamBackoff = 5 * time.Second
|
|
waitForClosedShardBackoff = 1 * time.Second
|
|
|
|
ShardStatusActive = "ACTIVE"
|
|
ShardStatusPending = "PENDING"
|
|
ShardStatusClosed = "CLOSED"
|
|
)
|
|
|
|
// ShardRecord wraps a kinesis record including metadata about its shard and an IDK-local index
|
|
type ShardRecord struct {
|
|
ShardID string
|
|
Index uint64
|
|
*kinesis.Record
|
|
}
|
|
|
|
type StreamReader struct {
|
|
recordsChan chan ShardRecord
|
|
StreamReaderConfig
|
|
offsets *StreamOffsets
|
|
// keep track of what shards have been consumed to the end
|
|
shardStatus sync.Map
|
|
stopCh chan struct{}
|
|
processShardsMtx sync.Mutex
|
|
}
|
|
|
|
type StreamReaderConfig struct {
|
|
log logger.Logger
|
|
streamName string
|
|
offsetsPath string
|
|
kinesisClient kinesisiface.KinesisAPI
|
|
s3client s3iface.S3API
|
|
getRecordsBatchSize int
|
|
getRecordsQueriesPerSecondAtTip int
|
|
getRecordsQueriesPerSecondBehindTip int
|
|
}
|
|
|
|
func (r *StreamReader) Close() {
|
|
r.log.Infof("Shutting down stream reader")
|
|
close(r.stopCh)
|
|
}
|
|
|
|
func (r *StreamReader) getShardIterator(shardID string) *string {
|
|
r.log.Infof("Getting iterator for shard %s", shardID)
|
|
shardIteratorInput := &kinesis.GetShardIteratorInput{
|
|
// Shard Id is provided when making put record(s) request.
|
|
ShardId: aws.String(shardID),
|
|
ShardIteratorType: aws.String(kinesis.ShardIteratorTypeTrimHorizon),
|
|
StreamName: aws.String(r.streamName),
|
|
}
|
|
|
|
if shardOffset, ok := r.offsets.Load(shardID); ok {
|
|
r.log.Infof("Shard %s will begin reading from sequence number %s", shardID, shardOffset.SequenceNumber)
|
|
shardIteratorInput.ShardIteratorType = aws.String(kinesis.ShardIteratorTypeAfterSequenceNumber)
|
|
shardIteratorInput.StartingSequenceNumber = &shardOffset.SequenceNumber
|
|
}
|
|
|
|
// retrieve iterator
|
|
iteratorOutput, err := r.kinesisClient.GetShardIterator(shardIteratorInput)
|
|
if err != nil {
|
|
panic(errors.Wrap(err, "Failed to get shard iterator"))
|
|
}
|
|
return iteratorOutput.ShardIterator
|
|
}
|
|
|
|
// loadRecordIndex reads the shard record index from the offsets file
|
|
// or returns a pointer initialized with a value of 0 if not found
|
|
func (r *StreamReader) loadRecordIndex(shardID string) *uint64 {
|
|
var idx uint64
|
|
if shardOffset, ok := r.offsets.Load(shardID); ok {
|
|
atomic.StoreUint64(&idx, shardOffset.Index)
|
|
}
|
|
return &idx
|
|
}
|
|
|
|
type shardReader struct {
|
|
*StreamReader
|
|
shardID string
|
|
shardIterator *string
|
|
recordIdx *uint64
|
|
atTip bool
|
|
limiter *rate.Limiter
|
|
}
|
|
|
|
func (r *StreamReader) newShardReader(shardID string) *shardReader {
|
|
return &shardReader{
|
|
StreamReader: r,
|
|
shardID: shardID,
|
|
shardIterator: r.getShardIterator(shardID),
|
|
recordIdx: r.loadRecordIndex(shardID),
|
|
atTip: false,
|
|
limiter: rate.NewLimiter(rate.Limit(r.getRecordsQueriesPerSecondBehindTip), 1),
|
|
}
|
|
}
|
|
|
|
// Start consumes records from a Kinesis shard indefinitely until the shard
|
|
// has been closed, at which point this method will return.
|
|
func (r *shardReader) start() {
|
|
r.log.Infof("Starting to get records for shard %s", r.shardID)
|
|
defer r.log.Infof("Stopped reading from shard %s", r.shardID)
|
|
|
|
for {
|
|
select {
|
|
case <-r.stopCh:
|
|
return
|
|
default:
|
|
if err := r.getRecords(); err != nil {
|
|
r.log.Errorf("Backing off due to Kinesis GetRecords error: %v\n", err)
|
|
time.Sleep(getRecordsErrorBackoff)
|
|
}
|
|
}
|
|
// mark the shard as closed, so we can reflect its status in the offsets file once
|
|
// all records have been committed
|
|
if r.shardIterator == nil {
|
|
r.log.Infof("Shard %s has been closed", r.shardID)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *shardReader) getRecords() error {
|
|
if err := r.limiter.Wait(context.Background()); err != nil {
|
|
r.log.Warnf("Unexpected error waiting for shard reader limiter: %v", err)
|
|
}
|
|
// get records use shard iterator for making request
|
|
records, err := r.kinesisClient.GetRecords(&kinesis.GetRecordsInput{
|
|
Limit: aws.Int64(int64(r.getRecordsBatchSize)),
|
|
ShardIterator: r.shardIterator,
|
|
})
|
|
|
|
if err != nil {
|
|
if aerr, ok := err.(awserr.Error); ok {
|
|
switch aerr.Code() {
|
|
case kinesis.ErrCodeExpiredIteratorException:
|
|
r.log.Warnf("Shard iterator expired for shard %s", r.shardID)
|
|
// obtain a new shard iterator from Kinesis
|
|
r.shardIterator = r.getShardIterator(r.shardID)
|
|
// retry calling GetRecords with new iterator
|
|
return nil
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
if len(records.Records) > 0 {
|
|
r.log.Debugf("Read %d records from shard %v", len(records.Records), r.shardID)
|
|
for _, d := range records.Records {
|
|
r.recordsChan <- ShardRecord{ShardID: r.shardID, Index: atomic.AddUint64(r.recordIdx, 1), Record: d}
|
|
}
|
|
}
|
|
|
|
r.shardIterator = records.NextShardIterator
|
|
if records.NextShardIterator == nil {
|
|
return nil
|
|
}
|
|
|
|
if *records.MillisBehindLatest == 0 { // at tip of queue
|
|
if !r.atTip {
|
|
r.log.Debugf("Caught up with tip of shard %s", r.shardID)
|
|
// we are caught up and can reduce the polling rate
|
|
r.limiter.SetLimit(rate.Limit(r.getRecordsQueriesPerSecondAtTip))
|
|
r.atTip = true
|
|
}
|
|
} else { // behind tip of queue
|
|
if r.atTip {
|
|
r.log.Debugf("Fell behind tip of shard %s", r.shardID)
|
|
r.limiter.SetLimit(rate.Limit(r.getRecordsQueriesPerSecondBehindTip))
|
|
r.atTip = false
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ReadOffsets(cfg StreamReaderConfig) (*StreamOffsets, error) {
|
|
offsetsBytes, err := internal.ReadFileOrURL(cfg.offsetsPath, cfg.s3client)
|
|
if err != nil {
|
|
if err == internal.ErrFileOrURLNotFound {
|
|
return &StreamOffsets{
|
|
StreamName: cfg.streamName,
|
|
Shards: make(map[string]*ShardOffset),
|
|
}, nil
|
|
}
|
|
return nil, errors.Wrap(err, "failed to read path")
|
|
}
|
|
|
|
var offsets StreamOffsets
|
|
if err := json.Unmarshal(offsetsBytes, &offsets); err != nil {
|
|
return nil, errors.Wrap(err, "failed to unmarshal offsets")
|
|
}
|
|
cfg.log.Infof("Successfully read offsets")
|
|
return &offsets, err
|
|
}
|
|
|
|
func (r *StreamReader) isShardClosed(shardID string) bool {
|
|
// unknown shards (nil entry in status map) are expired and effectively closed
|
|
if status, found := r.shardStatus.Load(shardID); found && status != ShardStatusClosed {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// CanReadFromShard returns true when both parent and adjacent shards have been closed
|
|
// if they exist
|
|
func (r *StreamReader) canReadFromShard(shard *kinesis.Shard) bool {
|
|
return (shard.ParentShardId == nil || r.isShardClosed(*shard.ParentShardId)) &&
|
|
(shard.AdjacentParentShardId == nil || r.isShardClosed(*shard.AdjacentParentShardId))
|
|
}
|
|
|
|
func (r *StreamReader) startShardReader(shard *kinesis.Shard) {
|
|
for !r.canReadFromShard(shard) {
|
|
r.log.Debugf("Waiting for parent shards of %s to be closed", *shard.ShardId)
|
|
time.Sleep(waitForClosedShardBackoff)
|
|
}
|
|
r.shardStatus.Store(*shard.ShardId, ShardStatusActive)
|
|
reader := r.newShardReader(*shard.ShardId)
|
|
// Start returns when the shard has been closed
|
|
reader.start()
|
|
r.shardStatus.Store(*shard.ShardId, ShardStatusClosed)
|
|
|
|
select {
|
|
case <-r.stopCh:
|
|
return
|
|
default:
|
|
r.log.Infof("Reprocessing shards due to close event")
|
|
if err := r.ProcessShards(); err != nil {
|
|
// cannot recover
|
|
panic("failed to reprocess shards after a close event")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *StreamReader) ProcessShards() error {
|
|
r.log.Infof("Processing shards for stream %s", r.streamName)
|
|
r.processShardsMtx.Lock()
|
|
defer r.processShardsMtx.Unlock()
|
|
|
|
for {
|
|
streamOut, err := r.kinesisClient.DescribeStream(
|
|
&kinesis.DescribeStreamInput{StreamName: aws.String(r.streamName)})
|
|
|
|
if err != nil {
|
|
if aerr, ok := err.(awserr.Error); ok {
|
|
switch aerr.Code() {
|
|
case kinesis.ErrCodeResourceNotFoundException:
|
|
return errors.Wrap(err, "kinesis.DescribeStream stream not found")
|
|
}
|
|
}
|
|
r.log.Errorf("kinesis.DescribeStream failed with error %v", err)
|
|
return errors.Wrap(err, "calling kinesis.DescribeStream")
|
|
}
|
|
|
|
if *streamOut.StreamDescription.StreamStatus == kinesis.StreamStatusActive {
|
|
shards := streamOut.StreamDescription.Shards
|
|
for _, shard := range shards {
|
|
shardID := *shard.ShardId
|
|
// process the new shard if we haven't seen it before, and set its status to pending
|
|
if _, exists := r.shardStatus.LoadOrStore(shardID, ShardStatusPending); !exists {
|
|
r.log.Infof("Found pending shard %s", *shard.ShardId)
|
|
go r.startShardReader(shard)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
r.log.Infof("Waiting for stream %s to become active", r.streamName)
|
|
time.Sleep(waitForActiveStreamBackoff)
|
|
}
|
|
}
|
|
|
|
func NewStreamReader(cfg StreamReaderConfig) (*StreamReader, error) {
|
|
// initialize optional config if not set
|
|
if cfg.getRecordsBatchSize == 0 {
|
|
cfg.getRecordsBatchSize = defaultGetRecordsBatchSize
|
|
}
|
|
if cfg.getRecordsQueriesPerSecondAtTip == 0 {
|
|
cfg.getRecordsQueriesPerSecondAtTip = defaultGetRecordsQueriesPerSecondAtTip
|
|
}
|
|
if cfg.getRecordsQueriesPerSecondBehindTip == 0 {
|
|
cfg.getRecordsQueriesPerSecondBehindTip = defaultGetRecordsQueriesPerSecondBehindTip
|
|
}
|
|
|
|
offsets, err := ReadOffsets(cfg)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to read offsets")
|
|
}
|
|
|
|
return &StreamReader{
|
|
recordsChan: make(chan ShardRecord, 100),
|
|
StreamReaderConfig: cfg,
|
|
offsets: offsets,
|
|
shardStatus: sync.Map{},
|
|
stopCh: make(chan struct{}),
|
|
}, nil
|
|
}
|
|
|
|
type ShardOffset struct {
|
|
ArrivalTime string `json:"arrival_time"`
|
|
CommittedTime string `json:"committed_time"`
|
|
Index uint64 `json:"index"`
|
|
SequenceNumber string `json:"sequence_number"`
|
|
}
|
|
|
|
type StreamOffsets struct {
|
|
// synchronize updates/reads to the Shards map
|
|
sync.RWMutex
|
|
StreamName string `json:"stream_name"`
|
|
Shards map[string]*ShardOffset `json:"shards"`
|
|
}
|
|
|
|
// Load returns the ShardOffset for the shard in a thread safe manner
|
|
func (o *StreamOffsets) Load(shardID string) (*ShardOffset, bool) {
|
|
o.RLock()
|
|
defer o.RUnlock()
|
|
v, ok := o.Shards[shardID]
|
|
return v, ok
|
|
}
|
|
|
|
func (r *StreamReader) Start() error {
|
|
if err := r.ProcessShards(); err != nil {
|
|
return errors.Wrap(err, "failed to process shards")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *StreamReader) FetchMessage(ctx context.Context) (ShardRecord, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ShardRecord{}, err
|
|
}
|
|
|
|
select {
|
|
case record, ok := <-r.recordsChan:
|
|
if !ok {
|
|
return ShardRecord{}, io.EOF
|
|
}
|
|
return record, nil
|
|
|
|
case <-ctx.Done():
|
|
return ShardRecord{}, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (r *StreamReader) CommitMessages(ctx context.Context, msgs ...ShardRecord) error {
|
|
r.log.Debugf("Commiting %d messages", len(msgs))
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
r.offsets.Lock()
|
|
for _, record := range msgs {
|
|
r.offsets.Shards[record.ShardID] = &ShardOffset{
|
|
ArrivalTime: (*record.ApproximateArrivalTimestamp).UTC().Format(time.RFC3339),
|
|
CommittedTime: now,
|
|
Index: atomic.LoadUint64(&record.Index),
|
|
SequenceNumber: *record.SequenceNumber,
|
|
}
|
|
}
|
|
r.offsets.Unlock()
|
|
|
|
offsetsBytes, err := json.Marshal(r.offsets)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to marshal offsets")
|
|
}
|
|
return internal.WriteFileOrURL(r.offsetsPath, offsetsBytes, r.s3client)
|
|
}
|