├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── LICENSE ├── Makefile ├── README.md ├── adapter └── psql │ ├── doc.go │ ├── options.go │ └── psql.go ├── examples └── postgres_rabbitmq │ ├── Makefile │ ├── docker-compose.yml │ ├── go.mod │ ├── go.sum │ ├── main.go │ ├── outbox.go │ └── sender.go ├── export_test.go ├── go.mod ├── go.sum ├── internal └── mocks │ └── outbox_mock.go ├── options.go ├── outbox.go ├── outbox_test.go ├── tests └── integration │ ├── Makefile │ ├── docker-compose.yml │ ├── go.mod │ ├── go.sum │ └── psql_test.go └── troutbox.png /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 2 8 | allow: 9 | - dependency-type: "direct" 10 | groups: 11 | dependencies: 12 | patterns: 13 | - "*" 14 | - package-ecosystem: "gomod" 15 | directory: "/examples/postgres_rabbitmq" 16 | schedule: 17 | interval: "daily" 18 | open-pull-requests-limit: 2 19 | allow: 20 | - dependency-type: "direct" 21 | groups: 22 | dependencies: 23 | patterns: 24 | - "*" 25 | - package-ecosystem: "gomod" 26 | directory: "/tests/integration" 27 | schedule: 28 | interval: "daily" 29 | open-pull-requests-limit: 2 30 | allow: 31 | - dependency-type: "direct" 32 | groups: 33 | dependencies: 34 | patterns: 35 | - "*" 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version-file: 'go.mod' 20 | 21 | - name: Run lint 22 | run: make lint 23 | 24 | - name: Run tests 25 | run: make test 26 | 27 | - name: Run integration tests 28 | run: make test-integration 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Artem Krasotin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | GOLANGCI_LINT_VERSION := v2.1.2 4 | MOCKERY_VERSION := v3.2.2 5 | 6 | help: ## Show this help message 7 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 8 | 9 | test: ## Run unit tests 10 | go test -race ./... 11 | 12 | test-integration: ## Run integration tests 13 | ( cd tests/integration && make test-setup test test-teardown ) 14 | 15 | lint: ## Run linter 16 | go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run 17 | 18 | gen-mocks: ## Generate mocks 19 | go run github.com/vektra/mockery/v3@$(MOCKERY_VERSION) 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

Every trout is delivered.

4 |

