Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/fastapi_toolsets/models/watched.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ async def _invoke_callback(
await result


async def _reload_if_present(session: AsyncSession, obj: Any, state: Any) -> None:
"""Re-populate *obj* from the DB if its row still exists."""
await session.get(type(obj), state.key[1], populate_existing=True)


class EventSession(AsyncSession):
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""

Expand Down Expand Up @@ -253,7 +258,7 @@ async def commit(self) -> None: # noqa: C901
state is None or state.detached or state.transient
): # pragma: no cover
continue
await self.refresh(obj)
await _reload_if_present(self, obj, state)
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
except Exception as exc:
Expand All @@ -277,7 +282,7 @@ async def commit(self) -> None: # noqa: C901
state is None or state.detached or state.transient
): # pragma: no cover
continue
await self.refresh(obj)
await _reload_if_present(self, obj, state)
for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
except Exception as exc:
Expand Down
53 changes: 52 additions & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@
listens_for,
)
from fastapi_toolsets.models.watched import (
EventSession,
_EVENT_HANDLERS,
_SESSION_CREATES,
_SESSION_DELETES,
_SESSION_UPDATES,
_WATCHED_MODELS,
EventSession,
_after_flush,
_after_rollback,
_get_watched_fields,
Expand Down Expand Up @@ -1001,6 +1001,57 @@ async def test_non_watched_model_no_callback(self, mixin_session):

assert _test_events == []

@pytest.mark.anyio
async def test_create_survives_row_deleted_before_reload(self, mixin_session):
"""A row deleted by another transaction right after commit still fires CREATE."""
keep = WatchedModel(status="active", other="x")
doomed = WatchedModel(status="active", other="x")
mixin_session.add_all([keep, doomed])
await mixin_session.flush()
doomed_id = doomed.id

raced = {"done": False}

async def kill_doomed_row_once():
if raced["done"]:
return
raced["done"] = True
engine = create_async_engine(DATABASE_URL, echo=False)
async with async_sessionmaker(engine)() as other:
row = await other.get(WatchedModel, doomed_id)
await other.delete(row)
await other.commit()
await engine.dispose()

real_get = mixin_session.get
real_refresh = mixin_session.refresh

def _matches_doomed(pk):
return pk == doomed_id or (isinstance(pk, tuple) and pk[0] == doomed_id)

async def racing_get(model, pk, *args, **kwargs):
if _matches_doomed(pk):
await kill_doomed_row_once()
return await real_get(model, pk, *args, **kwargs)

async def racing_refresh(obj, *args, **kwargs):
if getattr(obj, "id", None) == doomed_id:
await kill_doomed_row_once()
return await real_refresh(obj, *args, **kwargs)

# 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()
mock_error.assert_not_called()

assert raced["done"]
created_ids = {e["obj_id"] for e in _test_events if e["event"] == "create"}
assert created_ids == {keep.id, doomed_id}


class TestTransientObject:
"""Create + delete within the same transaction should fire no events."""
Expand Down