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
gonzalesedwin1123 pushed a commit that referenced this pull request Aug 27, 2026
…ads (#464)

#384 made the create/write scan-queue hooks re-raise database errors instead
of swallowing them, so a transient serialization conflict reaches
`odoo.service.model.retrying` and is retried. That contract is right inside a
request. It is a trap during registry construction, which has no retry wrapper:
a re-raised `could not serialize access due to concurrent update` there aborts
the whole load and the server never boots.

Registry load writes binary attachments routinely. Module data may declare
`ir.attachment` records outright, and every `ir.ui.menu` carrying a `web_icon`
recomputes `web_icon_data` -- a `Binary(attachment=True)` field -- whenever its
XML is loaded, which lands as an `ir.attachment` create or write. Enqueueing
there inserts a `queue.job` row, and that insert flushes the deferred
`ir_attachment` UPDATE inside the re-raising hook, against a peer instance
holding the same rows on a shared database.

So gate both hooks on `env.registry.ready`. While it is False, nothing is
enqueued. New attachments keep the `scan_status` default ("pending"), so
nothing is left looking scanned; and `write` still performs the status reset
before bailing out, because skipping it would leave changed bytes wearing the
previous scan's `clean` verdict. That reset is a cache-level write on a row the
transaction has already dirtied -- no extra SQL, no extra conflict window.

Attachments written during load therefore stay `pending` until a rescan; no
cron sweeps them today, which is the honest state and strictly safer than the
alternative. Runtime uploads are untouched.

Tests assert all four sides so the guard cannot regress in either direction:
create and write enqueue nothing while the registry loads (and the write path
still resets the status), and both still enqueue at runtime.
gonzalesedwin1123 pushed a commit that referenced this pull request Aug 28, 2026
…can (#470)

* feat(spp_attachment_av_scan): sweep attachments stranded at pending

Queueing a malware scan is best-effort by design (#384): a non-database
enqueue failure is logged and the attachment is still written. Nothing
came back for the records that left behind — written, at the scan_status
default, indistinguishable in the UI from a file still waiting its turn
in a deep queue, retained unscanned indefinitely, and reachable only by
a human clicking Rescan. #464 added a second, benign source of the same
state.

An hourly ir.cron re-queues them, active by default and bounded on both
axes so it is safe on an existing database: pending_sweep_batch_size
caps one run, pending_sweep_max_attempts caps the attempts per record,
and pending_sweep_min_age_minutes keeps a fresh upload that is merely
waiting in a deep queue from being double-queued. Bumping the attempt
counter refreshes write_date, so the age threshold doubles as a flat
backoff, and a broken queue is reported once per run at WARNING rather
than once per record per tick.

Scope is user content: quarantined files (matching action_rescan),
forensic download copies, attachments with no res_model, and the system
models that store their own source-controlled binaries. A blank
res_model is not a proxy for that last set — Binary(attachment=True)
storage records the owning model, so menu icons arrive as
res_model='ir.ui.menu' — hence the explicit denylist. The sweep also has
to search under skip_res_field_check, because ir.attachment._search
silently hides every attachment backing a binary field, which would have
dropped user-uploaded image_1920 content from the sweep while appearing
to cover it.

The attempt counter is bumped for every record the batch picks up,
before the readability check: an attachment whose filestore file is lost
has file_size > 0 but no readable bytes, and skipping it without moving
write_date would park it at the head of every run forever.

* fix(spp_attachment_av_scan): mark sweep cron and config defaults noupdate

Without noupdate every module upgrade silently reset admin-tuned sweep
values (batch size, age threshold, a deliberately disabled cron) back to
the shipped defaults.

* fix(spp_attachment_av_scan): evict swept payloads from the ORM cache

The sweep reads each attachment's bytes only to prove they are readable;
the queued job re-reads them in its own transaction. Binary fields are
never evicted on their own, so a full batch would otherwise hold every
payload in one cron transaction's cache simultaneously.
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