featurebase/idk/idallocator.go
souhailanoor 2bdf1f5081
FB-1597: unifying idk and featurebase (#2160)
* 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
2022-07-28 17:23:16 -05:00

462 lines
12 KiB
Go

package idk
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"math/bits"
"net/http"
"net/url"
"time"
"unicode/utf8"
"sync"
pilosacore "github.com/molecula/featurebase/v3"
"github.com/pkg/errors"
)
type RangeAllocator interface {
Get() (*IDRange, error)
Return(*IDRange) error
}
type IDAllocator interface {
Next(context.Context, Record) (uint64, error)
Reserve(context.Context, uint64) error
Commit(context.Context) error
}
type LocalRangeAllocator struct {
shardWidth uint64
next uint64
returned []*IDRange
mu sync.Mutex
}
func NewLocalRangeAllocator(shardWidth uint64) RangeAllocator {
if shardWidth < 1<<16 || bits.OnesCount64(shardWidth) > 1 {
panic(fmt.Sprintf("bad shardWidth in NewRangeAllocator: %d", shardWidth))
}
return &LocalRangeAllocator{
shardWidth: shardWidth,
}
}
// IDRange is inclusive at Start and exclusive at End... like slices.
type IDRange struct {
Start uint64
End uint64
}
type rangeNexter struct {
a RangeAllocator
r *IDRange
}
func NewRangeNexter(a RangeAllocator) (IDAllocator, error) {
r, err := a.Get()
if err != nil {
return nil, errors.Wrap(err, "getting range")
}
return &rangeNexter{
a: a,
r: r,
}, nil
}
func (n *rangeNexter) Next(ctx context.Context, _ Record) (val uint64, err error) {
if n.r.Start == n.r.End {
n.r, err = n.a.Get()
if err != nil {
return 0, errors.Wrap(err, "getting next range")
}
}
if n.r.Start >= n.r.End {
panic("Start is greater than End")
}
val = n.r.Start
n.r.Start++
return
}
func (n *rangeNexter) Reserve(ctx context.Context, count uint64) error {
return nil
}
func (n *rangeNexter) Commit(ctx context.Context) error {
return nil
}
func (a *LocalRangeAllocator) Get() (*IDRange, error) {
a.mu.Lock()
defer a.mu.Unlock()
n := len(a.returned)
if n > 0 {
ret := a.returned[n-1]
a.returned = a.returned[:n-1]
return ret, nil
}
ret := &IDRange{
Start: a.next,
End: a.next + a.shardWidth,
}
a.next += a.shardWidth
return ret, nil
}
func (a *LocalRangeAllocator) Return(r *IDRange) error {
if r.Start == r.End {
return nil
}
if r.Start > r.End {
return errors.Errorf("attempted to return range with start > end: %v", r)
}
a.mu.Lock()
a.returned = append(a.returned, r)
a.mu.Unlock()
return nil
}
type normalModeNexter struct {
key pilosacore.IDAllocKey
session [32]byte
localIDs []pilosacore.IDRange
localOffset uint64
idManager *pilosaIDManager
}
func newNormalModeNexter(idManager *pilosaIDManager, index, key string) IDAllocator {
nexter := &normalModeNexter{
key: pilosacore.IDAllocKey{
Index: index,
Key: key,
},
idManager: idManager,
}
return nexter
}
func (n *normalModeNexter) Reserve(ctx context.Context, count uint64) (err error) {
n.localIDs = nil
n.localOffset = 0
_, err = io.ReadFull(rand.Reader, n.session[:])
if err != nil {
return errors.Wrap(err, "acquiring session ID")
}
reserveReq := pilosacore.IDAllocReserveRequest{
Key: n.key,
Session: n.session,
Offset: ^uint64(0),
Count: count,
}
ids, err := n.idManager.reserve(ctx, reserveReq)
if err != nil {
return err
}
n.localIDs = ids
return nil
}
func (n *normalModeNexter) Commit(ctx context.Context) error {
commitRequest := pilosacore.IDAllocCommitRequest{
Key: n.key,
Session: n.session,
Count: n.localOffset,
}
err := n.idManager.commit(ctx, commitRequest)
if err != nil {
return err
}
n.localIDs = nil
n.localOffset = 0
return nil
}
func (n *normalModeNexter) Next(ctx context.Context, _ Record) (uint64, error) {
if len(n.localIDs) == 0 {
return 0, errors.New("no reserved IDs")
}
id := n.localIDs[0].First
if n.localIDs[0].First == n.localIDs[0].Last {
n.localIDs = n.localIDs[1:]
} else {
n.localIDs[0].First++
}
n.localOffset++
return id, nil
}
// idAllocation is a collection of IDs reserved for an offset stream.
type idAllocation struct {
// localIDs are the currently-reserved IDs to use.
// The first ID (if present) maps to offset.
localIDs []pilosacore.IDRange
// offset is the offset of the lowest available mapped record.
offset uint64
}
// offsetModeNexter reserves IDs when pulling data from a set of offset-streams (e.g. kafka partitions).
// Currently, a lot of the design of offsetModeNexter assumes
// it's used with kafka though ideally, it could be used with
// any other source that's partitioned and offset based.
type offsetModeNexter struct {
// idManager is a client to Pilosa's ID allocator.
idManager *pilosaIDManager
// index is the index to reserve IDs on.
index string
// reserveCount is the number of IDs to request from Pilosa at once.
reserveCount uint64
// localStore is the set of currently-reserved IDs.
// When using kafka, the key is derived from the topic and partition.
localStore map[string]*idAllocation
}
func newOffsetModeNexter(idManager *pilosaIDManager, index string, reserveCount uint64) IDAllocator {
nexter := &offsetModeNexter{
idManager: idManager,
index: index,
reserveCount: reserveCount,
localStore: make(map[string]*idAllocation),
}
return nexter
}
func (n *offsetModeNexter) Next(ctx context.Context, r Record) (uint64, error) {
// Get the stream and offset of the record. In the case of a kafka message
// the stream key will have the following format: "<topic>:<partition>"
rec, ok := r.(OffsetStreamRecord)
if !ok {
return 0, errors.New("invalid source of records for OffsetMode ID Allocator: should implement OffsetStreamRecord")
}
key, offset := rec.StreamOffset()
// retrieve allocation based on key
// for kafka, the key is constructed from the topic + partition
alloc, ok := n.localStore[key]
if !ok {
alloc = &idAllocation{}
n.localStore[key] = alloc
}
if offset < alloc.offset {
// This may happen in a brief period when two ingesters are processing the same partition.
return 0, ErrIDOffsetDesync{
Requested: offset,
Base: alloc.offset,
}
}
// Skip IDs under the offset.
if len(alloc.localIDs) != 0 && offset > alloc.offset {
for len(alloc.localIDs) != 0 && alloc.localIDs[0].Last-alloc.localIDs[0].First < offset-alloc.offset {
alloc.offset += alloc.localIDs[0].Last - alloc.localIDs[0].First + 1
alloc.localIDs = alloc.localIDs[1:]
}
if len(alloc.localIDs) != 0 {
skip := offset - alloc.offset
alloc.offset += skip
alloc.localIDs[0].First += skip
}
}
// if we don't have any local IDs to nextify, then
// get fresh IDs from idManager ie Pilosa
if len(alloc.localIDs) == 0 {
// get fresh IDs from Pilosa
allocKey := pilosacore.IDAllocKey{
Key: key,
Index: n.index,
}
var session [32]byte
_, err := io.ReadFull(rand.Reader, session[:])
if err != nil {
return 0, errors.Wrap(err, "acquiring session ID")
}
reserveReq := pilosacore.IDAllocReserveRequest{
Key: allocKey,
Session: session,
Offset: offset,
Count: n.reserveCount,
}
ids, err := n.idManager.reserve(ctx, reserveReq)
if err != nil {
if err, ok := err.(ErrIDOffsetDesync); ok {
// Skip all messages until the latest offset.
alloc.offset = err.Base
}
return 0, err
}
alloc.localIDs = ids
alloc.offset = offset
}
// finally, get the ID
return alloc.localIDs[0].First, nil
}
// Commits for offset-based allocated IDs are done implicitly when
// one reserves the next IDs, so there's no need to carry out an
// explicit commit here (for the time-being).
func (n *offsetModeNexter) Commit(ctx context.Context) error {
return nil
}
func (n *offsetModeNexter) Reserve(ctx context.Context, count uint64) (err error) {
// no op, reservation is done on demand during calls to Next
return nil
}
// pilosaIDManager is an external ID Manager
// for autogenerated IDs
type pilosaIDManager struct {
hcli *http.Client
url url.URL
}
// TODO, once pull/1559 is merged into pilosa main branch
// no need to maintain a duplicate definition of ErrIDOffsetDesync
// here
type ErrIDOffsetDesync struct {
Requested uint64 `json:"requested"`
// Base is the next lowest uncommitted offset for which
// IDs may be reserved
Base uint64 `json:"base"`
}
func (e ErrIDOffsetDesync) Error() string {
return fmt.Sprintf("attempted to reserve IDs at committed offset %d (base offset: %d)", e.Requested, e.Base)
}
func (ps *pilosaIDManager) reserve(ctx context.Context, reserveReq pilosacore.IDAllocReserveRequest) ([]pilosacore.IDRange, error) {
data, err := json.Marshal(reserveReq)
if err != nil {
return nil, errors.Wrap(err, "marshaling ID reservation request")
}
targ, err := ps.url.Parse("/internal/idalloc/reserve")
if err != nil {
return nil, errors.Wrap(err, "resolving ID reservation target URL")
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targ.String(), bytes.NewReader(data))
if err != nil {
return nil, errors.Wrap(err, "preparing ID reservation HTTP request")
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
// add authorization token to request
token, ok := ctx.Value(contextKeyToken).(string)
if ok && token != "" {
req.Header.Set("Authorization", token)
}
resp, err := ps.hcli.Do(req)
if err != nil {
return nil, errors.Wrap(err, "dispatching ID reservation HTTP request")
}
var body []byte
if resp.Body != nil {
defer func() {
cerr := resp.Body.Close()
if cerr != nil && err == nil {
err = errors.Wrap(cerr, "closing ID reservation request body")
}
}()
body, err = ioutil.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "reading ID reservation request body")
}
}
if resp.StatusCode/100 != http.StatusOK/100 {
// first check if OffsetDesync Error
if resp.StatusCode == http.StatusConflict && resp.Header.Get("Content-Type") == "application/json" {
var esync ErrIDOffsetDesync
if err := json.Unmarshal(body, &esync); err == nil {
return nil, esync
}
}
var err error
if len(body) > 0 && utf8.Valid(body) {
err = errors.New(string(body))
} else {
err = errors.New(resp.Status)
}
err = errors.Wrapf(err, "status %d", resp.StatusCode)
return nil, errors.Wrap(err, "fetching ID reservation")
}
var ids []pilosacore.IDRange
err = json.Unmarshal(body, &ids)
if err != nil {
return nil, errors.Wrap(err, "decoding ID reservation")
}
return ids, nil
}
func (ps *pilosaIDManager) commit(ctx context.Context, commitRequest pilosacore.IDAllocCommitRequest) error {
data, err := json.Marshal(commitRequest)
if err != nil {
return errors.Wrap(err, "marshaling ID commit request")
}
targ, err := ps.url.Parse("/internal/idalloc/commit")
if err != nil {
return errors.Wrap(err, "resolving ID commit target URL")
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targ.String(), bytes.NewReader(data))
if err != nil {
return errors.Wrap(err, "preparing ID commit HTTP request")
}
req.Header.Add("Content-Type", "application/json")
// add authorization token to request
token, ok := ctx.Value(contextKeyToken).(string)
if ok && token != "" {
req.Header.Set("Authorization", token)
}
resp, err := ps.hcli.Do(req)
if err != nil {
return errors.Wrap(err, "dispatching ID commit HTTP request")
}
var body []byte
if resp.Body != nil {
defer func() {
cerr := resp.Body.Close()
if cerr != nil && err == nil {
err = errors.Wrap(cerr, "closing ID reservation request body")
}
}()
body, err = ioutil.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "reading ID reservation request body")
}
}
if resp.StatusCode/100 != http.StatusOK/100 {
var err error
if len(body) > 0 && utf8.Valid(body) {
err = errors.New(string(body))
} else {
err = errors.New(resp.Status)
}
err = errors.Wrapf(err, "status %d", resp.StatusCode)
return errors.Wrap(err, "committing ID reservation")
}
return nil
}