featurebase/idk/kafka/putsource.go
Travis Turner aa17b8d725
Enable linter: stylecheck (#2317)
* Enable linter: stylecheck

This enabled the stylecheck linter, but excludes some staticchecks for
now. The following are ignored because they will take a bit of time to
address, but the intention is to address them and remove them from the
exclusion list.

ST1000: at least one file in a package should have a package comment
ST1003: golang naming standards
ST1008: error should be returned as the last argument
ST1016: methods on the same type should have the same receiver name
ST1020: comment on exported function

* Address ST1015

For some reason this failed in CI but not locally. I can't figure out
why that check isn't happening locally. This just moves the switch
statements around so that the `default` is the first (or last) item.

* Adjust error string in test to match case-adjusted error

* Remove TestCloseTimeout
2023-03-14 08:45:18 -05:00

821 lines
22 KiB
Go

package kafka
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"hash/fnv"
"io"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
confluent "github.com/confluentinc/confluent-kafka-go/kafka"
"github.com/featurebasedb/featurebase/v3/idk"
"github.com/featurebasedb/featurebase/v3/idk/common"
"github.com/featurebasedb/featurebase/v3/idk/kafka/csrc"
"github.com/featurebasedb/featurebase/v3/logger"
"github.com/featurebasedb/featurebase/v3/pql"
"github.com/go-avro/avro"
liavro "github.com/linkedin/goavro/v2"
"github.com/pkg/errors"
"golang.org/x/sync/errgroup"
)
// PutSource represents a kafka put process that operates on one
// or more idk.Source.
type PutSource struct {
idk.ConfluentCommand
Topic string `help:"Kafka topic to post to."`
Subject string `help:"Kafka schema subject."`
BatchSize int `help:"Size of record batches to submit to Kafka."`
// FB specific config for setting partition key efficiently
FBPrimaryKeyFields []string
FBIndexName string
FBIDField string
Concurrency int `help:"Number of concurrent sources and indexing routines to launch."`
TrackProgress bool `help:"Periodically print status updates on how many records have been sourced." short:""`
ReplicationFactor int `help:"set replication factor for kafka cluster"`
NumPartitions int `help:"set partition for kafka cluster"`
// NewSource must be set by the user of Main before calling
// Main.Run. Main.Run will call this function "Concurrency" times. It
// is the job of this function to ensure that the concurrent
// sources which are started partition work appropriately. This is
// typically set up (by convention) in the Source's package in
// cmd.go
NewSource func() (idk.Source, error) `flag:"-"`
Log logger.Logger
progress *idk.ProgressTracker
schemaClient *csrc.Client
ConfigMap *confluent.ConfigMap `flag:"-"`
Target string
}
// NewPutSource returns a new instance of PutSource.
func NewPutSource() (*PutSource, error) {
var err error
p := PutSource{}
p.ConfluentCommand = idk.ConfluentCommand{}
p.KafkaBootstrapServers = []string{"localhost:9092"}
p.SchemaRegistryURL = "http://localhost:8081"
p.Topic = "defaulttopic"
p.Subject = "defaultsubject"
p.BatchSize = 1000
p.Log = logger.NewStandardLogger(os.Stderr)
p.ConfigMap, err = common.SetupConfluent(&p.ConfluentCommand)
return &p, err
}
// Run sends the source records to kafka based on concurrency.
func (p *PutSource) Run() error {
// Set up the schema registry client.
var auth *csrc.BasicAuth
if p.SchemaRegistryUsername != "" {
auth = &csrc.BasicAuth{
KafkaSchemaApiKey: p.SchemaRegistryUsername,
KafkaSchemaApiSecret: p.SchemaRegistryPassword,
}
}
p.schemaClient = csrc.NewClient(p.SchemaRegistryURL, nil, auth)
if p.TrackProgress {
p.progress = &idk.ProgressTracker{}
startTime := time.Now()
var wg sync.WaitGroup
defer wg.Wait()
doneCh := make(chan struct{})
defer func() { close(doneCh) }()
wg.Add(1)
go func() {
defer wg.Done()
// Set up a timer to check progress every 10 seconds.
tick := time.NewTicker(10 * time.Second)
defer tick.Stop()
prev := uint64(0)
stalled := true
for {
progress := p.progress.Check()
switch {
case progress != prev:
// Forward progress continues.
p.Log.Printf("sourced %d records (%.2f records/minute)", progress, float64(progress)/time.Since(startTime).Minutes())
stalled = false
case stalled:
// We already told the user that it is stalled.
default:
// This is the start of a stall.
// No records have been sourced in the past 5 seconds.
p.Log.Printf("record sourcing stalled")
stalled = true
}
prev = progress
select {
case <-tick.C:
case <-doneCh:
// Generate a final status update.
p.Log.Printf("sourced %d records in %s", p.progress.Check(), time.Since(startTime))
return
}
}
}()
}
// Create Producer instance
pr, err := confluent.NewProducer(p.ConfigMap)
if err != nil {
return errors.Wrap(err, "Failed to create producer")
}
// Create the topic if it does not exist.
err = CreateKafkaTopic(context.Background(), p.Topic, pr, p.NumPartitions, p.ReplicationFactor)
pr.Close()
if err != nil {
return errors.Wrap(err, "creating topic")
}
//Make producer channel buffered
err = p.ConfigMap.SetKey("go.produce.channel.size", 1)
if err != nil {
return errors.Wrap(err, "setting produce channel")
}
// TODO: i don't think we actually want to run these concurrently for now
// we can just iterate over them serially.
eg := errgroup.Group{}
for c := 0; c < p.Concurrency; c++ {
c := c
eg.Go(func() error {
// targets allowed are defined in datagen target, make sure they match
var err error
if p.Target == "kafka" {
err = p.runSource(c)
} else { // this is for target kafkastatic
err = p.runSourceJson(c)
}
if err != nil && err != io.EOF {
return err
}
return nil
})
}
err = eg.Wait()
if err != nil && err != io.EOF {
return errors.Wrap(err, "confluent.PutSource.Run")
}
return nil
}
func convertToJson(schema []idk.Field, record []interface{}) ([]byte, error) {
if len(schema) != len(record) {
return []byte{}, fmt.Errorf("length of schema %v and record %v don't match", len(schema), len(record))
}
mesg := make(map[string]interface{})
for i := range schema {
field, val := schema[i], record[i]
if valb, ok := val.([]byte); ok {
val = string(valb)
}
mesg[field.Name()] = val
}
return json.Marshal(mesg)
}
func (p *PutSource) getKeyFunc(schema []idk.Field) (func(vals []interface{}) ([]byte, error), error) {
if len(p.FBPrimaryKeyFields) > 0 {
indices := make([]int, len(p.FBPrimaryKeyFields))
for i, fieldName := range p.FBPrimaryKeyFields {
for j, f := range schema {
if f.Name() == fieldName {
indices[i] = j
break
}
}
}
return func(vals []interface{}) ([]byte, error) {
h := fnv.New64a()
h.Write([]byte(p.FBIndexName))
for pos, ind := range indices {
valb, ok := vals[ind].([]byte)
if !ok {
valS, ok := vals[ind].(string)
if !ok {
return nil, errors.Errorf("primary key value must be string or byte slice, but got %v of %[1]T", vals[ind])
}
valb = []byte(valS)
}
h.Write(valb)
if pos < len(indices)-1 {
h.Write([]byte{'|'})
}
}
partition := h.Sum64() % 256
ret := make([]byte, 8)
binary.BigEndian.PutUint64(ret, partition)
return ret, nil
}, nil
} else if p.FBIDField != "" {
return nil, errors.New("getting partition key for IDField not implemented")
}
return nil, nil // autogen doesn't need to set the key at all
}
func (p *PutSource) runSourceJson(c int) error {
p.Log.Printf("start source %d", c)
source, err := p.NewSource()
if err != nil {
return errors.Wrap(err, "getting source")
}
if p.progress != nil {
source = p.progress.Track(source)
}
// Create Producer instance
producer, err := confluent.NewProducer(p.ConfigMap)
if err != nil {
return errors.Wrap(err, "Failed to create producer")
}
finished := int32(0)
iter := int64(0)
doneChan := common.LaunchKafkaEventConfirmer(producer, &finished, &iter)
defer producer.Close()
var schema []idk.Field
var getKeyFunc func(vals []interface{}) ([]byte, error)
rec, err := source.Record()
if err == nil {
err = idk.ErrSchemaChange // always need to fetch the schema the first time
}
for ; ; rec, err = source.Record() {
if err == idk.ErrFlush {
continue
}
if err != nil || iter == 0 {
if err == idk.ErrSchemaChange || iter == 0 {
p.Log.Debugf("handle kafka schema\n")
schema = source.Schema()
getKeyFunc, err = p.getKeyFunc(schema)
if err != nil && err.Error() != "getting partition key for IDField not implemented" {
return errors.Wrap(err, "getting key func")
}
} else {
break
}
}
// in custom.go, we return nil when it is EOF
if rec == nil {
break
}
// map schema and records to json
data, err := convertToJson(schema, rec.Data())
if err != nil {
return errors.Wrap(err, "converting record to json")
}
// maybe we need a message key, but don't know what to do
p.Log.Debugf("put kafka record json: %v, %s, %s, %v\n", p.Topic, rec.Data()[0], data, err)
var key []byte
if getKeyFunc != nil {
key, err = getKeyFunc(rec.Data())
if err != nil {
return errors.Wrap(err, "trying to get partition key")
}
}
producer.ProduceChannel() <- &confluent.Message{
TopicPartition: confluent.TopicPartition{Topic: &p.Topic, Partition: confluent.PartitionAny},
Key: key,
Value: data,
}
atomic.AddInt64(&iter, 1)
}
atomic.AddInt32(&finished, 1)
<-doneChan
recNotFlushed := producer.Flush(15 * 10000) // 10ms
count := 0
for recNotFlushed != 0 {
recNotFlushed = producer.Flush(15 * 10000) // 10ms
count += 1
if count > 4 {
p.Log.Debugf("Tried more than 4 times to flush confluent producer")
break
}
}
if err != io.EOF {
return errors.Wrap(err, "getting record")
}
p.Log.Printf("Put %d generated records\n", iter)
return nil
}
func (p *PutSource) runSource(c int) error {
p.Log.Printf("start source %d", c)
source, err := p.NewSource()
if err != nil {
return errors.Wrap(err, "getting source")
}
if p.progress != nil {
source = p.progress.Track(source)
}
// Create Producer instance
producer, err := confluent.NewProducer(p.ConfigMap)
if err != nil {
return errors.Wrap(err, "Failed to create producer")
}
defer producer.Close()
finished := int32(0)
iter := int64(0)
doneChan := common.LaunchKafkaEventConfirmer(producer, &finished, &iter)
var licodec *liavro.Codec
var resp *csrc.SchemaResponse
var schema []idk.Field
rec, err := source.Record()
if err == nil {
err = idk.ErrSchemaChange // always need to fetch the schema the first time
}
for ; ; rec, err = source.Record() {
if err == idk.ErrFlush {
continue
}
if err != nil || iter == 0 {
if err == idk.ErrSchemaChange || iter == 0 {
p.Log.Debugf("handle kafka schema\n")
schema = source.Schema()
recordSchema, err := idkSchemaToAvroRecordSchema(schema)
if err != nil {
return errors.Wrap(err, "converting schema to record schema")
}
b, err := json.Marshal(recordSchema)
if err != nil {
return errors.Wrap(err, "converting record schema to json")
}
schemaStr := string(b)
resp, err = p.schemaClient.PostSubjects(p.Subject, schemaStr)
if err != nil {
return errors.Wrap(err, "posting schema")
}
p.Log.Printf("Posted schema ID: %d\n", resp.ID)
licodec, err = liavro.NewCodec(schemaStr)
if err != nil {
return errors.Wrap(err, "li decoding schema")
}
} else {
break
}
}
// Convert record to a data structure suitable for kafka put.
recordMap, err := p.recordToMap(rec, schema)
if err != nil {
return errors.Wrap(err, "converting record to map")
}
data, err := endcodeAvro(int(resp.ID), licodec, recordMap)
if err != nil {
return errors.Wrap(err, "encoding record")
}
producer.ProduceChannel() <- &confluent.Message{
TopicPartition: confluent.TopicPartition{Topic: &p.Topic, Partition: confluent.PartitionAny},
//Key: []byte(strconv.Itoa(uid)),
Value: data,
}
atomic.AddInt64(&iter, 1) //increment message produced
}
atomic.AddInt32(&finished, 1) //signal no more messages
<-doneChan //wait till kafka acknowledges
recNotFlushed := producer.Flush(15 * 10000) // 10ms
count := 0
for recNotFlushed != 0 {
recNotFlushed = producer.Flush(10000) // 10ms
count += 1
if count > 4 {
p.Log.Debugf("Tried more than 4 times to flush confluent producer")
break
}
}
if err != io.EOF {
return errors.Wrap(err, "getting record")
}
return nil
}
// recordToMap converts an idk.Record to a map of interfaces based on the
// provided schema.
func (p *PutSource) recordToMap(rec idk.Record, schema []idk.Field) (map[string]interface{}, error) {
m := make(map[string]interface{})
data := rec.Data()
if len(schema) != len(data) {
return nil, errors.Errorf("number of data fields (%d) does not match schema (%d)", len(data), len(schema))
}
// TODO: return errors for cases where casting isn't ok
for i, field := range schema {
v := data[i]
if v == nil {
m[field.Name()] = nil
continue
}
switch fld := field.(type) {
case idk.BoolField:
b, err := toBool(v)
if err != nil {
return nil, errors.Wrap(err, "converting value to bool")
}
m[fld.Name()] = map[string]interface{}{"boolean": b}
case idk.DateIntField:
if n, err := toInt64(v); err == nil {
m[fld.Name()] = map[string]interface{}{"long": n}
} else {
m[fld.Name()] = map[string]interface{}{"bytes": v}
}
case idk.DecimalField:
d, err := toDecimal(v, fld.Scale)
if err != nil {
return nil, errors.Wrap(err, "converting value to decimal")
}
m[fld.Name()] = map[string]interface{}{"bytes": d}
case idk.IDArrayField:
switch vt := v.(type) {
case []uint64:
int64s := make([]int64, len(vt))
for i := range vt {
int64s[i] = int64(vt[i])
}
m[fld.Name()] = map[string]interface{}{"array": int64s}
case []int64:
m[fld.Name()] = map[string]interface{}{"array": vt}
}
case idk.IDField:
l, err := toInt64(v)
if err != nil {
return nil, errors.Wrap(err, "converting value to int64")
}
m[fld.Name()] = map[string]interface{}{"long": l}
case idk.IntField:
l, err := toInt64(v)
if err != nil {
return nil, errors.Wrap(err, "converting value to int64")
}
m[fld.Name()] = map[string]interface{}{"long": l}
case idk.RecordTimeField:
m[fld.Name()] = map[string]interface{}{"bytes": v}
case idk.TimestampField:
buf := make([]byte, 8)
u := uint64(v.(time.Time).Unix())
binary.BigEndian.PutUint64(buf, u)
m[fld.Name()] = map[string]interface{}{"bytes": buf}
case idk.SignedIntBoolKeyField:
l, err := toInt64(v)
if err != nil {
return nil, errors.Wrap(err, "converting value to int64")
}
m[fld.Name()] = map[string]interface{}{"long": l}
case idk.StringArrayField:
m[fld.Name()] = map[string]interface{}{"array": v}
case idk.StringField:
s, err := toString(v)
if err != nil {
return nil, errors.Wrap(err, "converting value to string")
}
m[fld.Name()] = map[string]interface{}{"string": s}
default:
m[fld.Name()] = v
}
}
return m, nil
}
// idkSchemaToAvroRecordSchema converts an []idk.Field to a RecordSchema.
func idkSchemaToAvroRecordSchema(fields []idk.Field) (*avro.RecordSchema, error) {
record := &avro.RecordSchema{
Name: "idk_datagen",
Namespace: "idk.datagen",
Doc: "idk-datagen",
Fields: make([]*avro.SchemaField, len(fields)),
}
for n, fld := range fields {
var schemaField = &avro.SchemaField{}
quantum := idk.QuantumOf(fld)
var ttl time.Duration = 0
if quantum != "" {
ttlTemp, err := idk.TTLOf(fld)
if err != nil {
return nil, err
}
ttl = ttlTemp
}
ttlString := ttl.String()
cacheConfig := idk.CacheConfigOf(fld)
hasMutex := idk.HasMutex(fld)
switch typ := fld.(type) {
case idk.BoolField:
schemaField.Type = &avro.UnionSchema{
Types: []avro.Schema{
&avro.NullSchema{},
&avro.BooleanSchema{},
},
}
schemaField.Default = nil
case idk.DateIntField:
props := map[string]interface{}{
"fieldType": "dateInt", // TODO: namespace molecula custom properties?
"epoch": typ.Epoch.Format(typ.Layout),
"unit": typ.Unit,
"customUnit": typ.CustomUnit,
"layout": typ.Layout,
}
schemaField.Type = &avro.UnionSchema{
Types: []avro.Schema{
&avro.NullSchema{},
&avro.BytesSchema{Properties: props},
&avro.LongSchema{Properties: props},
},
}
schemaField.Default = nil
case idk.TimestampField:
props := map[string]interface{}{
"fieldType": "timestamp",
"granularity": typ.Granularity,
"layout": typ.Layout,
"epoch": typ.Epoch.Format(typ.Layout),
"unit": typ.Unit,
}
schemaField.Type = &avro.UnionSchema{
Types: []avro.Schema{
&avro.NullSchema{},
&avro.BytesSchema{Properties: props},
},
}
schemaField.Default = nil
case idk.DecimalField:
props := map[string]interface{}{
"fieldType": "decimal",
"scale": typ.Scale,
"precision": idk.DecimalPrecision,
}
schemaField.Type = &avro.UnionSchema{
Types: []avro.Schema{
&avro.NullSchema{},
&avro.BytesSchema{Properties: props},
},
}
schemaField.Default = nil
case idk.IDArrayField:
schemaField.Type = &avro.UnionSchema{
Types: []avro.Schema{
&avro.NullSchema{},
&avro.ArraySchema{
Items: &avro.LongSchema{
Properties: map[string]interface{}{
"quantum": quantum,
"ttl": ttlString,
"cacheType": string(cacheConfig.CacheType),
"cacheSize": strconv.Itoa(cacheConfig.CacheSize),
},
},
},
},
}
schemaField.Default = nil
case idk.IDField:
schemaField.Type = &avro.UnionSchema{
Types: []avro.Schema{
&avro.NullSchema{},
&avro.LongSchema{
Properties: map[string]interface{}{
"fieldType": "id",
"mutex": hasMutex,
"quantum": quantum,
"ttl": ttlString,
"cacheType": string(cacheConfig.CacheType),
"cacheSize": strconv.Itoa(cacheConfig.CacheSize),
},
},
},
}
schemaField.Default = nil
case idk.IgnoreField:
continue
case idk.IntField:
props := map[string]interface{}{
"fieldType": "int",
}
// JSON decodes numeric values into float64,
// so reading them back from kafka gives us float64 value,
// which after convertion overflows int64.
// That's why we convert Min and Max properties to strings,
// so while unmarshaling (func intProp(p propper, s string)),
// we can parse it as int value.
if typ.Min != nil {
props["min"] = strconv.FormatInt(*typ.Min, 10)
}
if typ.Max != nil {
props["max"] = strconv.FormatInt(*typ.Max, 10)
}
schemaField.Type = &avro.UnionSchema{
Types: []avro.Schema{
&avro.NullSchema{},
&avro.LongSchema{Properties: props},
},
}
schemaField.Default = nil
case idk.RecordTimeField:
schemaField.Type = &avro.UnionSchema{
Types: []avro.Schema{
&avro.NullSchema{},
&avro.BytesSchema{
Properties: map[string]interface{}{
"fieldType": "recordTime",
"layout": typ.Layout,
},
},
},
}
schemaField.Default = nil
case idk.SignedIntBoolKeyField:
schemaField.Type = &avro.UnionSchema{
Types: []avro.Schema{
&avro.NullSchema{},
&avro.LongSchema{
Properties: map[string]interface{}{
"fieldType": "signedIntBoolKey",
},
},
},
}
schemaField.Default = nil
case idk.StringArrayField:
schemaField.Type = &avro.UnionSchema{
Types: []avro.Schema{
&avro.NullSchema{},
&avro.ArraySchema{
Items: &avro.StringSchema{
Properties: map[string]interface{}{
"quantum": quantum,
"ttl": ttlString,
"cacheType": string(cacheConfig.CacheType),
"cacheSize": strconv.Itoa(cacheConfig.CacheSize),
},
},
},
},
}
schemaField.Default = nil
case idk.StringField:
schemaField.Type = &avro.UnionSchema{
Types: []avro.Schema{
&avro.NullSchema{},
&avro.StringSchema{
Properties: map[string]interface{}{
"mutex": hasMutex,
"quantum": quantum,
"ttl": ttlString,
"cacheType": string(cacheConfig.CacheType),
"cacheSize": strconv.Itoa(cacheConfig.CacheSize),
},
},
},
}
schemaField.Default = nil
default:
return nil, errors.Errorf("unsupported idk fieldtype %T for schema", typ)
}
schemaField.Name = fields[n].Name()
record.Fields[n] = schemaField
}
return record, nil
}
// helper functions below were all copied from idk/interfaces.go
func toInt64(val interface{}) (int64, error) {
switch vt := val.(type) {
case uint:
return int64(vt), nil
case uint8:
return int64(vt), nil
case uint16:
return int64(vt), nil
case uint32:
return int64(vt), nil
case uint64:
return int64(vt), nil
case int:
return int64(vt), nil
case int8:
return int64(vt), nil
case int16:
return int64(vt), nil
case int32:
return int64(vt), nil
case int64:
return vt, nil
case string: // added this case because of mysql driver sending the ids as strings
v, err := strconv.ParseInt(strings.TrimSpace(vt), 10, 64)
if err != nil {
return 0, err
}
return v, nil
default:
return 0, errors.Errorf("couldn't convert %v of %[1]T to int64", vt)
}
}
func toBool(val interface{}) (bool, error) {
switch vt := val.(type) {
case bool:
return vt, nil
case byte:
if vt == '0' || vt == 'f' || vt == 'F' {
return false, nil
}
return vt != 0, nil
case string:
switch strings.ToLower(vt) {
case "", "0", "f", "false":
return false, nil
}
return true, nil
default:
if vint, err := toInt64(val); err == nil {
return vint != 0, nil
}
return false, errors.Errorf("couldn't convert %v of %[1]T to bool", vt)
}
}
func toString(val interface{}) (string, error) {
switch vt := val.(type) {
case string:
return vt, nil
case []byte:
return string(vt), nil
default:
if vt == nil {
return "", nil
}
return fmt.Sprintf("%v", val), nil
}
}
func toDecimal(val interface{}, scale int64) ([]byte, error) {
var value uint64
switch vt := val.(type) {
case pql.Decimal:
v := vt.Value()
value, scale = uint64(v.Int64()), vt.Scale
case float32:
value = uint64(vt) * uint64(pql.Pow10(scale))
case float64:
value = uint64(vt) * uint64(pql.Pow10(scale))
default:
return nil, errors.Errorf("couldn't convert %v of %[1]T to decimal", vt)
}
b := make([]byte, 16)
binary.BigEndian.PutUint64(b[0:8], value)
binary.BigEndian.PutUint64(b[8:16], uint64(scale))
return b, nil
}