Skip to content

Commit 863e16c

Browse files
thecrockstergeorge.crockerCopilotCharles-Gagnon
authored
Make App Lock timeout configurable (#982) (#1213)
* Make App Lock timeout configurable (#982) Add configurable AppLockTimeoutMs setting for the SQL trigger binding's application lock timeout. Previously hardcoded to 30 seconds, the timeout can now be configured via: - Sql_Trigger_AppLockTimeoutMs app setting - AppLockTimeoutMs property in host.json SqlOptions The default remains 30000ms (30 seconds) with a minimum of 1000ms (1 second). Changes: - Convert static AppLockStatements field to GetAppLockStatements(int) method - Add AppLockTimeoutMs property to SqlOptions with validation - Read config from app settings with SqlOptions fallback in SqlTableChangeMonitor, SqlTriggerListener, and SqlScalerProvider - Pass appLockTimeoutMs through ScaleMonitor/TargetScaler/MetricsProvider chain - Add telemetry for HasConfiguredAppLockTimeout and AppLockTimeoutMs - Add unit tests for new config and GetAppLockStatements method - Update documentation in BindingsOverview.md and TriggerBinding.md Closes #982 * Address review feedback: rename MinimumAppLockTimeoutMs and add validation - Rename DefaultMinimumAppLockTimeoutMs to MinimumAppLockTimeoutMs (public const) to clarify it represents the minimum accepted value, not a default - Add min-value validation in SqlScalerProvider for app-settings value - Tighten validation in SqlTableChangeMonitor and SqlTriggerListener to check against MinimumAppLockTimeoutMs instead of just > 0 Co-authored-by: Copilot <[email protected]> * Address PR #1213 review comments - SqlScalerProvider: Use nullable int for AppLockTimeoutMs config to distinguish 'not configured' from 'explicitly set to 0' - SqlTriggerListener: Add HasConfiguredAppLockTimeout property and AppLockTimeoutMs measure to StartListener telemetry event - SqlTriggerConstants: Merge duplicate XML summary doc comments on GetAppLockStatements method - SqlOptionsTests: Add test for negative AppLockTimeoutMs values Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: george.crocker <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Charles Gagnon <[email protected]>
1 parent 45b7804 commit 863e16c

15 files changed

Lines changed: 197 additions & 35 deletions

docs/BindingsOverview.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- [Sql\_Trigger\_MaxBatchSize](#sql_trigger_maxbatchsize)
2121
- [Sql\_Trigger\_PollingIntervalMs](#sql_trigger_pollingintervalms)
2222
- [Sql\_Trigger\_MaxChangesPerWorker](#sql_trigger_maxchangesperworker)
23+
- [Sql\_Trigger\_AppLockTimeoutMs](#sql_trigger_applocktimeoutms)
2324
- [WEBSITE\_SITE\_NAME](#website_site_name)
2425
- [Scaling for Trigger Bindings](#scaling-for-trigger-bindings)
2526
- [Retry support for Trigger Bindings](#retry-support-for-trigger-bindings)
@@ -153,6 +154,10 @@ The delay in milliseconds between processing each batch of changes.
153154

154155
The upper limit on the number of pending changes in the user table that are allowed per application-worker. If the count of changes exceeds this limit, it may result in a scale out. The setting only applies for Azure Function Apps with runtime driven scaling enabled. See the [Scaling](#scaling-for-trigger-bindings) section for more information.
155156

157+
#### Sql_Trigger_AppLockTimeoutMs
158+
159+
The timeout in milliseconds for acquiring the application lock used to prevent deadlocks when processing changes. The default value is 30000 (30 seconds). The minimum allowed value is 1000 (1 second).
160+
156161
#### WEBSITE_SITE_NAME
157162

158163
If this setting exists, it will be used to generate a unique identifier for the function that is used for tracking function state. If not specified, this unique identifier will be generated from the [IHostIdProvider.GetHostIdAsync](https://github.com/Azure/azure-webjobs-sdk/blob/dev/src/Microsoft.Azure.WebJobs.Host/Executors/IHostIdProvider.cs#L14).

docs/TriggerBinding.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,6 @@ 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 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. The timeout for acquiring this lock defaults to 30 seconds and can be configured using the `Sql_Trigger_AppLockTimeoutMs` [application setting](./BindingsOverview.md#sql_trigger_applocktimeoutms) or the `AppLockTimeoutMs` host.json option.
123123

124124
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.

src/Common/SqlOptions.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public class SqlOptions : IOptionsFormatter
2020
public const int DefaultPollingIntervalMs = 1000;
2121
private const int DefaultMinimumPollingIntervalMs = 100;
2222
public const int DefaultMaxChangesPerWorker = 1000;
23+
public const int DefaultAppLockTimeoutMs = 30000;
24+
public const int MinimumAppLockTimeoutMs = 1000;
2325
/// <summary>
2426
/// Maximum number of changes to process in each iteration of the loop
2527
/// </summary>
@@ -30,6 +32,7 @@ public class SqlOptions : IOptionsFormatter
3032
private int _pollingIntervalMs = DefaultPollingIntervalMs;
3133
private readonly int _minPollingInterval = DefaultMinimumPollingIntervalMs;
3234
private int _maxChangesPerWorker = DefaultMaxChangesPerWorker;
35+
private int _appLockTimeoutMs = DefaultAppLockTimeoutMs;
3336

3437
/// <summary>
3538
/// Initializes a new instance of the <see cref="SqlOptions"/> class.
@@ -97,6 +100,27 @@ public int MaxChangesPerWorker
97100
}
98101
}
99102

103+
/// <summary>
104+
/// Gets or sets the timeout in milliseconds for acquiring the application lock.
105+
/// The default is 30000 (30 seconds).
106+
/// </summary>
107+
public int AppLockTimeoutMs
108+
{
109+
get => this._appLockTimeoutMs;
110+
111+
set
112+
{
113+
if (value < MinimumAppLockTimeoutMs)
114+
{
115+
string message = string.Format(System.Globalization.CultureInfo.CurrentCulture,
116+
"AppLockTimeoutMs must not be less than {0}Ms.", MinimumAppLockTimeoutMs);
117+
throw new ArgumentException(message, nameof(value));
118+
}
119+
120+
this._appLockTimeoutMs = value;
121+
}
122+
}
123+
100124
/// <inheritdoc/>
101125
[EditorBrowsable(EditorBrowsableState.Never)]
102126
string IOptionsFormatter.Format()
@@ -105,7 +129,8 @@ string IOptionsFormatter.Format()
105129
{
106130
{ nameof(this.MaxBatchSize), this.MaxBatchSize },
107131
{ nameof(this.PollingIntervalMs), this.PollingIntervalMs },
108-
{ nameof(this.MaxChangesPerWorker), this.MaxChangesPerWorker }
132+
{ nameof(this.MaxChangesPerWorker), this.MaxChangesPerWorker },
133+
{ nameof(this.AppLockTimeoutMs), this.AppLockTimeoutMs }
109134
};
110135

111136
return options.ToString(Formatting.Indented);
@@ -117,7 +142,8 @@ internal SqlOptions Clone()
117142
{
118143
_maxBatchSize = this._maxBatchSize,
119144
_pollingIntervalMs = this._pollingIntervalMs,
120-
_maxChangesPerWorker = this._maxChangesPerWorker
145+
_maxChangesPerWorker = this._maxChangesPerWorker,
146+
_appLockTimeoutMs = this._appLockTimeoutMs
121147
};
122148
return copy;
123149
}

src/Telemetry/Telemetry.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ public enum TelemetryPropertyName
365365
HasConfiguredMaxBatchSize,
366366
HasConfiguredMaxChangesPerWorker,
367367
HasConfiguredPollingInterval,
368+
HasConfiguredAppLockTimeout,
368369
LeasesTableName,
369370
QueryType,
370371
ScaleRecommendation,
@@ -384,6 +385,7 @@ public enum TelemetryPropertyName
384385
public enum TelemetryMeasureName
385386
{
386387
AcquireLeasesDurationMs,
388+
AppLockTimeoutMs,
387389
BatchCount,
388390
BatchSize,
389391
CommandDurationMs,

src/TriggerBinding/SqlScalerProvider.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,18 @@ public SqlScalerProvider(IServiceProvider serviceProvider, TriggerMetadata trigg
3838
int configAppSettingsMaxChangesPerWorker = config.GetValue<int>(SqlTriggerConstants.ConfigKey_SqlTrigger_MaxChangesPerWorker);
3939
// Override the maxChangesPerWorker value from config if the value is set in the trigger appsettings
4040
int maxChangesPerWorker = configAppSettingsMaxChangesPerWorker != 0 ? configAppSettingsMaxChangesPerWorker : configOptionsMaxChangesPerWorker != 0 ? configOptionsMaxChangesPerWorker : SqlOptions.DefaultMaxChangesPerWorker;
41+
int configOptionsAppLockTimeoutMs = options.Value.AppLockTimeoutMs;
42+
int? configAppSettingsAppLockTimeoutMs = config.GetValue<int?>(SqlTriggerConstants.ConfigKey_SqlTrigger_AppLockTimeoutMs);
43+
int appLockTimeoutMs = configAppSettingsAppLockTimeoutMs ?? (configOptionsAppLockTimeoutMs != 0 ? configOptionsAppLockTimeoutMs : SqlOptions.DefaultAppLockTimeoutMs);
44+
if (appLockTimeoutMs < SqlOptions.MinimumAppLockTimeoutMs)
45+
{
46+
throw new InvalidOperationException($"Invalid value for configuration setting '{SqlTriggerConstants.ConfigKey_SqlTrigger_AppLockTimeoutMs}'. Value must not be less than {SqlOptions.MinimumAppLockTimeoutMs}ms.");
47+
}
4148
string userDefinedLeasesTableName = sqlMetadata.LeasesTableName;
4249
string userFunctionId = sqlMetadata.UserFunctionId;
4350

44-
this._scaleMonitor = new SqlTriggerScaleMonitor(userFunctionId, userTable, userDefinedLeasesTableName, connectionString, maxChangesPerWorker, logger);
45-
this._targetScaler = new SqlTriggerTargetScaler(userFunctionId, userTable, userDefinedLeasesTableName, connectionString, maxChangesPerWorker, logger);
51+
this._scaleMonitor = new SqlTriggerScaleMonitor(userFunctionId, userTable, userDefinedLeasesTableName, connectionString, maxChangesPerWorker, appLockTimeoutMs, logger);
52+
this._targetScaler = new SqlTriggerTargetScaler(userFunctionId, userTable, userDefinedLeasesTableName, connectionString, maxChangesPerWorker, appLockTimeoutMs, logger);
4653
}
4754

4855
public IScaleMonitor GetMonitor()

src/TriggerBinding/SqlTableChangeMonitor.cs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ internal sealed class SqlTableChangeMonitor<T> : IDisposable
6565
/// Delay in ms between processing each batch of changes
6666
/// </summary>
6767
private readonly int _pollingIntervalInMs;
68+
private readonly string _appLockStatements;
6869
private readonly CancellationTokenSource _cancellationTokenSourceCheckForChanges = new CancellationTokenSource();
6970
private readonly CancellationTokenSource _cancellationTokenSourceRenewLeases = new CancellationTokenSource();
7071
private CancellationTokenSource _cancellationTokenSourceExecutor = new CancellationTokenSource();
@@ -144,15 +145,24 @@ public SqlTableChangeMonitor(
144145
{
145146
throw new InvalidOperationException($"Invalid value for configuration setting '{ConfigKey_SqlTrigger_PollingInterval}'. Ensure that the value is a positive integer.");
146147
}
148+
int? configuredAppLockTimeout = configuration.GetValue<int?>(ConfigKey_SqlTrigger_AppLockTimeoutMs);
149+
int appLockTimeoutMs = configuredAppLockTimeout ?? this._sqlOptions.AppLockTimeoutMs;
150+
if (appLockTimeoutMs < SqlOptions.MinimumAppLockTimeoutMs)
151+
{
152+
throw new InvalidOperationException($"Invalid value for configuration setting '{ConfigKey_SqlTrigger_AppLockTimeoutMs}'. Value must not be less than {SqlOptions.MinimumAppLockTimeoutMs}ms.");
153+
}
154+
this._appLockStatements = GetAppLockStatements(appLockTimeoutMs);
147155
TelemetryInstance.TrackEvent(
148156
TelemetryEventName.TriggerMonitorStart,
149157
new Dictionary<TelemetryPropertyName, string>(telemetryProps) {
150158
{ TelemetryPropertyName.HasConfiguredMaxBatchSize, (configuredMaxBatchSize != null).ToString() },
151159
{ TelemetryPropertyName.HasConfiguredPollingInterval, (configuredPollingInterval != null).ToString() },
160+
{ TelemetryPropertyName.HasConfiguredAppLockTimeout, (configuredAppLockTimeout != null).ToString() },
152161
},
153162
new Dictionary<TelemetryMeasureName, double>() {
154163
{ TelemetryMeasureName.MaxBatchSize, this._maxBatchSize },
155-
{ TelemetryMeasureName.PollingIntervalMs, this._pollingIntervalInMs }
164+
{ TelemetryMeasureName.PollingIntervalMs, this._pollingIntervalInMs },
165+
{ TelemetryMeasureName.AppLockTimeoutMs, appLockTimeoutMs }
156166
}
157167
);
158168

@@ -789,7 +799,7 @@ private static SqlChangeOperation GetChangeOperation(IReadOnlyDictionary<string,
789799
private SqlCommand BuildUpdateTablesPreInvocation(SqlConnection connection, SqlTransaction transaction)
790800
{
791801
string updateTablesPreInvocationQuery = $@"
792-
{AppLockStatements}
802+
{this._appLockStatements}
793803
794804
DECLARE @min_valid_version bigint;
795805
SET @min_valid_version = CHANGE_TRACKING_MIN_VALID_VERSION({this._userTableId});
@@ -834,7 +844,7 @@ private SqlCommand BuildGetChangesCommand(SqlConnection connection, SqlTransacti
834844
// up regardless since we know it should be processed - no need to check the change version.
835845
// Once a row is successfully processed the LeaseExpirationTime column is set to NULL.
836846
string getChangesQuery = $@"
837-
{AppLockStatements}
847+
{this._appLockStatements}
838848
839849
DECLARE @last_sync_version bigint;
840850
SELECT @last_sync_version = LastSyncVersion
@@ -882,7 +892,7 @@ private async Task<string> GetLeaseLockedOrMaxAttemptRowCountMessage(SqlConnecti
882892
// * NULL LeaseExpirationTime OR LeaseExpirationTime <= Current Time
883893
// * No attempts remaining (Attempt count = Max attempts)
884894
string getLeaseLockedOrMaxAttemptRowCountQuery = $@"
885-
{AppLockStatements}
895+
{this._appLockStatements}
886896
887897
DECLARE @last_sync_version bigint;
888898
SELECT @last_sync_version = LastSyncVersion
@@ -948,7 +958,7 @@ private SqlCommand BuildAcquireLeasesCommand(SqlConnection connection, SqlTransa
948958
const string rowDataParameter = "@rowData";
949959
// Create the merge query that will either update the rows that already exist or insert a new one if it doesn't exist
950960
string query = $@"
951-
{AppLockStatements}
961+
{this._appLockStatements}
952962
953963
WITH {acquireLeasesCte} AS ( SELECT * FROM OPENJSON(@rowData) WITH ({string.Join(",", cteColumnDefinitions)}) )
954964
MERGE INTO {this._bracketedLeasesTableName}
@@ -989,7 +999,7 @@ private SqlCommand BuildRenewLeasesCommand(SqlConnection connection, SqlTransact
989999
return null;
9901000
}
9911001
string renewLeasesQuery = $@"
992-
{AppLockStatements}
1002+
{this._appLockStatements}
9931003
9941004
UPDATE {this._bracketedLeasesTableName}
9951005
SET {LeasesTableLeaseExpirationTimeColumnName} = DATEADD(second, {LeaseIntervalInSeconds}, SYSDATETIME())
@@ -1021,7 +1031,7 @@ private SqlCommand BuildReleaseLeasesCommand(SqlConnection connection, SqlTransa
10211031
const string rowDataParameter = "@rowData";
10221032

10231033
string releaseLeasesQuery =
1024-
$@"{AppLockStatements}
1034+
$@"{this._appLockStatements}
10251035
10261036
WITH {releaseLeasesCte} AS ( SELECT * FROM OPENJSON(@rowData) WITH ({string.Join(",", cteColumnDefinitions)}) )
10271037
UPDATE {this._bracketedLeasesTableName}
@@ -1053,7 +1063,7 @@ private SqlCommand BuildUpdateTablesPostInvocation(SqlConnection connection, Sql
10531063
string leasesTableJoinCondition = string.Join(" AND ", this._primaryKeyColumns.Select(col => $"c.{col.name.AsBracketQuotedString()} = l.{col.name.AsBracketQuotedString()}"));
10541064

10551065
string updateTablesPostInvocationQuery = $@"
1056-
{AppLockStatements}
1066+
{this._appLockStatements}
10571067
10581068
DECLARE @current_last_sync_version bigint;
10591069
SELECT @current_last_sync_version = LastSyncVersion

src/TriggerBinding/SqlTriggerConstants.cs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ internal static class SqlTriggerConstants
3636
public const string ConfigKey_SqlTrigger_MaxBatchSize = "Sql_Trigger_MaxBatchSize";
3737
public const string ConfigKey_SqlTrigger_PollingInterval = "Sql_Trigger_PollingIntervalMs";
3838
public const string ConfigKey_SqlTrigger_MaxChangesPerWorker = "Sql_Trigger_MaxChangesPerWorker";
39+
public const string ConfigKey_SqlTrigger_AppLockTimeoutMs = "Sql_Trigger_AppLockTimeoutMs";
3940

4041
/// <summary>
4142
/// The resource name to use for getting the application lock. We use the same resource name for all instances
@@ -45,13 +46,15 @@ internal static class SqlTriggerConstants
4546
/// working on different tables aren't blocking each other</remarks>
4647
public const string AppLockResource = "_az_func_Trigger";
4748
/// <summary>
48-
/// Timeout for acquiring the application lock - 30sec chosen as a reasonable value to ensure we aren't
49+
/// Default timeout for acquiring the application lock - 30sec chosen as a reasonable value to ensure we aren't
4950
/// hanging infinitely while also giving plenty of time for the blocking transaction to complete.
5051
/// </summary>
51-
public const int AppLockTimeoutMs = 30000;
52+
public const int DefaultAppLockTimeoutMs = 30000;
5253

5354
/// <summary>
54-
/// T-SQL statements for getting an application lock. This is used to prevent deadlocks - primarily when multiple instances
55+
/// Generates T-SQL statements for getting an application lock with the specified timeout.
56+
///
57+
/// This is used to prevent deadlocks - primarily when multiple instances
5558
/// of a function are running in parallel.
5659
///
5760
/// 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
@@ -70,14 +73,19 @@ internal static class SqlTriggerConstants
7073
/// https://learn.microsoft.com/sql/t-sql/statements/set-transaction-isolation-level-transact-sql
7174
/// https://learn.microsoft.com/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql
7275
/// </summary>
73-
public static readonly string AppLockStatements = $@"DECLARE @result int;
76+
/// <param name="appLockTimeoutMs">Timeout in milliseconds for acquiring the application lock</param>
77+
/// <returns>T-SQL statements for acquiring the application lock</returns>
78+
public static string GetAppLockStatements(int appLockTimeoutMs)
79+
{
80+
return $@"DECLARE @result int;
7481
EXEC @result = sp_getapplock @Resource = '{AppLockResource}',
7582
@LockMode = 'Exclusive',
76-
@LockTimeout = {AppLockTimeoutMs}
83+
@LockTimeout = {appLockTimeoutMs}
7784
IF @result < 0
7885
BEGIN
7986
RAISERROR('Unable to acquire exclusive lock on {AppLockResource}. Result = %d', 16, 1, @result)
8087
END;";
88+
}
8189

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

0 commit comments

Comments
 (0)