Skip to content

Commit 9ca8861

Browse files
authored
Merge pull request #62 from kraken-tech/expand-on-problems-with-atomic
Expand on problems with `atomic` in the "why" doc
2 parents a5121fe + 35337c5 commit 9ca8861

File tree

1 file changed

+133
-25
lines changed

1 file changed

+133
-25
lines changed

docs/why.md

Lines changed: 133 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,146 @@
1-
## Django's Atomic
2-
Django's `atomic` ensures database changes are committed together-or-not-at-all. It creates a savepoint or a transaction depending on two factors:
1+
# Django's Atomic
32

4-
- The arguments passed to it (`durable` and `savepoint`).
3+
This doc will discuss the behaviours available through Django's `atomic`
4+
and the outcomes people are usually trying to achieve with it.
5+
It goes on to outline some pitfalls that can result from using `atomic`
6+
and how Subatomic avoids them.
7+
8+
Django's `atomic` ensures database changes are committed together-or-not-at-all.
9+
It creates a savepoint or a transaction depending on two factors:
10+
11+
- The arguments passed to it (`durable=` and `savepoint=`).
512
- If a database transaction is already open.
613

7-
Specifically, the **Behaviours** which `atomic` exhibits are:
14+
## Behaviours
15+
16+
The *Behaviours* which `atomic` exhibits are:
817

9-
| | `durable=False` (default) | `durable=True` |
10-
| --- | --- | --- |
11-
| `savepoint=True` (default) | **A**. Begin a transaction if needed. Creates a savepoint if already in a transaction. | **B**. Begin a transaction, or throw an error if one is already open. Never creates a savepoint. (The `savepoint` flag is ignored.) |
12-
| `savepoint=False` | **C**. Begin a transaction if needed. Never creates a savepoint. | **D**. Same as **B**. |
18+
| `savepoint=` | `durable=False` (default) | `durable=True` |
19+
| --- | --- | --- |
20+
| **`True` (default)** | **A**. Begin a transaction if needed. Creates a savepoint if already in a transaction. | **B**. Begin a transaction, or throw an error if one is already open. Never creates a savepoint. (The `savepoint=` flag is ignored.) |
21+
| **`False`** | **C**. Begin a transaction if needed. Never creates a savepoint. | Same as **B**. |
1322

14-
Uses of `atomic` fall into three broad **Categories**:
23+
## Outcomes
1524

16-
1. Create a *transaction* to wrap multiple changes.
17-
2. Create a *savepoint* so we can roll back to in order to continue with a transaction after failure.
18-
3. Changes to be committed *atomically*, but not specific about where the transaction is created, as long as there is one.
25+
When people use `atomic`,
26+
they're generally trying to achieve one of three *Outcomes*:
27+
28+
1. to create a *transaction*
29+
which will commit multiple changes atomically.
30+
2. to create a *savepoint*
31+
so we can roll back to in order to continue with a transaction after failure.
32+
3. to indicate that changes should be committed atomically,
33+
without needing to be specific about the scope of the transaction,
34+
as long as there is one.
1935

2036
## Problems
2137

