Skip to content

Commit bbda180

Browse files
authored
Fix race condition with completer refreshing (#1934)
1 parent 5057282 commit bbda180

3 files changed

Lines changed: 134 additions & 2 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Upcoming (TBD)
44
Bug Fixes
55
---------
66
* Ensure that `--batch` and `--checkpoint` files are distinct.
7+
* Fix crash from completions after `USE` starts a background refresh (#1933)
78

89

910
Internal

mycli/sqlcompleter.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1754,8 +1754,16 @@ def populate_scoped_cols(self, scoped_tbls: list[tuple[str | None, str, str | No
17541754
# if scoped tables is empty, this is just after a SELECT so we
17551755
# show all columns for all tables in the schema.
17561756
if len(scoped_tbls) == 0 and self.dbname:
1757-
for table in meta["tables"][self.dbname]:
1758-
columns.extend(meta["tables"][self.dbname][table])
1757+
# ``dbname`` can point at a schema whose metadata has not been
1758+
# loaded yet: a ``USE`` switch updates the live completer's
1759+
# ``dbname`` immediately while the matching tables are still being
1760+
# fetched on a background thread. Default to an empty mapping in
1761+
# that window instead of raising ``KeyError``. Grab the per-schema
1762+
# dict once so a concurrent refresh swapping it out cannot break
1763+
# the iteration mid-loop.
1764+
schema_tables = meta["tables"].get(self.dbname, {})
1765+
for table in schema_tables:
1766+
columns.extend(schema_tables[table])
17591767
return columns or ['*']
17601768

17611769
# query includes tables, so use those to populate columns
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# type: ignore
2+
"""Regression tests for completing right after a schema (``USE``) switch.
3+
4+
When the user runs ``USE <db>``, ``refresh_completions(reset=True)`` updates the
5+
live completer's ``dbname`` *immediately* so unqualified completions reflect the
6+
switch, while the matching table metadata is still being fetched on a background
7+
thread (and only swapped in when the refresh finishes).
8+
9+
In that window ``self.dbname`` names a schema that is not yet a key in
10+
``dbmetadata["tables"]``. A naked ``SELECT `` completion reaches
11+
``populate_scoped_cols`` with no scoped tables and used to crash with
12+
``KeyError`` on ``meta["tables"][self.dbname]``. These tests pin the safe
13+
behaviour: suggest ``*`` during the window, real columns once loaded.
14+
"""
15+
16+
import threading
17+
import traceback
18+
19+
from prompt_toolkit.document import Document
20+
21+
from mycli.sqlcompleter import SQLCompleter
22+
23+
24+
def _make_completer() -> SQLCompleter:
25+
completer = SQLCompleter(smart_completion=True)
26+
completer.load_schema_metadata(
27+
schema="old_db",
28+
table_columns={"orders": ["*", "id", "total"]},
29+
foreign_keys={},
30+
enum_values={},
31+
functions={},
32+
procedures={},
33+
)
34+
completer.set_dbname("old_db")
35+
return completer
36+
37+
38+
def test_populate_scoped_cols_unloaded_schema_returns_star() -> None:
39+
completer = _make_completer()
40+
# Switch to a schema whose metadata has not been loaded yet.
41+
completer.set_dbname("new_db")
42+
assert "new_db" not in completer.dbmetadata["tables"]
43+
44+
# Empty scoped tables => the "naked SELECT" branch that does the lookup.
45+
assert completer.populate_scoped_cols([]) == ["*"]
46+
47+
48+
def test_get_completions_after_use_switch_before_refresh() -> None:
49+
completer = _make_completer()
50+
completer.set_dbname("new_db") # metadata not loaded yet
51+
52+
for text in ("SELECT ", "SELECT col", "SELECT a, "):
53+
document = Document(text, len(text))
54+
# Must not raise KeyError while the background refresh is in flight.
55+
completions = list(completer.get_completions(document, None))
56+
assert all(c.text for c in completions)
57+
58+
59+
def test_columns_available_once_schema_loads() -> None:
60+
completer = _make_completer()
61+
completer.set_dbname("new_db")
62+
# Background refresh finishes and loads the new schema.
63+
completer.load_schema_metadata(
64+
schema="new_db",
65+
table_columns={"customers": ["*", "name", "email"]},
66+
foreign_keys={},
67+
enum_values={},
68+
functions={},
69+
procedures={},
70+
)
71+
72+
cols = completer.populate_scoped_cols([])
73+
assert "name" in cols
74+
assert "email" in cols
75+
76+
77+
def test_completion_during_concurrent_use_switch_does_not_crash() -> None:
78+
"""A reader must survive a writer flipping ``dbname`` between schemas.
79+
80+
Mirrors the live REPL: prompt_toolkit's completion thread reads the
81+
completer lock-free while a background refresh switches ``dbname`` and
82+
loads/unloads schema metadata.
83+
"""
84+
completer = _make_completer()
85+
stop = threading.Event()
86+
errors: list[str] = []
87+
88+
def reader() -> None:
89+
document = Document("SELECT ", len("SELECT "))
90+
while not stop.is_set():
91+
try:
92+
list(completer.get_completions(document, None))
93+
except Exception:
94+
errors.append(traceback.format_exc())
95+
return
96+
97+
def writer() -> None:
98+
n = 0
99+
while not stop.is_set() and n < 2000:
100+
n += 1
101+
schema = f"db_{n}"
102+
# Point dbname at a not-yet-loaded schema, then load it, as the
103+
# reset=True refresh path does.
104+
completer.set_dbname(schema)
105+
completer.load_schema_metadata(
106+
schema=schema,
107+
table_columns={"t": ["*", "c1", "c2"]},
108+
foreign_keys={},
109+
enum_values={},
110+
functions={},
111+
procedures={},
112+
)
113+
114+
threads = [threading.Thread(target=reader) for _ in range(3)]
115+
threads.append(threading.Thread(target=writer))
116+
for thread in threads:
117+
thread.start()
118+
threads[-1].join(timeout=5)
119+
stop.set()
120+
for thread in threads:
121+
thread.join(timeout=5)
122+
123+
assert not errors, "completion crashed during USE switch:\n" + "\n".join(errors)

0 commit comments

Comments
 (0)