Skip to content

fix(spp_attachment_av_scan): never swallow a database error when queueing a scan - #384

Merged
gonzalesedwin1123 merged 4 commits into
19.0from
fix/av-scan-must-not-swallow-db-errors
Aug 3, 2026
Merged

fix(spp_attachment_av_scan): never swallow a database error when queueing a scan#384
gonzalesedwin1123 merged 4 commits into
19.0from
fix/av-scan-must-not-swallow-db-errors

Conversation

@reichie020212

@reichie020212 reichie020212 commented Jul 31, 2026

Copy link
Copy Markdown
Member

What

The create/write hooks in spp_attachment_av_scan wrap the scan-queue call in a bare except Exception and log whatever they catch. A database error is different in kind: it leaves the transaction unusable. Swallowing one lets execution continue on a dead cursor, so the next statement to touch the database fails instead — in unrelated code, with no trace of the real cause.

The incident

On a DSWD dev instance, a routine attachment write during a module upgrade hit a transient conflict:

ERROR: could not serialize access due to concurrent update
  bad query: UPDATE "ir_attachment" SET "db_datas" = ..., "quarantine_data" = ... WHERE id = 504

This module caught it and moved on:

ERROR ... spp_attachment_av_scan.models.ir_attachment:
  Failed to queue malware scan for updated attachment ID 504: could not serialize access due to concurrent update

The very next statement was an unrelated XML-id lookup in spp_base_common's menu-icon refresh:

  bad query: SELECT model, res_id FROM ir_model_data WHERE module='stock' AND name='menu_stock_root'
  ERROR: current transaction is aborted, commands ignored until end of transaction block

and that was the only error the operator ever saw:

  File ".../spp_base_common/models/ir_module_module.py", line 80, in update_menu_icons
    menu = self.env.ref(icon_info["menu_xml_id"])
psycopg2.errors.InFailedSqlTransaction: current transaction is aborted

Every module upgrade failed this way. Four different modules were investigated and blamed in turn — including two wrong root-cause diagnoses — before the real trigger was found as a stray ERROR line in the server log. InFailedSqlTransaction is emitted by Postgres only when an earlier statement already failed, so the reported line is always a victim; the traceback structurally cannot name its own cause.

It also defeated the recovery Odoo already provides

This is the part that makes it more than a logging annoyance.

odoo.service.model.retrying retries a request on (IntegrityError, OperationalError, ConcurrencyError) — up to MAX_TRIES_ON_CONCURRENCY_FAILURE, rolling back in between. SerializationFailure reaches that tuple through SerializationFailure -> TransactionRollbackError -> OperationalError.

So left alone, this conflict would have been retried transparently and nobody would have noticed. Caught here, it became a hard, permanent failure attributed to the wrong subsystem. retrying appears in every one of the incident tracebacks (service/model.py:188) — it was on the stack, ready to handle exactly this, and never got the chance.

