carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "database/sql"
6 "database/sql/driver"
7 "errors"
8 "github.com/carlosframework/platform/internal/hostops"
9 "runtime"
10 "strings"
11 "sync/atomic"
12 "testing"
13 "time"
14)
15
16// plan9SealRetainedStore is a retained *Store from a batch callback writing
17// after the callback returned.
18func plan9SealRetainedStore(v *Store, out chan<- error) {
19 _, err := v.db.Exec(`UPDATE audit SET action='late-store' WHERE account_id=7 AND app='blog'`)
20 out <- err
21}
22
23// plan9SealQueuedDriver is an operation that already passed the view's public
24// check and is queued on the held driver connection.
25func plan9SealQueuedDriver(d *claimBoundDB, out chan<- error) {
26 _, err := d.conn.ExecContext(context.Background(), `UPDATE audit SET action='late-driver' WHERE account_id=7 AND app='blog'`)
27 out <- err
28}
29
30// plan9SealQueuedStmtExec and plan9SealQueuedStmtQuery run an escaped prepared
31// statement: one that was never registered with the view, as if the statement
32// had been prepared before the callback returned.
33func plan9SealQueuedStmtExec(st *sql.Stmt, out chan<- error) {
34 _, err := st.ExecContext(context.Background())
35 out <- err
36}
37
38func plan9SealQueuedStmtQuery(st *sql.Stmt, out chan<- error) {
39 rows, err := st.QueryContext(context.Background())
40 if err == nil {
41 for rows.Next() {
42 }
43 err = rows.Err()
44 rows.Close()
45 }
46 out <- err
47}
48
49// waitPlan9SealQueued returns once the goroutine running fn has finished
50// (done has a value) or is parked on the database/sql driver-connection lock,
51// which the batch commit holds.
52func waitPlan9SealQueued(t *testing.T, fn string, done chan error) {
53 t.Helper()
54 deadline := time.Now().Add(10 * time.Second)
55 buf := make([]byte, 1<<20)
56 for time.Now().Before(deadline) {
57 if len(done) > 0 {
58 return
59 }
60 n := runtime.Stack(buf, true)
61 for _, g := range strings.Split(string(buf[:n]), "\n\n") {
62 onDriver := strings.Contains(g, "database/sql.withLock(") || strings.Contains(g, "database/sql.resultFromStatement(") || strings.Contains(g, "database/sql.rowsiFromStatement(")
63 if strings.Contains(g, "console."+fn+"(") && onDriver && strings.Contains(g, "sync.(*Mutex).lockSlow(") {
64 return
65 }
66 }
67 runtime.Gosched()
68 }
69 t.Fatalf("%s neither finished nor queued on the driver connection", fn)
70}
71
72// TestPlan9SQLBatchSealsRetainedWriteQueuedAcrossCommit checks the view fast
73// path and queues driver and prepared-statement writes across COMMIT. None
74// may run under the transaction permit once its commit begins.
75func TestPlan9SQLBatchSealsRetainedWriteQueuedAcrossCommit(t *testing.T) {
76 s := openGatedStore(t)
77 seedGateApp(t, s)
78 r := receiptFixture(t, s)
79 var view *Store
80 var returned atomic.Bool
81 store, driver := make(chan error, 1), make(chan error, 1)
82 stmtExec, stmtQuery := make(chan error, 1), make(chan error, 1)
83 var escapedExec, escapedQuery *sql.Stmt
84 local := func(ctx context.Context, _ claimSQLScope) error {
85 if !returned.CompareAndSwap(true, false) {
86 return ctx.Err()
87 }
88 // The commit authorization: endBatch holds the driver connection.
89 go plan9SealRetainedStore(view, store)
90 waitPlan9SealQueued(t, "plan9SealRetainedStore", store)
91 go plan9SealQueuedDriver(view.db.(*claimBoundDB), driver)
92 waitPlan9SealQueued(t, "plan9SealQueuedDriver", driver)
93 go plan9SealQueuedStmtExec(escapedExec, stmtExec)
94 waitPlan9SealQueued(t, "plan9SealQueuedStmtExec", stmtExec)
95 go plan9SealQueuedStmtQuery(escapedQuery, stmtQuery)
96 waitPlan9SealQueued(t, "plan9SealQueuedStmtQuery", stmtQuery)
97 return nil
98 }
99 err := s.withPreparedClaimSQL(gateCtx(t), []claimSQLScope{gateScope}, allowAllClaimSQL, local, []hostops.SQLReceiptScope{r}, true, func(v *Store) error {
100 view = v
101 // Prepared on the raw sql.Conn, bypassing the view's registration, so
102 // these statements are past it and only the driver gate can stop them.
103 d := v.db.(*claimBoundDB)
104 var perr error
105 if escapedExec, perr = d.conn.PrepareContext(context.Background(), `UPDATE audit SET action='late-stmt-exec' WHERE account_id=7 AND app='blog'`); perr != nil {
106 return perr
107 }
108 if escapedQuery, perr = d.conn.PrepareContext(context.Background(), `UPDATE audit SET action='late-stmt-query' WHERE account_id=7 AND app='blog' RETURNING id`); perr != nil {
109 return perr
110 }
111 _, err := v.db.Exec(`UPDATE audit SET action='batch' WHERE account_id=7 AND app='blog'`)
112 returned.Store(true)
113 return err
114 })
115 if err != nil {
116 t.Fatal(err)
117 }
118 if escapedExec != nil {
119 defer escapedExec.Close()
120 }
121 if escapedQuery != nil {
122 defer escapedQuery.Close()
123 }
124 if returned.Load() {
125 t.Fatal("commit authorization never ran the local check")
126 }
127 for name, ch := range map[string]chan error{
128 "retained store (view fast path)": store,
129 "queued driver": driver,
130 "escaped stmt Exec": stmtExec,
131 "escaped stmt QueryContext": stmtQuery,
132 } {
133 select {
134 case werr := <-ch:
135 if !errors.Is(werr, ErrClaimSQLRevoked) {
136 t.Errorf("%s write across COMMIT: err=%v, want ErrClaimSQLRevoked", name, werr)
137 }
138 case <-time.After(10 * time.Second):
139 t.Fatalf("%s write never finished", name)
140 }
141 }
142 if n := countWhere(t, s, "audit", "action LIKE 'late-%'"); n != 0 {
143 t.Errorf("%d retained writes landed outside the receipted transaction", n)
144 }
145 if countWhere(t, s, "audit", "action='batch'") != 1 {
146 t.Error("batch write did not commit")
147 }
148 var state string
149 if err := s.rawBoot.QueryRow(`SELECT state FROM claim_sql_receipts WHERE effect=?`, r.Effect).Scan(&state); err != nil || state != "committed" {
150 t.Fatalf("receipt=%q err=%v", state, err)
151 }
152}
153
154// TestPlan9SQLEndBatchLeftoverTxRefusesCommit drives the driver directly: a
155// transaction that registered after the handles were joined forces the
156// requested commit to roll back, and endBatch must say so instead of
157// reporting success with nothing committed.
158func TestPlan9SQLEndBatchLeftoverTxRefusesCommit(t *testing.T) {
159 s := openGatedStore(t)
160 seedGateApp(t, s)
161 r := receiptFixture(t, s)
162 ctx := gateCtx(t)
163 conn, err := s.rawPool.Conn(ctx)
164 if err != nil {
165 t.Fatal(err)
166 }
167 defer conn.Close()
168 sqids := make([]string, 1)
169 if s.gate.needSqid() {
170 if err := conn.QueryRowContext(ctx, `SELECT sqid FROM accounts WHERE id = ?`, gateScope.AccountID).Scan(&sqids[0]); err != nil {
171 t.Fatal(err)
172 }
173 }
174 scopes := []claimSQLScope{gateScope}
175 b := &claimBinding{ctx: ctx, scopes: scopes, check: func(ctx context.Context, _ claimSQLScope) error { return ctx.Err() }, deadline: time.Now().Add(hostops.SQLTransactionLifetime), batch: true, receipts: []hostops.SQLReceiptScope{r}, dead: make([]bool, 1)}
176 var endErr error
177 err = conn.Raw(func(dc any) error {
178 c := dc.(*claimConn)
179 if err := c.bindScopes(ctx, b, sqids); err != nil {
180 return err
181 }
182 defer func() { _ = c.unbind(b) }()
183 if err := c.beginBatch(ctx, b); err != nil {
184 return err
185 }
186 if _, err := c.ExecContext(ctx, `UPDATE audit SET action='batch' WHERE account_id=7 AND app='blog'`, nil); err != nil {
187 return err
188 }
189 if _, err := c.BeginTx(ctx, driver.TxOptions{}); err != nil {
190 return err
191 }
192 endErr = c.endBatch(b, true)
193 return nil
194 })
195 if err != nil {
196 t.Fatal(err)
197 }
198 if err := conn.Close(); err != nil {
199 t.Fatal(err)
200 }
201 if endErr == nil {
202 t.Fatal("endBatch reported success although a leftover transaction forced the commit to roll back")
203 }
204 if !errors.Is(endErr, ErrClaimSQLScope) {
205 t.Errorf("endBatch err=%v, want ErrClaimSQLScope", endErr)
206 }
207 var n int
208 if err := s.rawBoot.QueryRow(`SELECT count(*) FROM claim_sql_receipts WHERE effect=?`, r.Effect).Scan(&n); err != nil || n != 0 {
209 t.Errorf("receipt rows=%d err=%v, want none", n, err)
210 }
211 if got := countWhere(t, s, "audit", "action='batch'"); got != 0 {
212 t.Errorf("batch write present (%d rows) after forced rollback", got)
213 }
214}
215
216// Seal while a handle has left the driver but has not entered the view's
217// registry. It must be closed by the registering goroutine, not escape the
218// closeHandles snapshot. Holding mu makes this boundary deterministic.
219func TestPlan9SQLSealDuringHandleRegistration(t *testing.T) {
220 for _, method := range []string{"PrepareContext", "BeginTx"} {
221 t.Run(method, func(t *testing.T) {
222 s := openGatedStore(t)
223 seedGateApp(t, s)
224 r := receiptFixture(t, s)
225 err := s.withPreparedClaimSQL(gateCtx(t), []claimSQLScope{gateScope}, allowAllClaimSQL, allowAllClaimSQL, []hostops.SQLReceiptScope{r}, true, func(v *Store) error {
226 d := v.db.(*claimBoundDB)
227 d.mu.Lock()
228 locked := true
229 defer func() {
230 if locked {
231 d.mu.Unlock()
232 }
233 }()
234 done := make(chan error, 1)
235 go func() {
236 if method == "PrepareContext" {
237 st, err := d.PrepareContext(context.Background(), "SELECT 1")
238 if st != nil {
239 _ = st.Close()
240 }
241 done <- err
242 } else {
243 tx, err := d.BeginTx(context.Background(), nil)
244 if tx != nil {
245 _ = tx.Rollback()
246 }
247 done <- err
248 }
249 }()
250 deadline := time.Now().Add(10 * time.Second)
251 buf := make([]byte, 1<<20)
252 waiting := false
253 for time.Now().Before(deadline) && !waiting {
254 n := runtime.Stack(buf, true)
255 for _, g := range strings.Split(string(buf[:n]), "\n\n") {
256 if strings.Contains(g, "console.(*claimBoundDB)."+method+"(") && strings.Contains(g, "sync.(*Mutex).lockSlow(") {
257 waiting = true
258 break
259 }
260 }
261 runtime.Gosched()
262 }
263 if !waiting {
264 t.Fatal("handle never reached registration")
265 }
266 d.b.sealed.Store(true)
267 d.mu.Unlock()
268 locked = false
269 select {
270 case err := <-done:
271 if !errors.Is(err, ErrClaimSQLRevoked) {
272 t.Errorf("late registration = %v, want revoked", err)
273 }
274 case <-time.After(10 * time.Second):
275 t.Fatal("late registration did not join")
276 }
277 return nil
278 })
279 if err != nil {
280 t.Fatal(err)
281 }
282 })
283 }
284}
285