mirror of
https://github.com/OCA/account-financial-tools.git
synced 2025-02-02 12:47:26 +02:00
A demo ir_sequence record with 'standard' implementation was assigned to the payment journal so that no concurrency errors arise from using 'no gap' sequences when attempting to create multiple payments.
323 lines
14 KiB
Python
323 lines
14 KiB
Python
import logging
|
|
import threading
|
|
import time
|
|
|
|
import psycopg2
|
|
|
|
import odoo
|
|
from odoo import SUPERUSER_ID, api, fields, tools
|
|
from odoo.tests import tagged
|
|
from odoo.tests.common import Form, TransactionCase
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ThreadRaiseJoin(threading.Thread):
|
|
"""Custom Thread Class to raise the exception to main thread in the join"""
|
|
|
|
def run(self, *args, **kwargs):
|
|
self.exc = None
|
|
try:
|
|
return super().run(*args, **kwargs)
|
|
except BaseException as e:
|
|
self.exc = e
|
|
|
|
def join(self, *args, **kwargs):
|
|
super().join(*args, **kwargs)
|
|
# raise exception in the join
|
|
# to raise it in the main thread
|
|
if self.exc:
|
|
raise self.exc
|
|
|
|
|
|
@tagged("post_install", "-at_install", "test_move_sequence")
|
|
class TestSequenceConcurrency(TransactionCase):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.product = self.env.ref("product.product_delivery_01")
|
|
self.partner = self.env.ref("base.res_partner_12")
|
|
self.date = fields.Date.to_date("1985-04-14")
|
|
|
|
def _new_cr(self):
|
|
return self.env.registry.cursor()
|
|
|
|
def _create_invoice_form(self, env, post=True):
|
|
ctx = {"default_move_type": "out_invoice"}
|
|
with Form(env["account.move"].with_context(**ctx)) as invoice_form:
|
|
invoice_form.partner_id = self.partner
|
|
invoice_form.invoice_date = self.date
|
|
|
|
with invoice_form.invoice_line_ids.new() as line_form:
|
|
line_form.product_id = self.product
|
|
line_form.price_unit = 100.0
|
|
line_form.tax_ids.clear()
|
|
invoice = invoice_form.save()
|
|
if post:
|
|
invoice.action_post()
|
|
return invoice
|
|
|
|
def _create_payment_form(self, env, ir_sequence_standard=False):
|
|
with Form(
|
|
env["account.payment"].with_context(
|
|
default_payment_type="inbound",
|
|
default_partner_type="customer",
|
|
default_move_journal_types=("bank", "cash"),
|
|
)
|
|
) as payment_form:
|
|
payment_form.partner_id = env.ref("base.res_partner_12")
|
|
payment_form.amount = 100
|
|
payment_form.date = self.date
|
|
|
|
payment = payment_form.save()
|
|
if ir_sequence_standard:
|
|
payment.move_id.journal_id.sequence_id = self.env.ref(
|
|
"account_move_name_sequence.ir_sequence_demo"
|
|
)
|
|
payment.action_post()
|
|
return payment
|
|
|
|
def _clean_moves(self, move_ids, payment=None):
|
|
"""Delete moves created after finish unittest using
|
|
self.addCleanup(
|
|
self._clean_moves, self.env, (invoices | payments.mapped('move_id')).ids
|
|
)"""
|
|
with self._new_cr() as cr:
|
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
|
moves = env["account.move"].browse(move_ids)
|
|
moves.button_draft()
|
|
moves = moves.with_context(force_delete=True)
|
|
moves.unlink()
|
|
# TODO: Delete payment and journal
|
|
env.cr.commit()
|
|
|
|
def _create_invoice_payment(
|
|
self, deadlock_timeout, payment_first=False, ir_sequence_standard=False
|
|
):
|
|
with odoo.api.Environment.manage():
|
|
odoo.registry(self.env.cr.dbname)
|
|
with self._new_cr() as cr, cr.savepoint():
|
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
|
cr_pid = cr.connection.get_backend_pid()
|
|
# Avoid waiting for a long time and it needs to be less than deadlock
|
|
cr.execute(
|
|
"SET LOCAL statement_timeout = '%ss'", (deadlock_timeout + 10,)
|
|
)
|
|
if payment_first:
|
|
_logger.info("Creating payment cr %s", cr_pid)
|
|
self._create_payment_form(
|
|
env, ir_sequence_standard=ir_sequence_standard
|
|
)
|
|
_logger.info("Creating invoice cr %s", cr_pid)
|
|
self._create_invoice_form(env)
|
|
else:
|
|
_logger.info("Creating invoice cr %s", cr_pid)
|
|
self._create_invoice_form(env)
|
|
_logger.info("Creating payment cr %s", cr_pid)
|
|
self._create_payment_form(
|
|
env, ir_sequence_standard=ir_sequence_standard
|
|
)
|
|
# sleep in order to avoid release the locks too faster
|
|
# It could be many methods called after creating these
|
|
# kind of records e.g. reconcile
|
|
_logger.info("Finishing waiting %s" % (deadlock_timeout + 12))
|
|
time.sleep(deadlock_timeout + 12)
|
|
|
|
def test_sequence_concurrency_10_draft_invoices(self):
|
|
"""Creating 2 DRAFT invoices not should raises errors"""
|
|
with self._new_cr() as cr0, self._new_cr() as cr1, self._new_cr() as cr2:
|
|
env0 = api.Environment(cr0, SUPERUSER_ID, {})
|
|
env1 = api.Environment(cr1, SUPERUSER_ID, {})
|
|
env2 = api.Environment(cr2, SUPERUSER_ID, {})
|
|
for cr in [cr0, cr1, cr2]:
|
|
# Set 10s timeout in order to avoid waiting for release locks a long time
|
|
cr.execute("SET LOCAL statement_timeout = '10s'")
|
|
|
|
# Create "last move" to lock
|
|
invoice = self._create_invoice_form(env0)
|
|
self.addCleanup(self._clean_moves, invoice.ids)
|
|
env0.cr.commit()
|
|
with env1.cr.savepoint(), env2.cr.savepoint():
|
|
invoice1 = self._create_invoice_form(env1, post=False)
|
|
self.assertEqual(invoice1.state, "draft")
|
|
invoice2 = self._create_invoice_form(env2, post=False)
|
|
self.assertEqual(invoice2.state, "draft")
|
|
|
|
def test_sequence_concurrency_20_editing_last_invoice(self):
|
|
"""Edit last invoice and create a new invoice
|
|
should not raises errors"""
|
|
with self._new_cr() as cr0, self._new_cr() as cr1:
|
|
env0 = api.Environment(cr0, SUPERUSER_ID, {})
|
|
env1 = api.Environment(cr1, SUPERUSER_ID, {})
|
|
for cr in [cr0, cr1]:
|
|
# Set 10s timeout in order to avoid waiting for release locks a long time
|
|
cr.execute("SET LOCAL statement_timeout = '10s'")
|
|
|
|
# Create "last move" to lock
|
|
invoice = self._create_invoice_form(env0)
|
|
|
|
self.addCleanup(self._clean_moves, invoice.ids)
|
|
env0.cr.commit()
|
|
with env0.cr.savepoint(), env1.cr.savepoint():
|
|
# Edit something in "last move"
|
|
invoice.write({"write_uid": env0.uid})
|
|
invoice.flush()
|
|
self._create_invoice_form(env1)
|
|
|
|
def test_sequence_concurrency_30_editing_last_payment(self):
|
|
"""Edit last payment and create a new payment
|
|
should not raises errors"""
|
|
with self._new_cr() as cr0, self._new_cr() as cr1:
|
|
env0 = api.Environment(cr0, SUPERUSER_ID, {})
|
|
env1 = api.Environment(cr1, SUPERUSER_ID, {})
|
|
for cr in [cr0, cr1]:
|
|
# Set 10s timeout in order to avoid waiting for release locks a long time
|
|
cr.execute("SET LOCAL statement_timeout = '10s'")
|
|
|
|
# Create "last move" to lock
|
|
payment = self._create_payment_form(env0)
|
|
payment_move = payment.move_id
|
|
self.addCleanup(self._clean_moves, payment_move.ids)
|
|
env0.cr.commit()
|
|
with env0.cr.savepoint(), env1.cr.savepoint():
|
|
# Edit something in "last move"
|
|
payment_move.write({"write_uid": env0.uid})
|
|
payment_move.flush()
|
|
self._create_payment_form(env1)
|
|
|
|
@tools.mute_logger("odoo.sql_db")
|
|
def test_sequence_concurrency_40_reconciling_last_invoice(self):
|
|
"""Reconcile last invoice and create a new one
|
|
should not raises errors"""
|
|
with self._new_cr() as cr0, self._new_cr() as cr1:
|
|
env0 = api.Environment(cr0, SUPERUSER_ID, {})
|
|
env1 = api.Environment(cr1, SUPERUSER_ID, {})
|
|
for cr in [cr0, cr1]:
|
|
# Set 10s timeout in order to avoid waiting for release locks a long time
|
|
cr.execute("SET LOCAL statement_timeout = '10s'")
|
|
|
|
# Create "last move" to lock
|
|
invoice = self._create_invoice_form(env0)
|
|
payment = self._create_payment_form(env0)
|
|
payment_move = payment.move_id
|
|
self.addCleanup(self._clean_moves, invoice.ids + payment_move.ids)
|
|
env0.cr.commit()
|
|
lines2reconcile = (
|
|
(payment_move | invoice)
|
|
.mapped("line_ids")
|
|
.filtered(lambda l: l.account_id.internal_type == "receivable")
|
|
)
|
|
with env0.cr.savepoint(), env1.cr.savepoint():
|
|
# Reconciling "last move"
|
|
# reconcile a payment with many invoices spend a lot so it could
|
|
# lock records too many time
|
|
lines2reconcile.reconcile()
|
|
# Many pieces of code call flush directly
|
|
lines2reconcile.flush()
|
|
self._create_invoice_form(env1)
|
|
|
|
def test_sequence_concurrency_50_reconciling_last_payment(self):
|
|
"""Reconcile last payment and create a new one
|
|
should not raises errors"""
|
|
with self._new_cr() as cr0, self._new_cr() as cr1:
|
|
env0 = api.Environment(cr0, SUPERUSER_ID, {})
|
|
env1 = api.Environment(cr1, SUPERUSER_ID, {})
|
|
for cr in [cr0, cr1]:
|
|
# Set 10s timeout in order to avoid waiting for release locks a long time
|
|
cr.execute("SET LOCAL statement_timeout = '10s'")
|
|
|
|
# Create "last move" to lock
|
|
invoice = self._create_invoice_form(env0)
|
|
payment = self._create_payment_form(env0)
|
|
payment_move = payment.move_id
|
|
self.addCleanup(self._clean_moves, invoice.ids + payment_move.ids)
|
|
env0.cr.commit()
|
|
lines2reconcile = (
|
|
(payment_move | invoice)
|
|
.mapped("line_ids")
|
|
.filtered(lambda l: l.account_id.internal_type == "receivable")
|
|
)
|
|
with env0.cr.savepoint(), env1.cr.savepoint():
|
|
# Reconciling "last move"
|
|
# reconcile a payment with many invoices spend a lot so it could
|
|
# lock records too many time
|
|
lines2reconcile.reconcile()
|
|
# Many pieces of code call flush directly
|
|
lines2reconcile.flush()
|
|
self._create_payment_form(env1)
|
|
|
|
def test_sequence_concurrency_90_payments(self):
|
|
"""Creating concurrent payments should not raises errors"""
|
|
with self._new_cr() as cr0, self._new_cr() as cr1, self._new_cr() as cr2:
|
|
env0 = api.Environment(cr0, SUPERUSER_ID, {})
|
|
env1 = api.Environment(cr1, SUPERUSER_ID, {})
|
|
env2 = api.Environment(cr2, SUPERUSER_ID, {})
|
|
for cr in [cr0, cr1, cr2]:
|
|
# Set 10s timeout in order to avoid waiting for release locks a long time
|
|
cr.execute("SET LOCAL statement_timeout = '10s'")
|
|
|
|
# Create "last move" to lock
|
|
payment = self._create_payment_form(env0, ir_sequence_standard=True)
|
|
payment_move_ids = payment.move_id.ids
|
|
self.addCleanup(self._clean_moves, payment_move_ids)
|
|
env0.cr.commit()
|
|
with env1.cr.savepoint(), env2.cr.savepoint():
|
|
self._create_payment_form(env1)
|
|
self._create_payment_form(env2)
|
|
|
|
@tools.mute_logger("odoo.sql_db")
|
|
def test_sequence_concurrency_95_pay2inv_inv2pay(self):
|
|
"""Creating concurrent payment then invoice and invoice then payment
|
|
should not raises errors
|
|
It raises deadlock sometimes"""
|
|
with self._new_cr() as cr0:
|
|
env0 = api.Environment(cr0, SUPERUSER_ID, {})
|
|
|
|
# Create "last move" to lock
|
|
invoice = self._create_invoice_form(env0)
|
|
|
|
# Create "last move" to lock
|
|
payment = self._create_payment_form(env0)
|
|
payment_move_ids = payment.move_id.ids
|
|
self.addCleanup(self._clean_moves, invoice.ids + payment_move_ids)
|
|
env0.cr.commit()
|
|
env0.cr.execute(
|
|
"SELECT setting FROM pg_settings WHERE name = 'deadlock_timeout'"
|
|
)
|
|
deadlock_timeout = int(env0.cr.fetchone()[0]) # ms
|
|
# You could not have permission to set this parameter
|
|
# psycopg2.errors.InsufficientPrivilege
|
|
self.assertTrue(
|
|
deadlock_timeout,
|
|
"You need to configure PG parameter deadlock_timeout='1s'",
|
|
)
|
|
deadlock_timeout = int(deadlock_timeout / 1000) # s
|
|
try:
|
|
t_pay_inv = ThreadRaiseJoin(
|
|
target=self._create_invoice_payment,
|
|
args=(deadlock_timeout, True, True),
|
|
name="Thread payment invoice",
|
|
)
|
|
t_inv_pay = ThreadRaiseJoin(
|
|
target=self._create_invoice_payment,
|
|
args=(deadlock_timeout, False, True),
|
|
name="Thread invoice payment",
|
|
)
|
|
t_pay_inv.start()
|
|
t_inv_pay.start()
|
|
# the thread could raise the error before to wait for it so disable coverage
|
|
t_pay_inv.join(timeout=deadlock_timeout + 15) # pragma: no cover
|
|
t_inv_pay.join(timeout=deadlock_timeout + 15) # pragma: no cover
|
|
except psycopg2.OperationalError as e:
|
|
if e.pgcode in [
|
|
psycopg2.errorcodes.SERIALIZATION_FAILURE,
|
|
psycopg2.errorcodes.LOCK_NOT_AVAILABLE,
|
|
]: # pragma: no cover
|
|
# Concurrency error is expected but not deadlock so ok
|
|
pass
|
|
elif (
|
|
e.pgcode == psycopg2.errorcodes.DEADLOCK_DETECTED
|
|
): # pragma: no cover
|
|
self.assertFalse(True, "Deadlock detected.")
|
|
else: # pragma: no cover
|
|
raise
|