mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
* 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()
289 lines
6.8 KiB
Go
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
|
|
}
|