Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
Every figure below comes from one of two independent sources. <strong>Host-observed</strong>
(memory, CPU, threads, restarts) needs no cooperation from the adapter, so it still works when
one is wedged. <strong>Adapter-reported</strong> (state, in-flight, provider detail) arrives on
the heartbeat.
the heartbeat. A pooled instance also shows <strong>idle for</strong> once nothing has rented
it — past its <code>IdleTimeoutSeconds</code>, it simply drops off this list on the next
sweep; see <a href="/carrier">the Carrier page</a> for a live example.
</p>

@if (Bootstrapper.LastError is not null)
Expand Down Expand Up @@ -97,6 +99,10 @@
<div><span class="k">In flight</span><span class="v">@h.InFlight</span></div>
<div><span class="k">Last heartbeat</span><span class="v">@Ago(h.LastHeartbeatOn)</span></div>
<div><span class="k">Last message</span><span class="v">@Ago(h.LastMessageOn)</span></div>
@if (h.IdleSince is not null)
{
<div><span class="k">Idle for</span><span class="v warn">@Format(DateTimeOffset.UtcNow - h.IdleSince.Value)</span></div>
}
</div>

@if (h.LastError is not null)
Expand Down
18 changes: 16 additions & 2 deletions SW.Serverless.SampleWeb/Components/Pages/CarrierWork.razor
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ var result = await lease
</p>
</section>

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

<section class="card">
<h2>Create a shipment</h2>
<div class="actions">
Expand Down Expand Up @@ -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<string>("CreateShipment", request, timeoutSeconds: 30);
Expand Down Expand Up @@ -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<string>("Track",
Expand Down
6 changes: 6 additions & 0 deletions SW.Serverless.SampleWeb/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions SW.Serverless.UnitTests/CarrierAdapterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -90,6 +91,18 @@ await TestStore.PublishAsync(host.Services.GetRequiredService<ICloudFilesService
["Protocol"] = "2", ["Lifecycle"] = "resident",
["Poolable"] = "true", ["PoolSize"] = "2"
});

// A distinct adapter id, never touched by any other test's RentAsync, so its pool is
// guaranteed to be created with the idle-eviction settings below — AdapterPool captures
// whatever AdapterValues came in on the FIRST RentAsync for an adapter id and every
// later caller shares that same pool.
await TestStore.PublishAsync(host.Services.GetRequiredService<ICloudFilesService>(),
IdleAdapterId, "SW.Serverless.Samples.Carrier",
new Dictionary<string, string>
{
["Protocol"] = "2", ["Lifecycle"] = "resident",
["Poolable"] = "true", ["PoolSize"] = "2"
});
}

[ClassCleanup]
Expand Down Expand Up @@ -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");
}

/// <summary>
/// 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.
/// </summary>
[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<JObject>("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
Expand Down
Loading
Loading