diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index 46d54ed4233..426be18b029 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -15,7 +15,7 @@ This rule serves as high-level documentation for how you should write code in th - Lucide Icons for icons - OpenAI for AI chat - Database: PostgreSQL -- Jobs: Sidekiq + Redis +- Jobs: SolidQueue - External - Payments: Stripe - User bank data syncing: Plaid diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 442072675bb..5abfa84a097 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -9,7 +9,6 @@ x-rails-env: &rails_env POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres BUNDLE_PATH: /bundle - REDIS_URL: redis://redis:6379/1 services: app: @@ -27,37 +26,16 @@ services: <<: *rails_env depends_on: - db - - redis - - worker: - build: - context: .. - dockerfile: .devcontainer/Dockerfile - volumes: - - ..:/workspace:cached - - bundle_cache:/bundle - command: bundle exec sidekiq - restart: unless-stopped - environment: - <<: *rails_env - depends_on: - - redis - - redis: - image: redis:latest - volumes: - - redis-data:/data - restart: unless-stopped - db: image: postgres:latest volumes: - postgres-data:/var/lib/postgresql/data + ports: + - "5432:5432" restart: unless-stopped environment: <<: *db_env volumes: postgres-data: - redis-data: bundle_cache: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae6bb5e342..2306ec69af4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,6 @@ jobs: PLAID_CLIENT_ID: foo PLAID_SECRET: bar DATABASE_URL: postgres://postgres:postgres@localhost:5432 - REDIS_URL: redis://localhost:6379 RAILS_ENV: test services: @@ -93,12 +92,6 @@ jobs: - 5432:5432 options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 - redis: - image: redis - ports: - - 6379:6379 - options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 - steps: - name: Install packages run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev diff --git a/.ruby-version b/.ruby-version index f9892605c75..4b4ea1b6883 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.4 +3.4.6 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 575ba82c55d..8dca8d4bac8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Common Development Commands ### Development Server -- `bin/dev` - Start development server (Rails, Sidekiq, Tailwind CSS watcher) +- `bin/dev` - Start development server (Rails, Solid queue, Tailwind CSS watcher) - `bin/rails server` - Start Rails server only - `bin/rails console` - Open Rails console @@ -95,11 +95,11 @@ Two primary data ingestion methods: - Custom field mapping with transformation rules ### Background Processing -Sidekiq handles asynchronous tasks: +SolidQueue handles asynchronous tasks: - Account syncing (`SyncJob`) - Import processing (`ImportJob`) - AI chat responses (`AssistantResponseJob`) -- Scheduled maintenance via sidekiq-cron +- Scheduled maintenance via SolidQueue ### Frontend Architecture - **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript diff --git a/Dockerfile b/Dockerfile index 2248d827880..6864a5c8682 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax = docker/dockerfile:1 # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.4.4 +ARG RUBY_VERSION=3.4.6 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here diff --git a/Gemfile b/Gemfile index 9d4ac1fe962..45ff4c8702a 100644 --- a/Gemfile +++ b/Gemfile @@ -7,7 +7,6 @@ gem "rails", "~> 7.2.2" # Drivers gem "pg", "~> 1.5" -gem "redis", "~> 5.4" # Deployment gem "puma", ">= 5.0" @@ -31,15 +30,12 @@ gem "lookbook", "2.3.11" gem "hotwire_combobox" # Background Jobs -gem "sidekiq" -gem "sidekiq-cron" # Monitoring gem "vernier" gem "rack-mini-profiler" gem "sentry-ruby" gem "sentry-rails" -gem "sentry-sidekiq" gem "logtail-rails" gem "skylight", groups: [ :production ] @@ -122,3 +118,17 @@ group :test do gem "climate_control" gem "simplecov", require: false end + +gem "rails_db", "~> 2.5" + +gem "seed_dump", "~> 3.3" + +gem "solid_cache", "~> 1.0" + +gem "sqlite3", "~> 2.7" + +gem "solid_queue", "~> 1.2" + +gem "solid_cable", "~> 3.0" + +gem "mission_control-jobs", "~> 1.1" diff --git a/Gemfile.lock b/Gemfile.lock index d85c4caac44..65a3fd597cd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -149,9 +149,6 @@ GEM bigdecimal rexml crass (1.0.6) - cronex (0.15.0) - tzinfo - unicode (>= 0.4.4.5) css_parser (1.21.1) addressable csv (3.3.5) @@ -293,6 +290,18 @@ GEM faraday-follow_redirects jwt (2.10.2) base64 + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) langfuse-ruby (0.1.4) concurrent-ruby (~> 1.0) faraday (>= 1.8, < 3.0) @@ -351,6 +360,16 @@ GEM logger mini_mime (1.1.5) minitest (5.25.5) + mission_control-jobs (1.1.0) + actioncable (>= 7.1) + actionpack (>= 7.1) + activejob (>= 7.1) + activerecord (>= 7.1) + importmap-rails (>= 1.2.1) + irb (~> 1.13) + railties (>= 7.1) + stimulus-rails + turbo-rails mocha (2.7.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -492,6 +511,14 @@ GEM rails-settings-cached (2.9.6) activerecord (>= 5.0.0) railties (>= 5.0.0) + rails_db (2.5.0) + activerecord + csv + kaminari + rails (>= 5.0.0) + ransack (>= 2.3.2) + simple_form (>= 5.0.1) + terminal-table railties (7.2.2.2) actionpack (= 7.2.2.2) activesupport (= 7.2.2.2) @@ -502,6 +529,10 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) + ransack (4.3.0) + activerecord (>= 6.1.5) + activesupport (>= 6.1.5) + i18n rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) @@ -511,10 +542,6 @@ GEM erb psych (>= 4.0.0) redcarpet (3.6.1) - redis (5.4.0) - redis-client (>= 0.22.0) - redis-client (0.25.0) - connection_pool regexp_parser (2.10.0) reline (0.6.1) io-console (~> 0.5) @@ -575,6 +602,9 @@ GEM addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) + seed_dump (3.3.1) + activerecord (>= 4) + activesupport (>= 4) selenium-webdriver (4.34.0) base64 (~> 0.2) logger (~> 1.4) @@ -587,20 +617,9 @@ GEM sentry-ruby (5.26.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.26.0) - sentry-ruby (~> 5.26.0) - sidekiq (>= 3.0) - sidekiq (8.0.5) - connection_pool (>= 2.5.0) - json (>= 2.9.0) - logger (>= 1.6.2) - rack (>= 3.1.0) - redis-client (>= 0.23.2) - sidekiq-cron (2.3.0) - cronex (>= 0.13.0) - fugit (~> 1.8, >= 1.11.1) - globalid (>= 1.0.1) - sidekiq (>= 6.5.0) + simple_form (5.3.1) + actionpack (>= 5.2) + activemodel (>= 5.2) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -610,7 +629,31 @@ GEM skylight (6.0.4) activesupport (>= 5.2.0) smart_properties (1.17.0) + solid_cable (3.0.12) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.7) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.2.1) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11.0) + railties (>= 7.1) + thor (>= 1.3.1) sorbet-runtime (0.5.12163) + sqlite3 (2.7.4-aarch64-linux-gnu) + sqlite3 (2.7.4-aarch64-linux-musl) + sqlite3 (2.7.4-arm-linux-gnu) + sqlite3 (2.7.4-arm-linux-musl) + sqlite3 (2.7.4-arm64-darwin) + sqlite3 (2.7.4-x86_64-darwin) + sqlite3 (2.7.4-x86_64-linux-gnu) + sqlite3 (2.7.4-x86_64-linux-musl) stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) @@ -641,7 +684,6 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) unaccent (0.4.0) - unicode (0.4.4.5) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) @@ -727,6 +769,7 @@ DEPENDENCIES logtail-rails lookbook (= 2.3.11) lucide-rails! + mission_control-jobs (~> 1.1) mocha octokit omniauth (~> 2.1) @@ -742,22 +785,24 @@ DEPENDENCIES rack-mini-profiler rails (~> 7.2.2) rails-settings-cached + rails_db (~> 2.5) redcarpet - redis (~> 5.4) rotp (~> 6.3) rqrcode (~> 3.0) rubocop-rails-omakase ruby-lsp-rails ruby-openai rubyzip (~> 2.3) + seed_dump (~> 3.3) selenium-webdriver sentry-rails sentry-ruby - sentry-sidekiq - sidekiq - sidekiq-cron simplecov skylight + solid_cable (~> 3.0) + solid_cache (~> 1.0) + solid_queue (~> 1.2) + sqlite3 (~> 2.7) stackprof stimulus-rails stripe @@ -771,7 +816,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.4p34 + ruby 3.4.6p54 BUNDLED WITH - 2.6.7 + 2.7.2 diff --git a/Procfile.dev b/Procfile.dev index eb6eadebd30..98923918ae4 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,2 @@ web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0 css: bundle exec bin/rails tailwindcss:watch 2>/dev/null -worker: bundle exec sidekiq diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index 4f20b9346e3..06796605408 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -4,7 +4,6 @@ class AuthController < BaseController include Invitable skip_before_action :authenticate_request! - skip_before_action :check_api_key_rate_limit skip_before_action :log_api_access def signup diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index f176fff4b68..7c2bdf719c9 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -16,11 +16,8 @@ class Api::V1::BaseController < ApplicationController before_action :force_json_format # Use our custom authentication that supports both OAuth and API keys before_action :authenticate_request! - before_action :check_api_key_rate_limit before_action :log_api_access - - # Override Doorkeeper's default behavior to return JSON instead of redirecting def doorkeeper_unauthorized_render_options(error: nil) { json: { error: "unauthorized", message: "Access token is invalid, expired, or missing" } } @@ -98,55 +95,10 @@ def authenticate_api_key @current_user = @api_key.user @api_key.update_last_used! @authentication_method = :api_key - @rate_limiter = ApiRateLimiter.limit(@api_key) setup_current_context_for_api true end - # Check rate limits for API key authentication - def check_api_key_rate_limit - return unless @authentication_method == :api_key && @rate_limiter - - if @rate_limiter.rate_limit_exceeded? - usage_info = @rate_limiter.usage_info - render_rate_limit_exceeded(usage_info) - return false - end - - # Increment request count for successful API key requests - @rate_limiter.increment_request_count! - - # Add rate limit headers to response - add_rate_limit_headers(@rate_limiter.usage_info) - end - - # Render rate limit exceeded response - def render_rate_limit_exceeded(usage_info) - response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s - response.headers["X-RateLimit-Remaining"] = "0" - response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s - response.headers["Retry-After"] = usage_info[:reset_time].to_s - - Rails.logger.warn "API Rate Limit Exceeded: API Key #{@api_key.name} (User: #{@current_user.email}) - #{usage_info[:current_count]}/#{usage_info[:rate_limit]} requests" - - render_json({ - error: "rate_limit_exceeded", - message: "Rate limit exceeded. Try again in #{usage_info[:reset_time]} seconds.", - details: { - limit: usage_info[:rate_limit], - current: usage_info[:current_count], - reset_in_seconds: usage_info[:reset_time] - } - }, status: :too_many_requests) - end - - # Add rate limit headers to successful responses - def add_rate_limit_headers(usage_info) - response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s - response.headers["X-RateLimit-Remaining"] = usage_info[:remaining].to_s - response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s - end - # Render unauthorized response def render_unauthorized render_json({ error: "unauthorized", message: "Access token or API key is invalid, expired, or missing" }, status: :unauthorized) diff --git a/app/controllers/api/v1/usage_controller.rb b/app/controllers/api/v1/usage_controller.rb index b2a57df9e72..9d7bd4ee924 100644 --- a/app/controllers/api/v1/usage_controller.rb +++ b/app/controllers/api/v1/usage_controller.rb @@ -5,21 +5,12 @@ def show case @authentication_method when :api_key - usage_info = @rate_limiter.usage_info render_json({ api_key: { name: @api_key.name, scopes: @api_key.scopes, last_used_at: @api_key.last_used_at, created_at: @api_key.created_at - }, - rate_limit: { - tier: usage_info[:tier], - limit: usage_info[:rate_limit], - current_count: usage_info[:current_count], - remaining: usage_info[:remaining], - reset_in_seconds: usage_info[:reset_time], - reset_at: Time.current + usage_info[:reset_time].seconds } }) when :oauth diff --git a/app/controllers/concerns/onboardable.rb b/app/controllers/concerns/onboardable.rb index 60655094015..f2e69caf4c7 100644 --- a/app/controllers/concerns/onboardable.rb +++ b/app/controllers/concerns/onboardable.rb @@ -25,6 +25,7 @@ def redirectable_path?(path) return false if path.starts_with?("/subscription") return false if path.starts_with?("/onboarding") return false if path.starts_with?("/users") + return false if path.starts_with?("/jobs") return false if path.starts_with?("/api") # Exclude API endpoints from onboarding redirects [ diff --git a/app/controllers/concerns/self_hostable.rb b/app/controllers/concerns/self_hostable.rb index 3631571aea9..a863f17026b 100644 --- a/app/controllers/concerns/self_hostable.rb +++ b/app/controllers/concerns/self_hostable.rb @@ -3,8 +3,6 @@ module SelfHostable included do helper_method :self_hosted?, :self_hosted_first_login? - - prepend_before_action :verify_self_host_config end private @@ -15,29 +13,4 @@ def self_hosted? def self_hosted_first_login? self_hosted? && User.count.zero? end - - def verify_self_host_config - return unless self_hosted? - - # Special handling for Redis configuration error page - if controller_name == "pages" && action_name == "redis_configuration_error" - # If Redis is now working, redirect to home - if redis_connected? - redirect_to root_path, notice: "Redis is now configured properly! You can now setup your Sure application." - end - - return - end - - unless redis_connected? - redirect_to redis_configuration_error_path - end - end - - def redis_connected? - Redis.new.ping - true - rescue Redis::CannotConnectError - false - end end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 07a7d10f0e1..e4cddb383c0 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,8 +1,6 @@ class PagesController < ApplicationController include Periodable - skip_authentication only: :redis_configuration_error - def dashboard @balance_sheet = Current.family.balance_sheet @accounts = Current.family.accounts.visible.with_attached_logo @@ -66,10 +64,6 @@ def feedback render layout: "settings" end - def redis_configuration_error - render layout: "blank" - end - private def github_provider Provider::Registry.get_provider(:github) diff --git a/app/models/provider/twelve_data.rb b/app/models/provider/twelve_data.rb index 5a7738bb4d5..353cbd98903 100644 --- a/app/models/provider/twelve_data.rb +++ b/app/models/provider/twelve_data.rb @@ -48,29 +48,53 @@ def fetch_exchange_rate(from:, to:, date:) end rate = JSON.parse(response.body).dig("rate") - + if rate.nil? + Rails.logger.warn("#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} on: #{date}, response: #{response.body}") + raise InvalidExchangeRateError.new("Could not fetch exchange rate for #{from}/#{to} on #{date}, response: #{response.body}") + end Rate.new(date: date.to_date, from:, to:, rate: rate) end end + def fetch_exchange_cross_rates(from:, to:, start_date:, end_date:) + # Add a random delay to avoid rate limiting + sleep(rand(60..300)) + response = client.get("#{base_url}/time_series/cross") do |req| + req.params["base"] = "#{from}" + req.params["quote"] = "#{to}" + req.params["start_date"] = start_date.to_s + req.params["end_date"] = end_date.to_s + req.params["interval"] = "1day" + end + data = JSON.parse(response.body).dig("values") + if data.nil? + error_message = parsed.dig("message") || "No data returned" + error_code = parsed.dig("code") || "unknown" + Rails.logger.warn("#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} between: #{start_date} and #{end_date}, response: #{response.body}, error: #{error_message} (code: #{error_code})") + raise InvalidExchangeRateError.new("Could not fetch exchange rates for #{from}/#{to} between #{start_date} and #{end_date}, response: #{response.body}") + end + data + end + + def fetch_exchange_rates_internal(from:, to:, start_date:, end_date:) + response = client.get("#{base_url}/time_series") do |req| + req.params["symbol"] = "#{from}/#{to}" + req.params["start_date"] = start_date.to_s + req.params["end_date"] = end_date.to_s + req.params["interval"] = "1day" + end + parsed = JSON.parse(response.body) + if parsed.dig("code") == 404 + error_message = parsed.dig("message") || "No data returned" + error_code = parsed.dig("code") || "unknown" + Rails.logger.warn("#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} between: #{start_date} and #{end_date}, response: #{response.body}, error: #{error_message} (code: #{error_code})") + fetch_exchange_cross_rates(from:, to:, start_date:, end_date:) + end + parsed.dig("values") + end def fetch_exchange_rates(from:, to:, start_date:, end_date:) with_provider_response do - response = client.get("#{base_url}/time_series") do |req| - req.params["symbol"] = "#{from}/#{to}" - req.params["start_date"] = start_date.to_s - req.params["end_date"] = end_date.to_s - req.params["interval"] = "1day" - end - - parsed = JSON.parse(response.body) - data = parsed.dig("values") - - if data.nil? - error_message = parsed.dig("message") || "No data returned" - error_code = parsed.dig("code") || "unknown" - raise InvalidExchangeRateError, "API error (code: #{error_code}): #{error_message}" - end - + data = fetch_exchange_rates_internal(from:, to:, start_date:, end_date:) data.map do |resp| rate = resp.dig("close") date = resp.dig("datetime") @@ -167,15 +191,13 @@ def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end end parsed = JSON.parse(response.body) - values = parsed.dig("values") - - if values.nil? + if !parsed.dig("code").nil? error_message = parsed.dig("message") || "No data returned" error_code = parsed.dig("code") || "unknown" - raise InvalidSecurityPriceError, "API error (code: #{error_code}): #{error_message}" + Rails.logger.warn("#{self.class.name} returned invalid price data for security #{symbol} between #{start_date} and #{end_date}.") + raise InvalidSecurityPriceError.new("Could not fetch security prices for #{symbol} between #{start_date} and #{end_date}, response: #{response.body}, error: #{error_message} (code: #{error_code})") end - - values.map do |resp| + parsed.dig("values").map do |resp| price = resp.dig("close") date = resp.dig("datetime") if price.nil? @@ -207,7 +229,13 @@ def client max: 2, interval: 0.05, interval_randomness: 0.5, - backoff_factor: 2 + backoff_factor: 2, + exceptions: [ Faraday::TooManyRequestsError, Faraday::RetriableResponse ], + retry_statuses: [ 429 ], + retry_block: ->(env, _, retries, _) { + # Sleep between 1-10 minutes when retrying + sleep(60 + rand(540)) + } }) faraday.request :json diff --git a/app/models/sync.rb b/app/models/sync.rb index 3e2abfe6ee4..3a299eb160a 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -61,7 +61,7 @@ def clean def perform Rails.logger.tagged("Sync", id, syncable_type, syncable_id) do - # This can happen on server restarts or if Sidekiq enqueues a duplicate job + # This can happen on server restarts or if ~~Sidekiq~~ SolidQueue enqueues a duplicate job unless may_start? Rails.logger.warn("Sync #{id} is not in a valid state (#{aasm.from_state}) to start. Skipping sync.") return @@ -159,7 +159,7 @@ def report_error(error) def report_warnings todays_sync_count = syncable.syncs.where(created_at: Date.current.all_day).count - if todays_sync_count > 10 + if todays_sync_count > 100 Sentry.capture_exception( Error.new("#{syncable_type} (#{syncable.id}) has exceeded 10 syncs today (count: #{todays_sync_count})"), level: :warning diff --git a/app/services/api_rate_limiter.rb b/app/services/api_rate_limiter.rb deleted file mode 100644 index d3a771cf5c9..00000000000 --- a/app/services/api_rate_limiter.rb +++ /dev/null @@ -1,95 +0,0 @@ -class ApiRateLimiter - # Rate limit tiers (requests per hour) - RATE_LIMITS = { - standard: 100, - premium: 1000, - enterprise: 10000 - }.freeze - - DEFAULT_TIER = :standard - - def initialize(api_key) - @api_key = api_key - @redis = Redis.new - end - - # Check if the API key has exceeded its rate limit - def rate_limit_exceeded? - current_count >= rate_limit - end - - # Increment the request count for this API key - def increment_request_count! - key = redis_key - current_time = Time.current.to_i - window_start = (current_time / 3600) * 3600 # Hourly window - - @redis.multi do |transaction| - # Use a sliding window with hourly buckets - transaction.hincrby(key, window_start.to_s, 1) - transaction.expire(key, 7200) # Keep data for 2 hours to handle sliding window - end - end - - # Get current request count within the current hour - def current_count - key = redis_key - current_time = Time.current.to_i - window_start = (current_time / 3600) * 3600 - - count = @redis.hget(key, window_start.to_s) - count.to_i - end - - # Get the rate limit for this API key's tier - def rate_limit - tier = determine_tier - RATE_LIMITS[tier] - end - - # Calculate seconds until the rate limit resets - def reset_time - current_time = Time.current.to_i - next_window = ((current_time / 3600) + 1) * 3600 - next_window - current_time - end - - # Get detailed usage information - def usage_info - { - current_count: current_count, - rate_limit: rate_limit, - remaining: [ rate_limit - current_count, 0 ].max, - reset_time: reset_time, - tier: determine_tier - } - end - - # Class method to get usage for an API key without incrementing - def self.usage_for(api_key) - limit(api_key).usage_info - end - - def self.limit(api_key) - if Rails.application.config.app_mode.self_hosted? - # Use NoopApiRateLimiter for self-hosted mode - # This means no rate limiting is applied - NoopApiRateLimiter.new(api_key) - else - new(api_key) - end - end - - private - - def redis_key - "api_rate_limit:#{@api_key.id}" - end - - def determine_tier - # For now, all API keys are standard tier - # This can be extended later to support different tiers based on user subscription - # or API key configuration - DEFAULT_TIER - end -end diff --git a/app/services/noop_api_rate_limiter.rb b/app/services/noop_api_rate_limiter.rb deleted file mode 100644 index 116b6537ed8..00000000000 --- a/app/services/noop_api_rate_limiter.rb +++ /dev/null @@ -1,39 +0,0 @@ -class NoopApiRateLimiter - def initialize(api_key) - @api_key = api_key - end - - def rate_limit_exceeded? - false - end - - def increment_request_count! - # No operation - end - - def current_count - 0 - end - - def rate_limit - Float::INFINITY - end - - def reset_time - 0 - end - - def usage_info - { - current_count: 0, - rate_limit: Float::INFINITY, - remaining: Float::INFINITY, - reset_time: 0, - tier: :noop - } - end - - def self.usage_for(api_key) - new(api_key).usage_info - end -end diff --git a/app/views/impersonation_sessions/_super_admin_bar.html.erb b/app/views/impersonation_sessions/_super_admin_bar.html.erb index a09a8250fa3..a6ad6b43a5e 100644 --- a/app/views/impersonation_sessions/_super_admin_bar.html.erb +++ b/app/views/impersonation_sessions/_super_admin_bar.html.erb @@ -4,7 +4,7 @@ Super Admin
- <%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %> + <%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %>
diff --git a/app/views/pages/redis_configuration_error.html.erb b/app/views/pages/redis_configuration_error.html.erb deleted file mode 100644 index 53c43387f4a..00000000000 --- a/app/views/pages/redis_configuration_error.html.erb +++ /dev/null @@ -1,59 +0,0 @@ -<% content_for :title, "Redis Configuration Required - Sure" %> - -
-
-
- -
-
- <%= icon "alert-triangle", class: "w-8 h-8 text-red-600" %> -
-

