diff --git a/SW.Serverless.SampleWeb/Components/Pages/AdapterDashboard.razor b/SW.Serverless.SampleWeb/Components/Pages/AdapterDashboard.razor index d6eb06e..b16fb0a 100644 --- a/SW.Serverless.SampleWeb/Components/Pages/AdapterDashboard.razor +++ b/SW.Serverless.SampleWeb/Components/Pages/AdapterDashboard.razor @@ -19,7 +19,9 @@ Every figure below comes from one of two independent sources. Host-observed (memory, CPU, threads, restarts) needs no cooperation from the adapter, so it still works when one is wedged. Adapter-reported (state, in-flight, provider detail) arrives on - the heartbeat. + the heartbeat. A pooled instance also shows idle for once nothing has rented + it — past its IdleTimeoutSeconds, it simply drops off this list on the next + sweep; see the Carrier page for a live example.

@if (Bootstrapper.LastError is not null) @@ -97,6 +99,10 @@
In flight@h.InFlight
Last heartbeat@Ago(h.LastHeartbeatOn)
Last message@Ago(h.LastMessageOn)
+ @if (h.IdleSince is not null) + { +
Idle for@Format(DateTimeOffset.UtcNow - h.IdleSince.Value)
+ } @if (h.LastError is not null) diff --git a/SW.Serverless.SampleWeb/Components/Pages/CarrierWork.razor b/SW.Serverless.SampleWeb/Components/Pages/CarrierWork.razor index 3701729..ea42e9d 100644 --- a/SW.Serverless.SampleWeb/Components/Pages/CarrierWork.razor +++ b/SW.Serverless.SampleWeb/Components/Pages/CarrierWork.razor @@ -44,6 +44,20 @@ var result = await lease

+
+

Idle eviction

+

+ A warm pool trades memory for speed — but only while it is actually being used. This + page's lease carries ["IdleTimeoutSeconds"] = "15", overriding the host's + 20-second default (set once in Program.cs) for sample.carrier + specifically. Create a shipment or two, then stop clicking: on the + Adapters page, watch sample.carrier's idle instance(s) sit at + "idle for Ns" and disappear once 15 seconds pass — the pool's supervisor sweep + retired it and the warm set shrank back down. The next Create shipment after that + pays a fresh process spawn again, same as the very first call ever made. +

+
+

Create a shipment