5 | 6 | # Go Transactional Outbox Library 7 | 8 | [![Build Status](https://github.com/Darkemon/troutbox/actions/workflows/ci.yml/badge.svg)](https://github.com/Darkemon/troutbox/actions) 9 | 10 | The **Go Transactional Outbox Library** is a robust implementation of the **Outbox Pattern** for reliable message delivery in distributed systems. It ensures that messages are stored in a database and sent at least once to a message broker (e.g., RabbitMQ, Kafka) in a fault-tolerant and transactional manner. 11 | 12 | ## Usage 13 | 14 | Here is a simple example of how to use the library: 15 | 16 | ```go 17 | import ( 18 | "context" 19 | "github.com/Darkemon/troutbox" 20 | "github.com/Darkemon/troutbox/adapter/psql" 21 | ) 22 | 23 | var ( 24 | db *sql.DB // your database connection 25 | sender troutbox.Sender // your message sender implementation 26 | ) 27 | 28 | // Create psql implementation of the repository. 29 | // The second argument is lock id for partitioning job. 30 | // It is used to prevent multiple instances of the application 31 | // from running the partitioning job at the same time. 32 | repo, err := psql.NewPostgresMessageRepository(db, 12345) 33 | 34 | // Create necessary tables. 35 | err := repo.Migrate(ctx) 36 | 37 | // Run a job to create/remove partitions. 38 | go func() { 39 | err := repo.RunPartitioningJob(ctx) 40 | // ... 41 | }() 42 | 43 | // Create a new outbox instance and run it. 44 | outbox := troutbox.NewOutbox(repo, sender) 45 | 46 | go func() { 47 | err := outbox.Run(ctx) 48 | // ... 49 | }() 50 | 51 | // Add a message to the outbox. 52 | tr := db.BeginTx(ctx, nil) 53 | err := outbox.AddMessage(ctx, tr, "my-key", []byte("my-value")) 54 | // ... 55 | tr.Commit() // or tr.Rollback() 56 | ``` 57 | 58 | For more detailed example, please refer to the [examples](./examples/) directory. 59 | See [adapter](./adapter/) directory for repository implementations. 60 | 61 | ## Features 62 | 63 | - **Distributed Systems Support**: Designed to work seamlessly in distributed environments. 64 | - **Transactional Support**: Ensures messages are added to the outbox and processed reliably within database transactions. 65 | - **Retry Logic**: Automatically retries failed messages up to a configurable limit. 66 | - **Dead Letter Handling**: Marks messages as dead if they exceed the retry limit, ensuring they are not retried indefinitely. 67 | - **Extensibility**: Easily integrates with different storage backends (e.g., PostgreSQL, MySQL) and message brokers (e.g., RabbitMQ, Kafka). 68 | - **OpenTelemetry Integration**: Provides observability with metrics and tracing. 69 | - **Customizable**: Configurable batch size, retry limits and error handlers. 70 | 71 | ## Installation 72 | 73 | To install the library, run: 74 | 75 | ```bash 76 | go get github.com/Darkemon/troutbox 77 | ``` 78 | 79 | ## Observability 80 | The library provides OpenTelemetry traces and metrics for monitoring the status of the outbox. 81 | 82 | The following metrics are available: 83 | - `outbox_messages_sent`: (counter) the total number of messages sent to the message broker. 84 | - `outbox_messages_failed`: (counter) the total number of messages that failed to be sent. 85 | - `outbox_messages_retried`: (counter) the total number of messages that were retried. 86 | - `outbox_messages_dead`: (counter) the total number of messages that were marked as dead. 87 | 88 | Take into account that the counters might be not 100% accurate in case when there are issues with database connection. 89 | -------------------------------------------------------------------------------- /adapter/psql/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | This package provides an implementation of the TransactionalMessageRepository interface for a PostgreSQL database. 3 | It also includes partitioning support for the outbox table. 4 | 5 | # Usage example 6 | 7 | // Create the PostgreSQL adapter. 8 | psqlRepo := psql.NewPostgresMessageRepository(db, 9 | psql.WithTableName("outbox_messages"), 10 | psql.WithFuturePartitions(3), 11 | psql.WithPastPartitions(1), 12 | psql.WithJobPeriod(1*time.Hour), 13 | ) 14 | 15 | // Migrate the database: create tables and indexes, remove old partitions. 16 | if err := psqlRepo.Migrate(ctx); err != nil { 17 | log.Fatalf("Failed to migrate database: %v", err) 18 | } 19 | 20 | // Run partioning job: create future partitions and remove old ones. 21 | go func() { 22 | if err := psqlRepo.RunPartitioningJob(ctx); err != nil { 23 | log.Fatalf("Failed to run partitioning job: %v", err) 24 | } 25 | }() 26 | */ 27 | package psql 28 | -------------------------------------------------------------------------------- /adapter/psql/options.go: -------------------------------------------------------------------------------- 1 | package psql 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "go.opentelemetry.io/otel" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | type options struct { 12 | tracer trace.Tracer 13 | tableName string 14 | futurePartitions uint // how many future partitions to create 15 | pastPartitions uint // how many past partitions to keep 16 | jobPeriod time.Duration 17 | errorHandler func(error) 18 | } 19 | 20 | type Option func(*options) 21 | 22 | // WithOtelTracer sets the OpenTelemetry tracer for the outbox. 23 | // By default, the default OpenTelemetry tracer is used. 24 | func WithOtelTracer(tracer trace.Tracer) Option { 25 | return func(o *options) { 26 | if tracer != nil { 27 | o.tracer = tracer 28 | } 29 | } 30 | } 31 | 32 | // WithTableName sets the table name for the outbox messages. 33 | // Default is "outbox_messages". 34 | func WithTableName(name string) Option { 35 | return func(o *options) { 36 | o.tableName = name 37 | } 38 | } 39 | 40 | // WithFuturePartitions sets the number of future partitions to create including the current one. 41 | // Default is 2. 42 | func WithFuturePartitions(n uint) Option { 43 | return func(o *options) { 44 | if n > 0 { 45 | o.futurePartitions = n 46 | } 47 | } 48 | } 49 | 50 | // WithPastPartitions sets the number of past partitions to keep. 51 | // Default is 1. 52 | func WithPastPartitions(n uint) Option { 53 | return func(o *options) { 54 | o.pastPartitions = n 55 | } 56 | } 57 | 58 | // WithJobPeriod sets the job period for partition management. 59 | // Default is 1 hour. 60 | func WithJobPeriod(d time.Duration) Option { 61 | return func(o *options) { 62 | if d > 0 { 63 | o.jobPeriod = d 64 | } 65 | } 66 | } 67 | 68 | // WithErrorHandler sets the error handler. 69 | // The error handler is called when an error occurs while creating partitions. 70 | // The default error handler logs the error to the standard logger. 71 | // Be careful, it blocks the job's loop. 72 | func WithErrorHandler(handler func(error)) Option { 73 | return func(o *options) { 74 | o.errorHandler = handler 75 | } 76 | } 77 | 78 | // applyOptions applies the provided options to the default options. 79 | func applyOptions(opts ...Option) options { 80 | defualtErrorHandler := func(err error) { 81 | log.Println(err) 82 | } 83 | 84 | defaultOpts := options{ 85 | tracer: otel.GetTracerProvider().Tracer("github.com/Darkemon/troutbox/adapter/psql"), 86 | tableName: "outbox_messages", 87 | futurePartitions: 2, 88 | pastPartitions: 1, 89 | jobPeriod: time.Hour, 90 | errorHandler: defualtErrorHandler, 91 | } 92 | 93 | for _, opt := range opts { 94 | opt(&defaultOpts) 95 | } 96 | 97 | return defaultOpts 98 | } 99 | -------------------------------------------------------------------------------- /adapter/psql/psql.go: -------------------------------------------------------------------------------- 1 | package psql 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/lib/pq" 11 | "go.opentelemetry.io/otel/attribute" 12 | "go.opentelemetry.io/otel/codes" 13 | "go.opentelemetry.io/otel/trace" 14 | 15 | "github.com/Darkemon/troutbox" 16 | ) 17 | 18 | var _ troutbox.TransactionalMessageRepository[*sql.Tx] = (*PostgresMessageRepository)(nil) 19 | 20 | type querier interface { 21 | ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) 22 | QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) 23 | QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row 24 | } 25 | 26 | // PostgresMessageRepository is a PostgreSQL implementation of the outbox.MessageRepository interface. 27 | type PostgresMessageRepository struct { 28 | db *sql.DB 29 | q querier 30 | lockID int64 // a random lock ID for advisory locks 31 | opts options 32 | } 33 | 34 | // NewPostgresMessageRepository creates a new PostgresMessageRepository instance. 35 | // It takes a *sql.DB instance and a lock ID for advisory locks. If you have multiple 36 | // instances of the outbox running, they should use the same lock ID to avoid conflicts. 37 | // It panics if the db is nil. 38 | func NewPostgresMessageRepository(db *sql.DB, lockID int64, opts ...Option) *PostgresMessageRepository { 39 | if db == nil { 40 | panic("db cannot be nil") 41 | } 42 | 43 | r := PostgresMessageRepository{ 44 | db: db, 45 | q: db, 46 | lockID: lockID, 47 | } 48 | 49 | r.opts = applyOptions(opts...) 50 | 51 | return &r 52 | } 53 | 54 | // Migrate sets up the outbox table. The table is properly partitioned by timestamp with necessary indexes. 55 | // Additionally, it creates future partitions and removes outdated ones. 56 | func (r *PostgresMessageRepository) Migrate(ctx context.Context) error { 57 | query := fmt.Sprintf(` 58 | CREATE TABLE IF NOT EXISTS %s ( 59 | id BIGSERIAL NOT NULL, 60 | key TEXT NOT NULL, 61 | value BYTEA NOT NULL, 62 | timestamp TIMESTAMP NOT NULL, 63 | retries INT NOT NULL DEFAULT 0, 64 | status SMALLINT NOT NULL DEFAULT 0, 65 | PRIMARY KEY (id, timestamp) 66 | ) PARTITION BY RANGE (timestamp); 67 | 68 | CREATE INDEX IF NOT EXISTS idx_%s_timestamp ON %s (timestamp); 69 | CREATE INDEX IF NOT EXISTS idx_%s_status ON %s (status); 70 | `, r.opts.tableName, r.opts.tableName, r.opts.tableName, r.opts.tableName, r.opts.tableName) 71 | 72 | _, err := r.q.ExecContext(ctx, query) 73 | if err != nil { 74 | return fmt.Errorf("failed to migrate table %s: %w", r.opts.tableName, err) 75 | } 76 | 77 | return r.updatePartitions(ctx, "public") 78 | } 79 | 80 | // RunPartitioningJob runs a partitioning job, the call is blocking, stops when the context is done. 81 | // On each run it creates a few partitions in the future and removes old ones. 82 | // It uses a distributed lock to ensure that only one instance creates partitions at a time. 83 | func (r *PostgresMessageRepository) RunPartitioningJob(ctx context.Context) error { 84 | select { 85 | case <-ctx.Done(): 86 | return ctx.Err() 87 | default: 88 | } 89 | 90 | ticker := time.NewTicker(r.opts.jobPeriod) 91 | defer ticker.Stop() 92 | 93 | for { 94 | select { 95 | case <-ctx.Done(): 96 | err := ctx.Err() 97 | if errors.Is(err, context.Canceled) { 98 | return nil 99 | } 100 | return err 101 | case <-ticker.C: 102 | if err := r.updatePartitions(ctx, "public"); err != nil { 103 | r.opts.errorHandler(fmt.Errorf("failed to update partitions: %w", err)) 104 | } 105 | } 106 | } 107 | } 108 | 109 | // FetchAndLock fetches a batch of messages and locks them for processing. 110 | func (r *PostgresMessageRepository) FetchAndLock(ctx context.Context, batchSize uint) ([]troutbox.Message, error) { 111 | query := fmt.Sprintf(` 112 | SELECT id, key, value, timestamp, retries 113 | FROM %s 114 | WHERE status = %d 115 | ORDER BY timestamp ASC 116 | LIMIT $1 117 | FOR UPDATE SKIP LOCKED 118 | `, r.opts.tableName, troutbox.StatusNone) 119 | 120 | rows, err := r.q.QueryContext(ctx, query, batchSize) 121 | if err != nil { 122 | return nil, fmt.Errorf("query failed: %w", err) 123 | } 124 | 125 | defer func() { 126 | if closeErr := rows.Close(); closeErr != nil { 127 | err = errors.Join(err, fmt.Errorf("failed to close rows: %w", closeErr)) 128 | } 129 | }() 130 | 131 | messages := make([]troutbox.Message, 0, batchSize) 132 | 133 | for rows.Next() { 134 | var msg troutbox.Message 135 | if scanErr := rows.Scan(&msg.ID, &msg.Key, &msg.Value, &msg.Timestamp, &msg.Retries); scanErr != nil { 136 | err = fmt.Errorf("failed to scan message: %w", scanErr) 137 | return nil, err 138 | } 139 | messages = append(messages, msg) 140 | } 141 | 142 | if rowsErr := rows.Err(); rowsErr != nil { 143 | err = fmt.Errorf("failed to iterate over rows: %w", rowsErr) 144 | return nil, err 145 | } 146 | 147 | return messages, nil 148 | } 149 | 150 | // UpdateRetryCount increments the retry count for the given message IDs. 151 | func (r *PostgresMessageRepository) UpdateRetryCount(ctx context.Context, ids []uint64) error { 152 | query := fmt.Sprintf(` 153 | UPDATE %s 154 | SET retries = retries + 1 155 | WHERE id = ANY($1) 156 | `, r.opts.tableName) 157 | 158 | _, err := r.q.ExecContext(ctx, query, pq.Array(ids)) 159 | 160 | return err 161 | } 162 | 163 | // MarkAsDead marks the given message IDs as dead. 164 | func (r *PostgresMessageRepository) MarkAsDead(ctx context.Context, ids []uint64) error { 165 | query := fmt.Sprintf(` 166 | UPDATE %s 167 | SET status = %d 168 | WHERE id = ANY($1) 169 | `, r.opts.tableName, troutbox.StatusDead) 170 | 171 | _, err := r.q.ExecContext(ctx, query, pq.Array(ids)) 172 | 173 | return err 174 | } 175 | 176 | // MarkAsSent marks the given message IDs as sent. 177 | func (r *PostgresMessageRepository) MarkAsSent(ctx context.Context, ids []uint64) error { 178 | query := fmt.Sprintf(` 179 | UPDATE %s 180 | SET status = %d 181 | WHERE id = ANY($1) 182 | `, r.opts.tableName, troutbox.StatusSent) 183 | 184 | _, err := r.q.ExecContext(ctx, query, pq.Array(ids)) 185 | 186 | return err 187 | } 188 | 189 | // AddMessage adds a new message to the outbox. 190 | // If tx is nil, it will use the db of the repository, otherwise it will use the provided transaction. 191 | func (r *PostgresMessageRepository) AddMessage(ctx context.Context, tx *sql.Tx, msg troutbox.Message) error { 192 | query := fmt.Sprintf(` 193 | INSERT INTO %s (key, value, timestamp) 194 | VALUES ($1, $2, $3) 195 | `, r.opts.tableName) 196 | 197 | var err error 198 | 199 | if tx != nil { 200 | _, err = tx.ExecContext(ctx, query, msg.Key, msg.Value, msg.Timestamp) 201 | } else { 202 | _, err = r.q.ExecContext(ctx, query, msg.Key, msg.Value, msg.Timestamp) 203 | } 204 | 205 | return err 206 | } 207 | 208 | // WithTransaction executes a function within a transaction. 209 | func (r *PostgresMessageRepository) WithTransaction(ctx context.Context, cb troutbox.InTransactionFn) error { 210 | tx, err := r.db.BeginTx(ctx, nil) 211 | if err != nil { 212 | return fmt.Errorf("failed to begin transaction: %w", err) 213 | } 214 | 215 | repoCopy := *r 216 | repoCopy.q = tx 217 | 218 | if err := cb(ctx, &repoCopy); err != nil { 219 | if rollbackErr := tx.Rollback(); rollbackErr != nil { 220 | return fmt.Errorf("failed to rollback transaction: %w", rollbackErr) 221 | } 222 | return err 223 | } 224 | 225 | if err := tx.Commit(); err != nil { 226 | return fmt.Errorf("failed to commit transaction: %w", err) 227 | } 228 | 229 | return nil 230 | } 231 | 232 | // GetPartitionName generates a partition name in the format _. 233 | func (r *PostgresMessageRepository) GetPartitionName(startTime time.Time) string { 234 | return fmt.Sprintf("%s_%s", r.opts.tableName, startTime.Format("20060102")) 235 | } 236 | 237 | // updatePartitions creates multiple partitions for future time ranges and removes old partitions. 238 | // It uses disitributed locks to ensure that only one instance creates partitions at a time. 239 | func (r *PostgresMessageRepository) updatePartitions( 240 | ctx context.Context, 241 | schemaName string, 242 | ) error { 243 | var errs error 244 | 245 | ctx, span := r.opts.tracer.Start(ctx, "update partitions") 246 | defer func() { 247 | if errs != nil { 248 | span.RecordError(errs) 249 | span.SetStatus(codes.Error, "failed to update partitions") 250 | } 251 | span.End() 252 | }() 253 | 254 | daysPeriod := 1 // TODO: make it configurable? 255 | startTime := time.Now().UTC().Truncate(24 * time.Hour) // current day at midnight 256 | period := time.Duration(daysPeriod) * 24 * time.Hour 257 | 258 | acquired, errs := r.acquireLock(ctx) 259 | if errs != nil { 260 | return fmt.Errorf("failed to acquire lock: %w", errs) 261 | } 262 | 263 | if !acquired { 264 | span.AddEvent("lock already held by another instance") 265 | return nil 266 | } 267 | 268 | for i := uint(0); i < r.opts.futurePartitions; i++ { 269 | partitionStart := startTime.Add(time.Duration(i) * period) 270 | partitionEnd := partitionStart.Add(period) 271 | 272 | if err := r.CreatePartition(ctx, partitionStart, partitionEnd); err != nil { 273 | errs = errors.Join(errs, fmt.Errorf("failed to create partition: %w", err)) 274 | break 275 | } 276 | } 277 | 278 | currentPartition := r.GetPartitionName(startTime) 279 | 280 | if err := r.removeOldPartitions(ctx, schemaName, currentPartition); err != nil { 281 | errs = errors.Join( 282 | errs, 283 | fmt.Errorf("failed to remove old partitions: %w", err), 284 | ) 285 | } 286 | 287 | if err := r.releaseLock(ctx); err != nil { 288 | errs = errors.Join(errs, fmt.Errorf("failed to release lock: %w", err)) 289 | } 290 | 291 | return errs 292 | } 293 | 294 | // CreatePartition creates a new partition if not exists for the given time range. 295 | // It doesn't use any locks, so it should be called only from the partitioning job. 296 | func (r *PostgresMessageRepository) CreatePartition(ctx context.Context, startTime, endTime time.Time) error { 297 | partitionName := r.GetPartitionName(startTime) 298 | 299 | var err error 300 | 301 | ctx, span := r.opts.tracer.Start( 302 | ctx, 303 | "create partition", 304 | trace.WithAttributes(attribute.String("partition_name", partitionName)), 305 | ) 306 | defer func() { 307 | if err != nil { 308 | span.RecordError(err) 309 | span.SetStatus(codes.Error, "failed to create partition") 310 | } 311 | span.End() 312 | }() 313 | 314 | from := startTime.Format("2006-01-02 15:04:05") 315 | to := endTime.Format("2006-01-02 15:04:05") 316 | query := fmt.Sprintf(` 317 | CREATE TABLE IF NOT EXISTS %s PARTITION OF %s 318 | FOR VALUES FROM ('%s') TO ('%s'); 319 | `, partitionName, r.opts.tableName, from, to) 320 | 321 | if _, err = r.q.ExecContext(ctx, query); err != nil { 322 | err = fmt.Errorf("partition %s [%s - %s]: %w", partitionName, from, to, err) 323 | } 324 | 325 | return nil 326 | } 327 | 328 | // removeOldPartitions takes outdated partitions, keeps the most N recent ones and removes the rest. 329 | func (r *PostgresMessageRepository) removeOldPartitions( 330 | ctx context.Context, 331 | schemaName, 332 | currentPartition string, 333 | ) error { 334 | var err error 335 | 336 | ctx, span := r.opts.tracer.Start(ctx, "remove old partitions") 337 | defer func() { 338 | if err != nil { 339 | span.RecordError(err) 340 | span.SetStatus(codes.Error, "failed to remove old partitions") 341 | } 342 | span.End() 343 | }() 344 | 345 | query := fmt.Sprintf(` 346 | DO $$ 347 | DECLARE 348 | partition_name TEXT; 349 | BEGIN 350 | FOR partition_name IN 351 | SELECT tablename 352 | FROM pg_tables 353 | WHERE schemaname = '%s' 354 | AND tablename LIKE '%s_%%' 355 | AND tablename < '%s' 356 | ORDER BY tablename DESC 357 | OFFSET %d 358 | LOOP 359 | EXECUTE 'DROP TABLE IF EXISTS ' || partition_name; 360 | END LOOP; 361 | END $$; 362 | `, schemaName, r.opts.tableName, currentPartition, r.opts.pastPartitions) 363 | 364 | _, err = r.q.ExecContext(ctx, query) 365 | return err 366 | } 367 | 368 | // acquireLock acquires a distributed lock using PostgreSQL's pg_advisory_lock. 369 | // Reutrns true if the lock was acquired, false if it was already held by another instance. 370 | func (r *PostgresMessageRepository) acquireLock(ctx context.Context) (bool, error) { 371 | query := "SELECT pg_try_advisory_lock($1)" 372 | var acquired bool 373 | err := r.q.QueryRowContext(ctx, query, r.lockID).Scan(&acquired) 374 | return acquired, err 375 | } 376 | 377 | // releaseLock releases a distributed lock using PostgreSQL's pg_advisory_unlock. 378 | func (r *PostgresMessageRepository) releaseLock(ctx context.Context) error { 379 | query := "SELECT pg_advisory_unlock($1)" 380 | if _, err := r.q.ExecContext(ctx, query, r.lockID); err != nil { 381 | return err 382 | } 383 | return nil 384 | } 385 | -------------------------------------------------------------------------------- /examples/postgres_rabbitmq/Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | help: ## Show this help message 4 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 5 | 6 | infra-up: ## Start the infrastructure 7 | docker compose up -d 8 | @echo "" 9 | @echo "To see the RabbitMQ management interface go to:" 10 | @echo "http://guest:guest@localhost:15672" 11 | @echo "" 12 | 13 | infra-down: ## Stop the infrastructure 14 | docker compose down 15 | -------------------------------------------------------------------------------- /examples/postgres_rabbitmq/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | infra: 3 | image: busybox:1.35.0-uclibc 4 | entrypoint: ["echo", "ready"] 5 | depends_on: 6 | postgres: 7 | condition: service_healthy 8 | rabbitmq: 9 | condition: service_healthy 10 | 11 | postgres: 12 | image: postgres:15 13 | environment: 14 | POSTGRES_USER: postgres 15 | POSTGRES_PASSWORD: postgres 16 | POSTGRES_DB: test_db 17 | ports: 18 | - "5432:5432" 19 | healthcheck: 20 | test: [ "CMD", "pg_isready", "-U", "postgres" ] 21 | interval: 1s 22 | timeout: 3s 23 | retries: 10 24 | 25 | rabbitmq: 26 | image: rabbitmq:3-management 27 | ports: 28 | - "5672:5672" 29 | - "15672:15672" 30 | healthcheck: 31 | test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] 32 | interval: 1s 33 | timeout: 3s 34 | retries: 10 35 | -------------------------------------------------------------------------------- /examples/postgres_rabbitmq/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Darkemon/troutbox/examples/postgresrabbitmq 2 | 3 | go 1.24.0 4 | 5 | replace github.com/Darkemon/troutbox => ../.. 6 | 7 | require ( 8 | github.com/Darkemon/troutbox v0.0.0 9 | github.com/lib/pq v1.10.9 10 | github.com/rabbitmq/amqp091-go v1.10.0 11 | ) 12 | 13 | require ( 14 | github.com/go-logr/logr v1.4.2 // indirect 15 | github.com/go-logr/stdr v1.2.2 // indirect 16 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 17 | go.opentelemetry.io/otel v1.36.0 // indirect 18 | go.opentelemetry.io/otel/metric v1.36.0 // indirect 19 | go.opentelemetry.io/otel/trace v1.36.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /examples/postgres_rabbitmq/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 2 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 4 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 5 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 6 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 7 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 8 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 9 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 10 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 11 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 12 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 13 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= 15 | github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= 16 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 17 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 18 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 19 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 20 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 21 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 22 | go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 23 | go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 24 | go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= 25 | go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= 26 | go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 27 | go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 28 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 29 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 30 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 31 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 32 | -------------------------------------------------------------------------------- /examples/postgres_rabbitmq/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log" 8 | "math/rand" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | "time" 13 | 14 | _ "github.com/lib/pq" 15 | amqp "github.com/rabbitmq/amqp091-go" 16 | ) 17 | 18 | // addMessages simulates adding messages to the outbox in a loop. 19 | // Stops when the context is done. 20 | func addMessages(ctx context.Context, outboxService *Outbox) { 21 | timer := time.NewTimer(time.Second) 22 | defer timer.Stop() 23 | 24 | for i := 0; true; i++ { 25 | select { 26 | case <-ctx.Done(): 27 | log.Println("Received shutdown signal, stop adding messages...") 28 | return 29 | case <-timer.C: 30 | tr := (*sql.Tx)(nil) // replace with actual transaction if needed 31 | val := fmt.Sprintf("message-%d", i) 32 | 33 | if err := outboxService.AddMessage(ctx, tr, "key", []byte(val)); err != nil { 34 | log.Printf("Failed to add message to outbox: %v", err) 35 | } else { 36 | log.Printf("Added message to outbox: %s", val) 37 | } 38 | 39 | // Reset the timer for the next message. 40 | timer.Reset(time.Duration(500+rand.Intn(1500)) * time.Millisecond) 41 | } 42 | } 43 | } 44 | 45 | func main() { 46 | log.Println("Connecting to PostgreSQL...") 47 | db, err := sql.Open("postgres", "user=postgres password=postgres dbname=test_db sslmode=disable") 48 | if err != nil { 49 | log.Fatalf("Failed to connect to PostgreSQL: %v", err) 50 | } 51 | defer db.Close() 52 | 53 | log.Println("Connecting to RabbitMQ...") 54 | rabbitConn, err := amqp.Dial("amqp://guest:guest@localhost:5672/") 55 | if err != nil { 56 | log.Fatalf("Failed to connect to RabbitMQ: %v", err) 57 | } 58 | defer rabbitConn.Close() 59 | 60 | rabbitSender, err := NewRabbitMQSender(rabbitConn, "outbox_queue") 61 | if err != nil { 62 | log.Fatalf("Failed to create RabbitMQ sender: %v", err) 63 | } 64 | 65 | outboxService, err := NewOutbox(db, rabbitSender) 66 | if err != nil { 67 | log.Fatalf("Failed to create outbox service: %v", err) 68 | } 69 | 70 | // Set up signal handling for graceful shutdown. 71 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 72 | defer cancel() 73 | 74 | go addMessages(ctx, outboxService) 75 | 76 | log.Println("Starting outbox service...") 77 | if err := outboxService.Run(ctx); err != nil { 78 | log.Fatalf("Outbox service failed: %v", err) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /examples/postgres_rabbitmq/outbox.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/Darkemon/troutbox" 10 | "github.com/Darkemon/troutbox/adapter/psql" 11 | ) 12 | 13 | const lockID = 42 14 | 15 | type Outbox struct { 16 | *troutbox.Outbox[*sql.Tx] 17 | repo *psql.PostgresMessageRepository 18 | } 19 | 20 | func NewOutbox(db *sql.DB, sender troutbox.Sender) (*Outbox, error) { 21 | o := Outbox{} 22 | 23 | // Create the PostgreSQL adapter. 24 | o.repo = psql.NewPostgresMessageRepository( 25 | db, 26 | lockID, 27 | psql.WithTableName("outbox_messages"), 28 | psql.WithFuturePartitions(3), 29 | psql.WithPastPartitions(1), 30 | psql.WithJobPeriod(1*time.Hour), 31 | ) 32 | 33 | outboxService, err := troutbox.NewOutbox( 34 | o.repo, 35 | sender, 36 | troutbox.WithPeriod(time.Second), 37 | troutbox.WithBatchSize(10), 38 | troutbox.WithMaxRetries(5), 39 | troutbox.WithSendTimeout(5*time.Second), 40 | ) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | o.Outbox = outboxService 46 | 47 | return &o, nil 48 | } 49 | 50 | func (o *Outbox) Run(ctx context.Context) error { 51 | err := o.repo.Migrate(ctx) 52 | if err != nil { 53 | return fmt.Errorf("failed to migrate: %w", err) 54 | } 55 | 56 | // Run partitioning job. 57 | go func() { 58 | _ = o.repo.RunPartitioningJob(ctx) 59 | }() 60 | 61 | return o.Run(ctx) 62 | } 63 | -------------------------------------------------------------------------------- /examples/postgres_rabbitmq/sender.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | amqp "github.com/rabbitmq/amqp091-go" 7 | 8 | "github.com/Darkemon/troutbox" 9 | ) 10 | 11 | var _ troutbox.Sender = (*RabbitMQSender)(nil) 12 | 13 | // RabbitMQSender implements the Sender interface for RabbitMQ. 14 | type RabbitMQSender struct { 15 | channel *amqp.Channel 16 | queue string 17 | } 18 | 19 | // NewRabbitMQSender creates a new RabbitMQSender. 20 | func NewRabbitMQSender(conn *amqp.Connection, queue string) (*RabbitMQSender, error) { 21 | channel, err := conn.Channel() 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | // Declare the queue 27 | _, err = channel.QueueDeclare( 28 | queue, // name 29 | true, // durable 30 | false, // autoDelete 31 | false, // exclusive 32 | false, // noWait 33 | nil, // arguments 34 | ) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | return &RabbitMQSender{ 40 | channel: channel, 41 | queue: queue, 42 | }, nil 43 | } 44 | 45 | // Send sends a batch of messages to RabbitMQ. 46 | func (s *RabbitMQSender) Send(ctx context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 47 | for i, msg := range messages { 48 | err := s.channel.Publish( 49 | "", // exchange 50 | s.queue, // routing key 51 | false, // mandatory 52 | false, // immediate 53 | amqp.Publishing{ 54 | ContentType: "application/json", 55 | Body: msg.Value, 56 | }, 57 | ) 58 | if err != nil { 59 | messages[i].MarkAsFailed() 60 | } else { 61 | messages[i].MarkAsSent() 62 | } 63 | } 64 | return messages, nil 65 | } 66 | -------------------------------------------------------------------------------- /export_test.go: -------------------------------------------------------------------------------- 1 | package troutbox 2 | 3 | import "context" 4 | 5 | func (o *Outbox[T]) SendBatch(ctx context.Context, s MessageRepository) error { 6 | return o.sendBatch(ctx, s) 7 | } 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Darkemon/troutbox 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/lib/pq v1.10.9 7 | github.com/stretchr/testify v1.10.0 8 | go.opentelemetry.io/otel v1.36.0 9 | go.opentelemetry.io/otel/metric v1.36.0 10 | go.opentelemetry.io/otel/trace v1.36.0 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 15 | github.com/go-logr/logr v1.4.2 // indirect 16 | github.com/go-logr/stdr v1.2.2 // indirect 17 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 18 | github.com/rogpeppe/go-internal v1.14.1 // indirect 19 | github.com/stretchr/objx v0.5.2 // indirect 20 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 21 | gopkg.in/yaml.v3 v3.0.1 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 2 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 4 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 5 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 6 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 7 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 8 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 9 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 10 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 11 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 12 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 13 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 14 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 15 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 16 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 17 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 18 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 19 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 20 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 21 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 22 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 23 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 24 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 25 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 26 | go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 27 | go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 28 | go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= 29 | go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= 30 | go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 31 | go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 32 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 33 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 34 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 35 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | -------------------------------------------------------------------------------- /internal/mocks/outbox_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package mocks 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/Darkemon/troutbox" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // NewMockSender creates a new instance of MockSender. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func NewMockSender(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *MockSender { 20 | mock := &MockSender{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // MockSender is an autogenerated mock type for the Sender type 29 | type MockSender struct { 30 | mock.Mock 31 | } 32 | 33 | type MockSender_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *MockSender) EXPECT() *MockSender_Expecter { 38 | return &MockSender_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // Send provides a mock function for the type MockSender 42 | func (_mock *MockSender) Send(ctx context.Context, msg []troutbox.Message) ([]troutbox.Message, error) { 43 | ret := _mock.Called(ctx, msg) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for Send") 47 | } 48 | 49 | var r0 []troutbox.Message 50 | var r1 error 51 | if returnFunc, ok := ret.Get(0).(func(context.Context, []troutbox.Message) ([]troutbox.Message, error)); ok { 52 | return returnFunc(ctx, msg) 53 | } 54 | if returnFunc, ok := ret.Get(0).(func(context.Context, []troutbox.Message) []troutbox.Message); ok { 55 | r0 = returnFunc(ctx, msg) 56 | } else { 57 | if ret.Get(0) != nil { 58 | r0 = ret.Get(0).([]troutbox.Message) 59 | } 60 | } 61 | if returnFunc, ok := ret.Get(1).(func(context.Context, []troutbox.Message) error); ok { 62 | r1 = returnFunc(ctx, msg) 63 | } else { 64 | r1 = ret.Error(1) 65 | } 66 | return r0, r1 67 | } 68 | 69 | // MockSender_Send_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Send' 70 | type MockSender_Send_Call struct { 71 | *mock.Call 72 | } 73 | 74 | // Send is a helper method to define mock.On call 75 | // - ctx 76 | // - msg 77 | func (_e *MockSender_Expecter) Send(ctx interface{}, msg interface{}) *MockSender_Send_Call { 78 | return &MockSender_Send_Call{Call: _e.mock.On("Send", ctx, msg)} 79 | } 80 | 81 | func (_c *MockSender_Send_Call) Run(run func(ctx context.Context, msg []troutbox.Message)) *MockSender_Send_Call { 82 | _c.Call.Run(func(args mock.Arguments) { 83 | run(args[0].(context.Context), args[1].([]troutbox.Message)) 84 | }) 85 | return _c 86 | } 87 | 88 | func (_c *MockSender_Send_Call) Return(messages []troutbox.Message, err error) *MockSender_Send_Call { 89 | _c.Call.Return(messages, err) 90 | return _c 91 | } 92 | 93 | func (_c *MockSender_Send_Call) RunAndReturn(run func(ctx context.Context, msg []troutbox.Message) ([]troutbox.Message, error)) *MockSender_Send_Call { 94 | _c.Call.Return(run) 95 | return _c 96 | } 97 | 98 | // NewMockMessageRepository creates a new instance of MockMessageRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 99 | // The first argument is typically a *testing.T value. 100 | func NewMockMessageRepository(t interface { 101 | mock.TestingT 102 | Cleanup(func()) 103 | }) *MockMessageRepository { 104 | mock := &MockMessageRepository{} 105 | mock.Mock.Test(t) 106 | 107 | t.Cleanup(func() { mock.AssertExpectations(t) }) 108 | 109 | return mock 110 | } 111 | 112 | // MockMessageRepository is an autogenerated mock type for the MessageRepository type 113 | type MockMessageRepository struct { 114 | mock.Mock 115 | } 116 | 117 | type MockMessageRepository_Expecter struct { 118 | mock *mock.Mock 119 | } 120 | 121 | func (_m *MockMessageRepository) EXPECT() *MockMessageRepository_Expecter { 122 | return &MockMessageRepository_Expecter{mock: &_m.Mock} 123 | } 124 | 125 | // FetchAndLock provides a mock function for the type MockMessageRepository 126 | func (_mock *MockMessageRepository) FetchAndLock(ctx context.Context, batchSize uint) ([]troutbox.Message, error) { 127 | ret := _mock.Called(ctx, batchSize) 128 | 129 | if len(ret) == 0 { 130 | panic("no return value specified for FetchAndLock") 131 | } 132 | 133 | var r0 []troutbox.Message 134 | var r1 error 135 | if returnFunc, ok := ret.Get(0).(func(context.Context, uint) ([]troutbox.Message, error)); ok { 136 | return returnFunc(ctx, batchSize) 137 | } 138 | if returnFunc, ok := ret.Get(0).(func(context.Context, uint) []troutbox.Message); ok { 139 | r0 = returnFunc(ctx, batchSize) 140 | } else { 141 | if ret.Get(0) != nil { 142 | r0 = ret.Get(0).([]troutbox.Message) 143 | } 144 | } 145 | if returnFunc, ok := ret.Get(1).(func(context.Context, uint) error); ok { 146 | r1 = returnFunc(ctx, batchSize) 147 | } else { 148 | r1 = ret.Error(1) 149 | } 150 | return r0, r1 151 | } 152 | 153 | // MockMessageRepository_FetchAndLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FetchAndLock' 154 | type MockMessageRepository_FetchAndLock_Call struct { 155 | *mock.Call 156 | } 157 | 158 | // FetchAndLock is a helper method to define mock.On call 159 | // - ctx 160 | // - batchSize 161 | func (_e *MockMessageRepository_Expecter) FetchAndLock(ctx interface{}, batchSize interface{}) *MockMessageRepository_FetchAndLock_Call { 162 | return &MockMessageRepository_FetchAndLock_Call{Call: _e.mock.On("FetchAndLock", ctx, batchSize)} 163 | } 164 | 165 | func (_c *MockMessageRepository_FetchAndLock_Call) Run(run func(ctx context.Context, batchSize uint)) *MockMessageRepository_FetchAndLock_Call { 166 | _c.Call.Run(func(args mock.Arguments) { 167 | run(args[0].(context.Context), args[1].(uint)) 168 | }) 169 | return _c 170 | } 171 | 172 | func (_c *MockMessageRepository_FetchAndLock_Call) Return(messages []troutbox.Message, err error) *MockMessageRepository_FetchAndLock_Call { 173 | _c.Call.Return(messages, err) 174 | return _c 175 | } 176 | 177 | func (_c *MockMessageRepository_FetchAndLock_Call) RunAndReturn(run func(ctx context.Context, batchSize uint) ([]troutbox.Message, error)) *MockMessageRepository_FetchAndLock_Call { 178 | _c.Call.Return(run) 179 | return _c 180 | } 181 | 182 | // MarkAsDead provides a mock function for the type MockMessageRepository 183 | func (_mock *MockMessageRepository) MarkAsDead(ctx context.Context, ids []uint64) error { 184 | ret := _mock.Called(ctx, ids) 185 | 186 | if len(ret) == 0 { 187 | panic("no return value specified for MarkAsDead") 188 | } 189 | 190 | var r0 error 191 | if returnFunc, ok := ret.Get(0).(func(context.Context, []uint64) error); ok { 192 | r0 = returnFunc(ctx, ids) 193 | } else { 194 | r0 = ret.Error(0) 195 | } 196 | return r0 197 | } 198 | 199 | // MockMessageRepository_MarkAsDead_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkAsDead' 200 | type MockMessageRepository_MarkAsDead_Call struct { 201 | *mock.Call 202 | } 203 | 204 | // MarkAsDead is a helper method to define mock.On call 205 | // - ctx 206 | // - ids 207 | func (_e *MockMessageRepository_Expecter) MarkAsDead(ctx interface{}, ids interface{}) *MockMessageRepository_MarkAsDead_Call { 208 | return &MockMessageRepository_MarkAsDead_Call{Call: _e.mock.On("MarkAsDead", ctx, ids)} 209 | } 210 | 211 | func (_c *MockMessageRepository_MarkAsDead_Call) Run(run func(ctx context.Context, ids []uint64)) *MockMessageRepository_MarkAsDead_Call { 212 | _c.Call.Run(func(args mock.Arguments) { 213 | run(args[0].(context.Context), args[1].([]uint64)) 214 | }) 215 | return _c 216 | } 217 | 218 | func (_c *MockMessageRepository_MarkAsDead_Call) Return(err error) *MockMessageRepository_MarkAsDead_Call { 219 | _c.Call.Return(err) 220 | return _c 221 | } 222 | 223 | func (_c *MockMessageRepository_MarkAsDead_Call) RunAndReturn(run func(ctx context.Context, ids []uint64) error) *MockMessageRepository_MarkAsDead_Call { 224 | _c.Call.Return(run) 225 | return _c 226 | } 227 | 228 | // MarkAsSent provides a mock function for the type MockMessageRepository 229 | func (_mock *MockMessageRepository) MarkAsSent(ctx context.Context, ids []uint64) error { 230 | ret := _mock.Called(ctx, ids) 231 | 232 | if len(ret) == 0 { 233 | panic("no return value specified for MarkAsSent") 234 | } 235 | 236 | var r0 error 237 | if returnFunc, ok := ret.Get(0).(func(context.Context, []uint64) error); ok { 238 | r0 = returnFunc(ctx, ids) 239 | } else { 240 | r0 = ret.Error(0) 241 | } 242 | return r0 243 | } 244 | 245 | // MockMessageRepository_MarkAsSent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkAsSent' 246 | type MockMessageRepository_MarkAsSent_Call struct { 247 | *mock.Call 248 | } 249 | 250 | // MarkAsSent is a helper method to define mock.On call 251 | // - ctx 252 | // - ids 253 | func (_e *MockMessageRepository_Expecter) MarkAsSent(ctx interface{}, ids interface{}) *MockMessageRepository_MarkAsSent_Call { 254 | return &MockMessageRepository_MarkAsSent_Call{Call: _e.mock.On("MarkAsSent", ctx, ids)} 255 | } 256 | 257 | func (_c *MockMessageRepository_MarkAsSent_Call) Run(run func(ctx context.Context, ids []uint64)) *MockMessageRepository_MarkAsSent_Call { 258 | _c.Call.Run(func(args mock.Arguments) { 259 | run(args[0].(context.Context), args[1].([]uint64)) 260 | }) 261 | return _c 262 | } 263 | 264 | func (_c *MockMessageRepository_MarkAsSent_Call) Return(err error) *MockMessageRepository_MarkAsSent_Call { 265 | _c.Call.Return(err) 266 | return _c 267 | } 268 | 269 | func (_c *MockMessageRepository_MarkAsSent_Call) RunAndReturn(run func(ctx context.Context, ids []uint64) error) *MockMessageRepository_MarkAsSent_Call { 270 | _c.Call.Return(run) 271 | return _c 272 | } 273 | 274 | // UpdateRetryCount provides a mock function for the type MockMessageRepository 275 | func (_mock *MockMessageRepository) UpdateRetryCount(ctx context.Context, ids []uint64) error { 276 | ret := _mock.Called(ctx, ids) 277 | 278 | if len(ret) == 0 { 279 | panic("no return value specified for UpdateRetryCount") 280 | } 281 | 282 | var r0 error 283 | if returnFunc, ok := ret.Get(0).(func(context.Context, []uint64) error); ok { 284 | r0 = returnFunc(ctx, ids) 285 | } else { 286 | r0 = ret.Error(0) 287 | } 288 | return r0 289 | } 290 | 291 | // MockMessageRepository_UpdateRetryCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRetryCount' 292 | type MockMessageRepository_UpdateRetryCount_Call struct { 293 | *mock.Call 294 | } 295 | 296 | // UpdateRetryCount is a helper method to define mock.On call 297 | // - ctx 298 | // - ids 299 | func (_e *MockMessageRepository_Expecter) UpdateRetryCount(ctx interface{}, ids interface{}) *MockMessageRepository_UpdateRetryCount_Call { 300 | return &MockMessageRepository_UpdateRetryCount_Call{Call: _e.mock.On("UpdateRetryCount", ctx, ids)} 301 | } 302 | 303 | func (_c *MockMessageRepository_UpdateRetryCount_Call) Run(run func(ctx context.Context, ids []uint64)) *MockMessageRepository_UpdateRetryCount_Call { 304 | _c.Call.Run(func(args mock.Arguments) { 305 | run(args[0].(context.Context), args[1].([]uint64)) 306 | }) 307 | return _c 308 | } 309 | 310 | func (_c *MockMessageRepository_UpdateRetryCount_Call) Return(err error) *MockMessageRepository_UpdateRetryCount_Call { 311 | _c.Call.Return(err) 312 | return _c 313 | } 314 | 315 | func (_c *MockMessageRepository_UpdateRetryCount_Call) RunAndReturn(run func(ctx context.Context, ids []uint64) error) *MockMessageRepository_UpdateRetryCount_Call { 316 | _c.Call.Return(run) 317 | return _c 318 | } 319 | 320 | // NewMockTransactionalMessageRepository creates a new instance of MockTransactionalMessageRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 321 | // The first argument is typically a *testing.T value. 322 | func NewMockTransactionalMessageRepository[T any](t interface { 323 | mock.TestingT 324 | Cleanup(func()) 325 | }) *MockTransactionalMessageRepository[T] { 326 | mock := &MockTransactionalMessageRepository[T]{} 327 | mock.Mock.Test(t) 328 | 329 | t.Cleanup(func() { mock.AssertExpectations(t) }) 330 | 331 | return mock 332 | } 333 | 334 | // MockTransactionalMessageRepository is an autogenerated mock type for the TransactionalMessageRepository type 335 | type MockTransactionalMessageRepository[T any] struct { 336 | mock.Mock 337 | } 338 | 339 | type MockTransactionalMessageRepository_Expecter[T any] struct { 340 | mock *mock.Mock 341 | } 342 | 343 | func (_m *MockTransactionalMessageRepository[T]) EXPECT() *MockTransactionalMessageRepository_Expecter[T] { 344 | return &MockTransactionalMessageRepository_Expecter[T]{mock: &_m.Mock} 345 | } 346 | 347 | // AddMessage provides a mock function for the type MockTransactionalMessageRepository 348 | func (_mock *MockTransactionalMessageRepository[T]) AddMessage(ctx context.Context, tx T, msg troutbox.Message) error { 349 | ret := _mock.Called(ctx, tx, msg) 350 | 351 | if len(ret) == 0 { 352 | panic("no return value specified for AddMessage") 353 | } 354 | 355 | var r0 error 356 | if returnFunc, ok := ret.Get(0).(func(context.Context, T, troutbox.Message) error); ok { 357 | r0 = returnFunc(ctx, tx, msg) 358 | } else { 359 | r0 = ret.Error(0) 360 | } 361 | return r0 362 | } 363 | 364 | // MockTransactionalMessageRepository_AddMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddMessage' 365 | type MockTransactionalMessageRepository_AddMessage_Call[T any] struct { 366 | *mock.Call 367 | } 368 | 369 | // AddMessage is a helper method to define mock.On call 370 | // - ctx 371 | // - tx 372 | // - msg 373 | func (_e *MockTransactionalMessageRepository_Expecter[T]) AddMessage(ctx interface{}, tx interface{}, msg interface{}) *MockTransactionalMessageRepository_AddMessage_Call[T] { 374 | return &MockTransactionalMessageRepository_AddMessage_Call[T]{Call: _e.mock.On("AddMessage", ctx, tx, msg)} 375 | } 376 | 377 | func (_c *MockTransactionalMessageRepository_AddMessage_Call[T]) Run(run func(ctx context.Context, tx T, msg troutbox.Message)) *MockTransactionalMessageRepository_AddMessage_Call[T] { 378 | _c.Call.Run(func(args mock.Arguments) { 379 | run(args[0].(context.Context), args[1].(T), args[2].(troutbox.Message)) 380 | }) 381 | return _c 382 | } 383 | 384 | func (_c *MockTransactionalMessageRepository_AddMessage_Call[T]) Return(err error) *MockTransactionalMessageRepository_AddMessage_Call[T] { 385 | _c.Call.Return(err) 386 | return _c 387 | } 388 | 389 | func (_c *MockTransactionalMessageRepository_AddMessage_Call[T]) RunAndReturn(run func(ctx context.Context, tx T, msg troutbox.Message) error) *MockTransactionalMessageRepository_AddMessage_Call[T] { 390 | _c.Call.Return(run) 391 | return _c 392 | } 393 | 394 | // FetchAndLock provides a mock function for the type MockTransactionalMessageRepository 395 | func (_mock *MockTransactionalMessageRepository[T]) FetchAndLock(ctx context.Context, batchSize uint) ([]troutbox.Message, error) { 396 | ret := _mock.Called(ctx, batchSize) 397 | 398 | if len(ret) == 0 { 399 | panic("no return value specified for FetchAndLock") 400 | } 401 | 402 | var r0 []troutbox.Message 403 | var r1 error 404 | if returnFunc, ok := ret.Get(0).(func(context.Context, uint) ([]troutbox.Message, error)); ok { 405 | return returnFunc(ctx, batchSize) 406 | } 407 | if returnFunc, ok := ret.Get(0).(func(context.Context, uint) []troutbox.Message); ok { 408 | r0 = returnFunc(ctx, batchSize) 409 | } else { 410 | if ret.Get(0) != nil { 411 | r0 = ret.Get(0).([]troutbox.Message) 412 | } 413 | } 414 | if returnFunc, ok := ret.Get(1).(func(context.Context, uint) error); ok { 415 | r1 = returnFunc(ctx, batchSize) 416 | } else { 417 | r1 = ret.Error(1) 418 | } 419 | return r0, r1 420 | } 421 | 422 | // MockTransactionalMessageRepository_FetchAndLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FetchAndLock' 423 | type MockTransactionalMessageRepository_FetchAndLock_Call[T any] struct { 424 | *mock.Call 425 | } 426 | 427 | // FetchAndLock is a helper method to define mock.On call 428 | // - ctx 429 | // - batchSize 430 | func (_e *MockTransactionalMessageRepository_Expecter[T]) FetchAndLock(ctx interface{}, batchSize interface{}) *MockTransactionalMessageRepository_FetchAndLock_Call[T] { 431 | return &MockTransactionalMessageRepository_FetchAndLock_Call[T]{Call: _e.mock.On("FetchAndLock", ctx, batchSize)} 432 | } 433 | 434 | func (_c *MockTransactionalMessageRepository_FetchAndLock_Call[T]) Run(run func(ctx context.Context, batchSize uint)) *MockTransactionalMessageRepository_FetchAndLock_Call[T] { 435 | _c.Call.Run(func(args mock.Arguments) { 436 | run(args[0].(context.Context), args[1].(uint)) 437 | }) 438 | return _c 439 | } 440 | 441 | func (_c *MockTransactionalMessageRepository_FetchAndLock_Call[T]) Return(messages []troutbox.Message, err error) *MockTransactionalMessageRepository_FetchAndLock_Call[T] { 442 | _c.Call.Return(messages, err) 443 | return _c 444 | } 445 | 446 | func (_c *MockTransactionalMessageRepository_FetchAndLock_Call[T]) RunAndReturn(run func(ctx context.Context, batchSize uint) ([]troutbox.Message, error)) *MockTransactionalMessageRepository_FetchAndLock_Call[T] { 447 | _c.Call.Return(run) 448 | return _c 449 | } 450 | 451 | // MarkAsDead provides a mock function for the type MockTransactionalMessageRepository 452 | func (_mock *MockTransactionalMessageRepository[T]) MarkAsDead(ctx context.Context, ids []uint64) error { 453 | ret := _mock.Called(ctx, ids) 454 | 455 | if len(ret) == 0 { 456 | panic("no return value specified for MarkAsDead") 457 | } 458 | 459 | var r0 error 460 | if returnFunc, ok := ret.Get(0).(func(context.Context, []uint64) error); ok { 461 | r0 = returnFunc(ctx, ids) 462 | } else { 463 | r0 = ret.Error(0) 464 | } 465 | return r0 466 | } 467 | 468 | // MockTransactionalMessageRepository_MarkAsDead_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkAsDead' 469 | type MockTransactionalMessageRepository_MarkAsDead_Call[T any] struct { 470 | *mock.Call 471 | } 472 | 473 | // MarkAsDead is a helper method to define mock.On call 474 | // - ctx 475 | // - ids 476 | func (_e *MockTransactionalMessageRepository_Expecter[T]) MarkAsDead(ctx interface{}, ids interface{}) *MockTransactionalMessageRepository_MarkAsDead_Call[T] { 477 | return &MockTransactionalMessageRepository_MarkAsDead_Call[T]{Call: _e.mock.On("MarkAsDead", ctx, ids)} 478 | } 479 | 480 | func (_c *MockTransactionalMessageRepository_MarkAsDead_Call[T]) Run(run func(ctx context.Context, ids []uint64)) *MockTransactionalMessageRepository_MarkAsDead_Call[T] { 481 | _c.Call.Run(func(args mock.Arguments) { 482 | run(args[0].(context.Context), args[1].([]uint64)) 483 | }) 484 | return _c 485 | } 486 | 487 | func (_c *MockTransactionalMessageRepository_MarkAsDead_Call[T]) Return(err error) *MockTransactionalMessageRepository_MarkAsDead_Call[T] { 488 | _c.Call.Return(err) 489 | return _c 490 | } 491 | 492 | func (_c *MockTransactionalMessageRepository_MarkAsDead_Call[T]) RunAndReturn(run func(ctx context.Context, ids []uint64) error) *MockTransactionalMessageRepository_MarkAsDead_Call[T] { 493 | _c.Call.Return(run) 494 | return _c 495 | } 496 | 497 | // MarkAsSent provides a mock function for the type MockTransactionalMessageRepository 498 | func (_mock *MockTransactionalMessageRepository[T]) MarkAsSent(ctx context.Context, ids []uint64) error { 499 | ret := _mock.Called(ctx, ids) 500 | 501 | if len(ret) == 0 { 502 | panic("no return value specified for MarkAsSent") 503 | } 504 | 505 | var r0 error 506 | if returnFunc, ok := ret.Get(0).(func(context.Context, []uint64) error); ok { 507 | r0 = returnFunc(ctx, ids) 508 | } else { 509 | r0 = ret.Error(0) 510 | } 511 | return r0 512 | } 513 | 514 | // MockTransactionalMessageRepository_MarkAsSent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkAsSent' 515 | type MockTransactionalMessageRepository_MarkAsSent_Call[T any] struct { 516 | *mock.Call 517 | } 518 | 519 | // MarkAsSent is a helper method to define mock.On call 520 | // - ctx 521 | // - ids 522 | func (_e *MockTransactionalMessageRepository_Expecter[T]) MarkAsSent(ctx interface{}, ids interface{}) *MockTransactionalMessageRepository_MarkAsSent_Call[T] { 523 | return &MockTransactionalMessageRepository_MarkAsSent_Call[T]{Call: _e.mock.On("MarkAsSent", ctx, ids)} 524 | } 525 | 526 | func (_c *MockTransactionalMessageRepository_MarkAsSent_Call[T]) Run(run func(ctx context.Context, ids []uint64)) *MockTransactionalMessageRepository_MarkAsSent_Call[T] { 527 | _c.Call.Run(func(args mock.Arguments) { 528 | run(args[0].(context.Context), args[1].([]uint64)) 529 | }) 530 | return _c 531 | } 532 | 533 | func (_c *MockTransactionalMessageRepository_MarkAsSent_Call[T]) Return(err error) *MockTransactionalMessageRepository_MarkAsSent_Call[T] { 534 | _c.Call.Return(err) 535 | return _c 536 | } 537 | 538 | func (_c *MockTransactionalMessageRepository_MarkAsSent_Call[T]) RunAndReturn(run func(ctx context.Context, ids []uint64) error) *MockTransactionalMessageRepository_MarkAsSent_Call[T] { 539 | _c.Call.Return(run) 540 | return _c 541 | } 542 | 543 | // UpdateRetryCount provides a mock function for the type MockTransactionalMessageRepository 544 | func (_mock *MockTransactionalMessageRepository[T]) UpdateRetryCount(ctx context.Context, ids []uint64) error { 545 | ret := _mock.Called(ctx, ids) 546 | 547 | if len(ret) == 0 { 548 | panic("no return value specified for UpdateRetryCount") 549 | } 550 | 551 | var r0 error 552 | if returnFunc, ok := ret.Get(0).(func(context.Context, []uint64) error); ok { 553 | r0 = returnFunc(ctx, ids) 554 | } else { 555 | r0 = ret.Error(0) 556 | } 557 | return r0 558 | } 559 | 560 | // MockTransactionalMessageRepository_UpdateRetryCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRetryCount' 561 | type MockTransactionalMessageRepository_UpdateRetryCount_Call[T any] struct { 562 | *mock.Call 563 | } 564 | 565 | // UpdateRetryCount is a helper method to define mock.On call 566 | // - ctx 567 | // - ids 568 | func (_e *MockTransactionalMessageRepository_Expecter[T]) UpdateRetryCount(ctx interface{}, ids interface{}) *MockTransactionalMessageRepository_UpdateRetryCount_Call[T] { 569 | return &MockTransactionalMessageRepository_UpdateRetryCount_Call[T]{Call: _e.mock.On("UpdateRetryCount", ctx, ids)} 570 | } 571 | 572 | func (_c *MockTransactionalMessageRepository_UpdateRetryCount_Call[T]) Run(run func(ctx context.Context, ids []uint64)) *MockTransactionalMessageRepository_UpdateRetryCount_Call[T] { 573 | _c.Call.Run(func(args mock.Arguments) { 574 | run(args[0].(context.Context), args[1].([]uint64)) 575 | }) 576 | return _c 577 | } 578 | 579 | func (_c *MockTransactionalMessageRepository_UpdateRetryCount_Call[T]) Return(err error) *MockTransactionalMessageRepository_UpdateRetryCount_Call[T] { 580 | _c.Call.Return(err) 581 | return _c 582 | } 583 | 584 | func (_c *MockTransactionalMessageRepository_UpdateRetryCount_Call[T]) RunAndReturn(run func(ctx context.Context, ids []uint64) error) *MockTransactionalMessageRepository_UpdateRetryCount_Call[T] { 585 | _c.Call.Return(run) 586 | return _c 587 | } 588 | 589 | // WithTransaction provides a mock function for the type MockTransactionalMessageRepository 590 | func (_mock *MockTransactionalMessageRepository[T]) WithTransaction(ctx context.Context, cb troutbox.InTransactionFn) error { 591 | ret := _mock.Called(ctx, cb) 592 | 593 | if len(ret) == 0 { 594 | panic("no return value specified for WithTransaction") 595 | } 596 | 597 | var r0 error 598 | if returnFunc, ok := ret.Get(0).(func(context.Context, troutbox.InTransactionFn) error); ok { 599 | r0 = returnFunc(ctx, cb) 600 | } else { 601 | r0 = ret.Error(0) 602 | } 603 | return r0 604 | } 605 | 606 | // MockTransactionalMessageRepository_WithTransaction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithTransaction' 607 | type MockTransactionalMessageRepository_WithTransaction_Call[T any] struct { 608 | *mock.Call 609 | } 610 | 611 | // WithTransaction is a helper method to define mock.On call 612 | // - ctx 613 | // - cb 614 | func (_e *MockTransactionalMessageRepository_Expecter[T]) WithTransaction(ctx interface{}, cb interface{}) *MockTransactionalMessageRepository_WithTransaction_Call[T] { 615 | return &MockTransactionalMessageRepository_WithTransaction_Call[T]{Call: _e.mock.On("WithTransaction", ctx, cb)} 616 | } 617 | 618 | func (_c *MockTransactionalMessageRepository_WithTransaction_Call[T]) Run(run func(ctx context.Context, cb troutbox.InTransactionFn)) *MockTransactionalMessageRepository_WithTransaction_Call[T] { 619 | _c.Call.Run(func(args mock.Arguments) { 620 | run(args[0].(context.Context), args[1].(troutbox.InTransactionFn)) 621 | }) 622 | return _c 623 | } 624 | 625 | func (_c *MockTransactionalMessageRepository_WithTransaction_Call[T]) Return(err error) *MockTransactionalMessageRepository_WithTransaction_Call[T] { 626 | _c.Call.Return(err) 627 | return _c 628 | } 629 | 630 | func (_c *MockTransactionalMessageRepository_WithTransaction_Call[T]) RunAndReturn(run func(ctx context.Context, cb troutbox.InTransactionFn) error) *MockTransactionalMessageRepository_WithTransaction_Call[T] { 631 | _c.Call.Return(run) 632 | return _c 633 | } 634 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package troutbox 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "go.opentelemetry.io/otel" 8 | "go.opentelemetry.io/otel/metric" 9 | "go.opentelemetry.io/otel/trace" 10 | ) 11 | 12 | type options struct { 13 | tracer trace.Tracer 14 | meter metric.Meter 15 | batchSize uint 16 | maxRetries uint 17 | period time.Duration 18 | sendTimeout time.Duration 19 | errorHandler func(error) 20 | } 21 | 22 | type Option func(*options) 23 | 24 | // WithOtelTracer sets the OpenTelemetry tracer for the outbox. 25 | // By default, the default OpenTelemetry tracer is used. 26 | func WithOtelTracer(tracer trace.Tracer) Option { 27 | return func(o *options) { 28 | if tracer != nil { 29 | o.tracer = tracer 30 | } 31 | } 32 | } 33 | 34 | // WithOtelMeter sets the OpenTelemetry meter for the outbox. 35 | // By default, the default OpenTelemetry meter is used. 36 | func WithOtelMeter(meter metric.Meter) Option { 37 | return func(o *options) { 38 | if meter != nil { 39 | o.meter = meter 40 | } 41 | } 42 | } 43 | 44 | // WithBatchSize sets the batch size to fetch messages from the repository. 45 | // The default batch size is 10. 46 | func WithBatchSize(size uint) Option { 47 | return func(o *options) { 48 | if size > 0 { 49 | o.batchSize = size 50 | } 51 | } 52 | } 53 | 54 | // WithMaxRetries sets the maximum number of retries for sending messages. 55 | // The default maximum number of retries is 3. 56 | func WithMaxRetries(retries uint) Option { 57 | return func(o *options) { 58 | o.maxRetries = retries 59 | } 60 | } 61 | 62 | // WithPeriod sets the period for sending messages. 63 | // The default period is 5 seconds. 64 | func WithPeriod(period time.Duration) Option { 65 | return func(o *options) { 66 | if period > 0 { 67 | o.period = period 68 | } 69 | } 70 | } 71 | 72 | // WithSendTimeout sets the timeout for sending messages, including communication with db. 73 | // The default timeout is 2 second. 74 | func WithSendTimeout(timeout time.Duration) Option { 75 | return func(o *options) { 76 | if timeout > 0 { 77 | o.sendTimeout = timeout 78 | } 79 | } 80 | } 81 | 82 | // WithErrorHandler sets the error handler for the outbox. 83 | // The error handler is called when an error occurs while sending messages. 84 | // The default error handler logs the error to the standard logger. 85 | // Be careful, it blocks the main sending loop. 86 | func WithErrorHandler(handler func(error)) Option { 87 | return func(o *options) { 88 | o.errorHandler = handler 89 | } 90 | } 91 | 92 | // applyOptions applies the provided options to the default options. 93 | func applyOptions(opts ...Option) options { 94 | defaultErrorHandler := func(err error) { 95 | log.Println(err) 96 | } 97 | 98 | defaultOpts := options{ 99 | tracer: otel.GetTracerProvider().Tracer("github.com/Darkemon/troutbox"), 100 | meter: otel.GetMeterProvider().Meter("github.com/Darkemon/troutbox"), 101 | batchSize: 10, 102 | maxRetries: 3, 103 | period: 5 * time.Second, 104 | sendTimeout: 2 * time.Second, 105 | errorHandler: defaultErrorHandler, 106 | } 107 | 108 | for _, opt := range opts { 109 | opt(&defaultOpts) 110 | } 111 | 112 | return defaultOpts 113 | } 114 | -------------------------------------------------------------------------------- /outbox.go: -------------------------------------------------------------------------------- 1 | package troutbox 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "go.opentelemetry.io/otel/attribute" 10 | "go.opentelemetry.io/otel/codes" 11 | "go.opentelemetry.io/otel/metric" 12 | "go.opentelemetry.io/otel/trace" 13 | ) 14 | 15 | type SendStatus uint8 16 | 17 | const ( 18 | StatusNone SendStatus = 0 // not tried to send 19 | StatusSent SendStatus = 1 // sent successfully 20 | StatusFail SendStatus = 2 // failed to send 21 | StatusDead SendStatus = 3 // retries exceeded, message is dead 22 | ) 23 | 24 | type Message struct { 25 | ID uint64 26 | Key string 27 | Value []byte 28 | Timestamp time.Time 29 | Retries uint 30 | 31 | status SendStatus // by default it's statusNone 32 | } 33 | 34 | func (m *Message) MarkAsSent() { 35 | m.status = StatusSent 36 | } 37 | 38 | func (m *Message) MarkAsFailed() { 39 | m.status = StatusFail 40 | } 41 | 42 | type Sender interface { 43 | // Send sends a batch of messages to the message broker. The passed messages are 44 | // sorted by timestamp in ascending order. 45 | // It should return the same list of messages with updated statuses 46 | // (method SetStatusSent or SetStatusFail of the Message). Returned error should be 47 | // returned if the sending failed completely or partially. 48 | Send(ctx context.Context, msg []Message) ([]Message, error) 49 | } 50 | 51 | // MessageRepository describes an interface for interacting with the outbox storage (e.g., a database). 52 | type MessageRepository interface { 53 | // FetchAndLock fetches a batch of ready-to-sent messages from the outbox and locks them for processing. 54 | // It fetches at most batchSize messages sorted by timestamp in ascending order. 55 | FetchAndLock(ctx context.Context, batchSize uint) ([]Message, error) 56 | // UpdateRetryCount updates the retry count for the message with the given ID. 57 | // It's called when the message is retried. 58 | UpdateRetryCount(ctx context.Context, ids []uint64) error 59 | // MarkAsDead marks the message as dead so that it won't be retried anymore. 60 | // It's called when the message cannot be sent after maxRetries. 61 | MarkAsDead(ctx context.Context, ids []uint64) error 62 | // MarkAsSent marks the message as sent. 63 | MarkAsSent(ctx context.Context, ids []uint64) error 64 | } 65 | 66 | type InTransactionFn func(ctx context.Context, s MessageRepository) error 67 | 68 | // TransactionalMessageRepository extends [MessageRepository] to support transactional operations. 69 | type TransactionalMessageRepository[T any] interface { 70 | MessageRepository 71 | 72 | // AddMessage adds a message to the outbox. 73 | AddMessage(ctx context.Context, tx T, msg Message) error 74 | // WithTransaction executes a function within a transaction. 75 | // The function should return an error if the transaction should be rolled back. 76 | WithTransaction(ctx context.Context, cb InTransactionFn) error 77 | } 78 | 79 | type Outbox[T any] struct { 80 | storage TransactionalMessageRepository[T] 81 | sender Sender 82 | opts options 83 | 84 | // OpenTelemetry metrics. 85 | messagesSent metric.Int64Counter 86 | messagesFailed metric.Int64Counter 87 | messagesRetried metric.Int64Counter 88 | messagesDead metric.Int64Counter 89 | } 90 | 91 | // NewOutbox creates a new Outbox instance. 92 | func NewOutbox[T any](storage TransactionalMessageRepository[T], sender Sender, opts ...Option) (*Outbox[T], error) { 93 | o := Outbox[T]{ 94 | storage: storage, 95 | sender: sender, 96 | opts: applyOptions(opts...), 97 | } 98 | 99 | if err := o.createCounters(); err != nil { 100 | return nil, fmt.Errorf("failed to create counters: %w", err) 101 | } 102 | 103 | return &o, nil 104 | } 105 | 106 | func (o *Outbox[T]) createCounters() error { 107 | var err error 108 | 109 | if o.messagesSent, err = o.opts.meter.Int64Counter( 110 | "outbox_messages_sent", 111 | metric.WithDescription("Number of messages successfully sent"), 112 | ); err != nil { 113 | return fmt.Errorf("messages sent: %w", err) 114 | } 115 | 116 | if o.messagesFailed, err = o.opts.meter.Int64Counter( 117 | "outbox_messages_failed", 118 | metric.WithDescription("Number of messages that failed to send"), 119 | ); err != nil { 120 | return fmt.Errorf("messages failed: %w", err) 121 | } 122 | 123 | if o.messagesRetried, err = o.opts.meter.Int64Counter( 124 | "outbox_messages_retried", 125 | metric.WithDescription("Number of messages retried"), 126 | ); err != nil { 127 | return fmt.Errorf("messages retried: %w", err) 128 | } 129 | 130 | if o.messagesDead, err = o.opts.meter.Int64Counter( 131 | "outbox_messages_dead", 132 | metric.WithDescription("Number of messages marked as dead"), 133 | ); err != nil { 134 | return fmt.Errorf("messages dead: %w", err) 135 | } 136 | 137 | return nil 138 | } 139 | 140 | // AddMessage adds a message to the outbox. 141 | // It sets the timestamp to the current UTC time. 142 | func (o *Outbox[T]) AddMessage(ctx context.Context, tx T, key string, value []byte) error { 143 | msg := Message{ 144 | Key: key, 145 | Value: value, 146 | Timestamp: time.Now().UTC(), 147 | Retries: 0, 148 | } 149 | 150 | return o.storage.AddMessage(ctx, tx, msg) 151 | } 152 | 153 | // Run starts the outbox loop. It fetches a batch of messages from the DB every period (5 seconds by default) 154 | // and sends them at least once. It marks the messages as sent after they are successfully sent. 155 | // The loop can be stopped by cancelling the context. 156 | func (o *Outbox[T]) Run(ctx context.Context) error { 157 | select { 158 | case <-ctx.Done(): 159 | return ctx.Err() 160 | default: 161 | } 162 | 163 | timer := time.NewTimer(0) // start immediately 164 | defer timer.Stop() 165 | 166 | for { 167 | select { 168 | case <-timer.C: 169 | if err := o.storage.WithTransaction(ctx, o.sendBatch); err != nil { 170 | o.opts.errorHandler(fmt.Errorf("sending failed: %w", err)) 171 | } 172 | timer.Reset(o.opts.period) 173 | case <-ctx.Done(): 174 | err := ctx.Err() 175 | if errors.Is(err, context.Canceled) { 176 | return nil 177 | } 178 | return err 179 | } 180 | } 181 | } 182 | 183 | func (o *Outbox[T]) sendBatch(ctx context.Context, s MessageRepository) error { 184 | var err error 185 | 186 | ctx, span := o.opts.tracer.Start( 187 | ctx, 188 | "send batch", 189 | trace.WithAttributes(attribute.Int("batch_size", int(o.opts.batchSize))), 190 | ) 191 | defer func() { 192 | if err != nil { 193 | span.RecordError(err) 194 | span.SetStatus(codes.Error, "failed to send batch") 195 | } 196 | span.End() 197 | }() 198 | 199 | ctx, cancel := context.WithTimeout(ctx, o.opts.sendTimeout) 200 | defer cancel() 201 | 202 | // Fetch batch of messages. 203 | messages, err := s.FetchAndLock(ctx, o.opts.batchSize) 204 | if err != nil { 205 | return fmt.Errorf("failed to fetch messages: %w", err) 206 | } 207 | 208 | if len(messages) == 0 { 209 | return nil 210 | } 211 | 212 | // Send messages. 213 | messages, err = o.sender.Send(ctx, messages) 214 | if err != nil { 215 | o.opts.errorHandler(fmt.Errorf("couldn't send messages: %w", err)) 216 | } 217 | 218 | sent := make([]uint64, 0) 219 | toRetry := make([]uint64, 0) 220 | toDead := make([]uint64, 0) 221 | 222 | for _, msg := range messages { 223 | switch msg.status { 224 | case StatusSent: 225 | sent = append(sent, msg.ID) 226 | case StatusFail: 227 | // If the message failed to send, we need to check if we should retry or mark it as dead. 228 | if msg.Retries+1 < o.opts.maxRetries { 229 | toRetry = append(toRetry, msg.ID) 230 | } else { 231 | toDead = append(toDead, msg.ID) 232 | } 233 | case StatusNone: 234 | // The message was not sent, so we need to retry it but not increase the retry count. 235 | // So basically we do nothing with it. 236 | } 237 | } 238 | 239 | if len(sent) > 0 { 240 | o.messagesSent.Add(ctx, int64(len(sent))) 241 | 242 | if err := s.MarkAsSent(ctx, sent); err != nil { 243 | return fmt.Errorf("failed to mark messages as sent: %w", err) 244 | } 245 | } 246 | 247 | if len(toRetry) > 0 { 248 | o.messagesRetried.Add(ctx, int64(len(toRetry))) 249 | 250 | if err := s.UpdateRetryCount(ctx, toRetry); err != nil { 251 | return fmt.Errorf("failed to update retry count: %w", err) 252 | } 253 | } 254 | 255 | if len(toDead) > 0 { 256 | o.messagesDead.Add(ctx, int64(len(toDead))) 257 | 258 | if err := s.MarkAsDead(ctx, toDead); err != nil { 259 | return fmt.Errorf("failed to mark messages as dead: %w", err) 260 | } 261 | } 262 | 263 | return nil 264 | } 265 | -------------------------------------------------------------------------------- /outbox_test.go: -------------------------------------------------------------------------------- 1 | package troutbox_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/mock" 11 | "github.com/stretchr/testify/suite" 12 | 13 | "github.com/Darkemon/troutbox" 14 | "github.com/Darkemon/troutbox/internal/mocks" 15 | ) 16 | 17 | const ( 18 | maxRetries = uint(3) 19 | batchSize = uint(10) 20 | ) 21 | 22 | type OutboxTestSuite struct { 23 | suite.Suite 24 | mockTrRepo *mocks.MockTransactionalMessageRepository[struct{}] 25 | mockRepo *mocks.MockMessageRepository 26 | mockSender *mocks.MockSender 27 | outbox *troutbox.Outbox[struct{}] 28 | } 29 | 30 | // Run the test suite. 31 | func TestOutboxTestSuite(t *testing.T) { 32 | suite.Run(t, new(OutboxTestSuite)) 33 | } 34 | 35 | func (ots *OutboxTestSuite) SetupTest() { 36 | ots.mockTrRepo = mocks.NewMockTransactionalMessageRepository[struct{}](ots.T()) 37 | ots.mockRepo = mocks.NewMockMessageRepository(ots.T()) 38 | ots.mockSender = mocks.NewMockSender(ots.T()) 39 | 40 | var err error 41 | ots.outbox, err = troutbox.NewOutbox( 42 | ots.mockTrRepo, 43 | ots.mockSender, 44 | troutbox.WithMaxRetries(maxRetries), 45 | troutbox.WithBatchSize(batchSize), 46 | ) 47 | ots.Require().NoError(err) 48 | } 49 | 50 | // TestBasicFunctionality tests that all messages are sent successfully. 51 | func (ots *OutboxTestSuite) TestBasicFunctionality() { 52 | messages := []troutbox.Message{ 53 | {ID: 1, Key: "key1", Value: []byte("value1"), Retries: 0}, 54 | {ID: 2, Key: "key2", Value: []byte("value2"), Retries: 0}, 55 | } 56 | 57 | sentMessages := make([]troutbox.Message, 0, len(messages)) 58 | for _, msg := range messages { 59 | msg.MarkAsSent() 60 | sentMessages = append(sentMessages, msg) 61 | } 62 | 63 | ots.mockRepo.EXPECT().FetchAndLock(mock.Anything, batchSize).Return(messages, nil) 64 | ots.mockSender.EXPECT().Send(mock.Anything, messages).Return(sentMessages, nil) 65 | ots.mockRepo.EXPECT().MarkAsSent(mock.Anything, []uint64{1, 2}).Return(nil) 66 | 67 | err := ots.outbox.SendBatch(context.Background(), ots.mockRepo) 68 | 69 | assert.NoError(ots.T(), err) 70 | } 71 | 72 | func (ots *OutboxTestSuite) TestAddMessage() { 73 | expMessages := []troutbox.Message{ 74 | {Key: "key1", Value: []byte("value1"), Timestamp: time.Now().UTC()}, 75 | {Key: "key2", Value: []byte("value2"), Timestamp: time.Now().UTC()}, 76 | } 77 | i := 0 78 | 79 | ots.mockTrRepo.EXPECT(). 80 | AddMessage(mock.Anything, mock.Anything, mock.Anything). 81 | RunAndReturn(func(_ context.Context, _ struct{}, msg troutbox.Message) error { 82 | ots.Assert().Equal(msg.Key, expMessages[i].Key) 83 | ots.Assert().Equal(msg.Value, expMessages[i].Value) 84 | ots.Assert().WithinDuration(expMessages[i].Timestamp, msg.Timestamp, time.Millisecond) 85 | i++ 86 | return nil 87 | }).Times(2) 88 | 89 | err := ots.outbox.AddMessage(context.Background(), struct{}{}, "key1", []byte("value1")) 90 | ots.Assert().NoError(err) 91 | err = ots.outbox.AddMessage(context.Background(), struct{}{}, "key2", []byte("value2")) 92 | ots.Assert().NoError(err) 93 | } 94 | 95 | // TestEmptyBatch tests that no messages are sent when the batch is empty. 96 | func (ots *OutboxTestSuite) TestEmptyBatch() { 97 | ots.mockRepo.EXPECT().FetchAndLock(mock.Anything, batchSize).Return([]troutbox.Message{}, nil) 98 | 99 | err := ots.outbox.SendBatch(context.Background(), ots.mockRepo) 100 | assert.NoError(ots.T(), err) 101 | } 102 | 103 | // TestRetryLogic tests that failed messages are retried or marked as dead. 104 | func (ots *OutboxTestSuite) TestRetryLogic() { 105 | messages := []troutbox.Message{ 106 | {ID: 1, Key: "key1", Value: []byte("value1"), Retries: 1}, 107 | {ID: 2, Key: "key2", Value: []byte("value2"), Retries: 2}, 108 | } 109 | 110 | sentMessages := make([]troutbox.Message, 0, len(messages)) 111 | for _, msg := range messages { 112 | msg.MarkAsFailed() 113 | sentMessages = append(sentMessages, msg) 114 | } 115 | 116 | ots.mockRepo.EXPECT().FetchAndLock(mock.Anything, batchSize).Return(messages, nil) 117 | ots.mockSender.EXPECT().Send(mock.Anything, messages).Return(sentMessages, errors.New("send failed")) 118 | ots.mockRepo.EXPECT().MarkAsDead(mock.Anything, []uint64{2}).Return(nil) 119 | ots.mockRepo.EXPECT().UpdateRetryCount(mock.Anything, []uint64{1}).Return(nil) 120 | 121 | err := ots.outbox.SendBatch(context.Background(), ots.mockRepo) 122 | assert.NoError(ots.T(), err) 123 | } 124 | 125 | // TestPartialRetryLogic tests that some messages are sent successfully while others fail. 126 | func (ots *OutboxTestSuite) TestPartialRetryLogic() { 127 | messages := []troutbox.Message{ 128 | {ID: 1, Key: "key1", Value: []byte("value1"), Retries: 1}, // will be sent successfully 129 | {ID: 2, Key: "key2", Value: []byte("value2"), Retries: 2}, // will fail and exceed maxRetries 130 | {ID: 3, Key: "key3", Value: []byte("value3"), Retries: 1}, // will fail but can retry 131 | } 132 | 133 | updatedMessages := make([]troutbox.Message, len(messages)) 134 | copy(updatedMessages, messages) 135 | 136 | updatedMessages[0].MarkAsSent() 137 | updatedMessages[1].MarkAsFailed() 138 | updatedMessages[2].MarkAsFailed() 139 | 140 | ots.mockRepo.EXPECT().FetchAndLock(mock.Anything, batchSize).Return(messages, nil) 141 | ots.mockSender.EXPECT().Send(mock.Anything, messages).Return(updatedMessages, errors.New("partial failure")) 142 | ots.mockRepo.EXPECT().MarkAsSent(mock.Anything, []uint64{1}).Return(nil) 143 | ots.mockRepo.EXPECT().MarkAsDead(mock.Anything, []uint64{2}).Return(nil) 144 | ots.mockRepo.EXPECT().UpdateRetryCount(mock.Anything, []uint64{3}).Return(nil) 145 | 146 | err := ots.outbox.SendBatch(context.Background(), ots.mockRepo) 147 | assert.NoError(ots.T(), err) 148 | } 149 | 150 | // TestMarkAsDead tests that messages that exceed the max retries are marked as dead. 151 | func (ots *OutboxTestSuite) TestMarkAsDead() { 152 | messages := []troutbox.Message{ 153 | {ID: 1, Key: "key1", Value: []byte("value1"), Retries: 2}, 154 | } 155 | 156 | sentMessages := make([]troutbox.Message, 0, len(messages)) 157 | for _, msg := range messages { 158 | msg.MarkAsFailed() 159 | sentMessages = append(sentMessages, msg) 160 | } 161 | 162 | ots.mockRepo.EXPECT().FetchAndLock(mock.Anything, batchSize).Return(messages, nil) 163 | ots.mockSender.EXPECT().Send(mock.Anything, messages).Return(sentMessages, errors.New("send failed")) 164 | ots.mockRepo.EXPECT().MarkAsDead(mock.Anything, []uint64{1}).Return(nil) 165 | 166 | err := ots.outbox.SendBatch(context.Background(), ots.mockRepo) 167 | assert.NoError(ots.T(), err) 168 | } 169 | 170 | // TestContextCancellation tests that the outbox stops when the context is canceled. 171 | func (ots *OutboxTestSuite) TestContextCancellation() { 172 | ctx, cancel := context.WithCancel(context.Background()) 173 | cancel() // cancel the context immediately 174 | 175 | err := ots.outbox.Run(ctx) 176 | assert.ErrorIs(ots.T(), err, context.Canceled) 177 | } 178 | -------------------------------------------------------------------------------- /tests/integration/Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | help: ## Show this help message 4 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 5 | 6 | test-setup: ## Setup test environment 7 | docker compose up -d 8 | 9 | test-teardown: ## Teardown test environment 10 | docker compose down 11 | 12 | test: ## Run tests 13 | go test -race -tags=integration ./... 14 | -------------------------------------------------------------------------------- /tests/integration/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | infra: 3 | image: busybox:1.35.0-uclibc 4 | entrypoint: ["echo", "ready"] 5 | depends_on: 6 | postgres: 7 | condition: service_healthy 8 | 9 | postgres: 10 | image: postgres:15 11 | environment: 12 | POSTGRES_USER: postgres 13 | POSTGRES_PASSWORD: postgres 14 | POSTGRES_DB: test_db 15 | ports: 16 | - "5432:5432" 17 | healthcheck: 18 | test: [ "CMD", "pg_isready", "-U", "postgres" ] 19 | interval: 1s 20 | timeout: 3s 21 | retries: 10 22 | -------------------------------------------------------------------------------- /tests/integration/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Darkemon/troutbox/tests/integration 2 | 3 | go 1.24.0 4 | 5 | replace github.com/Darkemon/troutbox => ../.. 6 | 7 | require ( 8 | github.com/Darkemon/troutbox v0.0.0 9 | github.com/lib/pq v1.10.9 10 | github.com/stretchr/testify v1.10.0 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 15 | github.com/go-logr/logr v1.4.2 // indirect 16 | github.com/go-logr/stdr v1.2.2 // indirect 17 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 18 | github.com/stretchr/objx v0.5.2 // indirect 19 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 20 | go.opentelemetry.io/otel v1.36.0 // indirect 21 | go.opentelemetry.io/otel/metric v1.36.0 // indirect 22 | go.opentelemetry.io/otel/trace v1.36.0 // indirect 23 | gopkg.in/yaml.v3 v3.0.1 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /tests/integration/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 2 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 4 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 5 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 6 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 7 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 8 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 9 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 10 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 11 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 12 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 13 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 14 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 15 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 16 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 17 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 18 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 19 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 20 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 21 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 22 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 23 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 24 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 25 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 26 | go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 27 | go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 28 | go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= 29 | go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= 30 | go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 31 | go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 32 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 33 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 34 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 35 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | -------------------------------------------------------------------------------- /tests/integration/psql_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package integration_test 4 | 5 | import ( 6 | "context" 7 | "database/sql" 8 | "errors" 9 | "fmt" 10 | "os" 11 | "os/exec" 12 | "sync" 13 | "testing" 14 | "time" 15 | 16 | _ "github.com/lib/pq" 17 | "github.com/stretchr/testify/mock" 18 | "github.com/stretchr/testify/suite" 19 | 20 | "github.com/Darkemon/troutbox" 21 | "github.com/Darkemon/troutbox/adapter/psql" 22 | "github.com/Darkemon/troutbox/internal/mocks" 23 | ) 24 | 25 | const ( 26 | lockID = 12345 27 | maxRetries = uint(3) 28 | batchSize = uint(5) 29 | sendTimeout = 200 * time.Millisecond 30 | ) 31 | 32 | type OutboxPsqlTestSuite struct { 33 | suite.Suite 34 | db *sql.DB 35 | mockSender *mocks.MockSender 36 | cancelFn []context.CancelFunc 37 | wg sync.WaitGroup 38 | } 39 | 40 | // Run the test suite. 41 | func TestOutboxPsqlTestSuite(t *testing.T) { 42 | suite.Run(t, new(OutboxPsqlTestSuite)) 43 | } 44 | 45 | func (s *OutboxPsqlTestSuite) SetupTest() { 46 | s.cancelFn = nil 47 | s.mockSender = mocks.NewMockSender(s.T()) 48 | 49 | s.db = s.newDBConn() 50 | 51 | if _, err := s.db.Exec("DROP TABLE IF EXISTS outbox_messages"); err != nil { 52 | s.T().Fatalf("Failed to drop outbox table: %v", err) 53 | } 54 | 55 | if _, err := s.db.Exec("DROP TABLE IF EXISTS outbox_messages_partitions_test"); err != nil { 56 | s.T().Fatalf("Failed to drop outbox table: %v", err) 57 | } 58 | 59 | repo := psql.NewPostgresMessageRepository(s.db, lockID) 60 | 61 | if err := repo.Migrate(s.T().Context()); err != nil { 62 | s.T().Fatalf("Failed to migrate database: %v", err) 63 | } 64 | } 65 | 66 | func (s *OutboxPsqlTestSuite) TearDownTest() { 67 | defer s.db.Close() 68 | 69 | if len(s.cancelFn) == 0 { 70 | return 71 | } 72 | 73 | for _, cancel := range s.cancelFn { 74 | cancel() 75 | } 76 | 77 | closeCh := make(chan struct{}) 78 | 79 | go func() { 80 | s.wg.Wait() 81 | close(closeCh) 82 | }() 83 | 84 | timer := time.NewTimer(5 * time.Second) 85 | defer timer.Stop() 86 | 87 | select { 88 | case <-closeCh: 89 | case <-timer.C: 90 | s.T().Error("Timeout waiting for outbox to finish") 91 | } 92 | } 93 | 94 | func (s *OutboxPsqlTestSuite) manageDBContainer(cmd string) { 95 | if cmd != "start" && cmd != "stop" { 96 | s.T().Fatalf("Invalid command: %s", cmd) 97 | } 98 | 99 | dockerCmd := exec.Command("docker", "compose", cmd, "postgres") 100 | dockerCmd.Stdout = os.Stdout 101 | dockerCmd.Stderr = os.Stderr 102 | 103 | if err := dockerCmd.Run(); err != nil { 104 | s.T().Fatalf("Failed to %s PostgreSQL container: %v", cmd, err) 105 | } 106 | } 107 | 108 | func (s *OutboxPsqlTestSuite) newDBConn() *sql.DB { 109 | db, err := sql.Open("postgres", "user=postgres password=postgres dbname=test_db sslmode=disable") 110 | if err != nil { 111 | s.T().Fatalf("Failed to connect to database: %v", err) 112 | } 113 | return db 114 | } 115 | 116 | // newOutbox creates a new outbox instance for testing. 117 | func (s *OutboxPsqlTestSuite) newOutbox(sender troutbox.Sender) *troutbox.Outbox[*sql.Tx] { 118 | s.wg.Add(1) 119 | 120 | if sender == nil { 121 | sender = s.mockSender 122 | } 123 | 124 | o, err := troutbox.NewOutbox( 125 | psql.NewPostgresMessageRepository(s.db, lockID, psql.WithTableName("outbox_messages")), 126 | sender, 127 | troutbox.WithMaxRetries(maxRetries), 128 | troutbox.WithBatchSize(batchSize), 129 | troutbox.WithPeriod(50*time.Millisecond), 130 | troutbox.WithSendTimeout(sendTimeout), 131 | ) 132 | s.Require().NoError(err, "Failed to create outbox") 133 | 134 | return o 135 | } 136 | 137 | func (s *OutboxPsqlTestSuite) startOutbox(ctx context.Context, outb *troutbox.Outbox[*sql.Tx]) { 138 | ctx, cancel := context.WithCancel(ctx) 139 | s.cancelFn = append(s.cancelFn, cancel) 140 | 141 | go func() { 142 | defer func() { 143 | s.wg.Done() 144 | 145 | if r := recover(); r != nil { 146 | switch r := r.(type) { 147 | case string: 148 | if r == "test panic" { 149 | cancel() // simulate the process stopped completely 150 | return 151 | } 152 | } 153 | panic(r) 154 | } 155 | }() 156 | 157 | if err := outb.Run(ctx); err != nil { 158 | s.T().Errorf("Outbox run failed: %v", err) 159 | } 160 | }() 161 | } 162 | 163 | func (s *OutboxPsqlTestSuite) getPartitions(ctx context.Context, tableName string) []string { 164 | query := fmt.Sprintf(` 165 | SELECT tablename 166 | FROM pg_tables 167 | WHERE schemaname = 'public' 168 | AND tablename LIKE '%s_%%' 169 | ORDER BY tablename DESC 170 | `, tableName) 171 | 172 | rows, err := s.db.QueryContext(ctx, query) 173 | s.Require().NoError(err, "failed to query partitions") 174 | 175 | defer rows.Close() 176 | 177 | var tables []string 178 | for rows.Next() { 179 | var table string 180 | err := rows.Scan(&table) 181 | s.Require().NoError(err, "failed to scan table name") 182 | tables = append(tables, table) 183 | } 184 | 185 | err = rows.Err() 186 | s.Require().NoError(err, "failed to iterate over rows") 187 | 188 | return tables 189 | } 190 | 191 | // assertMessagesCount checks the number of messages in the outbox table with the given status. 192 | func (s *OutboxPsqlTestSuite) assertMessagesCount(expectedCount int, status troutbox.SendStatus) { 193 | db := s.newDBConn() 194 | defer db.Close() 195 | 196 | rows, err := db.Query("SELECT COUNT(*) FROM outbox_messages WHERE status = $1", status) 197 | if err != nil { 198 | s.T().Fatalf("Failed to query sent messages: %v", err) 199 | } 200 | defer rows.Close() 201 | 202 | var actualCount int 203 | if rows.Next() { 204 | if err := rows.Scan(&actualCount); err != nil { 205 | s.T().Fatalf("Failed to scan sent messages count: %v", err) 206 | } 207 | } 208 | 209 | s.Assert().Equal(expectedCount, actualCount, "Expected %d messages, got %d", expectedCount, actualCount) 210 | } 211 | 212 | func (s *OutboxPsqlTestSuite) assertMessageEquals(msg troutbox.Message, key string, value []byte, timestamp time.Time) { 213 | s.Assert().Equal(key, msg.Key) 214 | s.Assert().Equal(value, msg.Value) 215 | s.Assert().WithinDuration(timestamp, msg.Timestamp, time.Millisecond) 216 | } 217 | 218 | // Ensure messages are fetched, sent, and marked as sent successfully. 219 | func (s *OutboxPsqlTestSuite) TestBasic() { 220 | outb := s.newOutbox(nil) 221 | 222 | // Message to be sent. 223 | key1 := "key1" 224 | key2 := "key2" 225 | value1 := []byte("value1") 226 | 227 | var ( 228 | expTimestamp1 *time.Time 229 | expTimestamp2 *time.Time 230 | ) 231 | 232 | // Expected call to the sender. 233 | s.mockSender.EXPECT(). 234 | Send(mock.Anything, mock.Anything). 235 | RunAndReturn(func(_ context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 236 | s.Assert().Len(messages, 2) 237 | s.assertMessageEquals(messages[0], key1, value1, *expTimestamp1) 238 | s.assertMessageEquals(messages[1], key2, []byte{}, *expTimestamp2) 239 | messages[0].MarkAsSent() 240 | messages[1].MarkAsSent() 241 | return messages, nil 242 | }).Times(1) 243 | 244 | // Add the message to the outbox. 245 | now1 := time.Now() 246 | expTimestamp1 = &now1 247 | if err := outb.AddMessage(s.T().Context(), nil, key1, value1); err != nil { 248 | s.T().Fatalf("Failed to add message 1: %v", err) 249 | } 250 | 251 | now2 := time.Now() 252 | expTimestamp2 = &now2 253 | if err := outb.AddMessage(s.T().Context(), nil, key2, nil); err != nil { 254 | s.T().Fatalf("Failed to add message 2: %v", err) 255 | } 256 | 257 | // Run the outbox after adding the messages to ensure they are processed at once. 258 | s.startOutbox(s.T().Context(), outb) 259 | 260 | // Wait for a short period to allow the outbox to process the message. 261 | time.Sleep(100 * time.Millisecond) 262 | s.assertMessagesCount(2, troutbox.StatusSent) 263 | s.assertMessagesCount(0, troutbox.StatusNone) 264 | s.assertMessagesCount(0, troutbox.StatusDead) 265 | } 266 | 267 | // Ensure messages that fail to send are retried up to the maximum retry count. 268 | func (s *OutboxPsqlTestSuite) TestRetry() { 269 | outb := s.newOutbox(nil) 270 | 271 | key1 := "key1" 272 | key2 := "key2" 273 | value1 := []byte("value1") 274 | value2 := []byte("value2") 275 | 276 | var ( 277 | expTimestamp1 *time.Time 278 | expTimestamp2 *time.Time 279 | ) 280 | 281 | s.mockSender.EXPECT(). 282 | Send(mock.Anything, mock.Anything). 283 | RunAndReturn(func(_ context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 284 | s.Assert().Len(messages, 2) 285 | s.Assert().Less(messages[0].Retries, maxRetries) 286 | s.Assert().Less(messages[1].Retries, maxRetries) 287 | s.assertMessageEquals(messages[0], key1, value1, *expTimestamp1) 288 | s.assertMessageEquals(messages[1], key2, value2, *expTimestamp2) 289 | 290 | if messages[0].Retries == maxRetries-1 { // last retry 291 | messages[0].MarkAsSent() 292 | } else { 293 | messages[0].MarkAsFailed() 294 | } 295 | 296 | if messages[1].Retries == maxRetries-1 { // last retry 297 | messages[1].MarkAsSent() 298 | } else { 299 | messages[1].MarkAsFailed() 300 | } 301 | 302 | return messages, nil 303 | }).Times(int(maxRetries)) 304 | 305 | now1 := time.Now() 306 | expTimestamp1 = &now1 307 | if err := outb.AddMessage(s.T().Context(), nil, key1, value1); err != nil { 308 | s.T().Fatalf("Failed to add message 1: %v", err) 309 | } 310 | 311 | now2 := time.Now() 312 | expTimestamp2 = &now2 313 | if err := outb.AddMessage(s.T().Context(), nil, key2, value2); err != nil { 314 | s.T().Fatalf("Failed to add message 2: %v", err) 315 | } 316 | 317 | s.startOutbox(s.T().Context(), outb) 318 | 319 | time.Sleep(200 * time.Millisecond) 320 | s.assertMessagesCount(2, troutbox.StatusSent) 321 | s.assertMessagesCount(0, troutbox.StatusNone) 322 | s.assertMessagesCount(0, troutbox.StatusDead) 323 | } 324 | 325 | // Ensure messages that exceed the maximum retry count are marked as dead. 326 | func (s *OutboxPsqlTestSuite) TestDead() { 327 | outb := s.newOutbox(nil) 328 | 329 | key1 := "key1" 330 | key2 := "key2" 331 | value1 := []byte("value1") 332 | value2 := []byte("value2") 333 | 334 | var ( 335 | expTimestamp1 *time.Time 336 | expTimestamp2 *time.Time 337 | ) 338 | 339 | s.mockSender.EXPECT(). 340 | Send(mock.Anything, mock.Anything). 341 | RunAndReturn(func(_ context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 342 | s.Assert().Len(messages, 2) 343 | s.Assert().Less(messages[0].Retries, maxRetries) 344 | s.Assert().Less(messages[1].Retries, maxRetries) 345 | s.assertMessageEquals(messages[0], key1, value1, *expTimestamp1) 346 | s.assertMessageEquals(messages[1], key2, value2, *expTimestamp2) 347 | 348 | messages[0].MarkAsFailed() 349 | messages[1].MarkAsFailed() 350 | 351 | return messages, nil 352 | }).Times(int(maxRetries)) 353 | 354 | now1 := time.Now() 355 | expTimestamp1 = &now1 356 | if err := outb.AddMessage(s.T().Context(), nil, key1, value1); err != nil { 357 | s.T().Fatalf("Failed to add message 1: %v", err) 358 | } 359 | 360 | now2 := time.Now() 361 | expTimestamp2 = &now2 362 | if err := outb.AddMessage(s.T().Context(), nil, key2, value2); err != nil { 363 | s.T().Fatalf("Failed to add message 2: %v", err) 364 | } 365 | 366 | s.startOutbox(s.T().Context(), outb) 367 | 368 | time.Sleep(200 * time.Millisecond) 369 | s.assertMessagesCount(0, troutbox.StatusSent) 370 | s.assertMessagesCount(0, troutbox.StatusNone) 371 | s.assertMessagesCount(2, troutbox.StatusDead) 372 | } 373 | 374 | // Ensure some messages are sent successfully while others fail. 375 | func (s *OutboxPsqlTestSuite) TestPartialRetry() { 376 | outb := s.newOutbox(nil) 377 | 378 | key1 := "key1" 379 | key2 := "key2" 380 | value1 := []byte("value1") 381 | value2 := []byte("value2") 382 | i := 0 383 | 384 | var ( 385 | expTimestamp1 *time.Time 386 | expTimestamp2 *time.Time 387 | ) 388 | 389 | s.mockSender.EXPECT(). 390 | Send(mock.Anything, mock.Anything). 391 | RunAndReturn(func(_ context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 392 | switch i { 393 | case 0: 394 | s.Assert().Len(messages, 2) 395 | s.Assert().Less(messages[0].Retries, maxRetries) 396 | s.Assert().Less(messages[1].Retries, maxRetries) 397 | s.assertMessageEquals(messages[0], key1, value1, *expTimestamp1) 398 | s.assertMessageEquals(messages[1], key2, value2, *expTimestamp2) 399 | messages[0].MarkAsSent() 400 | messages[1].MarkAsFailed() 401 | i++ 402 | case 1, 2: 403 | s.Assert().Len(messages, 1) 404 | s.Assert().Less(messages[0].Retries, maxRetries) 405 | s.assertMessageEquals(messages[0], key2, value2, *expTimestamp2) 406 | messages[0].MarkAsFailed() 407 | i++ 408 | } 409 | 410 | return messages, nil 411 | }).Times(int(maxRetries)) 412 | 413 | now1 := time.Now() 414 | expTimestamp1 = &now1 415 | if err := outb.AddMessage(s.T().Context(), nil, key1, value1); err != nil { 416 | s.T().Fatalf("Failed to add message 1: %v", err) 417 | } 418 | 419 | now2 := time.Now() 420 | expTimestamp2 = &now2 421 | if err := outb.AddMessage(s.T().Context(), nil, key2, value2); err != nil { 422 | s.T().Fatalf("Failed to add message 2: %v", err) 423 | } 424 | 425 | s.startOutbox(s.T().Context(), outb) 426 | 427 | time.Sleep(200 * time.Millisecond) 428 | s.assertMessagesCount(1, troutbox.StatusSent) 429 | s.assertMessagesCount(0, troutbox.StatusNone) 430 | s.assertMessagesCount(1, troutbox.StatusDead) 431 | } 432 | 433 | // Ensure no errors occur when there are no messages to process. 434 | func (s *OutboxPsqlTestSuite) TestNoMessages() { 435 | outb := s.newOutbox(nil) 436 | s.startOutbox(s.T().Context(), outb) 437 | 438 | s.mockSender.AssertNotCalled(s.T(), "Send") 439 | 440 | time.Sleep(100 * time.Millisecond) 441 | s.assertMessagesCount(0, troutbox.StatusSent) 442 | s.assertMessagesCount(0, troutbox.StatusNone) 443 | s.assertMessagesCount(0, troutbox.StatusDead) 444 | } 445 | 446 | // Ensure messages are added to the outbox within a transaction. 447 | func (s *OutboxPsqlTestSuite) TestAddMessageInTransaction() { 448 | outb := s.newOutbox(nil) 449 | s.startOutbox(s.T().Context(), outb) 450 | 451 | key1 := "key1" 452 | key2 := "key2" 453 | value1 := []byte("value1") 454 | value2 := []byte("value2") 455 | 456 | var expTimestamp1 *time.Time 457 | 458 | s.mockSender.EXPECT(). 459 | Send(mock.Anything, mock.Anything). 460 | RunAndReturn(func(_ context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 461 | s.Assert().Len(messages, 1) 462 | s.assertMessageEquals(messages[0], key1, value1, *expTimestamp1) 463 | messages[0].MarkAsSent() 464 | return messages, nil 465 | }).Times(1) 466 | 467 | // Add the message 1 and commit the transaction. 468 | tx, err := s.db.BeginTx(s.T().Context(), nil) 469 | s.Require().NoError(err) 470 | 471 | now1 := time.Now() 472 | expTimestamp1 = &now1 473 | if err := outb.AddMessage(s.T().Context(), tx, key1, value1); err != nil { 474 | s.T().Fatalf("Failed to add message 1: %v", err) 475 | } 476 | 477 | err = tx.Commit() 478 | s.Require().NoError(err) 479 | 480 | // Add the message 2 and rollback the transaction. 481 | tx, err = s.db.BeginTx(s.T().Context(), nil) 482 | s.Require().NoError(err) 483 | 484 | if err := outb.AddMessage(s.T().Context(), tx, key2, value2); err != nil { 485 | s.T().Fatalf("Failed to add message 2: %v", err) 486 | } 487 | 488 | err = tx.Rollback() 489 | s.Require().NoError(err) 490 | 491 | // Wait for a short period to allow the outbox to process the message. 492 | time.Sleep(100 * time.Millisecond) 493 | s.assertMessagesCount(1, troutbox.StatusSent) 494 | s.assertMessagesCount(0, troutbox.StatusNone) 495 | s.assertMessagesCount(0, troutbox.StatusDead) 496 | } 497 | 498 | // Ensure multiple workers can process the same outbox table without conflicts. 499 | func (s *OutboxPsqlTestSuite) TestMultipleWorkers() { 500 | sender1 := mocks.NewMockSender(s.T()) 501 | outb1 := s.newOutbox(sender1) 502 | 503 | sender2 := mocks.NewMockSender(s.T()) 504 | outb2 := s.newOutbox(sender2) 505 | 506 | // It'll get only one batch. 507 | sender1.EXPECT(). 508 | Send(mock.Anything, mock.Anything). 509 | RunAndReturn(func(_ context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 510 | s.Assert().Len(messages, 5) 511 | time.Sleep(100 * time.Millisecond) // simulate some latency 512 | for i := 0; i < len(messages); i++ { 513 | messages[i].MarkAsSent() 514 | } 515 | return messages, nil 516 | }).Times(1) 517 | 518 | // It'll handle two batches. It won't get the 3rd batch because the first worker has locked it. 519 | sender2.EXPECT(). 520 | Send(mock.Anything, mock.Anything). 521 | RunAndReturn(func(_ context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 522 | s.Assert().Len(messages, 5) 523 | for i := 0; i < len(messages); i++ { 524 | messages[i].MarkAsSent() 525 | } 526 | return messages, nil 527 | }).Times(2) 528 | 529 | // We add 15 messages, batch size is 5, so 3 batches will be sent. 530 | for i := 1; i <= 15; i++ { 531 | key := fmt.Sprintf("key%d", i) 532 | value := []byte(fmt.Sprintf("value%d", i)) 533 | if err := outb1.AddMessage(s.T().Context(), nil, key, value); err != nil { 534 | s.T().Fatalf("Failed to add message 1: %v", err) 535 | } 536 | } 537 | 538 | // Start the outboxes after adding the messages to ensure they are processed. 539 | s.startOutbox(s.T().Context(), outb1) 540 | s.startOutbox(s.T().Context(), outb2) 541 | 542 | time.Sleep(500 * time.Millisecond) 543 | s.assertMessagesCount(15, troutbox.StatusSent) 544 | s.assertMessagesCount(0, troutbox.StatusNone) 545 | s.assertMessagesCount(0, troutbox.StatusDead) 546 | } 547 | 548 | // Ensure messages locked by a crashed worker are retried by another worker. 549 | func (s *OutboxPsqlTestSuite) TestCrashedWorker() { 550 | sender1 := mocks.NewMockSender(s.T()) 551 | outb1 := s.newOutbox(sender1) 552 | 553 | sender2 := mocks.NewMockSender(s.T()) 554 | outb2 := s.newOutbox(sender2) 555 | 556 | // It'll get only one batch and crach. 557 | sender1.EXPECT(). 558 | Send(mock.Anything, mock.Anything). 559 | RunAndReturn(func(_ context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 560 | s.Assert().Len(messages, 5) 561 | panic("test panic") // simulate a crash 562 | }).Times(1) 563 | 564 | // It'll handle all batches as the first worker crashed. 565 | sender2.EXPECT(). 566 | Send(mock.Anything, mock.Anything). 567 | RunAndReturn(func(_ context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 568 | time.Sleep(100 * time.Millisecond) // simulate some latency, let the first worker get a batch 569 | s.Assert().Len(messages, 5) 570 | for i := 0; i < len(messages); i++ { 571 | messages[i].MarkAsSent() 572 | } 573 | return messages, nil 574 | }).Times(3) 575 | 576 | // We add 15 messages, batch size is 5, so 3 batches will be sent. 577 | for i := 1; i <= 15; i++ { 578 | key := fmt.Sprintf("key%d", i) 579 | value := []byte(fmt.Sprintf("value%d", i)) 580 | if err := outb1.AddMessage(s.T().Context(), nil, key, value); err != nil { 581 | s.T().Fatalf("Failed to add message 1: %v", err) 582 | } 583 | } 584 | 585 | // Start the outboxes after adding the messages to ensure they are processed. 586 | s.startOutbox(s.T().Context(), outb1) 587 | s.startOutbox(s.T().Context(), outb2) 588 | 589 | time.Sleep(500 * time.Millisecond) 590 | s.assertMessagesCount(15, troutbox.StatusSent) 591 | s.assertMessagesCount(0, troutbox.StatusNone) 592 | s.assertMessagesCount(0, troutbox.StatusDead) 593 | } 594 | 595 | // Ensure the outbox handles database connection failures gracefully. 596 | func (s *OutboxPsqlTestSuite) TestDatabaseConnectionFailure() { 597 | outb := s.newOutbox(nil) 598 | i := 0 599 | 600 | s.mockSender.EXPECT(). 601 | Send(mock.Anything, mock.Anything). 602 | RunAndReturn(func(_ context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 603 | s.Assert().Len(messages, 1) 604 | 605 | if i == 0 { 606 | // Simulate database connection failure. 607 | s.manageDBContainer("stop") 608 | i++ 609 | } 610 | 611 | messages[0].MarkAsSent() 612 | return messages, nil 613 | }).Times(2) 614 | 615 | if err := outb.AddMessage(s.T().Context(), nil, "key", []byte("value")); err != nil { 616 | s.T().Fatalf("Failed to add message: %v", err) 617 | } 618 | 619 | s.startOutbox(s.T().Context(), outb) 620 | time.Sleep(time.Second) 621 | 622 | // Restart the database container. 623 | s.manageDBContainer("start") 624 | 625 | time.Sleep(time.Second) 626 | s.assertMessagesCount(1, troutbox.StatusSent) 627 | s.assertMessagesCount(0, troutbox.StatusNone) 628 | s.assertMessagesCount(0, troutbox.StatusDead) 629 | } 630 | 631 | // Ensure the outbox handles message processing timeouts gracefully. 632 | func (s *OutboxPsqlTestSuite) TestMessageProcessingTimeout() { 633 | outb := s.newOutbox(nil) 634 | s.startOutbox(s.T().Context(), outb) 635 | 636 | s.mockSender.EXPECT(). 637 | Send(mock.Anything, mock.Anything). 638 | RunAndReturn(func(ctx context.Context, messages []troutbox.Message) ([]troutbox.Message, error) { 639 | s.Assert().Len(messages, 1) 640 | 641 | select { 642 | case <-ctx.Done(): 643 | // context.Canceled appears when the test is being finished 644 | s.Assert().True(errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(ctx.Err(), context.Canceled)) 645 | messages[0].MarkAsFailed() // it doesn't matter if we mark it as sent or failed 646 | case <-time.After(2 * sendTimeout): 647 | s.T().Error("should not reach here") 648 | } 649 | 650 | return messages, ctx.Err() 651 | }) 652 | 653 | if err := outb.AddMessage(s.T().Context(), nil, "key", []byte("value")); err != nil { 654 | s.T().Fatalf("Failed to add message: %v", err) 655 | } 656 | 657 | time.Sleep(300 * time.Millisecond) 658 | 659 | s.assertMessagesCount(0, troutbox.StatusSent) 660 | s.assertMessagesCount(1, troutbox.StatusNone) 661 | s.assertMessagesCount(0, troutbox.StatusDead) 662 | } 663 | 664 | // Ensure the repository adapter updates partitions correctly. 665 | func (s *OutboxPsqlTestSuite) TestUpdatePartitions() { 666 | const tableName = "outbox_messages_partitions_test" 667 | 668 | // 1. create repository with default options 669 | repo := psql.NewPostgresMessageRepository( 670 | s.db, 671 | lockID, 672 | psql.WithTableName(tableName), 673 | psql.WithFuturePartitions(2), 674 | psql.WithPastPartitions(2), 675 | ) 676 | 677 | // 2. create the table with partitions 678 | err := repo.Migrate(s.T().Context()) 679 | s.Require().NoError(err, "failed to migrate database") 680 | 681 | // 3. check if the tables are created 682 | now := time.Now().UTC().Truncate(24 * time.Hour) 683 | expPartitions := []string{ 684 | repo.GetPartitionName(now.Add(24 * time.Hour)), 685 | repo.GetPartitionName(now), 686 | } 687 | partitions := s.getPartitions(s.T().Context(), tableName) 688 | s.Require().Equal(expPartitions, partitions) 689 | 690 | // 4. create outdated partitions 691 | endTime := now 692 | for i := 0; i < 5; i++ { 693 | startTime := endTime.Add(-24 * time.Hour) 694 | expPartitions = append(expPartitions, repo.GetPartitionName(startTime)) 695 | err = repo.CreatePartition(s.T().Context(), startTime, endTime) 696 | s.Require().NoError(err, "failed to create partition") 697 | endTime = startTime 698 | } 699 | partitions = s.getPartitions(s.T().Context(), tableName) 700 | s.Require().Equal(expPartitions, partitions) 701 | 702 | // 5. clean up outdated partitions 703 | err = repo.Migrate(s.T().Context()) 704 | s.Require().NoError(err, "failed to migrate database") 705 | 706 | // 6. check if the tables are removed 707 | expPartitions = []string{ 708 | repo.GetPartitionName(now.Add(24 * time.Hour)), 709 | repo.GetPartitionName(now), 710 | repo.GetPartitionName(now.Add(-24 * time.Hour)), 711 | repo.GetPartitionName(now.Add(-48 * time.Hour)), 712 | } 713 | partitions = s.getPartitions(s.T().Context(), tableName) 714 | s.Require().Equal(expPartitions, partitions) 715 | } 716 | 717 | func (s *OutboxPsqlTestSuite) TestZeroOutdatedPartitions() { 718 | const tableName = "outbox_messages_partitions_test" 719 | 720 | // 1. create repository with default options 721 | repo := psql.NewPostgresMessageRepository( 722 | s.db, 723 | lockID, 724 | psql.WithTableName(tableName), 725 | psql.WithFuturePartitions(2), 726 | psql.WithPastPartitions(0), 727 | ) 728 | 729 | // 2. create the table with partitions 730 | err := repo.Migrate(s.T().Context()) 731 | s.Require().NoError(err, "failed to migrate database") 732 | 733 | // 3. check if the tables are created 734 | now := time.Now().UTC().Truncate(24 * time.Hour) 735 | expPartitions := []string{ 736 | repo.GetPartitionName(now.Add(24 * time.Hour)), 737 | repo.GetPartitionName(now), 738 | } 739 | partitions := s.getPartitions(s.T().Context(), tableName) 740 | s.Require().Equal(expPartitions, partitions) 741 | 742 | // 4. create outdated partitions 743 | endTime := now 744 | for i := 0; i < 5; i++ { 745 | startTime := endTime.Add(-24 * time.Hour) 746 | expPartitions = append(expPartitions, repo.GetPartitionName(startTime)) 747 | err = repo.CreatePartition(s.T().Context(), startTime, endTime) 748 | s.Require().NoError(err, "failed to create partition") 749 | endTime = startTime 750 | } 751 | partitions = s.getPartitions(s.T().Context(), tableName) 752 | s.Require().Equal(expPartitions, partitions) 753 | 754 | // 5. clean up outdated partitions 755 | err = repo.Migrate(s.T().Context()) 756 | s.Require().NoError(err, "failed to migrate database") 757 | 758 | // 6. check if the tables are removed 759 | expPartitions = []string{ 760 | repo.GetPartitionName(now.Add(24 * time.Hour)), 761 | repo.GetPartitionName(now), 762 | } 763 | partitions = s.getPartitions(s.T().Context(), tableName) 764 | s.Require().Equal(expPartitions, partitions) 765 | } 766 | -------------------------------------------------------------------------------- /troutbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Darkemon/troutbox/b0fb30d81bdaf8d2cb2de96170270d21bc0fc50b/troutbox.png --------------------------------------------------------------------------------