mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-10-06 19:07:50 +00:00
* unifying idk and featurebase: first pass * resolved conflict with master for gitignore & dockerignore * deleted binaries that were accidentally pushed to git * combined gitlab jobs for idk & featurebase * run go fmt for idk * updated ssh env variable, and made docker password variable in gitlab env variables * fixed typo assigning variable name * trying to fix docker login error * trying a different solution for docker password * pass registry * fixed docker login * updated paths for idk * exclude idk tests from featurebase test run * fix vendor error * update certificates * grpc needs to be in version 1.38 genproto, which is imported by big query updates the grpc version to 1.47.0 grpc 1.47.0 causes etcd to deadlock when calling etcd.Close() the fix is to have a replace in go.mod to specify a specific grpc version * run go mod tidy * go mod * run go mod tidy * exclude bigquery since it is causing issues and undo grpc replace in go.mod * fix grpc version * fix formatting error * update formatting * attempt to fix formatting * update path for code coverage * update to use current branch binaries, not master * fix for building idk - path updates * udpate path for binaries * update job dependecies * update docker idk tests to use the current branch registry * update stages for jobs * updated job dependencies * not allow idk s3 dump to fail since it is a dependency for integration tests * update dependecy for idk tests * update paths for idk build and code coverage * download featurebase binary from s3 * pass branch name to all setup scripts * change to current branch instead of master * updated sonarcloud * sonarcloud fix and branch name fix * trying to speed up pipeline run time * update stage * branch name fix + sonar cloud * sonarcloud
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/molecula/featurebase/v3/idk/internal"
|
|
"github.com/molecula/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.FileOrURLNotFound {
|
|
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)
|
|
}
|