Skip to content

Commit 7150d2b

Browse files
committed
Add test for multiple function hosts
1 parent f912d2c commit 7150d2b

File tree

2 files changed

+119
-46
lines changed

2 files changed

+119
-46
lines changed

test/Integration/IntegrationTestBase.cs

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,36 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License. See License.txt in the project root for license information.
33

4-
using Microsoft.Data.SqlClient;
5-
using Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common;
64
using System;
5+
using System.Collections.Generic;
76
using System.Data.Common;
87
using System.Diagnostics;
98
using System.IO;
9+
using System.Linq;
1010
using System.Net.Http;
1111
using System.Reflection;
1212
using System.Runtime.InteropServices;
1313
using System.Text;
1414
using System.Threading.Tasks;
15+
using Microsoft.Azure.WebJobs.Extensions.Sql.Samples.Common;
16+
using Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common;
17+
using Microsoft.Data.SqlClient;
1518
using Xunit;
1619
using Xunit.Abstractions;
17-
using Microsoft.Azure.WebJobs.Extensions.Sql.Samples.Common;
1820

1921
namespace Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Integration
2022
{
2123
public class IntegrationTestBase : IDisposable
2224
{
2325
/// <summary>
24-
/// Host process for Azure Function CLI
26+
/// Host process for Azure Function CLI. Useful when only one host process is involved.
2527
/// </summary>
26-
protected Process FunctionHost { get; private set; }
28+
protected Process FunctionHost => this.FunctionHostList.FirstOrDefault();
29+
30+
/// <summary>
31+
/// Host processes for Azure Function CLI.
32+
/// </summary>
33+
protected List<Process> FunctionHostList { get; } = new List<Process>();
2734

2835
/// <summary>
2936
/// Host process for Azurite local storage emulator. This is required for non-HTTP trigger functions:
@@ -164,44 +171,58 @@ protected void StartAzurite()
164171
/// - The functionName is different than its route.<br/>
165172
/// - You can start multiple functions by passing in a space-separated list of function names.<br/>
166173
/// </remarks>
167-
protected void StartFunctionHost(string functionName, SupportedLanguages language, bool useTestFolder = false, DataReceivedEventHandler customOutputHandler = null)
174+
protected void StartFunctionHost(string functionName, SupportedLanguages language, bool useTestFolder = false, DataReceivedEventHandler[] customOutputHandlers = null)
168175
{
169176
string workingDirectory = useTestFolder ? GetPathToBin() : Path.Combine(GetPathToBin(), "SqlExtensionSamples", Enum.GetName(typeof(SupportedLanguages), language));
170177
if (!Directory.Exists(workingDirectory))
171178
{
172179
throw new FileNotFoundException("Working directory not found at " + workingDirectory);
173180
}
181+
182+
// Use a different port for each new host process, starting with the default port number: 7071.
183+
int port = this.Port + this.FunctionHostList.Count;
184+
174185
var startInfo = new ProcessStartInfo
175186
{
176187
// The full path to the Functions CLI is required in the ProcessStartInfo because UseShellExecute is set to false.
177188
// We cannot both use shell execute and redirect output at the same time: https://docs.microsoft.com//dotnet/api/system.diagnostics.processstartinfo.redirectstandardoutput#remarks
178189
FileName = GetFunctionsCoreToolsPath(),
179-
Arguments = $"start --verbose --port {this.Port} --functions {functionName}",
190+
Arguments = $"start --verbose --port {port} --functions {functionName}",
180191
WorkingDirectory = workingDirectory,
181192
WindowStyle = ProcessWindowStyle.Hidden,
182193
RedirectStandardOutput = true,
183194
RedirectStandardError = true,
184195
UseShellExecute = false
185196
};
197+
186198
this.TestOutput.WriteLine($"Starting {startInfo.FileName} {startInfo.Arguments} in {startInfo.WorkingDirectory}");
187-
this.FunctionHost = new Process
199+
200+
var functionHost = new Process
188201
{
189202
StartInfo = startInfo
190203
};
191204

205+
this.FunctionHostList.Add(functionHost);
206+
192207
// Register all handlers before starting the functions host process.
193208
var taskCompletionSource = new TaskCompletionSource<bool>();
194-
this.FunctionHost.OutputDataReceived += this.TestOutputHandler;
195-
this.FunctionHost.OutputDataReceived += SignalStartupHandler;
196-
this.FunctionHost.OutputDataReceived += customOutputHandler;
209+
functionHost.OutputDataReceived += SignalStartupHandler;
197210

198-
this.FunctionHost.ErrorDataReceived += this.TestOutputHandler;
211+
if (customOutputHandlers != null)
212+
{
213+
foreach (DataReceivedEventHandler handler in customOutputHandlers)
214+
{
215+
functionHost.OutputDataReceived += handler;
216+
}
217+
}
199218

200-
this.FunctionHost.Start();
201-
this.FunctionHost.BeginOutputReadLine();
202-
this.FunctionHost.BeginErrorReadLine();
219+
functionHost.Start();
220+
functionHost.OutputDataReceived += this.GetTestOutputHandler(functionHost.Id);
221+
functionHost.ErrorDataReceived += this.GetTestOutputHandler(functionHost.Id);
222+
functionHost.BeginOutputReadLine();
223+
functionHost.BeginErrorReadLine();
203224

204-
this.TestOutput.WriteLine($"Waiting for Azure Function host to start...");
225+
this.TestOutput.WriteLine("Waiting for Azure Function host to start...");
205226

206227
const int FunctionHostStartupTimeoutInSeconds = 60;
207228
bool isCompleted = taskCompletionSource.Task.Wait(TimeSpan.FromSeconds(FunctionHostStartupTimeoutInSeconds));
@@ -212,8 +233,8 @@ protected void StartFunctionHost(string functionName, SupportedLanguages languag
212233
const int BufferTimeInSeconds = 5;
213234
Task.Delay(TimeSpan.FromSeconds(BufferTimeInSeconds)).Wait();
214235

215-
this.TestOutput.WriteLine($"Azure Function host started!");
216-
this.FunctionHost.OutputDataReceived -= SignalStartupHandler;
236+
this.TestOutput.WriteLine("Azure Function host started!");
237+
functionHost.OutputDataReceived -= SignalStartupHandler;
217238

218239
void SignalStartupHandler(object sender, DataReceivedEventArgs e)
219240
{
@@ -259,11 +280,17 @@ private static string GetFunctionsCoreToolsPath()
259280
return funcPath;
260281
}
261282

262-
private void TestOutputHandler(object sender, DataReceivedEventArgs e)
283+
284+
private DataReceivedEventHandler GetTestOutputHandler(int processId)
263285
{
264-
if (e != null && !string.IsNullOrEmpty(e.Data))
286+
return TestOutputHandler;
287+
288+
void TestOutputHandler(object sender, DataReceivedEventArgs e)
265289
{
266-
this.TestOutput.WriteLine(e.Data);
290+
if (e != null && !string.IsNullOrEmpty(e.Data))
291+
{
292+
this.TestOutput.WriteLine($"[{processId}] {e.Data}");
293+
}
267294
}
268295
}
269296

@@ -343,14 +370,17 @@ public void Dispose()
343370
this.TestOutput.WriteLine($"Failed to close connection. Error: {e1.Message}");
344371
}
345372

346-
try
347-
{
348-
this.FunctionHost?.Kill();
349-
this.FunctionHost?.Dispose();
350-
}
351-
catch (Exception e2)
373+
foreach (Process functionHost in this.FunctionHostList)
352374
{
353-
this.TestOutput.WriteLine($"Failed to stop function host, Error: {e2.Message}");
375+
try
376+
{
377+
functionHost?.Kill();
378+
functionHost?.Dispose();
379+
}
380+
catch (Exception e2)
381+
{
382+
this.TestOutput.WriteLine($"Failed to stop function host, Error: {e2.Message}");
383+
}
354384
}
355385

356386
try

test/Integration/SqlTriggerBindingIntegrationTests.cs

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ public SqlTriggerBindingIntegrationTests(ITestOutputHelper output) : base(output
3030
public async Task SingleOperationTriggerTest()
3131
{
3232
this.EnableChangeTrackingForTable("Products");
33-
this.StartFunctionHost(nameof(ProductsTrigger), Common.SupportedLanguages.CSharp);
3433

3534
var changes = new List<SqlChange<Product>>();
36-
this.MonitorProductChanges(changes);
35+
DataReceivedEventHandler[] changeHandlers = new[] { this.GetProductChangeHandler(changes, "SQL Changes: ") };
36+
this.StartFunctionHost(nameof(ProductsTrigger), Common.SupportedLanguages.CSharp, useTestFolder: false, changeHandlers);
3737

3838
// Considering the polling interval of 5 seconds and batch-size of 10, it should take around 15 seconds to
3939
// process 30 insert operations. Similar reasoning is used to set delays for update and delete operations.
@@ -65,10 +65,10 @@ public async Task SingleOperationTriggerTest()
6565
public async Task MultiOperationTriggerTest()
6666
{
6767
this.EnableChangeTrackingForTable("Products");
68-
this.StartFunctionHost(nameof(ProductsTrigger), Common.SupportedLanguages.CSharp);
6968

7069
var changes = new List<SqlChange<Product>>();
71-
this.MonitorProductChanges(changes);
70+
DataReceivedEventHandler[] changeHandlers = new[] { this.GetProductChangeHandler(changes, "SQL Changes: ") };
71+
this.StartFunctionHost(nameof(ProductsTrigger), Common.SupportedLanguages.CSharp, useTestFolder: false, changeHandlers);
7272

7373
// Insert + multiple updates to a row are treated as single insert with latest row values.
7474
this.InsertProducts(1, 5);
@@ -97,13 +97,51 @@ public async Task MultiOperationTriggerTest()
9797
changes.Clear();
9898
}
9999

100+
/// <summary>
101+
/// Ensures correct functionality with user functions running across multiple functions host processes.
102+
/// </summary>
103+
[Fact]
104+
public async Task MultiHostTriggerTest()
105+
{
106+
this.EnableChangeTrackingForTable("Products");
107+
108+
var changes = new List<SqlChange<Product>>();
109+
DataReceivedEventHandler[] changeHandlers = new[] { this.GetProductChangeHandler(changes, "SQL Changes: ") };
110+
111+
// Prepare three function host processes
112+
this.StartFunctionHost(nameof(ProductsTrigger), Common.SupportedLanguages.CSharp, useTestFolder: false, changeHandlers);
113+
this.StartFunctionHost(nameof(ProductsTrigger), Common.SupportedLanguages.CSharp, useTestFolder: false, changeHandlers);
114+
this.StartFunctionHost(nameof(ProductsTrigger), Common.SupportedLanguages.CSharp, useTestFolder: false, changeHandlers);
115+
116+
// Considering the polling interval of 5 seconds and batch-size of 10, it should take around 15 seconds to
117+
// process 90 insert operations across all functions host processes. Similar reasoning is used to set delays
118+
// for update and delete operations.
119+
this.InsertProducts(1, 90);
120+
await Task.Delay(TimeSpan.FromSeconds(20));
121+
ValidateProductChanges(changes, 1, 90, SqlChangeOperation.Insert, id => $"Product {id}", id => id * 100);
122+
changes.Clear();
123+
124+
// All table columns (not just the columns that were updated) would be returned for update operation.
125+
this.UpdateProducts(1, 60);
126+
await Task.Delay(TimeSpan.FromSeconds(15));
127+
ValidateProductChanges(changes, 1, 60, SqlChangeOperation.Update, id => $"Updated Product {id}", id => id * 100);
128+
changes.Clear();
129+
130+
// The properties corresponding to non-primary key columns would be set to the C# type's default values
131+
// (null and 0) for delete operation.
132+
this.DeleteProducts(31, 90);
133+
await Task.Delay(TimeSpan.FromSeconds(15));
134+
ValidateProductChanges(changes, 31, 90, SqlChangeOperation.Delete, _ => null, _ => 0);
135+
changes.Clear();
136+
}
137+
100138
/// <summary>
101139
/// Tests the error message when the user table is not present in the database.
102140
/// </summary>
103141
[Fact]
104142
public void TableNotPresentTriggerTest()
105143
{
106-
this.StartFunctionsHostAndWaitForError(
144+
this.StartFunctionHostAndWaitForError(
107145
nameof(TableNotPresentTrigger),
108146
true,
109147
"Could not find table: 'dbo.TableNotPresent'.");
@@ -115,7 +153,7 @@ public void TableNotPresentTriggerTest()
115153
[Fact]
116154
public void PrimaryKeyNotCreatedTriggerTest()
117155
{
118-
this.StartFunctionsHostAndWaitForError(
156+
this.StartFunctionHostAndWaitForError(
119157
nameof(PrimaryKeyNotPresentTrigger),
120158
true,
121159
"Could not find primary key created in table: 'dbo.ProductsWithoutPrimaryKey'.");
@@ -128,7 +166,7 @@ public void PrimaryKeyNotCreatedTriggerTest()
128166
[Fact]
129167
public void ReservedPrimaryKeyColumnNamesTriggerTest()
130168
{
131-
this.StartFunctionsHostAndWaitForError(
169+
this.StartFunctionHostAndWaitForError(
132170
nameof(ReservedPrimaryKeyColumnNamesTrigger),
133171
true,
134172
"Found reserved column name(s): '_az_func_ChangeVersion', '_az_func_AttemptCount', '_az_func_LeaseExpirationTime' in table: 'dbo.ProductsWithReservedPrimaryKeyColumnNames'." +
@@ -141,7 +179,7 @@ public void ReservedPrimaryKeyColumnNamesTriggerTest()
141179
[Fact]
142180
public void UnsupportedColumnTypesTriggerTest()
143181
{
144-
this.StartFunctionsHostAndWaitForError(
182+
this.StartFunctionHostAndWaitForError(
145183
nameof(UnsupportedColumnTypesTrigger),
146184
true,
147185
"Found column(s) with unsupported type(s): 'Location' (type: geography), 'Geometry' (type: geometry), 'Organization' (type: hierarchyid)" +
@@ -154,7 +192,7 @@ public void UnsupportedColumnTypesTriggerTest()
154192
[Fact]
155193
public void ChangeTrackingNotEnabledTriggerTest()
156194
{
157-
this.StartFunctionsHostAndWaitForError(
195+
this.StartFunctionHostAndWaitForError(
158196
nameof(ProductsTrigger),
159197
false,
160198
"Could not find change tracking enabled for table: 'dbo.Products'.");
@@ -177,17 +215,22 @@ ALTER TABLE [dbo].[{tableName}]
177215
");
178216
}
179217

180-
private void MonitorProductChanges(List<SqlChange<Product>> changes)
218+
private DataReceivedEventHandler GetProductChangeHandler(List<SqlChange<Product>> changes, string messagePrefix)
181219
{
182-
int index = 0;
183-
string prefix = "SQL Changes: ";
220+
return ProductChangeHandler;
184221

185-
this.FunctionHost.OutputDataReceived += (sender, e) =>
222+
void ProductChangeHandler(object sender, DataReceivedEventArgs e)
186223
{
187-
if (e.Data != null && (index = e.Data.IndexOf(prefix, StringComparison.Ordinal)) >= 0)
224+
int index = 0;
225+
226+
if (e.Data != null && (index = e.Data.IndexOf(messagePrefix, StringComparison.Ordinal)) >= 0)
188227
{
189-
string json = e.Data[(index + prefix.Length)..];
190-
changes.AddRange(JsonConvert.DeserializeObject<IReadOnlyList<SqlChange<Product>>>(json));
228+
string json = e.Data[(index + messagePrefix.Length)..];
229+
230+
lock (changes)
231+
{
232+
changes.AddRange(JsonConvert.DeserializeObject<IReadOnlyList<SqlChange<Product>>>(json));
233+
}
191234
}
192235
};
193236
}
@@ -248,7 +291,7 @@ private static void ValidateProductChanges(List<SqlChange<Product>> changes, int
248291
/// <param name="functionName">Name of the user function that should cause error in trigger listener</param>
249292
/// <param name="useTestFolder">Whether the functions host should be launched from test folder</param>
250293
/// <param name="expectedErrorMessage">Expected error message string</param>
251-
private void StartFunctionsHostAndWaitForError(string functionName, bool useTestFolder, string expectedErrorMessage)
294+
private void StartFunctionHostAndWaitForError(string functionName, bool useTestFolder, string expectedErrorMessage)
252295
{
253296
string errorMessage = null;
254297
var tcs = new TaskCompletionSource<bool>();
@@ -268,7 +311,7 @@ void OutputHandler(object sender, DataReceivedEventArgs e)
268311
};
269312

270313
// All trigger integration tests are only using C# functions for testing at the moment.
271-
this.StartFunctionHost(functionName, Common.SupportedLanguages.CSharp, useTestFolder, OutputHandler);
314+
this.StartFunctionHost(functionName, Common.SupportedLanguages.CSharp, useTestFolder, new DataReceivedEventHandler[] { OutputHandler });
272315
this.FunctionHost.OutputDataReceived -= OutputHandler;
273316
this.FunctionHost.Kill();
274317

0 commit comments

Comments
 (0)