@@ -138,7 +152,7 @@ var result = await lease { AdapterId = DemoBootstrapper.CarrierId, StartupValues = CarrierSettings, - AdapterValues = { ["Poolable"] = "true", ["PoolSize"] = "3" } + AdapterValues = { ["Poolable"] = "true", ["PoolSize"] = "3", ["IdleTimeoutSeconds"] = "15" } }); var result = await lease.InvokeAsync("CreateShipment", request, timeoutSeconds: 30); @@ -181,7 +195,7 @@ var result = await lease { AdapterId = DemoBootstrapper.CarrierId, StartupValues = CarrierSettings, - AdapterValues = { ["Poolable"] = "true", ["PoolSize"] = "3" } + AdapterValues = { ["Poolable"] = "true", ["PoolSize"] = "3", ["IdleTimeoutSeconds"] = "15" } }); trackResult = await lease.InvokeAsync("Track", diff --git a/SW.Serverless.SampleWeb/Program.cs b/SW.Serverless.SampleWeb/Program.cs index bfbb793..f1c98fd 100644 --- a/SW.Serverless.SampleWeb/Program.cs +++ b/SW.Serverless.SampleWeb/Program.cs @@ -47,6 +47,12 @@ o.MaxInFlight = 8; o.SoftMemoryLimitBytes = 512L * 1024 * 1024; o.CrashLoopThreshold = 4; + + // Demo-sized so the effect is visible on the Adapters page without waiting minutes: a + // pooled carrier instance nobody rents for 20s is retired by the next supervisor sweep, and + // the warm set shrinks back down. CarrierWork.razor overrides this per-adapter to 15s to show + // the same "IdleTimeoutSeconds" AdapterValues knob a real deployment would use. + o.IdleTimeout = TimeSpan.FromSeconds(20); }); // ---------------------------------------------------------------- observability diff --git a/SW.Serverless.UnitTests/CarrierAdapterTests.cs b/SW.Serverless.UnitTests/CarrierAdapterTests.cs index 23dc207..4c0334f 100644 --- a/SW.Serverless.UnitTests/CarrierAdapterTests.cs +++ b/SW.Serverless.UnitTests/CarrierAdapterTests.cs @@ -31,6 +31,7 @@ namespace SW.Serverless.UnitTests public class CarrierAdapterTests { const string AdapterId = "test.carrier"; + const string IdleAdapterId = "test.carrier.idle-evict"; static WebApplication carrier; static IHost host; @@ -90,6 +91,18 @@ await TestStore.PublishAsync(host.Services.GetRequiredService(), + IdleAdapterId, "SW.Serverless.Samples.Carrier", + new Dictionary + { + ["Protocol"] = "2", ["Lifecycle"] = "resident", + ["Poolable"] = "true", ["PoolSize"] = "2" + }); } [ClassCleanup] @@ -301,6 +314,43 @@ public async Task Leases_reuse_a_warm_process_rather_than_spawning_one_per_call( $"four sequential leases used {pids.Count} processes; a pool of 2 should reuse them"); } + /// + /// A pool with an idle timeout must shrink back down once nothing is renting from it, + /// instead of holding its peak size — and its warm processes — forever. + /// + [TestMethod] + public async Task Idle_pooled_instances_are_evicted_after_the_configured_timeout() + { + var spec = new AdapterSpec + { + AdapterId = IdleAdapterId, + StartupValues = + { + ["BaseUrl"] = baseUrl, + ["Account"] = "TEST-ACCT", + ["ApiKey"] = "not-a-real-secret", + ["MaxAttempts"] = "3", + ["RetryDelayMs"] = "50", + ["TimeoutSeconds"] = "10" + }, + AdapterValues = { ["Poolable"] = "true", ["PoolSize"] = "2", ["IdleTimeoutSeconds"] = "1" } + }; + + int pid; + await using (var lease = await adapters.RentAsync(spec)) + { + await lease.InvokeAsync("TestConnection"); + pid = lease.Instance.Process.Id; + } + + // The 1s idle timeout plus at least one full supervisor sweep (HeartbeatInterval = 3s + // for this host, see ClassInitialize), with slack for scheduling jitter. + await Task.Delay(TimeSpan.FromSeconds(6)); + + Assert.IsFalse(adapters.Describe().Any(h => h.AdapterId == IdleAdapterId && h.ProcessId == pid), + "the idle instance should have been retired by the eviction sweep"); + } + // ------------------------------------------------------------------ upstream class FakeCarrier : Carrier.CarrierBase diff --git a/SW.Serverless.UnitTests/IdleEvictionTests.cs b/SW.Serverless.UnitTests/IdleEvictionTests.cs new file mode 100644 index 0000000..d2b6b4e --- /dev/null +++ b/SW.Serverless.UnitTests/IdleEvictionTests.cs @@ -0,0 +1,295 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.CloudFiles.Extensions; +using SW.PrimitiveTypes; +using SW.Serverless.Resident; +using SW.Serverless.UnitTests.Fixtures; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace SW.Serverless.UnitTests +{ + /// + /// Edge cases for pool idle-eviction (, + /// ) that CarrierAdapterTests' single "basic eviction + /// happens" case does not cover: the host-level default, the 0-means-"use the default" + /// override rule, cancelling an eviction by renting again, exclusive instances being immune, + /// and partial eviction when a pool holds more than one idle instance. + /// + /// The carrier sample fails its upstream ping softly (state goes "Disconnected", it does not + /// throw — see CarrierHandler.StartAsync), so none of this needs the gRPC fake CarrierAdapterTests + /// runs; a plain BaseUrl that nothing is listening on is enough to reach Ready. + /// + [TestClass] + public class IdleEvictionTests + { + const string AdapterId = "test.idle-eviction"; + + static IHost host; + static IResidentAdapterHost adapters; + + [ClassInitialize] + public static async Task ClassInitialize(TestContext context) + { + host = Host.CreateDefaultBuilder() + .ConfigureLogging(l => l.ClearProviders()) + .ConfigureServices(s => + { + s.AddLocalTestsCloudFiles(o => o.BucketName = TestStore.BucketName + "-idle"); + s.AddServerless(o => + { + o.AdapterRemotePath = "adapters"; + o.AdapterLocalPath = Path.Combine(Path.GetTempPath(), "swsl-idletests", "installed"); + o.AdapterMetadataCacheDuration = 1; + }); + s.AddSingleton(); + s.AddSingleton(sp => sp.GetRequiredService()); + s.AddResidentAdapters(o => + { + o.SocketPath = $"/tmp/swsl-ie{Environment.ProcessId}.sock"; + o.PipeName = $"swsl-ie{Environment.ProcessId}"; + o.HandshakeTimeout = TimeSpan.FromSeconds(30); + // Fast enough that every test below finishes in single-digit seconds; the + // sweep runs on this same cadence (ResidentAdapterHost.SuperviseAsync). + o.HeartbeatInterval = TimeSpan.FromSeconds(1); + // The host-level default under test in several cases below. Each test uses + // its own AdapterId, so a per-adapter override never leaks between them — + // AdapterPool captures whatever AdapterValues came in on the FIRST RentAsync + // for an adapter id, and every later caller for that id shares that pool. + o.IdleTimeout = TimeSpan.FromSeconds(1); + }); + }) + .Build(); + + await host.StartAsync(); + adapters = host.Services.GetRequiredService(); + + // One adapter id per test, all backed by the same package, all pointed at a BaseUrl + // nothing answers — CarrierHandler.StartAsync swallows that and still reaches Ready. + var cloudFiles = host.Services.GetRequiredService(); + foreach (var id in new[] + { + AdapterId + ".global-default", + AdapterId + ".zero-override", + AdapterId + ".reuse", + AdapterId + ".exclusive", + AdapterId + ".partial", + AdapterId + ".disabled", + }) + { + await TestStore.PublishAsync(cloudFiles, id, "SW.Serverless.Samples.Carrier", + new Dictionary { ["Protocol"] = "2", ["Lifecycle"] = "resident" }); + } + } + + [ClassCleanup] + public static async Task ClassCleanup() + { + if (host != null) { await host.StopAsync(); host.Dispose(); } + try { Directory.Delete(Path.Combine(Path.GetTempPath(), "swsl-idletests"), true); } catch { } + } + + static AdapterSpec Spec(string idSuffix, IDictionary adapterValues = null) => new() + { + AdapterId = AdapterId + idSuffix, + StartupValues = { ["BaseUrl"] = "http://localhost:1", ["TimeoutSeconds"] = "1" }, + AdapterValues = adapterValues ?? new Dictionary { ["Poolable"] = "true" } + }; + + // ------------------------------------------------------------------ + + /// + /// An adapter that never mentions IdleTimeoutSeconds still gets evicted once it outlives + /// the host's own — the per-adapter override is + /// optional, not required to opt into the feature at all. + /// + [TestMethod] + public async Task An_adapter_with_no_override_uses_the_hosts_default_idle_timeout() + { + int pid; + await using (var lease = await adapters.RentAsync(Spec(".global-default"))) + pid = lease.Instance.Process.Id; + + // 1s default + at least one 1s sweep, with slack for scheduling jitter. + await Task.Delay(TimeSpan.FromSeconds(3)); + + Assert.IsFalse(adapters.Describe().Any(h => h.ProcessId == pid), + "the host's default idle timeout should have evicted this instance even without a per-adapter override"); + } + + /// + /// AdapterPool.IdleTimeoutFor only honours a POSITIVE override ("seconds > 0"); an explicit + /// "0" is not a way to disable eviction per-adapter, it just falls through to whatever the + /// host default is. If this ever changed to mean "disabled", it would be a silent + /// footgun — an adapter author writing "0" meaning "off" would instead inherit the host's + /// timeout, however short. + /// + [TestMethod] + public async Task An_explicit_zero_override_falls_back_to_the_hosts_default_instead_of_disabling_eviction() + { + var spec = Spec(".zero-override", + new Dictionary { ["Poolable"] = "true", ["IdleTimeoutSeconds"] = "0" }); + + int pid; + await using (var lease = await adapters.RentAsync(spec)) + pid = lease.Instance.Process.Id; + + await Task.Delay(TimeSpan.FromSeconds(3)); + + Assert.IsFalse(adapters.Describe().Any(h => h.ProcessId == pid), + "\"IdleTimeoutSeconds\": \"0\" must fall back to the host default (non-zero here), not disable eviction"); + } + + /// + /// Checking an instance back out clears IdleSince (AdapterPool.RentAsync), so a caller that + /// keeps renting before the timeout elapses keeps its warm process indefinitely — the + /// eviction clock only starts counting from the MOST RECENT check-in. + /// + [TestMethod] + public async Task Renting_again_before_the_timeout_elapses_keeps_the_instance_warm() + { + var spec = Spec(".reuse", + new Dictionary { ["Poolable"] = "true", ["IdleTimeoutSeconds"] = "2" }); + + int firstPid; + await using (var lease = await adapters.RentAsync(spec)) + firstPid = lease.Instance.Process.Id; + + // Well under the 2s timeout, and past at least one sweep, so this proves the sweep ran + // and chose not to evict rather than merely not having had a chance to yet. + await Task.Delay(TimeSpan.FromSeconds(1)); + + int secondPid; + await using (var lease = await adapters.RentAsync(spec)) + secondPid = lease.Instance.Process.Id; + + Assert.AreEqual(firstPid, secondPid, "renting again before the timeout should reuse the warm process"); + + // Now let it actually sit idle past the timeout, from THIS check-in. + await Task.Delay(TimeSpan.FromSeconds(3)); + + Assert.IsFalse(adapters.Describe().Any(h => h.ProcessId == firstPid), + "once nothing rents it again, the instance should still be evicted on its own schedule"); + } + + /// + /// EvictIdleAsync only ever walks the host's pools — exclusive instances (broker + /// connections, StartExclusiveAsync) are never in a pool's idle bag, so a host-wide idle + /// timeout must never retire one, no matter how long it sits unused. + /// + [TestMethod] + public async Task Exclusive_instances_are_never_evicted_by_the_idle_sweep() + { + var instance = await adapters.StartExclusiveAsync(Spec(".exclusive")); + var pid = instance.Process.Id; + + await Task.Delay(TimeSpan.FromSeconds(3)); + + Assert.IsTrue(adapters.Describe().Any(h => h.ProcessId == pid && h.State == InstanceState.Ready), + "an exclusive instance must survive the same idle window that would evict a pooled one"); + + await adapters.StopAsync(AdapterId + ".exclusive", "default", drain: false); + } + + /// + /// A pool can hold several idle instances at once, checked in at different times. Eviction + /// must retire only the ones actually past the timeout on a given sweep, not the whole + /// idle set — otherwise a busy pool would thrash down to zero the moment ANY one instance + /// goes stale. + /// + [TestMethod] + public async Task Only_the_instance_past_its_own_timeout_is_retired_not_the_whole_pool() + { + var spec = Spec(".partial", + new Dictionary { ["Poolable"] = "true", ["PoolSize"] = "2", ["IdleTimeoutSeconds"] = "3" }); + + // Two concurrent rents against an empty pool spawn two separate instances. + var leaseA = await adapters.RentAsync(spec); + var leaseB = await adapters.RentAsync(spec); + var pidA = leaseA.Instance.Process.Id; + var pidB = leaseB.Instance.Process.Id; + Assert.AreNotEqual(pidA, pidB, "two concurrent rents on an empty pool must not share a process"); + + await leaseA.DisposeAsync(); // A's idle clock starts now (t=0) + await Task.Delay(TimeSpan.FromSeconds(2)); + await leaseB.DisposeAsync(); // B's idle clock starts two seconds later (t=2) + + // At t≈4: A is 4s idle (past its 3s timeout) and evicted; B is 2s idle (still under it). + await Task.Delay(TimeSpan.FromSeconds(2)); + + var midway = adapters.Describe(); + Assert.IsFalse(midway.Any(h => h.ProcessId == pidA), "A should already be past its timeout and retired"); + Assert.IsTrue(midway.Any(h => h.ProcessId == pidB && h.State == InstanceState.Ready), + "B checked in two seconds after A, so it should still be within its own timeout"); + + // At t≈6: B is now past its 3s timeout too. + await Task.Delay(TimeSpan.FromSeconds(2)); + + Assert.IsFalse(adapters.Describe().Any(h => h.ProcessId == pidB), + "B should eventually be retired on its own schedule once it, too, sits idle long enough"); + } + + /// + /// A per-adapter override of 0 falls back to the host default (see the "zero override" + /// test above) — so to prove eviction can genuinely be switched off, the HOST itself must + /// be configured with IdleTimeout = TimeSpan.Zero. That is a different host from the one + /// this class shares (whose default is 1s), built here with its own short-lived scope. + /// + [TestMethod] + public async Task Idle_timeout_of_zero_on_the_host_disables_eviction_entirely() + { + using var disabledHost = Host.CreateDefaultBuilder() + .ConfigureLogging(l => l.ClearProviders()) + .ConfigureServices(s => + { + s.AddLocalTestsCloudFiles(o => o.BucketName = TestStore.BucketName + "-idle-disabled"); + s.AddServerless(o => + { + o.AdapterRemotePath = "adapters"; + o.AdapterLocalPath = Path.Combine(Path.GetTempPath(), "swsl-idletests-disabled", "installed"); + o.AdapterMetadataCacheDuration = 1; + }); + s.AddSingleton(); + s.AddSingleton(sp => sp.GetRequiredService()); + s.AddResidentAdapters(o => + { + o.SocketPath = $"/tmp/swsl-ied{Environment.ProcessId}.sock"; + o.PipeName = $"swsl-ied{Environment.ProcessId}"; + o.HandshakeTimeout = TimeSpan.FromSeconds(30); + o.HeartbeatInterval = TimeSpan.FromSeconds(1); + // The default in production too (ResidentOptions.IdleTimeout = TimeSpan.Zero): + // eviction is opt-in, so a host that never configures it must never evict. + }); + }) + .Build(); + + await disabledHost.StartAsync(); + try + { + var disabledAdapters = disabledHost.Services.GetRequiredService(); + var cloudFiles = disabledHost.Services.GetRequiredService(); + await TestStore.PublishAsync(cloudFiles, AdapterId + ".disabled", "SW.Serverless.Samples.Carrier", + new Dictionary { ["Protocol"] = "2", ["Lifecycle"] = "resident" }); + + int pid; + await using (var lease = await disabledAdapters.RentAsync(Spec(".disabled"))) + pid = lease.Instance.Process.Id; + + // Several sweeps' worth of idling — with the default disabled, none of them should act. + await Task.Delay(TimeSpan.FromSeconds(4)); + + Assert.IsTrue(disabledAdapters.Describe().Any(h => h.ProcessId == pid && h.State == InstanceState.Ready), + "IdleTimeout = TimeSpan.Zero (the default) must never evict, however long an instance sits idle"); + } + finally + { + await disabledHost.StopAsync(); + } + } + } +} diff --git a/SW.Serverless/Resident/AdapterPool.cs b/SW.Serverless/Resident/AdapterPool.cs index abd5e43..42eb8ef 100644 --- a/SW.Serverless/Resident/AdapterPool.cs +++ b/SW.Serverless/Resident/AdapterPool.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -46,6 +47,14 @@ static int MaxInstances(AdapterSpec spec) => ? Math.Min(n, MaxPoolSize) : 4; + /// Per-adapter override for , same shape as PoolSize. + static TimeSpan IdleTimeoutFor(AdapterSpec spec, ResidentOptions options) => + spec.AdapterValues != null && + spec.AdapterValues.TryGetValue("IdleTimeoutSeconds", out var raw) && + double.TryParse(raw, out var seconds) && seconds > 0 + ? TimeSpan.FromSeconds(seconds) + : options.IdleTimeout; + public async Task RentAsync(CancellationToken cancellationToken) { await slots.WaitAsync(cancellationToken); @@ -71,6 +80,7 @@ public async Task RentAsync(CancellationToken cancellationToken) catch (Exception ex) { logger.LogWarning(ex, "Could not retire pooled slot {Slot}.", retiring); } } + instance.IdleSince = null; return new Lease(this, instance); } catch @@ -91,6 +101,7 @@ async Task ReturnAsync(ResidentAdapterInstance instance, string sessionId) // and the next lease could see the previous session's state — the exact leak // this boundary exists to prevent. await instance.ResetAsync(sessionId); + instance.IdleSince = DateTimeOffset.UtcNow; idle.Add(instance); } } @@ -112,6 +123,51 @@ async Task ReturnAsync(ResidentAdapterInstance instance, string sessionId) } } + /// + /// Retires warm instances that have sat checked-in longer than the idle timeout, so a + /// quiet pool shrinks back down instead of holding its peak size forever. Called + /// periodically by the host's supervisor loop (design doc 14.5's "idle eviction"). A no-op + /// when no idle timeout is configured for this adapter. + /// + public async Task EvictIdleAsync() + { + var idleTimeout = IdleTimeoutFor(spec, options); + if (idleTimeout <= TimeSpan.Zero) return; + + var now = DateTimeOffset.UtcNow; + var keep = new List(); + var stale = new List<(string Slot, ResidentAdapterInstance Instance)>(); + + // Drain-then-rebuild rather than inspecting in place: ConcurrentBag has no way to + // remove a specific item, only to pop an arbitrary one. A RentAsync racing this sweep + // may briefly see fewer idle instances than exist and spawn one it did not strictly + // need to — self-correcting on the next return, and far cheaper than a lock around the + // whole bag. + while (idle.TryTake(out var instance)) + { + var slot = instance.IdleSince.HasValue && now - instance.IdleSince.Value >= idleTimeout + ? all.FirstOrDefault(kv => ReferenceEquals(kv.Value, instance)).Key + : null; + + if (slot != null) stale.Add((slot, instance)); + else keep.Add(instance); + } + + foreach (var instance in keep) idle.Add(instance); + + foreach (var (slot, instance) in stale) + { + if (!all.TryRemove(slot, out _)) continue; + + logger.LogInformation( + "Retiring idle pooled instance {AdapterId}/{Slot}: idle for {Idle}, timeout is {Timeout}.", + spec.AdapterId, slot, now - instance.IdleSince.Value, idleTimeout); + + try { await host.RetireAsync(spec.AdapterId, slot); } + catch (Exception ex) { logger.LogWarning(ex, "Could not retire idle pooled slot {Slot}.", slot); } + } + } + public async ValueTask DisposeAsync() { foreach (var kv in all) diff --git a/SW.Serverless/Resident/InstanceHealth.cs b/SW.Serverless/Resident/InstanceHealth.cs index 09155fd..5900d51 100644 --- a/SW.Serverless/Resident/InstanceHealth.cs +++ b/SW.Serverless/Resident/InstanceHealth.cs @@ -27,6 +27,14 @@ public class InstanceHealth public bool DrainRequested { get; set; } public DateTimeOffset? LastHeartbeatOn { get; set; } + /// + /// When a pooled instance was last checked back in. Null while checked out, for an + /// exclusive instance (never pooled), or right after spawning. Once set, it ages until + /// the pool's idle-eviction sweep retires the instance — see + /// . + /// + public DateTimeOffset? IdleSince { get; set; } + // Adapter-reported public bool Connected { get; set; } public string ReportedState { get; set; } diff --git a/SW.Serverless/Resident/ResidentAdapterHost.cs b/SW.Serverless/Resident/ResidentAdapterHost.cs index e52c4af..0e47dda 100644 --- a/SW.Serverless/Resident/ResidentAdapterHost.cs +++ b/SW.Serverless/Resident/ResidentAdapterHost.cs @@ -451,6 +451,7 @@ static InstanceHealth Describe(Supervised supervised) Quarantined = supervised.Quarantined, DrainRequested = supervised.DrainRequested, LastHeartbeatOn = supervised.LastHeartbeatOn, + IdleSince = instance.IdleSince, Capabilities = instance.Capabilities, Commands = instance.Commands, CommandDetails = instance.CommandDetails, @@ -493,6 +494,7 @@ async Task SuperviseAsync(CancellationToken ct) // every adapter behind it in the loop, so a whole node could look healthy because // the first instance was hanging. await Task.WhenAll(instances.Values.ToArray().Select(HeartbeatAsync)); + await Task.WhenAll(pools.Values.ToArray().Select(p => p.EvictIdleAsync())); } } diff --git a/SW.Serverless/Resident/ResidentAdapterInstance.cs b/SW.Serverless/Resident/ResidentAdapterInstance.cs index c2e3ec1..371c62d 100644 --- a/SW.Serverless/Resident/ResidentAdapterInstance.cs +++ b/SW.Serverless/Resident/ResidentAdapterInstance.cs @@ -67,6 +67,14 @@ internal ResidentAdapterInstance(string adapterId, string instanceKey, string to public Pong LastStatus { get; private set; } public IReadOnlyDictionary StartupValues { get; internal set; } + /// + /// When this pooled instance was last checked back in. Null while checked out, while + /// exclusive (never pooled), or freshly spawned. Set by ; the + /// idle-eviction sweep in reads it to age out warm + /// instances nobody has rented in a while. + /// + public DateTimeOffset? IdleSince { get; internal set; } + /// /// What the adapter said it can do, from its Hello frame: "resident", "resettable", and /// "command:{Name}" for every command it discovered on its handler. A UI can build itself diff --git a/SW.Serverless/Resident/ResidentOptions.cs b/SW.Serverless/Resident/ResidentOptions.cs index 69d8af6..97e4876 100644 --- a/SW.Serverless/Resident/ResidentOptions.cs +++ b/SW.Serverless/Resident/ResidentOptions.cs @@ -46,5 +46,15 @@ public class ResidentOptions /// Workstation GC by default: server GC costs a heap and a thread per core. public bool UseWorkstationGc { get; set; } = true; + + /// + /// How long a pooled resident instance may sit checked-in and unused before the pool + /// retires it, letting the warm set shrink back down. Zero (the default) disables idle + /// eviction — a pool trading memory for a guaranteed-warm next call is often exactly what + /// pooling is for. Applies only to pooled ("Poolable") adapters (); + /// exclusive instances (broker connections, etc.) are never evicted for idling. An adapter + /// can override this via the "IdleTimeoutSeconds" adapter-metadata value. + /// + public TimeSpan IdleTimeout { get; set; } = TimeSpan.Zero; } }