-
Notifications
You must be signed in to change notification settings - Fork 2.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Account state machine #836
Conversation
enforce status changes through aasm
avoiding family being feature envy
@@ -38,7 +38,7 @@ def edit | |||
def update | |||
if @account.update(account_params.except(:accountable_type)) | |||
|
|||
@account.sync_later if account_params[:is_active] == "1" && @account.can_sync? | |||
@account.sync_later |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The controller shouldn't know too much about the model's internal behavior.
@@ -74,7 +74,7 @@ def destroy | |||
end | |||
|
|||
def sync | |||
if @account.can_sync? | |||
if @account.may_start_sync? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This now also tests if the state change is allowed.
@@ -13,8 +13,6 @@ class Account < ApplicationRecord | |||
|
|||
monetize :balance | |||
|
|||
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true | |||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moved to syncable
@@ -42,11 +40,6 @@ def self.by_provider | |||
[ { name: "Manual accounts", accounts: all.order(balance: :desc).group_by(&:accountable_type) } ] | |||
end | |||
|
|||
def self.some_syncing? | |||
exists?(status: "syncing") | |||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moved to syncable
@@ -69,7 +62,7 @@ def self.by_group(period: Period.all, currency: Money.default_currency) | |||
types.each do |type| | |||
group = grouped_accounts[classification.to_sym].add_child_group(type, currency) | |||
self.where(accountable_type: type).each do |account| | |||
value_node = group.add_value_node( | |||
group.add_value_node( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cleanup: the assigned value_node
was never used.
included do | ||
include AASM | ||
|
||
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Coming from the model. It might be sensible to name this to sync_state
. An Account
may have other states (maybe related to the financial organization, such as closed
, unavailable
, etc.).
@@ -104,6 +104,6 @@ def liabilities | |||
end | |||
|
|||
def sync_accounts | |||
accounts.each { |account| account.sync_later if account.can_sync? } | |||
accounts.each { |account| account.sync_later } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Family
shouldn't know too much about internal states of Accounts
.
logger.error("Failed to sync account #{id}: #{e.message}") | ||
end | ||
|
||
def can_sync? | ||
# Skip account sync if account is not active or the sync process is already running | ||
return false unless is_active | ||
return false if syncing? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is now tested earlier through the state machine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for this contribution! Overall, I agree with the consolidation you've done to move everything into the model and within the Syncable
module.
Before we get this merged, I just had a few questions below:
Naming
I agree that we should probably consider renaming a few things to make it clear that these state transitions are in the context of "Account Syncing".
What do you think of something like this?
(would require us to do a DB migration to change :status
-> :sync_state
and remove the enum)
aasm column: :sync_state do
# implementation
end
Possible states
The account sync will soon have several steps within it and I'm wondering your thoughts on the best way to incorporate a state machine for this. For example, a "Sync" may include all of the following steps:
- Sync data provider transactions
- Sync data provider exchange rates
- Sync data provider account data
- Sync data provider stock securities prices
- Sync balances (takes all 3rd party data and uses it to calculate balances)
Essentially, it's an ETL process that will be similar to the V1 implementation.
While I suppose we could keep these all combined into an event :sync, after: :start_sync
, it may be nicer if we're able to see the exact step that failed in the DB.
My thought is that rather than including an :error
state, we'd let all the steps run and "collect" an array of errors that we could later view in the sync_errors
column on the accounts
table. There may be steps that don't make sense to run after certain failures, but I'm thinking it might be too early to decide on that.
aasm column: :sync_state do
state :idle, initial: true
state :syncing_exchange_rates
state :syncing_balances
event :sync_exchange_rates do
after { |start_date| start_exchange_rate_sync(start_date) }
error { |e| append_sync_error(e) }
transitions from: :idle, to: :syncing_exchange_rates
end
event :sync_balances do
after { |start_date| start_balance_sync(start_date) }
error { |e| append_sync_error(e) }
transitions from: :syncing_exchange_rates, to: :syncing_balances
end
event :complete_sync do
transitions from: :syncing_balances, to: :idle
end
def start_exchange_rate_sync(start_date = nil)
end
def start_balance_sync(start_date = nil)
end
def complete_sync
end
def append_sync_error(e)
# Updates the `sync_errors` column
end
end
There may be a better way to represent this. Curious to hear your thoughts.
Hi @zachgoll, Thank you for your feedback! I like your approach, which is to step through the individual stages and label them as states.
To improve observability, it's not unreasonable to create a model |
@timlawrenz yeah I'd agree with that. If we created an My guess is that 3rd party provider data could be stored directly on the sync = Account::Sync.new(start_date: 10.days.ago)
sync.fetch_exchange_rates # stores in a JSONB column
sync.fetch_other_provider_data And as you mentioned, future improvements may include things like purging old syncs or maybe even setting up associations (rather than JSONB) as we become more familiar with exactly what data we need. |
@zachgoll I think that's the right next step. I don't yet have a deep enough understanding of which model should know about which part of the business logic. I assume that some parts of the Provider play a role in this. |
@timlawrenz I meant the actual results that were fetched from the provider. Each sync will follow some sort of ETL pattern, and it may not be possible to "transform + load" the data right away (i.e. we might need to combine transaction data with exchange rate data). My line of thinking is that we should be able to perform the "extract" step in parallel with retries, which could be challenging if we don't save the raw fetched data in some sort of interim "staging" area. None of this needs to be implemented here in this PR, but wanted to add this as context in case it helps design the various states we'll need here. |
@zachgoll Thanks for clarifying. I'll update this PR to
We should create a new sync object (row) for every sync step to keep the individual results/error messages around. The account state machine orchestrates the creation of those objects. What is now in the syncable concern should be moved to the sync class. |
@timlawrenz yep, I like that approach. If you'd like to introduce a sync "row", that's fine. The one thing I think we should optimize most for right now is clarity and simplicity. 100% fine suffering on performance a little bit to get us to the clearest and most understandable flow possible. |
@zachgoll, that is very good guidance. |
@timlawrenz do you plan on continuing work on this? I'll be updating some sync related logic soon, so just let me know. No worries either way. |
Hi,
I suggest using state machines for columns that should have controlled workflows.
This PR also tries to ensure that the decision logic (whether an account can be synced) stays close to the model. A family should not know too much about whether an account is syncable.
Please let me know what you think.