diff --git a/account_move_name_sequence/__manifest__.py b/account_move_name_sequence/__manifest__.py index 18cc46660..b9b39f39e 100644 --- a/account_move_name_sequence/__manifest__.py +++ b/account_move_name_sequence/__manifest__.py @@ -17,6 +17,9 @@ "depends": [ "account", ], + "demo": [ + "demo/ir_sequence_demo.xml", + ], "data": [ "views/account_journal.xml", "views/account_move.xml", diff --git a/account_move_name_sequence/demo/ir_sequence_demo.xml b/account_move_name_sequence/demo/ir_sequence_demo.xml new file mode 100644 index 000000000..34f9c5f56 --- /dev/null +++ b/account_move_name_sequence/demo/ir_sequence_demo.xml @@ -0,0 +1,11 @@ + + + + Standard Sequence Demo + demo/%(range_year)s/ + + 1 + 1 + + + diff --git a/account_move_name_sequence/tests/__init__.py b/account_move_name_sequence/tests/__init__.py index 5de02aafc..5510ca286 100644 --- a/account_move_name_sequence/tests/__init__.py +++ b/account_move_name_sequence/tests/__init__.py @@ -1 +1,2 @@ from . import test_account_move_name_seq +from . import test_sequence_concurrency diff --git a/account_move_name_sequence/tests/test_sequence_concurrency.py b/account_move_name_sequence/tests/test_sequence_concurrency.py new file mode 100644 index 000000000..7f7c7cc45 --- /dev/null +++ b/account_move_name_sequence/tests/test_sequence_concurrency.py @@ -0,0 +1,322 @@ +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