From c29ae56a83209b120afe24ca42da0263b230c8b8 Mon Sep 17 00:00:00 2001 From: Muhannad Al-Khatib Date: Sun, 18 Oct 2026 16:20:04 +0300 Subject: [PATCH] Keep a pinned version's resolution for the life of the process A published version never changes, yet GetMetadataAsync re-read it from storage every AdapterMetadataCacheDuration, like a current package that can move. A host whose adapters are all pinned (Traxis Gateway since 2026-10-18) made a storage round trip per adapter every five minutes for nothing. Only plain ids now expire. --- .../PinnedMetadataCacheTests.cs | 57 +++++++++++++++++++ SW.Serverless/Services/AdapterInstaller.cs | 7 +++ docs/hosting.md | 2 +- 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 SW.Serverless.Installer.UnitTests/PinnedMetadataCacheTests.cs diff --git a/SW.Serverless.Installer.UnitTests/PinnedMetadataCacheTests.cs b/SW.Serverless.Installer.UnitTests/PinnedMetadataCacheTests.cs new file mode 100644 index 0000000..ea076b7 --- /dev/null +++ b/SW.Serverless.Installer.UnitTests/PinnedMetadataCacheTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Internal; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.PrimitiveTypes; +using SW.Serverless.Contract.Catalog; + +namespace SW.Serverless.Installer.UnitTests; + +[TestClass] +public class PinnedMetadataCacheTests +{ +#pragma warning disable CS0618 // MemoryCacheOptions.Clock is how a test moves the cache's time + sealed class TestClock : ISystemClock + { + public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow; + } +#pragma warning restore CS0618 + + static Task Put(ObjectStore store, string key, string content) => + store.WriteAsync(new MemoryStream(Encoding.UTF8.GetBytes(content)), new WriteFileSettings + { + Key = key, + Metadata = new Dictionary { ["EntryAssembly"] = "Adapter.dll" } + }); + + [TestMethod] + public async Task A_pinned_version_is_resolved_once_and_a_current_package_again_after_the_cache_duration() + { + using var store = new ObjectStore(); + var clock = new TestClock(); +#pragma warning disable CS0618 + var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock }); +#pragma warning restore CS0618 + var options = new ServerlessOptions { AdapterRemotePath = "adapters", AdapterMetadataCacheDuration = 5 }; + var installer = new AdapterInstaller(options, cache, store); + var versionKey = AdapterCatalogPaths.Version("adapters", "acme.x", "1.0.0"); + + await Put(store, "adapters/acme.x", "current 1"); + await Put(store, versionKey, "version 1"); + var current = await installer.GetMetadataAsync("acme.x"); + var pinned = await installer.GetMetadataAsync("acme.x/1.0.0"); + + // A new current package; and, to make the cache observable, the version rewritten too + // (a published version never is). + await Put(store, "adapters/acme.x", "current 2"); + await Put(store, versionKey, "version 1, rewritten"); + clock.UtcNow += TimeSpan.FromMinutes(6); + + Assert.AreNotEqual(current.Hash, (await installer.GetMetadataAsync("acme.x")).Hash, "the current package is read again"); + Assert.AreEqual(pinned.Hash, (await installer.GetMetadataAsync("acme.x/1.0.0")).Hash, "the pinned version is not"); + } +} diff --git a/SW.Serverless/Services/AdapterInstaller.cs b/SW.Serverless/Services/AdapterInstaller.cs index b8d19f0..e5d2ff0 100644 --- a/SW.Serverless/Services/AdapterInstaller.cs +++ b/SW.Serverless/Services/AdapterInstaller.cs @@ -386,6 +386,13 @@ public async Task GetMetadataAsync(string adapterId) ?? throw new InvalidOperationException( $"Adapter '{adapterId}' metadata at '{remotePath}' has an 'EntryAssembly' outside its package."); + // A pinned version never changes once published, so its resolution is kept for the life + // of the process. Only a plain id's current package can move, and it is read again after + // AdapterMetadataCacheDuration. A host whose agents are all pinned then makes no storage + // round trip per adapter every few minutes. + if (AdapterCatalogPaths.Split(adapterId).Version != null) + return memoryCache.Set($"{NamingPrefix}.{adapterId}", installed); + return memoryCache.Set($"{NamingPrefix}.{adapterId}", installed, TimeSpan.FromMinutes(options.AdapterMetadataCacheDuration)); } diff --git a/docs/hosting.md b/docs/hosting.md index ddc8457..a48421f 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -100,7 +100,7 @@ Rules to know: |---|---|---| | `AdapterRemotePath` | `"adapters"` | The root folder in storage. Must match the root adapters were published under; the CLI always publishes under `adapters`. | | `AdapterLocalPath` | `"./adapters"` | Where packages are unpacked on this machine, one folder per package. | -| `AdapterMetadataCacheDuration` | `5` | Minutes the host remembers which package an adapter id resolves to. A newly promoted version is picked up once this expires. | +| `AdapterMetadataCacheDuration` | `5` | Minutes the host remembers which package an adapter id resolves to. A newly promoted version is picked up once this expires. A pinned ref (`{id}/{version}`) never changes, so it is resolved once and kept for the life of the process. | | `CommandTimeout` | `30` | Seconds a classic session's command may take, unless the call passes its own. | | `IdleTimeout` | `300` | Seconds a protocol 1 (.NET classic) adapter waits for its next command before it exits. |