Ctrl K
ComunidadGuías…

internal/webhooks/webhook_service_batch.go

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

Archivo original ↗Preguntar a KiBot
package webhooks

import (
	"context"
	"fmt"
	"time"

	svcerrors "github.com/sarathsp06/sparrow/internal/errors"
	"github.com/sarathsp06/sparrow/internal/storage"
	"github.com/sarathsp06/sparrow/internal/tenant"
	"github.com/sarathsp06/sparrow/internal/webhooks/queue"
	"github.com/sarathsp06/sparrow/internal/webhooks/store"
)

// --- Batch Operations ---

// BatchScope says whose batch jobs a caller may reach. Consumer is the
// consumer in the request path. A job snapshotted for one consumer is
// reachable only under that consumer; a job snapshotted across all consumers
// (Consumer "") is reachable under any consumer path unless ConsumerOnly is
// set, which consumer-scoped callers (portal tokens) always set. A job outside
// the scope answers not found, so its existence is not revealed.
type BatchScope struct {
	Consumer     string
	ConsumerOnly bool
}

func (b BatchScope) allows(job *store.BatchJob) bool {
	if job.Consumer == b.Consumer {
		return true
	}
	return job.Consumer == "" && !b.ConsumerOnly
}

// loadAndValidateBatch parses a batch ID, loads the job, and verifies it is in
// scope and matches the expected job type. This eliminates the repeated
// parse+get+nil-check+type-check pattern across all batch operations.
func (s *WebhookService) loadAndValidateBatch(ctx context.Context, scope BatchScope, batchID string, expectedType store.BatchJobType) (*store.BatchJob, error) {
	batchUUID, err := parseUUID(batchID, "batch ID")
	if err != nil {
		return nil, err
	}

	batch, err := s.webhookRepo.GetBatchJob(ctx, tenant.FromContext(ctx), batchUUID)
	if err != nil {
		return nil, fmt.Errorf("failed to get batch job: %w", err)
	}
	if batch == nil || !scope.allows(batch) {
		return nil, svcerrors.Error(svcerrors.NotFound, "batch job not found")
	}
	if batch.JobType != expectedType {
		return nil, svcerrors.Errorf(svcerrors.FailedPrecondition, "batch job is not a %s", expectedType)
	}
	return batch, nil
}

// startBatch validates a pending batch job, transitions it to processing, and
// enqueues a River job for async execution. Used by RePushEvents and RetryDeliveries.
func (s *WebhookService) startBatch(ctx context.Context, scope BatchScope, batchID string, jobType store.BatchJobType) error {
	batch, err := s.loadAndValidateBatch(ctx, scope, batchID, jobType)
	if err != nil {
		return err
	}
	if batch.Status != store.BatchStatusPending {
		return svcerrors.Errorf(svcerrors.FailedPrecondition, "batch job is not in pending status (current: %s)", batch.Status)
	}
	if time.Now().After(batch.ExpiresAt) {
		return svcerrors.Error(svcerrors.FailedPrecondition, "batch job has expired")
	}

	// CAS pending→processing: only one caller wins; a concurrent start loses the race.
	if err := s.webhookRepo.UpdateBatchJobStatus(ctx, batch.TenantID, batch.ID, store.BatchStatusPending, store.BatchStatusProcessing); err != nil {
		if storage.IsNotFound(err) {
			return svcerrors.Error(svcerrors.FailedPrecondition, "batch job is no longer in pending status")
		}
		return fmt.Errorf("failed to update batch status: %w", err)
	}

	_, err = s.jobInserter.Insert(ctx, &queue.BatchJobArgs{
		TenantID: batch.TenantID.String(),
		BatchID:  batchID,
	})
	if err != nil {
		// Roll back status on enqueue failure
		_ = s.webhookRepo.UpdateBatchJobStatus(ctx, batch.TenantID, batch.ID, store.BatchStatusProcessing, store.BatchStatusPending)
		return fmt.Errorf("failed to enqueue batch job: %w", err)
	}

	s.logger.InfoContext(ctx, "Batch job enqueued", "batch_id", batchID, "job_type", jobType, "total", batch.Total)
	return nil
}

// cancelBatch validates a batch job and transitions it to cancelled.
// Used by CancelRepush and CancelRetry.
func (s *WebhookService) cancelBatch(ctx context.Context, scope BatchScope, batchID string, jobType store.BatchJobType) error {
	batch, err := s.loadAndValidateBatch(ctx, scope, batchID, jobType)
	if err != nil {
		return err
	}
	if batch.Status == store.BatchStatusCompleted || batch.Status == store.BatchStatusCancelled {
		return svcerrors.Errorf(svcerrors.FailedPrecondition, "batch job is already in terminal state: %s", batch.Status)
	}

	// CAS from the observed status: if it changed concurrently (e.g. completed), fail rather than clobber.
	if err := s.webhookRepo.UpdateBatchJobStatus(ctx, batch.TenantID, batch.ID, batch.Status, store.BatchStatusCancelled); err != nil {
		if storage.IsNotFound(err) {
			return svcerrors.Error(svcerrors.FailedPrecondition, "batch job status changed concurrently; retry cancel")
		}
		return fmt.Errorf("failed to cancel batch job: %w", err)
	}

	s.logger.InfoContext(ctx, "Batch job cancelled", "batch_id", batchID, "job_type", jobType)
	return nil
}

// RePushEvents starts async processing of a batch re-push.
// The batch must exist, belong to the tenant, be of type event_repush, and be in pending status.
func (s *WebhookService) RePushEvents(ctx context.Context, scope BatchScope, repushID string) error {
	return s.startBatch(ctx, scope, repushID, store.BatchTypeEventRepush)
}

// GetRepushStatus returns the current state of a batch re-push.
func (s *WebhookService) GetRepushStatus(ctx context.Context, scope BatchScope, repushID string) (*store.BatchJob, error) {
	return s.loadAndValidateBatch(ctx, scope, repushID, store.BatchTypeEventRepush)
}

// CancelRepush aborts a pending or in-progress batch re-push.
func (s *WebhookService) CancelRepush(ctx context.Context, scope BatchScope, repushID string) error {
	return s.cancelBatch(ctx, scope, repushID, store.BatchTypeEventRepush)
}

// RetryDeliveries starts async processing of a batch delivery retry.
func (s *WebhookService) RetryDeliveries(ctx context.Context, scope BatchScope, retryID string) error {
	return s.startBatch(ctx, scope, retryID, store.BatchTypeDeliveryRetry)
}

// GetRetryStatus returns the current state of a batch delivery retry.
func (s *WebhookService) GetRetryStatus(ctx context.Context, scope BatchScope, retryID string) (*store.BatchJob, error) {
	return s.loadAndValidateBatch(ctx, scope, retryID, store.BatchTypeDeliveryRetry)
}

// CancelRetry aborts a pending or in-progress batch delivery retry.
func (s *WebhookService) CancelRetry(ctx context.Context, scope BatchScope, retryID string) error {
	return s.cancelBatch(ctx, scope, retryID, store.BatchTypeDeliveryRetry)
}