pigeon-rb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +343 -0
  3. data/lib/pigeon/active_job_integration.rb +32 -0
  4. data/lib/pigeon/api.rb +200 -0
  5. data/lib/pigeon/configuration.rb +161 -0
  6. data/lib/pigeon/core.rb +104 -0
  7. data/lib/pigeon/encryption.rb +213 -0
  8. data/lib/pigeon/generators/hanami/migration_generator.rb +89 -0
  9. data/lib/pigeon/generators/rails/install_generator.rb +32 -0
  10. data/lib/pigeon/generators/rails/migration_generator.rb +20 -0
  11. data/lib/pigeon/generators/rails/templates/create_outbox_messages.rb.erb +34 -0
  12. data/lib/pigeon/generators/rails/templates/initializer.rb.erb +88 -0
  13. data/lib/pigeon/hanami_integration.rb +78 -0
  14. data/lib/pigeon/health_check/kafka.rb +37 -0
  15. data/lib/pigeon/health_check/processor.rb +70 -0
  16. data/lib/pigeon/health_check/queue.rb +69 -0
  17. data/lib/pigeon/health_check.rb +63 -0
  18. data/lib/pigeon/logging/structured_logger.rb +181 -0
  19. data/lib/pigeon/metrics/collector.rb +200 -0
  20. data/lib/pigeon/mock_producer.rb +18 -0
  21. data/lib/pigeon/models/adapters/active_record_adapter.rb +133 -0
  22. data/lib/pigeon/models/adapters/rom_adapter.rb +150 -0
  23. data/lib/pigeon/models/outbox_message.rb +182 -0
  24. data/lib/pigeon/monitoring.rb +113 -0
  25. data/lib/pigeon/outbox.rb +61 -0
  26. data/lib/pigeon/processor/background_processor.rb +109 -0
  27. data/lib/pigeon/processor.rb +798 -0
  28. data/lib/pigeon/publisher.rb +524 -0
  29. data/lib/pigeon/railtie.rb +29 -0
  30. data/lib/pigeon/schema.rb +35 -0
  31. data/lib/pigeon/security.rb +30 -0
  32. data/lib/pigeon/serializer.rb +77 -0
  33. data/lib/pigeon/tasks/pigeon.rake +64 -0
  34. data/lib/pigeon/trace_api.rb +37 -0
  35. data/lib/pigeon/tracing/core.rb +119 -0
  36. data/lib/pigeon/tracing/messaging.rb +144 -0
  37. data/lib/pigeon/tracing.rb +107 -0
  38. data/lib/pigeon/version.rb +5 -0
  39. data/lib/pigeon.rb +52 -0
  40. metadata +127 -0
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pigeon configuration
4
+ Pigeon.configure do |config|
5
+ # Kafka client ID (defaults to application name)
6
+ config.client_id = Rails.application.class.module_parent_name.underscore
7
+
8
+ # Kafka broker configuration
9
+ config.kafka_brokers = ENV.fetch("KAFKA_BROKERS", "localhost:9092").split(",")
10
+
11
+ # Maximum number of retries for failed messages
12
+ config.max_retries = ENV.fetch("PIGEON_MAX_RETRIES", 10).to_i
13
+
14
+ # Base retry delay (will be used with exponential backoff)
15
+ config.retry_delay = ENV.fetch("PIGEON_RETRY_DELAY", 30).to_i # seconds
16
+
17
+ # Maximum retry delay
18
+ config.max_retry_delay = ENV.fetch("PIGEON_MAX_RETRY_DELAY", 86_400).to_i # 24 hours
19
+
20
+ # Whether to encrypt message payloads
21
+ config.encrypt_payload = ActiveModel::Type::Boolean.new.cast(ENV.fetch("PIGEON_ENCRYPT_PAYLOAD", false))
22
+
23
+ # Encryption key for payload encryption (if enabled)
24
+ config.encryption_key = ENV["PIGEON_ENCRYPTION_KEY"]
25
+
26
+ # Retention period for processed messages
27
+ config.retention_period = ENV.fetch("PIGEON_RETENTION_PERIOD", 7).to_i # days
28
+
29
+ # Use Rails logger
30
+ config.logger = Rails.logger
31
+
32
+ # Register sensitive fields for masking in logs
33
+ # config.register_sensitive_fields(%i[password email credit_card])
34
+
35
+ # Dead letter queue configuration
36
+ config.dead_letter_queue_enabled = ActiveModel::Type::Boolean.new.cast(ENV.fetch("PIGEON_DLQ_ENABLED", true))
37
+ config.dead_letter_queue_suffix = ENV.fetch("PIGEON_DLQ_SUFFIX", ".dlq")
38
+
39
+ # Schema validation configuration
40
+ # config.schema_validation_enabled = true
41
+ # config.register_schema(:user, Rails.root.join("app/schemas/user.json").read)
42
+ end
43
+
44
+ # Start background processing in production environments
45
+ if Rails.env.production? && ActiveModel::Type::Boolean.new.cast(ENV.fetch("PIGEON_AUTO_START", false))
46
+ Rails.application.config.after_initialize do
47
+ # Don't start in Rails console or rake tasks
48
+ unless defined?(Rails::Console) || File.basename($PROGRAM_NAME) == "rake"
49
+ batch_size = ENV.fetch("PIGEON_BATCH_SIZE", 100).to_i
50
+ interval = ENV.fetch("PIGEON_INTERVAL", 5).to_i
51
+ thread_count = ENV.fetch("PIGEON_THREAD_COUNT", 2).to_i
52
+
53
+ Rails.logger.info("Starting Pigeon background processing")
54
+ Pigeon.start_processing(
55
+ batch_size: batch_size,
56
+ interval: interval,
57
+ thread_count: thread_count
58
+ )
59
+ end
60
+ end
61
+ end
62
+
63
+ # Schedule periodic jobs if ActiveJob is available and enabled
64
+ if defined?(ActiveJob) && ActiveModel::Type::Boolean.new.cast(ENV.fetch("PIGEON_SCHEDULE_JOBS", false))
65
+ Rails.application.config.after_initialize do
66
+ # Don't schedule in Rails console or rake tasks
67
+ unless defined?(Rails::Console) || File.basename($PROGRAM_NAME) == "rake"
68
+ # Use a job scheduler like Sidekiq-Scheduler, Whenever, or Rails' own scheduler
69
+ # This is just a placeholder - implement according to your job scheduling system
70
+ Rails.logger.info("Scheduling Pigeon periodic jobs")
71
+
72
+ # Example for Sidekiq-Scheduler (if available)
73
+ if defined?(Sidekiq) && defined?(Sidekiq::Scheduler)
74
+ Sidekiq.set_schedule("pigeon_processor", {
75
+ "class" => "Pigeon::ActiveJobIntegration::ProcessorJob",
76
+ "every" => ENV.fetch("PIGEON_PROCESS_INTERVAL", "1m"),
77
+ "args" => [ENV.fetch("PIGEON_BATCH_SIZE", 100).to_i]
78
+ })
79
+
80
+ Sidekiq.set_schedule("pigeon_cleanup", {
81
+ "class" => "Pigeon::ActiveJobIntegration::CleanupJob",
82
+ "every" => ENV.fetch("PIGEON_CLEANUP_INTERVAL", "1h"),
83
+ "args" => [ENV.fetch("PIGEON_RETENTION_PERIOD", 7).to_i]
84
+ })
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pigeon"
4
+
5
+ module Pigeon
6
+ # Hanami integration for Pigeon
7
+ module HanamiIntegration
8
+ # Register the Pigeon plugin with Hanami
9
+ class Plugin
10
+ def self.install(app, **_options)
11
+ # Configure Pigeon with Hanami logger
12
+ Pigeon.configure do |config|
13
+ config.logger = app["logger"]
14
+ config.client_id = app.name.to_s.underscore
15
+ end
16
+
17
+ # Register repositories and relations
18
+ register_repositories(app)
19
+ end
20
+
21
+ # Register repositories with Hanami container
22
+ def self.register_repositories(app)
23
+ # Register the outbox messages repository
24
+ app.register_provider :repositories, namespace: true do
25
+ prepare do
26
+ require "pigeon/models/adapters/rom_adapter"
27
+ end
28
+
29
+ start do
30
+ # Define the repository class
31
+ repository = Class.new(ROM::Repository[:outbox_messages]) do
32
+ commands :create, update: :by_pk, delete: :by_pk
33
+
34
+ def find(id)
35
+ outbox_messages.by_pk(id).one!
36
+ end
37
+
38
+ def find_by_status(status, limit = 100)
39
+ outbox_messages.by_status(status).limit(limit).to_a
40
+ end
41
+
42
+ def find_ready_for_retry(limit = 100)
43
+ outbox_messages.ready_for_retry.limit(limit).to_a
44
+ end
45
+ end
46
+
47
+ # Register the repository
48
+ register "outbox_messages", repository.new(target["persistence.rom"])
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ # Background job for processing outbox messages
55
+ class ProcessorJob
56
+ include Dry::Monads[:result]
57
+
58
+ # Process pending outbox messages
59
+ # @param batch_size [Integer] Number of messages to process in one batch
60
+ # @return [Hash] Processing statistics
61
+ def self.perform(batch_size = 100)
62
+ Pigeon.processor.process_pending(batch_size: batch_size)
63
+ end
64
+ end
65
+
66
+ # Background job for cleaning up processed outbox messages
67
+ class CleanupJob
68
+ include Dry::Monads[:result]
69
+
70
+ # Clean up processed outbox messages
71
+ # @param older_than [Integer] Age threshold for cleanup in days
72
+ # @return [Integer] Number of records cleaned up
73
+ def self.perform(older_than = 7)
74
+ Pigeon.processor.cleanup_processed(older_than: older_than)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pigeon
4
+ module HealthCheck
5
+ # Kafka health check functionality
6
+ module Kafka
7
+ # Check the health of Kafka connectivity
8
+ # @return [Hash] Health check result
9
+ def self.health
10
+ # Try to connect to Kafka
11
+ begin
12
+ # Use Karafka producer to check connectivity
13
+ Pigeon.karafka_producer.produce_sync("health_check", topic: "health_check", key: "health_check")
14
+
15
+ status = "healthy"
16
+ message = "Kafka connection is healthy"
17
+ details = { connected: true }
18
+ rescue StandardError => e
19
+ status = "critical"
20
+ message = "Failed to connect to Kafka: #{e.message}"
21
+ details = {
22
+ connected: false,
23
+ error_class: e.class.name,
24
+ error_message: e.message
25
+ }
26
+ end
27
+
28
+ {
29
+ component: "kafka",
30
+ status: status,
31
+ message: message,
32
+ details: details
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pigeon
4
+ module HealthCheck
5
+ # Processor health check functionality
6
+ module Processor
7
+ # Check the health of the processor
8
+ # @return [Hash] Health check result
9
+ def self.health
10
+ processor_running = Pigeon.processing?
11
+
12
+ # Get processor statistics
13
+ stats = statistics(processor_running)
14
+
15
+ # Determine overall status
16
+ status, message = status(processor_running, stats)
17
+
18
+ {
19
+ component: "processor",
20
+ status: status,
21
+ message: message,
22
+ details: stats
23
+ }
24
+ end
25
+
26
+ # Get processor statistics
27
+ # @param processor_running [Boolean] Whether the processor is running
28
+ # @return [Hash] Processor statistics
29
+ def self.statistics(processor_running)
30
+ stats = {
31
+ running: processor_running,
32
+ uptime_seconds: processor_running ? (Time.now - Pigeon.processor_start_time).to_i : 0,
33
+ last_run_at: Pigeon.last_processing_run,
34
+ last_successful_run_at: Pigeon.last_successful_processing_run
35
+ }
36
+
37
+ # Add error rate if metrics are available
38
+ add_error_rate_to_stats(stats)
39
+
40
+ stats
41
+ end
42
+
43
+ # Add error rate to statistics if metrics are available
44
+ # @param stats [Hash] Statistics hash to update
45
+ # @return [void]
46
+ def self.add_error_rate_to_stats(stats)
47
+ return unless Pigeon.config.metrics_collector
48
+
49
+ total_processed = Pigeon.metrics_collector.get_counter(:messages_processed_total) || 0
50
+ total_failed = Pigeon.metrics_collector.get_counter(:messages_failed_total) || 0
51
+
52
+ stats[:error_rate] = total_processed.positive? ? (total_failed.to_f / total_processed) : 0
53
+ end
54
+
55
+ # Determine processor status
56
+ # @param processor_running [Boolean] Whether the processor is running
57
+ # @param stats [Hash] Processor statistics
58
+ # @return [Array<String, String>] Status and message
59
+ def self.status(processor_running, stats)
60
+ if !processor_running
61
+ ["critical", "Processor is not running"]
62
+ elsif stats[:last_run_at].nil? || (Time.now - stats[:last_run_at]) > 300 # 5 minutes
63
+ ["warning", "Processor has not run in the last 5 minutes"]
64
+ else
65
+ ["healthy", "Processor is running normally"]
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pigeon
4
+ module HealthCheck
5
+ # Queue health check functionality
6
+ module Queue
7
+ # Check the health of the queue
8
+ # @return [Hash] Health check result
9
+ def self.health
10
+ # Get queue statistics
11
+ stats = statistics
12
+
13
+ # Determine overall status based on queue depth and age
14
+ status, message = status(stats)
15
+
16
+ {
17
+ component: "queue",
18
+ status: status,
19
+ message: message,
20
+ details: stats
21
+ }
22
+ end
23
+
24
+ # Get queue statistics
25
+ # @return [Hash] Queue statistics
26
+ def self.statistics
27
+ pending_count = Pigeon.count_outbox_messages_by_status("pending")
28
+ failed_count = Pigeon.count_outbox_messages_by_status("failed")
29
+
30
+ # Get the oldest pending message
31
+ oldest_pending = Pigeon.find_oldest_outbox_message_by_status("pending")
32
+ oldest_pending_age = oldest_pending ? (Time.now - oldest_pending.created_at).to_i : 0
33
+
34
+ {
35
+ pending_count: pending_count,
36
+ failed_count: failed_count,
37
+ oldest_pending_age_seconds: oldest_pending_age,
38
+ oldest_pending_id: oldest_pending&.id
39
+ }
40
+ end
41
+
42
+ # Determine queue status
43
+ # @param stats [Hash] Queue statistics
44
+ # @return [Array<String, String>] Status and message
45
+ def self.status(stats)
46
+ pending_count = stats[:pending_count]
47
+ age_seconds = stats[:oldest_pending_age_seconds]
48
+
49
+ message = queue_status_message(pending_count, age_seconds)
50
+
51
+ if pending_count > 1000 || age_seconds > 3600 # 1 hour
52
+ ["critical", message]
53
+ elsif pending_count > 100 || age_seconds > 300 # 5 minutes
54
+ ["warning", message]
55
+ else
56
+ ["healthy", "Queue is processing normally"]
57
+ end
58
+ end
59
+
60
+ # Generate queue status message
61
+ # @param pending_count [Integer] Number of pending messages
62
+ # @param age_seconds [Integer] Age of oldest message in seconds
63
+ # @return [String] Status message
64
+ def self.queue_status_message(pending_count, age_seconds)
65
+ "Queue has #{pending_count} pending messages, oldest is #{age_seconds} seconds old"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "health_check/processor"
4
+ require_relative "health_check/queue"
5
+ require_relative "health_check/kafka"
6
+
7
+ module Pigeon
8
+ # Health check module for Pigeon
9
+ module HealthCheck
10
+ # Check the health of the processor
11
+ # @return [Hash] Health check result
12
+ def self.processor_health
13
+ Processor.health
14
+ end
15
+
16
+ # Check the health of the queue
17
+ # @return [Hash] Health check result
18
+ def self.queue_health
19
+ Queue.health
20
+ end
21
+
22
+ # Check the health of Kafka connectivity
23
+ # @return [Hash] Health check result
24
+ def self.kafka_health
25
+ Kafka.health
26
+ end
27
+
28
+ # Get overall health status
29
+ # @return [Hash] Overall health status
30
+ def self.status
31
+ processor = processor_health
32
+ queue = queue_health
33
+ kafka = kafka_health
34
+
35
+ # Determine overall status (worst of all components)
36
+ components = [processor, queue, kafka]
37
+ status = determine_overall_status(components)
38
+
39
+ {
40
+ status: status,
41
+ components: {
42
+ processor: processor,
43
+ queue: queue,
44
+ kafka: kafka
45
+ },
46
+ timestamp: Time.now.iso8601
47
+ }
48
+ end
49
+
50
+ # Determine the overall status based on component statuses
51
+ # @param components [Array<Hash>] Component health check results
52
+ # @return [String] Overall status
53
+ def self.determine_overall_status(components)
54
+ if components.any? { |c| c[:status] == "critical" }
55
+ "critical"
56
+ elsif components.any? { |c| c[:status] == "warning" }
57
+ "warning"
58
+ else
59
+ "healthy"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
5
+
6
+ module Pigeon
7
+ module Logging
8
+ # Structured logger for Pigeon
9
+ class StructuredLogger
10
+ # Log levels
11
+ LEVELS = {
12
+ debug: Logger::DEBUG,
13
+ info: Logger::INFO,
14
+ warn: Logger::WARN,
15
+ error: Logger::ERROR,
16
+ fatal: Logger::FATAL
17
+ }.freeze
18
+
19
+ # Initialize a new structured logger
20
+ # @param logger [Logger] Base logger to use
21
+ # @param default_context [Hash] Default context to include in all log entries
22
+ def initialize(logger = nil, default_context = {})
23
+ @logger = logger || Logger.new($stdout)
24
+ @default_context = default_context || {}
25
+ end
26
+
27
+ # Set the log level
28
+ # @param level [Symbol, String, Integer] Log level
29
+ # @return [Integer] New log level
30
+ def level=(level)
31
+ level_int = level_to_int(level)
32
+ @logger.level = level_int
33
+ end
34
+
35
+ # Get the current log level
36
+ # @return [Integer] Current log level
37
+ def level
38
+ @logger.level
39
+ end
40
+
41
+ # Check if a log level is enabled
42
+ # @param level [Symbol, String, Integer] Log level
43
+ # @return [Boolean] Whether the log level is enabled
44
+ def level_enabled?(level)
45
+ @logger.level <= level_to_int(level)
46
+ end
47
+
48
+ # Log a debug message
49
+ # @param message [String] Log message
50
+ # @param context [Hash] Additional context for the log entry
51
+ # @return [void]
52
+ def debug(message, context = {})
53
+ log(:debug, message, context)
54
+ end
55
+
56
+ # Log an info message
57
+ # @param message [String] Log message
58
+ # @param context [Hash] Additional context for the log entry
59
+ # @return [void]
60
+ def info(message, context = {})
61
+ log(:info, message, context)
62
+ end
63
+
64
+ # Log a warning message
65
+ # @param message [String] Log message
66
+ # @param context [Hash] Additional context for the log entry
67
+ # @return [void]
68
+ def warn(message, context = {})
69
+ log(:warn, message, context)
70
+ end
71
+
72
+ # Log an error message
73
+ # @param message [String] Log message
74
+ # @param context [Hash] Additional context for the log entry
75
+ # @param error [Exception, nil] Optional error to include in the log entry
76
+ # @return [void]
77
+ def error(message, context = {}, error = nil)
78
+ context = context.dup
79
+ if error
80
+ context[:error] = {
81
+ class: error.class.name,
82
+ message: error.message,
83
+ backtrace: error.backtrace&.take(10)
84
+ }
85
+ end
86
+ log(:error, message, context)
87
+ end
88
+
89
+ # Log a fatal message
90
+ # @param message [String] Log message
91
+ # @param context [Hash] Additional context for the log entry
92
+ # @param error [Exception, nil] Optional error to include in the log entry
93
+ # @return [void]
94
+ def fatal(message, context = {}, error = nil)
95
+ context = context.dup
96
+ if error
97
+ context[:error] = {
98
+ class: error.class.name,
99
+ message: error.message,
100
+ backtrace: error.backtrace
101
+ }
102
+ end
103
+ log(:fatal, message, context)
104
+ end
105
+
106
+ # Check if debug level is enabled
107
+ # @return [Boolean] Whether debug level is enabled
108
+ def debug?
109
+ level_enabled?(:debug)
110
+ end
111
+
112
+ # Check if info level is enabled
113
+ # @return [Boolean] Whether info level is enabled
114
+ def info?
115
+ level_enabled?(:info)
116
+ end
117
+
118
+ # Check if warn level is enabled
119
+ # @return [Boolean] Whether warn level is enabled
120
+ def warn?
121
+ level_enabled?(:warn)
122
+ end
123
+
124
+ # Check if error level is enabled
125
+ # @return [Boolean] Whether error level is enabled
126
+ def error?
127
+ level_enabled?(:error)
128
+ end
129
+
130
+ # Check if fatal level is enabled
131
+ # @return [Boolean] Whether fatal level is enabled
132
+ def fatal?
133
+ level_enabled?(:fatal)
134
+ end
135
+
136
+ # Create a new logger with additional default context
137
+ # @param additional_context [Hash] Additional context to include in all log entries
138
+ # @return [StructuredLogger] New logger instance
139
+ def with_context(additional_context)
140
+ self.class.new(@logger, @default_context.merge(additional_context))
141
+ end
142
+
143
+ private
144
+
145
+ # Log a message at the specified level
146
+ # @param level [Symbol] Log level
147
+ # @param message [String] Log message
148
+ # @param context [Hash] Additional context for the log entry
149
+ # @return [void]
150
+ def log(level, message, context = {})
151
+ return unless level_enabled?(level)
152
+
153
+ # Combine default context and provided context
154
+ combined_context = @default_context.merge(context)
155
+
156
+ # Create the log entry
157
+ log_entry = {
158
+ timestamp: Time.now.iso8601(3),
159
+ level: level.to_s.upcase,
160
+ message: message
161
+ }
162
+
163
+ # Add context if present
164
+ log_entry[:context] = combined_context unless combined_context.empty?
165
+
166
+ # Log as JSON
167
+ @logger.send(level, log_entry.to_json)
168
+ end
169
+
170
+ # Convert a log level to its integer value
171
+ # @param level [Symbol, String, Integer] Log level
172
+ # @return [Integer] Integer log level
173
+ def level_to_int(level)
174
+ return level if level.is_a?(Integer)
175
+ return LEVELS[level.to_sym] if level.respond_to?(:to_sym) && LEVELS.key?(level.to_sym)
176
+
177
+ Logger::INFO
178
+ end
179
+ end
180
+ end
181
+ end