Ctrl K
ComunidadGuías…

internal/repository/clickhouse/activity.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package clickhouse

import (
	"context"
	"slices"
	"strings"
	"time"

	clickhouse_go "github.com/ClickHouse/clickhouse-go/v2"
	"github.com/flexprice/flexprice/internal/clickhouse"
	"github.com/flexprice/flexprice/internal/domain/activity"
	ierr "github.com/flexprice/flexprice/internal/errors"
	"github.com/flexprice/flexprice/internal/logger"
	"github.com/flexprice/flexprice/internal/types"
)

type activityRepository struct {
	store  *clickhouse.ClickHouseStore
	logger *logger.Logger
}

func NewActivityRepository(store *clickhouse.ClickHouseStore, log *logger.Logger) activity.Repository {
	return &activityRepository{store: store, logger: log}
}

// Insert writes all logs in one batch and waits for the async insert to be flushed.
func (r *activityRepository) Insert(ctx context.Context, logs []*activity.Log) error {
	if len(logs) == 0 {
		return nil
	}
	ctx, cancel := context.WithTimeout(ctx, insertTimeout)
	defer cancel()
	ctx = clickhouse_go.Context(ctx, clickhouse_go.WithSettings(clickhouse_go.Settings{
		"async_insert":          1,
		"wait_for_async_insert": 1,
	}))

	batch, err := r.store.GetConn().PrepareBatch(ctx, `
		INSERT INTO activity_logs (
			id, tenant_id, environment_id, entity_type, entity_id, entity_label, customer_id, action,
			actor_type, actor_id, source, request_id, ip, user_agent, state, schema_version, occurred_at
		)`)
	if err != nil {
		return ierr.WithError(err).WithHint("Failed to prepare batch for activity_logs insert").Mark(ierr.ErrDatabase)
	}
	for _, l := range logs {
		if err := batch.Append(
			l.ID, l.TenantID, l.EnvironmentID, l.Entity.Type, l.Entity.ID, l.Entity.Label, l.CustomerID, l.Action,
			l.Actor.Type, l.Actor.ID, l.Request.Source, l.Request.ID, l.Request.IP, l.Request.UserAgent, l.State, l.SchemaVersion, l.OccurredAt.UTC(),
		); err != nil {
			return ierr.WithError(err).WithHint("Failed to append row to activity_logs batch").
				WithReportableDetails(map[string]interface{}{"activity_id": l.ID}).Mark(ierr.ErrDatabase)
		}
	}
	if err := batch.Send(); err != nil {
		return ierr.WithError(err).WithHint("Failed to send activity_logs batch").Mark(ierr.ErrDatabase)
	}
	return nil
}

// millisParam binds a time as epoch milliseconds; the driver's time.Time binding drops sub-second precision.
const millisParam = "fromUnixTimestamp64Milli(?)"

const activityListColumns = `id, entity_type, entity_id, entity_label, customer_id, action,
	actor_type, actor_id, source, request_id, ip, user_agent, schema_version, occurred_at`

// List returns up to q.Limit entries, newest first, without state.
func (r *activityRepository) List(ctx context.Context, q *activity.ListQuery) ([]*activity.Log, error) {
	where := []string{"tenant_id = ?", "environment_id = ?", "occurred_at >= " + millisParam, "occurred_at < " + millisParam}
	args := []any{types.GetTenantID(ctx), types.GetEnvironmentID(ctx), q.From.UnixMilli(), q.To.UnixMilli()}
	eq := func(column, value string) {
		if value != "" {
			where = append(where, column+" = ?")
			args = append(args, value)
		}
	}
	in := func(column string, values []string) {
		if len(values) > 0 {
			where = append(where, column+" IN (?)")
			args = append(args, values)
		}
	}
	eq("entity_type", q.EntityType)
	eq("entity_id", q.EntityID)
	eq("customer_id", q.CustomerID)
	eq("actor_id", q.ActorID)
	eq("request_id", q.RequestID)
	in("actor_type", q.ActorTypes)
	in("action", q.Actions)

	return uniqueEntries(q.Limit, q.After, func(after *activity.Cursor) ([]*activity.Log, error) {
		return r.listBatch(ctx, where, args, after, q.Limit)
	})
}

// uniqueEntries reads batches until it has limit distinct entries or the rows run out.
// Unmerged copies of an entry share (occurred_at, id), so they arrive next to each other.
func uniqueEntries(limit int, after *activity.Cursor, fetch func(after *activity.Cursor) ([]*activity.Log, error)) ([]*activity.Log, error) {
	var logs []*activity.Log
	for {
		batch, err := fetch(after)
		if err != nil {
			return nil, err
		}
		for _, l := range batch {
			if n := len(logs); n > 0 && logs[n-1].ID == l.ID {
				continue
			}
			logs = append(logs, l)
		}
		if len(batch) < limit || len(logs) >= limit {
			return logs[:min(len(logs), limit)], nil
		}
		last := batch[len(batch)-1]
		after = &activity.Cursor{OccurredAt: last.OccurredAt, ID: last.ID}
	}
}