22-
Django's atomic creates many savepoints that are never used. There are a couple of main causes:
38+
### Ambiguous code
39+
40+
Ideally, we should be able to look at a line of code and say what it will do.
41+
42+
Because `atomic`'s behaviour depends on whether a transaction is already open,
43+
one must know the full call stack
44+
to know what any particular `atomic` will do.
45+
If it is called in multiple code paths,
46+
developers must know that it will do different database operations
47+
depending on who calls it.
48+
49+
Subatomic avoids this issue
50+
by offering an unambiguous API (`transaction()`, `savepoint()`, etc).
51+
52+
### Transactions without context
53+
54+
Low-level code rarely has the context to know when a transaction should be committed.
55+
For example, it may know that its changes must happen atomically,
56+
but cannot know if it is part of a larger suite of changes
57+
managed by higher-level code
58+
which must also be committed together.
59+
60+
When low-level code uses `atomic`
61+
to indicate that its changes should be atomic (*Outcome* **3**),
62+
this can have one of two effects:
63+
64+
- If the higher-level code has opened a transaction,
65+
the lower-level code will create a savepoint it does not need.
66+
67+
- If the higher-level code has not opened a transaction,
68+
the lower-level code will.
69+
While this will achieve the atomicity _it_ demands,
70+
it fails to ensure that the larger suite of changes
71+
is also atomic.
72+
73+
Django offers no APIs to indicate
74+
the creation of a savepoint (*Outcome* **2**)
75+
or the need for atomicity (*Outcome* **3**)
76+
that doesn't have the potential to create a transaction instead.
77+
78+
A function decorated with Subatomic's `@transaction_required`
79+
will raise an error when called outside of a transaction,
80+
rather than run the risk of creating a transaction with the wrong scope.
81+
82+
### Savepoints by default
83+
84+
`atomic` defaults to *Behaviour* **A**
85+
which creates savepoints by default
86+
when there is already an open transaction.
87+
88+
It's common to decorate functions with `atomic`
89+
to indicate that code should be atomic (*Outcome* **3**),
90+
but neglect to pass `savepoint=False`.
91+
This results in more database queries than necessary.
92+
93+
Subatomic's `@transaction_required` decorator
94+
gives developers an unambiguous alternative
95+
that will never open a savepoint.
96+
97+
### Savepoints as decorators
98+
99+
Savepoints are intrinsically linked to error handling.
100+
They are only required when we need
101+
a safe place to continue from after a failure within a transaction.
102+
Ideally then, the logic for catching the failure and continuing a transaction
103+
should be adjacent to the logic which creates the savepoint.
104+
105+
When we use `atomic` as a decorator,
106+
we separate the savepoint creation from the error handling logic.
107+
The decorated function will not be within a `try:...except...:`.
108+
109+
This lack of cohesion
110+
can make it difficult to know
111+
where continuing after rolling back a savepoint is intended to be handled,
112+
or even if it is handled at all.
113+
This is compounded by the fact that
114+
because `atomic`'s API is ambiguous,
115+
it can be hard to know the intended *Outcome*.
116+
117+
To encourage putting rollback logic alongside savepoint creation,
118+
Subatomic's `savepoint` cannot be used as a decorator.
119+
120+
### Tests without after-commit callbacks
23121

24-
1. Savepoints are created with decorators (`@atomic`).
25-
2. `atomic` creates savepoints by default. The default arguments (*Behaviour* **A**) are an [attractive nuisance](https://blog.ganssle.io/articles/2023/01/attractive-nuisances.html) because they make us create savepoints when we don't need them.
122+
To avoid leaking state between tests,
123+
Django's `TestCase` runs each test within a transaction
124+
which gets rolled back at the end of the test.
125+
As a result,
126+
`atomic` blocks encountered during the test
127+
will not create transactions
128+
so no after-commit callbacks will be run.
26129

27-
> … if you have two ways to accomplish a task and one is a simple way that *looks* like the right thing but is subtly wrong, and the other is correct but
28-
> more complicated, the majority of people will end up doing the wrong
29-
> thing.
30-
> [**Attractive nuisances in software design**](https://blog.ganssle.io/articles/2023/01/attractive-nuisances.html) - [Paul Ganssle](https://blog.ganssle.io/author/paul-ganssle.html)
130+
Even if Django wanted to simulate after-commit callbacks in tests,
131+
it has no way to know which *Outcome* was intended
132+
when it encounters an `atomic` block.
133+
It might be running a high-level test where a transaction is intended
134+
and callbacks should be run,
135+
or a low-level test where an open transaction is assumed
136+
and callbacks should _not_ be run.
31137

32-
3. We have no easy way to indicate the creation of a savepoint that doesn't have the potential to create a transaction instead. The only tool we have to create a savepoint is *Behaviour* **A**, which can create a transaction.
138+
Without Subatomic,
139+
developers must either manually run after-commit callbacks in tests,
140+
which is prone to error and omission,
141+
or run the test using `TransactionTestCase`,
142+
which can be very slow.
33143

34-
## What Subatomic implements
35-
- `transaction()`. Begin a transaction, or throw an error if a transaction is already open. Like `atomic(durable=True)`, but with added after-commit callback support in tests.
36-
- `savepoint()`. Create a savepoint, or throw an error if we're not already in a transaction. This is not in the table of *Behaviours* (the closest we have is *Behaviour* **A**, but that can create transactions).
37-
- `transaction_if_not_already()`. Begin a transaction if we're not already in one. Just like *Behaviour* **C**. This has a bit of a clunky name. This is deliberate, and reflects that it's a bit of a clunky thing to do. To be used with caution because the creation of a transaction is implicit. For a stricter alternative, see `transaction_required()` below.
38-
- `transaction_required()`. Throw an error if we're not already in a transaction. Does not create savepoints *or* transactions.
144+
Subatomic's `transaction()` function
145+
will run after-commit callbacks automatically in tests
146+
so that code behaves the same in tests as it does in production.

0 commit comments

Comments
 (0)