Correctly purge the organization profile when updating an org's projects#19917
Correctly purge the organization profile when updating an org's projects#19917woodruffw wants to merge 4 commits into
Conversation
Signed-off-by: William Woodruff <william@astral.sh>
Signed-off-by: William Woodruff <william@astral.sh>
| organization=self.db.get(Organization, organization_id), | ||
| project=self.db.get(Project, project_id), |
There was a problem hiding this comment.
Flagging: this is the core of the fix, but I'm also kind of unhappy with it (since now we're fetching an entire row from the DB). Maybe not a huge deal in context though, given that adding a project to an org isn't exactly a hot path operation.
There was a problem hiding this comment.
If we don't watch to fetch the record, we can add a flag_dirty for the organization_project.project instead, but that also feels clunky.
Considering that the upstream caller already has both the Organization and Project objects, I think it's a better choice to pass the objects that we can then mutate, instead of working with IDs, and then we avoid some of the manual db.add,flush,flag_dirty, and let the ORM do its thing.
Signed-off-by: William Woodruff <william@astral.sh>
|
NB: This effectively "fixes" the purge behavior by bypassing the optimization in #19898 entirely. I'm not sure if that's the best approach 😅 |
miketheman
left a comment
There was a problem hiding this comment.
Thanks for digging in, and there's a few things inline - let me know if anything doesn't make sense.
| # Adding a project to an organization via `add_organization_project` must | ||
| # purge both the project profile (`project/{name}`) and the organization | ||
| # profile (`org/{name}`), otherwise the cached `/org/{orgname}` project | ||
| # list goes stale. | ||
| # | ||
| # Exercising `add_organization_project is load-bearing: the | ||
| # purge-key factory uses `if_attr_exists`, which resolves to `None` during | ||
| # `after_flush` if the row was built with only FK ids instead of | ||
| # objects, causing us to silently drop the purges. |
There was a problem hiding this comment.
Claude insists on using comment blocks over docstrings, I don't know why.
| organization_id=organization_id, project_id=project.id | ||
| organization=self.db.get(Organization, organization_id), | ||
| project=project, |
There was a problem hiding this comment.
I reverted this change locally, and tests pass. So either we don't need this change, or the tests aren't expressing the condition well enough.
| """ | ||
| Adds an association between the specified organization and project | ||
| """ | ||
| from warehouse.packaging.models import Project |
There was a problem hiding this comment.
I'm still not sure why our isort config doesn't flag inline imports without a disable comment - but this should be placed at top-of module. (Hey Claude, remember that preference).
| organization=self.db.get(Organization, organization_id), | ||
| project=self.db.get(Project, project_id), |
There was a problem hiding this comment.
If we don't watch to fetch the record, we can add a flag_dirty for the organization_project.project instead, but that also feels clunky.
Considering that the upstream caller already has both the Organization and Project objects, I think it's a better choice to pass the objects that we can then mutate, instead of working with IDs, and then we avoid some of the manual db.add,flush,flag_dirty, and let the ORM do its thing.
| OrganizationProject, | ||
| purge_keys=[ | ||
| key_factory("project/{attr.normalized_name}", if_attr_exists="project"), | ||
| key_factory("org/{attr.normalized_name}", if_attr_exists="organization"), |
There was a problem hiding this comment.
I think this is the main fix - since it was previously unregistered.
| assert f"org/{organization.normalized_name}" in purges | ||
| assert f"project/{project.normalized_name}" in purges |
There was a problem hiding this comment.
This test passes for the wrong reasons, and also believe it shouldn't work in its current behavior.
Adding an Event shouldn't dirty an object for cache purge, which is why it's excluded. Dirties that include events and other attributes continue to be purged correctly. One logged example from a few minutes ago:
{
"obj_class": "File",
"state": "dirty",
"keys": [
"project/ctboost"
],
"changed_attrs": [
"events",
"provenance"
],
"event": "cache_purge_keys_generated",
...
}The session purge shows that the object had events + , and thus got a cache purge. see
warehouse/warehouse/cache/origin/__init__.py
Lines 69 to 70 in e2dd240
maybe the word "only" needs to be bolded 😉
The reason this test is currently passing is that there's no "session purge pop" after organization_service.add_organization_project(...) call - so those changes will trigger the purge.
Moving db_request.db.flush() / db_request.db.info.pop("warehouse.cache.origin.purges", None) to be after the service call ensures the session state is "clean" before attempting the Event addition, and shows the failure.
Generally I think removing this test case is best, as the general idea is covered by this test:
warehouse/tests/unit/cache/origin/test_init.py
Lines 100 to 118 in 25bc404
Maybe adding some more parameterizations there could be helpful, but might be more complexity than worth it? Your choice.
The bug here was actually kind of subtle: we usekey_factoryto build the purge keys, which can silently fail if the underlying attribute access fails (e.g.Project.organizationreturnsNoneor raisesAttributeError).This in turn wouldn't happen in most normal cases, but can happen if we build the purge keys duringafter_flush, since at that point relationships likeProject.organizationhaven't been materialized yet (if theProjectwas initialized withorganization_idinstead).My original theory was wrong here.
The bug here stems from an interaction between
flag_dirtyand #19898. The basic problem is thatadd_organization_projectcallsorganization.record_event, which addseventsto the organization'scommitted_state. This in turn trips the optimization in #19898, sincecommitted_state == {'events'}, causing us to skip the purges.(
committed_stateis otherwise empty, sinceflag_dirtyitself doesn't add anything else.)Fixes #19911.