mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
313 lines
8.9 KiB
Go
313 lines
8.9 KiB
Go
package pubsub
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"cloud.google.com/go/pubsub"
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/google/uuid"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// Client wraps Google Pub/Sub for real-time streaming
|
|
type Client struct {
|
|
client *pubsub.Client
|
|
projectID string
|
|
}
|
|
|
|
// NewClient creates a new Pub/Sub client
|
|
func NewClient(ctx context.Context, projectID string) (*Client, error) {
|
|
client, err := pubsub.NewClient(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create pubsub client: %w", err)
|
|
}
|
|
|
|
return &Client{
|
|
client: client,
|
|
projectID: projectID,
|
|
}, nil
|
|
}
|
|
|
|
// Close closes the Pub/Sub client
|
|
func (c *Client) Close() error {
|
|
return c.client.Close()
|
|
}
|
|
|
|
// getTopic gets or creates a topic
|
|
func (c *Client) getTopic(ctx context.Context, topicID string) (*pubsub.Topic, error) {
|
|
topic := c.client.Topic(topicID)
|
|
exists, err := topic.Exists(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !exists {
|
|
topic, err = c.client.CreateTopic(ctx, topicID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return topic, nil
|
|
}
|
|
|
|
// realtimeTopology is the full topic -> pull-subscription set the Elixir
|
|
// realtime service consumes. It is the single source of truth for provisioning:
|
|
// every topic the StreamingPublisher writes to gets exactly one subscription
|
|
// named "<topic>-sub", matching :realtime, :pubsub_subscriptions in the Elixir
|
|
// config (realtime/config/{config,runtime}.exs). Keep the two lists in lockstep.
|
|
var realtimeTopology = map[string]string{
|
|
TopicTaskStatus: TopicTaskStatus + "-sub",
|
|
TopicCampaignUpdate: TopicCampaignUpdate + "-sub",
|
|
TopicWarmupUpdate: TopicWarmupUpdate + "-sub",
|
|
TopicEmailError: TopicEmailError + "-sub",
|
|
TopicEmailWarning: TopicEmailWarning + "-sub",
|
|
TopicUserEvents: TopicUserEvents + "-sub",
|
|
TopicEmailInbox: TopicEmailInbox + "-sub",
|
|
TopicBulkOps: TopicBulkOps + "-sub",
|
|
TopicContactsSync: TopicContactsSync + "-sub",
|
|
}
|
|
|
|
// EnsureRealtimeTopology idempotently creates every realtime topic and its pull
|
|
// subscription. Safe to call on every boot and from multiple services
|
|
// concurrently: AlreadyExists is treated as success. Provisioning here (the
|
|
// control plane) means the Elixir Broadway producers always find their
|
|
// subscriptions, instead of silently consuming from a subscription that was
|
|
// never created. Called only when PUBSUB_ENABLED=true.
|
|
func (c *Client) EnsureRealtimeTopology(ctx context.Context) error {
|
|
for topicID, subID := range realtimeTopology {
|
|
topic, err := c.client.CreateTopic(ctx, topicID)
|
|
if err != nil {
|
|
if status.Code(err) != codes.AlreadyExists {
|
|
return fmt.Errorf("create topic %s: %w", topicID, err)
|
|
}
|
|
topic = c.client.Topic(topicID)
|
|
}
|
|
if _, err := c.client.CreateSubscription(ctx, subID, pubsub.SubscriptionConfig{
|
|
Topic: topic,
|
|
AckDeadline: 30 * time.Second,
|
|
}); err != nil && status.Code(err) != codes.AlreadyExists {
|
|
return fmt.Errorf("create subscription %s: %w", subID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Publish publishes a message to a topic
|
|
func (c *Client) Publish(ctx context.Context, topicID string, data interface{}, attributes map[string]string) error {
|
|
topic, err := c.getTopic(ctx, topicID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get topic: %w", err)
|
|
}
|
|
|
|
jsonData, err := json.Marshal(data)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal data: %w", err)
|
|
}
|
|
|
|
result := topic.Publish(ctx, &pubsub.Message{
|
|
Data: jsonData,
|
|
Attributes: attributes,
|
|
})
|
|
|
|
_, err = result.Get(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to publish message: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// eventBus is the transport the StreamingPublisher writes to. Both the Google
|
|
// Pub/Sub Client and the Redis bridge implement it, so the publish helpers stay
|
|
// transport-agnostic and the same code path serves GCP and non-GCP (local dev)
|
|
// environments.
|
|
type eventBus interface {
|
|
Publish(ctx context.Context, topicID string, data interface{}, attributes map[string]string) error
|
|
}
|
|
|
|
// StreamingPublisher handles real-time streaming to users
|
|
type StreamingPublisher struct {
|
|
client eventBus
|
|
}
|
|
|
|
// NewStreamingPublisher creates a new streaming publisher
|
|
func NewStreamingPublisher(client eventBus) *StreamingPublisher {
|
|
return &StreamingPublisher{
|
|
client: client,
|
|
}
|
|
}
|
|
|
|
// Topic names for real-time updates
|
|
const (
|
|
TopicTaskStatus = "task-status"
|
|
TopicCampaignUpdate = "campaign-update"
|
|
TopicWarmupUpdate = "warmup-update"
|
|
TopicEmailError = "email-error"
|
|
TopicEmailWarning = "email-warning"
|
|
)
|
|
|
|
// Event types
|
|
type EventType string
|
|
|
|
const (
|
|
EventTaskCreated EventType = "TASK_CREATED"
|
|
EventTaskStarted EventType = "TASK_STARTED"
|
|
EventTaskCompleted EventType = "TASK_COMPLETED"
|
|
EventTaskFailed EventType = "TASK_FAILED"
|
|
EventEmailSent EventType = "EMAIL_SENT"
|
|
EventEmailFailed EventType = "EMAIL_FAILED"
|
|
EventError EventType = "ERROR"
|
|
EventWarning EventType = "WARNING"
|
|
)
|
|
|
|
// StreamEvent represents a real-time event for users
|
|
type StreamEvent struct {
|
|
EventType EventType `json:"event_type"`
|
|
UserID string `json:"user_id"`
|
|
TaskID string `json:"task_id,omitempty"`
|
|
EmailID string `json:"email_id,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Data any `json:"data,omitempty"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
// PublishTaskStatus publishes a task status update
|
|
func (p *StreamingPublisher) PublishTaskStatus(ctx context.Context, userID string, taskID uuid.UUID, eventType EventType, message string, data any) {
|
|
if p.client == nil {
|
|
return
|
|
}
|
|
|
|
event := StreamEvent{
|
|
EventType: eventType,
|
|
UserID: userID,
|
|
TaskID: taskID.String(),
|
|
Message: message,
|
|
Data: data,
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
attrs := map[string]string{
|
|
"user_id": userID,
|
|
"event_type": string(eventType),
|
|
}
|
|
|
|
if err := p.client.Publish(ctx, TopicTaskStatus, event, attrs); err != nil {
|
|
sentry.CaptureException(fmt.Errorf("failed to publish task status: %w", err))
|
|
}
|
|
}
|
|
|
|
// PublishEmailError publishes an email error for user visibility
|
|
func (p *StreamingPublisher) PublishEmailError(ctx context.Context, userID string, emailID uuid.UUID, taskID uuid.UUID, errorTitle, errorMessage string) {
|
|
if p.client == nil {
|
|
return
|
|
}
|
|
|
|
event := StreamEvent{
|
|
EventType: EventError,
|
|
UserID: userID,
|
|
TaskID: taskID.String(),
|
|
EmailID: emailID.String(),
|
|
Message: errorTitle,
|
|
Data: map[string]string{
|
|
"title": errorTitle,
|
|
"message": errorMessage,
|
|
},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
attrs := map[string]string{
|
|
"user_id": userID,
|
|
"email_id": emailID.String(),
|
|
"event_type": string(EventError),
|
|
}
|
|
|
|
if err := p.client.Publish(ctx, TopicEmailError, event, attrs); err != nil {
|
|
sentry.CaptureException(fmt.Errorf("failed to publish email error: %w", err))
|
|
}
|
|
}
|
|
|
|
// PublishEmailWarning publishes an email warning for user visibility
|
|
func (p *StreamingPublisher) PublishEmailWarning(ctx context.Context, userID string, emailID uuid.UUID, warningTitle, warningMessage string) {
|
|
if p.client == nil {
|
|
return
|
|
}
|
|
|
|
event := StreamEvent{
|
|
EventType: EventWarning,
|
|
UserID: userID,
|
|
EmailID: emailID.String(),
|
|
Message: warningTitle,
|
|
Data: map[string]string{
|
|
"title": warningTitle,
|
|
"message": warningMessage,
|
|
},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
attrs := map[string]string{
|
|
"user_id": userID,
|
|
"email_id": emailID.String(),
|
|
"event_type": string(EventWarning),
|
|
}
|
|
|
|
if err := p.client.Publish(ctx, TopicEmailWarning, event, attrs); err != nil {
|
|
sentry.CaptureException(fmt.Errorf("failed to publish email warning: %w", err))
|
|
}
|
|
}
|
|
|
|
// PublishCampaignProgress publishes campaign progress update
|
|
func (p *StreamingPublisher) PublishCampaignProgress(ctx context.Context, userID string, campaignID uuid.UUID, progress any) {
|
|
if p.client == nil {
|
|
return
|
|
}
|
|
|
|
event := StreamEvent{
|
|
EventType: EventTaskCompleted,
|
|
UserID: userID,
|
|
Message: "Campaign progress updated",
|
|
Data: progress,
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
attrs := map[string]string{
|
|
"user_id": userID,
|
|
"campaign_id": campaignID.String(),
|
|
"event_type": "CAMPAIGN_PROGRESS",
|
|
}
|
|
|
|
if err := p.client.Publish(ctx, TopicCampaignUpdate, event, attrs); err != nil {
|
|
sentry.CaptureException(fmt.Errorf("failed to publish campaign progress: %w", err))
|
|
}
|
|
}
|
|
|
|
// PublishWarmupStats publishes warmup statistics update
|
|
func (p *StreamingPublisher) PublishWarmupStats(ctx context.Context, userID string, emailID uuid.UUID, stats any) {
|
|
if p.client == nil {
|
|
return
|
|
}
|
|
|
|
event := StreamEvent{
|
|
EventType: EventTaskCompleted,
|
|
UserID: userID,
|
|
EmailID: emailID.String(),
|
|
Message: "Warmup statistics updated",
|
|
Data: stats,
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
attrs := map[string]string{
|
|
"user_id": userID,
|
|
"email_id": emailID.String(),
|
|
"event_type": "WARMUP_STATS",
|
|
}
|
|
|
|
if err := p.client.Publish(ctx, TopicWarmupUpdate, event, attrs); err != nil {
|
|
sentry.CaptureException(fmt.Errorf("failed to publish warmup stats: %w", err))
|
|
}
|
|
}
|