mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 02:44:59 +00:00
465 lines
14 KiB
Go
465 lines
14 KiB
Go
// Copyright 2021 Molecula Corp. All rights reserved.
|
|
package pilosa
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"regexp"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/molecula/featurebase/v3/logger"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
var txIDRegexp = regexp.MustCompile("^[A-Za-z0-9_-]*$")
|
|
|
|
// Transaction contains information related to a block of work that
|
|
// needs to be tracked and spans multiple API calls.
|
|
type Transaction struct {
|
|
// ID is an arbitrary string identifier. All transactions must have a unique ID.
|
|
ID string `json:"id"`
|
|
|
|
// Active notes whether an exclusive transaction is active, or
|
|
// still pending (if other active transactions exist). All
|
|
// non-exclusive transactions are always active.
|
|
Active bool `json:"active"`
|
|
|
|
// Exclusive is set to true for transactions which can only become active when no other
|
|
// transactions exist.
|
|
Exclusive bool `json:"exclusive"`
|
|
|
|
// Timeout is the minimum idle time for which this transaction should continue to exist.
|
|
Timeout time.Duration `json:"timeout"`
|
|
|
|
// CreatedAt is the timestamp at which the transaction was created. This supports
|
|
// the case of listing transactions in a useful order.
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
// Deadline is calculated from Timeout. TODO reset deadline each time there is activity
|
|
// on the transaction. (we can't do this until there is some method of associating a
|
|
// request/call with a transaction)
|
|
Deadline time.Time `json:"deadline"`
|
|
|
|
// Stats track statistics for the transaction. Not yet used.
|
|
Stats TransactionStats `json:"stats"`
|
|
}
|
|
|
|
type TransactionStats struct{}
|
|
|
|
// TransactionManager enforces the rules for transactions on a single
|
|
// node. It is goroutine-safe. It should be created by a call to
|
|
// NewTransactionManager where it takes a TransactionStore. If logging
|
|
// is desired, Log should be set before an instance of
|
|
// TransactionManager is used.
|
|
type TransactionManager struct {
|
|
mu sync.RWMutex
|
|
|
|
Log logger.Logger
|
|
|
|
store TransactionStore
|
|
|
|
checkingDeadlines bool
|
|
}
|
|
|
|
// NewTransactionManager creates a new TransactionManager with the
|
|
// given store, and starts a deadline-checker in a goroutine.
|
|
func NewTransactionManager(store TransactionStore) *TransactionManager {
|
|
tm := &TransactionManager{
|
|
Log: logger.NopLogger,
|
|
store: store,
|
|
checkingDeadlines: true,
|
|
}
|
|
// start deadline checker in case we've just started up, but there is already state in the store.
|
|
go tm.deadlineChecker()
|
|
return tm
|
|
}
|
|
|
|
// Start starts a new transaction with the given parameters. If an
|
|
// exclusive transaction is pending or in progress,
|
|
// ErrTransactionExclusive is returned. If a transaction with the same
|
|
// id already exists, that transaction is returned along with
|
|
// ErrTransactionExists. If there is no error, the created transaction
|
|
// is returned—this is primarily so that the caller can discover if an
|
|
// exclusive transaction has been made immediately active or if they
|
|
// need to poll.
|
|
func (tm *TransactionManager) Start(ctx context.Context, id string, timeout time.Duration, exclusive bool) (*Transaction, error) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
if !txIDRegexp.Match([]byte(id)) {
|
|
return nil, errors.New("invalid transaction ID, must match [A-Za-z0-9_-]")
|
|
}
|
|
|
|
trnsMap, err := tm.store.List()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "listing transactions in Start")
|
|
}
|
|
|
|
// check for an exclusive transaction
|
|
for _, trns := range trnsMap {
|
|
if trns.Exclusive {
|
|
// if someone wants a transaction, and we're not able to
|
|
// give it to them, we want to be checking deadlines.
|
|
// TODO: it would be nice if we could identify whether
|
|
// this trns has expired, and if so, automatically remove
|
|
// it and continue without returning ErrTransactionExclusive
|
|
// on this iteration of the loop. One way we could do that is
|
|
// to call tm.checkDeadlines() here (note that we'd have to
|
|
// have an unprotectedCheckDeadlines()), and then after that
|
|
// check if trns still exists in tm.store. If not, continue.
|
|
tm.startDeadlineChecker()
|
|
return trns, ErrTransactionExclusive
|
|
}
|
|
}
|
|
if trns, ok := trnsMap[id]; ok {
|
|
return trns, ErrTransactionExists
|
|
}
|
|
|
|
// set new transaction to active if it is not exclusive or if
|
|
// there are no other transactions.
|
|
active := !exclusive || (len(trnsMap) == 0)
|
|
|
|
// set deadline according to timeout
|
|
createdAt := time.Now()
|
|
deadline := createdAt.Add(timeout)
|
|
trns := &Transaction{
|
|
ID: id,
|
|
Active: active,
|
|
Exclusive: exclusive,
|
|
Timeout: timeout,
|
|
CreatedAt: createdAt,
|
|
Deadline: deadline,
|
|
}
|
|
if err = tm.store.Put(trns); err != nil {
|
|
return nil, errors.Wrap(err, "adding to store")
|
|
}
|
|
|
|
// we won't check deadlines unless there's actually a
|
|
// transaction pending
|
|
if exclusive && !active {
|
|
tm.startDeadlineChecker()
|
|
}
|
|
|
|
return trns, nil
|
|
}
|
|
|
|
// Finish completes and removes a transaction, returning the completed
|
|
// transaction (so that the caller can e.g. view the Stats)
|
|
func (tm *TransactionManager) Finish(ctx context.Context, id string) (*Transaction, error) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
return tm.finish(id)
|
|
}
|
|
|
|
// finish is the unprotected implementation of Finish
|
|
func (tm *TransactionManager) finish(id string) (*Transaction, error) {
|
|
trns, err := tm.store.Remove(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// After removing, check to see if we need to activate an exclusive transaction
|
|
trnsMap, err := tm.store.List()
|
|
if err != nil {
|
|
tm.log().Errorf("error listing transactions in Finish: %v", err)
|
|
return trns, nil
|
|
}
|
|
|
|
if len(trnsMap) == 1 {
|
|
for _, etrans := range trnsMap {
|
|
if etrans.Exclusive {
|
|
if etrans.Active { // sanity check
|
|
panic("we just removed a transaction, and the sole remaining exclusive transaction was already active")
|
|
}
|
|
etrans.Active = true
|
|
etrans.Deadline = time.Now().Add(etrans.Timeout)
|
|
if err := tm.store.Put(etrans); err != nil {
|
|
tm.log().Errorf("activating exclusive transaction after finishing last transaction: %v", err)
|
|
return trns, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return trns, nil
|
|
}
|
|
|
|
// Get retrieves the transaction with the given ID. Returns ErrTransactionNotFound
|
|
// if there isn't one.
|
|
func (tm *TransactionManager) Get(ctx context.Context, id string) (*Transaction, error) {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
|
|
return tm.store.Get(id)
|
|
}
|
|
|
|
// List returns map of all transactions by their ID. It is a copy and
|
|
// so may be retained and modified by the caller.
|
|
func (tm *TransactionManager) List(ctx context.Context) (map[string]*Transaction, error) {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
return tm.store.List()
|
|
}
|
|
|
|
// ResetDeadline updates the deadline for the transaction with the
|
|
// given ID to be equal to the current time plus the transaction's
|
|
// timeout.
|
|
func (tm *TransactionManager) ResetDeadline(ctx context.Context, id string) (*Transaction, error) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
trns, err := tm.store.Get(id)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "getting transaction")
|
|
}
|
|
|
|
trns.Deadline = time.Now().Add(trns.Timeout)
|
|
|
|
err = tm.store.Put(trns)
|
|
return trns, errors.Wrap(err, "storing transaction with new timeout")
|
|
}
|
|
|
|
// startDeadlineChecker may only be called while tm.mu is held.
|
|
func (tm *TransactionManager) startDeadlineChecker() {
|
|
if !tm.checkingDeadlines {
|
|
tm.checkingDeadlines = true
|
|
go tm.deadlineChecker()
|
|
}
|
|
}
|
|
|
|
// deadlineChecker loops continuously checking for expired
|
|
// deadlines. It stops when there are no upcoming deadlines.
|
|
func (tm *TransactionManager) deadlineChecker() {
|
|
interval := tm.checkDeadlines()
|
|
for interval != 0 {
|
|
time.Sleep(interval)
|
|
interval = tm.checkDeadlines()
|
|
}
|
|
tm.mu.Lock()
|
|
tm.checkingDeadlines = false
|
|
tm.mu.Unlock()
|
|
}
|
|
|
|
// checkDeadlines finishes transactions which are past their
|
|
// deadlines. It returns the duration until the next deadline. If
|
|
// there are no exclusive transactions, it does nothing and returns 0
|
|
// as a signal to stop checking.
|
|
func (tm *TransactionManager) checkDeadlines() time.Duration {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
trnsMap, err := tm.store.List()
|
|
if err != nil {
|
|
tm.log().Errorf("transaction deadline checker couldn't list transactions: %v", err)
|
|
return 0
|
|
}
|
|
|
|
hasExclusive := false
|
|
for _, trns := range trnsMap {
|
|
if trns.Exclusive {
|
|
hasExclusive = true
|
|
break
|
|
}
|
|
}
|
|
if !hasExclusive {
|
|
return 0 // no need to expire things if nothing is waiting
|
|
}
|
|
|
|
now := time.Now()
|
|
// track the time interval to next deadline
|
|
nextInterval := time.Duration(0)
|
|
for id, trns := range trnsMap {
|
|
if !trns.Active {
|
|
continue
|
|
}
|
|
if !now.Before(trns.Deadline) {
|
|
trnsF, err := tm.finish(id)
|
|
if err != nil {
|
|
tm.log().Errorf("error finishing expired transaction '%s': %+v: %v", id, trnsF, err)
|
|
} else {
|
|
tm.log().Infof("cleared expired transaction: %+v", trnsF)
|
|
}
|
|
} else {
|
|
interval := trns.Deadline.Sub(now)
|
|
if nextInterval == 0 || interval < nextInterval {
|
|
nextInterval = interval
|
|
}
|
|
}
|
|
}
|
|
return nextInterval
|
|
}
|
|
|
|
func (tm *TransactionManager) log() logger.Logger {
|
|
if tm.Log != nil {
|
|
return tm.Log
|
|
}
|
|
return logger.NopLogger
|
|
}
|
|
|
|
// TransactionStore declares the functionality which a store for
|
|
// Pilosa transactions must implement.
|
|
type TransactionStore interface {
|
|
// Put stores a new transaction or replaces an existing transaction with the given one.
|
|
Put(trns *Transaction) error
|
|
// Get retrieves the transaction at id or returns ErrTransactionNotFound if there isn't one.
|
|
Get(id string) (*Transaction, error)
|
|
// List returns a map of all transactions by ID. The map must be safe to modify by the caller.
|
|
List() (map[string]*Transaction, error)
|
|
// Remove deletes the transaction from the store. It must return ErrTransactionNotFound if there isn't one.
|
|
Remove(id string) (*Transaction, error)
|
|
}
|
|
|
|
type OpenTransactionStoreFunc func(path string) (TransactionStore, error)
|
|
|
|
func OpenInMemTransactionStore(path string) (TransactionStore, error) {
|
|
return NewInMemTransactionStore(), nil
|
|
}
|
|
|
|
// InMemTransactionStore does not persist transaction data and is only
|
|
// useful for testing.
|
|
type InMemTransactionStore struct {
|
|
mu sync.RWMutex
|
|
tmap map[string]*Transaction
|
|
}
|
|
|
|
func NewInMemTransactionStore() *InMemTransactionStore {
|
|
return &InMemTransactionStore{
|
|
tmap: make(map[string]*Transaction),
|
|
}
|
|
}
|
|
|
|
func (s *InMemTransactionStore) Put(trns *Transaction) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.tmap[trns.ID] = trns.Copy()
|
|
return nil
|
|
}
|
|
|
|
func (s *InMemTransactionStore) Get(id string) (*Transaction, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if trns, ok := s.tmap[id]; ok {
|
|
return trns.Copy(), nil
|
|
}
|
|
return nil, ErrTransactionNotFound
|
|
}
|
|
|
|
func (s *InMemTransactionStore) List() (map[string]*Transaction, error) {
|
|
cp := make(map[string]*Transaction)
|
|
for id, trns := range s.tmap {
|
|
cp[id] = trns.Copy()
|
|
}
|
|
return cp, nil
|
|
}
|
|
|
|
func (s *InMemTransactionStore) Remove(id string) (*Transaction, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if trns, ok := s.tmap[id]; ok {
|
|
delete(s.tmap, id)
|
|
return trns.Copy(), nil
|
|
}
|
|
return nil, ErrTransactionNotFound
|
|
}
|
|
|
|
type Error string
|
|
|
|
func (e Error) Error() string { return string(e) }
|
|
|
|
const ErrTransactionNotFound = Error("transaction not found")
|
|
const ErrTransactionExclusive = Error("there is an exclusive transaction, try later")
|
|
const ErrTransactionExists = Error("transaction with the given id already exists")
|
|
|
|
func CompareTransactions(t1, t2 *Transaction) error {
|
|
if t1 == nil && t2 == nil {
|
|
return nil
|
|
}
|
|
if t1 == nil || t2 == nil {
|
|
return errors.Errorf("transactions are not equal: %+v %+v", t1, t2)
|
|
}
|
|
if t1.ID != t2.ID {
|
|
return errors.Errorf("transaction IDs not equal: %+v %+v", t1, t2)
|
|
}
|
|
if t1.Active != t2.Active {
|
|
return errors.Errorf("transaction Actives not equal: %+v %+v", t1, t2)
|
|
}
|
|
if t1.Exclusive != t2.Exclusive {
|
|
return errors.Errorf("transaction Exclusives not equal: %+v %+v", t1, t2)
|
|
}
|
|
if t1.Timeout != t2.Timeout {
|
|
return errors.Errorf("transaction Timeouts not equal: %+v %+v", t1, t2)
|
|
}
|
|
// don't care about Deadline or Stats
|
|
return nil
|
|
}
|
|
|
|
func (trns *Transaction) UnmarshalJSON(b []byte) error {
|
|
tmp := &struct {
|
|
ID string `json:"id"`
|
|
Active bool `json:"active"`
|
|
Exclusive bool `json:"exclusive"`
|
|
Timeout interface{} `json:"timeout"`
|
|
Deadline string `json:"deadline"`
|
|
}{}
|
|
err := json.Unmarshal(b, tmp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
trns.ID = tmp.ID
|
|
trns.Active = tmp.Active
|
|
trns.Exclusive = tmp.Exclusive
|
|
switch tm := tmp.Timeout.(type) {
|
|
case string:
|
|
dur, err := time.ParseDuration(tm)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "timeout as string must be a valid duration got: '%s'", tm)
|
|
}
|
|
trns.Timeout = dur
|
|
case float64:
|
|
// interpret as number of seconds
|
|
seconds := int64(tm)
|
|
nsec := (tm - float64(seconds)) * 1e9
|
|
trns.Timeout = time.Duration(seconds*1e9 + int64(nsec))
|
|
case nil:
|
|
break
|
|
default:
|
|
return errors.New("timeout must be float64 or string")
|
|
}
|
|
|
|
if tmp.Deadline != "" {
|
|
trns.Deadline, err = time.Parse(time.RFC3339Nano, tmp.Deadline)
|
|
}
|
|
return errors.Wrap(err, "parsing deadline")
|
|
}
|
|
|
|
func (trns *Transaction) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(&struct {
|
|
ID string `json:"id"`
|
|
Active bool `json:"active"`
|
|
Exclusive bool `json:"exclusive"`
|
|
Timeout string `json:"timeout"`
|
|
CreatedAt string `json:"createdAt"`
|
|
Deadline string `json:"deadline"`
|
|
}{
|
|
ID: trns.ID,
|
|
Active: trns.Active,
|
|
Exclusive: trns.Exclusive,
|
|
Timeout: trns.Timeout.String(),
|
|
CreatedAt: trns.CreatedAt.In(time.UTC).Format(time.RFC3339Nano),
|
|
Deadline: trns.Deadline.In(time.UTC).Format(time.RFC3339Nano),
|
|
})
|
|
}
|
|
|
|
func (trns *Transaction) Copy() *Transaction {
|
|
return &Transaction{
|
|
ID: trns.ID,
|
|
Active: trns.Active,
|
|
Exclusive: trns.Exclusive,
|
|
Timeout: trns.Timeout,
|
|
CreatedAt: trns.CreatedAt,
|
|
Deadline: trns.Deadline,
|
|
Stats: trns.Stats,
|
|
}
|
|
}
|