Skip to content

Commit f38de47

Browse files
Add async PerformAsync and FlushAsync methods with CancellationToken support (#1170)
* Add async PerformAsync and FlushAsync methods with CancellationToken support * Refactor replication service and stream extensions for fully async I/O, updating PerformAsync, SessionToken, and ASP.NET Core response handling. * refactor: rename BigEndian methods and use ArrayPool for buffer allocations in StreamExtensions * feat: Add async read/write support in ReplicationServlet and corresponding J2N-based async tests in TestStreamExtensions * Add XML Summary For PerformAsync and remove dupilcate comment code * Lucene.Net.Tests.Replicator: Refactored to test both synchronous and asynchronous APIs as well as both Startup class and Middleware configurations (when both are supported). * Lucene.Net.Tests.Replicator: Added an HttpListener-based server so we don't have to rely on Microsoft.AspNetcore.TestHost prior to .NET Core. Bumped Microsoft.AspNetcore.TestHost package to 9.0.8. Refactored tests to separate ASP.NET Core functionality from HttpListener functionality. * Lucene.Net.Tests.Replicator: Only reference Lucene.Net.Replicator.AspNetCore on .NET Core 8 or higher * Lucene.Net.Replicator.AspNetCore: Dropped support for all target framworks but net8.0. * Lucene.Net.Replicator.AspNetCore: Removed PackageReference to transitive dependency System.Text.Encodings.Web, which only existed to bump the package to one without known security vulnerabilities. Bumped Microsoft.AspNetCore.Http.Abstractions to 2.3.0. * Add IAsyncReplicationServer/IAsyncReplicationResponse, update ReplicationService, Middleware, and TestServer to support async replication with conditional sync/async DI. * Revert "Add IAsyncReplicationServer/IAsyncReplicationResponse, update ReplicationService, Middleware, and TestServer to support async replication with conditional sync/async DI." This reverts commit 349621f. * Revert async interfaces, unify IReplicationService with Perform/PerformAsync, update ReplicationServlet, Middleware, and TestServer to use single interface --------- Co-authored-by: Shad Storhaug <shad@shadstorhaug.com>
1 parent 0a8d39c commit f38de47

19 files changed

Lines changed: 944 additions & 188 deletions

.build/dependencies.props

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<!--
1+
<!--
22
33
Licensed to the Apache Software Foundation (ASF) under one
44
or more contributor license agreements. See the NOTICE file
@@ -39,11 +39,8 @@
3939
<!-- J2N will break binary compatibility in 3.0.0 to fix the APIs of collection types -->
4040
<J2NPackageVersion>[2.1.0, 3.0.0)</J2NPackageVersion>
4141
<LiquidTestReportsMarkdownPackageVersion>1.0.9</LiquidTestReportsMarkdownPackageVersion>
42-
<MicrosoftAspNetCoreHttpAbstractionsPackageVersion>2.1.1</MicrosoftAspNetCoreHttpAbstractionsPackageVersion>
43-
<MicrosoftAspNetCoreHttpPackageVersion>2.1.34</MicrosoftAspNetCoreHttpPackageVersion>
44-
<MicrosoftAspNetCoreTestHostPackageVersion>6.0.0</MicrosoftAspNetCoreTestHostPackageVersion>
45-
<MicrosoftAspNetCoreTestHostPackageVersion Condition=" $(TargetFramework.StartsWith('net4')) ">2.1.1</MicrosoftAspNetCoreTestHostPackageVersion>
46-
<MicrosoftAspNetCoreTestHostPackageVersion Condition=" '$(TargetFramework)' == 'net5.0' ">3.1.32</MicrosoftAspNetCoreTestHostPackageVersion>
42+
<MicrosoftAspNetCoreHttpAbstractionsPackageVersion>2.3.0</MicrosoftAspNetCoreHttpAbstractionsPackageVersion>
43+
<MicrosoftAspNetCoreTestHostPackageVersion>8.0.19</MicrosoftAspNetCoreTestHostPackageVersion>
4744
<MicrosoftCodeAnalysisAnalyzersPackageVersion>2.9.8</MicrosoftCodeAnalysisAnalyzersPackageVersion>
4845
<MicrosoftCodeAnalysisCSharpPackageVersion>2.6.1</MicrosoftCodeAnalysisCSharpPackageVersion>
4946
<MicrosoftCodeAnalysisCSharpWorkspacesPackageVersion>$(MicrosoftCodeAnalysisCSharpPackageVersion)</MicrosoftCodeAnalysisCSharpWorkspacesPackageVersion>
@@ -71,7 +68,6 @@
7168
<RandomizedTestingGeneratorsPackageVersion>2.7.8</RandomizedTestingGeneratorsPackageVersion>
7269
<SharpZipLibPackageVersion>1.4.2</SharpZipLibPackageVersion>
7370
<Spatial4nPackageVersion>0.4.1.1</Spatial4nPackageVersion>
74-
<SystemIOPipelinesPackageVersion>8.0.0</SystemIOPipelinesPackageVersion>
7571
<SystemMemoryPackageVersion>4.5.5</SystemMemoryPackageVersion>
7672
<SystemNetHttpPackageVersion>4.3.4</SystemNetHttpPackageVersion>
7773
<SystemReflectionEmitPackageVersion>4.3.0</SystemReflectionEmitPackageVersion>
@@ -80,7 +76,6 @@
8076
<SystemRuntimeInteropServicesRuntimeInformationPackageVersion>4.3.0</SystemRuntimeInteropServicesRuntimeInformationPackageVersion>
8177
<SystemTextEncodingCodePagesPackageVersion>4.3.0</SystemTextEncodingCodePagesPackageVersion>
8278
<SystemTextEncodingCodePagesPackageVersion Condition=" '$(TargetFramework)' == 'net472' ">5.0.0</SystemTextEncodingCodePagesPackageVersion>
83-
<SystemTextEncodingsWebPackageVersion>8.0.0</SystemTextEncodingsWebPackageVersion>
8479
<SystemTextJsonPackageVersion>6.0.10</SystemTextJsonPackageVersion>
8580
<SystemTextRegularExpressionsPackageVersion>4.3.1</SystemTextRegularExpressionsPackageVersion>
8681
<TimeZoneConverterPackageVersion>6.1.0</TimeZoneConverterPackageVersion>

Directory.Build.targets

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
<!-- Features in .NET 8.x and .NET 9.x only -->
4141
<PropertyGroup Condition=" $(TargetFramework.StartsWith('net8.')) Or $(TargetFramework.StartsWith('net9.')) ">
4242

43+
<DefineConstants>$(DefineConstants);FEATURE_ASPNETCORE_TESTHOST</DefineConstants>
4344
<DefineConstants>$(DefineConstants);FEATURE_UTF8_TOUTF16</DefineConstants>
4445

4546
</PropertyGroup>
@@ -56,7 +57,6 @@
5657
<!-- Features in .NET 5.x, .NET 6.x, .NET 7.x, .NET 8.x, and .NET 9.x only -->
5758
<PropertyGroup Condition=" $(TargetFramework.StartsWith('net5.')) Or $(TargetFramework.StartsWith('net6.')) Or $(TargetFramework.StartsWith('net7.')) Or $(TargetFramework.StartsWith('net8.')) Or $(TargetFramework.StartsWith('net9.')) ">
5859

59-
<DefineConstants>$(DefineConstants);FEATURE_ASPNETCORE_ENDPOINT_CONFIG</DefineConstants>
6060
<DefineConstants>$(DefineConstants);FEATURE_READONLYSET</DefineConstants>
6161

6262
</PropertyGroup>

src/Lucene.Net.Replicator/Http/ReplicationService.cs

Lines changed: 90 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
using System.Collections.Generic;
88
using System.IO;
99
using System.Net;
10+
using System.Threading;
11+
using System.Threading.Tasks;
12+
1013

1114
namespace Lucene.Net.Replicator.Http
1215
{
@@ -118,62 +121,54 @@ private static string ExtractRequestParam(IReplicationRequest request, string pa
118121
return param;
119122
}
120123

121-
// LUCENENET specific - copy method not used
122-
123-
/// <summary>
124-
/// Executes the replication task.
125-
/// </summary>
126-
/// <exception cref="InvalidOperationException">required parameters are missing</exception>
127-
public virtual void Perform(IReplicationRequest request, IReplicationResponse response)
124+
// method to avoid code duplication in sync and async Perform methods
125+
private async Task ExecuteReplicationAsync(
126+
IReplicationRequest request,
127+
IReplicationResponse response,
128+
Func<Stream, Task> copyStreamFunc,
129+
Func<SessionToken, Task> writeTokenFunc,
130+
Func<Task> flushFunc)
128131
{
129132
string[] pathElements = GetPathElements(request);
130133
if (pathElements.Length != 2)
131-
{
132134
throw ServletException.Create("invalid path, must contain shard ID and action, e.g. */s1/update");
133-
}
134135

135136
if (!Enum.TryParse(pathElements[ACTION_IDX], true, out ReplicationAction action))
136-
{
137137
throw ServletException.Create("Unsupported action provided: " + pathElements[ACTION_IDX]);
138-
}
139138

140139
if (!replicators.TryGetValue(pathElements[SHARD_IDX], out IReplicator replicator))
141-
{
142140
throw ServletException.Create("unrecognized shard ID " + pathElements[SHARD_IDX]);
143-
}
144141

145142
// SOLR-8933 Don't close this stream.
146143
try
147144
{
148145
switch (action)
149146
{
150147
case ReplicationAction.OBTAIN:
151-
string sessionId = ExtractRequestParam(request, REPLICATE_SESSION_ID_PARAM);
152-
string fileName = ExtractRequestParam(request, REPLICATE_FILENAME_PARAM);
153-
string source = ExtractRequestParam(request, REPLICATE_SOURCE_PARAM);
154-
using (Stream stream = replicator.ObtainFile(sessionId, source, fileName))
155-
stream.CopyTo(response.Body);
156-
break;
148+
{
149+
string sessionId = ExtractRequestParam(request, REPLICATE_SESSION_ID_PARAM);
150+
string fileName = ExtractRequestParam(request, REPLICATE_FILENAME_PARAM);
151+
string source = ExtractRequestParam(request, REPLICATE_SOURCE_PARAM);
157152

158-
case ReplicationAction.RELEASE:
159-
replicator.Release(ExtractRequestParam(request, REPLICATE_SESSION_ID_PARAM));
160-
break;
153+
using (Stream stream = replicator.ObtainFile(sessionId, source, fileName))
154+
await copyStreamFunc(stream);
155+
break;
156+
}
161157

162-
case ReplicationAction.UPDATE:
163-
string currentVersion = request.QueryParam(REPLICATE_VERSION_PARAM);
164-
SessionToken token = replicator.CheckForUpdate(currentVersion);
165-
if (token is null)
158+
case ReplicationAction.RELEASE:
166159
{
167-
response.Body.Write(new byte[] { 0 }, 0, 1); // marker for null token
160+
replicator.Release(ExtractRequestParam(request, REPLICATE_SESSION_ID_PARAM));
161+
break;
168162
}
169-
else
163+
164+
case ReplicationAction.UPDATE:
170165
{
171-
response.Body.Write(new byte[] { 1 }, 0, 1);
172-
token.Serialize(new DataOutputStream(response.Body));
166+
string currentVersion = request.QueryParam(REPLICATE_VERSION_PARAM);
167+
SessionToken token = replicator.CheckForUpdate(currentVersion);
168+
await writeTokenFunc(token);
169+
break;
173170
}
174-
break;
175171

176-
// LUCENENET specific:
177172
default:
178173
if (Debugging.AssertsEnabled) Debugging.Assert(false, "Invalid ReplicationAction specified");
179174
break;
@@ -185,8 +180,70 @@ public virtual void Perform(IReplicationRequest request, IReplicationResponse re
185180
}
186181
finally
187182
{
188-
response.Flush();
183+
await flushFunc();
189184
}
190185
}
186+
187+
// LUCENENET specific - copy method not used
188+
189+
/// <summary>
190+
/// Executes the replication task.
191+
/// </summary>
192+
/// <exception cref="InvalidOperationException">required parameters are missing</exception>
193+
public virtual void Perform(IReplicationRequest request, IReplicationResponse response)
194+
{
195+
ExecuteReplicationAsync(
196+
request,
197+
response,
198+
stream => { stream.CopyTo(response.Body); return Task.CompletedTask; },
199+
token =>
200+
{
201+
if (token == null)
202+
{
203+
response.Body.Write(new byte[] { 0 }, 0, 1);
204+
}
205+
else
206+
{
207+
response.Body.Write(new byte[] { 1 }, 0, 1);
208+
token.Serialize(new DataOutputStream(response.Body));
209+
}
210+
return Task.CompletedTask;
211+
},
212+
() => { response.Body.Flush(); return Task.CompletedTask; }
213+
).ConfigureAwait(false).GetAwaiter().GetResult(); // keep sync behavior
214+
}
215+
216+
217+
/// <summary>
218+
/// Executes the replication task asynchronously.
219+
/// </summary>
220+
/// <param name="request">The replication request containing action and parameters.</param>
221+
/// <param name="response">The replication response used to send data back to the client.</param>
222+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while performing the replication.</param>
223+
/// <exception cref="InvalidOperationException">Thrown when required parameters are missing or invalid.</exception>
224+
public virtual Task PerformAsync(
225+
IReplicationRequest request,
226+
IReplicationResponse response,
227+
CancellationToken cancellationToken = default)
228+
{
229+
return ExecuteReplicationAsync(
230+
request,
231+
response,
232+
stream => stream.CopyToAsync(response.Body, 81920, cancellationToken),
233+
async token =>
234+
{
235+
if (token == null)
236+
{
237+
await response.Body.WriteAsync(new byte[] { 0 }, 0, 1, cancellationToken);
238+
}
239+
else
240+
{
241+
await response.Body.WriteAsync(new byte[] { 1 }, 0, 1, cancellationToken);
242+
await token.SerializeAsync(response.Body, cancellationToken);
243+
}
244+
},
245+
() => response.Body.FlushAsync(cancellationToken)
246+
);
247+
}
191248
}
192249
}

src/Lucene.Net.Replicator/SessionToken.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
using J2N.IO;
22
using System.Collections.Generic;
3+
using System;
34
using System.IO;
45
using JCG = J2N.Collections.Generic;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Lucene.Net.Support.IO;
59

610
namespace Lucene.Net.Replicator
711
{
@@ -112,6 +116,37 @@ public void Serialize(DataOutputStream writer)
112116
}
113117
}
114118

119+
/// <summary>
120+
/// Asynchronously serializes the token's properties, including ID, version, and source files,
121+
/// to the provided <see cref="Stream"/> for transmission or storage.
122+
/// </summary>
123+
/// <param name="output">The <see cref="Stream"/> to write the token data to.</param>
124+
/// <param name="cancellationToken">A cancellation token to observe while writing and flushing the stream.</param>
125+
/// <returns>A task representing the asynchronous serialization operation.</returns>
126+
internal async Task SerializeAsync(Stream output, CancellationToken cancellationToken = default)
127+
{
128+
if (output is null)
129+
throw new ArgumentNullException(nameof(output));
130+
131+
await output.WriteUTFAsync(Id, cancellationToken).ConfigureAwait(false);
132+
await output.WriteUTFAsync(Version, cancellationToken).ConfigureAwait(false);
133+
await output.WriteInt32BigEndianAsync(SourceFiles.Count, cancellationToken).ConfigureAwait(false);
134+
135+
foreach (var pair in SourceFiles)
136+
{
137+
await output.WriteUTFAsync(pair.Key, cancellationToken).ConfigureAwait(false);
138+
await output.WriteInt32BigEndianAsync(pair.Value.Count, cancellationToken).ConfigureAwait(false);
139+
140+
foreach (var file in pair.Value)
141+
{
142+
await output.WriteUTFAsync(file.FileName, cancellationToken).ConfigureAwait(false);
143+
await output.WriteInt64BigEndianAsync(file.Length, cancellationToken).ConfigureAwait(false);
144+
}
145+
}
146+
147+
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
148+
}
149+
115150
public override string ToString()
116151
{
117152
return string.Format("id={0} version={1} files={2}", Id, Version, SourceFiles);

src/Lucene.Net.Replicator/Support/Http/Abstractions/IReplicationResponse.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,5 @@ public interface IReplicationResponse
3737
/// The response content.
3838
/// </summary>
3939
Stream Body { get; }
40-
41-
/// <summary>
42-
/// Flushes the reponse to the underlying response stream.
43-
/// </summary>
44-
void Flush();
4540
}
4641
}

