597 lines
18 KiB
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))
|
|
}
|