influxdb/annotations/service.go

597 lines
18 KiB
Go

package annotations
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
sq "github.com/Masterminds/squirrel"
"github.com/influxdata/influxdb/v2"
"github.com/influxdata/influxdb/v2/kit/platform"
ierrors "github.com/influxdata/influxdb/v2/kit/platform/errors"
"github.com/influxdata/influxdb/v2/snowflake"
"github.com/influxdata/influxdb/v2/sqlite"
)
var (
errAnnotationNotFound = &ierrors.Error{
Code: ierrors.EInvalid,
Msg: "annotation not found",
}
errStreamNotFound = &ierrors.Error{
Code: ierrors.EInvalid,
Msg: "stream not found",
}
)
var _ influxdb.AnnotationService = (*Service)(nil)
type Service struct {
store *sqlite.SqlStore
idGenerator platform.IDGenerator
}
func NewService(store *sqlite.SqlStore) *Service {
return &Service{
store: store,
idGenerator: snowflake.NewIDGenerator(),
}
}
// CreateAnnotations creates annotations in the database for the provided orgID as defined by the provided list
// Streams corresponding to the StreamTag property of each annotation are created if they don't already exist
// as part of a transaction
func (s *Service) CreateAnnotations(ctx context.Context, orgID platform.ID, creates []influxdb.AnnotationCreate) ([]influxdb.AnnotationEvent, error) {
// Guard clause - an empty list was provided for some reason, immediately return an empty result
// set without doing the transaction
if len(creates) == 0 {
return []influxdb.AnnotationEvent{}, nil
}
s.store.Mu.Lock()
defer s.store.Mu.Unlock()
// store a unique list of stream names first. the invalid ID is a placeholder for the real id,
// which will be obtained separately
streamNamesIDs := make(map[string]platform.ID)
for _, c := range creates {
streamNamesIDs[c.StreamTag] = platform.InvalidID()
}
// streamIDsNames is used for re-populating the resulting list of annotations with the stream names
// from the stream IDs returned from the database
streamIDsNames := make(map[platform.ID]string)
tx, err := s.store.DB.BeginTxx(ctx, nil)
if err != nil {
tx.Rollback()
return nil, err
}
// upsert each stream individually. a possible enhancement might be to do this as a single batched query
// it is unlikely that this would offer much benefit since there is currently no mechanism for creating large numbers
// of annotations simultaneously
now := time.Now()
for name := range streamNamesIDs {
query, args, err := newUpsertStreamQuery(orgID, s.idGenerator.ID(), now, influxdb.Stream{Name: name})
if err != nil {
tx.Rollback()
return nil, err
}
var streamID platform.ID
if err = tx.GetContext(ctx, &streamID, query, args...); err != nil {
tx.Rollback()
return nil, err
}
streamNamesIDs[name] = streamID
streamIDsNames[streamID] = name
}
// bulk insert for the creates. this also is unlikely to offer much performance benefit, but since the query
// is only used here it is easy enough to form to bulk query.
q := sq.Insert("annotations").
Columns("id", "org_id", "stream_id", "summary", "message", "stickers", "duration", "lower", "upper").
Suffix("RETURNING *")
for _, create := range creates {
// double check that we have a valid name for this stream tag - error if we don't. this should never be an error.
streamID, ok := streamNamesIDs[create.StreamTag]
if !ok {
tx.Rollback()
return nil, &ierrors.Error{
Code: ierrors.EInternal,
Msg: fmt.Sprintf("unable to find id for stream %q", create.StreamTag),
}
}
// add the row to the query
newID := s.idGenerator.ID()
lower := create.StartTime.Format(time.RFC3339Nano)
upper := create.EndTime.Format(time.RFC3339Nano)
duration := timesToDuration(*create.StartTime, *create.EndTime)
q = q.Values(newID, orgID, streamID, create.Summary, create.Message, create.Stickers, duration, lower, upper)
}
// get the query string and args list for the bulk insert
query, args, err := q.ToSql()
if err != nil {
tx.Rollback()
return nil, err
}
// run the bulk insert and store the result
var res []*influxdb.StoredAnnotation
if err := tx.SelectContext(ctx, &res, query, args...); err != nil {
tx.Rollback()
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
// add the stream names to the list of results
for _, a := range res {
a.StreamTag = streamIDsNames[a.StreamID]
}
// convert the StoredAnnotation structs to AnnotationEvent structs before returning
return storedAnnotationsToEvents(res)
}
// ListAnnotations returns a list of annotations from the database matching the filter
// For time range matching, sqlite is able to compare times with millisecond accuracy
func (s *Service) ListAnnotations(ctx context.Context, orgID platform.ID, filter influxdb.AnnotationListFilter) ([]influxdb.StoredAnnotation, error) {
// we need to explicitly format time strings here and elsewhere to ensure they are
// interpreted by the database consistently
sf := filter.StartTime.Format(time.RFC3339Nano)
ef := filter.EndTime.Format(time.RFC3339Nano)
q := sq.Select("annotations.*", "streams.name AS stream").
Distinct().
InnerJoin("streams ON annotations.stream_id = streams.id").
Where(sq.Eq{"annotations.org_id": orgID}).
Where(sq.GtOrEq{"lower": sf}).
Where(sq.LtOrEq{"upper": ef})
// If the filter contains stickers, use the json_each table value function to break out
// rows with the sticker array values. If the filter does not contain stickers, using
// the json_each TVF would exclude annotations with an empty array of stickers, so select
// from the annotations table only. This allows a filter with no sticker constraints to
// return annotations that don't have any stickers.
if len(filter.StickerIncludes) > 0 {
q = q.From("annotations, json_each(annotations.stickers) AS json")
// Add sticker filters to the query
for k, v := range filter.StickerIncludes {
q = q.Where(sq.And{sq.Eq{"json.value": fmt.Sprintf("%s=%s", k, v)}})
}
} else {
q = q.From("annotations")
}
// Add stream name filters to the query
if len(filter.StreamIncludes) > 0 {
q = q.Where(sq.Eq{"stream": filter.StreamIncludes})
}
sql, args, err := q.ToSql()
if err != nil {
return nil, err
}
ans := []influxdb.StoredAnnotation{}
if err := s.store.DB.SelectContext(ctx, &ans, sql, args...); err != nil {
return nil, err
}
return ans, nil
}
// GetAnnotation gets a single annotation by ID
func (s *Service) GetAnnotation(ctx context.Context, id platform.ID) (*influxdb.StoredAnnotation, error) {
q := sq.Select("annotations.*, streams.name AS stream").
From("annotations").
InnerJoin("streams ON annotations.stream_id = streams.id").
Where(sq.Eq{"annotations.id": id})
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
var a influxdb.StoredAnnotation
if err := s.store.DB.GetContext(ctx, &a, query, args...); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errAnnotationNotFound
}
return nil, err
}
return &a, nil
}
// DeleteAnnotations deletes multiple annotations according to the provided filter
func (s *Service) DeleteAnnotations(ctx context.Context, orgID platform.ID, delete influxdb.AnnotationDeleteFilter) error {
s.store.Mu.Lock()
defer s.store.Mu.Unlock()
sf := delete.StartTime.Format(time.RFC3339Nano)
ef := delete.EndTime.Format(time.RFC3339Nano)
// This is a subquery that will be as part of a DELETE FROM ... WHERE id IN (subquery)
// A subquery is used because the json_each virtual table can only be used in a SELECT
subQ := sq.Select("annotations.id").
Distinct().
InnerJoin("streams ON annotations.stream_id = streams.id").
Where(sq.Eq{"annotations.org_id": orgID}).
Where(sq.GtOrEq{"lower": sf}).
Where(sq.LtOrEq{"upper": ef})
// If the filter contains stickers, use the json_each table value function to break out
// rows with the sticker array values. If the filter does not contain stickers, using
// the json_each TVF would exclude annotations with an empty array of stickers, so select
// from the annotations table only. This allows a filter with no sticker constraints to
// delete annotations that don't have any stickers.
if len(delete.Stickers) > 0 {
subQ = subQ.From("annotations, json_each(annotations.stickers) AS json")
// Add sticker filters to the subquery
for k, v := range delete.Stickers {
subQ = subQ.Where(sq.And{sq.Eq{"json.value": fmt.Sprintf("%s=%s", k, v)}})
}
} else {
subQ = subQ.From("annotations")
}
// Add the stream name filter to the subquery (if present)
if len(delete.StreamTag) > 0 {
subQ = subQ.Where(sq.Eq{"streams.name": delete.StreamTag})
}
// Add the stream ID filter to the subquery (if present)
if delete.StreamID.Valid() {
subQ = subQ.Where(sq.Eq{"stream_id": delete.StreamID})
}
// Parse the subquery into a string and list of args
subQuery, subArgs, err := subQ.ToSql()
if err != nil {
return err
}
// Convert the subquery into a sq.Sqlizer so that it can be used in the actual DELETE
// operation. This is a bit of a hack since squirrel doesn't have great support for subqueries
// outside of SELECT statements
subExpr := sq.Expr("("+subQuery+")", subArgs...)
q := sq.
Delete("annotations").
Suffix("WHERE annotations.id IN").
SuffixExpr(subExpr)
query, args, err := q.ToSql()
if err != nil {
return err
}
if _, err := s.store.DB.ExecContext(ctx, query, args...); err != nil {
return err
}
return nil
}
// DeleteAnnoation deletes a single annotation by ID
func (s *Service) DeleteAnnotation(ctx context.Context, id platform.ID) error {
s.store.Mu.Lock()
defer s.store.Mu.Unlock()
q := sq.Delete("annotations").
Where(sq.Eq{"id": id}).
Suffix("RETURNING id")
query, args, err := q.ToSql()
if err != nil {
return err
}
var d platform.ID
if err := s.store.DB.GetContext(ctx, &d, query, args...); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errAnnotationNotFound
}
return err
}
return nil
}
// UpdateAnnotation updates a single annotation by ID
// In a similar fashion as CreateAnnotations, if the StreamTag in the update request does not exist,
// a stream will be created as part of a transaction with the update operation
func (s *Service) UpdateAnnotation(ctx context.Context, id platform.ID, update influxdb.AnnotationCreate) (*influxdb.AnnotationEvent, error) {
// get the full data for this annotation first so we can get its orgID
// this will ensure that the annotation already exists before starting the transaction
ann, err := s.GetAnnotation(ctx, id)
if err != nil {
return nil, err
}
now := time.Now()
// get a write lock on the database before starting the transaction to create/update the stream
// while simultaneously updating the annotation
s.store.Mu.Lock()
defer s.store.Mu.Unlock()
tx, err := s.store.DB.BeginTxx(ctx, nil)
if err != nil {
tx.Rollback()
return nil, err
}
query, args, err := newUpsertStreamQuery(ann.OrgID, s.idGenerator.ID(), now, influxdb.Stream{Name: update.StreamTag})
if err != nil {
tx.Rollback()
return nil, err
}
var streamID platform.ID
if err = tx.GetContext(ctx, &streamID, query, args...); err != nil {
tx.Rollback()
return nil, err
}
q := sq.Update("annotations").
SetMap(sq.Eq{
"stream_id": streamID,
"summary": update.Summary,
"message": update.Message,
"stickers": update.Stickers,
"duration": timesToDuration(*update.StartTime, *update.EndTime),
"lower": update.StartTime.Format(time.RFC3339Nano),
"upper": update.EndTime.Format(time.RFC3339Nano),
}).
Where(sq.Eq{"id": id}).
Suffix("RETURNING *")
query, args, err = q.ToSql()
if err != nil {
return nil, err
}
var st influxdb.StoredAnnotation
err = tx.GetContext(ctx, &st, query, args...)
if err != nil {
tx.Rollback()
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
// add the stream name to the result. we know that this StreamTag value was updated to the
// stream via the transaction having completed successfully.
st.StreamTag = update.StreamTag
return st.ToEvent()
}
// ListStreams returns a list of streams matching the filter for the provided orgID.
func (s *Service) ListStreams(ctx context.Context, orgID platform.ID, filter influxdb.StreamListFilter) ([]influxdb.StoredStream, error) {
q := sq.Select("id", "org_id", "name", "description", "created_at", "updated_at").
From("streams").
Where(sq.Eq{"org_id": orgID})
// Add stream name filters to the query
if len(filter.StreamIncludes) > 0 {
q = q.Where(sq.Eq{"name": filter.StreamIncludes})
}
sql, args, err := q.ToSql()
if err != nil {
return nil, err
}
sts := []influxdb.StoredStream{}
err = s.store.DB.SelectContext(ctx, &sts, sql, args...)
if err != nil {
return nil, err
}
return sts, nil
}
// GetStream gets a single stream by ID
func (s *Service) GetStream(ctx context.Context, id platform.ID) (*influxdb.StoredStream, error) {
q := sq.Select("id", "org_id", "name", "description", "created_at", "updated_at").
From("streams").
Where(sq.Eq{"id": id})
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
var st influxdb.StoredStream
if err := s.store.DB.GetContext(ctx, &st, query, args...); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errStreamNotFound
}
return nil, err
}
return &st, nil
}
// CreateOrUpdateStream creates a new stream, or updates the description of an existing stream.
// Doesn't support updating a stream desctription to "". For that use the UpdateStream method.
func (s *Service) CreateOrUpdateStream(ctx context.Context, orgID platform.ID, stream influxdb.Stream) (*influxdb.ReadStream, error) {
s.store.Mu.Lock()
defer s.store.Mu.Unlock()
newID := s.idGenerator.ID()
now := time.Now()
query, args, err := newUpsertStreamQuery(orgID, newID, now, stream)
if err != nil {
return nil, err
}
var id platform.ID
if err = s.store.DB.GetContext(ctx, &id, query, args...); err != nil {
return nil, err
}
// do a separate query to read the stream back from the database and return it.
// this is necessary because the sqlite driver does not support scanning time values from
// a RETURNING clause back into time.Time
return s.getReadStream(ctx, id)
}
// UpdateStream updates a stream name and/or a description. It is strictly used for updating an existing stream.
func (s *Service) UpdateStream(ctx context.Context, id platform.ID, stream influxdb.Stream) (*influxdb.ReadStream, error) {
s.store.Mu.Lock()
defer s.store.Mu.Unlock()
q := sq.Update("streams").
SetMap(sq.Eq{
"name": stream.Name,
"description": stream.Description,
"updated_at": sq.Expr(`datetime('now')`),
}).
Where(sq.Eq{"id": id}).
Suffix(`RETURNING id`)
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
var newID platform.ID
err = s.store.DB.GetContext(ctx, &newID, query, args...)
if err != nil {
if err == sql.ErrNoRows {
return nil, errStreamNotFound
}
return nil, err
}
// do a separate query to read the stream back from the database and return it.
// this is necessary because the sqlite driver does not support scanning time values from
// a RETURNING clause back into time.Time
return s.getReadStream(ctx, newID)
}
// DeleteStreams is used for deleting multiple streams by name
func (s *Service) DeleteStreams(ctx context.Context, orgID platform.ID, delete influxdb.BasicStream) error {
s.store.Mu.Lock()
defer s.store.Mu.Unlock()
q := sq.Delete("streams").
Where(sq.Eq{"org_id": orgID}).
Where(sq.Eq{"name": delete.Names})
query, args, err := q.ToSql()
if err != nil {
return err
}
_, err = s.store.DB.ExecContext(ctx, query, args...)
if err != nil {
return err
}
return nil
}
// DeleteStreamByID deletes a single stream by ID. Returns an error if the ID could not be found.
func (s *Service) DeleteStreamByID(ctx context.Context, id platform.ID) error {
s.store.Mu.Lock()
defer s.store.Mu.Unlock()
q := sq.Delete("streams").
Where(sq.Eq{"id": id}).
Suffix("RETURNING id")
query, args, err := q.ToSql()
if err != nil {
return err
}
var d platform.ID
if err := s.store.DB.GetContext(ctx, &d, query, args...); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errStreamNotFound
}
return err
}
return nil
}
func newUpsertStreamQuery(orgID, newID platform.ID, t time.Time, stream influxdb.Stream) (string, []interface{}, error) {
q := sq.Insert("streams").
Columns("id", "org_id", "name", "description", "created_at", "updated_at").
Values(newID, orgID, stream.Name, stream.Description, t, t).
Suffix(`ON CONFLICT(org_id, name) DO UPDATE
SET
updated_at = excluded.updated_at,
description = IIF(length(excluded.description) = 0, description, excluded.description)`).
Suffix("RETURNING id")
return q.ToSql()
}
// getReadStream is a helper which should only be called when the stream has been verified to exist
// via an update or insert.
func (s *Service) getReadStream(ctx context.Context, id platform.ID) (*influxdb.ReadStream, error) {
q := sq.Select("id", "name", "description", "created_at", "updated_at").
From("streams").
Where(sq.Eq{"id": id})
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
r := &influxdb.ReadStream{}
if err := s.store.DB.GetContext(ctx, r, query, args...); err != nil {
return nil, err
}
return r, nil
}
func storedAnnotationsToEvents(stored []*influxdb.StoredAnnotation) ([]influxdb.AnnotationEvent, error) {
events := make([]influxdb.AnnotationEvent, 0, len(stored))
for _, s := range stored {
c, err := s.ToCreate()
if err != nil {
return nil, err
}
events = append(events, influxdb.AnnotationEvent{
ID: s.ID,
AnnotationCreate: *c,
})
}
return events, nil
}
func timesToDuration(l, u time.Time) string {
return fmt.Sprintf("[%s, %s]", l.Format(time.RFC3339Nano), u.Format(time.RFC3339Nano))
}