featurebase/idk/kafka_static/source.go
Travis Turner eb6c6e3105
Add kafka support to CLI (fbsql) (#2278)
* Add kafka support to CLI (fbsql)

This commit adds the ability to provide a `--kafka-config` command line
argument referncing a toml file to configure kafka.

* Move "Molecula Consumer" message to the logger; hide it in basic mode

* Fold decimal(scale) into kafka.source-type

* Build fbsql with cgo in docker for CI

* Re-organize the fbsql kafka config and setup.

Allow field config to use the table schema if no fields provided.

* Display timestamp fields with format RFC3339Nano

* remove kafkaRunner (no longer used)

* Fix cli/batch test (and make sure it's not excluded from CI)

The logic in our Makefile was exluding from tests any package with
`/batch` in the package name. This excluded `/cli/batch`, which is not
good.

This commit changes the exclusion logic to include the `/v3` portion of
the package name, so `/v3/batch`.

* Rename Basic() to SetBasic()
2023-03-07 08:18:22 -06:00

289 lines
6.8 KiB
Go

package kafka_static
import (
"bytes"
"context"
"encoding/json"
"io"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/featurebasedb/featurebase/v3/idk"
"github.com/featurebasedb/featurebase/v3/idk/internal"
"github.com/featurebasedb/featurebase/v3/logger"
"github.com/pkg/errors"
segmentio "github.com/segmentio/kafka-go"
)
// 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 {
Hosts []string
Topics []string
Group string
TLS idk.TLSConfig
Log logger.Logger
Timeout time.Duration
SkipOld bool
// Header is a file or url referencing a file containing JSON header
// configuration.
Header string
// HeaderFields can be provided instead of Header. It is a slice of
// RawFields which will be marshalled and parsed the same way a JSON object
// in Header would be. It is used only if a Header is not provided.
HeaderFields []idk.RawField
S3Region string
AllowMissingFields bool
schema []idk.Field
paths idk.PathTable
reader internal.KafkaReader
spoolBase uint64
spool []segmentio.Message
}
// NewSource gets a new Source
func NewSource() *Source {
src := &Source{
Hosts: []string{"localhost:9092"},
Topics: []string{"test"},
Group: "group0",
Log: logger.NopLogger,
}
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) {
msg, err := s.fetch()
switch err {
case nil:
case io.EOF:
return nil, io.EOF
case context.DeadlineExceeded:
return nil, idk.ErrFlush
default:
return nil, errors.Wrap(err, "failed to fetch record from Kafka")
}
data, err := s.decodeMessage(msg.Value)
if err != nil {
return nil, errors.Wrap(err, "decoding with schema registry")
}
s.spool = append(s.spool, msg)
return &Record{
src: s,
topic: msg.Topic,
partition: msg.Partition,
offset: msg.Offset,
idx: s.spoolBase + uint64(len(s.spool)),
data: data,
}, err
}
func (s *Source) fetch() (segmentio.Message, error) {
ctx := context.Background()
if s.Timeout != 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, s.Timeout)
defer cancel()
}
return s.reader.FetchMessage(ctx)
}
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
}
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 {
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:]
err := r.src.reader.CommitMessages(ctx, section...)
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 && len(s.HeaderFields) == 0 {
return errors.New("needs header specification (file or fields)")
}
var headerData []byte
var err error
if s.Header != "" {
headerData, err = s.readFileOrURL(s.Header)
if err != nil {
return errors.Wrap(err, "reading header file")
}
} else {
headerData, err = json.Marshal(s.HeaderFields)
if err != nil {
return errors.Wrap(err, "marshalling header fields")
}
}
schema, paths, err := idk.ParseHeader(headerData)
if err != nil {
return errors.Wrap(err, "processing header")
}
s.schema = schema
s.paths = paths
// init (custom) config, enable errors and notifications
config := segmentio.ReaderConfig{
Brokers: s.Hosts,
GroupID: s.Group,
Logger: segmentio.LoggerFunc(s.Log.Debugf),
ErrorLogger: s.Log,
}
if s.SkipOld {
config.StartOffset = segmentio.LastOffset
}
if s.TLS.CertificatePath != "" {
tlsConfig, err := idk.GetTLSConfig(&s.TLS, s.Log)
if err != nil {
return errors.Wrap(err, "getting TLS config")
}
config.Dialer = &segmentio.Dialer{
Timeout: 10 * time.Second,
DualStack: true,
TLS: tlsConfig,
}
}
readers := make(map[string]internal.KafkaReader, len(s.Topics))
for _, topic := range s.Topics {
config := config
config.Topic = topic
readers[topic] = internal.RetryReader{Reader: segmentio.NewReader(config)}
}
// Throw the readers into a blender.
s.reader = internal.BlendKafka(readers)
// TODO: dump stats
return nil
}
// Close closes the underlying kafka consumer.
func (s *Source) Close() error {
err := s.reader.Close()
return errors.Wrap(err, "closing kafka consumer")
}
func (s *Source) readFileOrURL(name string) ([]byte, error) {
var content []byte
var err error
if strings.HasPrefix(name, "s3://") {
u, err := url.Parse(name)
if err != nil {
return nil, errors.Wrapf(err, "parsing S3 URL %v", name)
}
bucket := u.Host
key := u.Path[1:] // strip leading slash
config := &aws.Config{}
if s.S3Region != "" {
config.Region = aws.String(s.S3Region)
// else, NewSession will use the default region.
}
sess, err := session.NewSession(config)
if err != nil {
return nil, errors.Wrap(err, "creating S3 session")
}
s3client := s3.New(sess)
result, err := s3client.GetObject(&s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return nil, errors.Wrapf(err, "fetching S3 object %v", name)
}
buf := new(bytes.Buffer)
bytesRead, err := buf.ReadFrom(result.Body)
if err != nil {
return nil, errors.Wrapf(err, "reading S3 object %v", name)
}
s.Log.Printf("read %d bytes from %s\n", bytesRead, name)
content = buf.Bytes()
} else {
content, err = os.ReadFile(name)
if err != nil {
return nil, errors.Wrapf(err, "reading file %v", name)
}
}
return content, nil
}