1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00
Maybe/app/models/sync.rb

193 lines
5.2 KiB
Ruby
Raw Normal View History

class Sync < ApplicationRecord
# We run a cron that marks any syncs that have not been resolved in 24 hours as "stale"
# Syncs often become stale when new code is deployed and the worker restarts
STALE_AFTER = 24.hours
# The max time that a sync will show in the UI (after 5 minutes)
VISIBLE_FOR = 5.minutes
include AASM
2025-04-18 09:46:49 -04:00
Error = Class.new(StandardError)
belongs_to :syncable, polymorphic: true
belongs_to :parent, class_name: "Sync", optional: true
has_many :children, class_name: "Sync", foreign_key: :parent_id, dependent: :destroy
scope :ordered, -> { order(created_at: :desc) }
scope :incomplete, -> { where("syncs.status IN (?)", %w[pending syncing]) }
scope :visible, -> { incomplete.where("syncs.created_at > ?", VISIBLE_FOR.ago) }
validate :window_valid
# Sync state machine
aasm column: :status, timestamps: true do
state :pending, initial: true
state :syncing
state :completed
state :failed
state :stale
after_all_transitions :handle_transition
event :start, after_commit: :handle_start_transition do
transitions from: :pending, to: :syncing
end
event :complete, after_commit: :handle_completion_transition do
transitions from: :syncing, to: :completed
end
event :fail do
transitions from: :syncing, to: :failed
end
# Marks a sync that never completed within the expected time window
event :mark_stale do
transitions from: %i[pending syncing], to: :stale
end
end
class << self
def clean
incomplete.where("syncs.created_at < ?", STALE_AFTER.ago).find_each(&:mark_stale!)
end
end
def perform
Rails.logger.tagged("Sync", id, syncable_type, syncable_id) do
2025-05-24 17:58:17 -04:00
# This can happen on server restarts or if Sidekiq 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
end
start!
begin
syncable.perform_sync(self)
rescue => e
fail!
update(error: e.message)
report_error(e)
ensure
finalize_if_all_children_finalized
end
end
end
# Finalizes the current sync AND parent (if it exists)
def finalize_if_all_children_finalized
Sync.transaction do
lock!
# If this is the "parent" and there are still children running, don't finalize.
return unless all_children_finalized?
if syncing?
if has_failed_children?
fail!
else
complete!
end
end
# If we make it here, the sync is finalized. Run post-sync, regardless of failure/success.
perform_post_sync
end
# If this sync has a parent, try to finalize it so the child status propagates up the chain.
parent&.finalize_if_all_children_finalized
end
# If a sync is pending, we can adjust the window if new syncs are created with a wider window.
def expand_window_if_needed(new_window_start_date, new_window_end_date)
return unless pending?
return if self.window_start_date.nil? && self.window_end_date.nil? # already as wide as possible
earliest_start_date = if self.window_start_date && new_window_start_date
2025-05-20 09:09:10 -04:00
[ self.window_start_date, new_window_start_date ].min
else
nil
end
latest_end_date = if self.window_end_date && new_window_end_date
2025-05-20 09:09:10 -04:00
[ self.window_end_date, new_window_end_date ].max
else
nil
end
update(
window_start_date: earliest_start_date,
window_end_date: latest_end_date
)
end
private
def log_status_change
Rails.logger.info("changing from #{aasm.from_state} to #{aasm.to_state} (event: #{aasm.current_event})")
end
def has_failed_children?
children.failed.any?
end
def all_children_finalized?
children.incomplete.empty?
end
def perform_post_sync
Rails.logger.info("Performing post-sync for #{syncable_type} (#{syncable.id})")
syncable.perform_post_sync
syncable.broadcast_sync_complete
rescue => e
Rails.logger.error("Error performing post-sync for #{syncable_type} (#{syncable.id}): #{e.message}")
report_error(e)
end
def report_error(error)
Sentry.capture_exception(error) do |scope|
scope.set_tags(sync_id: id)
2024-12-02 12:04:54 -05:00
end
end
2024-12-02 12:04:54 -05:00
def report_warnings
todays_sync_count = syncable.syncs.where(created_at: Date.current.all_day).count
if todays_sync_count > 10
Sentry.capture_exception(
Error.new("#{syncable_type} (#{syncable.id}) has exceeded 10 syncs today (count: #{todays_sync_count})"),
level: :warning
)
end
end
def handle_start_transition
report_warnings
end
def handle_transition
log_status_change
family.touch(:latest_sync_activity_at)
end
def handle_completion_transition
family.touch(:latest_sync_completed_at)
end
def window_valid
if window_start_date && window_end_date && window_start_date > window_end_date
errors.add(:window_end_date, "must be greater than window_start_date")
end
end
def family
if syncable.is_a?(Family)
syncable
else
syncable.family
end
end
end