Skip to content

Commit c6cd4a7

Browse files
george.crockerthecrockster
authored andcommitted
Replace single global app lock with per-table scoped locks (#984)
- Split AppLockResource/AppLockStatements into two levels: - GlobalAppLockStatements: used only for startup DDL (schema + GlobalState table creation) - GetTableScopedAppLockStatements(userTableId): used for all runtime operations - Table-scoped lock resource format: _az_func_TT_{userTableId} - Functions monitoring different tables can now process changes in parallel - Added unit tests for SqlTriggerConstants lock generation - Updated TriggerBinding.md documentation
1 parent 750de68 commit c6cd4a7

6 files changed

Lines changed: 162 additions & 37 deletions

File tree

docs/TriggerBinding.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ The SQL Trigger uses transactions to guarantee that changes are rolled back in t
119119

120120
Because of this, and the number of internal state tables that the trigger interacts with, there is a high chance of deadlocks occurring if two functions are attempting to make changes to the tables at the same time.
121121

122-
To avoid this from happening the trigger utilizes the [sp_getapplock](https://learn.microsoft.com/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql) statement to ensure that each transaction is processed serially. Before each statement in a transaction, sp_getapplock gets an Exclusive lock on the `_az_func_Trigger` resource - this ensures that for the duration of the transaction it is the only Azure Function that will be accessing any of the tables used.
122+
To avoid this from happening the trigger utilizes the [sp_getapplock](https://learn.microsoft.com/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql) statement to ensure that transactions targeting the same table are processed serially. There are two levels of application locks used:
123123

124-
While this helps ensure concurrency safety for Azure Functions, other queries on the system can still cause a deadlock to occur. [This guide](https://learn.microsoft.com/sql/relational-databases/sql-server-deadlocks-guide) can help troubleshoot any issues that occur and provides some suggestions for fixing these issues. You may also utilize the sp_getapplock statement yourself with the `_az_func_Trigger` resource to synchronize requests, although doing so may have a negative impact on the performance of your Functions.
124+
- **Global lock** (`_az_func_Trigger`): Used only during startup for DDL operations that create shared objects (the `az_func` schema and the `GlobalState` table). This ensures that multiple function instances starting simultaneously don't conflict when creating these shared resources.
125+
126+
- **Per-table lock** (`_az_func_TT_{userTableId}`): Used for all runtime operations. Before each statement in a transaction, `sp_getapplock` gets an Exclusive lock on a resource scoped to the specific table being monitored. This ensures that for the duration of the transaction it is the only Azure Function that will be accessing the state tables for that particular user table, while allowing functions monitoring different tables to process changes in parallel.
127+
128+
While this helps ensure concurrency safety for Azure Functions, other queries on the system can still cause a deadlock to occur. [This guide](https://learn.microsoft.com/sql/relational-databases/sql-server-deadlocks-guide) can help troubleshoot any issues that occur and provides some suggestions for fixing these issues. You may also utilize the sp_getapplock statement yourself with the `_az_func_TT_{userTableId}` resource (where `{userTableId}` is the SQL Server `OBJECT_ID` of the monitored table) to synchronize requests, although doing so may have a negative impact on the performance of your Functions.

src/TriggerBinding/SqlTableChangeMonitor.cs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ internal sealed class SqlTableChangeMonitor<T> : IDisposable
7777

7878
private readonly IDictionary<TelemetryPropertyName, string> _telemetryProps;
7979

80+
/// <summary>
81+
/// Pre-computed T-SQL statements for acquiring a table-scoped application lock.
82+
/// </summary>
83+
private readonly string _tableScopedAppLockStatements;
84+
8085
/// <summary>
8186
/// Rows that are currently being processed
8287
/// </summary>
@@ -129,6 +134,7 @@ public SqlTableChangeMonitor(
129134

130135
this._userTableId = userTableId;
131136
this._telemetryProps = telemetryProps ?? new Dictionary<TelemetryPropertyName, string>();
137+
this._tableScopedAppLockStatements = GetTableScopedAppLockStatements(userTableId);
132138

133139
// TODO: when we move to reading them exclusively from the host options, remove reading from settings.(https://github.com/Azure/azure-functions-sql-extension/issues/961)
134140
// Check if there's config settings to override the default max batch size/polling interval values
@@ -789,7 +795,7 @@ private static SqlChangeOperation GetChangeOperation(IReadOnlyDictionary<string,
789795
private SqlCommand BuildUpdateTablesPreInvocation(SqlConnection connection, SqlTransaction transaction)
790796
{
791797
string updateTablesPreInvocationQuery = $@"
792-
{AppLockStatements}
798+
{this._tableScopedAppLockStatements}
793799
794800
DECLARE @min_valid_version bigint;
795801
SET @min_valid_version = CHANGE_TRACKING_MIN_VALID_VERSION({this._userTableId});
@@ -834,7 +840,7 @@ private SqlCommand BuildGetChangesCommand(SqlConnection connection, SqlTransacti
834840
// up regardless since we know it should be processed - no need to check the change version.
835841
// Once a row is successfully processed the LeaseExpirationTime column is set to NULL.
836842
string getChangesQuery = $@"
837-
{AppLockStatements}
843+
{this._tableScopedAppLockStatements}
838844
839845
DECLARE @last_sync_version bigint;
840846
SELECT @last_sync_version = LastSyncVersion
@@ -882,7 +888,7 @@ private async Task<string> GetLeaseLockedOrMaxAttemptRowCountMessage(SqlConnecti
882888
// * NULL LeaseExpirationTime OR LeaseExpirationTime <= Current Time
883889
// * No attempts remaining (Attempt count = Max attempts)
884890
string getLeaseLockedOrMaxAttemptRowCountQuery = $@"
885-
{AppLockStatements}
891+
{this._tableScopedAppLockStatements}
886892
887893
DECLARE @last_sync_version bigint;
888894
SELECT @last_sync_version = LastSyncVersion
@@ -948,7 +954,7 @@ private SqlCommand BuildAcquireLeasesCommand(SqlConnection connection, SqlTransa
948954
const string rowDataParameter = "@rowData";
949955
// Create the merge query that will either update the rows that already exist or insert a new one if it doesn't exist
950956
string query = $@"
951-
{AppLockStatements}
957+
{this._tableScopedAppLockStatements}
952958
953959
WITH {acquireLeasesCte} AS ( SELECT * FROM OPENJSON(@rowData) WITH ({string.Join(",", cteColumnDefinitions)}) )
954960
MERGE INTO {this._bracketedLeasesTableName}
@@ -989,7 +995,7 @@ private SqlCommand BuildRenewLeasesCommand(SqlConnection connection, SqlTransact
989995
return null;
990996
}
991997
string renewLeasesQuery = $@"
992-
{AppLockStatements}
998+
{this._tableScopedAppLockStatements}
993999
9941000
UPDATE {this._bracketedLeasesTableName}
9951001
SET {LeasesTableLeaseExpirationTimeColumnName} = DATEADD(second, {LeaseIntervalInSeconds}, SYSDATETIME())
@@ -1021,7 +1027,7 @@ private SqlCommand BuildReleaseLeasesCommand(SqlConnection connection, SqlTransa
10211027
const string rowDataParameter = "@rowData";
10221028

10231029
string releaseLeasesQuery =
1024-
$@"{AppLockStatements}
1030+
$@"{this._tableScopedAppLockStatements}
10251031
10261032
WITH {releaseLeasesCte} AS ( SELECT * FROM OPENJSON(@rowData) WITH ({string.Join(",", cteColumnDefinitions)}) )
10271033
UPDATE {this._bracketedLeasesTableName}
@@ -1053,7 +1059,7 @@ private SqlCommand BuildUpdateTablesPostInvocation(SqlConnection connection, Sql
10531059
string leasesTableJoinCondition = string.Join(" AND ", this._primaryKeyColumns.Select(col => $"c.{col.name.AsBracketQuotedString()} = l.{col.name.AsBracketQuotedString()}"));
10541060

10551061
string updateTablesPostInvocationQuery = $@"
1056-
{AppLockStatements}
1062+
{this._tableScopedAppLockStatements}
10571063
10581064
DECLARE @current_last_sync_version bigint;
10591065
SELECT @current_last_sync_version = LastSyncVersion

src/TriggerBinding/SqlTriggerConstants.cs

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -38,46 +38,65 @@ internal static class SqlTriggerConstants
3838
public const string ConfigKey_SqlTrigger_MaxChangesPerWorker = "Sql_Trigger_MaxChangesPerWorker";
3939

4040
/// <summary>
41-
/// The resource name to use for getting the application lock. We use the same resource name for all instances
42-
/// of the function because there is some shared state across all the functions.
41+
/// The resource name to use for getting the global application lock. This is used for DDL operations
42+
/// during startup that touch shared objects (schema creation, GlobalState table creation).
4343
/// </summary>
44-
/// <remarks>A future improvement could be to make unique application locks for each FuncId/TableId combination so that functions
45-
/// working on different tables aren't blocking each other</remarks>
46-
public const string AppLockResource = "_az_func_Trigger";
44+
public const string GlobalAppLockResource = "_az_func_Trigger";
45+
/// <summary>
46+
/// The prefix for per-table scoped application locks. The full resource name is formed by appending
47+
/// the user table ID, e.g. "_az_func_TT_12345". This allows functions monitoring different tables
48+
/// to execute their transactions in parallel without blocking each other, while still preventing
49+
/// deadlocks between functions (or scaled-out instances) that monitor the same table.
50+
/// </summary>
51+
public const string TableAppLockResourcePrefix = "_az_func_TT_";
4752
/// <summary>
4853
/// Timeout for acquiring the application lock - 30sec chosen as a reasonable value to ensure we aren't
4954
/// hanging infinitely while also giving plenty of time for the blocking transaction to complete.
5055
/// </summary>
5156
public const int AppLockTimeoutMs = 30000;
5257

5358
/// <summary>
54-
/// T-SQL statements for getting an application lock. This is used to prevent deadlocks - primarily when multiple instances
55-
/// of a function are running in parallel.
56-
///
57-
/// The trigger heavily uses transactions to ensure atomic changes, that way if an error occurs during any step of a process we aren't left
58-
/// with an incomplete state. Because of this, locks are placed on rows that are read/modified during the transaction, but the lock isn't
59-
/// applied until the statement itself is executed. Some transactions have many statements executed in a row that touch a number of different
60-
/// tables so it's very easy for two transactions to get in a deadlock depending on the speed they execute their statements and the order they
61-
/// are processed in.
62-
///
63-
/// So to avoid this we use application locks to ensure that anytime we enter a transaction we first guarantee that we're the only transaction
64-
/// currently making any changes to the tables, which means that we're guaranteed not to have any deadlocks - albeit at the cost of speed. This
65-
/// is acceptable for now, although further investigation could be done into using multiple resources to lock on (such as a different one for each
66-
/// table) to increase the parallelization of the transactions.
59+
/// T-SQL statements for getting the global application lock. This is used only for startup DDL operations
60+
/// that create shared objects (the az_func schema and the GlobalState table).
6761
///
6862
/// See the following articles for more information on locking in MSSQL
6963
/// https://learn.microsoft.com/sql/relational-databases/sql-server-transaction-locking-and-row-versioning-guide
7064
/// https://learn.microsoft.com/sql/t-sql/statements/set-transaction-isolation-level-transact-sql
7165
/// https://learn.microsoft.com/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql
7266
/// </summary>
73-
public static readonly string AppLockStatements = $@"DECLARE @result int;
74-
EXEC @result = sp_getapplock @Resource = '{AppLockResource}',
67+
public static readonly string GlobalAppLockStatements = $@"DECLARE @result int;
68+
EXEC @result = sp_getapplock @Resource = '{GlobalAppLockResource}',
69+
@LockMode = 'Exclusive',
70+
@LockTimeout = {AppLockTimeoutMs}
71+
IF @result < 0
72+
BEGIN
73+
RAISERROR('Unable to acquire exclusive lock on {GlobalAppLockResource}. Result = %d', 16, 1, @result)
74+
END;";
75+
76+
/// <summary>
77+
/// Generates T-SQL statements for getting a per-table scoped application lock. This is used for all
78+
/// runtime operations that only touch data specific to a single table (GlobalState row, leases table,
79+
/// change tracking queries). By scoping the lock to the table level, functions monitoring different
80+
/// tables can process changes in parallel without blocking each other.
81+
///
82+
/// The lock is still needed at the table level to prevent deadlocks when multiple scaled-out instances
83+
/// or multiple functions monitor the same table, since they share GlobalState rows and may access
84+
/// overlapping change tracking data.
85+
/// </summary>
86+
/// <param name="userTableId">The SQL Server object ID of the user table</param>
87+
/// <returns>T-SQL statements that acquire an exclusive app lock scoped to the given table</returns>
88+
public static string GetTableScopedAppLockStatements(int userTableId)
89+
{
90+
string resource = $"{TableAppLockResourcePrefix}{userTableId}";
91+
return $@"DECLARE @result int;
92+
EXEC @result = sp_getapplock @Resource = '{resource}',
7593
@LockMode = 'Exclusive',
7694
@LockTimeout = {AppLockTimeoutMs}
7795
IF @result < 0
7896
BEGIN
79-
RAISERROR('Unable to acquire exclusive lock on {AppLockResource}. Result = %d', 16, 1, @result)
97+
RAISERROR('Unable to acquire exclusive lock on {resource}. Result = %d', 16, 1, @result)
8098
END;";
99+
}
81100

82101
/// <summary>
83102
/// There is already an object named '%.*ls' in the database.

src/TriggerBinding/SqlTriggerListener.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
146146
createdSchemaDurationMs = await this.CreateSchemaAsync(connection, transaction, cancellationToken);
147147
createGlobalStateTableDurationMs = await this.CreateGlobalStateTableAsync(connection, transaction, cancellationToken);
148148
insertGlobalStateTableRowDurationMs = await this.InsertGlobalStateTableRowAsync(connection, transaction, userTableId, cancellationToken);
149-
createLeasesTableDurationMs = await this.CreateLeasesTableAsync(connection, transaction, bracketedLeasesTableName, primaryKeyColumns, cancellationToken);
149+
createLeasesTableDurationMs = await this.CreateLeasesTableAsync(connection, transaction, bracketedLeasesTableName, primaryKeyColumns, userTableId, cancellationToken);
150150
transaction.Commit();
151151
}
152152

@@ -283,7 +283,7 @@ FROM sys.columns AS c
283283
private async Task<long> CreateSchemaAsync(SqlConnection connection, SqlTransaction transaction, CancellationToken cancellationToken)
284284
{
285285
string createSchemaQuery = $@"
286-
{AppLockStatements}
286+
{GlobalAppLockStatements}
287287
288288
IF SCHEMA_ID(N'{SchemaName}') IS NULL
289289
EXEC ('CREATE SCHEMA {SchemaName}');
@@ -328,7 +328,7 @@ IF SCHEMA_ID(N'{SchemaName}') IS NULL
328328
private async Task<long> CreateGlobalStateTableAsync(SqlConnection connection, SqlTransaction transaction, CancellationToken cancellationToken)
329329
{
330330
string createGlobalStateTableQuery = $@"
331-
{AppLockStatements}
331+
{GlobalAppLockStatements}
332332
333333
IF OBJECT_ID(N'{GlobalStateTableName}', 'U') IS NULL
334334
CREATE TABLE {GlobalStateTableName} (
@@ -400,8 +400,9 @@ private async Task<long> InsertGlobalStateTableRowAsync(SqlConnection connection
400400
}
401401
}
402402

403+
string tableScopedAppLockStatements = GetTableScopedAppLockStatements(userTableId);
403404
string insertRowGlobalStateTableQuery = $@"
404-
{AppLockStatements}
405+
{tableScopedAppLockStatements}
405406
-- For back compatibility copy the lastSyncVersion from _hostIdFunctionId if it exists.
406407
IF NOT EXISTS (
407408
SELECT * FROM {GlobalStateTableName}
@@ -440,13 +441,15 @@ INSERT INTO {GlobalStateTableName}
440441
/// <param name="transaction">The transaction wrapping this command</param>
441442
/// <param name="leasesTableName">The name of the leases table to create</param>
442443
/// <param name="primaryKeyColumns">The primary keys of the user table this leases table is for</param>
444+
/// <param name="userTableId">The ID of the table being watched</param>
443445
/// <param name="cancellationToken">Cancellation token to pass to the command</param>
444446
/// <returns>The time taken in ms to execute the command</returns>
445447
private async Task<long> CreateLeasesTableAsync(
446448
SqlConnection connection,
447449
SqlTransaction transaction,
448450
string leasesTableName,
449451
IReadOnlyList<(string name, string type)> primaryKeyColumns,
452+
int userTableId,
450453
CancellationToken cancellationToken)
451454
{
452455
string primaryKeysWithTypes = string.Join(", ", primaryKeyColumns.Select(col => $"{col.name.AsBracketQuotedString()} {col.type}"));
@@ -455,8 +458,9 @@ private async Task<long> CreateLeasesTableAsync(
455458
// We should only migrate the lease table from the old hostId based one to the newer WEBSITE_SITE_NAME one if
456459
// we're actually using the WEBSITE_SITE_NAME one (e.g. leasesTableName is different)
457460
bool shouldMigrateOldLeasesTable = !string.IsNullOrEmpty(oldLeasesTableName) && oldLeasesTableName != leasesTableName;
461+
string tableScopedAppLockStatements = GetTableScopedAppLockStatements(userTableId);
458462
string createLeasesTableQuery = shouldMigrateOldLeasesTable ? $@"
459-
{AppLockStatements}
463+
{tableScopedAppLockStatements}
460464
461465
IF OBJECT_ID(N'{leasesTableName}', 'U') IS NULL
462466
BEGIN
@@ -479,7 +483,7 @@ INSERT INTO {leasesTableName}
479483
End
480484
" :
481485
$@"
482-
{AppLockStatements}
486+
{tableScopedAppLockStatements}
483487
484488
IF OBJECT_ID(N'{leasesTableName}', 'U') IS NULL
485489
CREATE TABLE {leasesTableName} (

src/TriggerBinding/SqlTriggerMetricsProvider.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@ private SqlCommand BuildGetUnprocessedChangesCommand(SqlConnection connection, S
101101
{
102102
string leasesTableJoinCondition = string.Join(" AND ", primaryKeyColumns.Select(col => $"c.{col.name.AsBracketQuotedString()} = l.{col.name.AsBracketQuotedString()}"));
103103
string bracketedLeasesTableName = GetBracketedLeasesTableName(this._userDefinedLeasesTableName, this._userFunctionId, userTableId);
104+
string tableScopedAppLockStatements = GetTableScopedAppLockStatements(userTableId);
104105
string getUnprocessedChangesQuery = $@"
105-
{AppLockStatements}
106+
{tableScopedAppLockStatements}
106107
107108
DECLARE @last_sync_version bigint;
108109
SELECT @last_sync_version = LastSyncVersion

0 commit comments

Comments
 (0)