Plugin data: the three loops that ask data-owning plugins swallow failures unlogged, and a failing eraser makes erasing a contact a 500 #371
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#371
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, by reading every loop that calls a plugin'sexport_fororerase_forand running the erasure with a stand-in plugin that owns rows. Related to #297 (closed), which added the data-subject export and the erasure and promised one loop "rather than two"; this issue covers what that work left: three copies of the loop, none of which logs, and an erasure that a failing plugin can break.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The three loops andcore/export.pyare where they were; the code is now atplugins/registry.py:364-365for thelogger.exceptionused as the comparison, and atCONTRIBUTING.mdline 618 for "What a plugin that owns a model has to do".Three functions walk every plugin that declares
owns_modelsand call itsexport_fororerase_forinside a bareexcept Exception:. None of them logs, althoughdocs/THREAT-MODEL.md:17promises that "a plugin that raises is logged and skipped". None of them gives the plugin a savepoint either, so in the erasure a plugin whose failure comes out of an ORM write poisons the transaction: the contact is not erased and the person gets a 500. No plugin Postulo ships owns rows today (plugins/data.py:3,plugins/data.py:50-52), so all of this is latent, but the contract is written for plugins that will (CONTRIBUTING.md, "A plugin that owns a model").core/gdpr.py:250-276(_erase_plugin_rows):except Exception: not_erased.append(str(plugin.label)), called atcore/gdpr.py:292inside thetransaction.atomic()oferase_contact(core/gdpr.py:279-310), reached fromContactDeleteView.form_validinjobs/views.py:793-802while the GDPR feature is offered.plugins/data.py:146-175(export_sections_for, used by the account archive and by the subject export atcore/gdpr.py:159-161):except Exception: # pragma: no cover, thenmissing.extend(...).jobs/merging.py:522-545(_held_by_plugins, "Asked the way the archive asks"):except Exception: holds = True.core/export.py:490-496(_plugin_data) wraps the whole section in a second silentexcept.notifications/service.py:85-88andplugins/registry.py:382-383calllogger.exception.1. A failing plugin leaves nothing in the log
A plugin that owns a table raises in
export_fororerase_for, because of a bug or an unapplied migration. The archive names its models undernot_carried, the merge dialog says it holds rows, and the erasure lists it undernot_erased, but nothing reaches the log. The operator cannot tell a crashing plugin from one that simply does not answer, and the plugin's author has no traceback. The fail-safe answers are right; the silence is what breaks the stated rule.The
# pragma: no coveronplugins/data.py:170is also untrue:tests/test_plugin_data.py:261(test_a_plugin_that_raises_while_exporting_is_named_rather_than_fatal) runs that branch.2. The erasure has no savepoint around the plugin
erase_contactcalls every eraser inside onetransaction.atomic()and catches what it raises. When the failure comes out of an ORM write (asave()that violates a constraint, or adelete()whosepre_deletehandler raises), Django has already marked the transaction for rollback, and the next query,contact.phone_numbers.count()atcore/gdpr.py:294, raisesTransactionManagementError: An error occurred in the current transaction. Reproduced in the snapshot on SQLite with a stand-in plugin that ownscore.Tag: the contact survives, the request is a 500, and the log is empty. The first plugin whose eraser fails this way makes every contact impossible to delete while the feature is offered, with no trace of why.The two read loops have the same shape. Every request is a transaction (
config/settings/base.py:140setsATOMIC_REQUESTS), and on PostgreSQL a statement that fails, such as a query against a table whose migration was not applied, aborts the transaction until it is rolled back, so the caught exception would be followed by a failure at the next query of the export or the merge plan. This part was not run; SQLite does not behave that way.3. The docstring and the code disagree about the rows left behind
_erase_plugin_rowssays a failing plugin's "rows stay: deleting the contact underneath it would leave rows pointing at nothing".erase_contactthen callscontact.delete()whatevernot_erasedholds. When the eraser fails with a plain Python exception the erasure completes, the contact is deleted, and the summary reads "These still hold rows Postulo could not reach: ...", which are now rows about a person who no longer exists: the outcome the docstring says the function prevents. Reproduced in the snapshot.4. Three copies of one loop, and an undocumented half of the contract
plugins/data.py:139-141says that "one loop rather than two is what keeps the two from drifting apart (#297)", yetjobs/merging.pyandcore/gdpr.pyeach carry their own copy of the walk overGROUPS, theowned_labelstest and thegetattr. They have drifted already: one names a plugin byplugin.label, one bygetattr(plugin, "label", plugin.name), one by its model labels.erase_foritself appears nowhere butcore/gdpr.py:CONTRIBUTING.md("What a plugin that owns a model has to do", line 616) showsowns_modelsandexport_foronly, and no test defines a plugin witherase_for. A plugin author following the guide ships a plugin that is always named undernot_erased.plugins/data.pyone iterator over data-owning plugins, and besideexport_sections_fortwo helpers, for exampleholds_rows_for(subject)anderase_rows_for(subject); use them fromjobs/merging.pyandcore/gdpr.pyand delete the copies.with transaction.atomic():(a savepoint), and calllogger.exception(...)in theexcept, naming the plugin and the subject's model and primary key. Keep the fail-safe answers as they are, and log incore/export.py:495as well.# pragma: no coveratplugins/data.py:170, and documenterase_for(what it receives, that it returns the number of rows removed) besideexport_forinCONTRIBUTING.md.Done when
tests/test_gdpr.pyhas a test with a stand-in plugin that ownscore.Tagand whoseerase_forperforms a failing ORM write; it asserts thaterase_contactdoes not raise, that the plugin is named innot_erased, and that the failure is logged (caplog). It fails today withTransactionManagementError.tests/test_gdpr.pyhas a test for the outcome chosen in section 3: with an eraser that raises, either the contact is still there and the refusal names the plugin, or the contact is gone and the docstring says so.tests/test_plugin_data.pyregisters a data-owning plugin whoseexport_foranderase_forraise, and asserts withcaplogthat the archive export, the merge plan and the erasure each write an error record naming the plugin, while their answers (not_carried, held,not_erased) stay as they are. It fails today because nothing is logged.src/postuloforowned_labels(plugin)inside a loop overGROUPSfinds it inplugins/data.pyonly.