Skip to content

Commit

Permalink
Merge pull request #440 from serverlessworkflow/fix-watch-and-monitor…
Browse files Browse the repository at this point in the history
…-client

Fixed the API client to use SSEs for watching and monitoring resources
  • Loading branch information
cdavernas authored Oct 24, 2024
2 parents 03eae9d + 20d6fac commit 71d9adb
Show file tree
Hide file tree
Showing 11 changed files with 81 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ public interface IClusterResourceApiClient<TResource>
/// <param name="labelSelectors">Defines the expected labels, if any, of the resources to watch</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/></param>
/// <returns>A new <see cref="IAsyncEnumerable{T}"/> used to asynchronously enumerate resulting <see cref="IResourceWatchEvent"/>s</returns>
Task<IAsyncEnumerable<IResourceWatchEvent<TResource>>> WatchAsync(IEnumerable<LabelSelector>? labelSelectors = null, CancellationToken cancellationToken = default);
IAsyncEnumerable<IResourceWatchEvent<TResource>> WatchAsync(IEnumerable<LabelSelector>? labelSelectors = null, CancellationToken cancellationToken = default);

/// <summary>
/// Monitors the resource with the specified name
/// </summary>
/// <param name="name">The name of the resource to monitor</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/></param>
/// <returns>A new <see cref="IAsyncEnumerable{T}"/> used to asynchronously enumerate resulting <see cref="IResourceWatchEvent"/>s</returns>
Task<IAsyncEnumerable<IResourceWatchEvent<TResource>>> MonitorAsync(string name, CancellationToken cancellationToken = default);
IAsyncEnumerable<IResourceWatchEvent<TResource>> MonitorAsync(string name, CancellationToken cancellationToken = default);

/// <summary>
/// Gets the resource with the specified name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public interface INamespacedResourceApiClient<TResource>
/// <param name="labelSelectors">Defines the expected labels, if any, of the resources to watch</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/></param>
/// <returns>A new <see cref="IAsyncEnumerable{T}"/> used to asynchronously enumerate resulting <see cref="IResourceWatchEvent"/>s</returns>
Task<IAsyncEnumerable<IResourceWatchEvent<TResource>>> WatchAsync(string? @namespace = null, IEnumerable<LabelSelector>? labelSelectors = null, CancellationToken cancellationToken = default);
IAsyncEnumerable<IResourceWatchEvent<TResource>> WatchAsync(string? @namespace = null, IEnumerable<LabelSelector>? labelSelectors = null, CancellationToken cancellationToken = default);

/// <summary>
/// Monitors the resource with the specified name
Expand All @@ -49,7 +49,7 @@ public interface INamespacedResourceApiClient<TResource>
/// <param name="namespace">The namespace the resource to monitor belongs to</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/></param>
/// <returns>A new <see cref="IAsyncEnumerable{T}"/> used to asynchronously enumerate resulting <see cref="IResourceWatchEvent"/>s</returns>
Task<IAsyncEnumerable<IResourceWatchEvent<TResource>>> MonitorAsync(string name, string @namespace, CancellationToken cancellationToken = default);
IAsyncEnumerable<IResourceWatchEvent<TResource>> MonitorAsync(string name, string @namespace, CancellationToken cancellationToken = default);

/// <summary>
/// Gets the resource with the specified name
Expand Down
61 changes: 46 additions & 15 deletions src/api/Synapse.Api.Client.Http/Services/ResourceHttpApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.

using System.Runtime.CompilerServices;

namespace Synapse.Api.Client.Services;

/// <summary>
Expand Down Expand Up @@ -105,60 +107,89 @@ public virtual async Task<IAsyncEnumerable<TResource>> ListAsync(IEnumerable<Lab
}

