Skip to content

Fix cache poisoning when a singleton write is rolled back - #158

Open
alessio-b2c2 wants to merge 1 commit into
lazybird:masterfrom
alessio-b2c2:fix-cache-poisoning-on-transaction-rollback
Open

Fix cache poisoning when a singleton write is rolled back#158
alessio-b2c2 wants to merge 1 commit into
lazybird:masterfrom
alessio-b2c2:fix-cache-poisoning-on-transaction-rollback

Conversation

@alessio-b2c2

@alessio-b2c2 alessio-b2c2 commented May 27, 2026

Copy link
Copy Markdown

The bug

SingletonModel keeps the configured cache (SOLO_CACHE) in sync with the DB by updating it synchronously around every DB operation. If an operation runs inside an outer transaction.atomic() that later rolls back, the database reverts but the cache keeps the value it was given. The cache then disagrees with the DB until SOLO_CACHE_TIMEOUT expires (5 minutes by default) or the next successful write — so get_solo() serves stale (or non-existent) data in the meantime.

There are three poisoning paths in solo/models.py:

  1. save() called self.set_to_cache() immediately after super().save(). A rollback reverts the row but the cache keeps the rolled-back value. (Model.objects.create() routes through save(), so it's covered too.)
  2. delete() called self.clear_cache() before super().delete(). A rollback keeps the row, but the cache was already emptied — so the cache is stale/empty for a row that still exists.
  3. get_solo() on a cache miss did get_or_create(pk=...) then obj.set_to_cache(). This poisons the cache in two sub-cases: the row was created inside the transaction (and then rolled away), or the row existed but had an uncommitted modification earlier in the same transaction that get_solo read back and cached before the rollback.

The fix

Defer every cache update to transaction.on_commit(...):

  • save()transaction.on_commit(self.set_to_cache)
  • delete()transaction.on_commit(self.clear_cache)
  • get_solo() (cache miss) → transaction.on_commit(obj.set_to_cache)

on_commit callbacks fire when the surrounding transaction commits and are discarded on rollback, so the cache only ever reflects what the DB actually committed. In autocommit mode (no surrounding transaction) the callback fires immediately, so non-transactional behaviour is unchanged.

For get_solo() this is both simpler and more correct than capturing get_or_create's created flag and skipping the cache for new rows: deferring to commit closes the create-on-miss rollback case and the read-of-uncommitted-modification case, with no flag to track. The only behavioural change is that within a still-open transaction the cache isn't populated until commit (repeated reads hit the DB until then); the common autocommit path is unaffected.

Tests

Added a TransactionRollbackCacheTest (solo/tests/tests.py) with a regression test per path:

  • save() inside an atomic() that rolls back → cache must not hold the rolled-back value.
  • delete() inside an atomic() that rolls back → cache must still hold the (not-actually-deleted) value.
  • get_solo() create-on-miss inside an atomic() that rolls back → cache must not hold an object for the row that no longer exists.
  • get_solo() re-caching an uncommitted modification inside an atomic() that rolls back → cache must not hold the rolled-back value.

The tests:

  • use TransactionTestCase (not TestCase) — on_commit callbacks don't fire inside the wrapping transaction a plain TestCase uses, which would make the fixes look broken;
  • run with SOLO_CACHE enabled — the fixes are no-ops when caching is off, since set_to_cache/clear_cache early-return.

I verified each new test fails against the unfixed models.py and passes with the fix. The existing test_delete_if_cache_enabled was updated to wrap save()/delete() in captureOnCommitCallbacks(execute=True), since its cache assertions now depend on the deferred on_commit callbacks firing.

Full suite (manage.py test solo), ruff format/ruff check, and mypy all pass.

🤖 Generated with Claude Code

SingletonModel updated the cache synchronously around DB operations. When
one ran inside an outer transaction.atomic() that later rolled back, the DB
reverted but the cache kept the stale value, disagreeing with the DB until
SOLO_CACHE_TIMEOUT or the next write. Three paths were affected:

* save() called set_to_cache() right after super().save().
* delete() called clear_cache() before super().delete().
* get_solo() on a cache miss cached the row returned by get_or_create() --
  poisoning the cache whether the row was created in the transaction (and then
  rolled away) or was an uncommitted modification read back from the DB.

Defer all three cache updates to transaction.on_commit(), which fires
immediately in autocommit mode and is discarded on rollback. This keeps the
cache in sync with what the DB actually commits, and is simpler than tracking
get_or_create()'s created flag while also closing the read-of-uncommitted-row
window that flag-based approach would leave open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alessio-b2c2
alessio-b2c2 force-pushed the fix-cache-poisoning-on-transaction-rollback branch from 9dbe975 to 20f7711 Compare May 27, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant