pg_catalog_lock_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. package executor
  2. import (
  3. "errors"
  4. "fmt"
  5. "runtime"
  6. "testing"
  7. "time"
  8. "github.com/danfragoso/pizzasql-next/pkg/pgerror"
  9. "github.com/danfragoso/pizzasql-next/pkg/storage"
  10. )
  11. // A catalog-backed predicate must be resolved before a statement takes the
  12. // session lock. Evaluating it inside the locked scan re-enters the session lock
  13. // (which is not re-entrant), so the statement hangs forever and every other
  14. // session blocks on the table the stuck statement never releases.
  15. //
  16. // These regressions therefore run every statement under a bounded timeout and
  17. // probe a second session afterwards.
  18. const catalogLockTimeout = 10 * time.Second
  19. type catalogLockOutcome struct {
  20. result *Result
  21. err error
  22. }
  23. // runCatalogLockedStatement executes one statement and fails the test when it
  24. // does not finish within catalogLockTimeout.
  25. func runCatalogLockedStatement(t *testing.T, e *Executor, sql string) (*Result, error) {
  26. t.Helper()
  27. done := make(chan catalogLockOutcome, 1)
  28. go func() {
  29. result, err := execSQL(e, sql)
  30. done <- catalogLockOutcome{result: result, err: err}
  31. }()
  32. select {
  33. case out := <-done:
  34. return out.result, out.err
  35. case <-time.After(catalogLockTimeout):
  36. buf := make([]byte, 1<<20)
  37. n := runtime.Stack(buf, true)
  38. t.Fatalf("%s did not finish within %s (deadlock)\n%s", sql, catalogLockTimeout, buf[:n])
  39. return nil, nil
  40. }
  41. }
  42. // probeSessionLiveness proves a second connection can still read and write: a
  43. // statement that deadlocks while holding the session lock leaks the table gate,
  44. // so a later session's SELECT or INSERT blocks forever.
  45. func probeSessionLiveness(t *testing.T, schema *storage.SchemaManager, table *storage.TableManager, tableName string) {
  46. t.Helper()
  47. observer := newExec(schema, table)
  48. if _, err := runCatalogLockedStatement(t, observer, "SELECT count(*) FROM "+tableName); err != nil {
  49. t.Fatalf("observer SELECT on %s failed: %v", tableName, err)
  50. }
  51. if _, err := runCatalogLockedStatement(t, observer, "INSERT INTO probe (id) VALUES (1)"); err != nil {
  52. t.Fatalf("observer INSERT failed: %v", err)
  53. }
  54. }
  55. // setupCatalogLockFixture builds the target table plus an unrelated table the
  56. // observer session writes to.
  57. func setupCatalogLockFixture(t *testing.T) (*Executor, *storage.SchemaManager, *storage.TableManager) {
  58. t.Helper()
  59. _, schema, table := newTestDB(t)
  60. e := newExec(schema, table)
  61. execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
  62. execMust(t, e, "INSERT INTO t VALUES (1, 'a'), (2, 'b')")
  63. execMust(t, e, "CREATE TABLE other (id INTEGER PRIMARY KEY)")
  64. execMust(t, e, "INSERT INTO other VALUES (1)")
  65. execMust(t, e, "CREATE TABLE probe (id INTEGER PRIMARY KEY)")
  66. return e, schema, table
  67. }
  68. func TestPGCatalogPredicateInLockedScanDoesNotDeadlock(t *testing.T) {
  69. cases := []struct {
  70. name string
  71. sql string
  72. wantError bool
  73. wantRows int
  74. wantValue string
  75. }{
  76. {
  77. name: "update unknown regclass",
  78. sql: `UPDATE t SET v = 'z' WHERE v = 'a' AND 'missing_relation'::regclass IS NOT NULL`,
  79. wantError: true,
  80. },
  81. {
  82. name: "update known regclass",
  83. sql: `UPDATE t SET v = 'z' WHERE v = 'a' AND 't'::regclass IS NOT NULL`,
  84. wantRows: 1,
  85. },
  86. {
  87. name: "delete unknown regclass",
  88. sql: `DELETE FROM t WHERE v = 'a' AND 'missing_relation'::regclass IS NOT NULL`,
  89. wantError: true,
  90. },
  91. {
  92. name: "update catalog function predicate",
  93. sql: `UPDATE t SET v = 'z' WHERE v = 'a' AND format_type(23, -1) = 'integer'`,
  94. wantRows: 1,
  95. },
  96. {
  97. name: "update in catalog subquery",
  98. sql: `UPDATE t SET v = 'z' WHERE id IN (SELECT oid FROM pg_class)`,
  99. wantRows: 0,
  100. },
  101. {
  102. name: "update in constant subquery",
  103. sql: `UPDATE t SET v = 'z' WHERE id IN (SELECT 1)`,
  104. wantRows: 1,
  105. },
  106. {
  107. name: "update in base-table subquery",
  108. sql: `UPDATE t SET v = 'z' WHERE id IN (SELECT id FROM other)`,
  109. wantRows: 1,
  110. },
  111. {
  112. name: "update returning regclass",
  113. sql: `UPDATE t SET v = 'z' WHERE id = 1 RETURNING id, 't'::regclass`,
  114. wantRows: 1,
  115. },
  116. {
  117. name: "update set catalog function",
  118. sql: `UPDATE t SET v = 'fmt:' || format_type(23, -1) WHERE id = 1`,
  119. wantValue: "fmt:integer",
  120. },
  121. {
  122. name: "update correlated catalog subquery",
  123. sql: `UPDATE t SET v = 'z' WHERE EXISTS (SELECT 1 FROM pg_class c WHERE c.relname = t.v)`,
  124. wantRows: 0,
  125. },
  126. {
  127. name: "update catalog relation predicate",
  128. sql: `UPDATE t SET v = 'z' WHERE id IN (SELECT c.oid FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid)`,
  129. wantRows: 0,
  130. },
  131. }
  132. for _, tc := range cases {
  133. t.Run(tc.name, func(t *testing.T) {
  134. e, schema, table := setupCatalogLockFixture(t)
  135. _, err := runCatalogLockedStatement(t, e, tc.sql)
  136. if tc.wantError {
  137. if !errors.Is(err, pgerror.ErrUndefinedTable) {
  138. t.Fatalf("%s: err = %v, want an undefined_table error", tc.sql, err)
  139. }
  140. } else if err != nil {
  141. t.Fatalf("%s: %v", tc.sql, err)
  142. } else if tc.wantRows > 0 {
  143. res, countErr := runCatalogLockedStatement(t, e, `SELECT count(*) FROM t WHERE v = 'z'`)
  144. if countErr != nil {
  145. t.Fatal(countErr)
  146. }
  147. if res.Rows[0][0] != int64(tc.wantRows) {
  148. t.Fatalf("%s: updated rows = %v, want %d", tc.sql, res.Rows[0][0], tc.wantRows)
  149. }
  150. } else if tc.wantValue != "" {
  151. res, valueErr := runCatalogLockedStatement(t, e, `SELECT v FROM t WHERE id = 1`)
  152. if valueErr != nil {
  153. t.Fatal(valueErr)
  154. }
  155. if fmt.Sprintf("%v", res.Rows[0][0]) != tc.wantValue {
  156. t.Fatalf("%s: v = %v, want %q", tc.sql, res.Rows[0][0], tc.wantValue)
  157. }
  158. }
  159. probeSessionLiveness(t, schema, table, "t")
  160. })
  161. }
  162. }
  163. // A predicate that fails part-way through the scan must not leave rows written
  164. // by the rows the scan already visited.
  165. func TestPGCatalogPredicateErrorLeavesNoPartialWrite(t *testing.T) {
  166. _, schema, table := newTestDB(t)
  167. e := newExec(schema, table)
  168. // No UNIQUE index: nothing else makes the statement atomic for it.
  169. execMust(t, e, "CREATE TABLE plain (id INTEGER, v TEXT)")
  170. execMust(t, e, "INSERT INTO plain VALUES (1, 'one'), (2, 'two'), (3, 'three')")
  171. execMust(t, e, "CREATE TABLE probe (id INTEGER PRIMARY KEY)")
  172. // The predicate succeeds for rows 1 and 3 and fails on row 2, so a
  173. // non-atomic update would leave rows 1 and 3 changed.
  174. _, err := runCatalogLockedStatement(t, e, `UPDATE plain SET v = 'changed' WHERE id <> 2 OR 'missing_relation'::regclass IS NOT NULL`)
  175. if !errors.Is(err, pgerror.ErrUndefinedTable) {
  176. t.Fatalf("update err = %v, want an undefined_table error", err)
  177. }
  178. res := execMust(t, e, `SELECT count(*) FROM plain WHERE v = 'changed'`)
  179. if res.Rows[0][0] != int64(0) {
  180. t.Fatalf("partial write: %v rows changed", res.Rows[0][0])
  181. }
  182. _, err = runCatalogLockedStatement(t, e, `DELETE FROM plain WHERE id <> 2 OR 'missing_relation'::regclass IS NOT NULL`)
  183. if !errors.Is(err, pgerror.ErrUndefinedTable) {
  184. t.Fatalf("delete err = %v, want an undefined_table error", err)
  185. }
  186. res = execMust(t, e, `SELECT count(*) FROM plain`)
  187. if res.Rows[0][0] != int64(3) {
  188. t.Fatalf("partial delete: %v rows left, want 3", res.Rows[0][0])
  189. }
  190. probeSessionLiveness(t, schema, table, "plain")
  191. }
  192. // The same deadlock class applies to any scan that runs inside an open
  193. // transaction, including a SELECT.
  194. func TestPGCatalogPredicateInExplicitTransactionDoesNotDeadlock(t *testing.T) {
  195. e, schema, table := setupCatalogLockFixture(t)
  196. if _, err := runCatalogLockedStatement(t, e, "BEGIN"); err != nil {
  197. t.Fatalf("BEGIN: %v", err)
  198. }
  199. _, err := runCatalogLockedStatement(t, e, `SELECT * FROM t WHERE v = 'a' AND 'missing_relation'::regclass IS NOT NULL`)
  200. if !errors.Is(err, pgerror.ErrUndefinedTable) {
  201. t.Fatalf("select err = %v, want an undefined_table error", err)
  202. }
  203. _, err = runCatalogLockedStatement(t, e, `UPDATE t SET v = 'z' WHERE v = 'a' AND 'missing_relation'::regclass IS NOT NULL`)
  204. if !errors.Is(err, pgerror.ErrUndefinedTable) {
  205. t.Fatalf("update err = %v, want an undefined_table error", err)
  206. }
  207. // A catalog relation in a predicate still works inside the transaction.
  208. res, err := runCatalogLockedStatement(t, e, `SELECT count(*) FROM t WHERE id IN (SELECT 1)`)
  209. if err != nil {
  210. t.Fatalf("catalog-free predicate: %v", err)
  211. }
  212. if res.Rows[0][0] != int64(1) {
  213. t.Fatalf("count = %v, want 1", res.Rows[0][0])
  214. }
  215. if _, err := runCatalogLockedStatement(t, e, "ROLLBACK"); err != nil {
  216. t.Fatalf("ROLLBACK: %v", err)
  217. }
  218. // The transaction held no lock after the rollback.
  219. probeSessionLiveness(t, schema, table, "t")
  220. // A catalog predicate that matches still updates inside a transaction.
  221. if _, err := runCatalogLockedStatement(t, e, "BEGIN"); err != nil {
  222. t.Fatalf("BEGIN: %v", err)
  223. }
  224. if _, err := runCatalogLockedStatement(t, e, `UPDATE t SET v = 'z' WHERE id = 2 AND 't'::regclass IS NOT NULL`); err != nil {
  225. t.Fatalf("update in transaction: %v", err)
  226. }
  227. if _, err := runCatalogLockedStatement(t, e, "COMMIT"); err != nil {
  228. t.Fatalf("COMMIT: %v", err)
  229. }
  230. res = execMust(t, e, `SELECT v FROM t WHERE id = 2`)
  231. if fmt.Sprintf("%v", res.Rows[0][0]) != "z" {
  232. t.Fatalf("committed value = %v, want z", res.Rows[0][0])
  233. }
  234. }
  235. // A correlated subquery in a write predicate cannot read the durable schema while
  236. // the scan holds the session lock. It must report that (0A000) instead of
  237. // deadlocking, and the failed statement must leave no partial write and no lock.
  238. func TestPGCatalogCorrelatedPredicateErrorsInsteadOfDeadlocking(t *testing.T) {
  239. e, schema, table := setupCatalogLockFixture(t)
  240. _, err := runCatalogLockedStatement(t, e, `UPDATE t SET v = 'z' WHERE EXISTS (SELECT 1 FROM other o WHERE o.id = t.id)`)
  241. if !errors.Is(err, pgerror.ErrFeatureNotSupported) {
  242. t.Fatalf("err = %v, want a feature_not_supported error", err)
  243. }
  244. res := execMust(t, e, `SELECT count(*) FROM t WHERE v = 'z'`)
  245. if res.Rows[0][0] != int64(0) {
  246. t.Fatalf("partial write: %v rows changed", res.Rows[0][0])
  247. }
  248. probeSessionLiveness(t, schema, table, "t")
  249. }
  250. // A catalog-only correlated subquery resolves from the statement snapshot, so it
  251. // still works inside the locked scan.
  252. func TestPGCatalogCorrelatedSubqueryResolvesFromSnapshot(t *testing.T) {
  253. e, schema, table := setupCatalogLockFixture(t)
  254. if _, err := runCatalogLockedStatement(t, e, `UPDATE t SET v = 'pg_class' WHERE EXISTS (SELECT 1 FROM pg_class c WHERE c.relname = 'pg_class')`); err != nil {
  255. t.Fatalf("catalog-only correlated subquery: %v", err)
  256. }
  257. res := execMust(t, e, `SELECT count(*) FROM t WHERE v = 'pg_class'`)
  258. if res.Rows[0][0] != int64(2) {
  259. t.Fatalf("updated rows = %v, want 2", res.Rows[0][0])
  260. }
  261. probeSessionLiveness(t, schema, table, "t")
  262. }