Ctrl K
ComunidadGuías…

internal/repository/clickhouse/event_deletion.go

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

Archivo original ↗Preguntar a KiBot
package clickhouse

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/flexprice/flexprice/internal/clock"

	clickhouse_go "github.com/ClickHouse/clickhouse-go/v2"
	"github.com/samber/lo"

	"github.com/flexprice/flexprice/internal/clickhouse"
	"github.com/flexprice/flexprice/internal/domain/events"
	ierr "github.com/flexprice/flexprice/internal/errors"
	"github.com/flexprice/flexprice/internal/logger"
	"github.com/flexprice/flexprice/internal/types"
)

const (
	// maxMutationQuerySize fits the inline id list a feature-narrowed events
	// delete carries. Set on the connection, not in SETTINGS: the parser reads
	// the limit before it reaches a SETTINGS clause.
	maxMutationQuerySize = 1 << 30

	// mutationDeadline bounds the wait; without it a stalled mutation holds the
	// state row DELETING and blocks that customer's invoicing forever.
	mutationDeadline     = 10 * time.Minute
	mutationPollInterval = 2 * time.Second

	// recordBatchSize is how many ids one audit INSERT ... SELECT carries.
	recordBatchSize = 1000

	// mutationKillTimeout bounds the KILL issued for a mutation we stopped waiting
	// for. Detached from the caller, which has usually gone by then.
	mutationKillTimeout = 30 * time.Second
)

// EventDeletionLogRepository hard-deletes events and their meter usage, recording
// every deleted event first.
type EventDeletionLogRepository struct {
	store  *clickhouse.ClickHouseStore
	logger *logger.Logger
	qb     *MeterUsageQueryBuilder
}

func NewEventDeletionLogRepository(store *clickhouse.ClickHouseStore, log *logger.Logger) events.DeletedEventsLogRepository {
	return &EventDeletionLogRepository{
		store:  store,
		logger: log,
		qb:     NewMeterUsageQueryBuilder(),
	}
}

// meterUsageParams is the meter_usage clause. ids replace the scope's own event
// id filter: every step after the resolve keys on the resolved set.
func meterUsageParams(scope *events.MeterUsageQueryParams, ids []string) *events.MeterUsageQueryParams {
	narrowed := *scope
	if len(ids) > 0 {
		narrowed.IDs = ids
	}
	return &narrowed
}

// baseParams drops the meter filter. It is the only form the events table can be
// queried with, since events has no meter column.
func baseParams(scope *events.MeterUsageQueryParams, ids []string) *events.MeterUsageQueryParams {
	params := meterUsageParams(scope, ids)
	params.MeterIDs = nil
	params.MeterID = ""
	return params
}

// isMeterNarrowed reports whether the request named features.
func isMeterNarrowed(scope *events.MeterUsageQueryParams) bool {
	return len(scope.MeterIDs) > 0 || scope.MeterID != ""
}

func (r *EventDeletionLogRepository) ResolveEventIDs(
	ctx context.Context,
	scope *events.MeterUsageQueryParams,
) (*events.DeletionTargets, error) {
	span := StartRepositorySpan(ctx, "event_deletion", "resolve_event_ids", map[string]interface{}{
		"customer_count": len(scope.ExternalCustomerIDs),
		"meter_count":    len(scope.MeterIDs),
	})
	defer FinishSpan(span)

	// FINAL and count() per id, so the row count matches what the audit insert
	// writes and the mutation removes, without reading the audit table back.
	where, args := r.qb.BuildWhereClause(meterUsageParams(scope, nil))
	rows, err := r.store.GetConn().Query(ctx, fmt.Sprintf(`
		SELECT id, count() AS meter_rows
		FROM meter_usage FINAL
		WHERE %s
		GROUP BY id
		LIMIT %d
		SETTINGS do_not_merge_across_partitions_select_final = 1, %s
	`, where, events.MaxEventsPerRequest+1, maxMemoryUsageSetting), args...)
	if err != nil {
		SetSpanError(span, err)
		return nil, ierr.WithError(err).
			WithHint("Failed to resolve the meter usage in scope for deletion").
			Mark(ierr.ErrDatabase)
	}
	defer rows.Close()

	targets := &events.DeletionTargets{Metered: make([]string, 0), Unmetered: make([]string, 0)}
	for rows.Next() {
		var id string
		var meterRows uint64
		if err := rows.Scan(&id, &meterRows); err != nil {
			SetSpanError(span, err)
			return nil, ierr.WithError(err).
				WithHint("Failed to read the meter usage in scope for deletion").
				Mark(ierr.ErrDatabase)
		}
		targets.Metered = append(targets.Metered, id)
		targets.MeterUsageRows += meterRows
	}
	if err := rows.Err(); err != nil {
		SetSpanError(span, err)
		return nil, ierr.WithError(err).
			WithHint("Failed to read the meter usage in scope for deletion").
			Mark(ierr.ErrDatabase)
	}

	// A meter-narrowed request can never match an event that produced no meter
	// usage, so there is nothing to look for in events.
	if isMeterNarrowed(scope) {
		SetSpanSuccess(span)
		return targets, nil
	}

	baseWhere, baseArgs := r.qb.BuildWhereClause(baseParams(scope, nil))
	all, err := r.selectIDs(ctx, fmt.Sprintf(`
		SELECT DISTINCT id
		FROM events
		WHERE %s
		LIMIT %d
		SETTINGS %s
	`, baseWhere, events.MaxEventsPerRequest+1, maxMemoryUsageSetting), baseArgs)
	if err != nil {
		SetSpanError(span, err)
		return nil, ierr.WithError(err).
			WithHint("Failed to resolve the events in scope for deletion").
			Mark(ierr.ErrDatabase)
	}

	meteredSet := lo.SliceToMap(targets.Metered, func(id string) (string, struct{}) {
		return id, struct{}{}
	})
	for _, id := range all {
		if _, ok := meteredSet[id]; !ok {
			targets.Unmetered = append(targets.Unmetered, id)
		}
	}

	SetSpanSuccess(span)
	return targets, nil
}

func (r *EventDeletionLogRepository) FindStrandedMeters(ctx context.Context, scope *events.MeterUsageQueryParams, eventIDs []string) (*events.StrandedMeters, error) {
	if !isMeterNarrowed(scope) || len(eventIDs) == 0 {
		return &events.StrandedMeters{}, nil
	}

	span := StartRepositorySpan(ctx, "event_deletion", "find_stranded_meters", map[string]interface{}{
		"event_count": len(eventIDs),
	})
	defer FinishSpan(span)

	// Narrow form: an event whose every meter was named passes.
	// No FINAL: count(DISTINCT id) and groupUniqArray collapse versions already.
	where, args := r.qb.BuildWhereClause(baseParams(scope, eventIDs))
	args = append(args, scope.MeterIDs)

	query := fmt.Sprintf(`
		SELECT count(DISTINCT id) AS event_count, groupUniqArray(meter_id) AS meters_involved
		FROM meter_usage
		WHERE %s AND meter_id NOT IN (?)
		SETTINGS %s
	`, where, maxMemoryUsageSetting)

	var result events.StrandedMeters
	if err := r.store.GetConn().QueryRow(ctx, query, args...).Scan(&result.EventCount, &result.MetersInvolved); err != nil {
		SetSpanError(span, err)
		return nil, ierr.WithError(err).
			WithHint("Failed to check whether the deletion would strand usage on another meter").
			Mark(ierr.ErrDatabase)
	}

	SetSpanSuccess(span)
	return &result, nil
}

