Opened 6 weeks ago
Closed 5 weeks ago
#36551 closed Bug (invalid)
Race condition in savepoint ID generation causes duplicate identifiers
Reported by: | Mijo Kristo | Owned by: | |
---|---|---|---|
Component: | Database layer (models, ORM) | Version: | |
Severity: | Normal | Keywords: | threading savepoint race condition thread safety |
Cc: | Triage Stage: | Unreviewed | |
Has patch: | no | Needs documentation: | no |
Needs tests: | no | Patch needs improvement: | no |
Easy pickings: | no | UI/UX: | no |
Description (last modified by )
Problem
The BaseDatabaseWrapper.savepoint() method contains a race condition where the operation self.savepoint_state += 1 is not atomic. This can lead to duplicate savepoint IDs in multithreaded environments.
Location
django/db/backends/base/base.py, line ~608
Problematic Code
thread_ident = _thread.get_ident() tid = str(thread_ident).replace("-", "") self.savepoint_state += 1 # Race condition here sid = "s%s_x%d" % (tid, self.savepoint_state)
Impact
- Multiple threads can read the same savepoint_state value
- Results in duplicate savepoint IDs (e.g., "s123_x5", "s123_x5")
- Causes database errors when rolling back to savepoints
- Can lead to data corruption in high-concurrency applications
- Affects applications using nested transaction.atomic() blocks
Reproduction
The race condition can be reproduced with this simple test:
import threading, time counter = 0 def buggy_increment(): global counter temp = counter time.sleep(0.001) counter = temp + 1 print(f'Thread got: {counter}') threads = [threading.Thread(target=buggy_increment) for _ in range(5)] for t in threads: t.start() for t in threads: t.join() print(f'Final: {counter} (should be 5)')
Expected output: Final: 5
Actual output: Final: 1 (lost updates due to race condition)
Real-world scenarios
This bug can manifest in production applications as:
- "savepoint does not exist" database errors
- Transaction rollback failures
- Silent data corruption in e-commerce/banking applications
- Inventory overselling in high-traffic scenarios
Proposed Fix
Wrap the increment operation in the existing _thread_sharing_lock:
thread_ident = _thread.get_ident() tid = str(thread_ident).replace("-", "") with self._thread_sharing_lock: self.savepoint_state += 1 sid = "s%s_x%d" % (tid, self.savepoint_state)
This solution:
- Uses Django's existing thread safety infrastructure
- Has minimal performance impact
- Maintains backward compatibility
- Fixes the race condition completely
Testing
The fix can be verified by running the reproduction case above - with the fix, all threads will get unique values.
Django's existing test suite should continue to pass as this change only affects the thread safety of savepoint generation without changing the API.
Additional Notes
- This is an ideal "easy pickings" issue for new contributors
- The bug is timing-dependent and may not reproduce consistently
- High-concurrency production environments are most affected
- The fix leverages existing Django patterns for thread safety
Change History (2)
comment:1 by , 5 weeks ago
Description: | modified (diff) |
---|---|
Easy pickings: | unset |
comment:2 by , 5 weeks ago
Resolution: | → invalid |
---|---|
Status: | new → closed |
I've looked into this some more, and my conclusion is that, in practice, the race condition described here can't happen under normal Django usage. Each database connection is tied to a single thread, and the call to self.validate_thread_sharing()
enforces this. All savepoint()
calls happen inside transaction.atomic()
blocks on the same thread, so savepoint_state
is safely incremented sequentially. Therefore, for documented ORM usage, duplicate savepoint IDs should not occur.
If you are seeing this issue in a real django project, please share a minimal reproducer or a django test case for us to evaluate further. Until then, I'll close this issue as invalid
following the ticket triaging process.
Hello Mijo Kristo, thank you for your ticket report. Can you please confirm:
This will help us understand and prioritize the issue better.