/// <inheritdoc/>
public virtual async Task<IAsyncEnumerable<IResourceWatchEvent<TResource>>> WatchAsync(string? @namespace = null, IEnumerable<LabelSelector>? labelSelectors = null, CancellationToken cancellationToken = default)
public virtual async IAsyncEnumerable<IResourceWatchEvent<TResource>> WatchAsync(string? @namespace = null, IEnumerable<LabelSelector>? labelSelectors = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var resource = new TResource();
var uri = string.IsNullOrWhiteSpace(@namespace) ? $"/api/{resource.Definition.Version}/{resource.Definition.Plural}/watch" : $"/api/{resource.Definition.Version}/{resource.Definition.Plural}/{@namespace}/watch";
var uri = string.IsNullOrWhiteSpace(@namespace) ? $"/api/{resource.Definition.Version}/{resource.Definition.Plural}/watch/sse" : $"/api/{resource.Definition.Version}/{resource.Definition.Plural}/{@namespace}/watch";
var queryStringArguments = new Dictionary<string, string>();
if (labelSelectors?.Any() == true) queryStringArguments.Add("labelSelector", labelSelectors.Select(s => s.ToString()).Join(','));
if (queryStringArguments.Count != 0) uri += $"?{queryStringArguments.Select(kvp => $"{kvp.Key}={kvp.Value}").Join('&')}";
using var request = await this.ProcessRequestAsync(new HttpRequestMessage(HttpMethod.Get, uri), cancellationToken).ConfigureAwait(false);
request.EnableWebAssemblyStreamingResponse();
var response = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return this.JsonSerializer.DeserializeAsyncEnumerable<ResourceWatchEvent<TResource>>(responseStream, cancellationToken)!;
using var streamReader = new StreamReader(await response.Content.ReadAsStreamAsync());
while (!streamReader.EndOfStream)
{
var sseMessage = await streamReader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(sseMessage)) continue;
var json = sseMessage["data: ".Length..].Trim();
var e = JsonSerializer.Deserialize<ResourceWatchEvent<TResource>>(json)!;
yield return e;
}
}

/// <inheritdoc/>
public virtual async Task<IAsyncEnumerable<IResourceWatchEvent<TResource>>> WatchAsync(IEnumerable<LabelSelector>? labelSelectors = null, CancellationToken cancellationToken = default)
public virtual async IAsyncEnumerable<IResourceWatchEvent<TResource>> WatchAsync(IEnumerable<LabelSelector>? labelSelectors = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var resource = new TResource();
var uri = $"/api/{resource.Definition.Version}/{resource.Definition.Plural}/watch";
var uri = $"/api/{resource.Definition.Version}/{resource.Definition.Plural}/watch/sse";
var queryStringArguments = new Dictionary<string, string>();
if (labelSelectors?.Any() == true) queryStringArguments.Add("labelSelector", labelSelectors.Select(s => s.ToString()).Join(','));
if (queryStringArguments.Count != 0) uri += $"?{queryStringArguments.Select(kvp => $"{kvp.Key}={kvp.Value}").Join('&')}";
using var request = await this.ProcessRequestAsync(new HttpRequestMessage(HttpMethod.Get, uri), cancellationToken).ConfigureAwait(false);
request.EnableWebAssemblyStreamingResponse();
var response = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return this.JsonSerializer.DeserializeAsyncEnumerable<ResourceWatchEvent<TResource>>(responseStream, cancellationToken)!;
using var streamReader = new StreamReader(await response.Content.ReadAsStreamAsync());
while (!streamReader.EndOfStream)
{
var sseMessage = await streamReader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(sseMessage)) continue;
var json = sseMessage["data: ".Length..].Trim();
var e = JsonSerializer.Deserialize<ResourceWatchEvent<TResource>>(json)!;
yield return e;
}
}

/// <inheritdoc/>
public virtual async Task<IAsyncEnumerable<IResourceWatchEvent<TResource>>> MonitorAsync(string name, string @namespace, CancellationToken cancellationToken = default)
public virtual async IAsyncEnumerable<IResourceWatchEvent<TResource>> MonitorAsync(string name, string @namespace, [EnumeratorCancellation]CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(@namespace);
var resource = new TResource();
var uri = $"/api/{resource.Definition.Version}/{resource.Definition.Plural}/{@namespace}/{name}/monitor";
var uri = $"/api/{resource.Definition.Version}/{resource.Definition.Plural}/{@namespace}/{name}/monitor/sse";
using var request = await this.ProcessRequestAsync(new HttpRequestMessage(HttpMethod.Get, uri), cancellationToken).ConfigureAwait(false);
request.EnableWebAssemblyStreamingResponse();
var response = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return this.JsonSerializer.DeserializeAsyncEnumerable<ResourceWatchEvent<TResource>>(responseStream, cancellationToken)!;
using var streamReader = new StreamReader(await response.Content.ReadAsStreamAsync());
while (!streamReader.EndOfStream)
{
var sseMessage = await streamReader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(sseMessage)) continue;
var json = sseMessage["data: ".Length..].Trim();
var e = JsonSerializer.Deserialize<ResourceWatchEvent<TResource>>(json)!;
yield return e;
}
}

