Skip to content

Commit 33b18a3

Browse files
authored
feat: support optional table locking for SQL Server bulk loads
Add a connected-client control for SQL Server table locking during bulk loads, with safe identifier handling and compatibility coverage. This lets staging workflows opt into faster bulk loads while retaining explicit fallback, reset, and cleanup behavior.
1 parent 23a06a9 commit 33b18a3

2 files changed

Lines changed: 97 additions & 1 deletion

File tree

src/connection.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,26 @@ impl ConnectedMssqlClient {
136136
})
137137
}
138138

139+
/// Enables or disables SQL Server's persistent `table lock on bulk load`
140+
/// option for a table.
141+
///
142+
/// Enabling requires `ALTER` permission. Callers may ignore a known
143+
/// nonfatal enable failure and continue without this optimization. Other
144+
/// failures should be propagated.
145+
///
146+
/// If enabling succeeds for a temporary load table, disabling must succeed
147+
/// before the table is published. A disable failure should prevent
148+
/// publication and trigger cleanup of the temporary table.
149+
pub async fn set_bulk_load_table_lock(
150+
&mut self,
151+
table: &TableName,
152+
enabled: bool,
153+
) -> Result<()> {
154+
self.execute_statement(&bulk_load_table_lock_sql(table, enabled))
155+
.await?;
156+
Ok(())
157+
}
158+
139159
/// Starts a bulk writer on this same SQL Server connection.
140160
///
141161
/// The returned writer borrows the connected client, so lifecycle SQL and
@@ -214,6 +234,15 @@ fn target_row_count_query(table: &TableName) -> String {
214234
)
215235
}
216236

237+
fn bulk_load_table_lock_sql(table: &TableName, enabled: bool) -> String {
238+
let value = if enabled { "ON" } else { "OFF" };
239+
240+
format!(
241+
"EXEC sys.sp_tableoption {}, 'table lock on bulk load', '{value}';",
242+
sql_string_literal(&table.quoted_sql())
243+
)
244+
}
245+
217246
fn count_big_i64_to_u64(count: i64) -> Result<u64> {
218247
u64::try_from(count).map_err(|_| Error::TargetRowCountUnexpectedResult {
219248
reason: "target row count was outside the supported range".to_owned(),
@@ -282,6 +311,21 @@ mod tests {
282311
Ok(())
283312
}
284313

314+
#[test]
315+
fn bulk_load_table_lock_sql_uses_quoted_table_name_and_requested_state() -> crate::Result<()> {
316+
let table = crate::TableName::new("tenant's", "people's")?;
317+
318+
assert_eq!(
319+
super::bulk_load_table_lock_sql(&table, true),
320+
"EXEC sys.sp_tableoption N'[tenant''s].[people''s]', 'table lock on bulk load', 'ON';"
321+
);
322+
assert_eq!(
323+
super::bulk_load_table_lock_sql(&table, false),
324+
"EXEC sys.sp_tableoption N'[tenant''s].[people''s]', 'table lock on bulk load', 'OFF';"
325+
);
326+
Ok(())
327+
}
328+
285329
#[test]
286330
fn count_big_conversion_rejects_negative_values_without_panicking() {
287331
let error = super::count_big_i64_to_u64(-1).err().unwrap_or_else(|| {

tests/compatibility_sqlserver.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ use arrow_array::{ArrayRef, Int32Array, RecordBatch, TimestampMicrosecondArray};
1111
use arrow_schema::{DataType, Field, Schema, TimeUnit};
1212
use arrow_sql_server::{
1313
BulkWriter, CompatibilityLevel, MssqlProfile, MssqlVersion, PlanOptions, TableName,
14-
TimestampPolicy, WriteBackend, WriteOptions, create_table_sql_from_mappings,
14+
TimestampPolicy, WriteBackend, WriteOptions, connect_mssql_client_from_ado_string,
15+
create_table_sql_from_mappings,
1516
};
1617
use tokio::net::TcpStream;
1718
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
@@ -25,6 +26,48 @@ static TABLE_COUNTER: AtomicU64 = AtomicU64::new(0);
2526
type TestClient = tiberius::Client<Compat<TcpStream>>;
2627
type TestResult<T> = Result<T, Box<dyn std::error::Error>>;
2728

29+
#[tokio::test]
30+
async fn bulk_load_table_lock_can_be_enabled_and_disabled() -> TestResult<()> {
31+
let Some((connection_string, database)) = integration_config() else {
32+
eprintln!(
33+
"skipping SQL Server bulk-load table-lock compatibility probe: {CONNECTION_STRING_ENV} or {TEST_DATABASE_ENV} is not set"
34+
);
35+
return Ok(());
36+
};
37+
38+
let connection_string = format!("{connection_string};database={database}");
39+
let mut client = connect_mssql_client_from_ado_string(&connection_string).await?;
40+
let table = unique_table_name()?;
41+
client
42+
.execute_statement(&format!(
43+
"CREATE TABLE {} ([value] int NOT NULL)",
44+
table.quoted_sql()
45+
))
46+
.await?;
47+
48+
let result = async {
49+
client.set_bulk_load_table_lock(&table, true).await?;
50+
client
51+
.execute_statement(&bulk_load_table_lock_assertion_sql(&table, true))
52+
.await?;
53+
client.set_bulk_load_table_lock(&table, false).await?;
54+
client
55+
.execute_statement(&bulk_load_table_lock_assertion_sql(&table, false))
56+
.await?;
57+
58+
Ok::<(), Box<dyn std::error::Error>>(())
59+
}
60+
.await;
61+
62+
let drop_result = client
63+
.execute_statement(&format!("DROP TABLE IF EXISTS {}", table.quoted_sql()))
64+
.await;
65+
result?;
66+
drop_result?;
67+
68+
Ok(())
69+
}
70+
2871
#[tokio::test]
2972
async fn datetime_rounding_matches_sql_server_casts() -> TestResult<()> {
3073
let Some((connection_string, database)) = integration_config() else {
@@ -297,6 +340,15 @@ fn integration_config() -> Option<(String, String)> {
297340
Some((connection_string, database))
298341
}
299342

343+
fn bulk_load_table_lock_assertion_sql(table: &TableName, expected: bool) -> String {
344+
let expected = u8::from(expected);
345+
let table = table.quoted_sql().replace('\'', "''");
346+
347+
format!(
348+
"IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE [object_id] = OBJECT_ID(N'{table}') AND [lock_on_bulk_load] = {expected}) RAISERROR('unexpected table lock on bulk load state', 16, 1);"
349+
)
350+
}
351+
300352
fn ensure_eq<T>(actual: T, expected: T, context: &str) -> TestResult<()>
301353
where
302354
T: Debug + PartialEq,

0 commit comments

Comments
 (0)