|
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 |
3 | 2 |
|
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=`). |
5 | 12 | - If a database transaction is already open. |
6 | 13 |
|
7 | | -Specifically, the **Behaviours** which `atomic` exhibits are: |
| 14 | +## Behaviours |
| 15 | + |
| 16 | +The *Behaviours* which `atomic` exhibits are: |
8 | 17 |
|
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**. | |
13 | 22 |
|
14 | | -Uses of `atomic` fall into three broad **Categories**: |
| 23 | +## Outcomes |
15 | 24 |
|
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. |
19 | 35 |
|
20 | 36 | ## Problems |
21 | 37 |
|
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 |
23 | 121 |
|
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. |
26 | 129 |
|
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. |
31 | 137 |
|
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. |
33 | 143 |
|
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