Skip to content

ePošťák cron send: one failed document rolls back the batch (sent invoice re-queued, transient failure parked in error) #6

Closed

Description

@4ktLuffy

When _cron_send_queued sends a batch and one document fails, _stamp_failure → _write_outside_job runs cr.rollback() on the cron's own cursor: with HTTP answers 202 / 503 / 202 the accepted DOC1 goes back to queued (so it is sent again on the next run), the transient-503 DOC2 is parked in error and never retried, and DOC3 is not sent (the log shows InvalidSavepointSpecification). Reproduced on 208b7f6 with the script below (HTTP is faked, no network); skipping _stamp_failure under the cron dispatch and committing per message with ir.cron._commit_progress gives DOC1 sent, DOC2 queued, DOC3 sent.

# odoo-bin shell -d <throwaway db> < issue_cron_repro.py   (edi_epostak_connector_sapi installed; no network: HTTP is faked)
import json
from unittest.mock import patch
import requests

def resp(status, body):
    r = requests.Response(); r.status_code = status; r._content = json.dumps(body).encode(); r.encoding = 'utf-8'
    return r

answers = [resp(202, {'providerDocumentId': 'P1'}),
           resp(503, {'error': {'code': 'UNAVAILABLE', 'message': 'try later', 'retryable': True}}),
           resp(202, {'providerDocumentId': 'P3'})]

def fake_send(session, request, **kw):
    if request.url.split('?')[0].endswith('/auth/token'):
        return resp(200, {'access_token': 't', 'expires_in': 900})
    return answers.pop(0)

ICP = env['ir.config_parameter'].sudo()
ICP.set_param('epostak.mode', 'sandbox')
ICP.set_param('epostak.sapi.sandbox.client_id', 'c1')
ICP.set_param('epostak.sapi.sandbox.client_secret', 'fake')
env.company.partner_id.write({'peppol_eas': '0245', 'peppol_endpoint': '2022749619'})
ubl = ('<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" '
       'xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" '
       'xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">'
       '<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>'
       '<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>'
       '<cbc:ID>%s</cbc:ID><cbc:IssueDate>2026-10-20</cbc:IssueDate>'
       '<cac:AccountingSupplierParty><cac:Party><cbc:EndpointID schemeID="0245">2022749619</cbc:EndpointID></cac:Party></cac:AccountingSupplierParty>'
       '<cac:AccountingCustomerParty><cac:Party><cbc:EndpointID schemeID="0245">2120000005</cbc:EndpointID></cac:Party></cac:AccountingCustomerParty></Invoice>')
msgs = env['edi.message']
for i in (1, 2, 3):
    m = env['edi.message'].create({'name': 'DOC%d.xml' % i, 'direction': 'out', 'provider': 'epostak',
                                   'doc_family': 'peppol', 'state': 'queued', 'company_id': env.company.id})
    m._store_xml(ubl % ('DOC%d' % i)); msgs |= m
env.cr.commit()

with patch('requests.sessions.Session.send', fake_send):
    try:
        env['edi.message']._cron_send_queued(); env.cr.commit()
    except Exception as e:
        print('cron raised:', type(e).__name__); env.cr.rollback()

env.invalidate_all()
print('HTTP answers were 202 / 503 / 202. Result:')
for m in msgs:
    print('  ', m.name, m.state, (m.error_message or '')[:60])
msgs.unlink(); env.cr.commit()

Output on 208b7f6:

cron raised: InFailedSqlTransaction
HTTP answers were 202 / 503 / 202. Result:
   DOC1.xml queued
   DOC2.xml error ePošťák POST /document/send failed (HTTP 503): UNAVAILABLE t
   DOC3.xml queued

Suggested fix (output after it: DOC1 sent, DOC2 queued, DOC3 sent):

--- a/edi_epostak_connector_sapi/models/epostak_connector.py
-                self._stamp_failure(
-                    msg, str(e), "warning" if retryable else "error"
-                )
+                if msg._dispatch_mode() != "cron":   # under cron this rolls back the cron's own transaction
+                    self._stamp_failure(
+                        msg, str(e), "warning" if retryable else "error"
+                    )
--- a/edi_base/models/edi_message.py  (_cron_send_queued, end of the loop body)
+            if not odoo.modules.module.current_test:   # make each outcome durable before the next HTTP call
+                if not self.env["ir.cron"]._commit_progress(1):
+                    break

Activity

  1. added a commit that references this issue on Oct 24, 2026
    792f382
  2. Rad0van commented on Oct 24, 2026

    @Rad0van
    Member

    Confirmed and fixed in 792f382 (edi_base, edi_epostak_connector_sapi). Thanks for the script, which made this quick to pin down. Your diagnosis was exactly right.

    The fix is close to what you suggested:

    • Cron context flag: _cron_send_queued passes edi_cron_send in the connector's context. Under it, the connector no longer calls _stamp_failure: it only raises, and the cron records the outcome in its own transaction (sent / queued with a warning / error). I used a context flag rather than _dispatch_mode() == "cron" so the rule is "don't roll back a cursor you don't own" whoever the caller is. The queue-job path still stamps outside the transaction as before.
    • Commit per message: in a real cron run (cron_id in context), each message's outcome is committed before the next send through ir.cron._commit_progress, and the loop stops when the run's time budget is spent. Outside a cron (a shell, a test) nothing is committed.

    Your script run through the actual cron record (edi_base.cron_edi_send_queued.method_direct_trigger()) gives DOC1 sent, DOC2 queued, DOC3 sent. Before the fix it gave the three wrong states and the InvalidSavepointSpecification.

    A note on the tests (edi_epostak_connector_sapi/tests/test_epostak_cron_send.py): a TransactionCase can't show the damage itself, because its rollback and its "separate" cursor both stay inside the test transaction. So they pin the invariant instead: no out-of-job write under the cron, and still one inside a job. The old code fails them.

  3. 4ktLuffy commented on Oct 25, 2026

    @4ktLuffy
    Author

    Thanks, Radovan. I re-ran my tests for #6 to #11 on 792f382 and they all pass, along with the modules' own tests. The cron script now gives DOC1 sent, DOC2 queued, DOC3 sent.

  4. added a commit that references this issue on Oct 25, 2026
    8bbd3fc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions