Skip to content

Commit bd95a6b

Browse files
committed
fix(scenario): cancel in-flight statements on Ctrl-C and fit names by bytes
Running every scenario through a worker pool and absorbing repeat interrupts made long statements uninterruptible, even at concurrency 1. Concurrency 1, --local replay and capture now run on the main thread again, so Ctrl-C reaches the driver as before. A SIGINT handler records the stop and cancels in-flight statements through the new adapter interrupt_connection (DuckDB interrupt, Postgres cancel_safe); without it a DuckDB query interrupted only by the signal kept running in the background and blocked cleanup. Concurrent runs cancel queued scenarios, cancel or close in-flight worker connections, wait for teardown, and sweep started scenarios on a valid or fresh connection. A second Ctrl-C prints one notice and exits at once; daemon workers never block exit, and the next run of the same scenarios drops what was left behind. Identifier fitting now measures UTF-8 bytes and cuts on character boundaries, so non-ASCII names fit Postgres's 63-byte limit.
1 parent c6a38b0 commit bd95a6b

30 files changed

Lines changed: 905 additions & 185 deletions

File tree

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ E2E_DUCKDB_CLI_DATA_PATHS := \
124124
tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_namespace.py \
125125
tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_promotion.py \
126126
tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_concurrency.py \
127+
tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_interrupts.py \
127128
tests/e2e/src/sqlbuild/cli/commands/main/scenario/test_empty_fixture.py
128129

129130
E2E_DUCKDB_CLI_PATHS := \

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/scenarios.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,11 @@ summary includes the total time. On DuckDB, concurrent scenarios use separate co
170170
same database file; an in-memory database (`:memory:`) gives each connection its own database, so
171171
fixtures still isolate every scenario.
172172

173-
On Ctrl-C, SQLBuild prints one notice, cancels scenarios that have not started, stops running
174-
scenarios at their next step, drops their relations (unless `--retain`), and only then exits.
175-
Further Ctrl-C presses during that cleanup are ignored.
173+
On Ctrl-C, SQLBuild prints one notice, cancels scenarios that have not started, cancels the
174+
statements running scenarios have in flight, drops their relations (unless `--retain`), and only
175+
then exits. A second Ctrl-C skips that cleanup and exits immediately; the next run of the same
176+
scenarios drops whatever they left behind. `--local` replay and `sqb scenario capture` run one
177+
scenario at a time.
176178

177179
### Parallel CI runs
178180

‎src/sqlbuild/adapter/contract/classes/base_adapter.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ def supports_unqualified_function_fingerprints(self) -> bool:
149149
def supports_table_functions(self) -> bool:
150150
return False
151151

152+
def interrupt_connection(self, connection: Any) -> bool:
153+
"""Cancel a connection's running statement from another thread; False if unsupported."""
154+
155+
del connection
156+
return False
157+
152158
def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool:
153159
"""Return whether ``list_relations`` reports every relation in this scope."""
154160

‎src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ def get_columns_for_relations(
112112
)
113113
relation_grants_supported: ClassVar[bool] = False
114114

115+
def interrupt_connection(self, connection: Any) -> bool:
116+
"""Cancel a connection's running statement from another thread; False if unsupported."""
117+
118+
connection.interrupt()
119+
return True
120+
115121
def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool:
116122
"""Return whether ``list_relations`` reports every relation in this scope."""
117123

‎src/sqlbuild/adapter/contract/classes/strict_adapter.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ def maximum_identifier_length(self) -> int:
101101
"""Return the maximum unqualified identifier length supported by the adapter."""
102102
...
103103

104+
@abstractmethod
105+
def interrupt_connection(self, connection: Any) -> bool:
106+
"""Cancel a connection's running statement from another thread; False if unsupported."""
107+
...
108+
104109
@abstractmethod
105110
def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool:
106111
"""Return whether ``list_relations`` reports every relation in this scope."""