(Nuance: retrying wraps RPC/HTTP dispatch. A CLI odoo -u upgrade gets no retry — on that path the fix's value is that the upgrade fails loudly with the true cause instead of a misleading victim traceback.)

The fix

Re-raise the retryable classes ahead of the existing broad catch, in both hooks:

_MUST_NOT_SWALLOW = (psycopg2.Error, ConcurrencyError)
...
                    except _MUST_NOT_SWALLOW:
                        raise
                    except Exception as error:
                        _logger.error("Failed to queue malware scan ...")

A split, not a removal — deliberately. Queueing a scan genuinely is best-effort: a misconfigured queue channel raising ValueError must not block attachment creation across the platform. Only database errors, which cannot be safely ignored, now propagate.

Scope: 2 sites of 9

The file has nine except Exception blocks. This changes only the two on the create/write request path.

The other seven (_scan_for_malware, _quarantine, _notify_security_admins, action_restore_quarantined, action_download_quarantined_for_analysis, action_rescan) are reached via queue jobs or explicit buttons, where a poisoned transaction is confined to that job rather than corrupting a user request. Same latent hazard, different blast radius — worth a follow-up (#385), deliberately not widened here.

Tests

spp_attachment_av_scan/tests/test_scan_queue_error_handling.py — the module's suite goes 40 → 45 tests, 0 failed, 0 errors. That the pre-existing 40 still pass matters: re-raising from a hook that previously never raised is exactly the change that could break unrelated attachment tests.

Both sides of the contract are asserted, because only one side is obvious:

Test Asserts
..._propagates_on_create / ..._propagates_on_write the incident's actual SerializationFailure propagates
..._is_still_swallowed_on_create / ..._on_write a ValueError is still logged and the attachment is still written
test_the_retry_machinery_can_see_the_error_class_we_re_raise SerializationFailure remains a subclass of what retrying catches

The two "still swallowed" tests are the anti-vacuity guard: re-raising everything would pass the propagation tests while breaking best-effort queueing platform-wide. The last test guards the reason the fix works — a future refactor that re-raised some wrapped exception would no longer be retried, reviving the incident in a new disguise, and would fail here.

Negative control: with both re-raise clauses removed (restoring the exact pre-fix code), 2 of 45 fail — precisely the two propagation tests, while both "still swallowed" tests and the subclass check still pass. The tests detect the behaviour, not the presence of the code.

Verification notes

pre-commit run --files passes on the changed files, except bandit, which fails with pyproject.toml : toml parser not available, reinstall with toml extra. That is a pre-existing environment fault in the hook, not this change: it fails identically on spp_attachment_av_scan/models/av_scanner_backend.py, which this PR does not touch.

Operator note

For an instance already hitting this, the immediate workaround is to stop the job worker during a module upgrade so no _scan_for_malware job races the attachment write. With this fix deployed that is unnecessary — the conflict returns to being retried invisibly.

…eing a scan

The create/write hooks wrapped the scan-queue call in a bare `except Exception`
and logged whatever it caught. A database error, however, leaves the transaction
unusable -- so swallowing one lets execution continue on a dead cursor, and the
next statement to touch the database fails instead, in unrelated code.

Observed on a DSWD dev instance: a routine attachment write during a module
upgrade hit a transient

    ERROR: could not serialize access due to concurrent update

on ir_attachment. This module caught it, logged "Failed to queue malware scan for
updated attachment ID 504", and carried on. The next statement was an unrelated
`env.ref("stock.menu_stock_root")` inside spp_base_common's menu-icon refresh,
which raised InFailedSqlTransaction -- and that was the only error the operator
ever saw. EVERY module upgrade failed this way, with four different modules
blamed in turn before the real cause was found in a stray ERROR log line.

The swallow also defeated the recovery Odoo already provides.
`odoo.service.model.retrying` retries a request on IntegrityError /
OperationalError / ConcurrencyError, and SerializationFailure reaches that tuple
via TransactionRollbackError -> OperationalError. Left alone the conflict would
have been retried transparently; caught, it became a hard failure attributed to
the wrong subsystem.

Re-raise those classes ahead of the existing broad catch. Queueing a scan stays
best-effort for everything else -- a misconfigured queue raising ValueError must
not block an attachment write -- so the fix is a split, not a removal.

Scope: only the two hooks on the create/write request path. The module's seven
other `except Exception` blocks (_scan_for_malware, _quarantine,
_notify_security_admins, and three action_* methods) are reached via queue jobs
or explicit buttons, where a poisoned transaction is confined to that job rather
than a user request. Same latent hazard, different blast radius; left for a
follow-up rather than widening this change.

Tests assert both sides of the contract: a DB error propagates from create and
from write, a non-DB error is still swallowed and still logged (and the
attachment is still written), and SerializationFailure remains a subclass of what
`retrying` recovers from, so a future refactor cannot silently revive the
incident by re-raising something wrapped.

Signed-off-by: Red <redickbutay02@gmail.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 68.50%. Comparing base (bf61488) to head (31f1f5c).
⚠️ Report is 132 commits behind head on 19.0.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #384      +/-   ##
==========================================
- Coverage   74.86%   68.50%   -6.36%     
==========================================
  Files        1093      103     -990     
  Lines       63718     8674   -55044     
==========================================
- Hits        47701     5942   -41759     
+ Misses      16017     2732   -13285     
Flag Coverage Δ
endpoint_route_handler ?
fastapi ?
spp_aggregation ?
spp_alerts ?
spp_analytics ?
spp_api_v2 ?
spp_api_v2_change_request ?
spp_api_v2_cycles ?
spp_api_v2_data ?
spp_api_v2_entitlements ?
spp_api_v2_gis ?
spp_api_v2_products ?
spp_api_v2_programs ?
spp_api_v2_service_points ?
spp_api_v2_simulation ?
spp_api_v2_vocabulary ?
spp_approval ?
spp_area ?
spp_area_hdx ?
spp_attachment_av_scan 85.12% <100.00%> (+1.37%) ⬆️
spp_audit ?
spp_audit_programs ?
spp_banking ?
spp_base_common 90.26% <ø> (ø)
spp_base_setting ?
spp_case_base ?
spp_case_cel ?
spp_case_demo ?
spp_case_entitlements ?
spp_case_graduation ?
spp_case_programs ?
spp_case_registry ?
spp_case_session ?
spp_cel_domain ?
spp_cel_event ?
spp_cel_registry_search ?
spp_cel_vocabulary ?
spp_change_request_v2 ?
spp_claim_169 ?
spp_cr_type_assign_program ?
spp_cr_types_advanced ?
spp_cr_types_base ?
spp_dci ?
spp_dci_client ?
spp_dci_client_dr ?
spp_dci_client_ibr ?
spp_dci_client_sr ?
spp_dci_compliance ?
spp_dci_demo ?
spp_dci_indicators ?
spp_dci_server ?
spp_dci_server_social ?
spp_demo ?
spp_demo_phl_luzon ?
spp_disability_registry ?
spp_drims ?
spp_drims_sl ?
spp_drims_sl_demo ?
spp_encryption ?
spp_farmer_registry ?
spp_farmer_registry_cr ?
spp_farmer_registry_demo ?
spp_farmer_registry_vocabularies ?
spp_gis ?
spp_gis_indicators ?
spp_gis_report ?
spp_graduation ?
spp_grm ?
spp_grm_case_link ?
spp_grm_demo ?
spp_hazard ?
spp_hazard_programs ?
spp_hxl_area ?
spp_import_match ?
spp_indicator ?
spp_irrigation ?
spp_land_record ?
spp_metric ?
spp_metric_service ?
spp_metrics_core ?
spp_metrics_services ?
spp_mis_demo_v2 ?
spp_oauth ?
spp_program_geofence ?
spp_programs 65.27% <ø> (ø)
spp_registrant_gis ?
spp_registry 87.12% <ø> (+0.28%) ⬆️
spp_registry_group_hierarchy ?
spp_scoring ?
spp_scoring_programs ?
spp_security 66.66% <ø> (ø)
spp_service_points ?
spp_simulation ?
spp_starter_disability_registry ?
spp_starter_farmer_registry ?
spp_starter_social_registry ?
spp_starter_sp_mis ?
spp_statistic ?
spp_storage_backend ?
spp_studio ?
spp_studio_change_requests ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_attachment_av_scan/__manifest__.py 0.00% <ø> (ø)
spp_attachment_av_scan/models/ir_attachment.py 81.99% <100.00%> (+1.99%) ⬆️

... and 992 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Patch bump + HISTORY fragment for the DB-error re-raise fix, per repo
convention. Also corrects the _MUST_NOT_SWALLOW docstring: psycopg2.Error
is a superset of what retrying recovers from, not an exact match.
Assert SerializationFailure against Odoo's exported
PG_CONCURRENCY_EXCEPTIONS_TO_RETRY instead of only a hardcoded tuple, so
an upstream change to the retry set fails the test. Add a propagation
test for ConcurrencyError, the second member of _MUST_NOT_SWALLOW.
@gonzalesedwin1123

Copy link
Copy Markdown
Member

Adversarial review summary

Ran a staff-engineer adversarial review on the full diff (f4d6586c..31f1f5ce). Verdict: the fix is sound. Every claim in the PR description was verified against primary sources, and the strongest attacks on the approach failed:

Attacks that failed

  1. "Re-raising is overreach if the enqueue uses a separate cursor." The one angle that could have invalidated the approach: if job_worker enqueued on its own connection, a DB error there would leave the request transaction healthy, and re-raising would turn a harmless best-effort failure into a request failure. Verified false against job_worker's source: queue.job.enqueue() runs the identity-key search(), the create(vals), and NOTIFY queue_job_wake_up all on the request cursor. Any psycopg2.Error escaping with_delay()._scan_for_malware() therefore means the request transaction is already dead — re-raising is the only correct behavior.
  2. "The retry-machinery claim might not match Odoo 19." Verified against the shipped source: odoo.service.model.retrying catches exactly (IntegrityError, OperationalError, ConcurrencyError), and PG_CONCURRENCY_EXCEPTIONS_TO_RETRY contains errors.SerializationFailure. ConcurrencyError exists in odoo/exceptions.py.
  3. "psycopg2.Error is too broad — it also re-raises non-retryable errors." True but correct: given (1), every psycopg2.Error here implies an aborted transaction. Non-retryable classes now fail with the true traceback instead of poisoning unrelated downstream code; retrying's else: raise branch handles them cleanly.
  4. "It could regress install/demo/CLI-upgrade paths." Those paths didn't succeed on a DB error before either — they failed later, blamed on the wrong module. Now they fail at the cause, and RPC-driven paths are additionally retried.
  5. Test mechanics — patch target, mock restoration, transaction isolation, except-clause ordering, and the anti-vacuity pair all check out.

Added on the branch during review

  • ba1f3efa — version bump to 19.0.2.0.1 + readme/HISTORY.md fragment (repo convention), and a _MUST_NOT_SWALLOW docstring correction (psycopg2.Error is a superset of the retry tuple, not an exact match).
  • 83a55bf3README.rst/static/description/index.html regenerated from CI's pinned generator output, applied verbatim.
  • 31f1f5ce — test hardening: the retry-eligibility guard now asserts against Odoo's exported PG_CONCURRENCY_EXCEPTIONS_TO_RETRY (so an upstream change to the retry set fails the test), plus a ConcurrencyError propagation test. Module suite: 46 tests, 0 failures, verified locally and in CI.

Follow-up

The seven remaining except Exception sites in this file (queue-job and button paths — same latent hazard, smaller blast radius) are tracked in #385.

CI is fully green on the final commit. Ready for human review.

@gonzalesedwin1123
gonzalesedwin1123 merged commit 7fff93b into 19.0 Aug 3, 2026
20 checks passed
@gonzalesedwin1123
gonzalesedwin1123 deleted the fix/av-scan-must-not-swallow-db-errors branch August 3, 2026 02:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants