Ctrl K
ComunidadGuías…

internal/repository/clickhouse/raw_event.go

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

Archivo original ↗Preguntar a KiBot
package clickhouse

import (
	"context"
	"time"

	"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"
)

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

func NewRawEventRepository(store *clickhouse.ClickHouseStore, logger *logger.Logger) events.RawEventRepository {
	return &RawEventRepository{store: store, logger: logger}
}

// FindRawEvents finds raw events with filtering and keyset pagination
// Query is optimized for the table structure:
// - PRIMARY KEY: (tenant_id, environment_id, external_customer_id, timestamp)
// - ORDER BY: (tenant_id, environment_id, external_customer_id, timestamp, event_name, id)
// - PARTITION BY: toYYYYMMDD(timestamp)
// - ENGINE: ReplacingMergeTree(version)
func (r *RawEventRepository) FindRawEvents(ctx context.Context, params *events.FindRawEventsParams) ([]*events.RawEvent, error) {
	span := StartRepositorySpan(ctx, "raw_event", "find_raw_events", map[string]interface{}{
		"batch_size":            params.BatchSize,
		"external_customer_ids": params.ExternalCustomerIDs,
	})
	defer FinishSpan(span)

	// Get tenant and environment ID from context
	tenantID := types.GetTenantID(ctx)
	environmentID := types.GetEnvironmentID(ctx)

	// Build query with filters following primary key order for optimal index usage
	query := `
		SELECT 
			id, tenant_id, environment_id, external_customer_id, event_name, 
			source, payload, field1, field2, field3, field4, field5, 
			field6, field7, field8, field9, field10, timestamp, ingested_at, 
			version, sign
		FROM raw_events
		WHERE tenant_id = ?
		AND environment_id = ?
	`

	args := []interface{}{tenantID, environmentID}

	// Add filters if provided - order matters for index usage
	// Follow the primary key order: tenant_id, environment_id, external_customer_id, timestamp
	if len(params.ExternalCustomerIDs) > 0 {
		query += " AND external_customer_id IN ?"
		args = append(args, params.ExternalCustomerIDs)
	}

	if !params.StartTime.IsZero() {
		query += " AND timestamp >= ?"
		args = append(args, params.StartTime)
	}

	if !params.EndTime.IsZero() {
		query += " AND timestamp <= ?"
		args = append(args, params.EndTime)
	}

	if len(params.EventNames) > 0 {
		query += " AND event_name IN ?"
		args = append(args, params.EventNames)
	}

	if len(params.EventIDs) > 0 {
		query += " AND id IN ?"
		args = append(args, params.EventIDs)
	}

	// Add sorting for consistent ordering
	query += " ORDER BY timestamp DESC, event_name DESC, id DESC"

	// Add OFFSET and LIMIT for simple pagination
	if params.Offset > 0 {
		query += " LIMIT ? OFFSET ?"
		if params.BatchSize > 0 {
			args = append(args, params.BatchSize, params.Offset)
		} else {
			args = append(args, 1000, params.Offset)
		}
	} else {
		// No offset, just limit
		if params.BatchSize > 0 {
			query += " LIMIT ?"
			args = append(args, params.BatchSize)
		} else {
			query += " LIMIT 1000"
		}
	}

	r.logger.Debug(ctx, "executing find raw events query",
		"query", query,
		"args", args,
		"external_customer_ids", params.ExternalCustomerIDs,
		"event_names", params.EventNames,
		"batch_size", params.BatchSize,
		"offset", params.Offset,
	)

	// Execute the query
	rows, err := r.store.GetConn().Query(ctx, query, args...)
	if err != nil {
		SetSpanError(span, err)
		return nil, ierr.WithError(err).
			WithHint("Failed to query raw events").
			Mark(ierr.ErrDatabase)
	}
	defer rows.Close()

	var eventsList []*events.RawEvent
	for rows.Next() {
		var event events.RawEvent

		err := rows.Scan(
			&event.ID,
			&event.TenantID,
			&event.EnvironmentID,
			&event.ExternalCustomerID,
			&event.EventName,
			&event.Source,
			&event.Payload,
			&event.Field1,
			&event.Field2,
			&event.Field3,
			&event.Field4,
			&event.Field5,
			&event.Field6,
			&event.Field7,
			&event.Field8,
			&event.Field9,
			&event.Field10,
			&event.Timestamp,
			&event.IngestedAt,
			&event.Version,
			&event.Sign,
		)
		if err != nil {
			SetSpanError(span, err)
			return nil, ierr.WithError(err).
				WithHint("Failed to scan raw event").
				Mark(ierr.ErrDatabase)
		}

		eventsList = append(eventsList, &event)
	}

	// Check for errors that occurred during iteration
	if err := rows.Err(); err != nil {
		SetSpanError(span, err)
		return nil, ierr.WithError(err).
			WithHint("Error occurred during row iteration").
			Mark(ierr.ErrDatabase)
	}

	r.logger.Debug(ctx, "fetched raw events from clickhouse",
		"count", len(eventsList),
		"expected_batch_size", params.BatchSize,
		"offset", params.Offset,
	)

	SetSpanSuccess(span)
	return eventsList, nil
}

