featurebase/idk/kafka_sasl/source.go
2023-03-06 09:52:31 -06:00

335 lines
8.3 KiB
Go

package kafka_sasl
import (
"context"
"encoding/json"
"io"
"os"
"sort"
"strconv"
"sync"
"time"
confluent "github.com/confluentinc/confluent-kafka-go/kafka"
"github.com/featurebasedb/featurebase/v3/idk"
"github.com/featurebasedb/featurebase/v3/logger"
"github.com/pkg/errors"
)
// Source implements the idk.Source interface using kafka as a data
// source. It is not threadsafe! Due to the way Kafka clients work, to
// achieve concurrency, create multiple Sources.
type Source struct {
idk.ConfluentCommand
Topics []string
Group string
Log logger.Logger
Timeout time.Duration
SkipOld bool
Header string
AllowMissingFields bool
schema []idk.Field
paths idk.PathTable
// reader internal.KafkaReader
spoolBase uint64
spool []confluent.TopicPartition
highmarks []confluent.TopicPartition
client *confluent.Consumer
recordChannel chan recordWithError
ConfigMap *confluent.ConfigMap
// synchronize closing
quit chan struct{}
wg sync.WaitGroup
opened bool
mu sync.Mutex
}
// NewSource gets a new Source
func NewSource() *Source {
src := &Source{
Topics: []string{"test"},
Group: "group0",
Log: logger.NopLogger,
recordChannel: make(chan recordWithError),
quit: make(chan struct{}),
}
src.KafkaBootstrapServers = []string{"localhost:9092"}
return src
}
// Record returns the value of the next kafka message. The same Record
// object may be used by successive calls to Record, so it should not
// be retained.
func (s *Source) Record() (idk.Record, error) {
rec := s.fetch()
switch rec.Err {
case nil:
case io.EOF:
return nil, io.EOF
case context.DeadlineExceeded:
return nil, idk.ErrFlush
default:
return nil, errors.Wrap(rec.Err, "failed to fetch record from Kafka Confluent")
}
if rec.Record == nil {
return nil, idk.ErrFlush
}
data, err := s.decodeMessage(rec.Record.Value)
if err != nil {
return nil, errors.Wrap(err, "decoding with schema registry")
}
msg := rec.Record
// with librdkafka, committing an offset means that offset is
// where we should pick up from... we don't want to re-read this
// message, so we add 1
msg.TopicPartition.Offset++
s.mu.Lock()
defer s.mu.Unlock()
s.spool = append(s.spool, msg.TopicPartition)
return &Record{
src: s,
topic: *msg.TopicPartition.Topic,
partition: int(msg.TopicPartition.Partition),
offset: int64(msg.TopicPartition.Offset),
idx: s.spoolBase + uint64(len(s.spool)),
data: data,
}, err
}
type recordWithError struct {
Record *confluent.Message
Err error
}
func (s *Source) fetch() recordWithError {
return <-s.recordChannel
}
func (s *Source) decodeMessage(buf []byte) ([]interface{}, error) {
message := map[string]interface{}{}
err := json.Unmarshal(buf, &message)
if err != nil {
jsonError, ok := err.(*json.SyntaxError)
if ok {
return nil, errors.Wrapf(err, "unmarshaling kafka message at character offset %v: %s", jsonError.Offset, string(buf))
} else {
return nil, errors.Wrapf(err, "unmarshaling kafka message at unknown character offset: %s", string(buf))
}
}
return s.paths.Lookup(message, s.AllowMissingFields)
}
func (s *Source) Schema() []idk.Field {
return s.schema
}
func (s *Source) CommitMessages(recs []confluent.TopicPartition) ([]confluent.TopicPartition, error) {
return s.client.CommitOffsets(recs)
}
type Record struct {
src *Source
topic string
partition int
offset int64
idx uint64
data []interface{}
}
func (r *Record) StreamOffset() (string, uint64) {
return r.topic + ":" + strconv.Itoa(r.partition), uint64(r.offset)
}
var _ idk.OffsetStreamRecord = &Record{}
func (r *Record) Schema() interface{} { return nil }
func (r *Record) Commit(ctx context.Context) error {
r.src.mu.Lock()
defer r.src.mu.Unlock()
idx, base := r.idx, r.src.spoolBase
if idx < base {
return errors.New("cannot commit a record that has already been committed")
}
section, remaining := r.src.spool[:idx-base], r.src.spool[idx-base:]
// sort by increasing partition, decreasing offset
sort.Slice(section, func(i, j int) bool {
if section[i].Partition != section[j].Partition {
return section[i].Partition < section[j].Partition
}
return section[i].Offset > section[j].Offset
})
// calculate the high marks
p := int32(-1)
r.src.highmarks = r.src.highmarks[:0]
for _, x := range section {
if p != x.Partition {
r.src.highmarks = append(r.src.highmarks, x)
}
p = x.Partition
}
_, err := r.src.CommitMessages(r.src.highmarks)
if err != nil {
return errors.Wrap(err, "failed to commit messages")
}
r.src.spool = remaining
r.src.spoolBase = idx
return nil
}
func (r *Record) Data() []interface{} {
return r.data
}
// Open initializes the kafka source.
func (s *Source) Open() error {
if len(s.Header) == 0 {
return errors.New("needs header specification file")
}
headerData, err := os.ReadFile(s.Header)
if err != nil {
return errors.Wrap(err, "reading header file")
}
schema, paths, err := idk.ParseHeader(headerData)
if err != nil {
return errors.Wrap(err, "processing header")
}
s.schema = schema
s.paths = paths
// group
if s.Group != "" {
err = s.ConfigMap.SetKey("group.id", s.Group)
if err != nil {
return err
}
}
// when there is no initial offset in Kafka or if the current offset does not exist any more,
// use this as starting offset:
// "earliest": automatically reset the offset to the earliest offset
// "latest": automatically reset the offset to the latest offset
err = s.ConfigMap.SetKey("auto.offset.reset", "earliest")
if err != nil {
return err
}
if s.SkipOld {
err = s.ConfigMap.SetKey("auto.offset.reset", "latest")
if err != nil {
return err
}
}
cl, err := confluent.NewConsumer(s.ConfigMap)
if err != nil {
return errors.Wrap(err, "new consumer")
}
// by default, Kafka will use the stored offset (the latest committed message) and continue on from there.
// to skip old msgs, use rebalanceCbSkipOld to manually set offset to the end
err = cl.SubscribeTopics(s.Topics, nil)
if err != nil {
return errors.Wrap(err, "subscribe topics")
}
s.client = cl
s.opened = true
s.wg.Add(1)
go func() {
s.generator()
}()
return nil
}
func (c *Source) generator() {
defer func() {
close(c.recordChannel)
c.wg.Done()
}()
for {
select {
case <-c.quit:
return
default:
ev := c.client.Poll(100)
if ev == nil {
continue
}
switch e := ev.(type) {
// If we received an `AssignedPartitions` event, we need to make sure we
// assign the currently running consumer to the right partitions.
case confluent.AssignedPartitions:
err := c.client.Assign(e.Partitions)
if err != nil {
return
}
// If we received an `RevokedPartitions` event, we need to revoke this
// consumer from all partitions. This means this consumer won't pick up
// any work anymore, until a new `AssignedPartitions` event is handled.
case confluent.RevokedPartitions:
err := c.client.Unassign()
if err != nil {
return
}
// If we receive an error, something happened on Kafka's side. We don't
// know what happened or if we can recover gracefully, so we instead
// terminate the running process.
case confluent.Error:
msg := recordWithError{Err: e}
select {
case c.recordChannel <- msg:
case <-c.quit:
return
}
// On receiving a Kafka message, we process the received message and
// prepare it for delivery to the consumer of the consumer.messages
// channel.
case *confluent.Message:
msg := recordWithError{Record: e}
// Once the message has been prepared, we offer it to the consumer of
// the messages channel. Since this is a blocking channel, we also
// listen for the quit signal, and stop delivering new messages
// accordingly.
select {
case c.recordChannel <- msg:
case <-c.quit:
return
}
default:
continue // consumer doesn't care about all event types (e.g. OffsetsCommitted)
}
}
}
}
// Close closes the underlying kafka consumer.
func (s *Source) Close() error {
if s.client != nil {
if s.opened { // only close opened sources
s.quit <- struct{}{}
s.wg.Wait()
err := s.client.Close()
s.opened = false
return errors.Wrap(err, "closing kafka consumer")
}
}
return nil
}