// RecordDeletedEvents writes the audit rows for both id sets, batched. An insert
// appends a part and is cheap, unlike the mutations, which is why these batch and
// the deletes do not.
func (r *EventDeletionLogRepository) RecordDeletedEvents(
	ctx context.Context,
	scope *events.MeterUsageQueryParams,
	metered, unmetered []string,
	stamp events.DeletionStamp,
) error {
	for _, batch := range lo.Chunk(metered, recordBatchSize) {
		if err := r.recordMetered(ctx, scope, batch, stamp); err != nil {
			return err
		}
	}
	for _, batch := range lo.Chunk(unmetered, recordBatchSize) {
		if err := r.recordUnmetered(ctx, scope, batch, stamp); err != nil {
			return err
		}
	}

	r.logger.Info(ctx, "recorded the events about to be deleted",
		"request_id", stamp.RequestID,
		"metered_count", len(metered),
		"unmetered_count", len(unmetered),
	)
	return nil
}

func (r *EventDeletionLogRepository) recordMetered(
	ctx context.Context,
	scope *events.MeterUsageQueryParams,
	eventIDs []string,
	stamp events.DeletionStamp,
) error {
	if len(eventIDs) == 0 {
		return nil
	}

	span := StartRepositorySpan(ctx, "event_deletion", "record_metered_events", map[string]interface{}{
		"request_id":  stamp.RequestID,
		"event_count": len(eventIDs),
	})
	defer FinishSpan(span)

	where, whereArgs := r.qb.BuildWhereClause(meterUsageParams(scope, eventIDs))

	// FINAL on meter_usage: an uncollapsed duplicate would be a duplicate audit row.
	// LIMIT 1 BY on the events side: timestamp is in its sort key, so FINAL alone
	// leaves the same id re-ingested at a corrected timestamp as two rows.
	// The subquery repeats the predicate by hand because predicate propagation
	// across a join is unavailable, and without it the whole table is scanned.
	query := fmt.Sprintf(`
		INSERT INTO deleted_events_logs (
			event_id, meter_id, tenant_id, environment_id, external_customer_id,
			event_name, source, event_timestamp, event_ingested_at,
			qty_total, unique_hash, properties, deleted_at, deleted_by, request_id
		)
		SELECT m.id, m.meter_id, m.tenant_id, m.environment_id, m.external_customer_id,
			m.event_name, m.source, e.ev_timestamp, e.ev_ingested_at,
			m.qty_total, m.unique_hash, %s,
			?, ?, ?
		FROM meter_usage AS m FINAL
		LEFT JOIN (
			SELECT id AS ev_id, timestamp AS ev_timestamp, ingested_at AS ev_ingested_at
			FROM events FINAL
			WHERE tenant_id = ? AND environment_id = ?
				AND timestamp >= ? AND timestamp < ?
				AND id IN (?)
			ORDER BY ingested_at DESC
			LIMIT 1 BY ev_id
		) AS e ON m.id = e.ev_id
		WHERE %s
		SETTINGS %s
	`, propertiesExpr(stamp.SkipPayload, "m.properties"), where, maxMemoryUsageSetting)

	args := []interface{}{stamp.DeletedAt.UTC(), stamp.DeletedBy, stamp.RequestID,
		scope.TenantID, scope.EnvironmentID, scope.StartTime.UTC(), scope.EndTime.UTC(), eventIDs}
	args = append(args, whereArgs...)

	if err := r.store.GetConn().Exec(ctx, query, args...); err != nil {
		SetSpanError(span, err)
		return ierr.WithError(err).
			WithHint("Failed to record the meter usage about to be deleted").
			WithReportableDetails(map[string]interface{}{"request_id": stamp.RequestID}).
			Mark(ierr.ErrDatabase)
	}

	SetSpanSuccess(span)
	return nil
}