/// <inheritdoc/>
public virtual async Task<IAsyncEnumerable<IResourceWatchEvent<TResource>>> MonitorAsync(string name, CancellationToken cancellationToken = default)
public virtual async IAsyncEnumerable<IResourceWatchEvent<TResource>> MonitorAsync(string name, [EnumeratorCancellation]CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var resource = new TResource();
var uri = $"/api/{resource.Definition.Version}/{resource.Definition.Plural}/{name}/monitor";
var uri = $"/api/{resource.Definition.Version}/{resource.Definition.Plural}/{name}/monitor/sse";
using var request = await this.ProcessRequestAsync(new HttpRequestMessage(HttpMethod.Get, uri), cancellationToken).ConfigureAwait(false);
request.EnableWebAssemblyStreamingResponse();
var response = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return this.JsonSerializer.DeserializeAsyncEnumerable<ResourceWatchEvent<TResource>>(responseStream, cancellationToken)!;
using var streamReader = new StreamReader(await response.Content.ReadAsStreamAsync());
while (!streamReader.EndOfStream)
{
var sseMessage = await streamReader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(sseMessage)) continue;
var json = sseMessage["data: ".Length..].Trim();
var e = JsonSerializer.Deserialize<ResourceWatchEvent<TResource>>(json)!;
yield return e;
}
}

