1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 15:35:22 +02:00

Setup health check

This commit is contained in:
Zach Gollwitzer 2025-05-21 08:44:05 -04:00
parent 3d2213b760
commit 3767ecb562
6 changed files with 138 additions and 2 deletions

View file

@ -0,0 +1,7 @@
class SecurityHealthCheckJob < ApplicationJob
queue_as :default
def perform
Security::HealthChecker.new.perform
end
end

View file

@ -0,0 +1,58 @@
# There are hundreds of thousands of market securities that Maybe must handle.
# Due to the always-changing nature of the market, the health checker is responsible
# for periodically checking active securities to ensure we can still fetch prices for them.
#
# Securities that cannot fetch prices are marked "offline" and will not run price updates.
#
# The health checker is run daily through SecurityHealthCheckJob (see config/schedule.yml)
class Security::HealthChecker
HEALTH_CHECK_INTERVAL = 30.days
DAILY_BATCH_SIZE = 1000
class << self
def check_all
# All securities that have never been checked run, regardless of daily batch size
never_checked_scope.find_each do |security|
new(security).run_check
end
due_for_check_scope.find_each do |security|
new(security).run_check
end
end
private
# If a security has never had a health check, we prioritize it, regardless of batch size
def never_checked_scope
Security.where(last_health_check_at: nil)
end
# Any securities not checked for 30 days are due
# We only process the batch size, which means some "due" securities will not be checked today
# This is by design, to prevent all securities from coming due at the same time
def due_for_check_scope
Security.where(last_health_check_at: ..HEALTH_CHECK_INTERVAL.ago)
.order(last_health_check_at: :asc)
.limit(DAILY_BATCH_SIZE)
end
end
def initialize(security)
@security = security
end
def run_check
end
private
def scope
Security.where(last_health_check_at: nil)
.or(Security.where(last_health_check_at: ..7.days.ago))
end
def can_fetch_from_provider?
end
def has_daily_prices?
end
end

View file

@ -0,0 +1,17 @@
class Security::Resolver
def initialize(symbol, exchange_operating_mic: nil, country_code: nil)
@symbol = symbol
@exchange_operating_mic = exchange_operating_mic
@country_code = country_code
end
def resolve
end
private
attr_reader :symbol, :exchange_operating_mic, :country_code
def provider
Security.provider
end
end

View file

@ -0,0 +1,8 @@
class AddSecurityResolverFields < ActiveRecord::Migration[7.2]
def change
add_column :securities, :offline, :boolean, default: false, null: false
add_column :securities, :failed_fetch_at, :datetime
add_column :securities, :failed_fetch_count, :integer, default: 0, null: false
add_column :securities, :last_health_check_at, :datetime
end
end

8
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_05_18_181619) do
ActiveRecord::Schema[7.2].define(version: 2025_05_21_112347) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -30,7 +30,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_18_181619) do
t.decimal "balance", precision: 19, scale: 4
t.string "currency"
t.boolean "is_active", default: true, null: false
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.uuid "import_id"
t.uuid "plaid_account_id"
t.boolean "scheduled_for_deletion", default: false
@ -513,6 +513,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_18_181619) do
t.string "exchange_acronym"
t.string "logo_url"
t.string "exchange_operating_mic"
t.boolean "offline", default: false, null: false
t.datetime "failed_fetch_at"
t.integer "failed_fetch_count", default: 0, null: false
t.datetime "last_health_check_at"
t.index ["country_code"], name: "index_securities_on_country_code"
t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic"
t.index ["ticker", "exchange_operating_mic"], name: "index_securities_on_ticker_and_exchange_operating_mic", unique: true

View file

@ -0,0 +1,42 @@
require "test_helper"
class Security::HealthCheckerTest < ActiveSupport::TestCase
test "checks all securities that don't have a health check" do
# Setup
unchecked_security = Security.create!(ticker: "AAA")
recent_security = Security.create!(ticker: "BBB", last_health_check_at: 5.days.ago)
due_security = Security.create!(ticker: "CCC", last_health_check_at: Security::HealthChecker::HEALTH_CHECK_INTERVAL.ago - 1.day)
scope = Security::HealthChecker.send(:never_checked_scope)
assert_includes scope, unchecked_security
refute_includes scope, recent_security
refute_includes scope, due_security
end
# We don't intend to check all securities every day
test "checks oldest DAILY_BATCH_SIZE securities that haven't been checked in HEALTH_CHECK_INTERVAL days" do
batch_size = Security::HealthChecker::DAILY_BATCH_SIZE
# Create batch_size + 2 securities that are all past the health check interval so that
# the scope needs to apply the LIMIT and ordering.
(batch_size + 2).times do |i|
# Spread the dates so we can assert ordering (older first)
days_past_interval = Security::HealthChecker::HEALTH_CHECK_INTERVAL + i.days + 1.day
Security.create!(ticker: "SEC#{i}", last_health_check_at: days_past_interval.ago)
end
scoped = Security::HealthChecker.send(:due_for_check_scope).to_a
# 1. Only DAILY_BATCH_SIZE records are returned
assert_equal batch_size, scoped.size
# 2. Records are ordered oldest -> newest by last_health_check_at
ordered_dates = scoped.map(&:last_health_check_at)
assert_equal ordered_dates.sort, ordered_dates, "due_for_check_scope should return oldest records first"
# 3. The newest (least old) security should have been excluded due to the LIMIT
newest_excluded_date = Security.order(last_health_check_at: :desc).where(last_health_check_at: ..Security::HealthChecker::HEALTH_CHECK_INTERVAL.ago).first.last_health_check_at
refute_includes scoped.map(&:last_health_check_at), newest_excluded_date
end
end