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();