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 @@
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;
}
}