src/Lucene.Net.Replicator/Support/Http/Abstractions/IReplicationService.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
25

36
namespace Lucene.Net.Replicator.Http.Abstractions
47
{
@@ -29,5 +32,15 @@ public interface IReplicationService
2932
/// </summary>
3033
/// <exception cref="InvalidOperationException">required parameters are missing</exception>
3134
void Perform(IReplicationRequest request, IReplicationResponse response);
35+
36+
/// <summary>
37+
/// Executes the replication task asynchronously.
38+
/// </summary>
39+
/// <param name="request">The replication request.</param>
40+
/// <param name="response">The replication response.</param>
41+
/// <param name="cancellationToken">Optional cancellation token.</param>
42+
/// <returns>A task representing the asynchronous operation.</returns>
43+
Task PerformAsync(IReplicationRequest request, IReplicationResponse response, CancellationToken cancellationToken = default);
44+
3245
}
3346
}

src/Lucene.Net.Tests.Replicator/Http/HttpReplicatorTest.cs

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@
22
using Lucene.Net.Index;
33
using Lucene.Net.Support;
44
using Lucene.Net.Util;
5-
using Microsoft.AspNetCore.TestHost;
65
using NUnit.Framework;
76
using System;
87
using System.Collections.Generic;
98
using System.Globalization;
109
using System.IO;
1110
using Directory = Lucene.Net.Store.Directory;
1211