/// <inheritdoc/>
Expand Down
2 changes: 2 additions & 0 deletions src/api/Synapse.Api.Http/ClusterResourceController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public virtual async Task<IActionResult> WatchResourcesUsingSSE(string? labelSel
this.Response.Headers.ContentType = "text/event-stream";
this.Response.Headers.CacheControl = "no-cache";
this.Response.Headers.Connection = "keep-alive";
await this.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
await foreach (var e in response.Data!)
{
var sseMessage = $"data: {this.JsonSerializer.SerializeToText(e)}\\n\\n";
Expand Down Expand Up @@ -147,6 +148,7 @@ public virtual async Task<IActionResult> MonitorResourceUsingSSE(string name, Ca
this.Response.Headers.ContentType = "text/event-stream";
this.Response.Headers.CacheControl = "no-cache";
this.Response.Headers.Connection = "keep-alive";
await this.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
await foreach (var e in response.Data!)
{
var sseMessage = $"data: {this.JsonSerializer.SerializeToText(e)}\\n\\n";
Expand Down
2 changes: 2 additions & 0 deletions src/api/Synapse.Api.Http/NamespacedResourceController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ public virtual async Task<IActionResult> WatchResourcesUsingSSE(string @namespac
this.Response.Headers.ContentType = "text/event-stream";
this.Response.Headers.CacheControl = "no-cache";
this.Response.Headers.Connection = "keep-alive";
await this.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
await foreach (var e in response.Data!)
{
var sseMessage = $"data: {this.JsonSerializer.SerializeToText(e)}\\n\\n";
Expand Down Expand Up @@ -211,6 +212,7 @@ public virtual async Task<IActionResult> MonitorResourceUsingSSE(string name, st
this.Response.Headers.ContentType = "text/event-stream";
this.Response.Headers.CacheControl = "no-cache";
this.Response.Headers.Connection = "keep-alive";
await this.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
await foreach(var e in response.Data!)
{
var sseMessage = $"data: {this.JsonSerializer.SerializeToText(e)}\\n\\n";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ public MonitorWorkflowInstancesCommand(IServiceProvider serviceProvider, ILogger
public async Task HandleAsync(string name, string @namespace, string output)
{
this.EnsureConfigured();
var enumerable = await this.Api.WorkflowInstances.MonitorAsync(name, @namespace);
await foreach (var e in enumerable)
await foreach (var e in this.Api.WorkflowInstances.MonitorAsync(name, @namespace))
{
string outputText = output.ToLowerInvariant() switch
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@
await this.JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text);
this.ToastService.Notify(new(ToastType.Success, "Copied to the clipboard!"));
}
catch (Exception ex)
catch
{
this.ToastService.Notify(new(ToastType.Danger, "Failed to copy the definition to the clipboard."));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,16 @@ await this.SetErrorAsync(new()
{
Definition = new()
{
Namespace = this.ProcessDefinition.Namespace,
Name = this.ProcessDefinition.Name,
Version = this.ProcessDefinition.Version
Namespace = workflowDefinition.Document.Namespace,
Name = workflowDefinition.Document.Name,
Version = workflowDefinition.Document.Version
},
Input = input
}
};
workflowInstance = await this.Api.WorkflowInstances.CreateAsync(workflowInstance, cancellationToken).ConfigureAwait(false);
}
var watchEvents = await this.Api.WorkflowInstances.MonitorAsync(workflowInstance.GetName(), workflowInstance.GetNamespace()!, cancellationToken).ConfigureAwait(false);
await foreach(var watchEvent in watchEvents)
await foreach(var watchEvent in this.Api.WorkflowInstances.MonitorAsync(workflowInstance.GetName(), workflowInstance.GetNamespace()!, cancellationToken))
{
switch (watchEvent.Resource.Status?.Phase)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ public virtual async Task<CorrelationContext> CorrelateAsync(ITaskExecutionConte
}
var taskCompletionSource = new TaskCompletionSource<CorrelationContext>();
using var cancellationTokenRegistration = cancellationToken.Register(() => taskCompletionSource.TrySetCanceled());
using var subscription = (await this.Api.WorkflowInstances.MonitorAsync(this.Instance.GetName(), this.Instance.GetNamespace()!, cancellationToken))
using var subscription = this.Api.WorkflowInstances.MonitorAsync(this.Instance.GetName(), this.Instance.GetNamespace()!, cancellationToken)
.ToObservable()
.Where(e => e.Type == ResourceWatchEventType.Updated)
.Select(e => e.Resource.Status?.Correlation?.Contexts)
Expand Down
15 changes: 11 additions & 4 deletions tests/Synapse.UnitTests/Services/MockClusterResourceApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using Neuroglia.Data.Infrastructure.ResourceOriented;
using Neuroglia.Data.Infrastructure.ResourceOriented.Services;
using Synapse.Api.Client.Services;
using System.Runtime.CompilerServices;

namespace Synapse.UnitTests.Services;

Expand All @@ -26,10 +27,16 @@ internal class MockClusterResourceApiClient<TResource>(IResourceRepository resou
public Task<TResource> CreateAsync(TResource resource, CancellationToken cancellationToken = default) => resources.AddAsync(resource, false, cancellationToken);

public Task<IAsyncEnumerable<TResource>> ListAsync(IEnumerable<LabelSelector>? labelSelectors = null, CancellationToken cancellationToken = default) => Task.FromResult(resources.GetAllAsync<TResource>(null, labelSelectors, cancellationToken)!);

public async Task<IAsyncEnumerable<IResourceWatchEvent<TResource>>> WatchAsync(IEnumerable<LabelSelector>? labelSelectors = null, CancellationToken cancellationToken = default) => (await resources.WatchAsync<TResource>(null!, labelSelectors, cancellationToken).ConfigureAwait(false)).ToAsyncEnumerable();

public async Task<IAsyncEnumerable<IResourceWatchEvent<TResource>>> MonitorAsync(string name, CancellationToken cancellationToken = default) => (await resources.MonitorAsync<TResource>(name, null!, false, cancellationToken).ConfigureAwait(false)).ToAsyncEnumerable();

public async IAsyncEnumerable<IResourceWatchEvent<TResource>> WatchAsync(IEnumerable<LabelSelector>? labelSelectors = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var e in (await resources.WatchAsync<TResource>(null!, labelSelectors, cancellationToken).ConfigureAwait(false)).ToAsyncEnumerable()) yield return e;
}

public async IAsyncEnumerable<IResourceWatchEvent<TResource>> MonitorAsync(string name, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach(var e in (await resources.MonitorAsync<TResource>(name, null!, false, cancellationToken).ConfigureAwait(false)).ToAsyncEnumerable()) yield return e;
}

public Task<TResource> GetAsync(string name, CancellationToken cancellationToken = default) => resources.GetAsync<TResource>(name, null, cancellationToken)!;

Expand Down
Loading

0 comments on commit 71d9adb

Please sign in to comment.