diff --git a/builds/azure-pipelines/build-pr.yml b/builds/azure-pipelines/build-pr.yml index 078a4f9d1..c1081afb7 100644 --- a/builds/azure-pipelines/build-pr.yml +++ b/builds/azure-pipelines/build-pr.yml @@ -37,7 +37,7 @@ stages: workspace: clean: all - timeoutInMinutes: '120' + timeoutInMinutes: '90' steps: - template: 'template-steps-build-test.yml' diff --git a/docs/BindingsOverview.md b/docs/BindingsOverview.md index bd6c477b6..c3631956e 100644 --- a/docs/BindingsOverview.md +++ b/docs/BindingsOverview.md @@ -152,6 +152,13 @@ The delay in milliseconds between processing each batch of changes. 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. +#### WEBSITE_SITE_NAME + +The unique name used in creating the lease tables. The local apps depend on this setting for creating unique leases tables, please give a unique name for each app. + > **NOTE:** If the setting is re-used accross apps, having the same function name could cause the functions to use the same lease tables and the function runs to not work as expected. + > **NOTE:** If you have 2 different SQL trigger functions with same functionName locally, not having WEBSITE_SITE_NAME would mean that the same leasees table would be used for both triggers resulting in only one of the functions being triggered. + > **NOTE:** This is a read-only variable that is provided by the azure environment variables for deployed functions and the user provided value will be overridden. Refer to [Environment variables](https://learn.microsoft.com/azure/app-service/reference-app-settings?tabs=kudu%2Cdotnet#app-environment) for apps. + ### Scaling for Trigger Bindings If your application containing functions with SQL trigger bindings is running as an Azure function app, it will be scaled automatically based on the amount of changes that are pending to be processed in the user table. As of today, we only support scaling of function apps running in Elastic Premium plan with 'Runtime Scale Monitoring' enabled. To enable scaling, you will need to go the function app resource's page on Azure Portal, then to Configuration > 'Function runtime settings' and turn on 'Runtime Scale Monitoring'. diff --git a/samples/samples-csharp/local.settings.json b/samples/samples-csharp/local.settings.json index 2ee8fdfec..11a573a93 100644 --- a/samples/samples-csharp/local.settings.json +++ b/samples/samples-csharp/local.settings.json @@ -4,6 +4,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "dotnet", "SqlConnectionString": "", + "WEBSITE_SITE_NAME": "SamplesCSharp", "Sp_SelectCost": "SelectProductsCost", "ProductCost": 100 } diff --git a/samples/samples-csx/local.settings.json b/samples/samples-csx/local.settings.json index 2ee8fdfec..2780f8f8d 100644 --- a/samples/samples-csx/local.settings.json +++ b/samples/samples-csx/local.settings.json @@ -4,6 +4,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "dotnet", "SqlConnectionString": "", + "WEBSITE_SITE_NAME": "SamplesCsx", "Sp_SelectCost": "SelectProductsCost", "ProductCost": 100 } diff --git a/samples/samples-java/local.settings.json b/samples/samples-java/local.settings.json index 520adf666..49701c41c 100644 --- a/samples/samples-java/local.settings.json +++ b/samples/samples-java/local.settings.json @@ -4,6 +4,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "java", "SqlConnectionString": "", + "WEBSITE_SITE_NAME": "SamplesJava", "Sp_SelectCost": "SelectProductsCost", "ProductCost": 100 } diff --git a/samples/samples-js-v4/local.settings.json b/samples/samples-js-v4/local.settings.json index 2609bea7d..9765f08a6 100644 --- a/samples/samples-js-v4/local.settings.json +++ b/samples/samples-js-v4/local.settings.json @@ -4,6 +4,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "node", "AzureWebJobsFeatureFlags": "EnableWorkerIndexing", - "SqlConnectionString": "" + "SqlConnectionString": "", + "WEBSITE_SITE_NAME": "SamplesNodeV4" } } \ No newline at end of file diff --git a/samples/samples-js/local.settings.json b/samples/samples-js/local.settings.json index d420cc65e..419f9a0fc 100644 --- a/samples/samples-js/local.settings.json +++ b/samples/samples-js/local.settings.json @@ -4,6 +4,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "node", "SqlConnectionString": "", + "WEBSITE_SITE_NAME": "SamplesJavascript", "Sp_SelectCost": "SelectProductsCost", "ProductCost": 100 } diff --git a/samples/samples-outofproc/local.settings.json b/samples/samples-outofproc/local.settings.json index fda4854dd..b941acc76 100644 --- a/samples/samples-outofproc/local.settings.json +++ b/samples/samples-outofproc/local.settings.json @@ -4,6 +4,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", "SqlConnectionString": "", + "WEBSITE_SITE_NAME": "SamplesOOP", "Sp_SelectCost": "SelectProductsCost", "ProductCost": 100 } diff --git a/samples/samples-powershell/local.settings.json b/samples/samples-powershell/local.settings.json index aac210146..6e4d9ba9b 100644 --- a/samples/samples-powershell/local.settings.json +++ b/samples/samples-powershell/local.settings.json @@ -5,6 +5,7 @@ "FUNCTIONS_WORKER_RUNTIME": "powershell", "FUNCTIONS_WORKER_RUNTIME_VERSION" : "~7.2", "SqlConnectionString": "", + "WEBSITE_SITE_NAME": "SamplesPowershell", "Sp_SelectCost": "SelectProductsCost", "ProductCost": 100 } diff --git a/samples/samples-python-v2/local.settings.json b/samples/samples-python-v2/local.settings.json index 9f6f238ce..881aeeed5 100644 --- a/samples/samples-python-v2/local.settings.json +++ b/samples/samples-python-v2/local.settings.json @@ -5,6 +5,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "AzureWebJobsFeatureFlags": "EnableWorkerIndexing", "SqlConnectionString": "", + "WEBSITE_SITE_NAME": "SamplesPythonV2", "PYTHON_ISOLATE_WORKER_DEPENDENCIES": "1" } } \ No newline at end of file diff --git a/samples/samples-python/local.settings.json b/samples/samples-python/local.settings.json index 687701584..38d7dff61 100644 --- a/samples/samples-python/local.settings.json +++ b/samples/samples-python/local.settings.json @@ -4,6 +4,7 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "python", "SqlConnectionString": "", + "WEBSITE_SITE_NAME": "SamplesPython", "Sp_SelectCost": "SelectProductsCost", "ProductCost": 100 } diff --git a/src/SqlBindingUtilities.cs b/src/SqlBindingUtilities.cs index 59fec983e..0a7553bf6 100644 --- a/src/SqlBindingUtilities.cs +++ b/src/SqlBindingUtilities.cs @@ -60,7 +60,13 @@ public static string GetWebSiteName(IConfiguration configuration) { throw new ArgumentNullException(nameof(configuration)); } - return configuration.GetConnectionStringOrSetting(SqlBindingConstants.WEBSITENAME); + string websitename = configuration.GetConnectionStringOrSetting(SqlBindingConstants.WEBSITENAME); + // We require a WEBSITE_SITE_NAME for avoiding duplicates if users use the same function name accross apps. + if (string.IsNullOrEmpty(websitename)) + { + throw new ArgumentException($"WEBSITE_SITE_NAME cannot be null or empty in your function app settings, please update the setting with a string value. Please refer to https://github.com/Azure/azure-functions-sql-extension/blob/main/docs/BindingsOverview.md#website_site_name for more information."); + } + return websitename; } /// diff --git a/src/TriggerBinding/SqlTriggerBinding.cs b/src/TriggerBinding/SqlTriggerBinding.cs index 323e8fa3d..326a21276 100644 --- a/src/TriggerBinding/SqlTriggerBinding.cs +++ b/src/TriggerBinding/SqlTriggerBinding.cs @@ -107,7 +107,8 @@ private string GetUserFunctionId() string websiteName = SqlBindingUtilities.GetWebSiteName(this._configuration); var methodInfo = (MethodInfo)this._parameter.Member; - string functionName = $"{methodInfo.DeclaringType.FullName}.{methodInfo.Name}"; + // Get the function name from FunctionName attribute for .NET functions and methodInfo.Name for non .Net + string functionName = ((FunctionNameAttribute)methodInfo.GetCustomAttribute(typeof(FunctionNameAttribute)))?.Name ?? $"{methodInfo.Name}"; using (var sha256 = SHA256.Create()) { diff --git a/test/Common/TestUtils.cs b/test/Common/TestUtils.cs index 43f045f86..b3f5fa224 100644 --- a/test/Common/TestUtils.cs +++ b/test/Common/TestUtils.cs @@ -122,8 +122,9 @@ public static void SetupDatabase(out string MasterConnectionString, out string D connectionStringBuilder.InitialCatalog = databaseName; - // Set SqlConnectionString env var for the tests to use + // Set SqlConnectionString and WEBSITE_SITE_NAME env variables for the tests to use Environment.SetEnvironmentVariable("SqlConnectionString", connectionStringBuilder.ToString()); + Environment.SetEnvironmentVariable("WEBSITE_SITE_NAME", "TestSqlFunction"); MasterConnectionString = masterConnectionString; DatabaseName = databaseName; } @@ -357,18 +358,21 @@ public static DataReceivedEventHandler CreateOutputReceievedHandler(TaskCompleti { return (object sender, DataReceivedEventArgs e) => { - Match match = Regex.Match(e.Data, regex); - if (match.Success) + if (e != null && e.Data != null) { - // We found the line so now check that the group matches our expected value - string actualValue = match.Groups[1].Value; - if (actualValue == expectedValue) + Match match = Regex.Match(e.Data, regex); + if (match.Success) { - taskCompletionSource.SetResult(true); - } - else - { - taskCompletionSource.SetException(new Exception($"Expected {valueName} value of {expectedValue} but got value {actualValue}")); + // We found the line so now check that the group matches our expected value + string actualValue = match.Groups[1].Value; + if (actualValue == expectedValue) + { + taskCompletionSource.SetResult(true); + } + else + { + taskCompletionSource.SetException(new Exception($"Expected {valueName} value of {expectedValue} but got value {actualValue}")); + } } } }; diff --git a/test/Integration/SqlTriggerBindingIntegrationTests.cs b/test/Integration/SqlTriggerBindingIntegrationTests.cs index 43c73c116..df0350456 100644 --- a/test/Integration/SqlTriggerBindingIntegrationTests.cs +++ b/test/Integration/SqlTriggerBindingIntegrationTests.cs @@ -981,5 +981,68 @@ public async Task NewUserFunctionId_Migration_Test() Assert.True(1 == (int)this.ExecuteScalar($@"SELECT 1 FROM {GlobalStateTableName} WHERE UserFunctionID = N'{userFunctionId}'"), $"{GlobalStateTableName} should have {userFunctionId} row on successful migration"); Assert.True(lastSyncVersion == (long)this.ExecuteScalar($@"SELECT LastSyncVersion FROM {GlobalStateTableName} WHERE UserFunctionID = N'{userFunctionId}'"), $"{GlobalStateTableName} should have {userFunctionId} row woth on successful migration"); } + + /// + /// Ensures that the user function gets invoked for each of the insert, update and delete operation after migration seamlessly. + /// + [RetryTheory] + [SqlInlineData()] + [UnsupportedLanguages(SupportedLanguages.Java)] // test timing out for Java + public async Task UserFunctionIdMigrationTriggerTest(SupportedLanguages lang) + { + this.SetChangeTrackingForTable("Products"); + string userFunctionId = "func-id"; + string newUserFuntionId = "new-func-id"; + IConfiguration configuration = new ConfigurationBuilder().Build(); + var listener = new SqlTriggerListener(this.DbConnectionString, "dbo.Products", "", userFunctionId, "", Mock.Of(), Mock.Of(), Mock.Of(), configuration); + await listener.StartAsync(CancellationToken.None); + // Cancel immediately so the listener doesn't start processing the changes + await listener.StopAsync(CancellationToken.None); + + this.StartFunctionHost(nameof(ProductsTrigger), lang); + + int firstId = 1; + int lastId = 30; + await this.WaitForProductChanges( + firstId, + lastId, + SqlChangeOperation.Insert, + () => { this.InsertProducts(firstId, lastId); return Task.CompletedTask; }, + id => $"Product {id}", + id => id * 100, + this.GetBatchProcessingTimeout(firstId, lastId)); + + listener = new SqlTriggerListener(this.DbConnectionString, "dbo.Products", "", newUserFuntionId, userFunctionId, Mock.Of(), Mock.Of(), Mock.Of(), configuration); + await listener.StartAsync(CancellationToken.None); + // Cancel immediately so the listener doesn't start processing the changes + await listener.StopAsync(CancellationToken.None); + + this.StartFunctionHost(nameof(ProductsTrigger), lang); + + firstId = 1; + lastId = 20; + // All table columns (not just the columns that were updated) would be returned for update operation. + await this.WaitForProductChanges( + firstId, + lastId, + SqlChangeOperation.Update, + () => { this.UpdateProducts(firstId, lastId); return Task.CompletedTask; }, + id => $"Updated Product {id}", + id => id * 100, + this.GetBatchProcessingTimeout(firstId, lastId)); + + firstId = 11; + lastId = 30; + // The properties corresponding to non-primary key columns would be set to the C# type's default values + // (null and 0) for delete operation. + await this.WaitForProductChanges( + firstId, + lastId, + SqlChangeOperation.Delete, + () => { this.DeleteProducts(firstId, lastId); return Task.CompletedTask; }, + _ => null, + _ => 0, + this.GetBatchProcessingTimeout(firstId, lastId)); + } } } \ No newline at end of file