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
use lix::Value;
// Companion audit for `subquery_reads`: the write-plan cache (`write_plans`,
// `LogicalWritePlan`) must not repeat the read-plan cache's traversal hole.
//
// The read cache stores a DataFusion `LogicalPlan` and has to detach every
// `TableScan` source before caching. `LogicalWritePlan` stores only lix's own
// bound IR plus sqlparser AST, so there is no provider to detach — but that is
// only safe while write statements cannot smuggle a live plan into the LRU.
// This test pins both halves of that argument:
//
// * subquery predicates are rejected at bind time, before a template can be
// cached, and stay rejected on the second (warm-cache) attempt;
// * `INSERT ... SELECT` — the one write shape that does carry a nested query
// into the cached template — re-plans its input from the stored AST on
// every execution and therefore never parks a provider in the cache.
//
// Every statement runs twice so a template written by the first attempt is
// exercised by the second.
simulation_test!(
subquery_writes_do_not_leak_storage_read_handles,
|sim| async move {
let engine = sim.boot_engine().await;
let session = sim.wrap_session(
engine
.open_session()
.await
.expect("main session should open"),
&engine,
);
for (index, key) in ["sqw-a", "sqw-b", "sqw-c"].into_iter().enumerate() {
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ($1, $2)",
&[
Value::Text(key.to_string()),
Value::Text(format!("value-{index}")),
],
)
.await
.expect("seed insert should succeed");
}
// Subquery-carrying write predicates never reach the write-plan cache.
let rejected = [
"UPDATE lix_key_value SET value = 'rewritten' \
WHERE key IN (SELECT key FROM lix_key_value WHERE key = 'sqw-a')",
"DELETE FROM lix_key_value \
WHERE EXISTS (SELECT 1 FROM lix_key_value WHERE key = 'sqw-b')",
"UPDATE lix_key_value \
SET value = (SELECT value FROM lix_key_value WHERE key = 'sqw-c') \
WHERE key = 'sqw-a'",
"DELETE FROM lix_key_value \
WHERE key = (SELECT MIN(key) FROM lix_key_value WHERE key LIKE 'sqw-%')",
];
for sql in rejected {
for attempt in 0..2 {
let error = session
.execute(sql, &[])
.await
.expect_err("subquery write predicates are not part of the write surface");
assert_eq!(
error.code,
lix::LixError::CODE_UNSUPPORTED_SQL,
"attempt {attempt} of `{sql}` must be refused by the binder, got {error:?}"
);
}
}
// The rejected statements must not have mutated anything, and the session
// must still be usable afterwards.
let surviving = session
.execute(
"SELECT key, value FROM lix_key_value WHERE key LIKE 'sqw-%' ORDER BY key",
&[],
)
.await
.expect("read after rejected writes should succeed");
assert_eq!(surviving.len(), 3, "rejected writes must not mutate rows");
// `INSERT ... SELECT` is the one write shape whose cached template retains
// a nested query. Run it twice with a subquery in the source so a cached
// template is exercised on the second attempt.
for (index, id) in [
"01920000-0000-7000-8000-0000000009a1",
"01920000-0000-7000-8000-0000000009a2",
]
.into_iter()
.enumerate()
{
let result = session
.execute(
"INSERT INTO lix_file (id, path) \
SELECT $1, $2 WHERE EXISTS (SELECT 1 FROM lix_key_value WHERE key = 'sqw-a')",
&[
Value::Text(id.to_string()),
Value::Text(format!("/subquery-{index}.txt")),
],
)
.await
.unwrap_or_else(|error| {
panic!("INSERT ... SELECT attempt {index} failed: {error:?}")
});
assert_eq!(
result.rows_affected(),
1,
"INSERT ... SELECT attempt {index} inserted the wrong row count"
);
}
let inserted = session
.execute(
"SELECT path FROM lix_file WHERE path LIKE '/subquery-%' ORDER BY path",
&[],
)
.await
.expect("read of INSERT ... SELECT rows should succeed");
assert_eq!(
inserted.len(),
2,
"both INSERT ... SELECT attempts must be visible"
);
}
);