‎src/sqlbuild/adapter/relations/_helpers/identifier_fitting.py‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Identifier-limit fitting implementations for generated relation names."""
1+
"""Fit generated relation names within identifier limits measured in UTF-8 bytes."""
22

33
from __future__ import annotations
44

@@ -13,13 +13,13 @@ def fit_artifact_logical_name_impl(
1313
) -> str:
1414
"""Fit a readable logical component with a deterministic hash suffix."""
1515

16-
max_logical_length: int = identifier_limit - len(fixed_prefix)
16+
max_logical_length: int = identifier_limit - _byte_length(fixed_prefix)
1717
if max_logical_length < 1:
1818
raise SharedInputError(
1919
f"{artifact_label} prefix '{fixed_prefix}' does not fit within identifier "
2020
f"limit {identifier_limit}"
2121
)
22-
if len(logical_name) <= max_logical_length:
22+
if _byte_length(logical_name) <= max_logical_length:
2323
return logical_name
2424

2525
suffix_length: int = SHORTENED_LOGICAL_NAME_HASH_LENGTH + 1
@@ -32,22 +32,32 @@ def fit_artifact_logical_name_impl(
3232
:SHORTENED_LOGICAL_NAME_HASH_LENGTH
3333
]
3434
prefix_length: int = max_logical_length - suffix_length
35-
return f"{logical_name[:prefix_length]}_{logical_hash}"
35+
return f"{_truncate_bytes(value=logical_name, max_bytes=prefix_length)}_{logical_hash}"
3636

3737

3838
def fit_auxiliary_relation_name_impl(*, base_name: str, suffix: str, identifier_limit: int) -> str:
3939
"""Append a suffix, shortening the base with a stable hash when the whole name is too long."""
4040

41-
if len(base_name) + len(suffix) <= identifier_limit:
41+
if _byte_length(base_name) + _byte_length(suffix) <= identifier_limit:
4242
return f"{base_name}{suffix}"
4343
hash_part: str = (
4444
"_"
4545
+ hashlib.sha256(base_name.encode("utf-8")).hexdigest()[:SHORTENED_LOGICAL_NAME_HASH_LENGTH]
4646
)
47-
kept_length: int = identifier_limit - len(suffix) - len(hash_part)
47+
kept_length: int = identifier_limit - _byte_length(suffix) - len(hash_part)
4848
if kept_length < 1:
4949
raise SharedInputError(
5050
f"auxiliary relation suffix '{suffix}' for '{base_name}' cannot fit within "
5151
f"identifier limit {identifier_limit}"
5252
)
53-
return f"{base_name[:kept_length]}{hash_part}{suffix}"
53+
return f"{_truncate_bytes(value=base_name, max_bytes=kept_length)}{hash_part}{suffix}"
54+
55+
56+
def _byte_length(value: str) -> int:
57+
return len(value.encode("utf-8"))
58+
59+
60+
def _truncate_bytes(*, value: str, max_bytes: int) -> str:
61+
"""Keep the longest prefix whose UTF-8 encoding fits, never splitting a character."""
62+
63+
return value.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore")

‎src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,6 +1512,12 @@ def close(self, connection: _BigQueryConnection) -> None:
15121512
def supports_transactions(self) -> bool:
15131513
return False
15141514

1515+
def interrupt_connection(self, connection: Any) -> bool:
1516+
"""Cancel a connection's running statement from another thread; False if unsupported."""
1517+
1518+
del connection
1519+
return False
1520+
15151521
def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool:
15161522
"""Return whether ``list_relations`` reports every relation in this scope."""
15171523

‎src/sqlbuild/adapters/databricks/classes/databricks_adapter.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1170,6 +1170,12 @@ def render_apply_historical_check_snapshot_changes(
11701170
invalidate_hard_deletes=invalidate_hard_deletes,
11711171
)
11721172

1173+
def interrupt_connection(self, connection: Any) -> bool:
1174+
"""Cancel a connection's running statement from another thread; False if unsupported."""
1175+
1176+
del connection
1177+
return False
1178+
11731179
def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool:
11741180
"""Return whether ``list_relations`` reports every relation in this scope."""
11751181

‎src/sqlbuild/adapters/postgres/classes/postgres_adapter.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ def get_columns_for_relations(
140140
historical_insert=HistoricalSnapshotInsertStyle.WITH_INSERT,
141141
)
142142

143+
def interrupt_connection(self, connection: Any) -> bool:
144+
"""Cancel a connection's running statement from another thread; False if unsupported."""
145+
146+
connection.raw_connection.cancel_safe()
147+
return True
148+
143149
def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool:
144150
"""Return whether ``list_relations`` reports every relation in this scope."""
145151

‎src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2843,6 +2843,12 @@ def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) ->
28432843
return f"'{escaped_value}'"
28442844
return f"'{value}'"
28452845

2846+
def interrupt_connection(self, connection: Any) -> bool:
2847+
"""Cancel a connection's running statement from another thread; False if unsupported."""
2848+
2849+
del connection
2850+
return False
2851+
28462852
def lists_relations_authoritatively(self, *, database: str | None, schema: str) -> bool:
28472853
"""Return whether ``list_relations`` reports every relation in this scope."""
28482854

0 commit comments

Comments
 (0)