fix: EventSession.commit() wasted and N+1 reloads

This commit is contained in:
2026-07-26 16:17:59 +02:00
committed by d3vyce
parent 169bf710f0
commit 651326d54b
2 changed files with 84 additions and 76 deletions
+45 -36
View File
@@ -5,7 +5,7 @@ from collections.abc import Callable
from enum import Enum from enum import Enum
from typing import Any from typing import Any
from sqlalchemy import event from sqlalchemy import event, select, tuple_
from sqlalchemy import inspect as sa_inspect from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
@@ -29,14 +29,11 @@ _SESSION_DELETES = "_ft_deletes"
_SESSION_UPDATES = "_ft_updates" _SESSION_UPDATES = "_ft_updates"
_DEFERRED_STRATEGY_KEY = (("deferred", True), ("instrument", True)) _DEFERRED_STRATEGY_KEY = (("deferred", True), ("instrument", True))
_EVENT_HANDLERS: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {} _EVENT_HANDLERS: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
_WATCHED_MODELS: set[type] = set()
_WATCHED_CACHE: dict[type, bool] = {}
_HANDLER_CACHE: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {} _HANDLER_CACHE: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
def _invalidate_caches() -> None: def _invalidate_caches() -> None:
"""Clear lookup caches after handler registration.""" """Clear lookup caches after handler registration."""
_WATCHED_CACHE.clear()
_HANDLER_CACHE.clear() _HANDLER_CACHE.clear()
@@ -56,24 +53,12 @@ def listens_for(
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
for ev in evs: for ev in evs:
_EVENT_HANDLERS.setdefault((model_class, ev), []).append(fn) _EVENT_HANDLERS.setdefault((model_class, ev), []).append(fn)
_WATCHED_MODELS.add(model_class)
_invalidate_caches() _invalidate_caches()
return fn return fn
return decorator return decorator
def _is_watched(obj: Any) -> bool:
"""Return True if *obj*'s type (or any ancestor) has registered handlers."""
cls = type(obj)
try:
return _WATCHED_CACHE[cls]
except KeyError:
result = any(klass in _WATCHED_MODELS for klass in cls.__mro__)
_WATCHED_CACHE[cls] = result
return result
def _get_handlers(cls: type, ev: ModelEvent) -> list[Callable[..., Any]]: def _get_handlers(cls: type, ev: ModelEvent) -> list[Callable[..., Any]]:
"""Return registered handlers for *cls* and *ev*, walking the MRO.""" """Return registered handlers for *cls* and *ev*, walking the MRO."""
key = (cls, ev) key = (cls, ev)
@@ -144,18 +129,18 @@ def _upsert_changes(
def _after_flush(session: Any, flush_context: Any) -> None: def _after_flush(session: Any, flush_context: Any) -> None:
# New objects: capture reference. Attributes will be refreshed after commit. # New objects: capture reference. Attributes will be refreshed after commit.
for obj in session.new: for obj in session.new:
if _is_watched(obj): if _get_handlers(type(obj), ModelEvent.CREATE):
session.info.setdefault(_SESSION_CREATES, []).append(obj) session.info.setdefault(_SESSION_CREATES, []).append(obj)
# Deleted objects: snapshot now while attributes are still loaded. # Deleted objects: snapshot now while attributes are still loaded.
for obj in session.deleted: for obj in session.deleted:
if _is_watched(obj): if _get_handlers(type(obj), ModelEvent.DELETE):
snapshot = _snapshot_column_attrs(obj) snapshot = _snapshot_column_attrs(obj)
session.info.setdefault(_SESSION_DELETES, []).append((obj, snapshot)) session.info.setdefault(_SESSION_DELETES, []).append((obj, snapshot))
# Dirty objects: read old/new from SQLAlchemy attribute history. # Dirty objects: read old/new from SQLAlchemy attribute history.
for obj in session.dirty: for obj in session.dirty:
if not _is_watched(obj): if not _get_handlers(type(obj), ModelEvent.UPDATE):
continue continue
watched = _get_watched_fields(type(obj)) watched = _get_watched_fields(type(obj))
@@ -204,9 +189,18 @@ async def _invoke_callback(
await result await result
async def _reload_if_present(session: AsyncSession, obj: Any, state: Any) -> None: async def _batch_reload(
"""Re-populate *obj* from the DB if its row still exists.""" session: AsyncSession, model: type, pk_tuples: list[tuple[Any, ...]]
await session.get(type(obj), state.key[1], populate_existing=True) ) -> None:
"""Re-populate all rows of *model* identified by *pk_tuples* in one round trip."""
pk_cols = sa_inspect(model).primary_key
where = (
pk_cols[0].in_([pk[0] for pk in pk_tuples])
if len(pk_cols) == 1
else tuple_(*pk_cols).in_(pk_tuples)
)
q = select(model).where(where).execution_options(populate_existing=True)
await session.execute(q)
class EventSession(AsyncSession): class EventSession(AsyncSession):
@@ -250,15 +244,36 @@ class EventSession(AsyncSession):
k: v for k, v in field_changes.items() if k not in create_ids k: v for k, v in field_changes.items() if k not in create_ids
} }
# Dispatch CREATE callbacks. # Resolve reloadable state up front and group PKs by model type so
# the post-commit reload is one query per type instead of one
# session.get() per object.
create_items: list[Any] = []
update_items: list[tuple[Any, dict[str, dict[str, Any]]]] = []
pk_by_type: dict[type, list[tuple[Any, ...]]] = {}
for obj in creates: for obj in creates:
state = sa_inspect(obj, raiseerr=False)
if state is None or state.detached or state.transient: # pragma: no cover
continue
create_items.append(obj)
pk_by_type.setdefault(type(obj), []).append(state.key[1])
for obj, changes in field_changes.values():
state = sa_inspect(obj, raiseerr=False)
if state is None or state.detached or state.transient: # pragma: no cover
continue
update_items.append((obj, changes))
pk_by_type.setdefault(type(obj), []).append(state.key[1])
for model, pk_tuples in pk_by_type.items():
try:
await _batch_reload(self, model, pk_tuples)
except Exception as exc:
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
# Dispatch CREATE callbacks.
for obj in create_items:
try: try:
state = sa_inspect(obj, raiseerr=False)
if (
state is None or state.detached or state.transient
): # pragma: no cover
continue
await _reload_if_present(self, obj, state)
for handler in _get_handlers(type(obj), ModelEvent.CREATE): for handler in _get_handlers(type(obj), ModelEvent.CREATE):
await _invoke_callback(handler, obj, ModelEvent.CREATE, None) await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
except Exception as exc: except Exception as exc:
@@ -275,14 +290,8 @@ class EventSession(AsyncSession):
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc) _logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
# Dispatch UPDATE callbacks. # Dispatch UPDATE callbacks.
for obj, changes in field_changes.values(): for obj, changes in update_items:
try: try:
state = sa_inspect(obj, raiseerr=False)
if (
state is None or state.detached or state.transient
): # pragma: no cover
continue
await _reload_if_present(self, obj, state)
for handler in _get_handlers(type(obj), ModelEvent.UPDATE): for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes) await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
except Exception as exc: except Exception as exc:
+39 -40
View File
@@ -25,13 +25,11 @@ from fastapi_toolsets.models.watched import (
_SESSION_CREATES, _SESSION_CREATES,
_SESSION_DELETES, _SESSION_DELETES,
_SESSION_UPDATES, _SESSION_UPDATES,
_WATCHED_MODELS,
EventSession, EventSession,
_after_flush, _after_flush,
_after_rollback, _after_rollback,
_get_watched_fields, _get_watched_fields,
_invalidate_caches, _invalidate_caches,
_is_watched,
_snapshot_column_attrs, _snapshot_column_attrs,
_upsert_changes, _upsert_changes,
) )
@@ -658,22 +656,6 @@ class TestWatchInheritance:
assert "other" in _watch_inherit_events[0]["changes"] assert "other" in _watch_inherit_events[0]["changes"]
class TestIsWatched:
def test_watched_model_is_watched(self):
"""_is_watched returns True for models with registered handlers."""
obj = WatchedModel(status="x", other="y")
assert _is_watched(obj) is True
def test_non_watched_model_is_not_watched(self):
"""_is_watched returns False for models without registered handlers."""
assert _is_watched(object()) is False
def test_subclass_of_watched_model_is_watched(self):
"""_is_watched returns True for subclasses of watched models (via MRO)."""
dog = PolyDog(name="Rex")
assert _is_watched(dog) is True
class TestUpsertChanges: class TestUpsertChanges:
def test_inserts_new_entry(self): def test_inserts_new_entry(self):
"""New key is inserted with the full changes dict.""" """New key is inserted with the full changes dict."""
@@ -715,7 +697,10 @@ class TestAfterFlush:
"""New watched objects are added to _SESSION_CREATES.""" """New watched objects are added to _SESSION_CREATES."""
obj = object() obj = object()
session = SimpleNamespace(new=[obj], deleted=[], dirty=[], info={}) session = SimpleNamespace(new=[obj], deleted=[], dirty=[], info={})
with patch("fastapi_toolsets.models.watched._is_watched", return_value=True): with patch(
"fastapi_toolsets.models.watched._get_handlers",
return_value=[lambda *a: None],
):
_after_flush(session, None) _after_flush(session, None)
assert session.info[_SESSION_CREATES] == [obj] assert session.info[_SESSION_CREATES] == [obj]
@@ -731,7 +716,10 @@ class TestAfterFlush:
obj = object() obj = object()
session = SimpleNamespace(new=[], deleted=[obj], dirty=[], info={}) session = SimpleNamespace(new=[], deleted=[obj], dirty=[], info={})
with ( with (
patch("fastapi_toolsets.models.watched._is_watched", return_value=True), patch(
"fastapi_toolsets.models.watched._get_handlers",
return_value=[lambda *a: None],
),
patch( patch(
"fastapi_toolsets.models.watched._snapshot_column_attrs", "fastapi_toolsets.models.watched._snapshot_column_attrs",
return_value={"id": 1}, return_value={"id": 1},
@@ -1023,28 +1011,19 @@ class TestEventCallbacks:
await other.commit() await other.commit()
await engine.dispose() await engine.dispose()
real_get = mixin_session.get real_batch_reload = _watched_module._batch_reload
real_refresh = mixin_session.refresh
def _matches_doomed(pk): async def racing_batch_reload(session, model, pk_tuples):
return pk == doomed_id or (isinstance(pk, tuple) and pk[0] == doomed_id) if any(pk[0] == doomed_id for pk in pk_tuples):
async def racing_get(model, pk, *args, **kwargs):
if _matches_doomed(pk):
await kill_doomed_row_once() await kill_doomed_row_once()
return await real_get(model, pk, *args, **kwargs) return await real_batch_reload(session, model, pk_tuples)
async def racing_refresh(obj, *args, **kwargs): # Patch the batched reload EventSession.commit() uses to pick up
if getattr(obj, "id", None) == doomed_id: # server defaults, so this test still exercises the race.
await kill_doomed_row_once() with (
return await real_refresh(obj, *args, **kwargs) patch.object(_watched_module, "_batch_reload", racing_batch_reload),
patch.object(_watched_module._logger, "error") as mock_error,
# Patch both possible reload mechanisms (session.get / session.refresh) ):
# so this test still exercises the race regardless of which one
# EventSession.commit() uses internally to pick up server defaults.
mixin_session.get = racing_get
mixin_session.refresh = racing_refresh
with patch.object(_watched_module._logger, "error") as mock_error:
await mixin_session.commit() await mixin_session.commit()
mock_error.assert_not_called() mock_error.assert_not_called()
@@ -1052,6 +1031,27 @@ class TestEventCallbacks:
created_ids = {e["obj_id"] for e in _test_events if e["event"] == "create"} created_ids = {e["obj_id"] for e in _test_events if e["event"] == "create"}
assert created_ids == {keep.id, doomed_id} assert created_ids == {keep.id, doomed_id}
@pytest.mark.anyio
async def test_batch_reload_exception_is_logged_and_dispatch_continues(
self, mixin_session
):
"""A batched-reload failure is logged; CREATE handlers still fire."""
obj = WatchedModel(status="active", other="x")
mixin_session.add(obj)
async def failing_batch_reload(session, model, pk_tuples):
raise RuntimeError("reload failed")
with (
patch.object(_watched_module, "_batch_reload", failing_batch_reload),
patch.object(_watched_module._logger, "error") as mock_error,
):
await mixin_session.commit()
mock_error.assert_called_once()
creates = [e for e in _test_events if e["event"] == "create"]
assert len(creates) == 1
class TestTransientObject: class TestTransientObject:
"""Create + delete within the same transaction should fire no events.""" """Create + delete within the same transaction should fire no events."""
@@ -1421,7 +1421,6 @@ class TestListensFor:
for key in list(_EVENT_HANDLERS): for key in list(_EVENT_HANDLERS):
if key[0] is ListenerModel: if key[0] is ListenerModel:
del _EVENT_HANDLERS[key] del _EVENT_HANDLERS[key]
_WATCHED_MODELS.discard(ListenerModel)
_invalidate_caches() _invalidate_caches()
@pytest.mark.anyio @pytest.mark.anyio