Repository navigation
ePošťák cron send: one failed document rolls back the batch (sent invoice re-queued, transient failure parked in error) #6
Copy link
Copy link
Closed
Description
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):
+ breakReactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
sync: l10n_cssk@8949f588
Rad0van commentedon Oct 24, 2026
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_send_queuedpassesedi_cron_sendin 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 /queuedwith 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.cron_idin context), each message's outcome is committed before the next send throughir.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 DOC1sent, DOC2queued, DOC3sent. Before the fix it gave the three wrong states and theInvalidSavepointSpecification.A note on the tests (
edi_epostak_connector_sapi/tests/test_epostak_cron_send.py): aTransactionCasecan'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.4ktLuffy commentedon Oct 25, 2026
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.
sync: l10n_cssk@8949f588