oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package slides
2
3import (
4 "context"
5 "errors"
6 "testing"
7)
8
9// TestSettingsRowAndDocumentChangeTogether is the regression test for a
10// real bug: saveDeckSettings used to write the document via editDeck
11// and then the decks row via a second, separate Updates call — two
12// transactions, so a failure between them left the row's title/theme
13// diverged from what the document's own meta said.
14//
15// The fix folds the row write into applyOps' one transaction via
16// editDeck's rowUpdates: it lands in the same Updates call the seq
17// bump already makes, under the same `WHERE id = ? AND seq = ?` lock,
18// so it commits or rolls back with the update-log insert. This proves
19// the "or rolls back" half by making the row write fail (an unknown
20// column — the particular reason doesn't matter, only that it is a
21// failure applyOps' transaction sees) and checking that BOTH the row
22// and the document come out unchanged, seq included.
23func TestSettingsRowAndDocumentChangeTogether(t *testing.T) {
24 d := testDB(t)
25 a, _, err := newApp(d, Config{Env: EnvDevelopment, Logger: quietLogger()})
26 if err != nil {
27 t.Fatalf("newApp: %v", err)
28 }
29 t.Cleanup(func() { a.Close() })
30 ctx := context.Background()
31
32 owner, _, err := ensureUser(ctx, d.G, "paul@example.test")
33 if err != nil {
34 t.Fatalf("ensureUser: %v", err)
35 }
36
37 row, err := a.createDeckRow(ctx, owner, "Original title", defaultTheme)
38 if err != nil {
39 t.Fatalf("createDeckRow: %v", err)
40 }
41 ops, err := openingOps("Original title", defaultTheme)
42 if err != nil {
43 t.Fatalf("openingOps: %v", err)
44 }
45 if err := a.editDeck(ctx, &row, actorFor(owner), ops, nil); err != nil {
46 t.Fatalf("seed editDeck: %v", err)
47 }
48 seqBefore := row.Seq
49
50 renameOps, err := opsJSON([]op{{"op": "set_meta", "title": "New title", "theme": defaultTheme}})
51 if err != nil {
52 t.Fatalf("opsJSON: %v", err)
53 }
54 // No column named this, so the Updates call inside applyOps' own
55 // transaction fails — the row write, not h.Ops, is what breaks.
56 badRowUpdates := map[string]any{"no_such_column": "boom"}
57 if err := a.editDeck(ctx, &row, actorFor(owner), renameOps, badRowUpdates); err == nil {
58 t.Fatal("editDeck with an invalid row update returned no error")
59 }
60
61 var reloaded Deck
62 if err := d.G.First(&reloaded, row.ID).Error; err != nil {
63 t.Fatalf("reload deck row: %v", err)
64 }
65 if reloaded.Title != "Original title" {
66 t.Errorf("deck row title = %q after a failed settings write, want %q (unchanged)", reloaded.Title, "Original title")
67 }
68 if reloaded.Seq != seqBefore {
69 t.Errorf("deck row seq = %d after a failed settings write, want %d (unchanged)", reloaded.Seq, seqBefore)
70 }
71
72 h, view, err := a.openDeck(ctx, reloaded)
73 if err != nil {
74 t.Fatalf("openDeck: %v", err)
75 }
76 defer h.Close(ctx)
77 if view.Meta.Title != "Original title" {
78 t.Errorf("document meta title = %q after a failed settings write, want %q (unchanged)", view.Meta.Title, "Original title")
79 }
80}
81
82// TestConcurrentWritersConflictThenRetry is the regression test for a
83// 500 where docs/api.md promises a 409.
84//
85// applyOps' optimistic lock reads as the `WHERE id = ? AND seq = ?`
86// bump, and that clause is genuinely a lock — but it is not what a
87// losing writer hits FIRST. The update row goes in before the bump,
88// under the (deck_id, seq) unique index, and rastrillo's db.Open sets
89// GORM's TranslateError, so the loser came out of the INSERT holding a
90// wrapped gorm.ErrDuplicatedKey and never reached the clause that
91// would have said errDeckBusy. editDeck's one retry and the API's 409
92// both hang off errDeckBusy, so in practice neither existed.
93//
94// Two handles opened on one deck at one seq is that race exactly, with
95// no goroutine and no sleep: the second apply must report the deck
96// moved, and editDeck from the stale row must land on its retry.
97func TestConcurrentWritersConflictThenRetry(t *testing.T) {
98 d := testDB(t)
99 a, _, err := newApp(d, Config{Env: EnvDevelopment, Logger: quietLogger()})
100 if err != nil {
101 t.Fatalf("newApp: %v", err)
102 }
103 t.Cleanup(func() { a.Close() })
104 ctx := context.Background()
105
106 owner, _, err := ensureUser(ctx, d.G, "paul@example.test")
107 if err != nil {
108 t.Fatalf("ensureUser: %v", err)
109 }
110 row, err := a.createDeckRow(ctx, owner, "Two writers", defaultTheme)
111 if err != nil {
112 t.Fatalf("createDeckRow: %v", err)
113 }
114 opening, err := openingOps("Two writers", defaultTheme)
115 if err != nil {
116 t.Fatalf("openingOps: %v", err)
117 }
118 if err := a.editDeck(ctx, &row, actorFor(owner), opening, nil); err != nil {
119 t.Fatalf("seed editDeck: %v", err)
120 }
121
122 // Two copies of the row at one seq: two requests that each read the
123 // deck before either of them wrote to it.
124 first, second := row, row
125
126 h1, _, err := a.openDeck(ctx, first)
127 if err != nil {
128 t.Fatalf("openDeck (first): %v", err)
129 }
130 defer h1.Close(ctx)
131 h2, _, err := a.openDeck(ctx, second)
132 if err != nil {
133 t.Fatalf("openDeck (second): %v", err)
134 }
135 defer h2.Close(ctx)
136
137 firstOps, err := opsJSON([]op{{"op": "set_meta", "title": "First writer"}})
138 if err != nil {
139 t.Fatalf("opsJSON: %v", err)
140 }
141 secondOps, err := opsJSON([]op{{"op": "set_meta", "title": "Second writer"}})
142 if err != nil {
143 t.Fatalf("opsJSON: %v", err)
144 }
145
146 if _, err := a.applyOps(ctx, h1, &first, actorFor(owner), firstOps, nil); err != nil {
147 t.Fatalf("the first writer failed: %v", err)
148 }
149 _, err = a.applyOps(ctx, h2, &second, actorFor(owner), secondOps, nil)
150 if !errors.Is(err, errDeckBusy) {
151 t.Fatalf("the second writer got %v, want errDeckBusy", err)
152 }
153
154 // The losing request's own next move: editDeck reopens, replays the
155 // batch against the state that beat it, and lands.
156 stale := second
157 if err := a.editDeck(ctx, &stale, actorFor(owner), secondOps, nil); err != nil {
158 t.Fatalf("editDeck did not recover from the conflict: %v", err)
159 }
160
161 var reloaded Deck
162 if err := d.G.First(&reloaded, row.ID).Error; err != nil {
163 t.Fatalf("reload deck row: %v", err)
164 }
165 if reloaded.Seq != 3 {
166 t.Errorf("deck row seq = %d after three landed batches, want 3", reloaded.Seq)
167 }
168 h, view, err := a.openDeck(ctx, reloaded)
169 if err != nil {
170 t.Fatalf("openDeck: %v", err)
171 }
172 defer h.Close(ctx)
173 if view.Meta.Title != "Second writer" {
174 t.Errorf("document meta title = %q, want %q", view.Meta.Title, "Second writer")
175 }
176}
177