// listBatch reads up to limit rows after the cursor, newest first. A plain LIMIT lets the in-order read stop early.
func (r *activityRepository) listBatch(ctx context.Context, where []string, args []any, after *activity.Cursor, limit int) ([]*activity.Log, error) {
	where, args = slices.Clip(where), slices.Clip(args)
	if after != nil {
		where = append(where, "(occurred_at, id) < ("+millisParam+", ?)")
		args = append(args, after.OccurredAt.UnixMilli(), after.ID)
	}
	args = append(args, limit)

	query := "SELECT " + activityListColumns + " FROM activity_logs WHERE " + strings.Join(where, " AND ") +
		" ORDER BY occurred_at DESC, id DESC LIMIT ?"
	rows, err := r.store.GetConn().Query(ctx, query, args...)
	if err != nil {
		return nil, ierr.WithError(err).WithHint("Failed to list activity").Mark(ierr.ErrDatabase)
	}
	defer rows.Close()

	var logs []*activity.Log
	for rows.Next() {
		l := &activity.Log{}
		if err := rows.Scan(&l.ID, &l.Entity.Type, &l.Entity.ID, &l.Entity.Label, &l.CustomerID, &l.Action,
			&l.Actor.Type, &l.Actor.ID, &l.Request.Source, &l.Request.ID, &l.Request.IP, &l.Request.UserAgent, &l.SchemaVersion, &l.OccurredAt); err != nil {
			return nil, ierr.WithError(err).WithHint("Failed to read activity").Mark(ierr.ErrDatabase)
		}
		logs = append(logs, l)
	}
	if err := rows.Err(); err != nil {
		return nil, ierr.WithError(err).WithHint("Failed to read activity").Mark(ierr.ErrDatabase)
	}
	return logs, nil
}

// States returns the stored state for each id; entries stored without state map to "".
func (r *activityRepository) States(ctx context.Context, ids []string, from, to time.Time) (map[string]string, error) {
	rows, err := r.store.GetConn().Query(ctx, `
		SELECT id, state FROM activity_logs
		WHERE tenant_id = ? AND environment_id = ? AND occurred_at >= `+millisParam+` AND occurred_at <= `+millisParam+` AND id IN (?)
		LIMIT 1 BY id`,
		types.GetTenantID(ctx), types.GetEnvironmentID(ctx), from.UnixMilli(), to.UnixMilli(), ids)
	if err != nil {
		return nil, ierr.WithError(err).WithHint("Failed to read activity state").Mark(ierr.ErrDatabase)
	}
	defer rows.Close()

	states := make(map[string]string, len(ids))
	for rows.Next() {
		var id, state string
		if err := rows.Scan(&id, &state); err != nil {
			return nil, ierr.WithError(err).WithHint("Failed to read activity state").Mark(ierr.ErrDatabase)
		}
		states[id] = state
	}
	if err := rows.Err(); err != nil {
		return nil, ierr.WithError(err).WithHint("Failed to read activity state").Mark(ierr.ErrDatabase)
	}
	return states, nil
}

// Get returns one entry with its state, or nil when no row matches.
func (r *activityRepository) Get(ctx context.Context, id string, from, to time.Time) (*activity.Log, error) {
	rows, err := r.store.GetConn().Query(ctx, "SELECT "+activityListColumns+`, state FROM activity_logs
		WHERE tenant_id = ? AND environment_id = ? AND occurred_at >= `+millisParam+` AND occurred_at <= `+millisParam+` AND id = ?
		LIMIT 1`,
		types.GetTenantID(ctx), types.GetEnvironmentID(ctx), from.UnixMilli(), to.UnixMilli(), id)
	if err != nil {
		return nil, ierr.WithError(err).WithHint("Failed to get activity").Mark(ierr.ErrDatabase)
	}
	defer rows.Close()

	if !rows.Next() {
		return nil, rows.Err()
	}
	l := &activity.Log{}
	if err := rows.Scan(&l.ID, &l.Entity.Type, &l.Entity.ID, &l.Entity.Label, &l.CustomerID, &l.Action,
		&l.Actor.Type, &l.Actor.ID, &l.Request.Source, &l.Request.ID, &l.Request.IP, &l.Request.UserAgent, &l.SchemaVersion, &l.OccurredAt, &l.State); err != nil {
		return nil, ierr.WithError(err).WithHint("Failed to read activity").Mark(ierr.ErrDatabase)
	}
	return l, nil
}