func (r *EventDeletionLogRepository) recordUnmetered(
	ctx context.Context,
	scope *events.MeterUsageQueryParams,
	eventIDs []string,
	stamp events.DeletionStamp,
) error {
	if len(eventIDs) == 0 {
		return nil
	}

	span := StartRepositorySpan(ctx, "event_deletion", "record_unmetered_events", map[string]interface{}{
		"request_id":  stamp.RequestID,
		"event_count": len(eventIDs),
	})
	defer FinishSpan(span)

	where, whereArgs := r.qb.BuildWhereClause(baseParams(scope, eventIDs))

	// LIMIT 1 BY id for the same reason as above: one audit row per id.
	query := fmt.Sprintf(`
		INSERT INTO deleted_events_logs (
			event_id, meter_id, tenant_id, environment_id, external_customer_id,
			event_name, source, event_timestamp, event_ingested_at,
			qty_total, unique_hash, properties, deleted_at, deleted_by, request_id
		)
		SELECT id, '', tenant_id, environment_id, external_customer_id,
			event_name, ifNull(source, ''), timestamp, ingested_at,
			CAST(0 AS Decimal(25, 15)), '', %s,
			?, ?, ?
		FROM events FINAL
		WHERE %s
		ORDER BY ingested_at DESC
		LIMIT 1 BY id
		SETTINGS %s
	`, propertiesExpr(stamp.SkipPayload, "ifNull(properties, '')"), where, maxMemoryUsageSetting)

	args := []interface{}{stamp.DeletedAt.UTC(), stamp.DeletedBy, stamp.RequestID}
	args = append(args, whereArgs...)

	if err := r.store.GetConn().Exec(ctx, query, args...); err != nil {
		SetSpanError(span, err)
		return ierr.WithError(err).
			WithHint("Failed to record the events about to be deleted").
			WithReportableDetails(map[string]interface{}{"request_id": stamp.RequestID}).
			Mark(ierr.ErrDatabase)
	}

	SetSpanSuccess(span)
	return nil
}

// propertiesExpr writes ” when the request set skip_payload.
func propertiesExpr(skipPayload bool, column string) string {
	if skipPayload {
		return "''"
	}
	return column
}

func (r *EventDeletionLogRepository) DeleteMeterUsage(ctx context.Context, scope *events.MeterUsageQueryParams, eventIDs []string) error {
	where, args := r.qb.BuildWhereClause(meterUsageParams(scope, eventIDs))
	return r.mutate(ctx, "meter_usage", where, args)
}

func (r *EventDeletionLogRepository) DeleteEvents(ctx context.Context, scope *events.MeterUsageQueryParams, eventIDs []string) error {
	where, args := r.qb.BuildWhereClause(baseParams(scope, eventIDs))
	return r.mutate(ctx, "events", where, args)
}

// mutate submits one heavyweight delete and waits for it. ALTER TABLE ... DELETE,
// not DELETE FROM: a lightweight delete fails on a table carrying a projection,
// and production events carries one.
func (r *EventDeletionLogRepository) mutate(ctx context.Context, table, where string, args []interface{}) error {
	span := StartRepositorySpan(ctx, "event_deletion", "mutate", map[string]interface{}{"table": table})
	defer FinishSpan(span)

	// A no-op predicate carrying a unique marker. clickhouse-go binds client
	// side, so the marker lands in system.mutations.command and the wait below
	// can tell this mutation apart from a concurrent one on the same table.
	marker := types.GenerateUUID()
	query := fmt.Sprintf("ALTER TABLE %s DELETE WHERE %s AND ? != ''", table, where)
	args = append(args, marker)

	// mutations_sync = 0 submits and returns: a blocking submit would hit the
	// client read deadline while still succeeding on the server.
	execCtx := clickhouse_go.Context(ctx, clickhouse_go.WithSettings(clickhouse_go.Settings{
		"mutations_sync": 0,
		"max_query_size": maxMutationQuerySize,
	}))

	if err := r.store.GetConn().Exec(execCtx, query, args...); err != nil {
		SetSpanError(span, err)
		return ierr.WithError(err).
			WithHint("Failed to submit the delete mutation").
			WithReportableDetails(map[string]interface{}{
				"reason":     types.EventDeletionReasonMutationFailed,
				"request_id": types.GetRequestID(ctx),
			}).
			Mark(ierr.ErrDatabase)
	}

	r.logger.Info(ctx, "submitted delete mutation", "table", table)

	if err := r.waitForMutation(ctx, table, marker); err != nil {
		SetSpanError(span, err)
		// A mutation that failed server side has already stopped. One we gave up
		// waiting for has not, and would keep rewriting parts unattended.
		if errors.Is(err, types.ErrMutationTimeout) || ctx.Err() != nil {
			r.kill(ctx, table, marker)
		}
		return err
	}

	SetSpanSuccess(span)
	return nil
}

