Files
upstream-watermill/_examples/real-world-examples/transactional-events-forwarder/main.go
2026-09-15 21:27:46 +05:00

312 lines
8.0 KiB
Go

package main
import (
"context"
stdSQL "database/sql"
"encoding/json"
"errors"
"log"
"math/rand"
"time"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill-googlecloud/v2/pkg/googlecloud"
"github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql"
"github.com/ThreeDotsLabs/watermill/components/forwarder"
"github.com/ThreeDotsLabs/watermill/message"
driver "github.com/go-sql-driver/mysql"
)
const (
projectID = "transactional-events"
forwarderSQLTopic = "eventsToForward"
googleCloudEventTopic = "lottery-concluded"
simulatedErrorProbability = 0.5
)
var (
logger = watermill.NewStdLogger(false, false)
db = createDB()
)
type LotteryConcludedEvent struct {
LotteryID int `json:"lottery_id"`
}
func main() {
// Setup the Forwarder component so it takes messages from MySQL subscription and pushes them to Google Pub/Sub.
sqlSubscriber, err := sql.NewSubscriber(
sql.BeginnerFromStdSQL(db),
sql.SubscriberConfig{
SchemaAdapter: sql.DefaultMySQLSchema{},
OffsetsAdapter: sql.DefaultMySQLOffsetsAdapter{},
InitializeSchema: true,
},
logger,
)
expectNoErr(err)
gcpPublisher, err := googlecloud.NewPublisher(
googlecloud.PublisherConfig{
ProjectID: projectID,
},
logger,
)
expectNoErr(err)
fwd, err := forwarder.NewForwarder(sqlSubscriber, gcpPublisher, logger, forwarder.Config{
ForwarderTopic: forwarderSQLTopic,
})
expectNoErr(err)
go func() {
err := fwd.Run(context.Background())
expectNoErr(err)
}()
go runLotteryService(logger)
go runPrizeSenderService(logger)
time.Sleep(time.Second * 60)
}
// Lottery service picks a random user at fixed intervals and makes him win a lottery.
func runLotteryService(logger watermill.LoggerAdapter) {
logger = logger.With(watermill.LogFields{"service": "lottery"})
// We'd like to persist in a database that for a given lottery id, a drawn user is a winner.
// At the same time, we want to emit an event that will tell the rest of the system this happened.
// We could approach implementing this handler in at least 3 ways:
availableServiceHandlers := []func(int, string, watermill.LoggerAdapter) error{
publishEventAndPersistData,
persistDataAndPublishEvent,
persistDataAndPublishEventInTransaction,
}
users := []string{"Mike", "Dwight", "Jim", "Pamela"}
lotteryID := 1
for range time.Tick(time.Second * 5) {
pickedUser := users[rand.Intn(len(users))]
logger := logger.With(watermill.LogFields{"user": pickedUser, "lottery_id": lotteryID})
logger.Info("User has been picked as a winner", nil)
pickedHandler := availableServiceHandlers[rand.Intn(len(availableServiceHandlers))]
err := pickedHandler(lotteryID, pickedUser, logger)
if err != nil {
logger.Error("Handler failed", err, nil)
}
lotteryID++
}
}
// 1. Publishes event to Google Cloud Pub/Sub first, then stores data in MySQL.
func publishEventAndPersistData(lotteryID int, pickedUser string, logger watermill.LoggerAdapter) error {
publisher, err := googlecloud.NewPublisher(
googlecloud.PublisherConfig{
ProjectID: projectID,
},
logger,
)
if err != nil {
return err
}
event := LotteryConcludedEvent{LotteryID: lotteryID}
payload, err := json.Marshal(event)
if err != nil {
return err
}
err = publisher.Publish(googleCloudEventTopic, message.NewMessage(watermill.NewULID(), payload))
if err != nil {
return err
}
// In case this fails, we have an event emitted, but no data persisted yet.
if err = simulateError(); err != nil {
logger.Error("Failed to persist data", err, nil)
return err
}
_, err = db.Exec(`INSERT INTO lotteries (lottery_id, winner) VALUES(?, ?)`, lotteryID, pickedUser)
if err != nil {
return err
}
return nil
}
// 2. Persists data to MySQL first, then publishes an event straight to Google Cloud Pub/Sub.
func persistDataAndPublishEvent(lotteryID int, pickedUser string, logger watermill.LoggerAdapter) error {
_, err := db.Exec(`INSERT INTO lotteries (lottery_id, winner) VALUES(?, ?)`, lotteryID, pickedUser)
if err != nil {
return err
}
var publisher message.Publisher
publisher, err = googlecloud.NewPublisher(
googlecloud.PublisherConfig{
ProjectID: projectID,
},
logger,
)
if err != nil {
return err
}
event := LotteryConcludedEvent{LotteryID: lotteryID}
payload, err := json.Marshal(event)
if err != nil {
return err
}
// In case this fails, we have data persisted, but no event emitted.
if err = simulateError(); err != nil {
logger.Error("Failed to emit event", err, nil)
return err
}
err = publisher.Publish(googleCloudEventTopic, message.NewMessage(watermill.NewULID(), payload))
if err != nil {
return err
}
return nil
}
// 3. Persists data in MySQL and emits an event through MySQL to Google Cloud Pub/Sub, all in one transaction.
func persistDataAndPublishEventInTransaction(lotteryID int, pickedUser string, logger watermill.LoggerAdapter) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
if err == nil {
tx.Commit()
} else {
logger.Info("Rolling transaction back due to error", watermill.LogFields{"error": err.Error()})
// In case of an error, we're 100% sure that thanks to MySQL transaction rollback, we won't have any of the undesired situations:
// - event is emitted, but no data is persisted,
// - data is persisted, but no event is emitted.
tx.Rollback()
}
}()
_, err = tx.Exec(`INSERT INTO lotteries (lottery_id, winner) VALUES(?, ?)`, lotteryID, pickedUser)
if err != nil {
return err
}
var publisher message.Publisher
publisher, err = sql.NewPublisher(
sql.TxFromStdSQL(tx),
sql.PublisherConfig{
SchemaAdapter: sql.DefaultMySQLSchema{},
},
logger,
)
if err != nil {
return err
}
// Decorate publisher so it wraps an event in an envelope understood by the Forwarder component.
publisher = forwarder.NewPublisher(publisher, forwarder.PublisherConfig{
ForwarderTopic: forwarderSQLTopic,
})
// Publish an event announcing the lottery winner. Please note we're publishing to a Google Cloud topic here,
// while using decorated MySQL publisher.
event := LotteryConcludedEvent{LotteryID: lotteryID}
payload, err := json.Marshal(event)
if err != nil {
return err
}
err = publisher.Publish(googleCloudEventTopic, message.NewMessage(watermill.NewULID(), payload))
if err != nil {
return err
}
return nil
}
// PrizeSender service listens to UserWonLottery events and sends a prize straight to the user that has won.
func runPrizeSenderService(logger watermill.LoggerAdapter) {
logger = logger.With(watermill.LogFields{"service": "prize_sender"})
ctx := context.Background()
googleCloudSubscriber, err := googlecloud.NewSubscriber(
googlecloud.SubscriberConfig{
ProjectID: projectID,
},
logger,
)
expectNoErr(err)
events, err := googleCloudSubscriber.Subscribe(ctx, googleCloudEventTopic)
for rawEvent := range events {
event := LotteryConcludedEvent{}
err := json.Unmarshal(rawEvent.Payload, &event)
expectNoErr(err)
rawEvent.Ack()
logger := logger.With(watermill.LogFields{"lottery_id": event.LotteryID})
row := db.QueryRow("SELECT winner FROM lotteries WHERE lottery_id=?", event.LotteryID)
var winner string
err = row.Scan(&winner)
if err != nil {
logger.Error("Could not get lottery winner", err, nil)
continue
}
logger.Info("Sending a prize to the winner", watermill.LogFields{
"winner": winner,
"lottery_id": event.LotteryID,
})
}
}
func expectNoErr(err error) {
if err != nil {
log.Fatalf("expected no error, got: %s", err)
}
}
func simulateError() error {
if simulatedErrorProbability >= rand.Float64() {
return errors.New("simulated error occurred")
}
return nil
}
func createDB() *stdSQL.DB {
conf := driver.NewConfig()
conf.Net = "tcp"
conf.User = "root"
conf.Addr = "mysql"
conf.DBName = "watermill"
db, err := stdSQL.Open("mysql", conf.FormatDSN())
expectNoErr(err)
err = db.Ping()
expectNoErr(err)
_, err = db.Exec(`DROP TABLE IF EXISTS lotteries`)
expectNoErr(err)
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS lotteries (
lottery_id INT NOT NULL PRIMARY KEY,
winner VARCHAR(255) NOT NULL
) ENGINE=INNODB;
`)
expectNoErr(err)
return db
}