Conversation
f7451ba to
3316f5d
Compare
3316f5d to
31f0f81
Compare
4cb8cc2 to
fd5a397
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Bulk session.execute(update(...)) calls can leave in-session ORM instances stale unless session synchronization (or explicit expire/refresh) is applied, risking incorrect behavior for callers that use those instances after mutation.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR extends the ORM-event-driven cache invalidation approach to also cover bulk DML (UPDATE/DELETE) statements by intercepting SQLAlchemy’s do_orm_execute event and attaching cache metadata to session.execute(...) statements.
Changes:
- Add a
do_orm_executelistener to queue cache invalidations for bulk ORMUPDATE/DELETEstatements using execution options metadata. - Introduce
cache_invalidating_dml(...)to tag DML statements with the entity IDs required by the invalidation registry. - Migrate several legacy
Query.update()/delete()call sites tosession.execute(update/delete(...))and add/extend tests for rollback/commit invalidation behavior.
File summaries
| File | Description |
|---|---|
| tests/app/cache/test_cache_events.py | Adds test coverage for DML-driven invalidation and rollback behavior. |
| app/dao/users_dao.py | Migrates user update paths to bulk update(User) wrapped with cache invalidation metadata. |
| app/dao/templates_dao.py | Migrates template bulk updates to update(Template) wrapped with cache invalidation metadata. |
| app/dao/service_permissions_dao.py | Converts permission removal to delete(ServicePermission) with DML invalidation tagging. |
| app/dao/permissions_dao.py | Converts permission deletions to delete(Permission) with DML invalidation tagging and reuse helper. |
| app/cache/cache_events.py | Adds DML interception via do_orm_execute and centralizes invalidation queueing helpers. |
| app/cache/cache_dml.py | Adds helper for tagging statements with invalidation entity IDs via execution options. |
Review details
Suppressed comments (1)
app/dao/users_dao.py:51
- When
update_dictis provided,save_model_usernow issues a bulk UPDATE viasession.execute(update(...)). Withoutsynchronize_session(or expiring/refreshingusr), callers may observe stale values on the passed-inusrinstance within the same session compared to the previous attribute-assignment implementation.
_remove_values_for_keys_if_present(updates, ["id", "password_changed_at"])
statement = update(User).where(User.id == usr.id).values(**updates)
db.session.execute(cache_invalidating_dml(statement, id=usr.id))
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| statement = update(User).where(User.id == usr.id).values(**updates) | ||
| db.session.execute(cache_invalidating_dml(statement, id=usr.id)) |
There was a problem hiding this comment.
@copilot what do you mean that the users could remain stale? That these wouldn't be updated on the update? or after the update? what if we don't expect these user objects to change after and we are okay with these remaining stale?
There was a problem hiding this comment.
Stale means the bulk UPDATE changes the row in Postgres, but any User objects already loaded in the same SQLAlchemy session are not rewritten in memory automatically. If nothing reads those objects again, leaving them stale can be acceptable; the risk is that callers later in the same request/session can still observe old values. In this branch I made the two user bulk-update paths use synchronize_session="fetch" so the DAO is safe for callers that reuse the User after the update (2480c81).
There was a problem hiding this comment.
Stale means the bulk UPDATE changes the row in Postgres, but any User objects already loaded in the same SQLAlchemy session are not rewritten in memory automatically. If nothing reads those objects again, leaving them stale can be acceptable; the risk is that callers later in the same request/session can still observe old values. In this branch I made the two user bulk-update paths use synchronize_session="fetch" so the DAO is safe for callers that reuse the User after the update (2480c81).
There was a problem hiding this comment.
Yea this is the case here and is specific to the code that follows this save operation. By default [SQLAlchemy will choose synchronize_session='auto'](https://docs.sqlalchemy.org/en/21/orm/queryguide/dml.html#selecting-a-synchronization-strategy) which could resolve to either evaluate(doesn't refresh the model in session) orfetch` (does refresh).
Needing to manually specify this is extra cognitive load but I think it's a good thing to explicitly document and guarantee object synchronization when it's needed. We already explicitly specify this in our code:
814ff93 to
5afccb1
Compare
jimleroyer
left a comment
There was a problem hiding this comment.
Very nice additions. This likely needs some more logic to hook in the actual listener registration with _intercept_bulk_operations and more unit-tests.
| from app.caching import invalidate_group_keys | ||
|
|
||
| _CACHE_INVALIDATIONS_KEY = "cache_invalidations_to_run" | ||
| _CACHE_INVALIDATION_ENTITY_IDS_OPTION = "cache_invalidation_entity_ids" |
There was a problem hiding this comment.
This is a string constant named _CACHE_INVALIDATION_ENTITY_IDS_OPTION defined on that line, as well as an import with the same name at line 13, but a tuple1(string). It's the end of the day so I might be confused, but yes I am confused atm. 😅
| # a flask context, so use a module level logger instead. | ||
| logger = logging.getLogger(__name__) | ||
|
|
||
| def _as_entity_id_collection(value): |
There was a problem hiding this comment.
Let's add type annotations on parameters and return types.
There was a problem hiding this comment.
Added a bunch of type annotations. Since value here can be quite a few things, I defined a custom type EntityIdValue for this. Let me know what you think.
|
|
||
| entity_ids = orm_context.execution_options.get(_CACHE_INVALIDATION_ENTITY_IDS_OPTION) | ||
| if entity_ids: | ||
| _queue_model_invalidations(orm_context.session, mapper.class_, entity_ids) |
There was a problem hiding this comment.
Ah there we actually refer to the entity via entity_ids (and not model). 😛
| session.info.pop(_CACHE_INVALIDATIONS_KEY, None) | ||
|
|
||
|
|
||
| def _intercept_bulk_operations(orm_context): |
There was a problem hiding this comment.
We didn't hook this function to any listener yet, right?
There was a problem hiding this comment.
This is hooked up to do_orm_execute in register_cache_orm_events
| for service_permission in service_permissions: | ||
| db.session.delete(service_permission) | ||
| ) | ||
| result = db.session.execute(cache_invalidating_dml(statement, service_id=service_id)) |
| statement = update(User).where(User.id == usr.id).values(**updates) | ||
| db.session.execute(cache_invalidating_dml(statement, id=usr.id)) |
There was a problem hiding this comment.
@copilot what do you mean that the users could remain stale? That these wouldn't be updated on the update? or after the update? what if we don't expect these user objects to change after and we are okay with these remaining stale?
eb28699 to
acdbc96
Compare
8fbd1ad to
23dd746
Compare
Many dao methods for updating / deleting leverage legacy sqlalchemy DML statements `Query.update()` and `Query.delete()`. These DML queries bypass the typical session ORM events like `after_commit` and `after_flush`. This prevents us from relying solely on ORM events for cache invalidation. Modernized DML syntax (`session.execute(<someQuery>)`) can be intercepted via `do_orm_execute` allowing us to follow the same event driven invalidation patterns for both session ORM changes and changes resulting from DML statements - Add `do_orm_execute` event listener - Add `cache_invalidating_dml` wrapper to execute DML statements and attach cache metadata to the `session.info` - Add `_queue_cache_invalidation` to store deduplicated cache invalidation actions to be executed post-commit - Migrate / modernize DML statements from legacy `Query.update()/delete()` to `session.execute(<query))`
Fixed CodeQL complaint
Co-authored-by: jimleroyer <805567+jimleroyer@users.noreply.github.com>
- Explicitly specify `synchronize_session` in users_dao - Added many type annotations to cache_events so it's clear what mechanisms are involved, and how things work in general - Rename `_queue_model_invalidations` -> `_queue_entity_invalidations` - Add comment explaining required_attributes and the validation around that in `_queue_entity_invalidations` - formatting and test fixes
23dd746 to
fa94c55
Compare
Summary | Résumé
Experimentally branched off of: #3033
Many dao methods for updating / deleting leverage legacy sqlalchemy DML statements
Query.update()andQuery.delete(). These DML queries bypass the typical session ORM events likeafter_commitandafter_flush. This prevents us from relying solely on ORM events for cache invalidation.However, modernized DML syntax (
session.execute(<someQuery>)) can be intercepted via thedo_orm_executeevent allowing us to follow the same event driven invalidation pattern for both session ORM changes and DML statement changes.do_orm_executeevent listenercache_invalidating_dmlwrapper to execute DML statements and attach cache metadata to thesession.info_queue_cache_invalidationto store deduplicated cache invalidation actions to be executed post-commitQuery.update()/delete()tosession.execute(<query))How does this work?
Bulk DML queries in SQLAlchemy bypass session.dirty and after_flush instance detection. Statements like the one below are both legacy syntax from v1.4 and do not trigger typical session ORM events. (
after_flush,after_commit, etc.)The helper method
cache_invalidating_dml()adds cache metadata to the SQL statement that can be later extracted. Note the updated query syntax, this facilitates listening todo_orm_executeevents and is central to enabling event driven invalidation for DML queries.Behind the scenes cache metadata is included:
{ "cache_invalidation_entity_ids": { "user_id": user.id, } }_intercept_bulk_operations()is attached to thedo_orm_executeevent. Whendb.session.execute(statement)is run, it executes and:updateordeletePermission_queue_model_invalidations()after_commitorafter_rollbackevents to fire, so that we only invalidate after successful mutation, or drop the invalidations when rolling backPermissionentity in theCACHE_INVALIDATION_REGISTRYafter_commitevent fires, signalling successful data changes and_invalidate_cache_after_commitis called to invalidate the keyAt a high level
flowchart TD A[Database mutation] --> B{Mutation style} B -->|ORM objects| C[Flush] C --> D[after_flush] D --> E[Resolve model through registry] B -->|Bulk DML| F[cache_invalidating_dml] F --> G[Session.execute] G --> H[do_orm_execute] H --> E E --> I[Store namespace and ID in Session.info] I --> J{Transaction result} J -->|Commit| K[after_commit] K --> L[Pop pending set] L --> M[Delete grouped Redis keys] J -->|Rollback| N[after_rollback] N --> O[Discard pending set]Benefits of this approach
ORM session based actions
CACHE_INVALIDATION_REGISTRYentries for the associated entity exist and clear the keys that it should.DML based statements
updateordeletestatements that affect cached values with the helper methodCACHE_INVALIDATION_REGISTRYentries for the associated entity exist and clear the keys that it should.CACHE_INVALIDATION_REGISTRYis technically global, it's read-only at runtime and will never be mutated.invalidate_group_keyscall maintains it's own local scan cursor, therefore parallel scans should not corrupt one another.Drawbacks
This would add some latency to API responses when executing an update or delete. The silver lining here is that all subsequent gets on that entity would hit cache cache instead of the DB and thus should offset added latency. You trade one slightly longer API call for many subsequent faster API calls.
Test instructions | Instructions pour tester la modification
TODO: Fill in test instructions for the reviewer.
Release Instructions | Instructions pour le déploiement
None.
Reviewer checklist | Liste de vérification du réviseur