// waitForMutation polls until this request's own mutation is done. Scoped by the
// marker: matching on time alone would pick up a concurrent deletion's mutation
// and report its failure, or its runtime, as ours.
func (r *EventDeletionLogRepository) waitForMutation(ctx context.Context, table, marker string) error {
	query := `
		SELECT count() AS total, countIf(is_done) AS done, max(latest_fail_reason) AS fail_reason
		FROM system.mutations
		WHERE database = currentDatabase() AND table = ? AND position(command, ?) > 0
	`

	deadline := clock.Mono().Add(mutationDeadline)
	for {
		var total, done uint64
		var failReason string
		if err := r.store.GetConn().QueryRow(ctx, query, table, marker).Scan(&total, &done, &failReason); err != nil {
			return ierr.WithError(err).
				WithHint("Failed to read the delete mutation status").
				WithReportableDetails(map[string]interface{}{"table": table}).
				Mark(ierr.ErrDatabase)
		}

		if failReason != "" {
			// The engine's own message stays server side: it names internal tables
			// and part paths the caller can neither read nor act on.
			r.logger.Error(ctx, "delete mutation failed",
				"error", failReason,
				"table", table,
			)
			return ierr.NewError("delete mutation failed").
				WithHint("The delete did not complete. Everything recorded for this request_id is still present").
				WithReportableDetails(map[string]interface{}{
					"reason":     types.EventDeletionReasonMutationFailed,
					"request_id": types.GetRequestID(ctx),
				}).
				Mark(ierr.ErrDatabase)
		}

		if total > 0 && done == total {
			return nil
		}

		if clock.Mono().After(deadline) {
			return ierr.WithError(types.ErrMutationTimeout).
				WithHint("The delete took too long and was cancelled. Retry it for the same period").
				WithReportableDetails(map[string]interface{}{
					"reason":     types.EventDeletionReasonMutationTimeout,
					"request_id": types.GetRequestID(ctx),
				}).
				Mark(ierr.ErrDatabase)
		}

		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(mutationPollInterval):
		}
	}
}

// selectIDs runs a single-column id query and collects the column.
func (r *EventDeletionLogRepository) selectIDs(ctx context.Context, query string, args []interface{}) ([]string, error) {
	rows, err := r.store.GetConn().Query(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	ids := make([]string, 0)
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		ids = append(ids, id)
	}
	return ids, rows.Err()
}

// kill cancels a mutation this request submitted but stopped waiting for. Without
// it an abandoned delete keeps rewriting parts with nothing watching, and a
// second attempt piles another rewrite on top of the first.
func (r *EventDeletionLogRepository) kill(ctx context.Context, table, marker string) {
	killCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), mutationKillTimeout)
	defer cancel()

	query := "KILL MUTATION WHERE database = currentDatabase() AND table = ? AND position(command, ?) > 0"
	if err := r.store.GetConn().Exec(killCtx, query, table, marker); err != nil {
		r.logger.Error(killCtx, "failed to cancel the abandoned delete mutation, it is still running",
			"error", err,
			"table", table,
		)
		return
	}

	r.logger.Info(killCtx, "cancelled the abandoned delete mutation", "table", table)
}