Redis Configuration Required

-

Your self-hosted Sure installation needs Redis to be properly configured.

-
- - -
-
-
- <%= icon "info", class: "w-5 h-5 text-amber-600 mt-0.5 mr-3 flex-shrink-0" %> -
-

Why is Redis required?

-

Sure uses Redis to power Sidekiq background jobs for tasks like syncing account data, processing imports, and other background operations that keep your financial data up to date.

-
-
-
- - -
- <%= render DS::Link.new( - text: "View Setup Guide", - href: "https://github.com/we-promise/sure/blob/main/docs/hosting/docker.md", - variant: "primary", - size: "lg", - icon: "external-link", - full_width: true, - target: "_blank", - rel: "noopener noreferrer" - ) %> -

Follow our complete Docker setup guide to configure Redis

-
-
- - -
-
-

Once you've configured Redis, refresh this page to continue.

- <%= render DS::Button.new( - text: "Refresh Page", - variant: "secondary", - icon: "refresh-cw", - type: "button", - full_width: true, - onclick: "window.location.reload()" - ) %> -
-
-
-
-
diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 00000000000..dcf59f309ae --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/compose.example.yml b/compose.example.yml index 01a97368b0d..172461178d1 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -40,7 +40,6 @@ x-rails-env: &rails_env RAILS_ASSUME_SSL: "false" DB_HOST: db DB_PORT: 5432 - REDIS_URL: redis://redis:6379/1 # NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this. OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN} @@ -57,24 +56,6 @@ services: depends_on: db: condition: service_healthy - redis: - condition: service_healthy - networks: - - sure_net - - worker: - image: ghcr.io/we-promise/sure:latest - command: bundle exec sidekiq - volumes: - - app-storage:/rails/storage - restart: unless-stopped - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - environment: - <<: *rails_env networks: - sure_net @@ -93,23 +74,9 @@ services: networks: - sure_net - redis: - image: redis:latest - restart: unless-stopped - volumes: - - redis-data:/data - healthcheck: - test: [ "CMD", "redis-cli", "ping" ] - interval: 5s - timeout: 5s - retries: 5 - networks: - - sure_net - volumes: app-storage: postgres-data: - redis-data: networks: sure_net: diff --git a/config/application.rb b/config/application.rb index 77d071173e1..9a2206ea717 100644 --- a/config/application.rb +++ b/config/application.rb @@ -41,5 +41,7 @@ class Application < Rails::Application # Enable Rack::Attack middleware for API rate limiting config.middleware.use Rack::Attack + MissionControl::Jobs.http_basic_auth_user = "mission_control_jobs" + MissionControl::Jobs.http_basic_auth_password = ENV["MISSION_CONTROL_JOBS_PASSWORD"] || "mission_control_jobs" end end diff --git a/config/cable.yml b/config/cable.yml index fc5a1eb8efa..7ca155ef873 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,10 +1,21 @@ +# Async adapter only works within the same process, so for manually triggering cable updates from a console, +# and seeing results in the browser, you must do so from the web console (running inside the dev process), +# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view +# to make the web console appear. + +default: &default + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day + development: - adapter: async + <<: *default test: adapter: test production: - adapter: redis - url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> - channel_prefix: sure_production + <<: *default diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 00000000000..fdb4f759653 --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,17 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + max_age: <%= 60.days.to_i %> + max_size: <%= 1.gigabytes %> + namespace: <%= Rails.env %> + +development: &development + <<: *default + database: cache + +test: + <<: *default + +production: &production + <<: *default + database: cache \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index 5b249238a33..9d8081311e2 100644 --- a/config/database.yml +++ b/config/database.yml @@ -8,13 +8,39 @@ default: &default password: <%= ENV.fetch("POSTGRES_PASSWORD") { nil } %> development: - <<: *default - database: <%= ENV.fetch("POSTGRES_DB") { "sure_development" } %> + primary: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB") { "maybe_development" } %> + cache: + adapter: sqlite3 + database: storage/development_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + adapter: sqlite3 + database: storage/development_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + adapter: sqlite3 + database: storage/development_cable.sqlite3 + migrations_paths: db/cable_migrate test: <<: *default - database: <%= ENV.fetch("POSTGRES_DB") { "sure_test" } %> + database: <%= ENV.fetch("POSTGRES_DB") { "maybe_test" } %> production: - <<: *default - database: <%= ENV.fetch("POSTGRES_DB") { "sure_production" } %> + primary: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB") { "maybe_production" } %> + cache: + adapter: sqlite3 + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + adapter: sqlite3 + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + adapter: sqlite3 + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/environments/development.rb b/config/environments/development.rb index 553da47e3e8..f8c5242ca61 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -67,6 +67,9 @@ config.assets.quiet = true config.active_record.verbose_query_logs = true config.active_job.verbose_enqueue_logs = true + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + config.solid_queue.logger = ActiveSupport::Logger.new(STDOUT) # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true diff --git a/config/environments/production.rb b/config/environments/production.rb index 671b5239fe0..31f9a0a59b1 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -69,9 +69,7 @@ # want to log everything, set the level to "debug". config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") - if ENV["CACHE_REDIS_URL"].present? - config.cache_store = :redis_cache_store, { url: ENV["CACHE_REDIS_URL"] } - end + config.cache_store = :solid_cache_store config.action_mailer.perform_caching = false config.action_mailer.deliver_later_queue_name = :high_priority @@ -107,6 +105,6 @@ # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } - # set REDIS_URL for Sidekiq to use Redis - config.active_job.queue_adapter = :sidekiq + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } end diff --git a/config/initializers/rails_db.rb b/config/initializers/rails_db.rb new file mode 100644 index 00000000000..359d9d7b79f --- /dev/null +++ b/config/initializers/rails_db.rb @@ -0,0 +1,30 @@ +if Object.const_defined?("RailsDb") + RailsDb.setup do |config| + # # enabled or not + config.enabled = true + + # # automatic engine routes mounting + config.automatic_routes_mount = true + + # set tables which you want to hide ONLY + # config.black_list_tables = [ "users", "accounts" ] + + # set tables which you want to show ONLY + # config.white_list_tables = [ "posts", "comments" ] + + # # Enable http basic authentication + config.http_basic_authentication_enabled = true + + # # Enable http basic authentication + config.http_basic_authentication_user_name = "rails_db" + + # # Enable http basic authentication + config.http_basic_authentication_password = ENV.fetch("RAILS_DB_PASSWORD", "rails_db") + + # # Enable verify access proc + # config.verify_access_proc = proc { |controller| true } + + # # Sandbox mode (only read-only operations) + # config.sandbox = false + end +end diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index d964a09b85d..1a6e9143f78 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -16,5 +16,7 @@ config.profiles_sample_rate = 0.25 config.profiler_class = Sentry::Vernier::Profiler + config.enable_logs = true + config.enabled_patches = [ :logger, :http, :puma ] end end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb deleted file mode 100644 index b80a8fddf23..00000000000 --- a/config/initializers/sidekiq.rb +++ /dev/null @@ -1,16 +0,0 @@ -require "sidekiq/web" - -if Rails.env.production? - Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| - configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "sure")) - configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "sure")) - - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) && - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) - end -end - -Sidekiq::Cron.configure do |config| - # 10 min "catch-up" window in case worker process is re-deploying when cron tick occurs - config.reschedule_grace_period = 600 -end diff --git a/config/puma.rb b/config/puma.rb index 47a2362e275..160bbef9187 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -47,6 +47,8 @@ # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart +plugin :solid_queue + pidfile ENV["PIDFILE"] if ENV["PIDFILE"] if rails_env == "development" diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 00000000000..9eace59c41e --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 00000000000..1b5043f2ea5 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,34 @@ +# examples: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_cleanup_with_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day + +development: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 + + import_market_data: + class: "ImportMarketDataJob" + queue: "scheduled" + args: { mode: "full", clear_cache: false } + schedule: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time) Monday through Friday + description: "Imports market data daily at 5:00 PM EST (1 hour after market close)" + + clean_syncs: + schedule: "0 * * * *" # every hour + class: "SyncCleanerJob" + queue: "scheduled" + description: "Cleans up stale syncs" + + run_security_health_checks: + schedule: "0 2 * * 1-5" # 2:00 AM EST / 3:00 AM EDT (NY time) Monday through Friday + class: "SecurityHealthCheckJob" + queue: "scheduled" + description: "Runs security health checks to detect issues with security data" diff --git a/config/routes.rb b/config/routes.rb index fbdecead056..f5a8547e278 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,3 @@ -require "sidekiq/web" -require "sidekiq/cron/web" - Rails.application.routes.draw do use_doorkeeper # MFA routes @@ -12,8 +9,7 @@ mount Lookbook::Engine, at: "/design-system" - # Uses basic auth - see config/initializers/sidekiq.rb - mount Sidekiq::Web => "/sidekiq" + mount MissionControl::Jobs::Engine, at: "/jobs" # AI chats resources :chats do @@ -303,8 +299,6 @@ post "stripe" end - get "redis-configuration-error", to: "pages#redis_configuration_error" - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 00000000000..de80d1068a8 --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,23 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 00000000000..fec220f6d64 --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,24 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 00000000000..df3358c2874 --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,141 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb index 955a0cfd343..8724b0deaf3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -254,8 +254,11 @@ t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date" t.index ["account_id", "source", "external_id"], name: "index_entries_on_account_source_and_external_id", unique: true, where: "((external_id IS NOT NULL) AND (source IS NOT NULL))" t.index ["account_id"], name: "index_entries_on_account_id" + t.index ["amount"], name: "index_entries_on_amount" t.index ["date"], name: "index_entries_on_date" + t.index ["entryable_id", "entryable_type"], name: "index_entries_on_entryable" t.index ["entryable_type"], name: "index_entries_on_entryable_type" + t.index ["excluded"], name: "index_entries_on_excluded" t.index ["import_id"], name: "index_entries_on_import_id" end @@ -266,6 +269,7 @@ t.date "date", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["date", "from_currency", "to_currency"], name: "index_exchange_rates_on_date_and_currencies" t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true t.index ["from_currency"], name: "index_exchange_rates_on_from_currency" t.index ["to_currency"], name: "index_exchange_rates_on_to_currency" @@ -874,6 +878,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["tag_id"], name: "index_taggings_on_tag_id" + t.index ["taggable_id", "taggable_type"], name: "index_taggings_on_taggable_id_and_type" t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable" end diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000000..af2d736dd2e --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,29 @@ +x-db-env: &db_env + POSTGRES_USER: ${POSTGRES_USER:-maybe_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-maybe_password} + POSTGRES_DB: ${POSTGRES_DB:-maybe-development} + +services: + db: + image: postgres:16 + restart: unless-stopped + volumes: + - maybe-dev-postgres-data:/var/lib/postgresql/data + ports: + - 5432:5432 + environment: + <<: *db_env + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] + interval: 5s + timeout: 5s + retries: 5 + networks: + - sure_net + +volumes: + maybe-dev-postgres-data: + +networks: + sure_net: + driver: bridge diff --git a/docs/api/chats.md b/docs/api/chats.md index 3bc8ea43668..ff7a3ca8512 100644 --- a/docs/api/chats.md +++ b/docs/api/chats.md @@ -221,7 +221,6 @@ Common error codes: - `forbidden` - Insufficient permissions or AI not enabled - `not_found` - Resource not found - `unprocessable_entity` - Invalid request data -- `rate_limit_exceeded` - Too many requests ## Rate Limits diff --git a/test/controllers/api/v1/base_controller_test.rb b/test/controllers/api/v1/base_controller_test.rb index af9e7066dba..036b356c7cf 100644 --- a/test/controllers/api/v1/base_controller_test.rb +++ b/test/controllers/api/v1/base_controller_test.rb @@ -22,14 +22,6 @@ class Api::V1::BaseControllerTest < ActionDispatch::IntegrationTest display_key: @plain_api_key, scopes: [ "read_write" ] ) - - # Clear any existing rate limit data - Redis.new.del("api_rate_limit:#{@api_key.id}") - end - - teardown do - # Clean up Redis data after each test - Redis.new.del("api_rate_limit:#{@api_key.id}") end test "should require authentication" do @@ -325,106 +317,6 @@ class Api::V1::BaseControllerTest < ActionDispatch::IntegrationTest assert_equal "forbidden", response_body["error"] end - test "should include rate limit headers on successful API key requests" do - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - - assert_response :success - assert_not_nil response.headers["X-RateLimit-Limit"] - assert_not_nil response.headers["X-RateLimit-Remaining"] - assert_not_nil response.headers["X-RateLimit-Reset"] - - assert_equal "100", response.headers["X-RateLimit-Limit"] - assert_equal "99", response.headers["X-RateLimit-Remaining"] - end - - test "should increment rate limit count with each request" do - # First request - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :success - assert_equal "99", response.headers["X-RateLimit-Remaining"] - - # Second request - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :success - assert_equal "98", response.headers["X-RateLimit-Remaining"] - end - - test "should return 429 when rate limit exceeded" do - # Make 100 requests to exhaust the rate limit - 100.times do - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :success - end - - # 101st request should be rate limited - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :too_many_requests - - response_body = JSON.parse(response.body) - assert_equal "rate_limit_exceeded", response_body["error"] - assert_includes response_body["message"], "Rate limit exceeded" - - # Check response headers - assert_equal "100", response.headers["X-RateLimit-Limit"] - assert_equal "0", response.headers["X-RateLimit-Remaining"] - assert_not_nil response.headers["X-RateLimit-Reset"] - assert_not_nil response.headers["Retry-After"] - end - - test "should not apply rate limiting to OAuth requests" do - # This would need to be implemented based on your OAuth setup - # For now, just verify that requests without API keys don't trigger rate limiting - get "/api/v1/test" - assert_response :unauthorized - - # Should not have rate limit headers for unauthorized requests - assert_nil response.headers["X-RateLimit-Limit"] - end - - test "should provide detailed rate limit information in 429 response" do - # Exhaust the rate limit - 100.times do - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - end - - # Make the rate-limited request - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :too_many_requests - - response_body = JSON.parse(response.body) - assert_equal "rate_limit_exceeded", response_body["error"] - assert response_body["details"]["limit"] == 100 - assert response_body["details"]["current"] >= 100 - assert response_body["details"]["reset_in_seconds"] > 0 - end - - test "rate limiting should be per API key" do - # Create a second user for independent API keys - other_user = users(:family_member) - other_api_key = ApiKey.create!( - user: other_user, - name: "Other Test API Key", - scopes: [ "read" ], - display_key: "other_rate_test_#{SecureRandom.hex(8)}" - ) - - begin - # Make 50 requests with first API key - 50.times do - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :success - end - - # Should still be able to make requests with second API key - get "/api/v1/test", headers: { "X-Api-Key" => other_api_key.display_key } - assert_response :success - assert_equal "99", response.headers["X-RateLimit-Remaining"] - ensure - Redis.new.del("api_rate_limit:#{other_api_key.id}") - other_api_key.destroy - end - end - private def capture_log(&block) diff --git a/test/controllers/api/v1/transactions_controller_test.rb b/test/controllers/api/v1/transactions_controller_test.rb index b028562a632..dbd355e2d11 100644 --- a/test/controllers/api/v1/transactions_controller_test.rb +++ b/test/controllers/api/v1/transactions_controller_test.rb @@ -27,10 +27,6 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest display_key: "test_ro_#{SecureRandom.hex(8)}", source: "mobile" # Use different source to allow multiple keys ) - - # Clear any existing rate limit data - Redis.new.del("api_rate_limit:#{@api_key.id}") - Redis.new.del("api_rate_limit:#{@read_only_api_key.id}") end # INDEX action tests diff --git a/test/controllers/api/v1/usage_controller_test.rb b/test/controllers/api/v1/usage_controller_test.rb index 27235826139..e169d51b385 100644 --- a/test/controllers/api/v1/usage_controller_test.rb +++ b/test/controllers/api/v1/usage_controller_test.rb @@ -12,14 +12,6 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest scopes: [ "read" ], display_key: "usage_test_#{SecureRandom.hex(8)}" ) - - # Clear any existing rate limit data - Redis.new.del("api_rate_limit:#{@api_key.id}") - end - - teardown do - # Clean up Redis data after each test - Redis.new.del("api_rate_limit:#{@api_key.id}") end test "should return usage information for API key authentication" do @@ -40,14 +32,6 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest assert_equal [ "read" ], response_body["api_key"]["scopes"] assert_not_nil response_body["api_key"]["last_used_at"] assert_not_nil response_body["api_key"]["created_at"] - - # Check rate limit information - assert_equal "standard", response_body["rate_limit"]["tier"] - assert_equal 100, response_body["rate_limit"]["limit"] - assert_equal 4, response_body["rate_limit"]["current_count"] # 3 test requests + 1 usage request - assert_equal 96, response_body["rate_limit"]["remaining"] - assert response_body["rate_limit"]["reset_in_seconds"] > 0 - assert_not_nil response_body["rate_limit"]["reset_at"] end test "should require read scope for usage endpoint" do @@ -68,7 +52,6 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest response_body = JSON.parse(response.body) assert_equal "insufficient_scope", response_body["error"] ensure - Redis.new.del("api_rate_limit:#{api_key_no_read.id}") api_key_no_read.destroy end end @@ -79,58 +62,4 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest get "/api/v1/usage" assert_response :unauthorized end - - test "should update usage count when accessing usage endpoint" do - # Check initial state - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - response_body = JSON.parse(response.body) - first_count = response_body["rate_limit"]["current_count"] - - # Make another usage request - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - response_body = JSON.parse(response.body) - second_count = response_body["rate_limit"]["current_count"] - - assert_equal first_count + 1, second_count - end - - test "should include rate limit headers in usage response" do - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - assert_not_nil response.headers["X-RateLimit-Limit"] - assert_not_nil response.headers["X-RateLimit-Remaining"] - assert_not_nil response.headers["X-RateLimit-Reset"] - - assert_equal "100", response.headers["X-RateLimit-Limit"] - assert_equal "99", response.headers["X-RateLimit-Remaining"] - end - - test "should work correctly when approaching rate limit" do - # Make 98 requests to get close to the limit - 98.times do - get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - end - - # Check usage - this should be request 99 - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - response_body = JSON.parse(response.body) - assert_equal 99, response_body["rate_limit"]["current_count"] - assert_equal 1, response_body["rate_limit"]["remaining"] - - # One more request should hit the limit - get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - # Now we should be rate limited - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :too_many_requests - end end diff --git a/test/services/api_rate_limiter_test.rb b/test/services/api_rate_limiter_test.rb deleted file mode 100644 index 8afc6bb9a8e..00000000000 --- a/test/services/api_rate_limiter_test.rb +++ /dev/null @@ -1,138 +0,0 @@ -require "test_helper" - -class ApiRateLimiterTest < ActiveSupport::TestCase - setup do - @user = users(:family_admin) - # Destroy any existing active API keys for this user - @user.api_keys.active.destroy_all - - @api_key = ApiKey.create!( - user: @user, - name: "Rate Limiter Test Key", - scopes: [ "read" ], - display_key: "rate_limiter_test_#{SecureRandom.hex(8)}" - ) - @rate_limiter = ApiRateLimiter.new(@api_key) - - # Clear any existing rate limit data - Redis.new.del("api_rate_limit:#{@api_key.id}") - end - - teardown do - # Clean up Redis data after each test - Redis.new.del("api_rate_limit:#{@api_key.id}") - end - - test "should have default rate limit" do - assert_equal 100, @rate_limiter.rate_limit - end - - test "should start with zero request count" do - assert_equal 0, @rate_limiter.current_count - end - - test "should not be rate limited initially" do - assert_not @rate_limiter.rate_limit_exceeded? - end - - test "should increment request count" do - assert_equal 0, @rate_limiter.current_count - - @rate_limiter.increment_request_count! - assert_equal 1, @rate_limiter.current_count - - @rate_limiter.increment_request_count! - assert_equal 2, @rate_limiter.current_count - end - - test "should be rate limited when exceeding limit" do - # Simulate reaching the rate limit - 100.times { @rate_limiter.increment_request_count! } - - assert_equal 100, @rate_limiter.current_count - assert @rate_limiter.rate_limit_exceeded? - end - - test "should provide correct usage info" do - 5.times { @rate_limiter.increment_request_count! } - - usage_info = @rate_limiter.usage_info - - assert_equal 5, usage_info[:current_count] - assert_equal 100, usage_info[:rate_limit] - assert_equal 95, usage_info[:remaining] - assert_equal :standard, usage_info[:tier] - assert usage_info[:reset_time] > 0 - assert usage_info[:reset_time] <= 3600 - end - - test "should calculate remaining requests correctly" do - 10.times { @rate_limiter.increment_request_count! } - - usage_info = @rate_limiter.usage_info - assert_equal 90, usage_info[:remaining] - end - - test "should have zero remaining when at limit" do - 100.times { @rate_limiter.increment_request_count! } - - usage_info = @rate_limiter.usage_info - assert_equal 0, usage_info[:remaining] - end - - test "should have zero remaining when over limit" do - 105.times { @rate_limiter.increment_request_count! } - - usage_info = @rate_limiter.usage_info - assert_equal 0, usage_info[:remaining] - end - - test "class method usage_for should work without incrementing" do - 5.times { @rate_limiter.increment_request_count! } - - usage_info = ApiRateLimiter.usage_for(@api_key) - assert_equal 5, usage_info[:current_count] - - # Should not increment when just checking usage - usage_info_again = ApiRateLimiter.usage_for(@api_key) - assert_equal 5, usage_info_again[:current_count] - end - - test "should handle multiple API keys separately" do - # Create a different user for the second API key - other_user = users(:family_member) - other_api_key = ApiKey.create!( - user: other_user, - name: "Other API Key", - scopes: [ "read_write" ], - display_key: "rate_limiter_other_#{SecureRandom.hex(8)}" - ) - - other_rate_limiter = ApiRateLimiter.new(other_api_key) - - @rate_limiter.increment_request_count! - other_rate_limiter.increment_request_count! - other_rate_limiter.increment_request_count! - - assert_equal 1, @rate_limiter.current_count - assert_equal 2, other_rate_limiter.current_count - ensure - Redis.new.del("api_rate_limit:#{other_api_key.id}") - other_api_key.destroy - end - - test "should calculate reset time correctly" do - reset_time = @rate_limiter.reset_time - - # Reset time should be within the current hour - assert reset_time > 0 - assert reset_time <= 3600 - - # Should be roughly the time until the next hour - current_time = Time.current.to_i - next_window = ((current_time / 3600) + 1) * 3600 - expected_reset = next_window - current_time - - assert_in_delta expected_reset, reset_time, 1 - end -end diff --git a/test/services/noop_api_rate_limiter_test.rb b/test/services/noop_api_rate_limiter_test.rb deleted file mode 100644 index 9c7105b12c8..00000000000 --- a/test/services/noop_api_rate_limiter_test.rb +++ /dev/null @@ -1,58 +0,0 @@ -require "test_helper" - -class NoopApiRateLimiterTest < ActiveSupport::TestCase - setup do - @user = users(:family_admin) - # Clean up any existing API keys for this user to ensure tests start fresh - @user.api_keys.destroy_all - - @api_key = ApiKey.create!( - user: @user, - name: "Noop Rate Limiter Test Key", - scopes: [ "read" ], - display_key: "noop_rate_limiter_test_#{SecureRandom.hex(8)}" - ) - @rate_limiter = NoopApiRateLimiter.new(@api_key) - end - - test "should never be rate limited" do - assert_not @rate_limiter.rate_limit_exceeded? - end - - test "should not increment request count" do - @rate_limiter.increment_request_count! - assert_equal 0, @rate_limiter.current_count - end - - test "should always have zero request count" do - assert_equal 0, @rate_limiter.current_count - end - - test "should have infinite rate limit" do - assert_equal Float::INFINITY, @rate_limiter.rate_limit - end - - test "should have zero reset time" do - assert_equal 0, @rate_limiter.reset_time - end - - test "should provide correct usage info" do - usage_info = @rate_limiter.usage_info - - assert_equal 0, usage_info[:current_count] - assert_equal Float::INFINITY, usage_info[:rate_limit] - assert_equal Float::INFINITY, usage_info[:remaining] - assert_equal 0, usage_info[:reset_time] - assert_equal :noop, usage_info[:tier] - end - - test "class method usage_for should work" do - usage_info = NoopApiRateLimiter.usage_for(@api_key) - - assert_equal 0, usage_info[:current_count] - assert_equal Float::INFINITY, usage_info[:rate_limit] - assert_equal Float::INFINITY, usage_info[:remaining] - assert_equal 0, usage_info[:reset_time] - assert_equal :noop, usage_info[:tier] - end -end