Skip to content

Commit 5939553

Browse files
committed
feat(analyser): lock timout warning
1 parent bc01ca1 commit 5939553

27 files changed

+568
-32
lines changed

crates/pgt_analyse/src/analysed_file_context.rs

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
pub struct AnalysedFileContext<'a> {
22
pub stmts: &'a Vec<pgt_query::NodeEnum>,
3-
43
pos: usize,
4+
transaction_state: TransactionState,
55
}
66

77
impl<'a> AnalysedFileContext<'a> {
88
pub fn new(stmts: &'a Vec<pgt_query::NodeEnum>) -> Self {
9-
Self { stmts, pos: 0 }
9+
Self {
10+
stmts,
11+
pos: 0,
12+
transaction_state: TransactionState::default(),
13+
}
1014
}
1115

1216
pub fn previous_stmts(&self) -> &[pgt_query::NodeEnum] {
@@ -17,7 +21,100 @@ impl<'a> AnalysedFileContext<'a> {
1721
self.stmts.len()
1822
}
1923

24+
/// Move to the next statement and update transaction state with the current statement
2025
pub fn next(&mut self) {
26+
if self.pos < self.stmts.len() {
27+
self.transaction_state
28+
.update_from_stmt(&self.stmts[self.pos]);
29+
}
2130
self.pos += 1;
2231
}
32+
33+
/// Get a reference to the transaction state
34+
pub fn transaction_state(&self) -> &TransactionState {
35+
&self.transaction_state
36+
}
37+
}
38+
39+
/// Represents the state of a transaction as we analyze statements in a file.
40+
///
41+
/// This tracks properties that span multiple statements, such as:
42+
/// - Whether a lock timeout has been set
43+
/// - Which objects have been created in this transaction
44+
#[derive(Debug, Default)]
45+
pub struct TransactionState {
46+
/// Whether `SET lock_timeout` has been called in this transaction
47+
lock_timeout_set: bool,
48+
/// Objects (schema, name) created in this transaction
49+
/// Schema names are normalized: empty string is stored as "public"
50+
created_objects: Vec<(String, String)>,
51+
}
52+
53+
impl TransactionState {
54+
/// Returns true if a lock timeout has been set in this transaction
55+
pub fn has_lock_timeout(&self) -> bool {
56+
self.lock_timeout_set
57+
}
58+
59+
/// Returns true if an object with the given schema and name was created in this transaction
60+
pub fn has_created_object(&self, schema: &str, name: &str) -> bool {
61+
// Normalize schema: treat empty string as "public"
62+
let normalized_schema = if schema.is_empty() { "public" } else { schema };
63+
64+
self.created_objects
65+
.iter()
66+
.any(|(s, n)| normalized_schema.eq_ignore_ascii_case(s) && name.eq_ignore_ascii_case(n))
67+
}
68+
69+
/// Record that an object was created, normalizing the schema name
70+
fn add_created_object(&mut self, schema: String, name: String) {
71+
// Normalize schema: store "public" instead of empty string
72+
let normalized_schema = if schema.is_empty() {
73+
"public".to_string()
74+
} else {
75+
schema
76+
};
77+
self.created_objects.push((normalized_schema, name));
78+
}
79+
80+
/// Update transaction state based on a statement
81+
pub(crate) fn update_from_stmt(&mut self, stmt: &pgt_query::NodeEnum) {
82+
// Track SET lock_timeout
83+
if let pgt_query::NodeEnum::VariableSetStmt(set_stmt) = stmt {
84+
if set_stmt.name.eq_ignore_ascii_case("lock_timeout") {
85+
self.lock_timeout_set = true;
86+
}
87+
}
88+
89+
// Track created objects
90+
match stmt {
91+
pgt_query::NodeEnum::CreateStmt(create_stmt) => {
92+
if let Some(relation) = &create_stmt.relation {
93+
let schema = relation.schemaname.clone();
94+
let name = relation.relname.clone();
95+
self.add_created_object(schema, name);
96+
}
97+
}
98+
pgt_query::NodeEnum::IndexStmt(index_stmt) => {
99+
if !index_stmt.idxname.is_empty() {
100+
let schema = index_stmt
101+
.relation
102+
.as_ref()
103+
.map(|r| r.schemaname.clone())
104+
.unwrap_or_default();
105+
self.add_created_object(schema, index_stmt.idxname.clone());
106+
}
107+
}
108+
pgt_query::NodeEnum::CreateTableAsStmt(ctas) => {
109+
if let Some(into) = &ctas.into {
110+
if let Some(rel) = &into.rel {
111+
let schema = rel.schemaname.clone();
112+
let name = rel.relname.clone();
113+
self.add_created_object(schema, name);
114+
}
115+
}
116+
}
117+
_ => {}
118+
}
119+
}
23120
}

crates/pgt_analyser/src/lint/safety.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub mod changing_column_type;
1818
pub mod constraint_missing_not_valid;
1919
pub mod creating_enum;
2020
pub mod disallow_unique_constraint;
21+
pub mod lock_timeout_warning;
2122
pub mod multiple_alter_table;
2223
pub mod prefer_big_int;
2324
pub mod prefer_bigint_over_int;
@@ -32,4 +33,4 @@ pub mod renaming_table;
3233
pub mod require_concurrent_index_creation;
3334
pub mod require_concurrent_index_deletion;
3435
pub mod transaction_nesting;
35-
declare_lint_group! { pub Safety { name : "safety" , rules : [self :: add_serial_column :: AddSerialColumn , self :: adding_field_with_default :: AddingFieldWithDefault , self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_char_field :: BanCharField , self :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade , self :: changing_column_type :: ChangingColumnType , self :: constraint_missing_not_valid :: ConstraintMissingNotValid , self :: creating_enum :: CreatingEnum , self :: disallow_unique_constraint :: DisallowUniqueConstraint , self :: multiple_alter_table :: MultipleAlterTable , self :: prefer_big_int :: PreferBigInt , self :: prefer_bigint_over_int :: PreferBigintOverInt , self :: prefer_bigint_over_smallint :: PreferBigintOverSmallint , self :: prefer_identity :: PreferIdentity , self :: prefer_jsonb :: PreferJsonb , self :: prefer_robust_stmts :: PreferRobustStmts , self :: prefer_text_field :: PreferTextField , self :: prefer_timestamptz :: PreferTimestamptz , self :: renaming_column :: RenamingColumn , self :: renaming_table :: RenamingTable , self :: require_concurrent_index_creation :: RequireConcurrentIndexCreation , self :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion , self :: transaction_nesting :: TransactionNesting ,] } }
36+
declare_lint_group! { pub Safety { name : "safety" , rules : [self :: add_serial_column :: AddSerialColumn , self :: adding_field_with_default :: AddingFieldWithDefault , self :: adding_foreign_key_constraint :: AddingForeignKeyConstraint , self :: adding_not_null_field :: AddingNotNullField , self :: adding_primary_key_constraint :: AddingPrimaryKeyConstraint , self :: adding_required_field :: AddingRequiredField , self :: ban_char_field :: BanCharField , self :: ban_concurrent_index_creation_in_transaction :: BanConcurrentIndexCreationInTransaction , self :: ban_drop_column :: BanDropColumn , self :: ban_drop_database :: BanDropDatabase , self :: ban_drop_not_null :: BanDropNotNull , self :: ban_drop_table :: BanDropTable , self :: ban_truncate_cascade :: BanTruncateCascade , self :: changing_column_type :: ChangingColumnType , self :: constraint_missing_not_valid :: ConstraintMissingNotValid , self :: creating_enum :: CreatingEnum , self :: disallow_unique_constraint :: DisallowUniqueConstraint , self :: lock_timeout_warning :: LockTimeoutWarning , self :: multiple_alter_table :: MultipleAlterTable , self :: prefer_big_int :: PreferBigInt , self :: prefer_bigint_over_int :: PreferBigintOverInt , self :: prefer_bigint_over_smallint :: PreferBigintOverSmallint , self :: prefer_identity :: PreferIdentity , self :: prefer_jsonb :: PreferJsonb , self :: prefer_robust_stmts :: PreferRobustStmts , self :: prefer_text_field :: PreferTextField , self :: prefer_timestamptz :: PreferTimestamptz , self :: renaming_column :: RenamingColumn , self :: renaming_table :: RenamingTable , self :: require_concurrent_index_creation :: RequireConcurrentIndexCreation , self :: require_concurrent_index_deletion :: RequireConcurrentIndexDeletion , self :: transaction_nesting :: TransactionNesting ,] } }
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
use pgt_analyse::{Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule};
2+
use pgt_console::markup;
3+
use pgt_diagnostics::Severity;
4+
5+
declare_lint_rule! {
6+
/// Taking a dangerous lock without setting a lock timeout can cause indefinite blocking.
7+
///
8+
/// When a statement acquires a lock that would block common operations (like SELECT, INSERT, UPDATE, DELETE),
9+
/// it can cause the database to become unresponsive if another transaction is holding a conflicting lock
10+
/// while idle in transaction or active. This is particularly dangerous for:
11+
///
12+
/// - ALTER TABLE statements (acquire ACCESS EXCLUSIVE lock)
13+
/// - CREATE INDEX without CONCURRENTLY (acquires SHARE lock)
14+
///
15+
/// Setting a lock timeout ensures that if the lock cannot be acquired within a reasonable time,
16+
/// the statement will fail rather than blocking indefinitely.
17+
///
18+
/// ## Examples
19+
///
20+
/// ### Invalid
21+
///
22+
/// ```sql,expect_diagnostic
23+
/// ALTER TABLE users ADD COLUMN email TEXT;
24+
/// ```
25+
///
26+
/// ```sql,expect_diagnostic
27+
/// CREATE INDEX users_email_idx ON users(email);
28+
/// ```
29+
///
30+
/// ### Valid
31+
///
32+
/// ```sql
33+
/// -- Use CONCURRENTLY to avoid taking dangerous locks
34+
/// CREATE INDEX CONCURRENTLY users_email_idx ON users(email);
35+
/// ```
36+
///
37+
pub LockTimeoutWarning {
38+
version: "next",
39+
name: "lockTimeoutWarning",
40+
severity: Severity::Error,
41+
recommended: false,
42+
sources: &[RuleSource::Eugene("E9")],
43+
}
44+
}
45+
46+
impl Rule for LockTimeoutWarning {
47+
type Options = ();
48+
49+
fn run(ctx: &RuleContext<Self>) -> Vec<RuleDiagnostic> {
50+
let mut diagnostics = Vec::new();
51+
52+
// Check if lock timeout has been set in the transaction
53+
let tx_state = ctx.file_context().transaction_state();
54+
if tx_state.has_lock_timeout() {
55+
return diagnostics;
56+
}
57+
58+
// Check if this statement takes a dangerous lock on an existing object
59+
match ctx.stmt() {
60+
// ALTER TABLE takes ACCESS EXCLUSIVE lock
61+
pgt_query::NodeEnum::AlterTableStmt(stmt) => {
62+
if let Some(relation) = &stmt.relation {
63+
let schema = if relation.schemaname.is_empty() {
64+
"public"
65+
} else {
66+
&relation.schemaname
67+
};
68+
let table = &relation.relname;
69+
70+
// Only warn if the table wasn't created in this transaction
71+
if !tx_state.has_created_object(schema, table) {
72+
let full_name = format!("{}.{}", schema, table);
73+
diagnostics.push(
74+
RuleDiagnostic::new(
75+
rule_category!(),
76+
None,
77+
markup! {
78+
"Statement takes ACCESS EXCLUSIVE lock on "<Emphasis>{full_name}</Emphasis>" without lock timeout set."
79+
},
80+
)
81+
.detail(None, "This can block all operations on the table indefinitely if another transaction holds a conflicting lock.")
82+
.note("Run 'SET LOCAL lock_timeout = '2s';' before this statement and retry the migration if it times out."),
83+
);
84+
}
85+
}
86+
}
87+
88+
// CREATE INDEX without CONCURRENTLY takes SHARE lock
89+
pgt_query::NodeEnum::IndexStmt(stmt) => {
90+
if !stmt.concurrent {
91+
if let Some(relation) = &stmt.relation {
92+
let schema = if relation.schemaname.is_empty() {
93+
"public"
94+
} else {
95+
&relation.schemaname
96+
};
97+
let table = &relation.relname;
98+
99+
// Only warn if the table wasn't created in this transaction
100+
if !tx_state.has_created_object(schema, table) {
101+
let full_name = format!("{}.{}", schema, table);
102+
let index_name = &stmt.idxname;
103+
diagnostics.push(
104+
RuleDiagnostic::new(
105+
rule_category!(),
106+
None,
107+
markup! {
108+
"Statement takes SHARE lock on "<Emphasis>{full_name}</Emphasis>" while creating index "<Emphasis>{index_name}</Emphasis>" without lock timeout set."
109+
},
110+
)
111+
.detail(None, "This blocks writes to the table indefinitely if another transaction holds a conflicting lock.")
112+
.note("Run 'SET LOCAL lock_timeout = '2s';' before this statement, or use CREATE INDEX CONCURRENTLY to avoid blocking writes."),
113+
);
114+
}
115+
}
116+
}
117+
}
118+
119+
_ => {}
120+
}
121+
122+
diagnostics
123+
}
124+
}

crates/pgt_analyser/src/options.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pub type ChangingColumnType =
2727
pub type ConstraintMissingNotValid = < lint :: safety :: constraint_missing_not_valid :: ConstraintMissingNotValid as pgt_analyse :: Rule > :: Options ;
2828
pub type CreatingEnum = <lint::safety::creating_enum::CreatingEnum as pgt_analyse::Rule>::Options;
2929
pub type DisallowUniqueConstraint = < lint :: safety :: disallow_unique_constraint :: DisallowUniqueConstraint as pgt_analyse :: Rule > :: Options ;
30+
pub type LockTimeoutWarning =
31+
<lint::safety::lock_timeout_warning::LockTimeoutWarning as pgt_analyse::Rule>::Options;
3032
pub type MultipleAlterTable =
3133
<lint::safety::multiple_alter_table::MultipleAlterTable as pgt_analyse::Rule>::Options;
3234
pub type PreferBigInt = <lint::safety::prefer_big_int::PreferBigInt as pgt_analyse::Rule>::Options;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- expect_only_lint/safety/lockTimeoutWarning
2+
-- ALTER TABLE without lock timeout should trigger the rule
3+
ALTER TABLE authors ADD COLUMN email TEXT;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
source: crates/pgt_analyser/tests/rules_tests.rs
3+
expression: snapshot
4+
snapshot_kind: text
5+
---
6+
# Input
7+
```
8+
-- expect_only_lint/safety/lockTimeoutWarning
9+
-- ALTER TABLE without lock timeout should trigger the rule
10+
ALTER TABLE authors ADD COLUMN email TEXT;
11+
```
12+
13+
# Diagnostics
14+
lint/safety/lockTimeoutWarning ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
15+
16+
× Statement takes ACCESS EXCLUSIVE lock on public.authors without lock timeout set.
17+
18+
i This can block all operations on the table indefinitely if another transaction holds a conflicting lock.
19+
20+
i Run 'SET LOCAL lock_timeout = '2s';' before this statement and retry the migration if it times out.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- expect_only_lint/safety/lockTimeoutWarning
2+
-- CREATE INDEX without CONCURRENTLY or lock timeout should trigger the rule
3+
CREATE INDEX books_title_idx ON books(title);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
source: crates/pgt_analyser/tests/rules_tests.rs
3+
expression: snapshot
4+
snapshot_kind: text
5+
---
6+
# Input
7+
```
8+
-- expect_only_lint/safety/lockTimeoutWarning
9+
-- CREATE INDEX without CONCURRENTLY or lock timeout should trigger the rule
10+
CREATE INDEX books_title_idx ON books(title);
11+
12+
```
13+
14+
# Diagnostics
15+
lint/safety/lockTimeoutWarning ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
16+
17+
× Statement takes SHARE lock on public.books while creating index books_title_idx without lock timeout set.
18+
19+
i This blocks writes to the table indefinitely if another transaction holds a conflicting lock.
20+
21+
i Run 'SET LOCAL lock_timeout = '2s';' before this statement, or use CREATE INDEX CONCURRENTLY to avoid blocking writes.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Both statements should trigger the rule
2+
-- expect_lint/safety/lockTimeoutWarning
3+
CREATE INDEX orders_user_idx ON orders(user_id);
4+
5+
-- expect_lint/safety/lockTimeoutWarning
6+
ALTER TABLE orders ADD COLUMN total DECIMAL(10, 2);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
source: crates/pgt_analyser/tests/rules_tests.rs
3+
expression: snapshot
4+
snapshot_kind: text
5+
---
6+
# Input
7+
```
8+
-- Both statements should trigger the rule
9+
-- expect_lint/safety/lockTimeoutWarning
10+
CREATE INDEX orders_user_idx ON orders(user_id);
11+
12+
-- expect_lint/safety/lockTimeoutWarning
13+
ALTER TABLE orders ADD COLUMN total DECIMAL(10, 2);
14+
15+
```
16+
17+
# Diagnostics
18+
lint/safety/lockTimeoutWarning ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
19+
20+
× Statement takes SHARE lock on public.orders while creating index orders_user_idx without lock timeout set.
21+
22+
i This blocks writes to the table indefinitely if another transaction holds a conflicting lock.
23+
24+
i Run 'SET LOCAL lock_timeout = '2s';' before this statement, or use CREATE INDEX CONCURRENTLY to avoid blocking writes.
25+
26+
27+
28+
lint/safety/lockTimeoutWarning ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
29+
30+
× Statement takes ACCESS EXCLUSIVE lock on public.orders without lock timeout set.
31+
32+
i This can block all operations on the table indefinitely if another transaction holds a conflicting lock.
33+
34+
i Run 'SET LOCAL lock_timeout = '2s';' before this statement and retry the migration if it times out.

0 commit comments

Comments
 (0)