diff --git a/SW.Bitween.Api/Resources/Adapters/AdapterListing.cs b/SW.Bitween.Api/Resources/Adapters/AdapterListing.cs new file mode 100644 index 00000000..1d71cd08 --- /dev/null +++ b/SW.Bitween.Api/Resources/Adapters/AdapterListing.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using SW.Bitween.Domain; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Adapters; + +/// One adapter of a kind, and the versions of it that are published. +/// The adapter id, as a subscription stores it. +/// In-process, so it has no published versions of its own. +/// +/// Published version files, as paths relative to the remote adapter root — the trailing segment is +/// the version number. +/// +public record AdapterEntry(string Key, bool Native, IReadOnlyList VersionPaths); + +/// +/// The list of adapters of one kind: the in-process ones plus whatever is published to storage. +/// +/// +/// Shared by , which answers with the shape the older UI reads, and +/// , which answers with the same list plus each adapter's startup properties. +/// The grouping of version files under their adapter is fiddly enough that a second copy of it +/// would be a second thing to get wrong. +/// +public class AdapterListing( + ServerlessOptions serverlessOptions, + ICloudFilesService cloudFilesService, + NativeAdapterDiscoveryService nativeAdapterDiscovery, + BitweenDbContext dbContext) +{ + /// The plural, lowercase kind: receivers, handlers, … + /// Native adapters first, then the published ones. + public async Task> List(string prefix) + { + var index = serverlessOptions.AdapterRemotePath.Length + 1; + + var native = (await nativeAdapterDiscovery.GetNativeAdapters(prefix).ExceptRetiring(dbContext)) + .Select(key => new AdapterEntry(key, true, [])) + .ToList(); + + var files = (await cloudFilesService.ListAsync($"{serverlessOptions.AdapterRemotePath}/infolink6.{prefix}")) + .Where(item => item.Size > 0) + .ToList(); + + var published = files + .GroupBy(i => + { + var lastSection = i.Key.Split("/").Last(); + var isSemver = Semver.IsVersionNumber(lastSection); + return isSemver ? i.Key.Split("/").ElementAt(^2) : lastSection; + }) + .Select(g => new AdapterEntry( + g.Key, + false, + g.Where(v => v.Key != g.Key && Semver.IsVersionNumber(v.Key.Split("/").Last())) + .Select(v => v.Key[index..]) + .ToList())); + + return native.Concat(published).ToList(); + } +} diff --git a/SW.Bitween.Api/Resources/Adapters/Catalog.cs b/SW.Bitween.Api/Resources/Adapters/Catalog.cs new file mode 100644 index 00000000..7f7d4b9d --- /dev/null +++ b/SW.Bitween.Api/Resources/Adapters/Catalog.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using SW.Bitween.Domain; +using SW.Bitween.Model; +using SW.PrimitiveTypes; + +namespace SW.Bitween.Resources.Adapters; + +/// +/// Every adapter of one kind, each with the startup properties it expects. +/// +/// +/// +/// answers with the adapters alone, which left a caller that needs to +/// draw a form per adapter to ask once per row. On a screen listing +/// all four kinds that is around ninety requests, six of which a browser will run at a time, so the +/// last of them waits behind fifteen rounds of queueing — and every one of those requests carries +/// its own permission check. Answering the whole kind at once makes it four requests for the screen. +/// +/// +/// is deliberately left as it is: the older UI reads it, does not need +/// the properties, and should not start paying for them. +/// +/// +[HandlerName("Catalog")] +public class Catalog( + AdapterListing listing, + AdapterStartupValues startupValues, + BitweenDbContext dbContext, + RequestContext requestContext) : IQueryHandler +{ + public async Task Handle(AdapterSearchRequest request) + { + await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.View); + + var adapters = await listing.List(request.Prefix); + + // Asked for all at once and left unthrottled on purpose. The native ones answer by + // reflection and should not be made to queue, and how many published adapters may be + // running at a time is capped in ServerlessAdapterDescriber — process-wide, which is the + // only place it can be, since this handler knows nothing of the other requests in flight. + var described = await Task.WhenAll(adapters.Select(async a => (a.Key, Values: await Describe(a.Key)))); + var byKey = described.ToDictionary(d => d.Key, d => d.Values); + + return adapters.Select(a => new + { + a.Key, + a.Native, + // Just the version numbers. VersionPaths carries each one as a path, which is what the + // older shape passed through and what made a version read as an object rather than + // "1.2.3" to anything trying to label it. + Versions = a.VersionPaths.Select(v => v.Split('/').Last()).ToList(), + StartupValues = byKey[a.Key] + }); + } + + private async Task> Describe(string adapterId) + { + try + { + return await startupValues.Describe(adapterId); + } + catch (Exception) + { + // One adapter that cannot be described — its runtime is missing locally, say — must not + // blank out the rest of the catalogue, including the native ones that resolved + // perfectly well. It comes back with no properties, as it did when the caller was + // asking row by row and swallowing the failure itself. + return new Dictionary(); + } + } +} diff --git a/SW.Bitween.Api/Resources/Adapters/GetProperties.cs b/SW.Bitween.Api/Resources/Adapters/GetProperties.cs index 52fa523e..94bdb9b2 100644 --- a/SW.Bitween.Api/Resources/Adapters/GetProperties.cs +++ b/SW.Bitween.Api/Resources/Adapters/GetProperties.cs @@ -11,15 +11,15 @@ namespace SW.Bitween.Resources.Adapters [HandlerName("properties")] public class GetProperties : IGetHandler { - private readonly IServerlessService serverless; + private readonly AdapterStartupValues startupValues; private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; private readonly BitweenDbContext dbContext; private readonly RequestContext requestContext; - public GetProperties(IServerlessService serverless, NativeAdapterDiscoveryService nativeAdapterDiscovery, + public GetProperties(AdapterStartupValues startupValues, NativeAdapterDiscoveryService nativeAdapterDiscovery, BitweenDbContext dbContext, RequestContext requestContext) { - this.serverless = serverless; + this.startupValues = startupValues; _nativeAdapterDiscovery = nativeAdapterDiscovery; this.dbContext = dbContext; this.requestContext = requestContext; @@ -37,21 +37,9 @@ async public Task Handle(string key) return _nativeAdapterDiscovery.GetExpectedStartupValues(decodedKey); } - // Handle serverless adapters - try - { - await serverless.StartAsync(decodedKey, null); - } - catch (KeyNotFoundException ex) - { - throw new BitweenException( - $"Adapter '{decodedKey}' metadata is incomplete or the adapter package is not installed. " + - $"Missing metadata key: {ex.Message}", ex); - } - - var expected = await serverless.GetExpectedStartupValues(); - if (expected == null) - return new Dictionary(); + // Handle serverless adapters. The native branch above returns a different shape — + // each key's default rather than a "key (default)" label — so it is left as it was. + var expected = await startupValues.Describe(decodedKey); return expected .ToList() diff --git a/SW.Bitween.Api/Resources/Adapters/GetStartupValues.cs b/SW.Bitween.Api/Resources/Adapters/GetStartupValues.cs index 09642da8..44cb1673 100644 --- a/SW.Bitween.Api/Resources/Adapters/GetStartupValues.cs +++ b/SW.Bitween.Api/Resources/Adapters/GetStartupValues.cs @@ -1,9 +1,6 @@ using SW.Bitween.Domain; using SW.PrimitiveTypes; -using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; namespace SW.Bitween.Resources.Adapters @@ -11,54 +8,23 @@ namespace SW.Bitween.Resources.Adapters [HandlerName(nameof(GetStartupValues))] public class GetStartupValues : IGetHandler> { - private readonly IServerlessService serverless; - private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; + private readonly AdapterStartupValues startupValues; private readonly BitweenDbContext dbContext; private readonly RequestContext requestContext; - public GetStartupValues(IServerlessService serverless, NativeAdapterDiscoveryService nativeAdapterDiscovery, + public GetStartupValues(AdapterStartupValues startupValues, BitweenDbContext dbContext, RequestContext requestContext) { - this.serverless = serverless; - _nativeAdapterDiscovery = nativeAdapterDiscovery; + this.startupValues = startupValues; this.dbContext = dbContext; this.requestContext = requestContext; } - - public async Task> Handle(string key) { await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.View); - var decodedKey = Uri.UnescapeDataString(key); - - IDictionary startupValues = new Dictionary(); - - // Check if it's a native adapter - if (decodedKey.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) - { - startupValues= _nativeAdapterDiscovery.GetStartupValues(decodedKey); - } - else - { - // Handle serverless adapters - try - { - await serverless.StartAsync(decodedKey, null); - } - catch (KeyNotFoundException ex) - { - throw new BitweenException( - $"Adapter '{decodedKey}' metadata is incomplete or the adapter package is not installed. " + - $"Missing metadata key: {ex.Message}", ex); - } - - startupValues = await serverless.GetExpectedStartupValues(); - } - - return startupValues ?? new Dictionary(); + return await startupValues.Describe(System.Uri.UnescapeDataString(key)); } } - } diff --git a/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs b/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs index a7b373a8..ebf583a1 100644 --- a/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs +++ b/SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs @@ -1,78 +1,40 @@ -using System; using System.Collections.Generic; -using SW.PrimitiveTypes; -using System.Threading.Tasks; using System.Linq; +using System.Threading.Tasks; +using SW.Bitween.Domain; +using SW.PrimitiveTypes; using SW.Bitween.Model; namespace SW.Bitween.Resources.Adapters { [HandlerName("Versioned")] - public class SearchVersioned : IQueryHandler + public class SearchVersioned : IQueryHandler { - private readonly ServerlessOptions _serverlessOptions; - private readonly ICloudFilesService _cloudFilesService; - private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; + private readonly AdapterListing _listing; private readonly BitweenDbContext _dbContext; private readonly RequestContext _requestContext; - public SearchVersioned(ServerlessOptions serverlessOptions, ICloudFilesService cloudFilesService, - NativeAdapterDiscoveryService nativeAdapterDiscovery, BitweenDbContext dbContext, + public SearchVersioned(AdapterListing listing, BitweenDbContext dbContext, RequestContext requestContext) { - _serverlessOptions = serverlessOptions; - _cloudFilesService = cloudFilesService; - _nativeAdapterDiscovery = nativeAdapterDiscovery; + _listing = listing; _dbContext = dbContext; _requestContext = requestContext; } - public async Task Handle(AdapterSearchRequest request) { await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.View); - var index = _serverlessOptions.AdapterRemotePath.Length + 1; + var adapters = await _listing.List(request.Prefix); - // Get native adapters first (they don't have versions) - var nativeAdapters = (await _nativeAdapterDiscovery.GetNativeAdapters(request.Prefix) - .ExceptRetiring(_dbContext)) - .Select(key => new - { - Key = key, - Versions = new List() // Native adapters have no versions - }) - .ToList(); - - // Get external adapters from storage - var cloudFilesList = - (await _cloudFilesService.ListAsync( - $"{_serverlessOptions.AdapterRemotePath}/infolink6.{request.Prefix}")) - .Where(item => item.Size > 0) - .ToList(); - - var grouped = cloudFilesList - .GroupBy(i => - { - var lastSection = i.Key.Split("/").Last(); - var isSemver = Semver.IsVersionNumber(lastSection); - var key = isSemver ? i.Key.Split("/").ElementAt(^2) : lastSection; - - return key; - }); - - var externalAdapters = grouped.Select(i => new + // Versions as a list of objects with a Key, which is the shape this endpoint has + // always answered with. Catalog is where the tidier shape lives. + return adapters.Select(a => new { - i.Key, - Versions = i.Where(v => v.Key != i.Key && Semver.IsVersionNumber(v.Key.Split("/").Last())) - .Select(v => new - { - Key = v.Key[index..] - }).ToList() + a.Key, + Versions = a.VersionPaths.Select(v => (object)new { Key = v }).ToList() }); - - // Return native adapters first, then external - return nativeAdapters.Concat(externalAdapters); } } -} \ No newline at end of file +} diff --git a/SW.Bitween.Api/Resources/Subscriptions/Get.cs b/SW.Bitween.Api/Resources/Subscriptions/Get.cs index 71de4794..24847542 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/Get.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/Get.cs @@ -5,9 +5,7 @@ using System.Linq; using System.Threading.Tasks; using SW.Bitween.Model; -using System; using System.Collections.Generic; -using Microsoft.Extensions.DependencyInjection; namespace SW.Bitween.Resources.Subscriptions { @@ -15,17 +13,15 @@ public class Get : IGetHandler { private readonly BitweenDbContext dbContext; private readonly RequestContext requestContext; - private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery; - private readonly IServiceProvider _serviceProvider; + private readonly AdapterStartupValues _startupValues; private const string PrivateSentinel = "__private__"; - public Get(BitweenDbContext dbContext, NativeAdapterDiscoveryService nativeAdapterDiscovery, IServiceProvider serviceProvider, RequestContext requestContext) + public Get(BitweenDbContext dbContext, AdapterStartupValues startupValues, RequestContext requestContext) { this.dbContext = dbContext; this.requestContext = requestContext; - _nativeAdapterDiscovery = nativeAdapterDiscovery; - _serviceProvider = serviceProvider; + _startupValues = startupValues; } public async Task Handle(int key) @@ -96,16 +92,7 @@ private async Task> MaskPrivateProps(string adapterId, try { - if (adapterId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) - { - startupValues = _nativeAdapterDiscovery.GetStartupValues(adapterId); - } - else - { - var serverless = _serviceProvider.GetRequiredService(); - await serverless.StartAsync(adapterId, null); - startupValues = await serverless.GetExpectedStartupValues(); - } + startupValues = await _startupValues.Describe(adapterId); } catch { diff --git a/SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs b/SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs index 4143203c..88cc615c 100644 --- a/SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs +++ b/SW.Bitween.Api/Resources/Subscriptions/SaveMapper.cs @@ -1,10 +1,8 @@ using FluentValidation; -using Microsoft.Extensions.DependencyInjection; using SW.EfCoreExtensions; using SW.Bitween.Domain; using SW.Bitween.Model; using SW.PrimitiveTypes; -using System; using System.Linq; using System.Threading.Tasks; @@ -45,7 +43,7 @@ public async Task Handle(int key, SubscriptionSaveMapper model) private class Validate : AbstractValidator { - public Validate(NativeAdapterDiscoveryService nativeAdapterDiscovery, IServiceProvider serviceProvider) + public Validate(AdapterRequirements adapterRequirements) { RuleFor(i => i.MapperId).NotEmpty(); @@ -54,23 +52,8 @@ public Validate(NativeAdapterDiscoveryService nativeAdapterDiscovery, IServicePr RuleFor(i => i.MapperProperties).CustomAsync(async (i, context, _) => { var mapperId = ((SubscriptionSaveMapper)context.InstanceToValidate).MapperId; - var mustProps = Enumerable.Empty(); - if (mapperId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) - { - var properties = nativeAdapterDiscovery.GetStartupValues(mapperId); - mustProps = properties.Where(p => !p.Value.Optional).Select(p => p.Key); - } - else - { - var serverless = serviceProvider.GetRequiredService(); - await serverless.StartAsync(mapperId, null); - mustProps = (await serverless.GetExpectedStartupValues()) - .Where(p => p.Value.Optional == false).Select(p => p.Key); - } - - var missing = mustProps.ToHashSet(StringComparer.OrdinalIgnoreCase) - .Except(i.Where(p => !string.IsNullOrEmpty(p.Value)).Select(p => p.Key)); + var missing = await adapterRequirements.MissingFor(mapperId, i); if (missing.Any()) context.AddFailure($"Missing: {string.Join(",", missing)}"); }); diff --git a/SW.Bitween.Api/Services/AdapterRequirements.cs b/SW.Bitween.Api/Services/AdapterRequirements.cs index 327ddfbe..5a4aa60f 100644 --- a/SW.Bitween.Api/Services/AdapterRequirements.cs +++ b/SW.Bitween.Api/Services/AdapterRequirements.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; using SW.PrimitiveTypes; namespace SW.Bitween; @@ -10,15 +9,13 @@ namespace SW.Bitween; /// /// Which of an adapter's required startup properties a caller failed to supply. /// -/// Asking this question means knowing whether the adapter runs in-process or in a serverless -/// container, and the answer was written out four times across the subscription validators -/// before this existed — three in Update alone. Create needs the same answer, and six copies -/// of it would be six places for the rule to drift. +/// The answer was written out four times across the subscription validators before this +/// existed — three in Update alone. Create needs the same answer, and six copies of it would +/// be six places for the rule to drift. Whether the adapter runs in-process or in a serverless +/// container is 's problem, not this one's. /// /// -public class AdapterRequirements( - NativeAdapterDiscoveryService nativeAdapterDiscovery, - IServiceProvider serviceProvider) +public class AdapterRequirements(AdapterStartupValues startupValues) { /// Native (native: prefix) or serverless. Null/blank means nothing is missing. /// What the caller supplied. Blank values count as not supplied. @@ -26,21 +23,8 @@ public async Task> MissingFor(string adapterId, ICol { if (string.IsNullOrEmpty(adapterId)) return Array.Empty(); - IEnumerable required; - if (adapterId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) - { - required = nativeAdapterDiscovery.GetStartupValues(adapterId) - .Where(p => !p.Value.Optional).Select(p => p.Key); - } - else - { - // Resolved late: starting a serverless adapter is expensive, and most validations - // never reach this branch. - var serverless = serviceProvider.GetRequiredService(); - await serverless.StartAsync(adapterId, null); - required = (await serverless.GetExpectedStartupValues()) - .Where(p => p.Value.Optional == false).Select(p => p.Key); - } + var required = (await startupValues.Describe(adapterId)) + .Where(p => !p.Value.Optional).Select(p => p.Key); return required .ToHashSet(StringComparer.OrdinalIgnoreCase) diff --git a/SW.Bitween.Api/Services/AdapterSecretProperties.cs b/SW.Bitween.Api/Services/AdapterSecretProperties.cs index 331f3bde..3b789f43 100644 --- a/SW.Bitween.Api/Services/AdapterSecretProperties.cs +++ b/SW.Bitween.Api/Services/AdapterSecretProperties.cs @@ -1,8 +1,6 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; using SW.Bitween.Model; using SW.PrimitiveTypes; @@ -24,17 +22,11 @@ namespace SW.Bitween; /// Subscriptions/Get and Subscriptions/Update; this is the reusable form of it. /// /// -public class AdapterSecretProperties( - NativeAdapterDiscoveryService nativeAdapterDiscovery, - IServiceProvider serviceProvider) +public class AdapterSecretProperties(AdapterStartupValues startupValues) { /// Stands in for a secret value in any response that carries adapter properties. public const string Sentinel = "__private__"; - // Describing a serverless adapter means starting it and asking, which is far too expensive to - // repeat per row of a report. Scoped service, so the memo lives exactly as long as one request. - private readonly Dictionary> _described = new(); - /// /// Returns a copy with every secret value replaced. Values that are already empty are left /// alone, so "not set" stays distinguishable from "set but hidden". @@ -50,10 +42,10 @@ public async Task> Mask( if (string.IsNullOrEmpty(adapterId)) return properties.ToDictionary(kv => kv.Key, kv => kv.Value); - IDictionary startupValues; + IDictionary described; try { - startupValues = await Describe(adapterId); + described = await startupValues.Describe(adapterId); } catch { @@ -63,7 +55,7 @@ public async Task> Mask( } return properties.ToDictionary(kv => kv.Key, kv => - startupValues.TryGetValue(kv.Key, out var startupValue) + described.TryGetValue(kv.Key, out var startupValue) && startupValue.Private && !string.IsNullOrEmpty(kv.Value) ? Sentinel @@ -123,23 +115,4 @@ public static void MergeInPlace( foreach (var kv in merged) incoming[kv.Key] = kv.Value; } - private async Task> Describe(string adapterId) - { - if (_described.TryGetValue(adapterId, out var cached)) return cached; - - IDictionary startupValues; - if (adapterId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) - { - startupValues = nativeAdapterDiscovery.GetStartupValues(adapterId); - } - else - { - var serverless = serviceProvider.GetRequiredService(); - await serverless.StartAsync(adapterId, null); - startupValues = await serverless.GetExpectedStartupValues(); - } - - _described[adapterId] = startupValues; - return startupValues; - } } diff --git a/SW.Bitween.Api/Services/AdapterStartupValues.cs b/SW.Bitween.Api/Services/AdapterStartupValues.cs new file mode 100644 index 00000000..80dc961e --- /dev/null +++ b/SW.Bitween.Api/Services/AdapterStartupValues.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using SW.PrimitiveTypes; + +namespace SW.Bitween; + +/// +/// What startup properties an adapter expects — its key names, which are optional, which are +/// secret, and their defaults. +/// +/// +/// +/// Answering this means knowing whether the adapter runs in-process or is published to storage and +/// run in a child process, and that fork was written out six times across the adapter and +/// subscription resources before this existed. +/// +/// +/// It is a schema, not data: nothing a user does in the UI can change it, because a subscription's +/// actual property values live in the database and are never part of this. It changes only +/// when a new adapter package is uploaded. is what makes +/// use of that. +/// +/// +public class AdapterStartupValues( + NativeAdapterDiscoveryService nativeAdapterDiscovery, + ServerlessAdapterDescriber serverlessDescriber) +{ + /// Drops what is remembered about a published adapter. + public void Forget(string adapterId) => serverlessDescriber.Forget(adapterId); + + /// Native (native prefix) or published. + /// Key name to description. Empty when the adapter reports nothing. + public async Task> Describe(string adapterId) + { + if (string.IsNullOrWhiteSpace(adapterId)) + return new Dictionary(); + + // Reflection over an in-process type, so there is nothing here worth caching, and nothing + // worth queueing behind the published adapters either. + if (adapterId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase)) + return nativeAdapterDiscovery.GetStartupValues(adapterId); + + return await serverlessDescriber.Describe(adapterId); + } +} diff --git a/SW.Bitween.Api/Services/ServerlessAdapterDescriber.cs b/SW.Bitween.Api/Services/ServerlessAdapterDescriber.cs new file mode 100644 index 00000000..ad938f91 --- /dev/null +++ b/SW.Bitween.Api/Services/ServerlessAdapterDescriber.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using SW.PrimitiveTypes; + +namespace SW.Bitween; + +/// +/// Asks a published adapter what startup properties it expects, and remembers the answer. +/// +/// +/// +/// Asking is expensive out of all proportion to the answer: the package is downloaded and unzipped, +/// a dotnet child process is started, and the question goes over stdio — to be told a handful +/// of key names. So the answer is cached, one ask is shared by everyone who wants it at that moment, +/// and only so many adapters may be running at once. +/// +/// +/// A singleton, and it has to be. All three of those only work between requests: a per-request copy +/// would coalesce nothing, limit nothing, and remember nothing past the request that filled it. +/// +/// +public class ServerlessAdapterDescriber( + IServiceScopeFactory scopeFactory, + IMemoryCache memoryCache, + ServerlessOptions serverlessOptions) +{ + private const string CacheKeyPrefix = "bitween.adapters.startupvalues"; + + /// + /// How many adapters may be running at once, across the whole process. + /// + /// Each one is a child process, and the callers are not coordinated — a screen loading four + /// kinds of adapter is four requests, and there can be a request per user on top of that. Six + /// is what one browser was already allowed per host before any of this was batched, so the + /// server holds no more open at a time than it used to. + /// + /// + private const int MaxConcurrentStarts = 6; + + private readonly SemaphoreSlim _starts = new(MaxConcurrentStarts, MaxConcurrentStarts); + + private readonly ConcurrentDictionary>>> _asking = + new(StringComparer.OrdinalIgnoreCase); + + /// Drops what is remembered about an adapter, so the next ask runs it again. + public void Forget(string adapterId) => memoryCache.Remove(CacheKey(adapterId)); + + public async Task> Describe(string adapterId) + { + if (memoryCache.TryGetValue(CacheKey(adapterId), out IDictionary cached)) + return cached; + + // Everyone who wants this adapter while it is being asked waits on the one ask, rather than + // starting an identical child process of their own. Without this the cache does not help + // the case it most needs to — a cold start, when every request arrives at once. + var asking = _asking.GetOrAdd(adapterId, id => + new Lazy>>(() => Ask(id), + LazyThreadSafetyMode.ExecutionAndPublication)); + + try + { + return await asking.Value; + } + finally + { + // Removed whether it worked or not. A failure left here would be what every later + // request awaits, forever; a success is in the cache by now, so the next request never + // needed this entry anyway. Matched on the instance so that a newer ask, started by + // someone else after this one finished, is not the one taken away. + _asking.TryRemove(new KeyValuePair>>>( + adapterId, asking)); + } + } + + private async Task> Ask(string adapterId) + { + await _starts.WaitAsync(); + try + { + // The queue may have been long enough for someone else to have answered this already. + if (memoryCache.TryGetValue(CacheKey(adapterId), out IDictionary cached)) + return cached; + + // Its own scope, disposed the moment this returns, because disposing the serverless + // service is what quits the child process. On the request's scope instead, a caller + // describing a whole catalogue would hold every adapter it started open until the + // request ended, rather than one at a time. + await using var scope = scopeFactory.CreateAsyncScope(); + var serverless = scope.ServiceProvider.GetRequiredService(); + + try + { + await serverless.StartAsync(adapterId, null); + } + catch (KeyNotFoundException ex) + { + throw new BitweenException( + $"Adapter '{adapterId}' metadata is incomplete or the adapter package is not installed. " + + $"Missing metadata key: {ex.Message}", ex); + } + + var startupValues = await serverless.GetExpectedStartupValues() + ?? new Dictionary(); + + // Read-only because one instance is now handed to every request that asks. A caller + // that edited it in place would be editing what the next request is told the adapter + // expects, and this way that attempt throws instead. + IDictionary shared = + new ReadOnlyDictionary(startupValues); + + // Only a successful answer is cached — a boot that failed because, say, the adapter's + // runtime is missing locally must not stick around as "this adapter has no properties". + // + // Held for as long as ServerlessService already serves a stale Hash from its own + // metadata cache. Within that window it boots the previous build of a re-uploaded + // adapter regardless, so this adds no staleness that re-uploading did not already have. + return memoryCache.Set(CacheKey(adapterId), shared, + TimeSpan.FromMinutes(serverlessOptions.AdapterMetadataCacheDuration)); + } + finally + { + _starts.Release(); + } + } + + private static string CacheKey(string adapterId) => + $"{CacheKeyPrefix}.{adapterId.ToLowerInvariant()}"; +} diff --git a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs index 21d08392..132dfa5a 100644 --- a/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs +++ b/SW.Bitween.IntegrationTests/Fixtures/BitweenFixture.cs @@ -166,6 +166,9 @@ public async Task InitializeAsync() services.AddSingleton(); services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/SW.Bitween.IntegrationTests/Tests/AdapterCatalogTests.cs b/SW.Bitween.IntegrationTests/Tests/AdapterCatalogTests.cs new file mode 100644 index 00000000..8064a3b0 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/AdapterCatalogTests.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// Describing an adapter — which startup properties it expects — and listing a whole kind of them +/// at once. +/// +/// +/// The published adapter here is the one the fixture uploads to local storage, so these are the +/// only tests that go through the expensive half: downloading the package and running it in a child +/// process to ask what it wants. The native adapters answer by reflection and cost nothing. +/// +[Collection("Bitween")] +public class AdapterCatalogTests +{ + private const string PublishedAdapter = "sw.bitween.sampleconfigurableadapter"; + + private readonly BitweenFixture _fixture; + + public AdapterCatalogTests(BitweenFixture fixture) + { + _fixture = fixture; + } + + private async Task> Describe(string adapterId) + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + return await scope.ServiceProvider + .GetRequiredService() + .Describe(adapterId); + } + + private async Task Forget(string adapterId) + { + await using var scope = _fixture.CreateScope(); + scope.ServiceProvider.GetRequiredService().Forget(adapterId); + } + + [Fact] + public async Task A_published_adapter_reports_the_values_it_expects() + { + var described = await Describe(PublishedAdapter); + + Assert.Equal( + new[] { "DelayMs", "ErrorMessage", "OutputData", "SimulateError" }, + described.Keys.OrderBy(k => k).ToArray()); + } + + /// + /// The same instance comes back on the second ask, which is only possible from the cache — + /// running the adapter again would have built a new dictionary. + /// + /// + /// Asked from two separate scopes, because a request gets its own scope and caching that only + /// lasted the length of one would leave the catalogue exactly as slow as it was. + /// + [Fact] + public async Task Describing_a_published_adapter_twice_only_runs_it_once() + { + // From a known-cold cache, so the first ask is the one that runs the adapter however the + // other tests in this collection happened to be ordered. + await Forget(PublishedAdapter); + + var first = await Describe(PublishedAdapter); + var second = await Describe(PublishedAdapter); + + Assert.Same(first, second); + } + + /// + /// Several requests arriving together on a cold cache still only run the adapter once. + /// + /// + /// They all come back with the same instance, which is only possible if one of them did the + /// work and the rest waited for it — each separate run of the adapter builds its own dictionary. + /// + [Fact] + public async Task Describing_the_same_adapter_from_several_requests_at_once_runs_it_once() + { + await Forget(PublishedAdapter); + + var asks = Enumerable.Range(0, 8).Select(_ => Task.Run(() => Describe(PublishedAdapter))); + var results = await Task.WhenAll(asks); + + Assert.All(results, r => Assert.Same(results[0], r)); + } + + /// + /// One instance is shared by every request that asks, so editing it would change what the next + /// request is told the adapter expects. + /// + [Fact] + public async Task A_cached_description_cannot_be_edited() + { + var described = await Describe(PublishedAdapter); + + Assert.Throws(() => described.Remove("DelayMs")); + } + + /// + /// The catalogue answers for a whole kind in one call, properties included — the point of it + /// being that a caller drawing a form per adapter no longer asks once per adapter. + /// + /// + /// Only the native handlers are asserted. The fixture's published adapters are uploaded under + /// their own key rather than under the infolink6.handlers prefix the listing reads, so + /// they are not part of any kind's list — + /// is what covers the published path. + /// + [Fact] + public async Task The_catalog_lists_a_kind_with_every_adapters_properties() + { + await using var scope = _fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities + .CreateInstance(scope.ServiceProvider); + + var result = await handler.Handle(new AdapterSearchRequest { Prefix = "handlers" }); + + // The handler returns anonymous types; going through JSON reads them the way the browser + // does rather than through reflection. + var rows = JArray.Parse(JsonConvert.SerializeObject(result)); + + Assert.NotEmpty(rows); + Assert.All(rows, row => Assert.NotNull(row["StartupValues"])); + + // The properties arrive with the list rather than needing a request each, which is the + // whole reason this endpoint exists. + var smtp = rows.Single(r => r["Key"]!.ToString() == "NativeSmtpHandler"); + Assert.True(smtp["Native"]!.Value()); + Assert.NotEmpty(smtp["StartupValues"]!.Children()); + } +} diff --git a/SW.Bitween.Web/ClientApp/src/api/http/adapters.ts b/SW.Bitween.Web/ClientApp/src/api/http/adapters.ts index c6938a49..867e7e16 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/adapters.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/adapters.ts @@ -2,16 +2,18 @@ import type { ApiClient } from "../client"; import type { AdapterInfo, AdapterKind, AdapterProp } from "../types"; import { get } from "./request"; -interface RawVersionedAdapter { - key: string; - versions: string[] | null; -} interface RawStartupValue { optional: boolean; default: string | null; private: boolean; description: string | null; } +interface RawCatalogAdapter { + key: string; + native: boolean; + versions: string[] | null; + startupValues: Record | null; +} // The backend's Prefix param takes the plural, lowercase form. const KIND_PREFIX: Record = { @@ -21,8 +23,7 @@ const KIND_PREFIX: Record = { validator: "validators", }; -async function fetchProps(id: string): Promise { - const values = await get>(`/adapters/${encodeURIComponent(id)}/GetStartupValues`); +function toProps(values: Record | null): AdapterProp[] { return Object.entries(values ?? {}).map(([key, v]) => ({ key, optional: v.optional, @@ -34,20 +35,19 @@ async function fetchProps(id: string): Promise { export const adapterMethods = { async listAdapters(kind: AdapterKind): Promise { - const rows = await get(`/adapters/Versioned?prefix=${KIND_PREFIX[kind]}`); - return Promise.all( - (rows ?? []).map(async (r) => ({ - id: r.key, - kind, - // No backend source for a friendly display name — fall back to the raw id. - label: r.key, - native: r.key.toLowerCase().startsWith("native"), - versions: r.versions ?? [], - // Legacy (non-native) adapters can fail to report startup values (e.g. their - // serverless runtime isn't available locally) — don't let that blank out the - // whole catalog, including the native adapters that did resolve fine. - props: await fetchProps(r.key).catch(() => []), - })), - ); + // One request for the whole kind, properties included. Asking `Versioned` for the adapters and + // then `GetStartupValues` per adapter was around ninety requests for the four kinds a + // subscription screen loads, and each of those booted the adapter in a child process to be + // told its property names. + const rows = await get(`/adapters/Catalog?prefix=${KIND_PREFIX[kind]}`); + return (rows ?? []).map((r) => ({ + id: r.key, + kind, + // No backend source for a friendly display name — fall back to the raw id. + label: r.key, + native: r.native, + versions: r.versions ?? [], + props: toProps(r.startupValues), + })); }, } satisfies Partial; diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index bb871f21..03a0d66a 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -76,6 +76,9 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped();