Repository navigation
Expand file tree
/
Copy pathaccepted_blocking_integration_test.go
More file actions
323 lines (294 loc) · 14 KB
/
Copy pathaccepted_blocking_integration_test.go
File metadata and controls
323 lines (294 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
package executor_test
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/block/pg-sprite/pkg/executor"
)
func TestExecuteAcceptedBlockingDropsIndex(t *testing.T) {
pool, schema := newPool(t)
_, err := pool.Exec(t.Context(), fmt.Sprintf("CREATE TABLE %s.t (id int); CREATE INDEX i ON %s.t (id)", schema, schema))
require.NoError(t, err)
b := executor.BlockingBudget{LockTimeout: time.Second, StatementTimeout: 10 * time.Second}
sql := fmt.Sprintf("DROP INDEX %s.i", schema)
rep, err := executor.ExecuteAcceptedBlocking(t.Context(), pool, sql, pgx.Identifier{schema, "t"}, b)
require.NoError(t, err)
assert.Equal(t, sql, rep.SQL)
assert.Equal(t, b.LockTimeout, rep.LockTimeout)
assert.Equal(t, b.StatementTimeout, rep.StatementTimeout)
assert.Positive(t, rep.Duration)
exists, _ := indexState(t, pool, schema, "i")
assert.False(t, exists)
}
func TestExecuteAcceptedBlockingLockBudgetExecutesNothing(t *testing.T) {
pool, schema := newPool(t)
_, err := pool.Exec(t.Context(), fmt.Sprintf("CREATE TABLE %s.t (id int); CREATE INDEX i ON %s.t (id)", schema, schema))
require.NoError(t, err)
holder, err := pool.Begin(t.Context())
require.NoError(t, err)
t.Cleanup(func() { _ = holder.Rollback(t.Context()) })
_, err = holder.Exec(t.Context(), fmt.Sprintf("LOCK TABLE %s.t IN ACCESS EXCLUSIVE MODE", schema))
require.NoError(t, err)
b := executor.BlockingBudget{LockTimeout: 100 * time.Millisecond, StatementTimeout: 5 * time.Second}
start := time.Now()
_, err = executor.ExecuteAcceptedBlocking(t.Context(), pool, fmt.Sprintf("DROP INDEX %s.i", schema), pgx.Identifier{schema, "t"}, b)
var budgetErr *executor.BudgetError
require.ErrorAs(t, err, &budgetErr)
assert.Equal(t, executor.CauseLock, budgetErr.Cause)
assert.Less(t, time.Since(start), 2*time.Second)
exists, valid := indexState(t, pool, schema, "i")
assert.True(t, exists)
assert.True(t, valid)
}
// REINDEX on a partitioned relation is admitted by shape but PostgreSQL
// refuses to run it inside a transaction block, which is the only way this
// executor runs anything. The failure is reported as a permanent outcome
// outside the path, not as a retryable execution failure, and nothing is
// changed: the parent and its partitions keep their indexes.
func TestExecuteAcceptedBlockingRefusesReindexOnPartitionedRelation(t *testing.T) {
pool, schema := newPool(t)
_, err := pool.Exec(t.Context(), fmt.Sprintf(`
CREATE TABLE %[1]s.parent (id int) PARTITION BY RANGE (id);
CREATE TABLE %[1]s.leaf PARTITION OF %[1]s.parent FOR VALUES FROM (0) TO (10);
CREATE INDEX parent_i ON %[1]s.parent (id)`, schema))
require.NoError(t, err)
b := executor.BlockingBudget{LockTimeout: time.Second, StatementTimeout: 10 * time.Second}
for _, sql := range []string{
fmt.Sprintf("REINDEX TABLE %s.parent", schema),
fmt.Sprintf("REINDEX INDEX %s.parent_i", schema),
} {
t.Run(sql, func(t *testing.T) {
_, err := executor.ExecuteAcceptedBlocking(t.Context(), pool, sql, pgx.Identifier{schema, "parent"}, b)
require.ErrorIs(t, err, executor.ErrUnsupportedAcceptedBlocking)
var pgErr *pgconn.PgError
require.ErrorAs(t, err, &pgErr)
assert.Equal(t, "25001", pgErr.Code)
assert.True(t, executor.OutcomeCode(err).Permanent())
})
}
exists, valid := indexState(t, pool, schema, "parent_i")
assert.True(t, exists)
assert.True(t, valid)
}
// DROP INDEX ... CASCADE drops the objects that depend on the index. Here the
// foreign key on u references the unique index on t, so the cascade would
// lock and change u, which the lock on t does not cover. The executor
// refuses the shape before it opens a transaction, so the index and the
// foreign key both stay.
func TestExecuteAcceptedBlockingRefusesCascadeDrop(t *testing.T) {
pool, schema := newReferencedIndexFixture(t)
b := executor.BlockingBudget{LockTimeout: time.Second, StatementTimeout: 10 * time.Second}
_, err := executor.ExecuteAcceptedBlocking(t.Context(), pool,
fmt.Sprintf("DROP INDEX %s.i CASCADE", schema), pgx.Identifier{schema, "t"}, b)
require.ErrorIs(t, err, executor.ErrUnsupportedAcceptedBlocking)
assert.True(t, executor.OutcomeCode(err).Permanent())
assertReferencedIndexKept(t, pool, schema)
}
// A plain DROP INDEX is eligible for the accepted-blocking path, and it is
// safe there because PostgreSQL defaults to RESTRICT: the foreign key on u
// depends on the unique index on t, so the server refuses the drop with
// dependent_objects_still_exist and changes nothing on either table.
func TestExecuteAcceptedBlockingPlainDropOfReferencedIndexIsRefusedByServer(t *testing.T) {
pool, schema := newReferencedIndexFixture(t)
b := executor.BlockingBudget{LockTimeout: time.Second, StatementTimeout: 10 * time.Second}
_, err := executor.ExecuteAcceptedBlocking(t.Context(), pool,
fmt.Sprintf("DROP INDEX %s.i", schema), pgx.Identifier{schema, "t"}, b)
assertDependentObjectsRefusal(t, err)
assertReferencedIndexKept(t, pool, schema)
}
// An explicit RESTRICT is the same eligible drop spelled out: the server
// refuses it while the foreign key on u depends on the index.
func TestExecuteAcceptedBlockingRestrictDropOfReferencedIndexIsRefusedByServer(t *testing.T) {
pool, schema := newReferencedIndexFixture(t)
b := executor.BlockingBudget{LockTimeout: time.Second, StatementTimeout: 10 * time.Second}
_, err := executor.ExecuteAcceptedBlocking(t.Context(), pool,
fmt.Sprintf("DROP INDEX %s.i RESTRICT", schema), pgx.Identifier{schema, "t"}, b)
assertDependentObjectsRefusal(t, err)
assertReferencedIndexKept(t, pool, schema)
}
// newReferencedIndexFixture creates a unique index on t that the foreign key
// on u depends on.
func newReferencedIndexFixture(t *testing.T) (*pgxpool.Pool, string) {
t.Helper()
pool, schema := newPool(t)
_, err := pool.Exec(t.Context(), fmt.Sprintf(`
CREATE TABLE %[1]s.t (id int NOT NULL);
CREATE UNIQUE INDEX i ON %[1]s.t (id);
CREATE TABLE %[1]s.u (
t_id int REFERENCES %[1]s.t (id)
)`, schema))
require.NoError(t, err)
return pool, schema
}
func assertDependentObjectsRefusal(t *testing.T, err error) {
t.Helper()
var pgErr *pgconn.PgError
require.ErrorAs(t, err, &pgErr)
assert.Equal(t, "2BP01", pgErr.Code, "dependent_objects_still_exist: the drop was not CASCADE")
}
func assertReferencedIndexKept(t *testing.T, pool *pgxpool.Pool, schema string) {
t.Helper()
exists, valid := indexState(t, pool, schema, "i")
assert.True(t, exists)
assert.True(t, valid)
var foreignKeys int
require.NoError(t, pool.QueryRow(t.Context(),
`SELECT count(*) FROM pg_constraint WHERE contype = 'f' AND conrelid = to_regclass($1)`,
schema+".u").Scan(&foreignKeys))
assert.Equal(t, 1, foreignKeys, "the foreign key on u must still exist")
}
func TestExecuteAcceptedBlockingAppliesBothBoundsOnExecutingSession(t *testing.T) {
pool, schema := newPool(t)
_, err := pool.Exec(t.Context(), fmt.Sprintf(`
CREATE TABLE %s.t (id int);
INSERT INTO %s.t VALUES (1);
CREATE FUNCTION %s.observe_bounds(int) RETURNS int LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
IF current_setting('lock_timeout') = '137ms' AND current_setting('statement_timeout') = '2468ms' THEN
RAISE EXCEPTION USING ERRCODE = 'P0001';
END IF;
RAISE EXCEPTION USING ERRCODE = 'P0002';
END $$`, schema, schema, schema))
require.NoError(t, err)
b := executor.BlockingBudget{LockTimeout: 137 * time.Millisecond, StatementTimeout: 2468 * time.Millisecond}
_, err = executor.ExecuteAcceptedBlocking(t.Context(), pool,
fmt.Sprintf("CREATE INDEX observed_i ON %s.t (%s.observe_bounds(id))", schema, schema), pgx.Identifier{schema, "t"}, b)
var pgErr *pgconn.PgError
require.ErrorAs(t, err, &pgErr)
assert.Equal(t, "P0001", pgErr.Code, "the executing transaction must observe both exact bounds")
var executionErr *executor.BlockingExecutionError
assert.True(t, errors.As(err, &executionErr))
}
// A statement that acquires its own locks takes them one at a time, and
// statement_timeout counts all of those waits together while lock_timeout
// bounds each on its own. REINDEX INDEX locks the table, then the index:
// with a writer holding the table for a while and a reader holding the
// index for longer, the table wait is granted, the index wait runs past
// the statement bound before its own lock bound, and the cancellation
// would read as work that ran. The executor instead takes the table's
// ACCESS EXCLUSIVE lock as its own statement first, so the whole wait is
// one lock-budget wait: the outcome is a lock refusal, and the index
// stands.
func TestExecuteAcceptedBlockingLocksTheTableBeforeTheStatement(t *testing.T) {
pool, schema := newPool(t)
_, err := pool.Exec(t.Context(), fmt.Sprintf(`
CREATE TABLE %[1]s.orders (id int PRIMARY KEY);
CREATE INDEX orders_id_idx ON %[1]s.orders (id)`, schema))
require.NoError(t, err)
// The reader's open transaction keeps ACCESS SHARE on the table and on
// every index planning opened, which conflicts with the ACCESS
// EXCLUSIVE the reindex needs on the index but not with the SHARE it
// needs on the table.
reader, err := pool.Begin(t.Context())
require.NoError(t, err)
t.Cleanup(func() { _ = reader.Rollback(context.WithoutCancel(t.Context())) })
var n int
require.NoError(t, reader.QueryRow(t.Context(), fmt.Sprintf("SELECT count(*) FROM %s.orders WHERE id = 1", schema)).Scan(&n))
// The writer's ROW EXCLUSIVE conflicts with the SHARE the reindex needs
// on the table, so the table wait lasts until the writer commits.
writer, err := pool.Begin(t.Context())
require.NoError(t, err)
_, err = writer.Exec(t.Context(), fmt.Sprintf("INSERT INTO %s.orders VALUES (1)", schema))
require.NoError(t, err)
const writerHolds = time.Second
// The timer's goroutine owns the writer until it has committed; the
// cleanup waits for that before its safety rollback so the two never
// touch the transaction at once.
committed := make(chan struct{})
release := time.AfterFunc(writerHolds, func() {
defer close(committed)
_ = writer.Commit(context.WithoutCancel(t.Context()))
})
t.Cleanup(func() {
if release.Stop() {
close(committed)
}
<-committed
_ = writer.Rollback(context.WithoutCancel(t.Context()))
})
// Each wait on its own fits the lock bound; the two in sequence do not
// fit the statement bound.
b := executor.BlockingBudget{LockTimeout: writerHolds + 500*time.Millisecond, StatementTimeout: writerHolds + 600*time.Millisecond}
start := time.Now()
_, err = executor.ExecuteAcceptedBlocking(t.Context(), pool,
fmt.Sprintf("REINDEX INDEX %s.orders_id_idx", schema), pgx.Identifier{schema, "orders"}, b)
elapsed := time.Since(start)
var budgetErr *executor.BudgetError
require.ErrorAs(t, err, &budgetErr)
assert.Equal(t, executor.CauseLock, budgetErr.Cause, "the whole wait is the table lock's; nothing ran")
assert.Equal(t, b.LockTimeout, budgetErr.Budget)
var pgErr *pgconn.PgError
require.ErrorAs(t, err, &pgErr)
assert.Equal(t, "55P03", pgErr.Code)
assert.GreaterOrEqual(t, elapsed, b.LockTimeout, "the lock budget, not the writer's release, ended the wait")
require.NoError(t, reader.Rollback(t.Context()))
exists, valid := indexState(t, pool, schema, "orders_id_idx")
assert.True(t, exists)
assert.True(t, valid)
}
// The caller's context ending while the table lock is still being waited
// for is not an unknown outcome: the statement was never submitted, so
// the cancellation is reported as the caller's own and nothing changes.
func TestExecuteAcceptedBlockingReportsACancelledLockWaitAsNothingSubmitted(t *testing.T) {
pool, schema := newPool(t)
_, err := pool.Exec(t.Context(), fmt.Sprintf("CREATE TABLE %s.t (id int); CREATE INDEX i ON %s.t (id)", schema, schema))
require.NoError(t, err)
holder, err := pool.Begin(t.Context())
require.NoError(t, err)
t.Cleanup(func() { _ = holder.Rollback(context.WithoutCancel(t.Context())) })
_, err = holder.Exec(t.Context(), fmt.Sprintf("LOCK TABLE %s.t IN ACCESS EXCLUSIVE MODE", schema))
require.NoError(t, err)
b := executor.BlockingBudget{LockTimeout: 30 * time.Second, StatementTimeout: time.Minute}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
done := make(chan error, 1)
go func() {
_, err := executor.ExecuteAcceptedBlocking(ctx, pool, fmt.Sprintf("DROP INDEX %s.i", schema), pgx.Identifier{schema, "t"}, b)
done <- err
}()
require.Eventually(t, func() bool {
var waiting bool
err := pool.QueryRow(t.Context(),
`SELECT EXISTS (SELECT 1 FROM pg_stat_activity
WHERE query LIKE 'LOCK TABLE %' AND wait_event_type = 'Lock')`).Scan(&waiting)
return err == nil && waiting
}, 30*time.Second, 25*time.Millisecond, "the table lock request must be waiting on the holder")
cancel()
select {
case err := <-done:
require.ErrorIs(t, err, executor.ErrCancelledByCaller)
var unknownErr *executor.BlockingOutcomeUnknownError
assert.False(t, errors.As(err, &unknownErr), "nothing was submitted, so the outcome is known")
assert.Equal(t, executor.CodeCancelledByCaller, executor.OutcomeCode(err))
case <-time.After(30 * time.Second):
t.Fatal("the cancelled lock wait must return")
}
require.NoError(t, holder.Rollback(t.Context()))
exists, valid := indexState(t, pool, schema, "i")
assert.True(t, exists)
assert.True(t, valid)
}
// LOCK TABLE cannot name a materialized view, so REINDEX TABLE on one runs
// without the table lock taken first; the statement acquires its own locks
// and still commits under the budgets.
func TestExecuteAcceptedBlockingReindexesAMaterializedViewWithoutAPreLock(t *testing.T) {
pool, schema := newPool(t)
_, err := pool.Exec(t.Context(), fmt.Sprintf(`
CREATE TABLE %[1]s.orders (id int PRIMARY KEY);
CREATE MATERIALIZED VIEW %[1]s.order_ids AS SELECT id FROM %[1]s.orders;
CREATE UNIQUE INDEX order_ids_id_idx ON %[1]s.order_ids (id)`, schema))
require.NoError(t, err)
b := executor.BlockingBudget{LockTimeout: time.Second, StatementTimeout: 10 * time.Second}
_, err = executor.ExecuteAcceptedBlocking(t.Context(), pool, fmt.Sprintf("REINDEX TABLE %s.order_ids", schema), nil, b)
require.NoError(t, err)
exists, valid := indexState(t, pool, schema, "order_ids_id_idx")
assert.True(t, exists)
assert.True(t, valid)
}