Work towards fixing embed images (#174)

* Add Redis and Sidekiq

* Rename PreviewGenerationMonitor

* Update production.rb

require master key

* Initialize AWS at application start

* Add fallbacks for credentials

* Add logging

* Create railway.toml
This commit is contained in:
Justin Edmund 2025-01-18 11:46:41 -08:00 committed by GitHub
parent e3a44ca0d5
commit 1c1ed0dd9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 191 additions and 56 deletions

View file

@ -47,6 +47,12 @@ gem 'email_validator'
# pg_search builds ActiveRecord named scopes that take advantage of PostgreSQLs full text search # pg_search builds ActiveRecord named scopes that take advantage of PostgreSQLs full text search
gem 'pg_search' gem 'pg_search'
# A Ruby client library for Redis
gem 'redis'
# Simple, efficient background processing for Ruby
gem 'sidekiq'
# scheduler for Ruby (at, in, cron and every jobs) # scheduler for Ruby (at, in, cron and every jobs)
gem 'rufus-scheduler' gem 'rufus-scheduler'

View file

@ -299,6 +299,10 @@ GEM
rbs (2.8.4) rbs (2.8.4)
rdoc (6.10.0) rdoc (6.10.0)
psych (>= 4.0.0) psych (>= 4.0.0)
redis (5.3.0)
redis-client (>= 0.22.0)
redis-client (0.23.2)
connection_pool
regexp_parser (2.10.0) regexp_parser (2.10.0)
reline (0.6.0) reline (0.6.0)
io-console (~> 0.5) io-console (~> 0.5)
@ -351,6 +355,11 @@ GEM
securerandom (0.4.1) securerandom (0.4.1)
shoulda-matchers (6.4.0) shoulda-matchers (6.4.0)
activesupport (>= 5.2.0) activesupport (>= 5.2.0)
sidekiq (7.3.7)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
simplecov (0.22.0) simplecov (0.22.0)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
@ -444,6 +453,7 @@ DEPENDENCIES
puma puma
rack-cors rack-cors
rails rails
redis
responders responders
rspec-rails rspec-rails
rspec_junit_formatter rspec_junit_formatter
@ -451,6 +461,7 @@ DEPENDENCIES
rufus-scheduler rufus-scheduler
sdoc sdoc
shoulda-matchers shoulda-matchers
sidekiq
simplecov simplecov
solargraph solargraph
spring spring

View file

@ -12,9 +12,7 @@ class GeneratePartyPreviewJob < ApplicationJob
around_perform :track_timing around_perform :track_timing
def perform(party_id) def perform(party_id)
# Log start of job processing
Rails.logger.info("Starting preview generation for party #{party_id}") Rails.logger.info("Starting preview generation for party #{party_id}")
party = Party.find(party_id) party = Party.find(party_id)
if party.preview_state == 'generated' && if party.preview_state == 'generated' &&
@ -26,7 +24,10 @@ class GeneratePartyPreviewJob < ApplicationJob
begin begin
service = PreviewService::Coordinator.new(party) service = PreviewService::Coordinator.new(party)
Rails.logger.info("Created PreviewService::Coordinator")
result = service.generate_preview result = service.generate_preview
Rails.logger.info("Generate preview result: #{result}")
if result if result
Rails.logger.info("Successfully generated preview for party #{party_id}") Rails.logger.info("Successfully generated preview for party #{party_id}")
@ -36,9 +37,10 @@ class GeneratePartyPreviewJob < ApplicationJob
end end
rescue => e rescue => e
Rails.logger.error("Error generating preview for party #{party_id}: #{e.message}") Rails.logger.error("Error generating preview for party #{party_id}: #{e.message}")
Rails.logger.error(e.backtrace.join("\n")) Rails.logger.error("Full error details:")
Rails.logger.error(e.full_message) # This will include the stack trace
notify_failure(party, e) notify_failure(party, e)
raise # Allow retry mechanism to handle the error raise
end end
end end

View file

@ -1,21 +1,21 @@
# frozen_string_literal: true
require 'aws-sdk-s3'
class AwsService class AwsService
attr_reader :s3_client, :bucket attr_reader :s3_client, :bucket
class ConfigurationError < StandardError; end class ConfigurationError < StandardError; end
def initialize def initialize
validate_credentials! Rails.logger.info "Environment: #{Rails.env}"
# Try different methods of getting credentials
creds = get_credentials
Rails.logger.info "Credentials source: #{creds[:source]}"
@s3_client = Aws::S3::Client.new( @s3_client = Aws::S3::Client.new(
region: Rails.application.credentials.dig(:aws, :region), region: creds[:region],
access_key_id: Rails.application.credentials.dig(:aws, :access_key_id), access_key_id: creds[:access_key_id],
secret_access_key: Rails.application.credentials.dig(:aws, :secret_access_key) secret_access_key: creds[:secret_access_key]
) )
@bucket = Rails.application.credentials.dig(:aws, :bucket_name) @bucket = creds[:bucket_name]
rescue KeyError => e rescue KeyError => e
raise ConfigurationError, "Missing AWS credential: #{e.message}" raise ConfigurationError, "Missing AWS credential: #{e.message}"
end end
@ -40,30 +40,59 @@ class AwsService
private private
def credentials def get_credentials
@credentials ||= begin # Try Rails credentials first
creds = Rails.application.credentials[:aws] rails_creds = Rails.application.credentials.dig(:aws)
raise ConfigurationError, 'AWS credentials not found' unless creds if rails_creds&.dig(:access_key_id)
Rails.logger.info "Using Rails credentials"
{ return rails_creds.merge(source: 'rails_credentials')
region: creds[:region],
access_key_id: creds[:access_key_id],
secret_access_key: creds[:secret_access_key],
bucket_name: creds[:bucket_name]
}
end
end
def validate_credentials!
missing = []
creds = Rails.application.credentials[:aws]
%i[region access_key_id secret_access_key bucket_name].each do |key|
missing << key unless creds&.dig(key)
end end
return unless missing.any? # Try string keys
rails_creds = Rails.application.credentials.dig('aws')
if rails_creds&.dig('access_key_id')
Rails.logger.info "Using Rails credentials (string keys)"
return {
region: rails_creds['region'],
access_key_id: rails_creds['access_key_id'],
secret_access_key: rails_creds['secret_access_key'],
bucket_name: rails_creds['bucket_name'],
source: 'rails_credentials_string'
}
end
raise ConfigurationError, "Missing AWS credentials: #{missing.join(', ')}" # Try environment variables
if ENV['AWS_ACCESS_KEY_ID']
Rails.logger.info "Using environment variables"
return {
region: ENV['AWS_REGION'],
access_key_id: ENV['AWS_ACCESS_KEY_ID'],
secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'],
bucket_name: ENV['AWS_BUCKET_NAME'],
source: 'environment'
}
end
# Try alternate environment variable names
if ENV['RAILS_AWS_ACCESS_KEY_ID']
Rails.logger.info "Using Rails-prefixed environment variables"
return {
region: ENV['RAILS_AWS_REGION'],
access_key_id: ENV['RAILS_AWS_ACCESS_KEY_ID'],
secret_access_key: ENV['RAILS_AWS_SECRET_ACCESS_KEY'],
bucket_name: ENV['RAILS_AWS_BUCKET_NAME'],
source: 'rails_environment'
}
end
validate_credentials = ->(creds, source) {
missing = []
%i[region access_key_id secret_access_key bucket_name].each do |key|
missing << key unless creds[key].present?
end
raise ConfigurationError, "Missing AWS credentials from #{source}: #{missing.join(', ')}" if missing.any?
}
raise ConfigurationError, "No AWS credentials found in any location"
end end
end end

View file

@ -15,12 +15,24 @@ module PreviewService
end end
def create_blank_canvas(width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT, color: DEFAULT_BACKGROUND_COLOR) def create_blank_canvas(width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT, color: DEFAULT_BACKGROUND_COLOR)
temp_file = Tempfile.new(%w[canvas .png]) Rails.logger.info("Checking ImageMagick installation...")
version = `convert -version`
Rails.logger.info("ImageMagick version: #{version}")
MiniMagick::Tool::Convert.new do |convert| temp_file = Tempfile.new(%w[canvas .png])
convert.size "#{width}x#{height}" Rails.logger.info("Created temp file: #{temp_file.path}")
convert << "xc:#{color}"
convert << temp_file.path begin
MiniMagick::Tool::Convert.new do |convert|
convert.size "#{width}x#{height}"
convert << "xc:#{color}"
convert << temp_file.path
end
Rails.logger.info("Canvas created successfully")
rescue => e
Rails.logger.error("Failed to create canvas: #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
raise
end end
temp_file temp_file

View file

@ -41,22 +41,33 @@ module PreviewService
Rails.logger.info("Starting preview generation for party #{@party.id}") Rails.logger.info("Starting preview generation for party #{@party.id}")
set_generation_in_progress set_generation_in_progress
# Generate the preview image Rails.logger.info("Creating preview image...")
image = create_preview_image image = create_preview_image
save_preview(image) Rails.logger.info("Preview image created successfully")
# Update party state Rails.logger.info("Saving preview...")
save_preview(image)
Rails.logger.info("Preview saved successfully")
Rails.logger.info("Updating party state...")
@party.update!( @party.update!(
preview_state: :generated, preview_state: :generated,
preview_generated_at: Time.current preview_generated_at: Time.current
) )
Rails.logger.info("Party state updated successfully")
true true
rescue => e rescue => e
Rails.logger.error("Failed to generate preview: #{e.class} - #{e.message}")
Rails.logger.error("Stack trace:")
Rails.logger.error(e.backtrace.join("\n"))
handle_preview_generation_error(e) handle_preview_generation_error(e)
false false
ensure ensure
Rails.logger.info("Cleaning up resources...")
@image_fetcher.cleanup @image_fetcher.cleanup
clear_generation_in_progress clear_generation_in_progress
Rails.logger.info("Cleanup completed")
end end
end end
@ -99,28 +110,34 @@ module PreviewService
# #
# @return [MiniMagick::Image] The generated preview image # @return [MiniMagick::Image] The generated preview image
def create_preview_image def create_preview_image
# Create blank canvas Rails.logger.info("Creating blank canvas...")
canvas = @canvas_service.create_blank_canvas canvas = @canvas_service.create_blank_canvas
image = MiniMagick::Image.new(canvas.path) image = MiniMagick::Image.new(canvas.path)
Rails.logger.info("Blank canvas created")
# Fetch job icon Rails.logger.info("Processing job icon...")
job_icon = nil job_icon = nil
if @party.job.present? if @party.job.present?
Rails.logger.info("Fetching job icon for job ID: #{@party.job.granblue_id}")
job_icon = @image_fetcher.fetch_job_icon(@party.job.granblue_id) job_icon = @image_fetcher.fetch_job_icon(@party.job.granblue_id)
Rails.logger.info("Job icon fetched successfully") if job_icon
end end
# Add party name with job icon Rails.logger.info("Adding party name and job icon...")
text_result = @canvas_service.add_text(image, @party.name, job_icon: job_icon, user: @party.user) text_result = @canvas_service.add_text(image, @party.name, job_icon: job_icon, user: @party.user)
image = text_result[:image] image = text_result[:image]
Rails.logger.info("Party name and job icon added")
# Calculate grid layout Rails.logger.info("Calculating grid layout...")
grid_layout = @grid_service.calculate_layout( grid_layout = @grid_service.calculate_layout(
canvas_height: Canvas::PREVIEW_HEIGHT, canvas_height: Canvas::PREVIEW_HEIGHT,
title_bottom_y: text_result[:text_bottom_y] title_bottom_y: text_result[:text_bottom_y]
) )
Rails.logger.info("Grid layout calculated")
# Organize and draw weapons Rails.logger.info("Drawing weapons...")
image = organize_and_draw_weapons(image, grid_layout) image = organize_and_draw_weapons(image, grid_layout)
Rails.logger.info("Weapons drawn successfully")
image image
end end
@ -196,15 +213,22 @@ module PreviewService
begin begin
image.write(temp_file.path) image.write(temp_file.path)
# Use timestamped filename similar to local storage
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
key = "#{PREVIEW_FOLDER}/#{@party.shortcode}_#{timestamp}.png"
File.open(temp_file.path, 'rb') do |file| File.open(temp_file.path, 'rb') do |file|
@aws_service.s3_client.put_object( @aws_service.s3_client.put_object(
bucket: S3_BUCKET, bucket: @aws_service.bucket,
key: preview_key, key: key,
body: file, body: file,
content_type: 'image/png', content_type: 'image/png',
acl: 'private' acl: 'private'
) )
end end
# Optionally, store this key on the party record if needed for retrieval
@party.update!(preview_s3_key: key)
ensure ensure
temp_file.close temp_file.close
temp_file.unlink temp_file.unlink

View file

@ -1,6 +1,7 @@
# app/services/preview_generation_monitor.rb require 'sidekiq/api'
module PreviewService module PreviewService
class PreviewGenerationMonitor class GenerationMonitor
class << self class << self
def check_stalled_jobs def check_stalled_jobs
Party.where(preview_state: :queued) Party.where(preview_state: :queued)

View file

@ -2,7 +2,8 @@ require "active_support/core_ext/integer/time"
Rails.application.configure do Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
config.require_master_key = true
# Code is not reloaded between requests. # Code is not reloaded between requests.
config.cache_classes = true config.cache_classes = true

View file

@ -0,0 +1,10 @@
Rails.application.config.after_initialize do
Rails.logger.info "Initializing AWS Service..."
begin
AwsService.new
Rails.logger.info "AWS Service initialized successfully"
rescue => e
Rails.logger.error "Failed to initialize AWS Service: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
end
end

View file

@ -0,0 +1,9 @@
# Fetch environment variables with defaults if not set
redis_url = ENV.fetch('REDIS_URL', 'redis://localhost')
redis_port = ENV.fetch('REDISPORT', '6379')
# Combine URL and port (adjust the path/DB as needed)
full_redis_url = "#{redis_url}/0"
# Initialize Redis using the constructed URL
$redis = Redis.new(url: full_redis_url)

View file

@ -5,14 +5,14 @@ unless defined?(Rails::Console) || Rails.env.test? || File.split($0).last == 'ra
scheduler = Rufus::Scheduler.new scheduler = Rufus::Scheduler.new
scheduler.every '5m' do scheduler.every '5m' do
PreviewGenerationMonitor.check_stalled_jobs PreviewService::GenerationMonitor.check_stalled_jobs
end end
scheduler.every '1h' do scheduler.every '1h' do
PreviewGenerationMonitor.retry_failed PreviewService::GenerationMonitor.retry_failed
end end
scheduler.every '1d' do scheduler.every '1d' do
PreviewGenerationMonitor.cleanup_old_previews PreviewService::GenerationMonitor.cleanup_old_previews
end end
end end

View file

@ -0,0 +1,14 @@
# Fetch environment variables with defaults if not set
redis_url = ENV.fetch('REDIS_URL', 'redis://localhost')
redis_port = ENV.fetch('REDISPORT', '6379')
# Combine URL and port (adjust the path/DB as needed)
full_redis_url = "#{redis_url}/0"
Sidekiq.configure_server do |config|
config.redis = { url: full_redis_url }
end
Sidekiq.configure_client do |config|
config.redis = { url: full_redis_url }
end

16
railway.toml Normal file
View file

@ -0,0 +1,16 @@
[phases.setup]
nixPkgs = [
"imagemagick",
"ghostscript", # For PDF/PS operations
"pkgconfig", # For gem compilation
"libmagickwand" # ImageMagick C library
]
[phases.install]
dependsOn = ["setup"]
[phases.build]
dependsOn = ["install"]
[start]
cmd = "bin/rails server"