12+
#if FEATURE_ASPNETCORE_TESTHOST
13+
using Microsoft.AspNetCore.TestHost;
14+
#else
15+
using Lucene.Net.Replicator.Net;
16+
#endif
17+
18+
1319
namespace Lucene.Net.Replicator.Http
1420
{
1521
/*
@@ -29,6 +35,14 @@ namespace Lucene.Net.Replicator.Http
2935
* limitations under the License.
3036
*/
3137

38+
// Technically, the ConfigOption is only supported by ASP.NET Core
39+
// so we just ignore the other option when running on HttpListener.
40+
[TestFixture(IOOption.Synchronous, ConfigOption.StartupClass)]
41+
[TestFixture(IOOption.Asynchronous, ConfigOption.StartupClass)]
42+
#if FEATURE_ASPNETCORE_TESTHOST
43+
[TestFixture(IOOption.Synchronous, ConfigOption.Middleware)]
44+
[TestFixture(IOOption.Asynchronous, ConfigOption.Middleware)]
45+
#endif
3246
public class HttpReplicatorTest : ReplicatorTestCase
3347
{
3448
private DirectoryInfo clientWorkDir;
@@ -45,16 +59,31 @@ public class HttpReplicatorTest : ReplicatorTestCase
4559

4660
private MockErrorConfig mockErrorConfig;
4761

48-
private void StartServer()
62+
private readonly bool useSynchronousIO;
63+
private readonly bool useStartupClass;
64+
65+
public enum IOOption
4966
{
50-
ReplicationService service = new ReplicationService(new Dictionary<string, IReplicator> { { "s1", serverReplicator } });
67+
Synchronous,
68+
Asynchronous,
69+
}
5170

52-
#if FEATURE_ASPNETCORE_ENDPOINT_CONFIG
53-
server = NewHttpServer(service, mockErrorConfig); // Call like this to use ReplicationServerMiddleware on the specific path /replicate/{shard?}/{action?}, but allow other paths to be served
54-
#else
55-
server = NewHttpServer<ReplicationServlet>(service, mockErrorConfig); // Call like this to use ReplicationServlet as a Startup Class
56-
#endif
71+
public enum ConfigOption
72+
{
73+
StartupClass,
74+
Middleware
75+
}
76+
77+
public HttpReplicatorTest(IOOption ioOption, ConfigOption configOption)
78+
{
79+
this.useSynchronousIO = ioOption == IOOption.Synchronous;
80+
this.useStartupClass = configOption == ConfigOption.StartupClass;
81+
}
5782

83+
private void StartServer()
84+
{
85+
ReplicationService service = new ReplicationService(new Dictionary<string, IReplicator> { { "s1", serverReplicator } });
86+
server = NewHttpServer(service, mockErrorConfig, useSynchronousIO, useStartupClass);
5887
port = ServerPort(server);
5988
host = ServerHost(server);
6089
}
@@ -144,7 +173,7 @@ public void TestServerErrors()
144173
mockErrorConfig.RespondWithError = false;
145174
client.UpdateNow(); // now it should work
146175
ReopenReader();
147-
assertEquals(5, J2N.Numerics.Int32.Parse(reader.IndexCommit.UserData["ID"], 16));
176+
assertEquals(5, int.Parse(reader.IndexCommit.UserData["ID"], NumberStyles.HexNumber));
148177

149178
client.Dispose();
150179
}

0 commit comments

Comments
 (0)