// FindUnprocessedRawEvents finds raw events that haven't been processed yet.
//
// Strategy (2-step batch, keyset pagination):
//
//	Step A — Narrow scan: LEFT ANTI JOIN to find only unprocessed IDs.
//	         Reads 3 columns (timestamp, id, event_name) — cheap columnar I/O.
//	         Uses keyset pagination (timestamp, id) < (last_ts, last_id) instead
//	         of OFFSET, so every page costs the same regardless of depth.
//
//	Step B — Wide fetch: fetch all 20 columns only for the batch of IDs found
//	         in Step A. Bounded to BatchSize rows, never scans the full range.
//
// Caller drives pagination by passing params.KeysetCursor (nil = first batch).
// When the returned cursor is nil, there are no more batches.
//
// Performance note on the ANTI JOIN subquery:
//
//	When ExternalCustomerIDs is provided, the same filter is pushed into the
//	events subquery so ClickHouse builds a small per-customer hash table
//	instead of loading all tenant events. Benchmarked: 34s → <1s.
//
//	events table PRIMARY KEY is (tenant_id, environment_id) so external_customer_id
//	is NOT in the index — but filtering it inside the subquery still drastically
//	reduces the hash table size before the ANTI JOIN executes.
func (r *RawEventRepository) FindUnprocessedRawEvents(
	ctx context.Context,
	params *events.FindRawEventsParams,
) ([]*events.RawEvent, *events.KeysetCursor, error) {
	span := StartRepositorySpan(ctx, "raw_event", "find_unprocessed_raw_events", map[string]interface{}{
		"batch_size":            params.BatchSize,
		"external_customer_ids": params.ExternalCustomerIDs,
		"has_cursor":            params.KeysetCursor != nil,
	})
	defer FinishSpan(span)

	tenantID := types.GetTenantID(ctx)
	environmentID := types.GetEnvironmentID(ctx)

	batchSize := params.BatchSize
	if batchSize <= 0 {
		batchSize = 1000
	}

	// ── Step A: find unprocessed IDs (narrow scan) ───────────────────────────
	//
	// The events subquery is built dynamically based on filters:
	//
	//   Case 1 — ExternalCustomerIDs provided:
	//     Push the customer filter INTO the subquery so ClickHouse builds a
	//     small per-customer hash table instead of loading every event for the
	//     entire tenant. Critical optimization — verified 34s → <1s.
	//
	//   Case 2 — No customer filter (tenant-wide):
	//     Subquery covers all tenant+environment+timestamp events. Hash table
	//     is larger but acceptable — tenant-wide reprocessing is a rare,
	//     intentional operation.

	// Build subquery dynamically.
	subquery := `
		SELECT id
		FROM events
		WHERE tenant_id      = ?
		  AND environment_id = ?
		  AND timestamp >= ?
		  AND timestamp <  ?
	`
	subArgs := []interface{}{tenantID, environmentID, params.StartTime, params.EndTime}

	// Push customer filter into subquery when present — this is the key fix.
	// Narrows the ANTI JOIN hash table from "all tenant events" to
	// "this customer's events only".
	if len(params.ExternalCustomerIDs) > 0 {
		subquery += " AND external_customer_id IN ?"
		subArgs = append(subArgs, params.ExternalCustomerIDs)
	}

	stepAQuery := `
		SELECT r.timestamp, r.id, r.event_name
		FROM raw_events r
		LEFT ANTI JOIN (` + subquery + `) e ON r.id = e.id
		WHERE r.tenant_id      = ?
		  AND r.environment_id = ?
		  AND r.timestamp >= ?
		  AND r.timestamp <  ?
	`

	// Full args: subquery args first, then outer WHERE.
	stepAArgs := append(subArgs,
		tenantID, environmentID, params.StartTime, params.EndTime,
	)

	// Outer WHERE optional filters.
	// ExternalCustomerIDs first — matches raw_events primary key column order:
	// (tenant_id, environment_id, external_customer_id, timestamp, id)
	if len(params.ExternalCustomerIDs) > 0 {
		stepAQuery += " AND r.external_customer_id IN ?"
		stepAArgs = append(stepAArgs, params.ExternalCustomerIDs)
	}

	if len(params.EventNames) > 0 {
		stepAQuery += " AND r.event_name IN ?"
		stepAArgs = append(stepAArgs, params.EventNames)
	}

	if len(params.EventIDs) > 0 {
		stepAQuery += " AND r.id IN ?"
		stepAArgs = append(stepAArgs, params.EventIDs)
	}

	// Keyset pagination: tuple comparison lets ClickHouse use the sort key
	// directly. Replaces OFFSET which scans N*batchSize rows per page —
	// catastrophic at billions of events.
	if params.KeysetCursor != nil {
		stepAQuery += " AND (r.timestamp, r.id) < (?, ?)"
		stepAArgs = append(stepAArgs, params.KeysetCursor.LastTimestamp, params.KeysetCursor.LastID)
	}

	stepAQuery += " ORDER BY r.timestamp DESC, r.id DESC LIMIT ?"
	stepAArgs = append(stepAArgs, batchSize)

	r.logger.Debug(ctx, "executing step A: find unprocessed IDs",
		"tenant_id", tenantID,
		"external_customer_ids", params.ExternalCustomerIDs,
		"event_names", params.EventNames,
		"batch_size", batchSize,
		"has_cursor", params.KeysetCursor != nil,
		"customer_filter_in_subquery", len(params.ExternalCustomerIDs) > 0,
	)

	rowsA, err := r.store.GetConn().Query(ctx, stepAQuery, stepAArgs...)
	if err != nil {
		SetSpanError(span, err)
		return nil, nil, ierr.WithError(err).
			WithHint("Failed to query unprocessed event IDs (step A)").
			Mark(ierr.ErrDatabase)
	}
	defer rowsA.Close()

	var (
		ids        []string
		nextCursor *events.KeysetCursor
	)

	for rowsA.Next() {
		var (
			ts        time.Time
			id        string
			eventName string
		)
		if err := rowsA.Scan(&ts, &id, &eventName); err != nil {
			SetSpanError(span, err)
			return nil, nil, ierr.WithError(err).
				WithHint("Failed to scan unprocessed event ID (step A)").
				Mark(ierr.ErrDatabase)
		}
		ids = append(ids, id)
		// Updated each iteration — after the loop this holds the last (oldest)
		// row, which becomes the cursor for the next batch.
		nextCursor = &events.KeysetCursor{
			LastTimestamp: ts,
			LastID:        id,
		}
	}

	// rows.Err() catches truncated streams — network blips or server timeouts
	// that silently stop rows.Next() without returning a scan error.
	if err := rowsA.Err(); err != nil {
		SetSpanError(span, err)
		return nil, nil, ierr.WithError(err).
			WithHint("Row iteration error in step A").
			Mark(ierr.ErrDatabase)
	}

	r.logger.Debug(ctx, "step A complete",
		"unprocessed_id_count", len(ids),
		"has_next_cursor", nextCursor != nil,
	)

	if len(ids) == 0 {
		SetSpanSuccess(span)
		return nil, nil, nil
	}

	// Fewer rows than batchSize means the dataset is exhausted — no next cursor.
	if len(ids) < batchSize {
		nextCursor = nil
	}

	// ── Step B: fetch full rows for the IDs found above ──────────────────────
	//
	// Bounded to len(ids) rows (≤ batchSize). The 20-column wide read only
	// touches exactly the rows we know need processing.
	// tenant_id + environment_id prunes partitions; id IN narrows to exact rows.

	stepBQuery := `
		SELECT
			id, tenant_id, environment_id, external_customer_id, event_name,
			source, payload,
			field1, field2, field3, field4, field5,
			field6, field7, field8, field9, field10,
			timestamp, ingested_at, version, sign
		FROM raw_events
		WHERE tenant_id      = ?
		  AND environment_id = ?
		  AND id IN ?
		ORDER BY timestamp DESC, id DESC
	`
	stepBArgs := []interface{}{tenantID, environmentID, ids}

	r.logger.Debug(ctx, "executing step B: fetch full event rows",
		"id_count", len(ids),
	)

	rowsB, err := r.store.GetConn().Query(ctx, stepBQuery, stepBArgs...)
	if err != nil {
		SetSpanError(span, err)
		return nil, nil, ierr.WithError(err).
			WithHint("Failed to fetch full raw event rows (step B)").
			Mark(ierr.ErrDatabase)
	}
	defer rowsB.Close()

	eventsList := make([]*events.RawEvent, 0, len(ids))

	for rowsB.Next() {
		var event events.RawEvent
		if err := rowsB.Scan(
			&event.ID,
			&event.TenantID,
			&event.EnvironmentID,
			&event.ExternalCustomerID,
			&event.EventName,
			&event.Source,
			&event.Payload,
			&event.Field1,
			&event.Field2,
			&event.Field3,
			&event.Field4,
			&event.Field5,
			&event.Field6,
			&event.Field7,
			&event.Field8,
			&event.Field9,
			&event.Field10,
			&event.Timestamp,
			&event.IngestedAt,
			&event.Version,
			&event.Sign,
		); err != nil {
			SetSpanError(span, err)
			return nil, nil, ierr.WithError(err).
				WithHint("Failed to scan raw event row (step B)").
				Mark(ierr.ErrDatabase)
		}
		eventsList = append(eventsList, &event)
	}

	if err := rowsB.Err(); err != nil {
		SetSpanError(span, err)
		return nil, nil, ierr.WithError(err).
			WithHint("Row iteration error in step B").
			Mark(ierr.ErrDatabase)
	}

	r.logger.Debug(ctx, "unprocessed raw events batch",
		"count", len(eventsList),
		"has_next_batch", nextCursor != nil,
	)

	SetSpanSuccess(span)
	return eventsList, nextCursor, nil
}