8. Use case 1
Naive implementation
➔ Lack of robustness: One fails, all fail
➔ Monolithic: In case of timeout
everything is rolled back
● Need to process all data again
➔ Timeout (or rollback) end up with
inconsistent state if emails are sent
➔ If an invoice is skipped one month, it
will never invoice that month
➔ High risk of concurrent update: long
transaction updating data
➔ Run twice the same month, end up
with 2 invoices
# defined on sale.subscription
# Run Once a month
def _run_subscription_invoice(self):
subscriptions_to_invoice = self.search([
('stage_id.category', '=', 'progress'),
])
for subscription in subscriptions_to_invoice:
subscription.create_invoice()
9. Use case 2
Naive implementation
➔ Always process all the data
● Data to process will only grow
● It will timeout
➔ Monolithic: In case of timeout the
process will retry forever
➔ If it runs twice in the same day, it will
synchronize twice
➔ Run only once a day, if service is
unavailable at that specific time: no
synchronization that day
# Defined on res.partner
# Run Once a day
def _sync_partner(self):
partners = self.search([])
data = partners._prepare_data()
self._send_data(data)
14. Idempotent
# Use case 1
# Run Once a DAY
def _run_subscription_invoice(self):
subscriptions_to_invoice = self.search([
('stage_id.category', '=', 'progress'),
('next_invoice_date', '=', today),
])
for subscription in subscriptions_to_invoice:
subscription.create_invoice()
subscription.next_invoice_date +=
relativedelta(month=1)
# Use case 2
# Run whenever you want
def _sync_partner(self):
# Dirty is set to True when the partner is modified
partners = self.search([('dirty', '=', True)])
data = partners._prepare_data()
self._send_data(data)
partners.write({'dirty': False})
An operation that can be applied multiple
times without changing the result beyond
the initial application.
➔ Cron(rec) ~ Cron(rec); Cron(rec); ...
➔ Make sure that data are only
processed once
➔ Keep track of processed records
+ Frequencies of cron and data
processing don’t need to be correlated
+ It’s safe to retry
15. Incremental
# Use case 1
def _run_subscription_invoice(self):
subscriptions_to_invoice = self.search([
('stage_id.category', '=', 'progress'),
('next_invoice_date', '=', today),
])
for subscription in subscriptions_to_invoice:
subscription.create_invoice()
subscription.next_invoice_date += relativedelta(month=1)
self.env.cr.commit()
# Use case 2
def _sync_partner(self):
while True:
partners = self.search([('dirty', '=', True)],
limit=100)
if not partners:
break
data = partners._prepare_data()
self._send_data(data)
partners.write({'dirty': False})
self.env.cr.commit()
Split in small batches and save at the end
of each batch.
➔ Only possible if already idempotent
+ Timeout will be resolved eventually
+ Reduce transaction time, lower
concurrent update risk
! Data consistency at commit
! Commit are annoying for automated
testing
! Run more often than you would to
avoid timeout
16. Robust
def _run_subscription_invoice(self):
subscriptions_to_invoice = self.search([
('stage_id.category', '=', 'progress'),
('next_invoice_date', '=', today),
])
for subscription in subscriptions_to_invoice:
try:
subscription.create_invoice()
subscription.next_invoice_date +=
relativedelta(month=1)
self.env.cr.commit()
except Exception:
self.env.cr.rollback()
def _sync_partner(self):
while True:
partners = self.search([('dirty', '=', True)],
limit=100)
if not partners:
break
try:
data = partners._prepare_data()
self._send_data(data)
partners.write({'dirty': False})
self.env.cr.commit()
except Exception:
self.env.cr.rollback()
Prevent an issue on a single record to lead
to a general failure.
➔ Crash != Timeout, there is no retry
➔ Keep data consistency: rollback
+ Process all correct records
! Record processing can fail silently
! Be able to filter those who failed
17. Complete
def _run_subscription_invoice(self):
subscriptions_to_invoice = self.search([
('stage_id.category', '=', 'progress'),
# Today or before
('next_invoice_date', '<=', today),
])
for subscription in subscriptions_to_invoice:
try:
subscription.create_invoice()
subscription.next_invoice_date +=
relativedelta(month=1)
self.env.cr.commit()
except Exception:
self.env.cr.rollback()
Don’t leave a record never processed.
➔ If a record was not processed,
make sure it will be included next
time
+ Process all correct records
! Keep idempotence
! May end up processing forever bad
records
! Too many bad records can lead to
infinite retry
18. Verbose
def _sync_partner(self):
while True:
partners = self.search([('dirty', '=', True)],
limit=100)
if not partners:
Break
try:
data = partners._prepare_data()
self._send_data(data)
partners.write({'dirty': False})
self.env.cr.commit()
_logger.debug('Batch of partner is synced')
except Exception:
self.env.cr.rollback()
_logger.exception('Something went wrong')
Log what is happening.
➔ When something went wrong
➔ When processing is long
+ Processing records with an issue does
not fail silently anymore
! Don’t bloat the logs
19. Testable
def _run_subscription_invoice(self, auto_commit=True):
subscriptions_to_invoice = self.search([
('stage_id.category', '=', 'progress'),
('next_invoice_date', '<=', today),
])
for subscription in subscriptions_to_invoice:
try:
subscription.create_invoice()
subscription.next_invoice_date +=
relativedelta(month=1)
if auto_commit:
self.env.cr.commit()
_logger.debug('Invoice created')
except Exception:
if auto_commit:
self.env.cr.rollback()
_logger.exception('Failure with subscription')
Make automated testing easy.
➔ Tests are rolled back
➔ Not possible with commit
➔ Make possible to disable commit
+ No excuse not to test anymore
! Default behavior: Commit
20. Not stubborn def _run_subscription_invoice(self):
subscriptions_to_invoice = self.search([
('stage_id.category', '=', 'progress'),
('next_invoice_date', '<=', today),
# Leave 10 tries, then drop it
('next_invoice_date', '>', 10_days_ago),
...
def _sync_partner(self):
# dirty is an int, will be set to 5
# Try until dirty reaches 1
# 0: ok
# 1: issue after max retry
for partner in self.search([('dirty', '>', 1)])
try:
data = partner._prepare_data()
self._send_data(data)
partner.write({'dirty': 0})
self.env.cr.commit()
except Exception:
self.env.cr.rollback()
partner.dirty -= 1
self.env.cr.commit()
Don’t retry bad records forever.
➔ Useful if a lot of bad records or bad
records that will never be fixed
➔ Implementation case by case
+ Avoid wasting time to process those
records
+ Resolved Time Out eventually
! Jeopardize completeness
! Easy way to find dropped records
! Analyse real usage to find the good
trade off
In this talk we are going to see how design cron for
the real life condition.
We will start with 2 naives use cases
See what problems thoses use cases will faces
see all the feature the cron should have to Solve
the use cases
and how to implement those feature
What are the benefit of those feature and some aspect you should pay attention on
Let’s start with the introduction
First, some definition.
You probably all know what is à cron in odoo
It’s a piece of code that run periodically à certain number of time (possible infinite number of time)
But what resilient mean ?
In other word, when something bad happen, for example à crash, à timeout, things going to be ok eventually without human intervention
Now let’s see the use case we are going to study
Telecom company for exemple
That create invoice for customer monthly subscription and probably send àn email with the invoice.
For the purpose of the talk we keep it minimum. Only monthly subscription, no payment or anything fancy, juste create the invoice, validate it and send an email
Here we want to synchronize contact information with another software at least once a day
We imagine that we have an api that we can call as much time as we want with as much partner data per call as we want
Now let’s see what could possibly go wrong.
What do we do ?
The script run once a month, since we need to generate only once a month an invoice. I told you it’s a naive solution
We search for all open subscription and called à method for each of them create_invoice
In that method, the invoice is posted and the mail is sent to the customer
We don’t know how many data to process but probably a lot, if not now, in the future, if the business grow the cron should continue to work
So timeout will likelity to happen.
In case of timeout: Leave the method call, leave the cron management, stop everything and write in the log timeout
=> Email sent, are sent, if transaction rollback, you’ll get twice the email (or more)
=> The transaction may be long, and you’ll get number for invoice that take à lock, during the potentially long transaction, nobody can post an invoice :D
=> RUn manually, because things went wrong, run twice (human mistake) got two invoice for that month
Here, we get all the partner
We prepare the call to the api
And send everything in one call, let’s assume it’s ok on their side which won’t be the case probably
He, we are more less sure the data to process will grow and thus time out
If we timeout during the call send_data, probably that will be update but we never get the answer and try forever
If it timeout before it’s à bit better, we don’t use the ressource of the api but still retry forever
Now let see the feature our cron should have to avoid the pitfalls
And à way to implement them
Idempotent:
What is it
Basicaly you run once or you run many times (on the very same data) it’s the same result
How to implement:
Have a flag, that will be turn off once the data is processed
And it solve at least
Run twice the same month, end up with 2 invoices
If it runs twice in the same day, it will synchronize twice
Make the cron run as much time as you want, does not change the fact that your data willl be processed only when needed
Processed only data that need to be processed, and this more often is a step away from the timeout
Alone it does not solve many things but it’s à pre-requisite for the other feature
To solve them
Pre-requisite Idempotent
If fail after few batch, you need to know those batch is already done
How to implementer it
In case of invoice it’s easy, after each invoice commit
For partners, need to split in batch: there is many way to to so
=> Make more calls with only 100 data: APi should like it much better
=> Size of the batch may be adapt: Keep transaction small less than few seconds
Commit often:
Does not help to avoid timeout but end them.
In case of interaction with external system it’s not à silver bullet.
=> Process more often should do the trick.
Do no commit everywhere, make sure that data are consistent
=> Do not commit when invoice is created but not posted
When you want to test your code, commit can be anoying since test should be rolled back and it won’t be the case anymore
More on that later
Solved:
Monolithic: In case of timeout everything is rolled back
Need to process all data again
Timeout forever
So far if something went wrong, the cron stopped
And retry the next execution date, it’s not like à timeout
and if we start with The first wrong data, the cron will stop forever and not data will be processed anymore until
The data is fixed (or the code)
We want to prevent this.
À single failure will not prevent other record to work
Try; except and rollback the bacth if the bach was wrong.
Issue with big batch, if only one record lead to issue the batch will be rollabck
Small batch or make sure you have filter wrong data. Keep try catch in case you missed something
=> Record failing silently: more on that later
=>
We want to make sure all data that should be processed are processed
The implementation is different for each cron.
The idea, make sure you’ll don’t leave someone behind
Idempotence is of course something you need to keep in mind.
Be complete but also indempotent, don’t process twice
=> Issue that raise, you have à huge backlog of wrong data that you keep
Forever: TImeout and you never process other data anymore
It depends on your use case, may happen quickly or not.
If it may happen quicly, we will see later how to mitigate this effect
We want to solve, the issue we introduce with robustness
Fail silently
Obviously: write in the log if something went wrong
More verbose than in this sample
Write exception message, traceback can be handy
Log.exception as well
May be interesting to log the progress, not every record or in debug mode
Don’t bloat the log
Quite simple, we want to offer the caller to not commit, so test can properly rollaback
Especially for testing purpose
Default behavior should commit.
Finally,
Remember that you can have à lot of wrong data that will always fail and take more and more time of processing
During the cron ?
Here we try to mitigate that by finding a trade off to completeness
Not useful for every use case
Only when you have lot of wrong data that you cannot exclude easily and that won’t be fixed quicly
For the invoicing
We only check few days ago so if a subscription failed to much we ignore it and we need to fix it manually
And probably create the invoice manually
For syncing partner,
Here we sync one by one, the api may not like it, since it will be à lot of calls
Dirty is set to 5, each try -1,
If it’s ok then 0
If not -1
We stop at 1, so 1 means to much retry and 0 means everything is fine
See this as a check list when you implement your cron.
Be carefull it’s not always that obvious than we saw in the use case.